fyro 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. fyro-0.1.0/LICENSE +21 -0
  2. fyro-0.1.0/PKG-INFO +101 -0
  3. fyro-0.1.0/README.md +64 -0
  4. fyro-0.1.0/pyproject.toml +78 -0
  5. fyro-0.1.0/pyproject.toml.orig +70 -0
  6. fyro-0.1.0/src/fyro/__init__.py +5 -0
  7. fyro-0.1.0/src/fyro/admin/__init__.py +50 -0
  8. fyro-0.1.0/src/fyro/admin/forms.py +137 -0
  9. fyro-0.1.0/src/fyro/admin/routes.py +435 -0
  10. fyro-0.1.0/src/fyro/admin/static/admin.css +147 -0
  11. fyro-0.1.0/src/fyro/admin/static/htmx.LICENSE +16 -0
  12. fyro-0.1.0/src/fyro/admin/static/htmx.min.js +1 -0
  13. fyro-0.1.0/src/fyro/admin/static/jetbrains-mono.LICENSE +97 -0
  14. fyro-0.1.0/src/fyro/admin/static/jetbrains-mono.woff2 +0 -0
  15. fyro-0.1.0/src/fyro/admin/templates/base.html +33 -0
  16. fyro-0.1.0/src/fyro/admin/templates/confirm_delete.html +16 -0
  17. fyro-0.1.0/src/fyro/admin/templates/forbidden.html +9 -0
  18. fyro-0.1.0/src/fyro/admin/templates/form.html +37 -0
  19. fyro-0.1.0/src/fyro/admin/templates/index.html +12 -0
  20. fyro-0.1.0/src/fyro/admin/templates/list.html +64 -0
  21. fyro-0.1.0/src/fyro/admin/templates/login.html +12 -0
  22. fyro-0.1.0/src/fyro/app.py +102 -0
  23. fyro-0.1.0/src/fyro/apps.py +100 -0
  24. fyro-0.1.0/src/fyro/auth/__init__.py +6 -0
  25. fyro-0.1.0/src/fyro/auth/models.py +35 -0
  26. fyro-0.1.0/src/fyro/auth/passwords.py +20 -0
  27. fyro-0.1.0/src/fyro/auth/routes.py +45 -0
  28. fyro-0.1.0/src/fyro/auth/sessions.py +130 -0
  29. fyro-0.1.0/src/fyro/cli.py +395 -0
  30. fyro-0.1.0/src/fyro/conf.py +121 -0
  31. fyro-0.1.0/src/fyro/crud.py +193 -0
  32. fyro-0.1.0/src/fyro/db/__init__.py +12 -0
  33. fyro-0.1.0/src/fyro/db/engine.py +47 -0
  34. fyro-0.1.0/src/fyro/db/session.py +81 -0
  35. fyro-0.1.0/src/fyro/migrations/__init__.py +279 -0
  36. fyro-0.1.0/src/fyro/migrations/template/env.py +34 -0
  37. fyro-0.1.0/src/fyro/migrations/template/script.py.mako +28 -0
  38. fyro-0.1.0/src/fyro/models/__init__.py +4 -0
  39. fyro-0.1.0/src/fyro/models/base.py +281 -0
  40. fyro-0.1.0/src/fyro/models/fields.py +62 -0
  41. fyro-0.1.0/src/fyro/models/manager.py +128 -0
  42. fyro-0.1.0/src/fyro/models/schemas.py +57 -0
