holm 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.
- holm-0.1.0/LICENSE +21 -0
- holm-0.1.0/PKG-INFO +136 -0
- holm-0.1.0/README.md +126 -0
- holm-0.1.0/holm/__init__.py +7 -0
- holm-0.1.0/holm/_model.py +239 -0
- holm-0.1.0/holm/app.py +166 -0
- holm-0.1.0/holm/fastapi.py +15 -0
- holm-0.1.0/holm/modules/__init__.py +0 -0
- holm-0.1.0/holm/modules/_api.py +34 -0
- holm-0.1.0/holm/modules/_error.py +68 -0
- holm-0.1.0/holm/modules/_layout.py +141 -0
- holm-0.1.0/holm/modules/_metadata.py +123 -0
- holm-0.1.0/holm/modules/_page.py +23 -0
- holm-0.1.0/holm/py.typed +0 -0
- holm-0.1.0/holm/typing.py +24 -0
- holm-0.1.0/pyproject.toml +107 -0
holm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Peter Volf
|
|
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.
|
holm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: holm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Web development framework that brings the Next.js developer experience to Python, built on FastAPI, htmy, and FastHX.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: fastapi>=0.115.12
|
|
7
|
+
Requires-Dist: fasthx>=2.3.3
|
|
8
|
+
Requires-Dist: htmy[lxml]>=0.8.2
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+

|
|
13
|
+

|
|
14
|
+

