Metadata-Version: 2.4
Name: surveyjs
Version: 0.11.0
Summary: Python API for SurveyJS Creator (Form Builder) JSON schema and Form response JSON.
Author-email: Nova Code BV <contact@novaforms.io>
Maintainer: Bob Leers
Maintainer-email: Nova Code BV <contact@novaforms.io>
License: MIT
Project-URL: Homepage, https://www.novaforms.io
Project-URL: Repository, https://github.com/novacode-nl/python-surveyjs
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: File Formats :: JSON
Classifier: Topic :: File Formats :: JSON :: JSON Schema
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Dynamic: license-file

[![Build Status](https://github.com/novacode-nl/python-surveyjs/actions/workflows/unittests.yml/badge.svg?branch=main)](https://github.com/novacode-nl/python-surveyjs/actions/workflows/unittests.yml/badge.svg?branch=main)
[![PyPI version](https://img.shields.io/pypi/v/surveyjs.svg)](https://pypi.org/project/surveyjs/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

# surveyjs (Python)

Python API for SurveyJS Creator (Form Builder) schema and Form response JSON.

For information about the SurveyJS project, see <https://surveyjs.io>

## Introduction

**surveyjs** is a Python package that parses and transforms **SurveyJS Creator (Form Builder) JSON schema** and **Form response JSON** into **usable Python objects**.

Its main aim is to provide easy access to a SurveyJS Form's questions (fields, layout elements, etc.) also captured as **Python objects**, which makes this API very versatile and usable.

**Notes about terms:**
- **SurveyCreator:** The Survey Creator (form builder) that defines the structure and design of a Form, with a schema in JSON format.
- **SurveyForm:** A filled-in or completed survey form, i.e. a form response or submission, with data in JSON format.
- **Question:** Represents a form field, ranging from simple input types (e.g. text, checkbox, rating) to more complex elements (e.g. matrix).
- **Layout:** A structural element such as a page, panel, or dynamic panel that can contain other elements, including questions and nested layouts.
- **Element:** The base concept for both Question and Layout elements in SurveyJS.

**SurveyJS question and layout classes (source code):**\
<https://github.com/surveyjs/survey-library/tree/master/packages/survey-core/src> \
The file prefix `question` indicates a question (field) class.

## Features

- Compatible with Python 3.8 and later.
- Constructor of the **SurveyCreator** and **SurveyForm** class only requires the JSON (string or dict) and an optional language code (e.g. 'en', 'fr', etc.).
- **Translations**: every displayed text (titles, descriptions, choices, boolean labels, error messages, …) is translated, from SurveyJS localizable strings and/or an `i18n` map (see [Translations](#translations)).
- Get a **SurveyForm object's Questions** as usable **Python objects** e.g. date, datetime, boolean, list (for checkbox), dict (for matrix) etc. Every question exposes both `raw_value` (exactly as submitted) and `value` (parsed according to its SurveyJS type and `inputType`).
- **Pages** and **paths**: iterate a survey's pages as objects, and address any element — however deeply nested — by a stable path such as `education[0].details.degree`.
- **Dynamic panels** are materialized per row of submission data, with values populated.
- **Matrix cells are questions**: a column's `cellType` decides a cell's class, so a dropdown cell resolves its own choices and a date cell parses its own value.
- **Validation**: `validation_errors()` on a whole form or a single question, covering the `isRequired` flag and SurveyJS's `validators` array (numeric, text, answercount, regex, email).
- **Value specs**: what each question accepts as its value, as JSON Schema, and a check of values written into a form, e.g. a prefill (see [Value specs](#value-specs)).
- **Extensible**: register an `inputType` parser, a validator, or a question class of your own (see [Extending](#extending)).
- Supports [23 SurveyJS question types](#supported-question-types); additional types will be added over time and contributions via PRs are welcome.
- Open source (MIT License).

## Supported question types

Each type below is implemented by its own class in `surveyjs/elements/` and covered
by its own test module. A type that is not listed still loads: it falls back to the
base `Element`, so its `raw_value`, `title` and position in the tree are available;
only the behaviour specific to that type is missing.

| Category | Types |
|---|---|
| Text input | `text`, `comment`, `multipletext` |
| Choice | `dropdown`, `checkbox`, `radiogroup`, `tagbox`, `buttongroup`, `imagepicker`, `boolean`, `ranking` |
| Scale | `rating`, `slider` |
| Matrix | `matrix`, `matrixdropdown`, `matrixdynamic` |
| Layout | `panel`, `paneldynamic`, `html`, `image` |
| Other | `file`, `signaturepad`, `expression` |

Missing a type you need? [Contributions](CONTRIBUTING.md) are welcome.

## Installation

The source code is currently hosted on GitHub at: <https://github.com/novacode-nl/python-surveyjs>

### PyPI - Python Package Index

Binary installers for the latest released version are available at the [Python Package Index](https://pypi.python.org/pypi/surveyjs).

```sh
pip install surveyjs
```

Releases are built and published by GitHub Actions with [Trusted Publishing](https://docs.pypi.org/trusted-publishers/). Each file on PyPI has a provenance attestation that links it to the commit in this repository it was built from; see *Download files* on the [PyPI page](https://pypi.org/project/surveyjs/).

### Source Install with pip

```sh
git clone git@github.com:novacode-nl/python-surveyjs.git
cd python-surveyjs
pip install -e .
```

### Source Install with Poetry (recommended)

```sh
git clone git@github.com:novacode-nl/python-surveyjs.git
cd python-surveyjs
poetry install
```

## Using direnv

You can use [nixpkgs](https://nixos.org/) to run a self-contained Python environment without any additional setup.\
Once you've installed nixpkgs, switch into the directory and type "nix-shell" to get a shell from which the correct Python with packages is available.

If you're using [direnv](https://direnv.net/), use `direnv allow` after changing into the project directory and you're good to go.\
Also consider [nix-direnv](https://github.com/nix-community/nix-direnv) to speed up the experience (it can re-use a cached local installation).

## Usage Examples

Every snippet below is executed by [`tests/test_readme_examples.py`](tests/test_readme_examples.py)
against the same demo survey, so an example that stopped being true fails the build
rather than misleading you. Change a snippet, change its test.

### Questions and values

```python
from surveyjs import SurveyCreator, SurveyForm

# creator_json is a SurveyJS Creator JSON schema (string or dict)
# form_json is a SurveyJS Form submission JSON (string or dict)

creator = SurveyCreator(creator_json)
form = SurveyForm(form_json, creator)

# Text question
form.questions["firstName"].label
# 'First Name'

form.questions["firstName"].value
# 'Bob'

# Checkbox question
form.questions["colors"].value
# ['red', 'blue']

# Rating question
form.questions["satisfaction"].value
# 4

# Boolean question
form.questions["agree"].value
# True

# Date question (inputType 'date')
form.questions["birthDate"].value
# datetime.date(1985, 6, 14)

# Datetime question (inputType 'datetime-local')
form.questions["appointment"].value
# datetime.datetime(2024, 3, 15, 13, 45)

# Matrix question
form.questions["quality"].value
# {'affordable': 'good', 'does-what-it-claims': 'excellent'}

# Panel element
form.elements["personal_data"]
# <QuestionPanel name=personal_data>
```

### Input types: `value` vs. `raw_value`

A question's `raw_value` is its answer exactly as submitted; `value` is that
answer as a Python object. For a text question, `value` is parsed according to
its SurveyJS `inputType`:

```python
# {"type": "text", "name": "birthDate", "inputType": "date"}

form.questions["birthDate"].value
# datetime.date(1985, 6, 14)

form.questions["birthDate"].raw_value
# '1985-06-14'
```

`date` yields a `date`, `datetime-local` a `datetime`, `time` a `time`,
`month` a `date` (first of the month), `week` a `date` (Monday of the ISO
week), and `number`/`range` an `int`/`float`. Types with no parser (text,
email, url, …) pass through unchanged.

A **boolean** question's `value` is always `True`, `False` or `None`, whatever
its `valueTrue`/`valueFalse`; `value_text` is the matching label:

```python
# {"type": "boolean", "name": "consent", "valueTrue": "agreed", "valueFalse": "declined"}

form.questions["consent"].raw_value
# 'agreed'

form.questions["consent"].value
# True

form.questions["consent"].value_text
# 'Yes'
```

The questions that hold other questions — `multipletext`, `matrixdynamic`,
`matrixdropdown` and `paneldynamic` — return a parsed copy from `value`: a new
dict or list with the same keys and rows as `raw_value`, each value inside
parsed by its own item, column or question (see
[Dynamic panels](#dynamic-panels) and
[Matrix columns and multiple-text items](#matrix-columns-and-multiple-text-items)).
Other questions (dropdown, checkbox, rating, …) submit JSON values that are
already Python objects, so their `value` equals `raw_value`.

Except when a choice question stores its picked items as objects: a choice
with its own comment area (`showCommentArea` on the choice), or a checkbox
with `valuePropertyName`. `value` is then the plain choice values, and
`choice_comments` holds the comments:

```python
# {"type": "checkbox", "name": "reasons",
#  "choices": ["price", {"value": "service", "showCommentArea": true}]}

form.questions["reasons"].raw_value
# [{'value': 'price'}, {'value': 'service', 'comment': 'Too slow'}]

form.questions["reasons"].value
# ['price', 'service']

form.questions["reasons"].choice_comments
# {'service': 'Too slow'}
```

`value` is read-only — assign `raw_value` instead. If a submitted value cannot
be parsed, `value` is `None` while `raw_value` keeps the original, which is how
you tell a malformed submission from an empty one.

To register an `inputType` of your own (or override a built-in):

```python
from datetime import timedelta
from surveyjs import register_input_type

register_input_type("duration", lambda v: timedelta(seconds=int(v)))

form.questions["elapsed"].value
# datetime.timedelta(seconds=90)
```

### Pages

Both **SurveyCreator** and **SurveyForm** have a `pages` property: a list of
`Page` objects in schema order. They mirror each other — same names, same
titles, same order — but hold different element objects. A creator's page
holds the *schema* elements (no submitted values); a form's page holds that
form's elements, with values populated.

A schema with a top-level `elements` array and no `pages` key is represented
by a single implicit page named `page1`, so there is only one code path.

```python
# the same pages on both, in schema order
[page.name for page in creator.pages]
# ['personal', 'history']

[page.name for page in form.pages]
# ['personal', 'history']

form.pages[0].title
# 'Personal'

# look a page up by name, on either class
form.get_page_by_name("history")
# <Page name=history>
```

A page's root elements are an `OrderedDict` of Element objects keyed by name —
the same shape as `creator.elements` / `form.elements`.

```python
form.pages[0].elements["firstName"]
# <QuestionText name=firstName input_type=text>

list(form.pages[0].elements.values())
# [<QuestionText name=firstName input_type=text>,
#  <QuestionText name=birthDate input_type=date>,
#  <QuestionText name=appointment input_type=datetime-local>,
#  <QuestionPanel name=contact>]

list(form.pages[0].elements)  # dict keys, i.e. the element names
# ['firstName', 'birthDate', 'appointment', 'contact']

# .questions is the same, filtered to input questions (no panel/html/image)
list(form.pages[0].questions)
# ['firstName', 'birthDate', 'appointment']
```

Read values from a **form** page — a creator page describes the schema, so its
elements have no submitted value:

```python
form.pages[0].elements["firstName"].value
# 'Bob'

form.pages[0].elements["birthDate"].value
# datetime.date(1985, 6, 14)

creator.pages[0].elements["birthDate"].value
# None      (a schema has no submission data)

# every element knows its page
form.questions["birthDate"].page.name
# 'personal'
```

### Paths

`path` is an element's position in the survey tree; `input_path` is where its
value lives in the submission data. They differ because a `panel` groups
elements visually without nesting their data.

```python
form.all_elements["phone"].path_str
# 'contact.phone'          (inside the 'contact' panel)

form.all_elements["phone"].input_path
# ['phone']                (panels are transparent to the data)

form.get_element_by_path("contact.phone").value
# '+31 6 1234 5678'
```

Nested panels each add a name to `path`, but nothing to `input_path`:

```python
# {"type": "panel", "name": "contact", "elements": [
#     {"type": "text", "name": "phone"},
#     {"type": "panel", "name": "address", "elements": [
#         {"type": "text", "name": "city"}]}]}
city = form.all_elements["city"]

city.path
# ['contact', 'address', 'city']

city.path_str
# 'contact.address.city'

city.input_path
# ['city']                 -> data['city']

form.get_element_by_path("contact.address.city").value
# 'Amsterdam'
```

A dynamic panel does nest its data: each row adds the dynamic panel's name and
the row index (see [Dynamic panels](#dynamic-panels)). A panel inside that row
still adds nothing to the data:

```python
# {"type": "paneldynamic", "name": "education", "templateElements": [
#     {"type": "text", "name": "school"},
#     {"type": "panel", "name": "details", "elements": [
#         {"type": "text", "name": "degree"}]}]}
degree = form.get_element_by_path("education[0].details.degree")

degree.path
# ['education[0]', 'details', 'degree']

degree.input_path
# ['education', 0, 'degree']    -> data['education'][0]['degree']

degree.input_path_str
# 'education[0].degree'

degree.value
# 'MSc'
```

A question with a `valueName` ("Join identifier" in SurveyJS Creator) stores its
value under that key instead of its name. You still look it up by name:

```python
# {"type": "text", "name": "q1", "valueName": "email"}
# submission: {"email": "a@b.c"}

form.questions["q1"].value
# 'a@b.c'

form.questions["q1"].input_path
# ['email']
```

Questions that share a `valueName` share one value.

### Names

SurveyJS Creator requires a name to be unique per data level, so a lookup by
name (`questions['city']`) works for most surveys. Two dynamic panels can still
each hold a question with the same name, as each row nests its data. Looking
such a name up raises `AmbiguousNameError` (a `LookupError`, not a `KeyError`).
Use a path, or `get_elements_by_name()` for all of them, which always returns a
list. A `SurveyForm` keeps the rows out of its name maps altogether.

```python
# {"type": "paneldynamic", "name": "jobs", "templateElements": [
#     {"type": "text", "name": "year"}]},
# {"type": "paneldynamic", "name": "school", "templateElements": [
#     {"type": "text", "name": "year"}]}
creator.questions["year"]
# AmbiguousNameError: 'year' is ambiguous: jobs.year, school.year; use
# get_element_by_path() or get_elements_by_name()

creator.ambiguous_names
# {'year': ['jobs.year', 'school.year']}

[e.path_str for e in creator.get_elements_by_name("year")]
# ['jobs.year', 'school.year']

[e.path_str for e in form.get_elements_by_name("year")]
# ['jobs[0].year', 'jobs[1].year', 'school[0].year']   (one per row)

form.get_element_by_path("jobs[1].year").value
# 2017
```

### Dynamic panels

A `SurveyCreator` holds a dynamic panel's *template*. A `SurveyForm`
materializes one set of child elements per row of submission data, with values
populated.

```python
education = form.questions["education"]

education.panels
# [<PanelInstance name=education[0]>]

education.panels[0]["graduated"].value
# datetime.date(2015, 5, 30)

education.get_panel_value(0, "school")
# 'MIT'

# the whole value: a parsed copy of raw_value
education.value
# [{'school': 'MIT', 'graduated': datetime.date(2015, 5, 30), 'degree': 'MSc'}]

education.raw_value
# [{'school': 'MIT', 'graduated': '2015-05-30', 'degree': 'MSc'}]

# rows are addressable by path
form.get_element_by_path("education[0].graduated").input_path
# ['education', 0, 'graduated']    -> data['education'][0]['graduated']
```

Because each row reuses the template's names, instance children are addressed
by path rather than by name: they are deliberately absent from `questions` and
`all_elements`.

### Matrix columns and multiple-text items

A matrix column with `cellType: "text"` may declare its own `inputType`, and so
may a `multipletext` item. Each parses independently.

```python
# columns: [{"name": "employer"},
#           {"name": "started", "cellType": "text", "inputType": "date"},
#           {"name": "role", "cellType": "dropdown", "choices": [...]}]
jobs = form.questions["jobs"]

jobs.get_column("started").input_type
# 'date'

jobs.get_cell_value(0, "started")
# datetime.date(2020, 1, 6)

jobs.get_cell_raw_value(0, "started")
# '2020-01-06'

jobs.get_row_value(0)
# {'employer': 'Nova Code', 'started': datetime.date(2020, 1, 6), 'role': 'dev'}

# the whole value: every row, each cell parsed — raw_value stays as submitted
jobs.value
# [{'employer': 'Nova Code', 'started': datetime.date(2020, 1, 6), 'role': 'dev'}]

jobs.raw_value
# [{'employer': 'Nova Code', 'started': '2020-01-06', 'role': 'dev'}]

# items: [{"name": "from", "inputType": "date"}]
form.questions["dates"].item_values
# {'from': datetime.date(2024, 7, 8)}

form.questions["dates"].value
# {'from': datetime.date(2024, 7, 8)}

form.questions["dates"].raw_value
# {'from': '2024-07-08'}
```

### Matrix cells as questions

A column's `cellType` *is* a question type, so on a **SurveyForm** every cell of a
submitted row is built as a question of that type. That is what gives a cell the
behaviour of its type: a dropdown cell resolves what its value means. The
matrix's parsed values (`value`, `get_row_value()`, `get_cell_value()`) come
from these cell questions, so a cell reads the same however you reach it.

```python
jobs.get_cell(0, "role")
# <QuestionDropdown name=role>

jobs.get_cell(0, "role").value
# 'dev'

jobs.get_cell(0, "role").value_text  # what a dropdown cell knows and a value does not
# 'Developer'

list(jobs.cells(0).values())  # one row of cells, in column order
# [<QuestionText name=employer input_type=text>,
#  <QuestionText name=started input_type=date>,
#  <QuestionDropdown name=role>]

list(jobs.cells(0))  # dict keys, i.e. the column names
# ['employer', 'started', 'role']

list(jobs.rows_cells)  # every submitted row, by row key
# [0]
```

A **SurveyCreator** materializes no cells: it has no submission, so it has no rows.
The column as the element definition its cells are built from is `cell_raw`:

```python
jobs.get_column("started").cell_raw
# {'name': 'started', 'cellType': 'text', 'inputType': 'date', 'type': 'text'}
```

Like a dynamic panel's row children, cells are registered by **path** and never by
name — a column name repeats in every row:

```python
form.get_element_by_path("jobs[0].role").value_text
# 'Developer'

jobs.get_cell(0, "started").input_path
# ['jobs', 0, 'started']       -> data['jobs'][0]['started']

"role" in form.questions
# False
```

A `matrixdropdown` keys its rows by **name** rather than position, and those names
are addressable the same way:

```python
# rows: [{"value": "marketing", "text": "Marketing"}]
form.questions["budget"].row_names
# ['marketing']

form.get_element_by_path("budget[marketing].status").value_text
# 'Done'

form.get_element_by_path("budget[marketing].status").input_path
# ['budget', 'marketing', 'status']
```

`row_key` is which row of a repeating container an element belongs to — an int
where the data is a list of rows (a dynamic panel, a dynamic matrix), the row's
name for a `matrixdropdown`. `panel_index` is the dynamic-panel spelling of the
same value, and keeps working.

`rows_data` and the `*_raw_value` accessors return the submission as is. Cells cost one
element per cell of a submitted matrix, on the form path only.

### Questions inside a choice

A checkbox or radiogroup choice can hold questions of its own, shown when it is
picked. SurveyJS stores their answers next to the question's own, so they are
its children without nesting the data:

```python
# {"type": "radiogroup", "name": "contact", "choices": ["email",
#   {"value": "phone", "elements": [
#       {"type": "text", "name": "call_after", "inputType": "time"}]}]}
# submission: {"contact": "phone", "call_after": "18:00"}

call_after = form.questions["call_after"]

call_after.value
# datetime.time(18, 0)

call_after.path_str
# 'contact.call_after'

call_after.input_path
# ['call_after']

call_after.choice_value
# 'phone'
```

When their choice is not picked, SurveyJS hides them: `validation_errors()`
skips them, and `in_unpicked_choice` is True.

### Translations

Every text a respondent reads is translated: titles, descriptions,
placeholders, choice texts (and so `value_text`), boolean labels, button
texts, and validation messages. SurveyJS offers two ways to translate, and both
apply, in this order:

1. **Localizable strings** in the schema — a dict keyed by locale. The
   survey's language is picked, else `default`, else the first locale given.
2. An **`i18n` map**, `{language: {source text: translation}}`, passed to the
   `SurveyCreator`. It translates the text from step 1; a text it does not
   list is kept as is.

```python
# {"type": "boolean", "name": "agree",
#  "title": {"default": "I agree", "fr": "J'accepte"}},
# {"type": "dropdown", "name": "color", "choices": ["Red", "Blue"]}
i18n = {"fr": {"Yes": "Oui", "No": "Non", "Red": "Rouge", "Blue": "Bleu"}}

creator = SurveyCreator(creator_json, language="fr", i18n=i18n)
form = SurveyForm(form_json, creator)

form.questions["agree"].title
# "J'accepte"

form.questions["agree"].value_text
# 'Oui'

form.questions["color"].value_text
# 'Rouge'
```

A form uses its creator's `language` unless you pass `lang`, which then
applies to the whole form — pages, questions, and everything nested in panels,
dynamic panels and matrix cells. The creator keeps its own language, so one
creator can serve forms in several languages:

```python
form_en = SurveyForm(form_json, creator, lang="default")

form_en.questions["agree"].title
# 'I agree'

form_en.questions["agree"].value_text
# 'Yes'
```

### Validation

`validation_errors()` reports what a submission gets wrong. On a **SurveyForm** it
collects errors across every input question, keyed by question name; an empty dict
means the whole submission is valid.

```python
# {"type": "text", "name": "email", "title": "Email", "isRequired": true,
#  "validators": [{"type": "email"}]}
# {"type": "text", "name": "age", "title": "Age", "inputType": "number",
#  "validators": [{"type": "numeric", "minValue": 18}]}

form = SurveyForm({"email": "not-an-address", "age": "16"}, creator)

form.validation_errors()
# {'email': {'email': 'Please enter a valid e-mail address.'},
#  'age': {'numeric': "The 'Age' should be at least 18"}}
```

The result is a plain dict, so reading an absent key raises instead of silently
inserting an empty entry. A single question answers for itself, keyed by error
type:

```python
form.questions["age"].validation_errors()
# {'numeric': "The 'Age' should be at least 18"}

form.questions["age"].is_empty()
# False
```

Two kinds of rule are checked, and they never collide because they apply to
opposite states:

- the **`isRequired`** flag, keyed `required`, only when the answer is empty;
- each entry in the **`validators`** array, keyed by its `type`, only when the
  answer is non-empty, matching SurveyJS, which passes validators on empty input.

```python
SurveyForm({"email": ""}, creator).validation_errors()
# {'email': {'required': 'Email is required'}}
```

Supported validator types are `numeric`, `text`, `answercount`, `regex` and
`email`, with SurveyJS's own default messages; a validator's own `text` overrides
the message. An unknown type is skipped rather than guessed at. That includes
`expression`, which needs a SurveyJS expression runtime this library does not
carry, so its rules are ignored rather than mis-evaluated. To add a type, see
[Extending](#extending).

### Value specs

`value` reads submitted data. A **value spec** goes the other way: it says what
value a question *accepts*, for code that writes data into a form, such as a
prefill. `value_spec()` returns a small subset of JSON Schema, so it is plain JSON
that people and language models read alike.

```python
# {"type": "text", "name": "birthDate", "inputType": "date"}
# {"type": "checkbox", "name": "colors", "choices": ["red", "blue"], "maxSelectedChoices": 2}

creator.questions["birthDate"].value_spec()
# {'type': 'string', 'format': 'date', 'examples': ['2024-03-15']}

creator.questions["colors"].value_spec()
# {'type': 'array', 'items': {'enum': ['red', 'blue']}, 'maxItems': 2}
```

`None` means the element holds no value of its own: a panel, an html or image
element, or an expression, whose computed value would overwrite one written into
it. A dropdown with `choicesByUrl` has no `enum`, since its choices load from a
URL; its spec carries the `choicesByUrl` config under `x-choicesByUrl`.

`SurveyCreator.value_spec()` is the spec of the survey's data: an object keyed by
the questions at its top level. Questions in a plain panel are at the top level;
those in a dynamic panel are part of the panel's value.

`check_value()` and `check_values()` report what in a value does not fit, as a
list of messages, each starting with the data path of the offending value:

```python
creator.questions["birthDate"].check_value(date(1985, 6, 14))
# ["birthDate: expected a string in the format YYYY-MM-DD (e.g. '2024-03-15'), got date"]

creator.check_values({"colors": ["red", "pink"], "nmae": "Bob"})
# ["colors[1]: expected one of ['red', 'blue'], got a str that is not one of them",
#  'nmae: no question has this name']
```

None always fits: it leaves a question unanswered. A message describes a wrong
value by its type and never contains the value itself, so it can be passed on
(e.g. to an AI that wrote the value) without disclosing any data.

## Extending

Three registration points let you teach the library something it does not already
know, or override what it does.

### Input types

`register_input_type` adds or replaces the parser behind a question's `inputType`
(see [Input types](#input-types-value-vs-raw_value) above).

### Validators

`register_validator` adds or replaces the function behind a validator `type`. It
receives the question's parsed `value`, the rule dict and the element, and returns
an error message, or `None` when the value passes. Usable as a decorator:

```python
from surveyjs.elements.validators import register_validator


@register_validator("creditcard")
def _creditcard(value, rule, element):
    return None if luhn_ok(value) else "Invalid card number."
```

### Question classes

`element_class_mapping` decides which class a question type is built as — for every
question of that type, including one inside a matrix cell or a dynamic panel row.
Pass a class, or a dotted path to one:

```python
from surveyjs import SurveyCreator
from surveyjs.elements.text import QuestionText


class MyText(QuestionText):
    @property
    def value(self):
        return (super().value or "").strip()


creator = SurveyCreator(creator_json, element_class_mapping={"text": MyText})

# or by dotted path, resolved on load
creator = SurveyCreator(creator_json, element_class_mapping={"text": "myapp.widgets.MyText"})
```

A type with no mapping resolves by convention to `surveyjs.elements.<type>.Question<Type>`.

## Unit Tests

### Run all tests

From toplevel directory:
```
poetry run python -m unittest
```

### Run specific (questions) unittests

All questions, from toplevel directory:

```
poetry run python -m unittest tests/test_question_*.py
```

Nested questions (complexity), from toplevel directory:

```
poetry run python -m unittest tests/test_nested_questions.py
```

### Run specific element/question unittest

```
poetry run python -m unittest tests.test_question_ranking.TestQuestionRanking.test_choices
```

## Contributing

Contributions are welcome — a new question type, a validator, a bug fix. See
[CONTRIBUTING.md](CONTRIBUTING.md) for how to set up, test and submit a change.

Note that the [Usage Examples](#usage-examples) are executed as tests: a change
that alters documented output must update
[`tests/test_readme_examples.py`](tests/test_readme_examples.py) alongside the
README.

## Changelog

Release notes are in [CHANGELOG.md](CHANGELOG.md).

## License

[MIT](LICENSE)

Copyright 2026 Nova Code ([https://www.novaforms.io](https://www.novaforms.io))

---

Developed and maintained by [Nova Code](https://www.novaforms.io).

Official [SurveyJS Partner](https://surveyjs.io/partner-solutions) [<img src="docs/surveyjs-partner-badge.svg" alt="Official SurveyJS Partner" width="120" align="middle">](https://surveyjs.io/partner-solutions)
