diff --git a/.env b/.env new file mode 100644 index 0000000..8053249 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +PYTHON_VERSION=3.13 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..05507c5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + paths: + - '**/*.py' + - '**/*.html' + - '**/*.yaml' + - '**/*.yml' + - requirements*.txt + pull_request: + paths: + - '**/*.py' + - '**/*.html' + - '**/*.yaml' + - '**/*.yml' + - requirements*.txt + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Install dependencies + run: make dev + - name: Run pylint + run: make lint + - name: Run tests + run: make test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7491024 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,43 @@ +name: Publish package + +on: + push: + tags: + - 'v*' + - 'test-v*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Install build tools + run: | + python -m pip install --upgrade pip + python -m pip install build twine + - name: Build package + run: | + python -m build + - name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/v') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} + skip-existing: true + - name: Publish to TestPyPI + if: startsWith(github.ref, 'refs/tags/test-v') + uses: pypa/gh-action-pypi-publish@release/v1 + with: + user: __token__ + password: ${{ secrets.TEST_PYPI_API_TOKEN }} + repository-url: https://test.pypi.org/legacy/ + skip-existing: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b53b489 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Python cache and bytecode +__pycache__/ + +# Virtual environments +.dev-venv/ + +# Test and coverage outputs +.coverage +coverage.xml + +.vscode/ diff --git a/.trunk/.gitignore b/.trunk/.gitignore new file mode 100644 index 0000000..15966d0 --- /dev/null +++ b/.trunk/.gitignore @@ -0,0 +1,9 @@ +*out +*logs +*actions +*notifications +*tools +plugins +user_trunk.yaml +user.yaml +tmp diff --git a/.trunk/configs/.hadolint.yaml b/.trunk/configs/.hadolint.yaml new file mode 100644 index 0000000..98bf0cd --- /dev/null +++ b/.trunk/configs/.hadolint.yaml @@ -0,0 +1,4 @@ +# Following source doesn't work in most setups +ignored: + - SC1090 + - SC1091 diff --git a/.trunk/configs/.isort.cfg b/.trunk/configs/.isort.cfg new file mode 100644 index 0000000..b9fb3f3 --- /dev/null +++ b/.trunk/configs/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +profile=black diff --git a/.trunk/configs/.markdownlint.yaml b/.trunk/configs/.markdownlint.yaml new file mode 100644 index 0000000..b40ee9d --- /dev/null +++ b/.trunk/configs/.markdownlint.yaml @@ -0,0 +1,2 @@ +# Prettier friendly markdownlint config (all formatting rules disabled) +extends: markdownlint/style/prettier diff --git a/.trunk/configs/.yamllint.yaml b/.trunk/configs/.yamllint.yaml new file mode 100644 index 0000000..184e251 --- /dev/null +++ b/.trunk/configs/.yamllint.yaml @@ -0,0 +1,7 @@ +rules: + quoted-strings: + required: only-when-needed + extra-allowed: ["{|}"] + key-duplicates: {} + octal-values: + forbid-implicit-octal: true diff --git a/.trunk/configs/ruff.toml b/.trunk/configs/ruff.toml new file mode 100644 index 0000000..41de174 --- /dev/null +++ b/.trunk/configs/ruff.toml @@ -0,0 +1,6 @@ +# Generic, formatter-friendly config. +[lint] +select = ["B", "D3", "E", "F"] + +# Never enforce `E501` (line length violations). This should be handled by formatters. +ignore = ["E501"] diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml new file mode 100644 index 0000000..cb0be36 --- /dev/null +++ b/.trunk/trunk.yaml @@ -0,0 +1,32 @@ +# This file controls the behavior of Trunk: https://docs.trunk.io/cli +# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml +version: 0.1 +cli: + version: 1.24.0 +# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) +plugins: + sources: + - id: trunk + ref: v1.7.1 + uri: https://github.com/trunk-io/plugins +# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) +runtimes: + enabled: + - node@22.16.0 + - python@3.10.8 +# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) +lint: + enabled: + - actionlint@1.7.7 + - bandit@1.8.5 + - black@25.1.0 + - checkov@3.2.446 + - git-diff-check + - hadolint@2.12.1-beta + - isort@6.0.1 + - markdownlint@0.45.0 + - osv-scanner@2.0.3 + - prettier@3.6.0 + - taplo@0.9.3 + - trufflehog@3.89.2 + - yamllint@1.37.1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7df0d5a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ + +ARG PYTHON_VERSION=3.13 +FROM python:${PYTHON_VERSION}-slim + +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements-demo.txt . +RUN pip install --no-cache-dir -r requirements-demo.txt \ + && playwright install --with-deps chromium || true + +COPY . . + +CMD ["uvicorn", "demo.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..4dc8715 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +include README.md +include LICENSE +recursive-include pyformatic/templates *.html diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2e6dd1a --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +.PHONY: venv deps-txt-update dev-venv run build test test-docker +.ONESHELL: + +ENV_FILE := $(realpath .env) +ENV_VARS := $(shell set -a && . $(ENV_FILE) && env | grep '=') +$(foreach v,$(ENV_VARS),$(eval export $(v))) + +IMAGE ?= pyformatic-demo + +venv: + python${PYTHON_VERSION} -m venv .dev-venv + +deps-update: venv + .dev-venv/bin/pip3 install --upgrade pip + .dev-venv/bin/pip3 install pip-tools + .dev-venv/bin/pip-compile -r -v --all-extras --upgrade --emit-find-links \ + --cache-dir .dev-venv/.cache/pip-tools --resolver backtracking --emit-index-url \ + --color requirements.in + .dev-venv/bin/pip-compile -r -v --all-extras --upgrade --emit-find-links \ + --cache-dir .dev-venv/.cache/pip-tools --resolver backtracking --emit-index-url \ + --color requirements-demo.in + .dev-venv/bin/pip-compile -r -v --all-extras --upgrade --emit-find-links \ + --cache-dir .dev-venv/.cache/pip-tools --resolver backtracking --emit-index-url \ + --color requirements-dev.in + +dev: venv + .dev-venv/bin/pip3 install --upgrade pip + .dev-venv/bin/pip3 --cache-dir .dev-venv/.cache/pip-tools install -r requirements-dev.txt + .dev-venv/bin/playwright install + +build: + docker build -t $(IMAGE) . + +run: build + docker compose up + +test-docker: build + docker compose up -d + docker compose exec demo pytest -vv + docker compose down + +test: dev + .dev-venv/bin/uvicorn demo.main:app --host 127.0.0.1 --port 8000 & \ + server_pid=$$!; \ + sleep 2; \ + PYTHONPATH=. DEMO_BASE_URL=http://127.0.0.1:8000 .dev-venv/bin/pytest -v --tb=short --color=yes --cov=pyformatic --cov-report=xml --cov-report=term; \ + kill $$server_pid && sleep 1; \ + echo "Tests completed." + +lint: dev + .dev-venv/bin/pylint --fail-under=9.5 pyformatic tests demo diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a25ff9 --- /dev/null +++ b/README.md @@ -0,0 +1,215 @@ +# Pyformatic + +Pyformatic is a lightweight Python library for building, rendering and validating +HTML forms. It supports plain Python definitions as well as multi‑step forms +described in YAML. A small FastAPI demo application is included to showcase the +library in action. + +Pyformatic aims to stay minimal while providing convenient helpers for common +form workflows. It ships with Jinja templates but you are free to supply your +own. Form validators can access user‑provided context, making it easy to build +advanced validation rules. + +## Features + +* Define forms directly in Python or load them from YAML files +* Render forms using built‑in Jinja templates +* Validate user input and display informative messages +* Define validators inline with form fields +* Support multi‑step wizards via `FormFlow` +* Optional progress indicator for multi-step flows +* Helper `run_form_flow` to manage multi‑step state in any framework +* Easily integrate into FastAPI or any ASGI framework +* Inject raw HTML snippets as inputs or standalone blocks +* Built-in CSRF protection using session tokens + +## Roadmap + +* Internationalization support +* Possibly integration with Pydantic +* Custom field plugins + +## Installation + +Install the library from PyPI: + +```bash +pip install pyformatic +``` + +Alternatively, clone this repository and install it in editable mode for local +development: + +```bash +git clone https://github.com/streaky/pyformatic.git +cd pyformatic +pip install -e . +``` + +Pyformatic requires Python 3.10 or newer. The development environment and CI run +on Python 3.13. + +## Quick start + +Begin by defining a form in YAML and loading it with `FormFlow`: + +```yaml +steps: + - name: contact + fields: + - name: email + type: text + label: Email +``` + +```python +from pyformatic import formflow, Display + +flow = formflow.FormFlow.from_file("contact.yaml") +html = Display(flow.steps[0]).get_html() +print(html) +``` + +Validation logic can be added inline in YAML: + +```yaml +fields: + - name: email + type: text + label: Email + validator: | + if "@" not in value: + raise ValidationError("invalid email") + return value +``` + +Validation can also be defined programmatically using callables: + +```python +def check_email(value, _): + if "@" not in value: + raise ValidationError("invalid email") + return value + +cfg = {"name": "step", "fields": [{"name": "email", "validator": check_email}]} +step = pyformatic.formflow.Step(cfg, None, action="/") +``` + +You can also describe a multi‑step form in YAML and load it with `FormFlow`. +See the files in `demo/` for complete examples. To manage progress and +validation you can call `run_form_flow` inside your request handler. +Set `show_progress: true` in the YAML or pass `show_progress=True` to +`FormFlow` to display a progress bar. The YAML signup demo enables this +feature and the repository also includes a fully Python-defined multi-step +form served at `/signup-python`. +The YAML version demonstrates an inline validator for the email field. + +Forms can also be created directly in Python: + +```python +from pyformatic import Form, TextInput, Button, Display + +form = Form("contact", action="/submit") +form.add_item(TextInput(name="email", label="Email")) +form.add_button(Button(name="submit", label="Send")) + +html = Display(form).get_html() +print(html) +``` + +Fields can also list other field names under an ``include`` option. When a field +with this option is validated via AJAX, the values of the referenced fields are +sent along and made available in the ``data_store`` during validation. + +When server‑side validation fails, a banner at the top of the page lists the +fields that require attention while each field still shows its individual +message inline. + +### CSRF protection + +If the request object passed to ``run_form_flow`` provides a ``session`` +mapping, Pyformatic automatically includes a CSRF token in the rendered +form and validates it on submission. The token is stored in the session under +``_pyformatic_csrf_token``. + +## Custom templates + +`Display` accepts additional template directories. If a file named +`ui/input_.html` exists in one of those directories it overrides the +default template for that input type. This allows applications to style specific +elements without modifying the bundled templates. + +## Demo application + +The repository includes a FastAPI demo showing how to serve forms and perform +AJAX validation. Pages are styled with [Pico CSS](https://picocss.com) and +`pyformatic/static/pyformatic.css` provides colours for validation messages. +Use ``pyformatic.Display.setup_jinja(env)`` to register template variables +``pyformatic_header`` and ``pyformatic_footer`` which include the required CSS +and JavaScript. +Start it using Docker: + +```bash +make run +``` + +Then open `http://localhost:8000` in your browser. + +To run the demo locally without Docker, create the development environment and +start the server: + +```bash +make dev +source .dev-venv/bin/activate +uvicorn demo.main:app --reload +``` + +## Running tests + +Unit and end‑to‑end tests are provided. Playwright is used for browser tests. +Execute them with: + +```bash +make test +``` + +This command sets up the virtual environment, starts the demo server and runs +both unit and browser tests. + +Lint the codebase with: + +```bash +make lint +``` + +This runs pylint on the library, tests and demo directories. + +## Repository layout + +* `pyformatic/` – library source code and Jinja templates +* `demo/` – FastAPI demo application +* `tests/` – unit tests and Playwright end‑to‑end tests + +## Source and support + +The code lives on [GitHub](https://github.com/streaky/pyformatic). Contributions +via pull request are welcome. Please run the linter and tests before PR, though. + +## Publishing to PyPI + +Releases are published automatically when a tag matching `v*` is pushed. To +release a new version, create and push a tag: + +```bash +git tag v0.1.0 +git push --tags +``` + +The `.github/workflows/publish.yml` workflow builds the package and uploads it +to PyPI using the `PYPI_API_TOKEN` repository secret. Pushing a tag beginning +with `test-v` uploads the build to TestPyPI instead using the +`TEST_PYPI_API_TOKEN` secret. + +## License + +This project is distributed under the terms of the [Apache License 2.0](LICENSE). diff --git a/demo/__init__.py b/demo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/elements.yaml b/demo/elements.yaml new file mode 100644 index 0000000..c79e4fa --- /dev/null +++ b/demo/elements.yaml @@ -0,0 +1,84 @@ +module: null +steps: + - name: elements + fields: + - name: search + type: search + label: Search + placeholder: Search + - name: text + type: text + label: Text + placeholder: Text + - name: select + type: select + label: Select + options: + - ["", "Select…"] + - ["1", "One"] + - ["2", "Two"] + - name: file + type: file + label: File browser + - name: range + type: range + label: Range slider + value: "50" + extra: + min: "0" + max: "100" + - name: valid + type: text + label: Valid + placeholder: Valid + extra: + aria-invalid: "false" + - name: invalid + type: text + label: Invalid + placeholder: Invalid + extra: + aria-invalid: "true" + - name: disabled + type: text + label: Disabled + placeholder: Disabled + extra: + disabled: disabled + - name: date + type: date + label: Date + - name: time + type: time + label: Time + - name: color + type: color + label: Color + value: "#0eaaaa" + - name: checkbox1 + type: checkbox + label: Checkbox + value: on + - name: checkbox2 + type: checkbox + label: Checkbox + - name: radio + type: radio + label: Radio button + value: radio1 + - name: radio + type: radio + label: Radio button + value: radio2 + - name: switch1 + type: switch + label: Switch + value: on + - name: switch2 + type: switch + label: Switch + - name: textarea + type: textarea + label: Textarea + placeholder: Textarea text + rows: 4 diff --git a/demo/main.py b/demo/main.py new file mode 100644 index 0000000..891a95e --- /dev/null +++ b/demo/main.py @@ -0,0 +1,211 @@ +"""Demo application to showcase Pyformatic forms.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from pyformatic.formflow import Step + +import pyformatic + +app = FastAPI() +static_dir = Path(__file__).with_name("static") +app.mount( + "/static", + StaticFiles(directory=str(static_dir), packages=[("pyformatic", "static")]), + name="static", +) + + +TEMPLATE_DIR = Path(__file__).with_name("templates") +env = Environment( + loader=FileSystemLoader(str(TEMPLATE_DIR)), + autoescape=select_autoescape(["html", "xml"]), +) +pyformatic.Display.setup_jinja(env) + + +@app.get("/", response_class=HTMLResponse) +async def index() -> HTMLResponse: + """Return the landing page.""" + tpl = env.get_template("index.html") + html = tpl.render(title="Home") + return HTMLResponse(html) + + +@app.api_route("/login", methods=["GET", "POST"]) +async def login_demo(request: Request): + """Serve and process the login form demo.""" + login_yaml_file = Path(__file__).with_name("user_login.yaml") + login_form = pyformatic.FormFlow.from_yaml( + str(login_yaml_file), + action="/login", + ) + state, result = await pyformatic.run_form_flow(login_form, request) + if state == "validation": + return JSONResponse(result) + if state == "complete": + tpl = env.get_template("done.html") + html = tpl.render(title="Complete", data=result) + return HTMLResponse(html) + tpl = env.get_template("form.html") + html = tpl.render(title="Login Form Demo", form_html=result) + return HTMLResponse(html) + + +@app.api_route("/signup", methods=["GET", "POST"]) +async def signup_demo(request: Request): + """Display the multi-step signup demo configured via YAML.""" + signup_yaml_file = Path(__file__).with_name("user_signup.yaml") + signup_form = pyformatic.FormFlow.from_yaml( + str(signup_yaml_file), + action="/signup", + template_dirs=[str(Path(__file__).with_name("templates") / "pyformatic")], + ) + for item in signup_form.steps[0].form.items: + if item.name in {"password", "confirm_password"}: + item.classes_outer.append("password-field") + state, result = await pyformatic.run_form_flow(signup_form, request) + if state == "validation": + return JSONResponse(result) + if state == "complete": + tpl = env.get_template("done.html") + html = tpl.render(title="Complete", data=result) + return HTMLResponse(html) + tpl = env.get_template("form.html") + html = tpl.render(title="Multi-Step Signup Form Demo", form_html=result) + return HTMLResponse(html) + + +@app.api_route("/signup-python", methods=["GET", "POST"]) +async def signup_python_demo(request: Request): + """Show a signup form built purely in Python.""" + py_steps_cfg = [ + { + "name": "step_one", + "title": "Step One", + "description": "This is the first step of the user signup process.", + "fields": [ + {"name": "first_name", "type": "text", "label": "First Name", "required": True}, + {"name": "last_name", "type": "text", "label": "Last Name", "required": True}, + {"name": "username", "type": "text", "label": "Username", "required": True}, + {"name": "password", "type": "password", "label": "Password", "required": True}, + { + "name": "confirm_password", + "type": "password", + "label": "Confirm Password", + "required": True, + "include": ["password"], + }, + { + "type": "raw_html", + "html": ( + "
" + "Signup
" + ), + }, + { + "name": "promo", + "type": "raw_input", + "html": "", + }, + ], + }, + { + "name": "step_two", + "title": "Step Two", + "description": "This is the second step of the user signup process.", + "fields": [ + { + "name": "email", + "type": "email", + "label": "Email Address", + "required": True, + "validator": ( + "cleaned = value.strip()\n" + "if not cleaned:\n" + " raise ValidationError('Email required')\n" + "if '@' not in cleaned:\n" + " raise ValidationError('Invalid email')\n" + "return cleaned" + ), + }, + {"name": "phone", "type": "text", "label": "Phone Number", "required": True} + ], + }, + { + "name": "step_three", + "title": "Step Three", + "description": "This is the final step of the user signup process.", + "fields": [ + { + "name": "terms", + "type": "checkbox", + "label": "I agree to the terms and conditions", + "required": True, + }, + { + "name": "marketing", + "type": "checkbox", + "label": "Send me occasional product updates", + } + ], + }, + ] + + signup_form_py = pyformatic.FormFlow( + [ + Step( + cfg, + "demo.myapp.forms.user_signup", + action="/signup-python", + is_last=i == len(py_steps_cfg) - 1, + ) + for i, cfg in enumerate(py_steps_cfg) + ], + template_dirs=[str(Path(__file__).with_name("templates") / "pyformatic")], + ) + + for item in signup_form_py.steps[0].form.items: + if item.name in {"password", "confirm_password"}: + item.classes_outer.append("password-field") + + state, result = await pyformatic.run_form_flow(signup_form_py, request) + if state == "validation": + return JSONResponse(result) + if state == "complete": + tpl = env.get_template("done.html") + html = tpl.render(title="Complete", data=result) + return HTMLResponse(html) + tpl = env.get_template("form.html") + html = tpl.render(title="Multi-Step Signup Form (No YAML) Demo", form_html=result) + return HTMLResponse(html) + + +@app.api_route("/elements", methods=["GET", "POST"]) +async def elements_demo(request: Request): + """Render the elements demo which showcases all field types.""" + elements_yaml = Path(__file__).with_name("elements.yaml") + flow = pyformatic.FormFlow.from_yaml( + str(elements_yaml), + action="/elements", + ) + form = flow.steps[0].form + + form.buttons.insert(0, pyformatic.Button(name="reset", label="Reset", button_type="reset")) + + if request.method == "POST": + data = await request.form() + tpl = env.get_template("done.html") + html = tpl.render(title="Elements Submitted", data=dict(data)) + return HTMLResponse(html) + + form_html = pyformatic.Display(form).get_html() + tpl = env.get_template("form.html") + html = tpl.render(title="Elements Demo", form_html=form_html) + return HTMLResponse(html) diff --git a/demo/myapp/__init__.py b/demo/myapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/myapp/forms/__init__.py b/demo/myapp/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/myapp/forms/user_signup/__init__.py b/demo/myapp/forms/user_signup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/myapp/forms/user_signup/validators/__init__.py b/demo/myapp/forms/user_signup/validators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demo/myapp/forms/user_signup/validators/step_one.py b/demo/myapp/forms/user_signup/validators/step_one.py new file mode 100644 index 0000000..d5ef18e --- /dev/null +++ b/demo/myapp/forms/user_signup/validators/step_one.py @@ -0,0 +1,28 @@ +"""Field validation for the first signup step.""" + +from pyformatic import ValidationError, ValidationInfo + + +class Validator: + """Validate username and password fields.""" + + def username(self, value: str) -> str: + """Return a cleaned username if valid.""" + cleaned = value.strip() + if not cleaned: + raise ValidationError("Username required") + if cleaned != value: + raise ValidationInfo("I adjusted your username", cleaned) + return cleaned + + def password(self, value: str) -> str: + """Ensure the password is long enough.""" + if len(value) < 6: + raise ValidationError("Password must be at least 6 characters") + return value + + def confirm_password(self, value: str, session_data: dict) -> str: + """Check that the password confirmation matches.""" + if value != session_data.get("password"): + raise ValidationError("Passwords do not match") + return value diff --git a/demo/myapp/forms/user_signup/validators/step_three.py b/demo/myapp/forms/user_signup/validators/step_three.py new file mode 100644 index 0000000..9784a51 --- /dev/null +++ b/demo/myapp/forms/user_signup/validators/step_three.py @@ -0,0 +1,16 @@ +"""Acceptance of terms validation step.""" + +# pylint: disable=too-few-public-methods,R0801 \ +# simple single-method validator; pattern matches other steps + +from pyformatic import ValidationError + + +class Validator: + """Validate that the user agreed to the terms of service.""" + + def terms(self, value: str) -> str: + """Return the value if the terms checkbox was ticked.""" + if value != 'on': + raise ValidationError("You must agree to the terms") + return value diff --git a/demo/myapp/forms/user_signup/validators/step_two.py b/demo/myapp/forms/user_signup/validators/step_two.py new file mode 100644 index 0000000..a4043c5 --- /dev/null +++ b/demo/myapp/forms/user_signup/validators/step_two.py @@ -0,0 +1,19 @@ +"""Email validation step for user sign up.""" + +# pylint: disable=too-few-public-methods,R0801 \ +# simple single-method validator; email step mirrors other steps + +from pyformatic import ValidationError + + +class Validator: + """Validate the email address entered by the user.""" + + def email(self, value: str) -> str: + """Return a stripped email if valid.""" + cleaned = value.strip() + if not cleaned: + raise ValidationError("Email required") + if "@" not in cleaned: + raise ValidationError("Invalid email") + return cleaned diff --git a/demo/static/demo.css b/demo/static/demo.css new file mode 100644 index 0000000..feb5c0e --- /dev/null +++ b/demo/static/demo.css @@ -0,0 +1,3 @@ +.demo-textbox input { + border: 2px dashed #0ea5e9; +} diff --git a/demo/static/logo.svg b/demo/static/logo.svg new file mode 100644 index 0000000..948a4ee --- /dev/null +++ b/demo/static/logo.svg @@ -0,0 +1,4 @@ + + + Demo + diff --git a/demo/templates/base.html b/demo/templates/base.html new file mode 100644 index 0000000..bbac437 --- /dev/null +++ b/demo/templates/base.html @@ -0,0 +1,47 @@ + + + + + + {{ title }} + + {{ pyformatic_header|safe }} + + + +
+ +
+ {% block body %}{% endblock %} +
+
+ {{ pyformatic_footer|safe }} + + diff --git a/demo/templates/done.html b/demo/templates/done.html new file mode 100644 index 0000000..6ea21d5 --- /dev/null +++ b/demo/templates/done.html @@ -0,0 +1,5 @@ +{% extends 'base.html' %} +{% block body %} +

Done

+
{{ data }}
+{% endblock %} diff --git a/demo/templates/form.html b/demo/templates/form.html new file mode 100644 index 0000000..4770284 --- /dev/null +++ b/demo/templates/form.html @@ -0,0 +1,4 @@ +{% extends 'base.html' %} +{% block body %} +{{ form_html|safe }} +{% endblock %} diff --git a/demo/templates/index.html b/demo/templates/index.html new file mode 100644 index 0000000..7bed637 --- /dev/null +++ b/demo/templates/index.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% block body %} +

Home

+

Welcome to the Pyformatic demo styled with PicoCSS.

+ +{% endblock %} diff --git a/demo/templates/pyformatic/ui/input_text.html b/demo/templates/pyformatic/ui/input_text.html new file mode 100644 index 0000000..4203c5e --- /dev/null +++ b/demo/templates/pyformatic/ui/input_text.html @@ -0,0 +1,6 @@ +
+ + + {{ item_message }} + {% if item_help %}{{ item_help }}{% endif %} +
diff --git a/demo/user_login.yaml b/demo/user_login.yaml new file mode 100644 index 0000000..130230e --- /dev/null +++ b/demo/user_login.yaml @@ -0,0 +1,14 @@ +module: demo.myapp.forms.user_signup +steps: + - name: step_one + title: Step One + description: This is the first step of the user signup process. + fields: + - name: username + type: text + label: Username + required: true + - name: password + type: password + label: Password + required: true diff --git a/demo/user_signup.yaml b/demo/user_signup.yaml new file mode 100644 index 0000000..a285735 --- /dev/null +++ b/demo/user_signup.yaml @@ -0,0 +1,64 @@ +module: demo.myapp.forms.user_signup +show_progress: true +steps: + - name: step_one + title: Step One + description: This is the first step of the user signup process. + fields: + - name: first_name + type: text + label: First Name + required: true + - name: last_name + type: text + label: Last Name + required: true + - name: username + type: text + label: Username + required: true + - name: password + type: password + label: Password + required: true + - name: confirm_password + type: password + label: Confirm Password + required: true + include: + - password + - type: raw_html + html: "
Signup
" + - name: promo + type: raw_input + html: "" + - name: step_two + title: Step Two + description: This is the second step of the user signup process. + fields: + - name: email + type: email + label: Email Address + required: true + validator: | + cleaned = value.strip() + if not cleaned: + raise ValidationError("Email required") + if "@" not in cleaned: + raise ValidationError("Invalid email") + return cleaned + - name: phone + type: text + label: Phone Number + required: true + - name: step_three + title: Step Three + description: This is the final step of the user signup process. + fields: + - name: terms + type: checkbox + label: I agree to the terms and conditions + required: true + - name: marketing + type: checkbox + label: Send me occasional product updates diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..4465208 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,13 @@ + +services: + + demo: + build: + context: . + dockerfile: Dockerfile + args: + - PYTHON_VERSION + env_file: + - .env + ports: + - 8000:8000 diff --git a/pyformatic.code-workspace b/pyformatic.code-workspace new file mode 100644 index 0000000..69e2a52 --- /dev/null +++ b/pyformatic.code-workspace @@ -0,0 +1,32 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "testing.coverageToolbarEnabled": true, + "testing.displayedCoveragePercent": "minimum", + "testing.showCoverageInExplorer": true, + "coverage-gutters.showGutterCoverage": true, + "coverage-gutters.coverageFileNames": [ + "coverage.xml" + ], + "coverage-gutters.showLineCoverage": true, + "coverage-gutters.showRulerCoverage": true, + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": true, + "python.testing.pytestEnabled": true + }, + "extensions": { + "recommendations": [ + "ms-python.python", + "ryanluker.vscode-coverage-gutters", + "ms-vscode.test-adapter-converter", + "ms-vscode.test-explorer-ui", + "ms-vscode.test-explorer-python" + ] + } +} \ No newline at end of file diff --git a/pyformatic/__init__.py b/pyformatic/__init__.py new file mode 100644 index 0000000..9399ffd --- /dev/null +++ b/pyformatic/__init__.py @@ -0,0 +1,31 @@ +"""Python form rendering module.""" + +from .form import Form +from .elements import TextInput, Button, RawInput, RawElement +from .display import Display +from .csrf import ensure_csrf_token, validate_csrf_token +from .formflow import FormFlow +from .flow_runner import run_form_flow +from .exceptions import ( + ValidationError, + ValidationInfo, + ValidationWarning, +) + +__version__ = "0.1.1" + +__all__ = [ + "Form", + "TextInput", + "Button", + "RawInput", + "RawElement", + "Display", + "FormFlow", + "run_form_flow", + "ValidationError", + "ValidationInfo", + "ValidationWarning", + "ensure_csrf_token", + "validate_csrf_token", +] diff --git a/pyformatic/csrf.py b/pyformatic/csrf.py new file mode 100644 index 0000000..19a7040 --- /dev/null +++ b/pyformatic/csrf.py @@ -0,0 +1,26 @@ +"""Helpers for CSRF token management.""" + +from __future__ import annotations + +from hmac import compare_digest +from secrets import token_urlsafe +from typing import Mapping, MutableMapping + +_TOKEN_KEY = "_pyformatic_csrf_token" + + +def ensure_csrf_token(session: MutableMapping[str, str]) -> str: + """Return existing token in ``session`` or create a new one.""" + token = session.get(_TOKEN_KEY) + if not token: + token = token_urlsafe(32) + session[_TOKEN_KEY] = token + return token + + +def validate_csrf_token(session: Mapping[str, str], token: str) -> bool: + """Return True if ``token`` matches the value stored in ``session``.""" + expected = session.get(_TOKEN_KEY) + if not expected: + return False + return compare_digest(str(expected), str(token)) diff --git a/pyformatic/display.py b/pyformatic/display.py new file mode 100644 index 0000000..d1d73bf --- /dev/null +++ b/pyformatic/display.py @@ -0,0 +1,134 @@ +"""Render forms using Jinja2 templates.""" + +from __future__ import annotations + +from importlib import resources + +from typing import Mapping +from markupsafe import escape +from jinja2 import ( + Environment, + FileSystemLoader, + TemplateNotFound, + select_autoescape, +) + +from .form import Form +from .elements import InputElement, RawInput, RawElement + + +class Display: + """Renders a form to HTML.""" + + static_url = "/static" + + @classmethod + def header_html(cls, static_url: str | None = None) -> str: + """Return ```` tags for required assets.""" + url = static_url or cls.static_url + url = url.rstrip("/") + return f'' + + @classmethod + def footer_html(cls, static_url: str | None = None) -> str: + """Return ``' + + @classmethod + def setup_jinja(cls, env: Environment, static_url: str | None = None) -> None: + """Register globals in a :class:`jinja2.Environment`.""" + env.globals["pyformatic_header"] = cls.header_html(static_url) + env.globals["pyformatic_footer"] = cls.footer_html(static_url) + + def __init__( + self, + form: Form, + *, + template_dirs: list[str] | None = None, + static_url: str | None = None, + ) -> None: + self.form = form + self.static_url = static_url or self.__class__.static_url + template_dir = resources.files(__package__) / "templates" + search_paths = [] + if template_dirs: + search_paths.extend(str(p) for p in template_dirs) + search_paths.append(str(template_dir)) + self.env = Environment( + loader=FileSystemLoader(search_paths), + autoescape=select_autoescape(["html", "xml"]), + ) + + def _get_input_template(self, input_type: str): + """Return template for the given input type, falling back to default.""" + + try: + return self.env.get_template(f"ui/input_{input_type}.html") + except TemplateNotFound: + return self.env.get_template("ui/input.html") + + def _render_items(self) -> str: + parts = [] + for item in self.form.items: + if isinstance(item, RawElement): + parts.append(item.html) + continue + if isinstance(item, InputElement): + tpl = self._get_input_template(item.input_type) + extra = dict(item.extra) + if item.include: + extra["data-include"] = ",".join(item.include) + extra_attrs = " ".join(f'{k}="{v}"' for k, v in extra.items()) + custom_html = item.html if isinstance(item, RawInput) else "" + parts.append( + tpl.render( + item_id=item.id, + item_label=item.label, + item_name=item.name, + item_value=item.value, + item_help=item.help, + item_message=item.message or "", + item_type=item.input_type, + item_placeholder=item.placeholder, + item_outer_classes=" ".join(item.classes_outer), + item_input_classes=" ".join(item.classes_input), + item_options=item.options, + item_rows=item.rows, + extra_attrs=extra_attrs, + item_raw_html=custom_html, + ) + ) + return "\n".join(parts) + + def _render_buttons(self) -> str: + if not self.form.buttons: + return "" + tpl_btn = self.env.get_template("ui/button.html") + rendered = [tpl_btn.render(button=btn) for btn in self.form.buttons] + outer = self.env.get_template("ui/buttons_outer.html") + return outer.render(buttons="".join(rendered)) + + def get_html(self, *, hidden_fields: Mapping[str, str] | None = None) -> str: + """Return HTML string for the form.""" + + tpl = self.env.get_template("ui/form.html") + items = self._render_items() + if hidden_fields: + hidden = [] + for name, value in hidden_fields.items(): + hidden.append( + f'' + ) + items += "\n".join(hidden) + buttons = self._render_buttons() + return tpl.render( + form_id=self.form.id, + form_action=self.form.action, + form_method=self.form.method, + form_autocomplete="off" if not self.form.autocomplete else "on", + form_items=items, + buttons=buttons, + ) diff --git a/pyformatic/elements.py b/pyformatic/elements.py new file mode 100644 index 0000000..4833eea --- /dev/null +++ b/pyformatic/elements.py @@ -0,0 +1,73 @@ +"""Form elements used by pyform.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional, Tuple + + +@dataclass +class BaseElement: + """Base class for all form elements.""" + # pylint: disable=too-many-instance-attributes # dataclass with many UI fields + + name: str + label: str + value: str = "" + element_id: Optional[str] = None + help: str = "" + placeholder: str = "" + include: List[str] = field(default_factory=list) + classes_outer: List[str] = field(default_factory=list) + classes_input: List[str] = field(default_factory=list) + extra: dict = field(default_factory=dict) + message: Optional[str] = None + + def reset(self) -> None: + """Reset value and classes to their defaults.""" + self.value = "" + self.classes_outer.clear() + self.classes_input.clear() + self.message = None + + @property + def id(self) -> str: + """Return HTML element id.""" + return self.element_id or self.name + + +@dataclass +class InputElement(BaseElement): + """Base element for input fields.""" + + input_type: str = "text" + options: List[Tuple[str, str]] = field(default_factory=list) + rows: int = 3 + + +@dataclass +class TextInput(InputElement): + """Simple text input.""" + + input_type: str = "text" + + +@dataclass +class Button(BaseElement): + """Simple form button.""" + + button_type: str = "submit" + + +@dataclass +class RawInput(InputElement): + """Custom HTML used in place of the input element.""" + + html: str = "" + + +@dataclass +class RawElement(BaseElement): + """Raw HTML snippet replacing the entire element wrapper.""" + + html: str = "" diff --git a/pyformatic/exceptions.py b/pyformatic/exceptions.py new file mode 100644 index 0000000..ed76fb2 --- /dev/null +++ b/pyformatic/exceptions.py @@ -0,0 +1,30 @@ +"""Custom exception hierarchy used during validation.""" + + +class ValidationMessage(Exception): + """Base class for validation messages raised by validators.""" + + level = "info" + + def __init__(self, message: str, value: str | None = None) -> None: + super().__init__(message) + self.message = message + self.value = value + + +class ValidationInfo(ValidationMessage): + """Informational validation message.""" + + level = "info" + + +class ValidationWarning(ValidationMessage): + """Warning level validation message.""" + + level = "warning" + + +class ValidationError(ValidationMessage): + """Error level validation message.""" + + level = "error" diff --git a/pyformatic/flow_runner.py b/pyformatic/flow_runner.py new file mode 100644 index 0000000..e9c8f30 --- /dev/null +++ b/pyformatic/flow_runner.py @@ -0,0 +1,68 @@ +"""Utility helpers for executing a :class:`~pyformatic.formflow.FormFlow`.""" +from __future__ import annotations + +from typing import Any, Tuple + +from .csrf import ensure_csrf_token, validate_csrf_token + +from .formflow import FormFlow, RequestLike + + +async def run_form_flow( + form_flow: FormFlow, + request: RequestLike, +) -> Tuple[str, Any]: + """Handle a request for a multi-step form. + + Parameters + ---------- + form_flow: + The :class:`~pyformatic.formflow.FormFlow` instance to operate on. + request: + An object providing ``method``, ``headers`` and ``json``/``form`` + coroutine methods. + + Returns + ------- + tuple + ``("validation", payload)`` for AJAX validation requests, + ``("form", html)`` for form pages and ``("complete", data)`` when the + flow has finished. + """ + data_store: dict[str, Any] = {} + try: + session = getattr(request, "session") + except (AttributeError, AssertionError): + session = None + csrf_token = ensure_csrf_token(session) if session is not None else None + + if form_flow.is_validation_request(request): + payload = await request.json() + data_store = payload.get("fields", {}) + data_store[payload.get("field", "")] = payload.get("value", "") + _is_val, payload = await form_flow.handle_request(request, data_store) + assert _is_val is True + return "validation", payload + + if request.method == "POST": + form_data = await request.form() + if session is not None: + if not validate_csrf_token(session, form_data.get("csrf_token", "")): + html = form_flow.render(0, data_store=data_store, csrf_token=csrf_token) + return "form", html + for k, v in form_data.items(): + if k not in {"next", "submit", "csrf_token"}: + data_store[k] = v + _is_val, result = await form_flow.handle_request(request, data_store) + assert not _is_val + step_index, messages, has_error = result + if step_index >= form_flow.num_steps: + return "complete", data_store + if has_error: + html = form_flow.render(step_index, messages, data_store, csrf_token=csrf_token) + else: + html = form_flow.render(step_index, data_store=data_store, csrf_token=csrf_token) + return "form", html + + html = form_flow.render(0, data_store=data_store, csrf_token=csrf_token) + return "form", html diff --git a/pyformatic/form.py b/pyformatic/form.py new file mode 100644 index 0000000..fa74f33 --- /dev/null +++ b/pyformatic/form.py @@ -0,0 +1,45 @@ +"""Form container for pyform.""" + +from __future__ import annotations + +from typing import List, Optional + +from .elements import BaseElement, Button + + +class Form: + """Represents a form and contains elements.""" + # pylint: disable=too-many-instance-attributes # tracks many field attributes + + def __init__( + self, + form_id: str, + action: str, + method: str = "POST", + autocomplete: bool = True, + validate: Optional[str] = None, + validate_url: Optional[str] = None, + ) -> None: + # pylint: disable=too-many-arguments,too-many-positional-arguments # constructor mirrors HTML form options + self.id = form_id + self.action = action + self.method = method + self.autocomplete = autocomplete + self.validate = validate + self.validate_url = validate_url + self.help: str = "" + self.items: List[BaseElement] = [] + self.buttons: List[Button] = [] + + def add_item(self, item: BaseElement) -> None: + """Add a form element.""" + self.items.append(item) + + def add_button(self, button: Button) -> None: + """Add a button element.""" + self.buttons.append(button) + + def reset(self) -> None: + """Reset all elements to default state.""" + for item in self.items: + item.reset() diff --git a/pyformatic/formflow.py b/pyformatic/formflow.py new file mode 100644 index 0000000..52291d8 --- /dev/null +++ b/pyformatic/formflow.py @@ -0,0 +1,412 @@ +"""Multi-step form flow management.""" +from __future__ import annotations + +from importlib import import_module +from importlib import resources +from pathlib import Path +from types import MethodType, SimpleNamespace +from typing import Any, Awaitable, Callable, Mapping, Protocol +from inspect import signature + +import yaml +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from .form import Form +from .elements import TextInput, Button, RawInput, RawElement +from .display import Display +from .exceptions import ( + ValidationError, + ValidationInfo, + ValidationMessage, + ValidationWarning, +) + + +class RequestLike(Protocol): + """Minimal request interface used by ``FormFlow``.""" + + method: str + headers: Mapping[str, str] + + def json(self) -> Awaitable[Any]: + """Return the request body parsed as JSON.""" + raise NotImplementedError + + def form(self) -> Awaitable[Mapping[str, Any]]: + """Return the request body parsed as form data.""" + raise NotImplementedError + + +class Step: + """Represents a single stage of a multi-step form.""" + + def __init__( # pylint: disable=too-many-arguments # initializer sets up many configurable options + self, + config: dict, + module_base: str | None, + action: str, + *, + is_last: bool = False, + validator_context: dict | None = None, + ) -> None: + """Create a step instance from configuration.""" + # pylint: disable=too-many-locals # splitting would reduce clarity here + + fields = [f for f in config.get("fields", []) if f.get("type") != "submit"] + self.config = {**config, "fields": fields} + self.validator = self._load_validator(module_base, config["name"]) + if validator_context: + for name, value in validator_context.items(): + setattr(self.validator, name, value) + self._setup_inline_validators(fields, config) + self.form = Form(config['name'], action=action) + for idx, field in enumerate(fields): + self._add_field(field, idx) + button_label = config.get("button_label", "Submit" if is_last else "Next") + self.form.add_button(Button(name="submit" if is_last else "next", label=button_label)) + + def _load_validator(self, module_base: str | None, name: str) -> Any: + """Return validator instance from module or an empty namespace.""" + if not module_base: + return SimpleNamespace() + try: + module_path = f"{module_base}.validators.{name}" + module = import_module(module_path) + return module.Validator() + except ModuleNotFoundError: + return SimpleNamespace() + + def _make_callable(self, spec: Any) -> Callable: + """Return a callable implementing the validator.""" + # pylint: disable=function-redefined # wrapper must match original callable signature + if callable(spec): + sig_len = len(signature(spec).parameters) + if sig_len <= 1: + def method(_, value): + return spec(value) + else: + def method(_, value, data_store): + return spec(value, data_store) + return method + code = str(spec) + local: dict[str, Any] = {} + body = "\n".join(f" {line}" for line in code.splitlines()) + src = "def _v(value, data_store):\n" + body + exec( # pylint: disable=exec-used # executing inline validator code from YAML + src, + { + "ValidationError": ValidationError, + "ValidationInfo": ValidationInfo, + "ValidationWarning": ValidationWarning, + }, + local, + ) + def method(_, value, data_store): + return local["_v"](value, data_store) + + return method + + def _setup_inline_validators(self, fields: list[dict], config: dict) -> None: + """Bind inline validators defined in the configuration.""" + + validators = dict(config.get("validators", {})) + for field in fields: + if "validator" in field: + validators[field["name"]] = field["validator"] + for name, spec in validators.items(): + func = self._make_callable(spec) + bound = MethodType(func, self.validator) + setattr(self.validator, name, bound) + + def _add_field(self, field: dict, idx: int) -> None: + """Add a configured field to ``self.form``.""" + field_type = field.get('type', 'text') + if field_type == 'raw_html': + self.form.add_item( + RawElement( + name=field.get('name', f'raw_{idx}'), + label='', + html=field.get('html', ''), + ) + ) + return + if field_type == 'raw_input': + item = RawInput( + name=field['name'], + label=field.get('label', ''), + html=field.get('html', ''), + ) + item.include = field.get('include', []) + self.form.add_item(item) + return + item = TextInput(name=field['name'], label=field.get('label', '')) + item.input_type = field_type + item.include = field.get('include', []) + self.form.add_item(item) + + def validate_field( # pylint: disable=too-many-arguments # flexible API for custom validators + self, + name: str, + value: str, + data_store: dict, + *, + update_data: bool = False, + extra_fields: Mapping[str, str] | None = None, + ) -> tuple[str, str | None, str]: + """Validate a single field. + + ``extra_fields`` may contain additional field values to temporarily + merge into ``data_store`` for the validation call. + + Returns the possibly modified value, message level and message text. + """ + func = getattr(self.validator, name, None) + if not func: + return value, None, "" + data_view = data_store if not extra_fields else {**data_store, **extra_fields} + try: + if func.__code__.co_argcount == 2: + new_value = func(value) + else: + new_value = func(value, data_view) + if new_value is None: + new_value = value + level = "ok" + message = "" + except ValidationMessage as exc: # catch info/warn/error + new_value = exc.value if exc.value is not None else value + level = exc.level + message = exc.message + if update_data: + data_store[name] = new_value + if extra_fields: + data_store.update(extra_fields) + item = next((i for i in self.form.items if i.name == name), None) + if item: + item.value = new_value + item.message = message if level else None + item.classes_outer = [ + c for c in item.classes_outer + if c not in {"info", "warning", "error", "ok"} + ] + if level: + item.classes_outer.append(level) + return new_value, level, message + + def validate(self, data: dict, data_store: dict) -> tuple[dict, bool]: + """Validate all fields in this step and update ``data_store``.""" + messages: dict[str, dict] = {} + has_error = False + for field in self.config.get('fields', []): + if field.get('type') in {'submit', 'raw_html'}: + continue + name = field['name'] + value = data.get(name, '') + new_val, level, msg = self.validate_field( + name, + value, + data_store, + update_data=True, + ) + if level: + messages[name] = {"level": level, "message": msg, "value": new_val} + if level == "error": + has_error = True + return messages, has_error + + +class FormFlow: + """Manage rendering and validation of a form flow.""" + + def __init__( # pylint: disable=too-many-arguments # flow setup requires several options + self, + steps: list[Step], + *, + template_dirs: list[str] | None = None, + static_url: str | None = None, + show_progress: bool = False, + ) -> None: + self.steps = steps + template_dir = resources.files(__package__) / 'templates' + search_paths = [] + if template_dirs: + search_paths.extend(template_dirs) + search_paths.append(str(template_dir)) + self.template_dirs = template_dirs or [] + self.env = Environment( + loader=FileSystemLoader(search_paths), + autoescape=select_autoescape(['html', 'xml']), + ) + self.static_url = static_url or Display.static_url + self.show_progress = show_progress + + @classmethod + def from_yaml( # pylint: disable=too-many-arguments # method builds complex object from file + cls, + yaml_path: str, + action: str, + *, + validator_context: dict | None = None, + template_dirs: list[str] | None = None, + static_url: str | None = None, + ) -> 'FormFlow': + """Construct a :class:`FormFlow` instance from a YAML definition.""" + with open(Path(yaml_path), 'r', encoding='utf-8') as fh: + cfg = yaml.safe_load(fh) + module_base = cfg['module'] + step_cfgs = cfg.get('steps', []) + show_progress = cfg.get('show_progress', False) + steps = [ + Step( + step_cfg, + module_base, + action, + is_last=idx == len(step_cfgs) - 1, + validator_context=validator_context, + ) + for idx, step_cfg in enumerate(step_cfgs) + ] + return cls( + steps, + template_dirs=template_dirs, + static_url=static_url, + show_progress=show_progress, + ) + + @staticmethod + def is_validation_request(request: RequestLike) -> bool: + """Return True if this request is for field validation.""" + content_type = request.headers.get("content-type", "").lower() + return request.method == "POST" and content_type.startswith("application/json") + + + async def handle_request( + self, + request: RequestLike, + data_store: dict, + ) -> tuple[bool, dict | tuple[int, dict | None, bool]]: + """Process a web request and return the resulting action.""" + if self.is_validation_request(request): + payload = await request.json() + field = payload.get("field", "") + data_store.update(payload.get("fields", {})) + data_store[field] = payload.get("value", "") + step_index = self.step_index_for_field(field) + value, level, message = self.validate_field( + step_index, + field, + payload.get("value", ""), + data_store, + extra_fields=payload.get("fields"), + ) + return True, {"level": level or "", "message": message, "value": value} + + form_data = await request.form() + for key, value in form_data.items(): + if key not in {"next", "submit"}: + data_store[key] = value + index, messages, has_error = self.current_step(data_store) + return False, (index, messages, has_error) + + def render( + self, + index: int, + messages: dict | None = None, + data_store: dict | None = None, + csrf_token: str | None = None, + ) -> str: + """Return HTML for the given step, applying validation messages.""" + + step = self.steps[index] + error_fields: list[str] = [] + if messages: + for name, meta in messages.items(): + item = next((i for i in step.form.items if i.name == name), None) + if item: + item.message = meta.get("message") + item.classes_outer = [ + c + for c in item.classes_outer + if c not in {"info", "warning", "error", "ok"} + ] + if meta.get("level"): + item.classes_outer.append(meta["level"]) + if "value" in meta: + item.value = meta["value"] + if meta.get("level") == "error": + error_fields.append(item.label or item.name) + hidden = data_store or {} + if csrf_token: + hidden = {**hidden, "csrf_token": csrf_token} + disp = Display( + step.form, + template_dirs=self.template_dirs, + static_url=self.static_url, + ) + form_html = disp.get_html(hidden_fields=hidden) + tpl = self.env.get_template("multi_step/page.html") + return tpl.render( + form_html=form_html, + messages=messages or {}, + form_id=step.form.id, + error_fields=error_fields, + show_progress=self.show_progress, + step_index=index, + total_steps=self.num_steps, + ) + + def validate_field( + self, + index: int, + name: str, + value: str, + data_store: dict, + *, + extra_fields: Mapping[str, str] | None = None, + ) -> tuple[str, str | None, str]: + """Validate a field in the specified step.""" # pylint: disable=too-many-arguments # passthrough helper + return self.steps[index].validate_field( + name, + value, + data_store, + extra_fields=extra_fields, + ) + + def validate(self, index: int, data: dict, data_store: dict) -> tuple[dict, bool]: + """Validate all fields for the given step.""" + return self.steps[index].validate(data, data_store) + + @property + def num_steps(self) -> int: + """Return the number of configured steps.""" + return len(self.steps) + + def _fields_for_step(self, index: int) -> list[str]: + """Return names of non-button fields for the given step.""" + step = self.steps[index] + return [ + f["name"] + for f in step.config.get("fields", []) + if f.get("type") not in {"submit", "raw_html"} + ] + + def step_index_for_field(self, name: str) -> int: + """Return the index of the step containing ``name``.""" + for idx in range(len(self.steps)): + if name in self._fields_for_step(idx): + return idx + return 0 + + def current_step(self, data_store: dict) -> tuple[int, dict | None, bool]: + """Return step index, validation messages and failure state.""" + data = dict(data_store) + for idx in range(len(self.steps)): + names = self._fields_for_step(idx) + if not any(n in data for n in names): + return idx, None, False + subset = {n: data.get(n, "") for n in names} + messages, has_error = self.validate(idx, subset, data) + if has_error: + return idx, messages, True + data_store.update(data) + return len(self.steps), None, False diff --git a/pyformatic/static/pyformatic.css b/pyformatic/static/pyformatic.css new file mode 100644 index 0000000..629f7c2 --- /dev/null +++ b/pyformatic/static/pyformatic.css @@ -0,0 +1,44 @@ + +.pyformatic .error-banner { + background: #dc3545; + color: #fff; + padding: 0.5rem; + margin-bottom: 1rem; + border-radius: 0.25rem; +} + +.pyformatic .progress-bar { + margin-bottom: 1rem; +} + +.pyformatic div.ok input { + border: 2px solid #28a745; +} + +.pyformatic div.ok small { + color: #28a745; +} + +.pyformatic div.info input { + border: 2px solid #0d6efd; +} + +.pyformatic div.info small { + color: #0d6efd; +} + +.pyformatic div.warning input { + border: 2px solid #ffc107; +} + +.pyformatic div.warning small { + color: #ffc107; +} + +.pyformatic div.error input { + border: 2px solid #dc3545; +} + +.pyformatic div.error small { + color: #dc3545; +} diff --git a/pyformatic/static/pyformatic.js b/pyformatic/static/pyformatic.js new file mode 100644 index 0000000..7d3c2b4 --- /dev/null +++ b/pyformatic/static/pyformatic.js @@ -0,0 +1,54 @@ +function pyformaticValidateField(ev) { + const el = ev.target; + const form = el.closest('form'); + if (!form) { + return; + } + const payload = { field: el.name, value: el.value }; + if (el.dataset.include) { + payload.fields = {}; + el.dataset.include.split(',').forEach(name => { + const other = form.querySelector(`[name="${name}"]`); + if (other) { + if (other.type === 'checkbox' || other.type === 'radio') { + payload.fields[name] = other.checked ? other.value : ''; + } else { + payload.fields[name] = other.value; + } + } + }); + } + fetch(form.action, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }).then(r => r.json()).then(resp => { + el.value = resp.value; + const outer = el.closest('div'); + if (outer) { + outer.classList.remove('info', 'warning', 'error', 'ok'); + if (resp.level) { + outer.classList.add(resp.level); + } + const msg = outer.querySelector('.validation-msg'); + if (msg) { + msg.textContent = resp.message || ''; + msg.className = 'validation-msg ' + (resp.level || ''); + } + } + }); +} + +function pyformaticInit() { + document.querySelectorAll('form.pyformatic input').forEach(el => { + if (el.type !== 'submit') { + el.addEventListener('blur', pyformaticValidateField); + } + }); +} + +if (document.readyState !== 'loading') { + pyformaticInit(); +} else { + document.addEventListener('DOMContentLoaded', pyformaticInit); +} diff --git a/pyformatic/templates/multi_step/page.html b/pyformatic/templates/multi_step/page.html new file mode 100644 index 0000000..c0ef32d --- /dev/null +++ b/pyformatic/templates/multi_step/page.html @@ -0,0 +1,9 @@ +{% if error_fields %} + +{% endif %} +{% if show_progress %} +
Step {{ step_index + 1 }} of {{ total_steps }}
+{% endif %} + +{{ form_html|safe }} + diff --git a/pyformatic/templates/ui/button.html b/pyformatic/templates/ui/button.html new file mode 100644 index 0000000..aebc7e2 --- /dev/null +++ b/pyformatic/templates/ui/button.html @@ -0,0 +1 @@ + diff --git a/pyformatic/templates/ui/buttons_outer.html b/pyformatic/templates/ui/buttons_outer.html new file mode 100644 index 0000000..c0efa48 --- /dev/null +++ b/pyformatic/templates/ui/buttons_outer.html @@ -0,0 +1 @@ +