|
|
15
|
+
|
|
16
|
+
**Source code**: [https://github.com/volfpeter/holm](https://github.com/volfpeter/holm)
|
|
17
|
+
|
|
18
|
+
**Documentation and examples**: [https://volfpeter.github.io/holm](https://volfpeter.github.io/holm/)
|
|
19
|
+
|
|
20
|
+
# holm
|
|
21
|
+
|
|
22
|
+
Web development framework that brings the Next.js developer experience to Python, built on FastAPI, `htmy`, and FastHX.
|
|
23
|
+
|
|
24
|
+
## Key features
|
|
25
|
+
|
|
26
|
+
- **Next.js**-like **developer experience** with **file-system based routing** and page composition.
|
|
27
|
+
- **Standard FastAPI** everywhere, so you can leverage the entire FastAPI ecosystem.
|
|
28
|
+
- **No JavaScript** dependencies
|
|
29
|
+
- **No build steps**, just server side rendering with **fully typed Python**.
|
|
30
|
+
- **Async** support everywhere, from APIs and dependencies all the way to UI components, thanks to `htmy`.
|
|
31
|
+
- Support for both **JSON** and **HTML** (server side rendering) APIs.
|
|
32
|
+
- First class `HTMX` support thanks to `FastHX`.
|
|
33
|
+
- **Stability** by building only on the core feature set of dependent libraries.
|
|
34
|
+
- **Unopinionated**: use any CSS framework for styling and any JavaScript framework for UI interactivity.
|
|
35
|
+
|
|
36
|
+
## Pre-requisite knowledge
|
|
37
|
+
|
|
38
|
+
To get started, all you need is a basic understanding of:
|
|
39
|
+
|
|
40
|
+
- [FastAPI](https://fastapi.tiangolo.com/tutorial/): the underlying web framework.
|
|
41
|
+
- [htmy](https://volfpeter.github.io/htmy/): the used component / templating and rendering library.
|
|
42
|
+
- HTML and CSS fundamentals.
|
|
43
|
+
|
|
44
|
+
Familiarity with [FastHX](https://volfpeter.github.io/fasthx/) (the rendering layer between `htmy` and `FastAPI`), and especially its `htmy` integration is useful, but not necessary, unless you are building an HTMX application.
|
|
45
|
+
|
|
46
|
+
It is recommended to use [HTMX](https://htmx.org/), if for nothing else, then to avoid hard page loads on navigation. [hx-boost](https://htmx.org/attributes/hx-boost/) (or navigation with `hx-get`) offers the same benefits as the `Link` component in Next.js.
|
|
47
|
+
|
|
48
|
+
## Installation
|
|
49
|
+
|
|
50
|
+
The package is available on PyPI and can be installed with:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install holm
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Supported operating systems:
|
|
57
|
+
|
|
58
|
+
- Linux
|
|
59
|
+
- macOS
|
|
60
|
+
- Windows: WSL only (native support may arrive in the future)
|
|
61
|
+
|
|
62
|
+
## Support
|
|
63
|
+
|
|
64
|
+
Consider supporting the development and maintenance of the project through [sponsoring](https://buymeacoffee.com/volfpeter), or reach out for [consulting](https://www.volfp.com/contact?subject=Consulting%20-%20holm) so you can get the most out of the library.
|
|
65
|
+
|
|
66
|
+
## Application structure
|
|
67
|
+
|
|
68
|
+
Similarly to Next.js, `holm` is built around the concept of file-system based routing. This essentially means that your code structure is automatically mapped to a matching API:
|
|
69
|
+
|
|
70
|
+
- You do not need to manually define routes, every [application component](https://volfpeter.github.io/holm/application-components) is automatically discovered and registered in the application.
|
|
71
|
+
- You do not need to manually wrap pages in their layouts, it is automatically done based on your application's code structure.
|
|
72
|
+
|
|
73
|
+
You can find all the necessary details on the [Application component](https://volfpeter.github.io/holm/application-components) page, and the [Quick start guide](https://volfpeter.github.io/holm/quick-start-guide) can walk you through the process of creating your first application. The two are complementary documents, reading both is strongly recommended.
|
|
74
|
+
|
|
75
|
+
HTML rendering is also fully automated, in the vast majority of cases you do not need to concern yourself with that (the only exceptions are HTML APIs, more on that below).
|
|
76
|
+
|
|
77
|
+
All you need to do is follow these simple rules:
|
|
78
|
+
|
|
79
|
+
- You must initialize your application instance using `holm.App()`. It optionally accepts a base `FastAPI` application and a `fasthx.htmy.HTMY` instance that handles HTML rendering.
|
|
80
|
+
- `holm.App()` must be called directly within a module in your root application package to make automatic application discovery work.
|
|
81
|
+
|
|
82
|
+
## Examples
|
|
83
|
+
|
|
84
|
+
If you prefer to learn through examples, the [Quick start guide](https://volfpeter.github.io/holm/quick-start-guide) is the best place to start. The entire source code of the quick start guide application can be found in the [examples/quick-start-guide](https://github.com/volfpeter/holm/tree/main/examples/quick-start-guide) directory of the repository.
|
|
85
|
+
|
|
86
|
+
You can discover even more features by exploring the [test application](https://github.com/volfpeter/holm/tree/main/test_app) of the project.
|
|
87
|
+
|
|
88
|
+
If you are looking for the simplest possible application you can create, then can find it in the [examples/minimal](https://github.com/volfpeter/holm/tree/main/examples/minimal) directory of the repository.
|
|
89
|
+
|
|
90
|
+
## Security
|
|
91
|
+
|
|
92
|
+
At the moment, the library **does not** provide security features like automatic **CSRF prevention** out of the box. A custom form component and a corresponding middleware may be added to the library later, but of course 3rd party implementations are very welcome! Alternative solutions include JavaScript, but the library aims to remain unopinionated, especially on the client front. If you use HTMX, you should check out their [CSRF prevention](https://htmx.org/docs/#csrf-prevention) recommendation, just keep in mind that the `<body>` tag is not swapped for boosted requests.
|
|
93
|
+
|
|
94
|
+
Also, do not forget about **XSS prevention**, when rendering untrusted data with custom components!
|
|
95
|
+
|
|
96
|
+
## Development
|
|
97
|
+
|
|
98
|
+
Development setup:
|
|
99
|
+
|
|
100
|
+
- `uv` for project and dependency management.
|
|
101
|
+
- `poethepoet` for running tasks. Run `uv run poe` to see all available tasks.
|
|
102
|
+
- `mypy` for static code analysis.
|
|
103
|
+
- `ruff` is used for formatting and linting.
|
|
104
|
+
- `pytest` for testing.
|
|
105
|
+
- `mkdocs-material` and `mkdocstrings` for documentation.
|
|
106
|
+
|
|
107
|
+
Tests are located in the `tests` directory and they use the application defined in the `test_app` package (through a `TestClient`). This setup makes it possible to run `test_app` outside of tests, which is useful for debugging.
|
|
108
|
+
|
|
109
|
+
You can run all tests with `uv run poe test`. You can separately start the test application with `uv run poe test-app`.
|
|
110
|
+
|
|
111
|
+
## Contributing
|
|
112
|
+
|
|
113
|
+
We welcome contributions from the community to help improve the project! Whether you're an experienced developer or just starting out, there are many ways you can contribute:
|
|
114
|
+
|
|
115
|
+
- **Discuss**: Join our [Discussion Board](https://github.com/volfpeter/holm/discussions) to ask questions, share ideas, provide feedback, and engage with the community.
|
|
116
|
+
- **Document**: Help improve the documentation by fixing typos, adding examples, and updating guides to make it easier for others to use the project.
|
|
117
|
+
- **Develop**: Prototype requested features or pick up issues from the issue tracker.
|
|
118
|
+
- **Test**: Write tests to improve coverage and reliability.
|
|
119
|
+
|
|
120
|
+
## Comparison with other frameworks
|
|
121
|
+
|
|
122
|
+
### Frameworks with JavaScript dependencies
|
|
123
|
+
|
|
124
|
+
The most prominent frameworks in this category are Reflex and NiceGUI. They both provide a Python-first web development experience while using complex JavaScript frameworks under the hood for constructing user interfaces (namely React and Vue.js). They take care of state management and synchronization (using WebSockets), seamlessly connecting the JavaScript frontend and the Python backend. In exchange for this convenience comes complexity and heavy reliance on the ever-changing JavaScript ecosystem.
|
|
125
|
+
|
|
126
|
+
`holm` comes with no JavaScript dependencies, but that doesn't mean you can't use JavaScript to add frontend interactivity. In fact, it comes with first class [HTMX](https://htmx.org/) support and naturally works with frameworks like [Alpine.js](https://alpinejs.dev/). Web component frameworks like [lit](https://lit.dev/), and even [React](https://react.dev/learn/add-react-to-an-existing-project#using-react-for-a-part-of-your-existing-page) application segments can also be integrated, just like into any server rendered page.
|
|
127
|
+
|
|
128
|
+
### Pure server-side rendering frameworks
|
|
129
|
+
|
|
130
|
+
This category includes frameworks like FastHTML or Ludic, and this is where `holm` belongs as well, but it has some key differentiators.
|
|
131
|
+
|
|
132
|
+
First, `holm` brings the Next.js developer experience to Python with file-system based routing, automatic layout composition, and dynamic page metadata creation, and more. Thanks to `htmy`, it supports async code throughout the stack, even in components, and it also solves the prop drilling problem. While being built with `htmy`, it is easy to integrate with other templating libraries, like `Jinja` or `htpy`. And it provides all these features using standard, simple FastAPI patterns.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
The package is open-sourced under the conditions of the [MIT license](https://choosealicense.com/licenses/mit/).
|
holm-0.1.0/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+

|
|
2
|
+

|
|
3
|
+

|
|
4
|
+

|
|
5
|
+
|
|
6
|
+
**Source code**: [https://github.com/volfpeter/holm](https://github.com/volfpeter/holm)
|
|
7
|
+
|
|
8
|
+
**Documentation and examples**: [https://volfpeter.github.io/holm](https://volfpeter.github.io/holm/)
|
|
9
|
+
|
|
10
|
+
# holm
|
|
11
|
+
|
|
12
|
+
Web development framework that brings the Next.js developer experience to Python, built on FastAPI, `htmy`, and FastHX.
|
|
13
|
+
|
|
14
|
+
## Key features
|
|
15
|
+
|
|
16
|
+
- **Next.js**-like **developer experience** with **file-system based routing** and page composition.
|
|
17
|
+
- **Standard FastAPI** everywhere, so you can leverage the entire FastAPI ecosystem.
|
|
18
|
+
- **No JavaScript** dependencies
|
|
19
|
+
- **No build steps**, just server side rendering with **fully typed Python**.
|
|
20
|
+
- **Async** support everywhere, from APIs and dependencies all the way to UI components, thanks to `htmy`.
|
|
21
|
+
- Support for both **JSON** and **HTML** (server side rendering) APIs.
|
|
22
|
+
- First class `HTMX` support thanks to `FastHX`.
|
|
23
|
+
- **Stability** by building only on the core feature set of dependent libraries.
|
|
24
|
+
- **Unopinionated**: use any CSS framework for styling and any JavaScript framework for UI interactivity.
|
|
25
|
+
|
|
26
|
+
## Pre-requisite knowledge
|
|
27
|
+
|
|
28
|
+
To get started, all you need is a basic understanding of:
|
|
29
|
+
|
|
30
|
+
- [FastAPI](https://fastapi.tiangolo.com/tutorial/): the underlying web framework.
|
|
31
|
+
- [htmy](https://volfpeter.github.io/htmy/): the used component / templating and rendering library.
|
|
32
|
+
- HTML and CSS fundamentals.
|
|
33
|
+
|
|
34
|
+
Familiarity with [FastHX](https://volfpeter.github.io/fasthx/) (the rendering layer between `htmy` and `FastAPI`), and especially its `htmy` integration is useful, but not necessary, unless you are building an HTMX application.
|
|
35
|
+
|
|
36
|
+
It is recommended to use [HTMX](https://htmx.org/), if for nothing else, then to avoid hard page loads on navigation. [hx-boost](https://htmx.org/attributes/hx-boost/) (or navigation with `hx-get`) offers the same benefits as the `Link` component in Next.js.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
The package is available on PyPI and can be installed with:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install holm
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Supported operating systems:
|
|
47
|
+
|
|
48
|
+
- Linux
|
|
49
|
+
- macOS
|
|
50
|
+
- Windows: WSL only (native support may arrive in the future)
|
|
51
|
+
|
|
52
|
+
## Support
|
|
53
|
+
|
|
54
|
+
Consider supporting the development and maintenance of the project through [sponsoring](https://buymeacoffee.com/volfpeter), or reach out for [consulting](https://www.volfp.com/contact?subject=Consulting%20-%20holm) so you can get the most out of the library.
|
|
55
|
+
|
|
56
|
+
## Application structure
|
|
57
|
+
|
|
58
|
+
Similarly to Next.js, `holm` is built around the concept of file-system based routing. This essentially means that your code structure is automatically mapped to a matching API:
|
|
59
|
+
|
|
60
|
+
- You do not need to manually define routes, every [application component](https://volfpeter.github.io/holm/application-components) is automatically discovered and registered in the application.
|
|
61
|
+
- You do not need to manually wrap pages in their layouts, it is automatically done based on your application's code structure.
|
|
62
|
+
|
|
63
|
+
You can find all the necessary details on the [Application component](https://volfpeter.github.io/holm/application-components) page, and the [Quick start guide](https://volfpeter.github.io/holm/quick-start-guide) can walk you through the process of creating your first application. The two are complementary documents, reading both is strongly recommended.
|
|
64
|
+
|
|
65
|
+
HTML rendering is also fully automated, in the vast majority of cases you do not need to concern yourself with that (the only exceptions are HTML APIs, more on that below).
|
|
66
|
+
|
|
67
|
+
All you need to do is follow these simple rules:
|
|
68
|
+
|
|
69
|
+
- You must initialize your application instance using `holm.App()`. It optionally accepts a base `FastAPI` application and a `fasthx.htmy.HTMY` instance that handles HTML rendering.
|
|
70
|
+
- `holm.App()` must be called directly within a module in your root application package to make automatic application discovery work.
|
|
71
|
+
|
|
72
|
+
## Examples
|
|
73
|
+
|
|
74
|
+
If you prefer to learn through examples, the [Quick start guide](https://volfpeter.github.io/holm/quick-start-guide) is the best place to start. The entire source code of the quick start guide application can be found in the [examples/quick-start-guide](https://github.com/volfpeter/holm/tree/main/examples/quick-start-guide) directory of the repository.
|
|
75
|
+
|
|
76
|
+
You can discover even more features by exploring the [test application](https://github.com/volfpeter/holm/tree/main/test_app) of the project.
|
|
77
|
+
|
|
78
|
+
If you are looking for the simplest possible application you can create, then can find it in the [examples/minimal](https://github.com/volfpeter/holm/tree/main/examples/minimal) directory of the repository.
|
|
79
|
+
|
|
80
|
+
## Security
|
|
81
|
+
|
|
82
|
+
At the moment, the library **does not** provide security features like automatic **CSRF prevention** out of the box. A custom form component and a corresponding middleware may be added to the library later, but of course 3rd party implementations are very welcome! Alternative solutions include JavaScript, but the library aims to remain unopinionated, especially on the client front. If you use HTMX, you should check out their [CSRF prevention](https://htmx.org/docs/#csrf-prevention) recommendation, just keep in mind that the `<body>` tag is not swapped for boosted requests.
|
|
83
|
+
|
|
84
|
+
Also, do not forget about **XSS prevention**, when rendering untrusted data with custom components!
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
Development setup:
|
|
89
|
+
|
|
90
|
+
- `uv` for project and dependency management.
|
|
91
|
+
- `poethepoet` for running tasks. Run `uv run poe` to see all available tasks.
|
|
92
|
+
- `mypy` for static code analysis.
|
|
93
|
+
- `ruff` is used for formatting and linting.
|
|
94
|
+
- `pytest` for testing.
|
|
95
|
+
- `mkdocs-material` and `mkdocstrings` for documentation.
|
|
96
|
+
|
|
97
|
+
Tests are located in the `tests` directory and they use the application defined in the `test_app` package (through a `TestClient`). This setup makes it possible to run `test_app` outside of tests, which is useful for debugging.
|
|
98
|
+
|
|
99
|
+
You can run all tests with `uv run poe test`. You can separately start the test application with `uv run poe test-app`.
|
|
100
|
+
|
|
101
|
+
## Contributing
|
|
102
|
+
|
|
103
|
+
We welcome contributions from the community to help improve the project! Whether you're an experienced developer or just starting out, there are many ways you can contribute:
|
|
104
|
+
|
|
105
|
+
- **Discuss**: Join our [Discussion Board](https://github.com/volfpeter/holm/discussions) to ask questions, share ideas, provide feedback, and engage with the community.
|
|
106
|
+
- **Document**: Help improve the documentation by fixing typos, adding examples, and updating guides to make it easier for others to use the project.
|
|
107
|
+
- **Develop**: Prototype requested features or pick up issues from the issue tracker.
|
|
108
|
+
- **Test**: Write tests to improve coverage and reliability.
|
|
109
|
+
|
|
110
|
+
## Comparison with other frameworks
|
|
111
|
+
|
|
112
|
+
### Frameworks with JavaScript dependencies
|
|
113
|
+
|
|
114
|
+
The most prominent frameworks in this category are Reflex and NiceGUI. They both provide a Python-first web development experience while using complex JavaScript frameworks under the hood for constructing user interfaces (namely React and Vue.js). They take care of state management and synchronization (using WebSockets), seamlessly connecting the JavaScript frontend and the Python backend. In exchange for this convenience comes complexity and heavy reliance on the ever-changing JavaScript ecosystem.
|
|
115
|
+
|
|
116
|
+
`holm` comes with no JavaScript dependencies, but that doesn't mean you can't use JavaScript to add frontend interactivity. In fact, it comes with first class [HTMX](https://htmx.org/) support and naturally works with frameworks like [Alpine.js](https://alpinejs.dev/). Web component frameworks like [lit](https://lit.dev/), and even [React](https://react.dev/learn/add-react-to-an-existing-project#using-react-for-a-part-of-your-existing-page) application segments can also be integrated, just like into any server rendered page.
|
|
117
|
+
|
|
118
|
+
### Pure server-side rendering frameworks
|
|
119
|
+
|
|
120
|
+
This category includes frameworks like FastHTML or Ludic, and this is where `holm` belongs as well, but it has some key differentiators.
|
|
121
|
+
|
|
122
|
+
First, `holm` brings the Next.js developer experience to Python with file-system based routing, automatic layout composition, and dynamic page metadata creation, and more. Thanks to `htmy`, it supports async code throughout the stack, even in components, and it also solves the prop drilling problem. While being built with `htmy`, it is easy to integrate with other templating libraries, like `Jinja` or `htpy`. And it provides all these features using standard, simple FastAPI patterns.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
The package is open-sourced under the conditions of the [MIT license](https://choosealicense.com/licenses/mit/).
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .app import App as App
|
|
4
|
+
from .fastapi import FastAPIDependency as FastAPIDependency
|
|
5
|
+
from .fastapi import FastAPIErrorHandler as FastAPIErrorHandler
|
|
6
|
+
from .modules._error import ErrorHandlerMapping as ErrorHandlerMapping
|
|
7
|
+
from .modules._metadata import Metadata as Metadata
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, TypeGuard, TypeVar
|
|
9
|
+
|
|
10
|
+
from .typing import ModuleName, URLSegment
|
|
11
|
+
|
|
12
|
+
_TModule = TypeVar("_TModule")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
16
|
+
class AppConfig:
|
|
17
|
+
"""
|
|
18
|
+
Stores basic configuration Information about the application.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
root_dir: Path
|
|
22
|
+
"""The root directory (as in Python import root)."""
|
|
23
|
+
|
|
24
|
+
app_dir_name: str
|
|
25
|
+
"""The name of the application directory/package."""
|
|
26
|
+
|
|
27
|
+
app_url_prefix_length: int
|
|
28
|
+
"""The length of the application URL prefix to remove (`len(app_dir_name) + 1`)."""
|
|
29
|
+
|
|
30
|
+
app_dir: Path
|
|
31
|
+
"""Path to the application directory."""
|
|
32
|
+
|
|
33
|
+
# Overriding __init__ and calculating inferred values automatically would serve the
|
|
34
|
+
# default case well, but it would complicate things if we ever need to support
|
|
35
|
+
# different application structures (for example an app in tests). So we use
|
|
36
|
+
# alternative initializers instead.
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def default(cls) -> AppConfig:
|
|
40
|
+
"""
|
|
41
|
+
Creates a default instance for the standard application configuration.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: If the application package cannot be determined.
|
|
45
|
+
"""
|
|
46
|
+
root_dir = Path.cwd()
|
|
47
|
+
app_dir_name = cls._find_app_package_name()
|
|
48
|
+
len_app_dir_name = len(app_dir_name)
|
|
49
|
+
|
|
50
|
+
return cls(
|
|
51
|
+
root_dir=root_dir,
|
|
52
|
+
app_dir_name=app_dir_name,
|
|
53
|
+
# Support applications that are not wrapped in a Python package.
|
|
54
|
+
# In that case app_dir_name is "".
|
|
55
|
+
app_url_prefix_length=len_app_dir_name + 1 if len_app_dir_name > 0 else 0,
|
|
56
|
+
app_dir=root_dir / app_dir_name,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def _find_app_package_name(cls) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Attempts to find and return the application package name.
|
|
63
|
+
|
|
64
|
+
The method uses the assumption that the application package is the one from which
|
|
65
|
+
the call stack directly led to this library.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If the application package cannot be determined.
|
|
69
|
+
"""
|
|
70
|
+
# We assume the app package is the one from which the call stack led to this library.
|
|
71
|
+
lib_root = __name__.split(".")[0]
|
|
72
|
+
stack = inspect.stack()
|
|
73
|
+
for i in range(1, len(stack)):
|
|
74
|
+
caller_frame = stack[i]
|
|
75
|
+
caller_module = inspect.getmodule(caller_frame.frame)
|
|
76
|
+
caller_package = caller_module.__package__ if caller_module else None
|
|
77
|
+
if caller_package is None:
|
|
78
|
+
break # Couldn't find app package.
|
|
79
|
+
|
|
80
|
+
if not caller_package.startswith(lib_root):
|
|
81
|
+
# The call stack is no longer in this library, so we've found the app package.
|
|
82
|
+
return caller_package
|
|
83
|
+
|
|
84
|
+
raise ValueError("Could not determine the application package.")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, kw_only=True, slots=True)
|
|
88
|
+
class PackageInfo:
|
|
89
|
+
"""
|
|
90
|
+
Information about a single package.
|
|
91
|
+
|
|
92
|
+
This class most importantly describes how a package can be imported and
|
|
93
|
+
what should be the web URL of its contents.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
package_dir: Path
|
|
97
|
+
"""
|
|
98
|
+
Relative path to the package directory.
|
|
99
|
+
|
|
100
|
+
It is basically the path matching the package name / import path.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
package_name: str
|
|
104
|
+
"""
|
|
105
|
+
The import name (path) of the package.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
url: str
|
|
109
|
+
"""
|
|
110
|
+
The web URL for the contents of the package.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __hash__(self) -> int:
|
|
114
|
+
return hash((PackageInfo, self.package_name))
|
|
115
|
+
|
|
116
|
+
def import_module(
|
|
117
|
+
self, name: ModuleName, validate: Callable[[Any], TypeGuard[_TModule]]
|
|
118
|
+
) -> _TModule | None:
|
|
119
|
+
"""
|
|
120
|
+
Imports the module with the given name from the package.
|
|
121
|
+
|
|
122
|
+
Arguments:
|
|
123
|
+
name: The name of the module to import.
|
|
124
|
+
validate: A function that validates the imported module.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
The imported module or `None` if the module does not exist.
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
ValueError: If the module is invalid.
|
|
131
|
+
"""
|
|
132
|
+
# Check if the module exists. Trying to import it and catching the exception would hide possible
|
|
133
|
+
# import error that occur in within the module, which is undesired because it would be hard for
|
|
134
|
+
# users to find why an API is not registered.
|
|
135
|
+
if not (self.package_dir / f"{name}.py").is_file():
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
# Support applications that are not wrapped in a Python package.
|
|
139
|
+
# In that case `self.package_name` is ".".
|
|
140
|
+
module = import_module(name if self.package_name == "." else f"{self.package_name}.{name}")
|
|
141
|
+
|
|
142
|
+
if not validate(module):
|
|
143
|
+
raise ValueError(f"Invalid module: {name}")
|
|
144
|
+
|
|
145
|
+
return module
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def from_marker_file(cls, file_path: Path, *, config: AppConfig) -> PackageInfo:
|
|
149
|
+
"""
|
|
150
|
+
Creates a `PackageInfo` instance from a so called "marker" that is located in the package.
|
|
151
|
+
"""
|
|
152
|
+
package_dir = file_path.relative_to(config.root_dir).parent
|
|
153
|
+
package_dir_str = str(package_dir)
|
|
154
|
+
package_name = package_dir_str.replace("/", ".")
|
|
155
|
+
# Create the base version of the URL
|
|
156
|
+
url = "/" if package_dir_str == "." else f"/{package_dir_str[config.app_url_prefix_length :]}"
|
|
157
|
+
# Technically there can be packages/directories with names like `{name}`. They can be
|
|
158
|
+
# directly used as URL segments and they could even be imported with `import_module()`,
|
|
159
|
+
# but they are invalid package names and static code analysis tools rightly complain
|
|
160
|
+
# about them, so we need an alternative option.
|
|
161
|
+
# That option is to use the `_name_` pattern instead and convert such package/directory
|
|
162
|
+
# names to the `{name}` format here and use that as the URL segment.
|
|
163
|
+
url = "/".join(
|
|
164
|
+
(f"{{{p[1:-1]}}}" if len(p) > 2 and p[0] == p[-1] == "_" else p for p in url.split("/"))
|
|
165
|
+
)
|
|
166
|
+
return cls(package_dir=package_dir, package_name=package_name, url=url)
|
|
167
|
+
|
|
168
|
+
def __lt__(self, other: PackageInfo) -> bool:
|
|
169
|
+
return self.package_name < other.package_name
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True, slots=True)
|
|
173
|
+
class AppNode:
|
|
174
|
+
"""
|
|
175
|
+
The description of an application tree.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
url: str
|
|
179
|
+
"""The full URL of this node."""
|
|
180
|
+
|
|
181
|
+
subtree: dict[URLSegment, AppNode] = field(default_factory=dict)
|
|
182
|
+
"""
|
|
183
|
+
Maps sub-URLs to child nodes.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
_package: PackageInfo | None = None
|
|
187
|
+
"""The package that belongs to this node."""
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def last_url_segment(self) -> URLSegment:
|
|
191
|
+
"""The last URL segment of this node."""
|
|
192
|
+
return f"/{self.url.rsplit('/', 1)[1]}"
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def package(self) -> PackageInfo | None:
|
|
196
|
+
"""The package that belongs to this node."""
|
|
197
|
+
return self._package
|
|
198
|
+
|
|
199
|
+
def add(self, item: PackageInfo) -> None:
|
|
200
|
+
"""
|
|
201
|
+
Adds the given package to this node.
|
|
202
|
+
|
|
203
|
+
If the package belongs in the subtree and not directly to this node, then the
|
|
204
|
+
entire subtree is created automatically.
|
|
205
|
+
"""
|
|
206
|
+
self_url = self.url
|
|
207
|
+
if not item.url.startswith(self_url):
|
|
208
|
+
raise ValueError(f"{item.url} must start with {self_url}")
|
|
209
|
+
|
|
210
|
+
if item.url == self_url:
|
|
211
|
+
if self._package is not None:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
(
|
|
214
|
+
f"Package already set for {self_url}: "
|
|
215
|
+
f"current is {self._package.package_name} "
|
|
216
|
+
f"new is {item.package_name}"
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Class is frozen so this is only way to set _package.
|
|
221
|
+
object.__setattr__(self, "_package", item)
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if self_url == "/": # Root node
|
|
225
|
+
sub_url = item.url
|
|
226
|
+
url_prefix = ""
|
|
227
|
+
else:
|
|
228
|
+
sub_url = item.url.removeprefix(self_url)
|
|
229
|
+
url_prefix = self_url
|
|
230
|
+
|
|
231
|
+
first_segment: URLSegment = f"/{sub_url.split('/', 2)[1]}" # First segment after leading slash
|
|
232
|
+
child = self.subtree.get(first_segment)
|
|
233
|
+
if child is None:
|
|
234
|
+
# The URL for this node must be constructed this way to not skip intermediate nodes
|
|
235
|
+
# that may not have a discovered package.
|
|
236
|
+
child = AppNode(f"{url_prefix}{first_segment}")
|
|
237
|
+
self.subtree[first_segment] = child
|
|
238
|
+
|
|
239
|
+
child.add(item)
|
holm-0.1.0/holm/app.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Awaitable, Iterable
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, Depends, FastAPI
|
|
6
|
+
from fastapi.responses import HTMLResponse
|
|
7
|
+
from fasthx.htmy import HTMY
|
|
8
|
+
from htmy import Component, as_component_type
|
|
9
|
+
|
|
10
|
+
from ._model import AppConfig, AppNode, PackageInfo
|
|
11
|
+
from .fastapi import FastAPIDependency
|
|
12
|
+
from .modules._api import PlainAPIFactory, RenderingAPIFactory, is_api_definition
|
|
13
|
+
from .modules._error import is_error_handler_owner, register_error_handlers
|
|
14
|
+
from .modules._layout import (
|
|
15
|
+
LayoutFactory,
|
|
16
|
+
combine_layouts_to_dependency,
|
|
17
|
+
empty_layout_dependency,
|
|
18
|
+
is_layout_definition,
|
|
19
|
+
)
|
|
20
|
+
from .modules._metadata import MetadataMapping, components_with_metadata, get_metadata_dependency
|
|
21
|
+
from .modules._page import is_page_definition
|
|
22
|
+
from .typing import module_names
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def App(*, app: FastAPI | None = None, htmy: HTMY | None = None) -> FastAPI:
|
|
26
|
+
"""
|
|
27
|
+
Creates a FastAPI application with all the routes that are defined in the application package.
|
|
28
|
+
|
|
29
|
+
Arguments:
|
|
30
|
+
app: Optional FastAPI application to use. If `None`, a new instance will be created.
|
|
31
|
+
htmy: Optional `fasthx.htmy.HTMY` instance to use for server-side rendering. If `None`,
|
|
32
|
+
a default instance will be created.
|
|
33
|
+
"""
|
|
34
|
+
if app is None:
|
|
35
|
+
app = FastAPI()
|
|
36
|
+
|
|
37
|
+
if htmy is None:
|
|
38
|
+
htmy = HTMY()
|
|
39
|
+
|
|
40
|
+
config = AppConfig.default()
|
|
41
|
+
packages = _discover_app_packages(config)
|
|
42
|
+
root_node = _build_app_tree(packages)
|
|
43
|
+
|
|
44
|
+
# Register error handlers
|
|
45
|
+
if pkg := root_node.package:
|
|
46
|
+
register_error_handlers(app, pkg.import_module("error", is_error_handler_owner), htmy=htmy)
|
|
47
|
+
|
|
48
|
+
# Build the API
|
|
49
|
+
app.include_router(_build_api(root_node, htmy=htmy))
|
|
50
|
+
|
|
51
|
+
return app
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build_api(
|
|
55
|
+
node: AppNode,
|
|
56
|
+
*,
|
|
57
|
+
base_layout_dep: FastAPIDependency[LayoutFactory] = empty_layout_dependency,
|
|
58
|
+
htmy: HTMY,
|
|
59
|
+
) -> APIRouter:
|
|
60
|
+
"""
|
|
61
|
+
Recursively builds an `APIRouter` based on the application defined by `node`.
|
|
62
|
+
|
|
63
|
+
Arguments:
|
|
64
|
+
node: Application definition.
|
|
65
|
+
base_layout: The base layout dependency to use for the API.
|
|
66
|
+
htmy: The `fasthx.htmy.HTMY` instance to use for server-side rendering.
|
|
67
|
+
"""
|
|
68
|
+
layout_dep = base_layout_dep # In case there is no package or it has no layout module.
|
|
69
|
+
pkg = node.package
|
|
70
|
+
api = _make_api_router_for_package(pkg, htmy)
|
|
71
|
+
if pkg is not None:
|
|
72
|
+
layout_module = pkg.import_module("layout", is_layout_definition)
|
|
73
|
+
page_module = pkg.import_module("page", is_page_definition)
|
|
74
|
+
|
|
75
|
+
layout_dep = combine_layouts_to_dependency(
|
|
76
|
+
base_layout_dep, None if layout_module is None else layout_module.layout
|
|
77
|
+
)
|
|
78
|
+
page_dep = None if page_module is None else page_module.page
|
|
79
|
+
|
|
80
|
+
if page_dep is not None:
|
|
81
|
+
metadata_dep = get_metadata_dependency(page_module)
|
|
82
|
+
|
|
83
|
+
async def path_operation(
|
|
84
|
+
layout: LayoutFactory = Depends(layout_dep), # noqa: B008
|
|
85
|
+
metadata: MetadataMapping | None = Depends(metadata_dep), # noqa: B008
|
|
86
|
+
page: Component = Depends(page_dep), # noqa: B008
|
|
87
|
+
) -> tuple[Component, MetadataMapping | None]:
|
|
88
|
+
result = layout(as_component_type(page))
|
|
89
|
+
# We must await here if result is an Awaitable, otherwise we would pass an
|
|
90
|
+
# awaitable to htmy.page() and rendering that would fail.
|
|
91
|
+
if isinstance(result, Awaitable):
|
|
92
|
+
result = await result
|
|
93
|
+
|
|
94
|
+
return result, metadata
|
|
95
|
+
|
|
96
|
+
# Register the route with rendering.
|
|
97
|
+
api.get(
|
|
98
|
+
"/",
|
|
99
|
+
response_class=HTMLResponse,
|
|
100
|
+
response_model=None,
|
|
101
|
+
name="page",
|
|
102
|
+
description=page_dep.__doc__,
|
|
103
|
+
tags=["Page"],
|
|
104
|
+
)(htmy.page(components_with_metadata)(path_operation))
|
|
105
|
+
|
|
106
|
+
for sub_url, child_node in node.subtree.items():
|
|
107
|
+
api.include_router(
|
|
108
|
+
_build_api(child_node, base_layout_dep=layout_dep, htmy=htmy),
|
|
109
|
+
prefix=sub_url,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return api
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _build_app_tree(packages: Iterable[PackageInfo]) -> AppNode:
|
|
116
|
+
"""
|
|
117
|
+
Returns an `AppNode` that represents the entire package tree of the application.
|
|
118
|
+
|
|
119
|
+
Arguments:
|
|
120
|
+
packages: The packages to include in the tree.
|
|
121
|
+
"""
|
|
122
|
+
root = AppNode("/")
|
|
123
|
+
|
|
124
|
+
for p in packages:
|
|
125
|
+
root.add(p)
|
|
126
|
+
|
|
127
|
+
return root
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _discover_app_packages(config: AppConfig) -> set[PackageInfo]:
|
|
131
|
+
"""
|
|
132
|
+
Discovers all packages that are part of the application and returns them as a set.
|
|
133
|
+
"""
|
|
134
|
+
return {
|
|
135
|
+
PackageInfo.from_marker_file(f, config=config)
|
|
136
|
+
for f in config.app_dir.rglob("*.py")
|
|
137
|
+
if f.stem in module_names
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _make_api_router_for_package(pkg: PackageInfo | None, htmy: HTMY) -> APIRouter:
|
|
142
|
+
"""
|
|
143
|
+
Creates an `APIRouter` for the given package.
|
|
144
|
+
"""
|
|
145
|
+
api_module = None if pkg is None else pkg.import_module("api", is_api_definition)
|
|
146
|
+
if api_module is None:
|
|
147
|
+
return APIRouter()
|
|
148
|
+
|
|
149
|
+
# api is either an APIRouter or a callable (is_api_definition validates that).
|
|
150
|
+
api = api_module.api
|
|
151
|
+
if isinstance(api, APIRouter):
|
|
152
|
+
return api
|
|
153
|
+
|
|
154
|
+
# Inspect the api callable instead of catching errors. Using a try-except with
|
|
155
|
+
# a TypeError handler would hide many potential issues that would then be
|
|
156
|
+
# hard to figure out without the exception trace.
|
|
157
|
+
num_params = len(inspect.signature(api).parameters)
|
|
158
|
+
if num_params == 0:
|
|
159
|
+
api = cast(PlainAPIFactory, api)()
|
|
160
|
+
elif num_params == 1:
|
|
161
|
+
api = cast(RenderingAPIFactory, api)(htmy)
|
|
162
|
+
|
|
163
|
+
if isinstance(api, APIRouter):
|
|
164
|
+
return api
|
|
165
|
+
|
|
166
|
+
raise ValueError(f"The api function of {cast(PackageInfo, pkg).package_name} must return an APIRouter.")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from collections.abc import Callable, Coroutine
|
|
2
|
+
from typing import Any, TypeAlias, TypeVar
|
|
3
|
+
|
|
4
|
+
from fastapi import Request
|
|
5
|
+
|
|
6
|
+
_T = TypeVar("_T")
|
|
7
|
+
|
|
8
|
+
SyncFastAPIDependency: TypeAlias = Callable[..., _T]
|
|
9
|
+
AsyncFastAPIDependency: TypeAlias = Callable[..., Coroutine[None, None, _T]]
|
|
10
|
+
|
|
11
|
+
FastAPIDependency: TypeAlias = SyncFastAPIDependency[_T] | AsyncFastAPIDependency[_T]
|
|
12
|
+
"""FastAPI dependency typing."""
|
|
13
|
+
|
|
14
|
+
FastAPIErrorHandler: TypeAlias = Callable[[Request, Exception], Coroutine[None, None, Any]]
|
|
15
|
+
"""Generic FastAPI error handler typing."""
|
|
File without changes
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, Protocol, TypeAlias, TypeGuard
|
|
3
|
+
|
|
4
|
+
from fastapi import APIRouter
|
|
5
|
+
from fasthx.htmy import HTMY
|
|
6
|
+
|
|
7
|
+
PlainAPIFactory: TypeAlias = Callable[[], APIRouter]
|
|
8
|
+
RenderingAPIFactory: TypeAlias = Callable[[HTMY], APIRouter]
|
|
9
|
+
|
|
10
|
+
APIFactory: TypeAlias = PlainAPIFactory | RenderingAPIFactory
|
|
11
|
+
"""
|
|
12
|
+
`APIRouter` factory definition.
|
|
13
|
+
|
|
14
|
+
It is a callable that optionally accepts an `HTMY` instance as an argument to make sure
|
|
15
|
+
the same renderer is used throughout the application if the API does HTML rendering.
|
|
16
|
+
Otherwise the argument can be omitted.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class APIDefinition(Protocol):
|
|
21
|
+
"""Protocol definition for objects (usually modules) that define an API."""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def api(self) -> APIFactory | APIRouter:
|
|
25
|
+
"""
|
|
26
|
+
The property may be an `APIFactory` or a plain `APIRouter` instance."""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_api_definition(obj: Any) -> TypeGuard[APIDefinition]:
|
|
31
|
+
"""Type guard for `APIDefinition`."""
|
|
32
|
+
api = getattr(obj, "api", None)
|
|
33
|
+
# Check APIRouter first, because that's also callable.
|
|
34
|
+
return isinstance(api, APIRouter) or callable(api)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from collections.abc import Callable, Mapping
|
|
2
|
+
from typing import Any, Protocol, TypeAlias, TypeGuard
|
|
3
|
+
|
|
4
|
+
from fastapi import FastAPI, Request, Response
|
|
5
|
+
from fastapi.responses import HTMLResponse
|
|
6
|
+
from fasthx.htmy import HTMY
|
|
7
|
+
|
|
8
|
+
from holm.fastapi import FastAPIErrorHandler
|
|
9
|
+
|
|
10
|
+
ErrorHandlerMapping: TypeAlias = Mapping[type[Exception] | int, FastAPIErrorHandler]
|
|
11
|
+
"""
|
|
12
|
+
Mapping whose keys are exception types or HTTP status codes, and the corresponding
|
|
13
|
+
values are FastAPI error handlers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ErrorHandlerOwner(Protocol):
|
|
18
|
+
"""
|
|
19
|
+
Protocol definition for objects (usually modules) that define FastAPI exception handlers.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def handlers(self) -> ErrorHandlerMapping | Callable[[HTMY], ErrorHandlerMapping]: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_error_handler_owner(obj: Any) -> TypeGuard[ErrorHandlerOwner]:
|
|
27
|
+
"""Type guard for `ErrorHandlerOwner`."""
|
|
28
|
+
handlers = getattr(obj, "handlers", None)
|
|
29
|
+
return handlers is not None and (isinstance(handlers, Mapping) or callable(handlers))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def register_error_handlers(app: FastAPI, owner: ErrorHandlerOwner | None, *, htmy: HTMY) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Registers the error handlers defined by the given owner on the application.
|
|
35
|
+
|
|
36
|
+
Arguments:
|
|
37
|
+
app: The FastAPI application instance.
|
|
38
|
+
owner: The error handler owner, if any.
|
|
39
|
+
htmy: The HTMY instance.
|
|
40
|
+
"""
|
|
41
|
+
if owner is None:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
handlers = owner.handlers
|
|
45
|
+
handler_mapping: ErrorHandlerMapping = handlers(htmy) if callable(handlers) else handlers
|
|
46
|
+
for key, handler in handler_mapping.items():
|
|
47
|
+
app.exception_handler(key)(wrap_error_handler(handler, htmy))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def wrap_error_handler(error_handler: FastAPIErrorHandler, htmy: HTMY) -> FastAPIErrorHandler:
|
|
51
|
+
"""
|
|
52
|
+
Wraps the given error handler in a function that automatically renders `htmy` `Component` return values.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
async def rendering_error_handler_wrapper(request: Request, error: Exception) -> Response:
|
|
56
|
+
result = await error_handler(request, error)
|
|
57
|
+
if isinstance(result, Response):
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
return HTMLResponse(await htmy.render_component(result, request))
|
|
62
|
+
except Exception as e:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
"HTML rendering failed. Make sure your error handler returns "
|
|
65
|
+
"either a FastAPI.Response or an htmy.Component!"
|
|
66
|
+
) from e
|
|
67
|
+
|
|
68
|
+
return rendering_error_handler_wrapper
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from asyncio import iscoroutinefunction
|
|
3
|
+
from collections.abc import Awaitable, Callable, Coroutine
|
|
4
|
+
from typing import Any, Protocol, TypeAlias, TypeGuard
|
|
5
|
+
|
|
6
|
+
from fastapi import Depends
|
|
7
|
+
from htmy import as_component_type
|
|
8
|
+
|
|
9
|
+
from holm.fastapi import FastAPIDependency
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SyncLayout(Protocol):
|
|
13
|
+
"""
|
|
14
|
+
Sync layout protocol definition.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __call__(self, __props: Any, /, **dependencies: Any) -> Any: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsyncLayout(Protocol):
|
|
21
|
+
"""
|
|
22
|
+
Async layout protocol definition.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
async def __call__(self, __props: Any, /, **dependencies: Any) -> Any: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
Layout: TypeAlias = SyncLayout | AsyncLayout
|
|
29
|
+
"""
|
|
30
|
+
Layout type definition.
|
|
31
|
+
|
|
32
|
+
A layout is a callable that expects a single positional argument the component or data returned
|
|
33
|
+
by the wrapped layout or page, and returns the properties for its wrapper layout. The root layout
|
|
34
|
+
must always return a `Component`.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
LayoutFactory: TypeAlias = Callable[[Any], Any | Coroutine[None, None, Any]]
|
|
38
|
+
"""
|
|
39
|
+
A layout factory is a sync or asynccallable that expects a single argument (the layout's properties)
|
|
40
|
+
and returns the properties for its wrapper layout.
|
|
41
|
+
|
|
42
|
+
The root layout factory must always return an `htmy` `Component`.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LayoutDefinition(Protocol):
|
|
47
|
+
"""Protocol definition for objects (usually modules) that define a layout."""
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def layout(self) -> Layout: ...
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_layout_definition(obj: Any) -> TypeGuard[LayoutDefinition]:
|
|
54
|
+
"""Type guard for `LayoutDefinition`."""
|
|
55
|
+
layout = getattr(obj, "layout", None)
|
|
56
|
+
return callable(layout)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def combine_layouts_to_dependency(
|
|
60
|
+
outer_dep: FastAPIDependency[LayoutFactory], inner: Layout | None
|
|
61
|
+
) -> FastAPIDependency[LayoutFactory]:
|
|
62
|
+
"""
|
|
63
|
+
Creates a layout factory dependency that wraps the inner layout inside the outer layout.
|
|
64
|
+
|
|
65
|
+
Arguments:
|
|
66
|
+
outer: The outer layout factory.
|
|
67
|
+
inner: The inner layout factory.
|
|
68
|
+
"""
|
|
69
|
+
if inner is None:
|
|
70
|
+
return outer_dep
|
|
71
|
+
|
|
72
|
+
inner_dep = layout_to_dependency(inner)
|
|
73
|
+
|
|
74
|
+
async def combined_layout_dep(
|
|
75
|
+
outer: LayoutFactory = Depends(outer_dep), # noqa: B008
|
|
76
|
+
inner: LayoutFactory = Depends(inner_dep), # noqa: B008
|
|
77
|
+
) -> LayoutFactory:
|
|
78
|
+
async def layout_factory(props: Any) -> Any:
|
|
79
|
+
inner_result = inner(props)
|
|
80
|
+
if isinstance(inner_result, Awaitable):
|
|
81
|
+
inner_result = await inner_result
|
|
82
|
+
|
|
83
|
+
result = outer(as_component_type(inner_result))
|
|
84
|
+
if isinstance(result, Awaitable):
|
|
85
|
+
result = await result
|
|
86
|
+
|
|
87
|
+
return result
|
|
88
|
+
|
|
89
|
+
return layout_factory
|
|
90
|
+
|
|
91
|
+
return combined_layout_dep
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def empty_layout(props: Any) -> Any:
|
|
95
|
+
"""`LayoutFactory` that simply returns the received props."""
|
|
96
|
+
return props
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def empty_layout_dependency() -> FastAPIDependency[LayoutFactory]:
|
|
100
|
+
"""
|
|
101
|
+
FastAPI dependency that returns `empty_layout`.
|
|
102
|
+
"""
|
|
103
|
+
return empty_layout
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def layout_to_dependency(layout: Layout) -> FastAPIDependency[LayoutFactory]:
|
|
107
|
+
"""
|
|
108
|
+
Converts a layout to a FastAPI dependency that returns a layout factory.
|
|
109
|
+
"""
|
|
110
|
+
# eval_str=True is necessary in case future __annotations__ is used
|
|
111
|
+
# where the layout is function is defined.
|
|
112
|
+
params = tuple(inspect.signature(layout, eval_str=True).parameters.values())
|
|
113
|
+
props_index = next((i for i, p in enumerate(params) if p.name != "self"), -1)
|
|
114
|
+
if props_index == -1:
|
|
115
|
+
raise ValueError("Layout factory must at least one argument.")
|
|
116
|
+
|
|
117
|
+
result: FastAPIDependency[LayoutFactory]
|
|
118
|
+
if iscoroutinefunction(layout):
|
|
119
|
+
|
|
120
|
+
async def async_layout_factory_dep(**kwargs: Any) -> LayoutFactory:
|
|
121
|
+
async def layout_factory(props: Any) -> Any:
|
|
122
|
+
return await layout(props, **kwargs)
|
|
123
|
+
|
|
124
|
+
return layout_factory
|
|
125
|
+
|
|
126
|
+
result = async_layout_factory_dep
|
|
127
|
+
else:
|
|
128
|
+
|
|
129
|
+
def sync_layout_factory_dep(**kwargs: Any) -> LayoutFactory:
|
|
130
|
+
def layout_factory(props: Any) -> Any:
|
|
131
|
+
return layout(props, **kwargs)
|
|
132
|
+
|
|
133
|
+
return layout_factory
|
|
134
|
+
|
|
135
|
+
result = sync_layout_factory_dep
|
|
136
|
+
|
|
137
|
+
# Copy the dependencies part of the signature from the original function, so
|
|
138
|
+
# FastAPI can resolve those arguments. The props argument must be excluded.
|
|
139
|
+
result.__signature__ = inspect.Signature(params[props_index + 1 :]) # type: ignore[union-attr]
|
|
140
|
+
|
|
141
|
+
return result
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from collections.abc import ItemsView, Iterator, KeysView, Mapping, ValuesView
|
|
2
|
+
from typing import Any, Protocol, TypeAlias, overload
|
|
3
|
+
|
|
4
|
+
from htmy import Component, ContextAware, as_component_sequence
|
|
5
|
+
|
|
6
|
+
from holm.fastapi import FastAPIDependency
|
|
7
|
+
|
|
8
|
+
MetadataMapping: TypeAlias = Mapping[Any, Any]
|
|
9
|
+
"""
|
|
10
|
+
A mapping representation of information to be placed in the `<head>` element of an HTML document.
|
|
11
|
+
|
|
12
|
+
It is usually a dictionary with string keys and values, but it is not enforced in runtime,
|
|
13
|
+
so the typing is more lenient.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MetadataOwner(Protocol):
|
|
18
|
+
"""
|
|
19
|
+
Protocol definition for objects (usually modules) that define page metadata.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def metadata(self) -> MetadataMapping | FastAPIDependency[MetadataMapping | None]: ...
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_metadata_dependency(obj: Any) -> FastAPIDependency[MetadataMapping | None]:
|
|
27
|
+
"""
|
|
28
|
+
Loads and returns the FastAPI `MetadataMapping` dependency based on the metadata definition
|
|
29
|
+
in the given object.
|
|
30
|
+
|
|
31
|
+
If the object is not a `MetadataOwner`, the dependency will simply return `None`.
|
|
32
|
+
"""
|
|
33
|
+
metadata = getattr(obj, "metadata", None)
|
|
34
|
+
if metadata is None or isinstance(metadata, Mapping):
|
|
35
|
+
return lambda: metadata
|
|
36
|
+
|
|
37
|
+
if callable(metadata):
|
|
38
|
+
return metadata # type: ignore[no-any-return]
|
|
39
|
+
|
|
40
|
+
raise ValueError(f"Invalid metadata object: {metadata}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Metadata(ContextAware):
|
|
44
|
+
"""
|
|
45
|
+
Context-aware metadata provider.
|
|
46
|
+
|
|
47
|
+
The class implements the `Mapping` protocol as a means of giving convenient access to
|
|
48
|
+
the metadata it contains.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
__slots__ = ("_metadata",)
|
|
52
|
+
|
|
53
|
+
def __init__(self, metadata: MetadataMapping) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Initialization.
|
|
56
|
+
|
|
57
|
+
Arguments:
|
|
58
|
+
metadata: The actual metadata mapping.
|
|
59
|
+
"""
|
|
60
|
+
self._metadata = metadata
|
|
61
|
+
"""The metadata mapping."""
|
|
62
|
+
|
|
63
|
+
@overload
|
|
64
|
+
def get(self, key: Any, default: None = None) -> Any | None: ...
|
|
65
|
+
|
|
66
|
+
@overload
|
|
67
|
+
def get(self, key: Any, default: Any) -> Any: ...
|
|
68
|
+
|
|
69
|
+
def get(self, key: Any, default: Any | None = None) -> Any | None:
|
|
70
|
+
"""Implements `Mapping.get()`."""
|
|
71
|
+
return self._metadata.get(key, default)
|
|
72
|
+
|
|
73
|
+
def __contains__(self, key: Any) -> bool:
|
|
74
|
+
"""Implements the `Container` protocol."""
|
|
75
|
+
return key in self._metadata
|
|
76
|
+
|
|
77
|
+
def __eq__(self, other: Any) -> bool:
|
|
78
|
+
return isinstance(other, Metadata) and self._metadata == other._metadata
|
|
79
|
+
|
|
80
|
+
def __getitem__(self, key: Any) -> Any:
|
|
81
|
+
"""Implements the `Sequence` protocol."""
|
|
82
|
+
return self._metadata[key]
|
|
83
|
+
|
|
84
|
+
def __iter__(self) -> Iterator[Any]:
|
|
85
|
+
"""Implements the `Iterable` protocol."""
|
|
86
|
+
return iter(self._metadata)
|
|
87
|
+
|
|
88
|
+
def __len__(self) -> int:
|
|
89
|
+
"""Implements the `Sized` protocol."""
|
|
90
|
+
return len(self._metadata)
|
|
91
|
+
|
|
92
|
+
def __ne__(self, other: Any) -> bool:
|
|
93
|
+
return not self.__eq__(other)
|
|
94
|
+
|
|
95
|
+
def items(self) -> ItemsView[Any, Any]:
|
|
96
|
+
"""Implements `Mapping.items()`."""
|
|
97
|
+
return self._metadata.items()
|
|
98
|
+
|
|
99
|
+
def keys(self) -> KeysView[Any]:
|
|
100
|
+
"""Implements `Mapping.keys()`."""
|
|
101
|
+
return self._metadata.keys()
|
|
102
|
+
|
|
103
|
+
def values(self) -> ValuesView[Any]:
|
|
104
|
+
"""Implements `Mapping.values()`."""
|
|
105
|
+
return self._metadata.values()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def components_with_metadata(data: tuple[Component, MetadataMapping | None]) -> Component:
|
|
109
|
+
"""
|
|
110
|
+
Function that converts a `Component` and optional `MetadataMapping` tuple into a `Component`
|
|
111
|
+
in which the received component is wrapped in a `MetadataContext`, making the given metadata
|
|
112
|
+
available in all wrapped components using `Metadata.from_context()`.
|
|
113
|
+
"""
|
|
114
|
+
components, metadata = data
|
|
115
|
+
# Wrap the rendered components in Metadata context provider component.
|
|
116
|
+
# This allows `Metadata.from_context()` to be used in all wrapped components.
|
|
117
|
+
# The received components object is usually a ComponentSequence (doctype and html components),
|
|
118
|
+
# so converting to a component sequence seems a tiny bit more efficient.
|
|
119
|
+
return (
|
|
120
|
+
components
|
|
121
|
+
if metadata is None
|
|
122
|
+
else Metadata(metadata).in_context(*as_component_sequence(components))
|
|
123
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from typing import Any, Protocol, TypeAlias, TypeGuard
|
|
2
|
+
|
|
3
|
+
from holm.fastapi import FastAPIDependency
|
|
4
|
+
|
|
5
|
+
Page: TypeAlias = FastAPIDependency[Any]
|
|
6
|
+
"""FastAPI dependency that returns the properties for the layout that wraps it
|
|
7
|
+
(for example a `Component`).
|
|
8
|
+
|
|
9
|
+
If the page is not wrapped by a layout, then it must return a `Component`
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PageDefinition(Protocol):
|
|
14
|
+
"""Protocol definition for objects (usually modules) that define a page."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def page(self) -> Page: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_page_definition(obj: Any) -> TypeGuard[PageDefinition]:
|
|
21
|
+
"""Type guard for `PageDefinition`."""
|
|
22
|
+
page = getattr(obj, "page", None)
|
|
23
|
+
return callable(page)
|
holm-0.1.0/holm/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Literal, TypeAlias, get_args
|
|
2
|
+
|
|
3
|
+
PackageModuleName: TypeAlias = Literal["api", "layout", "page"]
|
|
4
|
+
"""Recognized module names."""
|
|
5
|
+
|
|
6
|
+
RootOnlyModuleName: TypeAlias = Literal["error"]
|
|
7
|
+
"""Modules names that are only supported at the root level."""
|
|
8
|
+
|
|
9
|
+
ModuleName: TypeAlias = PackageModuleName | RootOnlyModuleName
|
|
10
|
+
"""All recognized module names, including ones that are only supported at the root level."""
|
|
11
|
+
|
|
12
|
+
# get_args() doesn't return the correct tuple for the ModuleName union type.
|
|
13
|
+
module_names: set[ModuleName] = {*get_args(PackageModuleName), *get_args(RootOnlyModuleName)}
|
|
14
|
+
"""Set of all recognized module names."""
|
|
15
|
+
|
|
16
|
+
URLSegment: TypeAlias = str
|
|
17
|
+
"""
|
|
18
|
+
A URL that consists of a leading slash followed by a non-empty URL part
|
|
19
|
+
that includes no more slashes.
|
|
20
|
+
|
|
21
|
+
Valid examples: "/about".
|
|
22
|
+
|
|
23
|
+
Invalid examples: "/", "/about/us".
|
|
24
|
+
"""
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "holm"
|
|
3
|
+
dynamic = []
|
|
4
|
+
description = "Web development framework that brings the Next.js developer experience to Python, built on FastAPI, htmy, and FastHX."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastapi>=0.115.12",
|
|
9
|
+
"fasthx>=2.3.3",
|
|
10
|
+
"htmy[lxml]>=0.8.2",
|
|
11
|
+
]
|
|
12
|
+
version = "0.1.0"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = [
|
|
16
|
+
"pdm-backend",
|
|
17
|
+
]
|
|
18
|
+
build-backend = "pdm.backend"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"httpx>=0.28.1",
|
|
23
|
+
"mkdocs-material>=9.6.20",
|
|
24
|
+
"mkdocstrings[python]>=0.30.1",
|
|
25
|
+
"mypy>=1.15.0",
|
|
26
|
+
"poethepoet>=0.35.0",
|
|
27
|
+
"pytest>=8.3.5",
|
|
28
|
+
"pytest-random-order>=1.1.1",
|
|
29
|
+
"ruff>=0.12.0",
|
|
30
|
+
"uvicorn>=0.34.1",
|
|
31
|
+
"watchfiles>=1.1.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[tool.mypy]
|
|
35
|
+
strict = true
|
|
36
|
+
explicit_package_bases = true
|
|
37
|
+
show_error_codes = true
|
|
38
|
+
|
|
39
|
+
[tool.pdm.build]
|
|
40
|
+
excludes = [
|
|
41
|
+
"tests/",
|
|
42
|
+
"test_app/",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[tool.pdm.version]
|
|
46
|
+
source = "file"
|
|
47
|
+
path = "holm/__init__.py"
|
|
48
|
+
|
|
49
|
+
[tool.pyright]
|
|
50
|
+
venvPath = "."
|
|
51
|
+
venv = ".venv"
|
|
52
|
+
|
|
53
|
+
[tool.pytest.ini_options]
|
|
54
|
+
addopts = "--random-order"
|
|
55
|
+
|
|
56
|
+
[tool.ruff]
|
|
57
|
+
line-length = 108
|
|
58
|
+
exclude = [
|
|
59
|
+
".git",
|
|
60
|
+
".mypy_cache",
|
|
61
|
+
".pytest_cache",
|
|
62
|
+
".ruff_cache",
|
|
63
|
+
".venv",
|
|
64
|
+
"dist",
|
|
65
|
+
"docs",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
[tool.ruff.lint]
|
|
69
|
+
select = [
|
|
70
|
+
"B",
|
|
71
|
+
"C",
|
|
72
|
+
"E",
|
|
73
|
+
"F",
|
|
74
|
+
"I",
|
|
75
|
+
"S",
|
|
76
|
+
"W",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint.per-file-ignores]
|
|
80
|
+
"tests/**/*" = [
|
|
81
|
+
"S101",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
[tool.poe.tasks]
|
|
85
|
+
format = "ruff format . --check"
|
|
86
|
+
format-fix = "ruff format ."
|
|
87
|
+
lint = "ruff check ."
|
|
88
|
+
lint-fix = "ruff check . --fix"
|
|
89
|
+
mypy = "mypy ."
|
|
90
|
+
serve-docs = "mkdocs serve"
|
|
91
|
+
|
|
92
|
+
[tool.poe.tasks.test-app]
|
|
93
|
+
cmd = "uvicorn test_app.main:app --reload"
|
|
94
|
+
|
|
95
|
+
[tool.poe.tasks.check]
|
|
96
|
+
sequence = [
|
|
97
|
+
"format",
|
|
98
|
+
"lint",
|
|
99
|
+
"mypy",
|
|
100
|
+
]
|
|
101
|
+
ignore_fail = "return_non_zero"
|
|
102
|
+
|
|
103
|
+
[tool.poe.tasks.test]
|
|
104
|
+
cmd = "python -m pytest tests"
|
|
105
|
+
|
|
106
|
+
[tool.poe.tasks.test.env]
|
|
107
|
+
APP_ENV = "test"
|