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 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
+ ![Tests](https://github.com/volfpeter/holm/actions/workflows/tests.yml/badge.svg)
12
+ ![Linters](https://github.com/volfpeter/holm/actions/workflows/linters.yml/badge.svg)
13
+ ![Documentation](https://github.com/volfpeter/holm/actions/workflows/build-docs.yml/badge.svg)
14
+ ![PyPI package](https://img.shields.io/pypi/v/holm?color=%2334D058&label=PyPI%20Package)
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
+ ![Tests](https://github.com/volfpeter/holm/actions/workflows/tests.yml/badge.svg)
2
+ ![Linters](https://github.com/volfpeter/holm/actions/workflows/linters.yml/badge.svg)
3
+ ![Documentation](https://github.com/volfpeter/holm/actions/workflows/build-docs.yml/badge.svg)
4
+ ![PyPI package](https://img.shields.io/pypi/v/holm?color=%2334D058&label=PyPI%20Package)
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)
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"