{{ buttons|safe }}

diff --git a/pyformatic/templates/ui/form.html b/pyformatic/templates/ui/form.html new file mode 100644 index 0000000..4a6b5ba --- /dev/null +++ b/pyformatic/templates/ui/form.html @@ -0,0 +1,4 @@ +
+{{ form_items|safe }} +{{ buttons|safe }} +
diff --git a/pyformatic/templates/ui/input.html b/pyformatic/templates/ui/input.html new file mode 100644 index 0000000..c9d0784 --- /dev/null +++ b/pyformatic/templates/ui/input.html @@ -0,0 +1,30 @@ +
+ {% if item_raw_html %} + {{ item_raw_html|safe }} + {% elif item_type == 'checkbox' or item_type == 'radio' %} + + {% elif item_type == 'switch' %} + + {% elif item_type == 'textarea' %} + + + {% elif item_type == 'select' %} + + + {% else %} + + + {% endif %} + {{ item_message }} + {% if item_help %}{{ item_help }}{% endif %} +
diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c9d62ad --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=65", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "pyformatic" +version = "0.1.1" +description = "Python form rendering module." +readme = "README.md" +license = {file = "LICENSE"} +requires-python = ">=3.10" +authors = [{name = "Unknown"}] +urls = {"Source" = "https://github.com/streaky/pyformatic"} +dependencies = ["jinja2", "pyyaml"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["pyformatic"] + +[tool.setuptools.package-data] +"pyformatic" = ["templates/**/*.html"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..eea2c18 --- /dev/null +++ b/pytest.ini @@ -0,0 +1 @@ +[pytest] diff --git a/requirements-demo.in b/requirements-demo.in new file mode 100644 index 0000000..4b16144 --- /dev/null +++ b/requirements-demo.in @@ -0,0 +1,8 @@ + +-r requirements.in + +fastapi +uvicorn +itsdangerous +python-multipart +httpx diff --git a/requirements-demo.txt b/requirements-demo.txt new file mode 100644 index 0000000..ab1bc28 --- /dev/null +++ b/requirements-demo.txt @@ -0,0 +1,60 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --all-extras --color requirements-demo.in +# +annotated-types==0.7.0 + # via pydantic +anyio==4.9.0 + # via + # httpx + # starlette +certifi==2025.6.15 + # via + # httpcore + # httpx +click==8.2.1 + # via uvicorn +fastapi==0.115.13 + # via -r requirements-demo.in +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via -r requirements-demo.in +idna==3.10 + # via + # anyio + # httpx +itsdangerous==2.2.0 + # via -r requirements-demo.in +jinja2==3.1.6 + # via -r /home/streaky/pyformatic-stealth/requirements.in +markupsafe==3.0.2 + # via jinja2 +pydantic==2.11.7 + # via fastapi +pydantic-core==2.33.2 + # via pydantic +python-multipart==0.0.20 + # via -r requirements-demo.in +pyyaml==6.0.2 + # via -r /home/streaky/pyformatic-stealth/requirements.in +sniffio==1.3.1 + # via anyio +starlette==0.46.2 + # via fastapi +typing-extensions==4.14.0 + # via + # fastapi + # pydantic + # pydantic-core + # typing-inspection +typing-inspection==0.4.1 + # via pydantic +uvicorn==0.34.3 + # via -r requirements-demo.in diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..1249a06 --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,9 @@ + +-r requirements.in +-r requirements-demo.in + +pytest +pytest-cov +pylint +playwright +pytest-playwright diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..e05c923 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,119 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --all-extras --color requirements-dev.in +# +annotated-types==0.7.0 + # via pydantic +anyio==4.9.0 + # via + # httpx + # starlette +astroid==3.3.10 + # via pylint +certifi==2025.6.15 + # via + # httpcore + # httpx + # requests +charset-normalizer==3.4.2 + # via requests +click==8.2.1 + # via uvicorn +coverage[toml]==7.9.1 + # via pytest-cov +dill==0.4.0 + # via pylint +fastapi==0.115.13 + # via -r /home/streaky/pyformatic-stealth/requirements-demo.in +greenlet==3.2.3 + # via playwright +h11==0.16.0 + # via + # httpcore + # uvicorn +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via -r /home/streaky/pyformatic-stealth/requirements-demo.in +idna==3.10 + # via + # anyio + # httpx + # requests +iniconfig==2.1.0 + # via pytest +isort==6.0.1 + # via pylint +itsdangerous==2.2.0 + # via -r /home/streaky/pyformatic-stealth/requirements-demo.in +jinja2==3.1.6 + # via -r /home/streaky/pyformatic-stealth/requirements.in +markupsafe==3.0.2 + # via jinja2 +mccabe==0.7.0 + # via pylint +packaging==25.0 + # via pytest +platformdirs==4.3.8 + # via pylint +playwright==1.52.0 + # via + # -r requirements-dev.in + # pytest-playwright +pluggy==1.6.0 + # via + # pytest + # pytest-cov +pydantic==2.11.7 + # via fastapi +pydantic-core==2.33.2 + # via pydantic +pyee==13.0.0 + # via playwright +pygments==2.19.2 + # via pytest +pylint==3.3.7 + # via -r requirements-dev.in +pytest==8.4.1 + # via + # -r requirements-dev.in + # pytest-base-url + # pytest-cov + # pytest-playwright +pytest-base-url==2.1.0 + # via pytest-playwright +pytest-cov==6.2.1 + # via -r requirements-dev.in +pytest-playwright==0.7.0 + # via -r requirements-dev.in +python-multipart==0.0.20 + # via -r /home/streaky/pyformatic-stealth/requirements-demo.in +python-slugify==8.0.4 + # via pytest-playwright +pyyaml==6.0.2 + # via -r /home/streaky/pyformatic-stealth/requirements.in +requests==2.32.4 + # via pytest-base-url +sniffio==1.3.1 + # via anyio +starlette==0.46.2 + # via fastapi +text-unidecode==1.3 + # via python-slugify +tomlkit==0.13.3 + # via pylint +typing-extensions==4.14.0 + # via + # fastapi + # pydantic + # pydantic-core + # pyee + # typing-inspection +typing-inspection==0.4.1 + # via pydantic +urllib3==2.5.0 + # via requests +uvicorn==0.34.3 + # via -r /home/streaky/pyformatic-stealth/requirements-demo.in diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..c326e9e --- /dev/null +++ b/requirements.in @@ -0,0 +1,2 @@ +jinja2 +PyYAML diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dc5a1e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --all-extras --color requirements.in +# +jinja2==3.1.6 + # via -r requirements.in +markupsafe==3.0.2 + # via jinja2 +pyyaml==6.0.2 + # via -r requirements.in diff --git a/tests/e2e/test_home.py b/tests/e2e/test_home.py new file mode 100644 index 0000000..3b41720 --- /dev/null +++ b/tests/e2e/test_home.py @@ -0,0 +1,15 @@ +"""Playwright check for the demo home page.""" + +import os +from playwright.sync_api import sync_playwright + +BASE_URL = os.environ.get("DEMO_BASE_URL", "http://demo:8000") + +def test_homepage(): + """Ensure the home page loads correctly.""" + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(f"{BASE_URL}/") + assert "Home" in page.content() + browser.close() diff --git a/tests/e2e/test_signup.py b/tests/e2e/test_signup.py new file mode 100644 index 0000000..f352231 --- /dev/null +++ b/tests/e2e/test_signup.py @@ -0,0 +1,35 @@ +"""Playwright end-to-end test for the multi-step signup demo.""" + +# pylint: disable=R0801 # shared setup code with other e2e tests + +import os +from playwright.sync_api import sync_playwright + +BASE_URL = os.environ.get("DEMO_BASE_URL", "http://demo:8000") + + +def test_signup_flow(): + """Complete the signup form across all steps.""" + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(f"{BASE_URL}/signup") + + page.fill("input[name=first_name]", "John") + page.fill("input[name=last_name]", "Doe") + page.fill("input[name=username]", "john") + page.fill("input[name=password]", "secret12") + page.fill("input[name=confirm_password]", "secret12") + page.fill("input[name=promo]", "PROMO") + page.click("text=Next") + + page.fill("input[name=email]", "john@example.com") + page.fill("input[name=phone]", "123456") + page.click("text=Next") + + page.check("input[name=terms]") + page.click("text=Submit") + page.wait_for_load_state("networkidle") + + assert "Done" in page.content() + browser.close() diff --git a/tests/e2e/test_validation.py b/tests/e2e/test_validation.py new file mode 100644 index 0000000..c48badc --- /dev/null +++ b/tests/e2e/test_validation.py @@ -0,0 +1,55 @@ +"""Playwright tests for client-side field validation.""" + +# pylint: disable=R0801 # shared setup code with other e2e tests + +import os +from playwright.sync_api import sync_playwright + +BASE_URL = os.environ.get("DEMO_BASE_URL", "http://demo:8000") + + +def test_username_ajax_validation(): + """Validate username field via AJAX.""" + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(f"{BASE_URL}/signup") + username = page.locator("input[name=username]") + username.fill(" john ") + with page.expect_request("**/signup") as req_info: + username.blur() + req = req_info.value + assert req.method == "POST" + assert req.post_data_json == {"field": "username", "value": " john "} + resp = req.response() + assert resp.status == 200 + data = resp.json() + assert data["level"] == "info" + assert data["message"] == "I adjusted your username" + assert data["value"] == "john" + browser.close() + + +def test_confirm_password_ajax_validation(): + """Validate confirm password field via AJAX.""" + with sync_playwright() as p: + browser = p.chromium.launch() + page = browser.new_page() + page.goto(f"{BASE_URL}/signup") + page.locator("input[name=password]").fill("secret12") + confirm = page.locator("input[name=confirm_password]") + confirm.fill("bad") + with page.expect_request("**/signup") as req_info: + confirm.blur() + req = req_info.value + assert req.post_data_json == { + "field": "confirm_password", + "value": "bad", + "fields": {"password": "secret12"}, + } + resp = req.response() + assert resp.status == 200 + data = resp.json() + assert data["level"] == "error" + assert data["message"] == "Passwords do not match" + browser.close() diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..ee73e85 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,25 @@ +# Helpers for test data used across modules. + +"""Utility functions returning common signup form data.""" + + +SIGNUP_BASE_DATA = { + "username": "john", + "password": "secret12", + "confirm_password": "secret12", +} + + +def step_one_data(): + """Return data for the first signup step.""" + return {**SIGNUP_BASE_DATA, "next": "Next"} + + +def step_two_data(): + """Return data for the second signup step.""" + return {**step_one_data(), "email": "john@example.com"} + + +def final_step_data(): + """Return data for the final signup submission.""" + return {**step_two_data(), "terms": "on", "submit": "Submit"} diff --git a/tests/test_csrf.py b/tests/test_csrf.py new file mode 100644 index 0000000..66053d9 --- /dev/null +++ b/tests/test_csrf.py @@ -0,0 +1,17 @@ +"""Tests for CSRF token helpers.""" + +import pyformatic + + +def test_ensure_and_validate_token(): + """Valid token roundtrip succeeds.""" + session = {} + token = pyformatic.ensure_csrf_token(session) + assert pyformatic.validate_csrf_token(session, token) + + +def test_invalid_token_fails(): + """Validation fails with a mismatched token.""" + session = {} + pyformatic.ensure_csrf_token(session) + assert not pyformatic.validate_csrf_token(session, "bad") diff --git a/tests/test_elements.py b/tests/test_elements.py new file mode 100644 index 0000000..fb6a0c1 --- /dev/null +++ b/tests/test_elements.py @@ -0,0 +1,22 @@ +"""Unit tests for form element behaviours.""" + +import pyformatic + + +def test_base_element_reset_and_id(): + """Reset should restore defaults and update the derived ID.""" + element = pyformatic.TextInput(name="foo", label="Foo", element_id="bar") + element.value = "data" + element.classes_outer.extend(["error", "info"]) + element.classes_input.append("x") + element.message = "Bad" + assert element.id == "bar" + + element.reset() + assert element.value == "" + assert element.classes_outer == [] + assert element.classes_input == [] + assert element.message is None + + element.element_id = None + assert element.id == "foo" diff --git a/tests/test_flow_runner.py b/tests/test_flow_runner.py new file mode 100644 index 0000000..635a4b6 --- /dev/null +++ b/tests/test_flow_runner.py @@ -0,0 +1,156 @@ +"""Integration tests for the form flow runner.""" + +from pathlib import Path +import asyncio + +import pyformatic +from tests import helpers + + +class DummyRequest: + """Minimal async request-like object used in tests.""" + + def __init__( + self, + method="GET", + headers=None, + json_data=None, + form_data=None, + session=None, + ): # pylint: disable=too-many-arguments,too-many-positional-arguments # test helper + self.method = method + self.headers = headers or {} + self._json_data = json_data or {} + self._form_data = form_data or {} + self.session = session + + async def json(self): + """Return stored JSON data.""" + return self._json_data + + async def form(self): + """Return stored form data.""" + return self._form_data + + +def test_run_form_flow_complete(): + """Complete flow returns final data after submission.""" + yaml_file = Path(__file__).parent.parent / "demo" / "user_login.yaml" + form_flow = pyformatic.FormFlow.from_yaml(str(yaml_file), action="/login") + # initial GET + req = DummyRequest() + state, body = asyncio.run(pyformatic.run_form_flow(form_flow, req)) + assert state == "form" + assert "username" in body + + # submit valid data + req = DummyRequest( + method="POST", + headers={"content-type": "application/x-www-form-urlencoded"}, + form_data={"username": "john", "password": "secret12", "submit": "Submit"}, + ) + state, data = asyncio.run(pyformatic.run_form_flow(form_flow, req)) + assert state == "complete" + assert data["username"] == "john" + assert data["password"] == "secret12" + + +def test_run_form_flow_error_banner(): + """Form flow adds banner when validation fails.""" + yaml_file = Path(__file__).parent.parent / "demo" / "user_login.yaml" + form_flow = pyformatic.FormFlow.from_yaml(str(yaml_file), action="/login") + + req = DummyRequest( + method="POST", + headers={"content-type": "application/x-www-form-urlencoded"}, + form_data={"username": "", "password": "123", "submit": "Submit"}, + ) + state, body = asyncio.run(pyformatic.run_form_flow(form_flow, req)) + assert state == "form" + assert "error-banner" in body + assert "Please check Username" in body + assert "Username required" in body + + +def test_run_form_flow_ajax_validation(): + """AJAX validation requests return structured payloads.""" + yaml_file = Path(__file__).parent.parent / "demo" / "user_login.yaml" + form_flow = pyformatic.FormFlow.from_yaml(str(yaml_file), action="/login") + + req = DummyRequest( + method="POST", + headers={"content-type": "application/json"}, + json_data={"field": "username", "value": ""}, + ) + state, payload = asyncio.run(pyformatic.run_form_flow(form_flow, req)) + assert state == "validation" + assert payload["level"] == "error" + assert payload["message"] == "Username required" + + +def test_run_form_flow_multistep_progression(): + """Flow progresses through steps and stores state.""" + yaml_file = Path(__file__).parent.parent / "demo" / "user_signup.yaml" + form_flow = pyformatic.FormFlow.from_yaml(str(yaml_file), action="/signup") + + req = DummyRequest() + state, body = asyncio.run(pyformatic.run_form_flow(form_flow, req)) + assert state == "form" + assert "step_one" in body + + req = DummyRequest( + method="POST", + headers={"content-type": "application/x-www-form-urlencoded"}, + form_data=helpers.step_one_data(), + ) + state, body = asyncio.run(pyformatic.run_form_flow(form_flow, req)) + assert state == "form" + assert "step_two" in body + + req = DummyRequest( + method="POST", + headers={"content-type": "application/x-www-form-urlencoded"}, + form_data=helpers.step_two_data(), + ) + state, body = asyncio.run(pyformatic.run_form_flow(form_flow, req)) + assert state == "form" + assert "step_three" in body + + req = DummyRequest( + method="POST", + headers={"content-type": "application/x-www-form-urlencoded"}, + form_data=helpers.final_step_data(), + ) + state, data = asyncio.run(pyformatic.run_form_flow(form_flow, req)) + assert state == "complete" + assert data["username"] == "john" + assert data["email"] == "john@example.com" + + +def test_run_form_flow_csrf_token_required(): + """Flow includes and checks CSRF tokens when session is present.""" + yaml_file = Path(__file__).parent.parent / "demo" / "user_login.yaml" + form_flow = pyformatic.FormFlow.from_yaml(str(yaml_file), action="/login") + + session = {} + req = DummyRequest(session=session) + state, body = asyncio.run(pyformatic.run_form_flow(form_flow, req)) + assert state == "form" + token = session.get("_pyformatic_csrf_token") + assert token in body + + form_data = { + "username": "john", + "password": "secret12", + "submit": "Submit", + "csrf_token": token, + } + req = DummyRequest( + method="POST", + headers={"content-type": "application/x-www-form-urlencoded"}, + form_data=form_data, + session=session, + ) + state, data = asyncio.run(pyformatic.run_form_flow(form_flow, req)) + assert state == "complete" + assert data["username"] == "john" diff --git a/tests/test_form.py b/tests/test_form.py new file mode 100644 index 0000000..213fb9e --- /dev/null +++ b/tests/test_form.py @@ -0,0 +1,71 @@ +"""Unit tests for basic form rendering.""" + +import pyformatic + +def test_render_form_basic(): + """Form renders with a text input and button.""" + form = pyformatic.Form("login", action="/submit") + form.add_item(pyformatic.TextInput(name="username", label="Username")) + form.add_button(pyformatic.Button(name="submit", label="Submit")) + display = pyformatic.Display(form) + html = display.get_html() + assert "OVERRIDE {{ item_name }}") + form = pyformatic.Form("f", action="/submit") + form.add_item(pyformatic.TextInput(name="foo", label="Foo")) + display = pyformatic.Display(form, template_dirs=[str(tmp_path)]) + html = display.get_html() + assert "OVERRIDE foo" in html diff --git a/tests/test_inline_validators.py b/tests/test_inline_validators.py new file mode 100644 index 0000000..d888a24 --- /dev/null +++ b/tests/test_inline_validators.py @@ -0,0 +1,75 @@ +"""Tests for validators defined inline in configuration.""" + +import pyformatic + + +def test_inline_validator_string(): + """Inline validator from string works as expected.""" + cfg = { + "name": "step", + "fields": [{"name": "foo"}], + "validators": { + "foo": "if not value:\n raise ValidationError('missing')\nreturn value.upper()", + }, + } + step = pyformatic.formflow.Step(cfg, None, action="/") + _val, level, msg = step.validate_field("foo", "", {}) + assert level == "error" + assert msg == "missing" + val, level, msg = step.validate_field("foo", "bar", {}) + assert val == "BAR" + assert level == "ok" + assert msg == "" + + +def test_inline_validator_callable(): + """Inline validator from callable is invoked.""" + def check(value, data): + if value != data.get("expect"): + raise pyformatic.ValidationError("bad") + return value + + cfg = { + "name": "step", + "fields": [{"name": "foo", "validator": check}], + } + step = pyformatic.formflow.Step(cfg, None, action="/") + val, level, msg = step.validate_field("foo", "ok", {"expect": "ok"}) + assert val == "ok" + assert level == "ok" + _val, level, msg = step.validate_field("foo", "no", {"expect": "ok"}) + assert level == "error" + assert msg == "bad" + + +def test_validate_field_with_extra_data(): + """Extra data passed to validators is respected.""" + cfg = { + "name": "step", + "fields": [ + {"name": "password"}, + {"name": "confirm"}, + ], + "validators": { + "confirm": ( + "if value != data_store.get('password'):\n" + " raise ValidationError('mismatch')" + ), + }, + } + step = pyformatic.formflow.Step(cfg, None, action="/") + _val, level, msg = step.validate_field( + "confirm", + "secret", + {}, + extra_fields={"password": "secret"}, + ) + assert level == "ok" + _val, level, msg = step.validate_field( + "confirm", + "wrong", + {}, + extra_fields={"password": "secret"}, + ) + assert level == "error" + assert msg == "mismatch" diff --git a/tests/test_multistep_e2e.py b/tests/test_multistep_e2e.py new file mode 100644 index 0000000..d938e83 --- /dev/null +++ b/tests/test_multistep_e2e.py @@ -0,0 +1,35 @@ +"""End-to-end tests for the multi-step signup flow.""" + +from fastapi.testclient import TestClient +from demo.main import app +from tests import helpers + +client = TestClient(app) + + +def test_multistep_flow(): + """Walk through the full signup process.""" + resp = client.get("/signup") + assert resp.status_code == 200 + assert "step_one" in resp.text + + resp = client.post( + "/signup", + data=helpers.step_one_data(), + ) + assert resp.status_code == 200 + assert "step_two" in resp.text + + resp = client.post( + "/signup", + data=helpers.step_two_data(), + ) + assert resp.status_code == 200 + assert "step_three" in resp.text + + resp = client.post( + "/signup", + data=helpers.final_step_data(), + ) + assert resp.status_code == 200 + assert "Done" in resp.text diff --git a/tests/test_progress.py b/tests/test_progress.py new file mode 100644 index 0000000..37a0de9 --- /dev/null +++ b/tests/test_progress.py @@ -0,0 +1,43 @@ +"""Tests for progress bar display in form flows.""" + +import yaml +import pyformatic +from pyformatic.formflow import Step + + +def _make_simple_step(action: str) -> Step: + """Return a single-step flow configuration.""" + cfg = { + "name": "step", + "fields": [{"name": "username", "type": "text", "label": "User"}], + } + return Step(cfg, "demo.myapp.forms.user_signup", action, is_last=True) + + +def test_progress_disabled_by_default(): + """Validate no progress bar is rendered when not configured.""" + step = _make_simple_step("/test") + flow = pyformatic.FormFlow([step]) + html = flow.render(0) + assert "progress-bar" not in html + + +def test_progress_enabled_via_yaml(tmp_path): + """Validate progress bar rendering from YAML configuration.""" + cfg = { + "module": "demo.myapp.forms.user_signup", + "show_progress": True, + "steps": [ + { + "name": "step", + "fields": [ + {"name": "username", "type": "text", "label": "User"}, + ], + } + ], + } + yaml_file = tmp_path / "cfg.yaml" + yaml_file.write_text(yaml.dump(cfg)) + flow = pyformatic.FormFlow.from_yaml(str(yaml_file), action="/test") + html = flow.render(0) + assert "progress-bar" in html diff --git a/tests/test_validation_messages.py b/tests/test_validation_messages.py new file mode 100644 index 0000000..08bd8bd --- /dev/null +++ b/tests/test_validation_messages.py @@ -0,0 +1,45 @@ +"""Tests for different validation message levels.""" + +import pyformatic + + +def test_warning_message_display(): + """Warnings are rendered correctly in the HTML.""" + cfg = { + "name": "step", + "fields": [{"name": "foo", "label": "Foo"}], + "validators": { + "foo": "if value == 'bad':\n raise ValidationWarning('Warn!')\nreturn value" + }, + } + step = pyformatic.formflow.Step(cfg, None, action="/") + messages, has_error = step.validate({"foo": "bad"}, {}) + assert messages == {"foo": {"level": "warning", "message": "Warn!", "value": "bad"}} + assert has_error is False + item = step.form.items[0] + assert item.message == "Warn!" + assert "warning" in item.classes_outer + html = pyformatic.Display(step.form).get_html() + assert "Warn!" in html + assert "class=\"warning\"" in html + + +def test_error_message_display(): + """Errors are rendered correctly in the HTML.""" + cfg = { + "name": "step", + "fields": [{"name": "foo", "label": "Foo"}], + "validators": { + "foo": "if value == 'bad':\n raise ValidationError('Bad!')\nreturn value" + }, + } + step = pyformatic.formflow.Step(cfg, None, action="/") + messages, has_error = step.validate({"foo": "bad"}, {}) + assert messages == {"foo": {"level": "error", "message": "Bad!", "value": "bad"}} + assert has_error is True + item = step.form.items[0] + assert item.message == "Bad!" + assert "error" in item.classes_outer + html = pyformatic.Display(step.form).get_html() + assert "Bad!" in html + assert "class=\"error\"" in html diff --git a/tests/test_validator_context.py b/tests/test_validator_context.py new file mode 100644 index 0000000..2928a10 --- /dev/null +++ b/tests/test_validator_context.py @@ -0,0 +1,48 @@ +"""Tests for populating validator instances with extra context.""" + +import sys +from types import ModuleType + +import pyformatic +from pyformatic import ValidationError + + +def test_validator_context_available(): + """Inline context is set on validator instances.""" + module_base = "dummyapp" + step_name = "step" + mod_name = f"{module_base}.validators.{step_name}" + + base_mod = ModuleType(module_base) + validators_pkg = ModuleType(f"{module_base}.validators") + step_mod = ModuleType(mod_name) + + class Validator: # pylint: disable=too-few-public-methods # minimal stub for tests + """Simple validator used for testing.""" + + flag: bool + prefix: str + + def sample(self, value: str) -> str: + """Return the value prefixed if flag set, else raise error.""" + if not self.flag: + raise ValidationError('flag missing') + return self.prefix + value + + step_mod.Validator = Validator + + sys.modules[module_base] = base_mod + sys.modules[f"{module_base}.validators"] = validators_pkg + sys.modules[mod_name] = step_mod + + cfg = {"name": step_name, "fields": [{"name": "sample"}]} + step = pyformatic.formflow.Step( + cfg, + module_base, + action="/", + validator_context={"flag": True, "prefix": "ok-"}, + ) + val, level, msg = step.validate_field("sample", "data", {}) + assert val == "ok-data" + assert level == "ok" + assert msg == ""