fyro-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chinmay Talnikar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
fyro-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: fyro
3
+ Version: 0.1.0
4
+ Summary: A Django-feeling web framework on top of FastAPI: one model class gives you a table, an API, migrations, auth and an admin.
5
+ Keywords: fastapi,django,web framework,orm,admin,postgres,sqlalchemy
6
+ Author: Chinmay Talnikar
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: FastAPI
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Database
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
19
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
20
+ Requires-Dist: alembic>=1.20.1
21
+ Requires-Dist: asyncpg>=0.32.0
22
+ Requires-Dist: fastapi>=0.159.1
23
+ Requires-Dist: jinja2>=3.1.6
24
+ Requires-Dist: pwdlib[argon2]>=0.3.1
25
+ Requires-Dist: pydantic>=2.14.1
26
+ Requires-Dist: pydantic-settings>=2.16.1
27
+ Requires-Dist: sqlalchemy[asyncio]>=2.1.6
28
+ Requires-Dist: typer>=0.27.3
29
+ Requires-Dist: uvicorn>=0.54.0
30
+ Requires-Python: >=3.12
31
+ Project-URL: Homepage, https://github.com/Carnage725/fyro
32
+ Project-URL: Documentation, https://carnage725.github.io/fyro/
33
+ Project-URL: Source, https://github.com/Carnage725/fyro
34
+ Project-URL: Issues, https://github.com/Carnage725/fyro/issues
35
+ Project-URL: Changelog, https://github.com/Carnage725/fyro/blob/main/CHANGELOG.md
36
+ Description-Content-Type: text/markdown
37
+
38
+ # fyro
39
+
40
+ **Fast, Yet Ridiculously Opinionated.** A web framework on top of FastAPI that feels like Django.
41
+
42
+ FastAPI is the engine. fyro adds what a project needs around it, with one obvious place for everything: one model class gives you a Postgres table, `Create`/`Read`/`Update` schemas, a REST API with docs, migrations and an admin page. Database sessions, a command line, users, login and permissions are built in.
43
+
44
+ ```python
45
+ # blog/models.py
46
+ from fyro import Field, Model
47
+
48
+
49
+ class Post(Model):
50
+ title: str = Field(max_length=200)
51
+ body: str
52
+ secret: str = Field(write_only=True)
53
+ published: bool = False
54
+ ```
55
+
56
+ That class is the table `blog_post`, the API at `/api/blog/post` (list with filters, search, ordering and paging; get; create; patch; delete) and the admin page at `/admin/blog/post`. Queries need no session code:
57
+
58
+ ```python
59
+ posts = await Post.objects.filter(published=True).order_by("-id").limit(10).all()
60
+ ```
61
+
62
+ ## Quickstart
63
+
64
+ Needs Python 3.12+, Docker (for Postgres) and [uv](https://docs.astral.sh/uv/) or pip.
65
+
66
+ ```bash
67
+ uv venv && source .venv/bin/activate
68
+ uv pip install fyro
69
+
70
+ fyro new mysite && cd mysite
71
+ docker compose up -d --wait
72
+ python manage.py startapp blog
73
+ # write blog/models.py, then in mysite/settings.py:
74
+ # installed_apps: list[str] = ["fyro.auth", "blog"]
75
+ python manage.py makemigrations
76
+ python manage.py migrate
77
+ python manage.py createsuperuser
78
+ python manage.py runserver # http://127.0.0.1:8000/docs and /admin
79
+ ```
80
+
81
+ The full walkthrough and the guides are in [`docs/`](docs/index.md): settings, models, querying, the automatic API, migrations, the command line, auth and permissions, the admin, and security notes.
82
+
83
+ ## Status
84
+
85
+ **v0.1, alpha.** PostgreSQL only. Relations between models come in v0.2; see [what v0.1 does not do](docs/limitations.md). The plan and the history of every design decision are in [`PLAN.md`](PLAN.md) and [`BUILD_PROMPT.md`](BUILD_PROMPT.md).
86
+
87
+ ## Development
88
+
89
+ ```bash
90
+ uv sync # install dependencies
91
+ docker compose up -d # Postgres 16 on localhost:5432 (user, password, db: fyro)
92
+ uv run pytest # tests, in random order (pytest-randomly)
93
+ uv run ruff check # lint
94
+ uv run --only-group docs zensical serve # docs at http://127.0.0.1:8000
95
+ ```
96
+
97
+ How tests are written and attacked is described in [`test.md`](test.md).
98
+
99
+ ## License
100
+
101
+ [MIT](LICENSE). The admin bundles [htmx](https://htmx.org) (0BSD) and [JetBrains Mono](https://www.jetbrains.com/lp/mono/) (OFL-1.1); their licenses are in `src/fyro/admin/static/`.
fyro-0.1.0/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # fyro
2
+
3
+ **Fast, Yet Ridiculously Opinionated.** A web framework on top of FastAPI that feels like Django.
4
+
5
+ FastAPI is the engine. fyro adds what a project needs around it, with one obvious place for everything: one model class gives you a Postgres table, `Create`/`Read`/`Update` schemas, a REST API with docs, migrations and an admin page. Database sessions, a command line, users, login and permissions are built in.
6
+
7
+ ```python
8
+ # blog/models.py
9
+ from fyro import Field, Model
10
+
11
+
12
+ class Post(Model):
13
+ title: str = Field(max_length=200)
14
+ body: str
15
+ secret: str = Field(write_only=True)
16
+ published: bool = False
17
+ ```
18
+
19
+ That class is the table `blog_post`, the API at `/api/blog/post` (list with filters, search, ordering and paging; get; create; patch; delete) and the admin page at `/admin/blog/post`. Queries need no session code:
20
+
21
+ ```python
22
+ posts = await Post.objects.filter(published=True).order_by("-id").limit(10).all()
23
+ ```
24
+
25
+ ## Quickstart
26
+
27
+ Needs Python 3.12+, Docker (for Postgres) and [uv](https://docs.astral.sh/uv/) or pip.
28
+
29
+ ```bash
30
+ uv venv && source .venv/bin/activate
31
+ uv pip install fyro
32
+
33
+ fyro new mysite && cd mysite
34
+ docker compose up -d --wait
35
+ python manage.py startapp blog
36
+ # write blog/models.py, then in mysite/settings.py:
37
+ # installed_apps: list[str] = ["fyro.auth", "blog"]
38
+ python manage.py makemigrations
39
+ python manage.py migrate
40
+ python manage.py createsuperuser
41
+ python manage.py runserver # http://127.0.0.1:8000/docs and /admin
42
+ ```
43
+
44
+ The full walkthrough and the guides are in [`docs/`](docs/index.md): settings, models, querying, the automatic API, migrations, the command line, auth and permissions, the admin, and security notes.
45
+
46
+ ## Status
47
+
48
+ **v0.1, alpha.** PostgreSQL only. Relations between models come in v0.2; see [what v0.1 does not do](docs/limitations.md). The plan and the history of every design decision are in [`PLAN.md`](PLAN.md) and [`BUILD_PROMPT.md`](BUILD_PROMPT.md).
49
+
50
+ ## Development
51
+
52
+ ```bash
53
+ uv sync # install dependencies
54
+ docker compose up -d # Postgres 16 on localhost:5432 (user, password, db: fyro)
55
+ uv run pytest # tests, in random order (pytest-randomly)
56
+ uv run ruff check # lint
57
+ uv run --only-group docs zensical serve # docs at http://127.0.0.1:8000
58
+ ```
59
+
60
+ How tests are written and attacked is described in [`test.md`](test.md).
61
+
62
+ ## License
63
+
64
+ [MIT](LICENSE). The admin bundles [htmx](https://htmx.org) (0BSD) and [JetBrains Mono](https://www.jetbrains.com/lp/mono/) (OFL-1.1); their licenses are in `src/fyro/admin/static/`.
@@ -0,0 +1,78 @@
1
+ [project]
2
+ name = "fyro"
3
+ version = "0.1.0"
4
+ description = "A Django-feeling web framework on top of FastAPI: one model class gives you a table, an API, migrations, auth and an admin."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ keywords = [
10
+ "fastapi",
11
+ "django",
12
+ "web framework",
13
+ "orm",
14
+ "admin",
15
+ "postgres",
16
+ "sqlalchemy",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Framework :: FastAPI",
21
+ "Intended Audience :: Developers",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Programming Language :: Python :: 3.14",
27
+ "Topic :: Database",
28
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
29
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
30
+ ]
31
+ dependencies = [
32
+ "alembic>=1.20.1",
33
+ "asyncpg>=0.32.0",
34
+ "fastapi>=0.159.1",
35
+ "jinja2>=3.1.6",
36
+ "pwdlib[argon2]>=0.3.1",
37
+ "pydantic>=2.14.1",
38
+ "pydantic-settings>=2.16.1",
39
+ "sqlalchemy[asyncio]>=2.1.6",
40
+ "typer>=0.27.3",
41
+ "uvicorn>=0.54.0",
42
+ ]
43
+
44
+ [[project.authors]]
45
+ name = "Chinmay Talnikar"
46
+
47
+ [project.urls]
48
+ Homepage = "https://github.com/Carnage725/fyro"
49
+ Documentation = "https://carnage725.github.io/fyro/"
50
+ Source = "https://github.com/Carnage725/fyro"
51
+ Issues = "https://github.com/Carnage725/fyro/issues"
52
+ Changelog = "https://github.com/Carnage725/fyro/blob/main/CHANGELOG.md"
53
+
54
+ [project.scripts]
55
+ fyro = "fyro.cli:main"
56
+
57
+ [build-system]
58
+ requires = ["uv_build>=0.10.11,<0.11"]
59
+ build-backend = "uv_build"
60
+
61
+ [tool.pytest.ini_options]
62
+ testpaths = ["tests"]
63
+ asyncio_mode = "auto"
64
+ asyncio_default_fixture_loop_scope = "function"
65
+ markers = ["slow: end-to-end tests that start real processes"]
66
+ timeout = 120
67
+
68
+ [dependency-groups]
69
+ dev = [
70
+ "httpx>=0.28.1",
71
+ "hypothesis>=6.170.5",
72
+ "pytest>=9.1.1",
73
+ "pytest-asyncio>=1.4.0",
74
+ "pytest-randomly>=5.0.0",
75
+ "pytest-timeout>=2.4.0",
76
+ "ruff>=0.17.1",
77
+ ]
78
+ docs = ["zensical==0.0.69"]
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "fyro"
3
+ version = "0.1.0"
4
+ description = "A Django-feeling web framework on top of FastAPI: one model class gives you a table, an API, migrations, auth and an admin."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Chinmay Talnikar" }]
9
+ requires-python = ">=3.12"
10
+ keywords = ["fastapi", "django", "web framework", "orm", "admin", "postgres", "sqlalchemy"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Framework :: FastAPI",
14
+ "Intended Audience :: Developers",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Topic :: Database",
21
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
22
+ "Topic :: Software Development :: Libraries :: Application Frameworks",
23
+ ]
24
+ dependencies = [
25
+ "alembic>=1.20.1",
26
+ "asyncpg>=0.32.0",
27
+ "fastapi>=0.159.1",
28
+ "jinja2>=3.1.6",
29
+ "pwdlib[argon2]>=0.3.1",
30
+ "pydantic>=2.14.1",
31
+ "pydantic-settings>=2.16.1",
32
+ "sqlalchemy[asyncio]>=2.1.6",
33
+ "typer>=0.27.3",
34
+ "uvicorn>=0.54.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/Carnage725/fyro"
39
+ Documentation = "https://carnage725.github.io/fyro/"
40
+ Source = "https://github.com/Carnage725/fyro"
41
+ Issues = "https://github.com/Carnage725/fyro/issues"
42
+ Changelog = "https://github.com/Carnage725/fyro/blob/main/CHANGELOG.md"
43
+
44
+ [project.scripts]
45
+ fyro = "fyro.cli:main"
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.10.11,<0.11"]
49
+ build-backend = "uv_build"
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ asyncio_mode = "auto"
54
+ asyncio_default_fixture_loop_scope = "function"
55
+ markers = ["slow: end-to-end tests that start real processes"]
56
+ timeout = 120 # pytest-timeout: a hung test fails instead of blocking the run
57
+
58
+ [dependency-groups]
59
+ dev = [
60
+ "httpx>=0.28.1",
61
+ "hypothesis>=6.170.5",
62
+ "pytest>=9.1.1",
63
+ "pytest-asyncio>=1.4.0",
64
+ "pytest-randomly>=5.0.0",
65
+ "pytest-timeout>=2.4.0",
66
+ "ruff>=0.17.1",
67
+ ]
68
+ docs = [
69
+ "zensical==0.0.69",
70
+ ]
@@ -0,0 +1,5 @@
1
+ from fyro.app import create_app
2
+ from fyro.conf import Settings
3
+ from fyro.models import Field, Model
4
+
5
+ __all__ = ["Field", "Model", "Settings", "create_app"]
@@ -0,0 +1,50 @@
1
+ """The admin: /admin pages for superusers, mounted by create_app() when fyro.auth is installed."""
2
+
3
+ from pathlib import Path
4
+
5
+ from fastapi import FastAPI, Request
6
+ from fastapi.staticfiles import StaticFiles
7
+ from starlette.datastructures import MutableHeaders
8
+ from starlette.types import ASGIApp, Message, Receive, Scope, Send
9
+
10
+ from fyro.admin.routes import AdminResponse, router
11
+
12
+ STATIC_DIR = Path(__file__).parent / "static"
13
+ SECURITY_HEADERS = {
14
+ "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'",
15
+ "X-Frame-Options": "DENY",
16
+ "Referrer-Policy": "same-origin",
17
+ }
18
+
19
+
20
+ class AdminSecurityHeaders:
21
+ """Adds the admin's security headers to every /admin response (pure ASGI)."""
22
+
23
+ def __init__(self, app: ASGIApp) -> None:
24
+ self.app = app
25
+
26
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
27
+ if scope["type"] != "http" or not scope["path"].startswith("/admin"):
28
+ await self.app(scope, receive, send)
29
+ return
30
+
31
+ async def send_with_headers(message: Message) -> None:
32
+ if message["type"] == "http.response.start":
33
+ headers = MutableHeaders(scope=message)
34
+ for name, value in SECURITY_HEADERS.items():
35
+ headers[name] = value
36
+ await send(message)
37
+
38
+ await self.app(scope, receive, send_with_headers)
39
+
40
+
41
+ async def _admin_response(request: Request, exc: Exception) -> object:
42
+ return exc.response # type: ignore[attr-defined]
43
+
44
+
45
+ def mount(app: FastAPI) -> None:
46
+ # Static first: /admin/static/... must not match the /admin/{app}/{model} page route.
47
+ app.mount("/admin/static", StaticFiles(directory=STATIC_DIR), name="admin-static")
48
+ app.include_router(router)
49
+ app.add_exception_handler(AdminResponse, _admin_response)
50
+ app.add_middleware(AdminSecurityHeaders)
@@ -0,0 +1,137 @@
1
+ """Turn model fields into HTML form widgets, and submitted form data back into schema input."""
2
+
3
+ import enum
4
+ from dataclasses import dataclass, field
5
+ from datetime import UTC, date, datetime
6
+ from decimal import Decimal
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, ValidationError
10
+
11
+ from fyro.models.base import _unwrap_optional
12
+ from fyro.models.fields import MISSING
13
+
14
+
15
+ @dataclass
16
+ class FormField:
17
+ name: str
18
+ label: str
19
+ widget: str # text | textarea | checkbox | number | date | datetime-local | select | password
20
+ value: str = ""
21
+ checked: bool = False
22
+ options: list[tuple[str, str]] = field(default_factory=list)
23
+ required: bool = False
24
+ readonly: bool = False
25
+ help: str = ""
26
+ error: str = ""
27
+
28
+
29
+ def label(name: str) -> str:
30
+ return name.replace("_", " ").capitalize()
31
+
32
+
33
+ def widget_for(tp: Any, info: Any) -> str:
34
+ if tp is bool:
35
+ return "checkbox"
36
+ if isinstance(tp, type) and issubclass(tp, enum.Enum):
37
+ return "select"
38
+ if tp in (int, float, Decimal):
39
+ return "number"
40
+ if tp is datetime:
41
+ return "datetime-local"
42
+ if tp is date:
43
+ return "date"
44
+ if tp is str and info.max_length is None:
45
+ return "textarea"
46
+ return "text"
47
+
48
+
49
+ def as_input(value: Any) -> str:
50
+ """A Python value as the string an <input> holds."""
51
+ if value is None:
52
+ return ""
53
+ if isinstance(value, enum.Enum):
54
+ return str(value.value)
55
+ if isinstance(value, datetime):
56
+ return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M")
57
+ if isinstance(value, date):
58
+ return value.isoformat()
59
+ return str(value)
60
+
61
+
62
+ def display(value: Any) -> str:
63
+ """A Python value as list-table text."""
64
+ if value is None:
65
+ return "—"
66
+ if isinstance(value, bool):
67
+ return "yes" if value else "no"
68
+ if isinstance(value, datetime):
69
+ return value.astimezone(UTC).strftime("%Y-%m-%d %H:%M UTC")
70
+ text = as_input(value)
71
+ return text if len(text) <= 80 else text[:79] + "…"
72
+
73
+
74
+ def build_fields(
75
+ model: Any, schema: type[BaseModel], instance: Any = None, *, editing: bool = False
76
+ ) -> list[FormField]:
77
+ """Editable fields come from the schema; read_only ones are shown, never submitted."""
78
+ fields: list[FormField] = []
79
+ for name, info in model.__fyro_fields__.items():
80
+ if name in schema.model_fields:
81
+ tp, nullable = _unwrap_optional(schema.model_fields[name].annotation)
82
+ widget = widget_for(tp, info)
83
+ current = getattr(instance, name) if instance is not None else None
84
+ if instance is None and info.default is not MISSING:
85
+ current = info.default
86
+ form_field = FormField(
87
+ name=name,
88
+ label=label(name),
89
+ widget=widget,
90
+ value="" if info.write_only else as_input(current),
91
+ checked=bool(current) if widget == "checkbox" else False,
92
+ options=[(str(m.value), m.name.title()) for m in tp] if widget == "select" else [],
93
+ required=not editing and not nullable and widget != "checkbox"
94
+ and info.default is MISSING and info.default_factory is None,
95
+ help="Leave blank to keep the current value." if editing and info.write_only else "",
96
+ )
97
+ fields.append(form_field)
98
+ elif instance is not None and info.read_only and not info.write_only:
99
+ fields.append(
100
+ FormField(
101
+ name=name, label=label(name), widget="text",
102
+ value=display(getattr(instance, name)), readonly=True,
103
+ )
104
+ )
105
+ return fields
106
+
107
+
108
+ def parse(
109
+ model: Any, schema: type[BaseModel], form: dict[str, list[str]], *, editing: bool
110
+ ) -> tuple[dict[str, Any], dict[str, str]]:
111
+ """Validate submitted form data with `schema`. Returns (values, errors by field)."""
112
+ raw: dict[str, Any] = {}
113
+ for name, schema_field in schema.model_fields.items():
114
+ info = model.__fyro_fields__[name]
115
+ tp, nullable = _unwrap_optional(schema_field.annotation)
116
+ if tp is bool:
117
+ raw[name] = name in form # an unchecked checkbox is simply absent
118
+ continue
119
+ value = form.get(name, [""])[0]
120
+ if value == "":
121
+ if editing and info.write_only:
122
+ continue # blank means "keep the current value"
123
+ if nullable:
124
+ raw[name] = None
125
+ elif tp is str and editing:
126
+ raw[name] = ""
127
+ continue # create: let the schema apply its default or report "Field required"
128
+ raw[name] = value
129
+ try:
130
+ validated = schema.model_validate(raw)
131
+ except ValidationError as error:
132
+ return {}, {str(e["loc"][0]) if e["loc"] else "__all__": e["msg"] for e in error.errors()}
133
+ values = validated.model_dump(exclude_unset=True)
134
+ for name, value in values.items():
135
+ if isinstance(value, datetime) and value.tzinfo is None:
136
+ values[name] = value.replace(tzinfo=UTC) # datetime-local has no zone: treat as UTC
137
+ return values, {}