xtr-maker 6.0.0__py3-none-any.whl
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.
- xtr_maker/.agents/skills/xtr-maker/SKILL.md +192 -0
- xtr_maker/__init__.py +60 -0
- xtr_maker/bundle/__init__.py +8 -0
- xtr_maker/bundle/maker_bundle.py +112 -0
- xtr_maker/bundle/maker_config.py +36 -0
- xtr_maker/class_name.py +144 -0
- xtr_maker/command/__init__.py +9 -0
- xtr_maker/command/_request_matchers.py +134 -0
- xtr_maker/command/_schedule_source.py +143 -0
- xtr_maker/command/_webhook_names.py +136 -0
- xtr_maker/command/make_command_command.py +262 -0
- xtr_maker/command/make_entity_command.py +259 -0
- xtr_maker/command/make_listener_command.py +362 -0
- xtr_maker/command/make_message_command.py +226 -0
- xtr_maker/command/make_migration_command.py +116 -0
- xtr_maker/command/make_schedule_command.py +282 -0
- xtr_maker/command/make_webhook_command.py +182 -0
- xtr_maker/command_parameter.py +205 -0
- xtr_maker/command_source.py +187 -0
- xtr_maker/entity/__init__.py +42 -0
- xtr_maker/entity/entity_field.py +206 -0
- xtr_maker/entity/entity_interviewer.py +132 -0
- xtr_maker/entity/entity_relation.py +116 -0
- xtr_maker/entity/entity_source_editor.py +260 -0
- xtr_maker/entity/entity_workspace.py +137 -0
- xtr_maker/entity/field_type.py +50 -0
- xtr_maker/entity/id_type.py +53 -0
- xtr_maker/entity/import_merger.py +268 -0
- xtr_maker/entity/mapped_attribute.py +47 -0
- xtr_maker/entity/needed_import.py +121 -0
- xtr_maker/entity/property_name.py +50 -0
- xtr_maker/entity/relation_kind.py +48 -0
- xtr_maker/entity/relation_writer.py +239 -0
- xtr_maker/event/__init__.py +17 -0
- xtr_maker/event/event_registry.py +275 -0
- xtr_maker/event/known_event.py +38 -0
- xtr_maker/exception/__init__.py +27 -0
- xtr_maker/exception/app_package_not_found_error.py +34 -0
- xtr_maker/exception/generated_source_error.py +33 -0
- xtr_maker/exception/invalid_argument_error.py +14 -0
- xtr_maker/exception/invalid_class_name_error.py +29 -0
- xtr_maker/exception/maker_error.py +14 -0
- xtr_maker/exception/target_exists_error.py +28 -0
- xtr_maker/exception/unsafe_path_error.py +32 -0
- xtr_maker/file_manager.py +158 -0
- xtr_maker/generator.py +159 -0
- xtr_maker/import_collector.py +202 -0
- xtr_maker/name_converter.py +187 -0
- xtr_maker/py.typed +0 -0
- xtr_maker/recipe/__init__.py +14 -0
- xtr_maker/recipe/manifest.toml +20 -0
- xtr_maker/template_renderer.py +77 -0
- xtr_maker/templates/command/command.py.jinja +30 -0
- xtr_maker/templates/entity/association_table.py.jinja +15 -0
- xtr_maker/templates/entity/entity.py.jinja +20 -0
- xtr_maker/templates/entity/repository.py.jinja +26 -0
- xtr_maker/templates/listener/event_listener.py.jinja +19 -0
- xtr_maker/templates/listener/event_subscriber.py.jinja +24 -0
- xtr_maker/templates/message/message.py.jinja +18 -0
- xtr_maker/templates/message/message_handler.py.jinja +19 -0
- xtr_maker/templates/scheduler/schedule.py.jinja +35 -0
- xtr_maker/templates/webhook/request_parser.py.jinja +40 -0
- xtr_maker/templates/webhook/webhook_config.py.jinja +22 -0
- xtr_maker/templates/webhook/webhook_consumer.py.jinja +19 -0
- xtr_maker-6.0.0.dist-info/METADATA +547 -0
- xtr_maker-6.0.0.dist-info/RECORD +69 -0
- xtr_maker-6.0.0.dist-info/WHEEL +4 -0
- xtr_maker-6.0.0.dist-info/entry_points.txt +6 -0
- xtr_maker-6.0.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: xtr-maker
|
|
3
|
+
description: Generate application code for the xtr libraries with the make:* console commands of xtr-maker - messages and handlers, schedule providers, webhook parsers and consumers, entities and repositories, console commands, event listeners and subscribers, and migration revisions. Use when scaffolding any of those by hand, when asking which folder a generated class belongs in, when scripting a maker non-interactively with -n, when a maker is missing from `list make`, when a run is refused with TargetExistsError or InvalidClassNameError, when deciding what make:entity's --field and --relation accept, or when adding MakerBundle to an application on xtr-dependency-injection.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# xtr-maker
|
|
7
|
+
|
|
8
|
+
Dev-only console commands that write code: a class in the folder named after what it is, its
|
|
9
|
+
imports, its decorator, and whatever belongs beside it. Nothing is overwritten, nothing is
|
|
10
|
+
written outside the project, and no secret is ever generated.
|
|
11
|
+
|
|
12
|
+
## Quick reference
|
|
13
|
+
|
|
14
|
+
| Command | Writes | Needs this bundle active |
|
|
15
|
+
| --- | --- | --- |
|
|
16
|
+
| `make:message [name] [--transport T] [--wire-name N]` | `message/<snake>.py`, `message_handler/<snake>_handler.py` | `messenger` |
|
|
17
|
+
| `make:schedule [name] [--schedule S] [--message M] [--stateful] [--lock]` | `scheduler/<snake>_schedule.py` | `scheduler` |
|
|
18
|
+
| `make:webhook [name] [--matcher M]...` | `webhook/<snake>_request_parser.py`, `remote_event/<snake>_webhook_consumer.py`, `config/packages/webhook_<snake>.py` | `webhook` |
|
|
19
|
+
| `make:entity [name] [--field F]... [--relation R]... [--id uuid\|uuid7\|bigint] [--no-audit]` | `entity/<snake>.py`, `repository/<snake>_repository.py` | `orm` |
|
|
20
|
+
| `make:command [name] [--argument A]... [--option O]...` | `command/<snake>_command.py` | — |
|
|
21
|
+
| `make:listener [name] [event]` | `event_listener/<snake>_listener.py` or `event_subscriber/<snake>_subscriber.py` | `event_dispatcher` |
|
|
22
|
+
| `make:migration [--message M] [--connection C]` | a revision in the connection's migrations directory | `orm` |
|
|
23
|
+
|
|
24
|
+
- Anything left off the command line is asked; `-n` asks nothing and takes the defaults, but the
|
|
25
|
+
name always has to be given.
|
|
26
|
+
- A name may carry a subpackage: `ordering.PlaceOrder` lands in `message/ordering/place_order.py`.
|
|
27
|
+
- A run renders every file before it writes any, so a refusal leaves the working tree untouched.
|
|
28
|
+
- `list make` shows what the application has. A maker whose library's bundle is not active is not
|
|
29
|
+
registered.
|
|
30
|
+
|
|
31
|
+
## A message and its handler
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
uv run app make:message PlaceOrder --transport async -n
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Writes a frozen slotted dataclass under `@as_message(name="app.place_order.v1",
|
|
38
|
+
transport="async")` and a handler class whose `async def __call__(self, message)` takes it. The
|
|
39
|
+
transport is one of `MessageBusConfig.transports`; the wire name defaults to
|
|
40
|
+
`<package>.<snake>.v1` and is pinned, so moving the class later changes nothing on the wire.
|
|
41
|
+
|
|
42
|
+
## A schedule
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
uv run app make:schedule ReportSchedule --schedule reports --message RestockCheck -n
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Writes an `@as_schedule("reports")` provider sending `RestockCheck()` every hour. `--stateful`
|
|
49
|
+
needs the `cache` bundle active, `--lock` the `lock` bundle. A schedule another declared provider
|
|
50
|
+
provides is refused. Then: `messenger:consume scheduler_reports`.
|
|
51
|
+
|
|
52
|
+
## A webhook
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
uv run app make:webhook stripe --matcher Method --matcher IsJson -n
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Writes the parser, the consumer and `config/packages/webhook_stripe.py` — a `@configure`
|
|
59
|
+
transform adding `RouteConfig(parser=..., secret=env("STRIPE_WEBHOOK_SECRET"))`. Matchers are
|
|
60
|
+
`Attributes`, `Callable`, `Header`, `Host`, `Ips`, `IsJson`, `Method`, `Path`, `Port`,
|
|
61
|
+
`QueryParameter`, `Scheme`; several are chained. Set `STRIPE_WEBHOOK_SECRET` in `.env.local`
|
|
62
|
+
yourself, and make sure `config/routes/webhook.py` exposes `resources["webhook"]`.
|
|
63
|
+
|
|
64
|
+
## An entity
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
uv run app make:entity Book \
|
|
68
|
+
--field title:string:120 --field published_at:datetime? \
|
|
69
|
+
--relation author:many_to_one:Author,inverse=books -n
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
New: the entity on `UUIDAuditBase` (`--id uuid7|bigint`, `--no-audit` pick another base) in the
|
|
73
|
+
plural of its name, plus its repository. Existing: only the new properties are added, every other
|
|
74
|
+
line byte-identical, and no repository — `--id` and `--no-audit` are then refused. Field types:
|
|
75
|
+
`string`, `text`, `integer`, `big_integer`, `small_integer`, `boolean`, `float`, `decimal`,
|
|
76
|
+
`date`, `datetime`, `time`, `json`, `uuid`, `enum:<dotted class>`, `binary`; `?` makes a column
|
|
77
|
+
nullable. Relation kinds: `many_to_one`, `one_to_many`, `many_to_many`, `one_to_one`;
|
|
78
|
+
`inverse=none` adds nothing to the other entity. Then run `make:migration`.
|
|
79
|
+
|
|
80
|
+
## A console command
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
uv run app make:command app:send-report --argument recipient --option dry-run -n
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`app:send-report` becomes `SendReportCommand`. Argument types are `str`, `int`, `float`, `list`
|
|
87
|
+
and a dotted enum class; options add `bool`, which is their default. A list argument must be last,
|
|
88
|
+
a required argument may not follow an optional one, and `io` is reserved.
|
|
89
|
+
|
|
90
|
+
## A listener or a subscriber
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
uv run app make:listener WorkerStatsSubscriber WorkerStartedEvent -n
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
A name ending in `Subscriber` writes an `EventSubscriberInterface` with
|
|
97
|
+
`get_subscribed_events`; one ending in `Listener` writes a class whose `on_<event>` method carries
|
|
98
|
+
`@as_event_listener`. The event is a class name, `module:Class` or a dotted path. With `-n` it has
|
|
99
|
+
to resolve to a class: a plain dispatch name needs the confirmation question, so it is refused.
|
|
100
|
+
|
|
101
|
+
## A migration
|
|
102
|
+
|
|
103
|
+
```sh
|
|
104
|
+
uv run app make:migration --message books -n
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Compares the connection's database with its table definitions and lets its migrator write the
|
|
108
|
+
revision, as `orm:migrations:diff` does. Every revision must be applied first, or the run fails
|
|
109
|
+
with `Run orm:migrations:migrate -n first.` Nothing differing writes nothing and still succeeds.
|
|
110
|
+
|
|
111
|
+
## Testing
|
|
112
|
+
|
|
113
|
+
- Generated code is ordinary application code: test it the way the application tests the rest.
|
|
114
|
+
Nothing of xtr-maker is imported at runtime by what it writes.
|
|
115
|
+
- To script a maker inside a test or a setup script, pass `-n` and every value as an option: with
|
|
116
|
+
no terminal nothing is asked, so the run never blocks.
|
|
117
|
+
- Templates are rendered and then parsed, so a template that produces broken Python fails loudly
|
|
118
|
+
with `GeneratedSourceError` rather than writing a file.
|
|
119
|
+
- Run the project's own gate over what was written; it is formatted (split statements keep a
|
|
120
|
+
trailing comma, so any line length from 88 up accepts them) with sorted imports, so it passes
|
|
121
|
+
unchanged. It assumes `TC001`/`TC002`/`TC003` are off: command parameters, constructor
|
|
122
|
+
parameters and entity columns are annotations read at runtime. A two-way relation's entities
|
|
123
|
+
carry `# pyright: reportImportCycles=false` above their `TYPE_CHECKING` imports of each other.
|
|
124
|
+
|
|
125
|
+
## Use in an application
|
|
126
|
+
|
|
127
|
+
1. **Install** — `uv run xtr-recipes recipes:add --dev xtr-maker` (or `uv add --dev xtr-maker`,
|
|
128
|
+
then `uv run xtr-recipes recipes:sync`). Add extras for libraries the application has not got
|
|
129
|
+
yet: `"xtr-maker[messenger,scheduler,webhook,orm,event-dispatcher]"`.
|
|
130
|
+
2. **Recipe** — the recipe shipped here does step 3 and nothing else; it lists the bundle for
|
|
131
|
+
`dev`.
|
|
132
|
+
3. **Activate** — `from xtr_maker.bundle import MakerBundle` at the top of
|
|
133
|
+
`<app>/config/bundles.py`, and `MakerBundle: {"dev": True}` in `BUNDLES`, which the recipe
|
|
134
|
+
writes. Production without the development dependencies still starts: the kernel stands in for
|
|
135
|
+
the bundle there, which is not active, and never needs its package.
|
|
136
|
+
4. **Brings along** — the console bundle. The messenger, scheduler, webhook, orm and event
|
|
137
|
+
dispatcher bundles are read, never required: each decides which makers exist.
|
|
138
|
+
5. **Configure** — nothing to configure: the code goes into `kernel.package`, found where the
|
|
139
|
+
import system loads it, and classes are `@final`. To change either, write the settings into
|
|
140
|
+
the `dev` environment folder — production does not install this package, and only a `dev`
|
|
141
|
+
build imports that folder:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
# <app>/config/packages/dev/maker.py (beside an empty __init__.py)
|
|
145
|
+
from xtr_dependency_injection import configure
|
|
146
|
+
from xtr_maker.bundle import MakerConfig
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@configure
|
|
150
|
+
def maker() -> MakerConfig:
|
|
151
|
+
return MakerConfig(final_classes=False)
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
6. **Environment** — nothing. A maker that needs a secret prints the variable to set.
|
|
155
|
+
7. **Ignore** — nothing: what the makers write is code to commit.
|
|
156
|
+
8. **Remove** — `uv run xtr-recipes recipes:remove xtr-maker`: `uv remove --dev xtr-maker`, then a
|
|
157
|
+
sync that drops the `BUNDLES` entry. Delete `<app>/config/packages/dev/maker.py` if you wrote
|
|
158
|
+
one. The generated code stays yours.
|
|
159
|
+
9. **Check** — `debug:bundles` lists `maker` as active in `dev`; `list make` shows the makers.
|
|
160
|
+
|
|
161
|
+
## Errors
|
|
162
|
+
|
|
163
|
+
Every one derives from `MakerError`, in `xtr_maker.exception`:
|
|
164
|
+
|
|
165
|
+
| Error | Raised when |
|
|
166
|
+
| --- | --- |
|
|
167
|
+
| `AppPackageNotFoundError` | The import system finds no package of that name; the message names `MakerConfig.root_package` |
|
|
168
|
+
| `GeneratedSourceError` | A template rendered Python that does not parse; it names the template |
|
|
169
|
+
| `InvalidArgumentError` | An argument, option or answer cannot be used — also a `ValueError` |
|
|
170
|
+
| `InvalidClassNameError` | What was typed is no identifier, is a keyword, or would hide a builtin |
|
|
171
|
+
| `TargetExistsError` | The file exists, or another file of the same run claimed it — also a `FileExistsError` |
|
|
172
|
+
| `UnsafePathError` | A path leaves the project directory, or is a symbolic link |
|
|
173
|
+
|
|
174
|
+
A maker reports them and returns an exit code rather than a traceback: `ExitCode.INVALID` for
|
|
175
|
+
anything refused before the files are rendered, `ExitCode.FAILURE` for a target that exists or a
|
|
176
|
+
revision that could not be written, `ExitCode.SUCCESS` otherwise — nothing to write included.
|
|
177
|
+
|
|
178
|
+
## Do not
|
|
179
|
+
|
|
180
|
+
- Do not install it as a runtime dependency, and do not list `MakerBundle` for `all`: the makers
|
|
181
|
+
belong in `dev`.
|
|
182
|
+
- Do not put a `MakerConfig` in a module directly under `<app>/config/packages/`: every
|
|
183
|
+
environment imports those, and production, which does not install `xtr-maker`, fails to start.
|
|
184
|
+
Use `<app>/config/packages/dev/`.
|
|
185
|
+
- Do not expect a maker to overwrite a file, or to edit configuration. `make:webhook` adds a
|
|
186
|
+
transform module; `make:entity` is the only maker that writes an existing file, and only to add
|
|
187
|
+
properties to it.
|
|
188
|
+
- Do not wait for a maker to generate a secret or run a migration: it prints the step.
|
|
189
|
+
- Do not hand-place a generated class elsewhere and expect the next maker to find it — the
|
|
190
|
+
folders in the table above are where the makers look.
|
|
191
|
+
- Do not run a maker in `-n` mode and leave the name off; it is the one thing never defaulted.
|
|
192
|
+
- Do not run `make:migration` on a database behind its revisions; apply them first.
|
xtr_maker/__init__.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Console commands that write application code for the xtr libraries.
|
|
2
|
+
|
|
3
|
+
Every maker writes through the same pieces, usable on their own:
|
|
4
|
+
|
|
5
|
+
* :class:`~xtr_maker.class_name.ClassName` — what a person typed, as the class,
|
|
6
|
+
module and file under the folder for its kind.
|
|
7
|
+
* :class:`~xtr_maker.name_converter.NameConverter` — one name as a class, a
|
|
8
|
+
module, a console name or a table.
|
|
9
|
+
* :class:`~xtr_maker.import_collector.ImportCollector` — the imports a module
|
|
10
|
+
needs, grouped and sorted.
|
|
11
|
+
* :class:`~xtr_maker.template_renderer.TemplateRenderer` — a template rendered,
|
|
12
|
+
and refused when its Python does not parse.
|
|
13
|
+
* :class:`~xtr_maker.file_manager.FileManager` — the application package on
|
|
14
|
+
disk, and writes kept inside the project.
|
|
15
|
+
* :class:`~xtr_maker.generator.Generator` — the files of one run, queued, then
|
|
16
|
+
written together.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
22
|
+
|
|
23
|
+
from .class_name import ClassName
|
|
24
|
+
from .exception import (
|
|
25
|
+
AppPackageNotFoundError,
|
|
26
|
+
GeneratedSourceError,
|
|
27
|
+
InvalidArgumentError,
|
|
28
|
+
InvalidClassNameError,
|
|
29
|
+
MakerError,
|
|
30
|
+
TargetExistsError,
|
|
31
|
+
UnsafePathError,
|
|
32
|
+
)
|
|
33
|
+
from .file_manager import FileManager
|
|
34
|
+
from .generator import Generator
|
|
35
|
+
from .import_collector import ImportCollector
|
|
36
|
+
from .name_converter import NameConverter
|
|
37
|
+
from .template_renderer import TemplateRenderer
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
__version__ = version("xtr-maker")
|
|
41
|
+
except PackageNotFoundError: # pragma: no cover
|
|
42
|
+
# Running from a source tree with no installed metadata to read.
|
|
43
|
+
__version__ = "0+unknown"
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AppPackageNotFoundError",
|
|
47
|
+
"ClassName",
|
|
48
|
+
"FileManager",
|
|
49
|
+
"GeneratedSourceError",
|
|
50
|
+
"Generator",
|
|
51
|
+
"ImportCollector",
|
|
52
|
+
"InvalidArgumentError",
|
|
53
|
+
"InvalidClassNameError",
|
|
54
|
+
"MakerError",
|
|
55
|
+
"NameConverter",
|
|
56
|
+
"TargetExistsError",
|
|
57
|
+
"TemplateRenderer",
|
|
58
|
+
"UnsafePathError",
|
|
59
|
+
"__version__",
|
|
60
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The xtr-maker bundle: the generator the ``make:*`` commands share, and the commands.
|
|
2
|
+
|
|
3
|
+
Each maker writes code for one library, so its command is registered only when
|
|
4
|
+
that library's bundle is active: an application never sees a maker for a
|
|
5
|
+
library it does not use. Those bundles are not required from here: making code
|
|
6
|
+
for a library is no reason to switch its bundle on, so the maker only reads
|
|
7
|
+
which bundles the application activated.
|
|
8
|
+
|
|
9
|
+
Nothing touches the disk until a maker runs: the application package is
|
|
10
|
+
looked for the first time a maker needs it.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from typing import Final, final
|
|
16
|
+
|
|
17
|
+
from typing_extensions import override
|
|
18
|
+
from xtr_console.bundle import ConsoleBundle
|
|
19
|
+
from xtr_dependency_injection import (
|
|
20
|
+
Bundle,
|
|
21
|
+
ContainerBuilder,
|
|
22
|
+
# Imported at runtime: the container reads the factory annotations below.
|
|
23
|
+
KernelInterface,
|
|
24
|
+
ServiceConfigurator,
|
|
25
|
+
as_bundle,
|
|
26
|
+
bundle_active,
|
|
27
|
+
required_bundle,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
from xtr_maker.file_manager import FileManager
|
|
31
|
+
from xtr_maker.generator import Generator
|
|
32
|
+
from xtr_maker.template_renderer import TemplateRenderer
|
|
33
|
+
|
|
34
|
+
from .maker_config import MakerConfig
|
|
35
|
+
|
|
36
|
+
__all__ = ["MakerBundle"]
|
|
37
|
+
|
|
38
|
+
_MAKERS: Final[tuple[tuple[str, str | None], ...]] = (
|
|
39
|
+
("xtr_maker.command.make_message_command", "messenger"),
|
|
40
|
+
("xtr_maker.command.make_schedule_command", "scheduler"),
|
|
41
|
+
("xtr_maker.command.make_webhook_command", "webhook"),
|
|
42
|
+
("xtr_maker.command.make_entity_command", "orm"),
|
|
43
|
+
("xtr_maker.command.make_command_command", None),
|
|
44
|
+
("xtr_maker.command.make_listener_command", "event_dispatcher"),
|
|
45
|
+
("xtr_maker.command.make_migration_command", "orm"),
|
|
46
|
+
)
|
|
47
|
+
"""Each maker's command module, and the bundle that must be active for it (``None``: none)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@final
|
|
51
|
+
@required_bundle(ConsoleBundle)
|
|
52
|
+
@as_bundle("maker", config=MakerConfig)
|
|
53
|
+
class MakerBundle(Bundle[MakerConfig]):
|
|
54
|
+
"""Registers the generator, and the makers whose library's bundle is active."""
|
|
55
|
+
|
|
56
|
+
@override
|
|
57
|
+
def load_extension(
|
|
58
|
+
self,
|
|
59
|
+
config: MakerConfig,
|
|
60
|
+
services: ServiceConfigurator,
|
|
61
|
+
builder: ContainerBuilder,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Register the generator's services, then the command module of each maker in reach.
|
|
64
|
+
|
|
65
|
+
The generator is scoped: a maker run is one unit of work, which queues
|
|
66
|
+
its own files, and a run that fails leaves nothing queued for the next.
|
|
67
|
+
"""
|
|
68
|
+
del config
|
|
69
|
+
_ = services.set(_template_renderer)
|
|
70
|
+
_ = services.set(_file_manager)
|
|
71
|
+
_ = services.set(_generator, lifetime="scoped")
|
|
72
|
+
if bundle_active(builder, "webhook"):
|
|
73
|
+
_set_routed_webhook_types(builder)
|
|
74
|
+
for module, peer in _MAKERS:
|
|
75
|
+
if peer is None or bundle_active(builder, peer):
|
|
76
|
+
services.load(module)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _set_routed_webhook_types(builder: ContainerBuilder) -> None:
|
|
80
|
+
"""Give ``make:webhook`` the types the webhook configuration routes, read as it is built.
|
|
81
|
+
|
|
82
|
+
Read here rather than injected: the configuration a service is given has
|
|
83
|
+
every secret resolved, so an application whose secrets are not set yet
|
|
84
|
+
could not make its next webhook.
|
|
85
|
+
"""
|
|
86
|
+
# Imported only now: xtr-webhook is optional, and only an active webhook bundle
|
|
87
|
+
# gets the maker's module loaded.
|
|
88
|
+
from xtr_webhook.bundle import WebhookConfig # noqa: PLC0415 — optional, see above.
|
|
89
|
+
|
|
90
|
+
from xtr_maker.command.make_webhook_command import ( # noqa: PLC0415 — see above.
|
|
91
|
+
ROUTED_TYPES_PARAMETER,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
config = builder.get_extension_config(WebhookConfig)
|
|
95
|
+
builder.set_parameter(ROUTED_TYPES_PARAMETER, sorted(config.routing))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _template_renderer() -> TemplateRenderer:
|
|
99
|
+
"""Render the templates this package ships."""
|
|
100
|
+
return TemplateRenderer()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _file_manager(kernel: KernelInterface, config: MakerConfig) -> FileManager:
|
|
104
|
+
"""Write into the configured package, else the kernel's own."""
|
|
105
|
+
return FileManager(kernel.project_dir, config.root_package or kernel.package)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _generator(
|
|
109
|
+
file_manager: FileManager, template_renderer: TemplateRenderer, config: MakerConfig
|
|
110
|
+
) -> Generator:
|
|
111
|
+
"""Build a generator with an empty queue, marking classes ``@final`` as configured."""
|
|
112
|
+
return Generator(file_manager, template_renderer, final_classes=config.final_classes)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Configuration for :class:`~xtr_maker.bundle.maker_bundle.MakerBundle`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from xtr_maker.exception.invalid_argument_error import InvalidArgumentError
|
|
8
|
+
|
|
9
|
+
__all__ = ["MakerConfig"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class MakerConfig:
|
|
14
|
+
"""Where the makers write, and how the classes they write are declared.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
root_package: The dotted application package the makers write into.
|
|
18
|
+
``None`` takes the kernel's package, ``kernel.package``; set it
|
|
19
|
+
only to write into another one.
|
|
20
|
+
final_classes: Whether generated classes are marked ``@final``.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
InvalidArgumentError: When ``root_package`` is not a dotted Python
|
|
24
|
+
identifier.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
root_package: str | None = None
|
|
28
|
+
final_classes: bool = True
|
|
29
|
+
|
|
30
|
+
def __post_init__(self) -> None:
|
|
31
|
+
"""Refuse a package no import statement could name."""
|
|
32
|
+
if self.root_package is not None and not all(
|
|
33
|
+
part.isidentifier() for part in self.root_package.split(".")
|
|
34
|
+
):
|
|
35
|
+
message = f"root_package {self.root_package!r} is not a dotted Python identifier"
|
|
36
|
+
raise InvalidArgumentError(message)
|
xtr_maker/class_name.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""ClassName: what a person typed, as the class, module and file a maker writes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
import keyword
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
from typing import Final, final
|
|
11
|
+
|
|
12
|
+
from .exception import InvalidClassNameError
|
|
13
|
+
from .name_converter import NameConverter
|
|
14
|
+
|
|
15
|
+
__all__ = ["ClassName"]
|
|
16
|
+
|
|
17
|
+
_TYPED: Final = re.compile(r"[A-Za-z0-9_ ./-]+")
|
|
18
|
+
"""What a typed name may hold: words, and the separators between them and their packages."""
|
|
19
|
+
|
|
20
|
+
_PACKAGE_SEPARATOR: Final = re.compile(r"[./]")
|
|
21
|
+
|
|
22
|
+
_BUILTINS: Final = frozenset(dir(builtins))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@final
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class ClassName:
|
|
28
|
+
"""A class a maker writes: its name, the package it lives in, and its file.
|
|
29
|
+
|
|
30
|
+
Built with :meth:`create` from what a person typed; built directly, every
|
|
31
|
+
part is checked the same way. The package is the folder for the kind of
|
|
32
|
+
class (``message_handler``) under the application package, plus the
|
|
33
|
+
subpackages the person typed before the name (``ordering.PlaceOrder``)::
|
|
34
|
+
|
|
35
|
+
name = ClassName.create(
|
|
36
|
+
"ordering.place order", root_package="app", kind="message_handler", suffix="Handler"
|
|
37
|
+
)
|
|
38
|
+
name.short_name # "PlaceOrderHandler"
|
|
39
|
+
name.module # "app.message_handler.ordering.place_order_handler"
|
|
40
|
+
name.path # PurePosixPath("message_handler/ordering/place_order_handler.py")
|
|
41
|
+
|
|
42
|
+
Attributes:
|
|
43
|
+
root_package: The application package, dotted.
|
|
44
|
+
package: The package under it, one entry per folder.
|
|
45
|
+
short_name: The class's own name.
|
|
46
|
+
suffix: The ending every class of its kind carries, ``""`` for none.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
InvalidClassNameError: When the class name is not an identifier, is a
|
|
50
|
+
keyword, or shadows a builtin, or a package part is not an
|
|
51
|
+
identifier or is a keyword.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
root_package: str
|
|
55
|
+
package: tuple[str, ...]
|
|
56
|
+
short_name: str
|
|
57
|
+
suffix: str = ""
|
|
58
|
+
|
|
59
|
+
def __post_init__(self) -> None:
|
|
60
|
+
"""Refuse any part that would not import, or would hide a name the code relies on."""
|
|
61
|
+
_check_class(self.short_name)
|
|
62
|
+
for part in (*self.root_package.split("."), *self.package, self.snake_name):
|
|
63
|
+
_check_module(part, self.short_name)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def create(cls, name: str, *, root_package: str, kind: str, suffix: str = "") -> ClassName:
|
|
67
|
+
"""Resolve what a person typed into the class a maker writes.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name: The class name as typed — ``place order``, ``PlaceOrder`` —
|
|
71
|
+
optionally after subpackages, separated by ``.`` or ``/``:
|
|
72
|
+
``ordering.PlaceOrder`` puts the class in ``<kind>/ordering/``.
|
|
73
|
+
root_package: The application package, dotted.
|
|
74
|
+
kind: The folder for the kind of class, dotted when nested
|
|
75
|
+
(``security.voter``).
|
|
76
|
+
suffix: The ending every class of its kind carries; added unless
|
|
77
|
+
the name already ends with it.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
The class, its package and its suffix.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
InvalidClassNameError: When ``name`` is empty, holds a character no
|
|
84
|
+
name can, or resolves to a part :class:`ClassName` refuses.
|
|
85
|
+
"""
|
|
86
|
+
typed = name.strip()
|
|
87
|
+
if not typed or _TYPED.fullmatch(typed) is None:
|
|
88
|
+
raise InvalidClassNameError(
|
|
89
|
+
name, "use letters, digits, spaces, '_' or '-', and '.' before a subpackage"
|
|
90
|
+
)
|
|
91
|
+
*subpackages, last = _PACKAGE_SEPARATOR.split(typed)
|
|
92
|
+
short_name = NameConverter.as_class_name(last)
|
|
93
|
+
if suffix and not short_name.endswith(suffix):
|
|
94
|
+
short_name += suffix
|
|
95
|
+
package = (
|
|
96
|
+
*kind.split("."),
|
|
97
|
+
*(NameConverter.as_snake_case(part) for part in subpackages),
|
|
98
|
+
)
|
|
99
|
+
return cls(root_package=root_package, package=package, short_name=short_name, suffix=suffix)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def name_without_suffix(self) -> str:
|
|
103
|
+
"""The class name without its kind's suffix: ``PlaceOrderHandler`` → ``PlaceOrder``."""
|
|
104
|
+
if self.suffix and self.short_name != self.suffix:
|
|
105
|
+
return self.short_name.removesuffix(self.suffix)
|
|
106
|
+
return self.short_name
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def snake_name(self) -> str:
|
|
110
|
+
"""The module's own name: ``PlaceOrderHandler`` → ``place_order_handler``."""
|
|
111
|
+
return NameConverter.as_snake_case(self.short_name)
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def package_name(self) -> str:
|
|
115
|
+
"""The dotted package the module lives in."""
|
|
116
|
+
return ".".join((self.root_package, *self.package))
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def module(self) -> str:
|
|
120
|
+
"""The dotted module the class is imported from."""
|
|
121
|
+
return f"{self.package_name}.{self.snake_name}"
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def path(self) -> PurePosixPath:
|
|
125
|
+
"""The module's file, relative to the application package's directory."""
|
|
126
|
+
return PurePosixPath(*self.package, f"{self.snake_name}.py")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _check_class(short_name: str) -> None:
|
|
130
|
+
"""Refuse a class name that does not parse, or that hides a builtin."""
|
|
131
|
+
if not short_name.isidentifier():
|
|
132
|
+
raise InvalidClassNameError(short_name, "it is not a Python identifier")
|
|
133
|
+
if keyword.iskeyword(short_name) or keyword.issoftkeyword(short_name):
|
|
134
|
+
raise InvalidClassNameError(short_name, "it is a Python keyword")
|
|
135
|
+
if short_name in _BUILTINS:
|
|
136
|
+
raise InvalidClassNameError(short_name, "it would hide the builtin of the same name")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _check_module(part: str, short_name: str) -> None:
|
|
140
|
+
"""Refuse a package or module name an import statement cannot spell."""
|
|
141
|
+
if not part.isidentifier():
|
|
142
|
+
raise InvalidClassNameError(short_name, f"module {part!r} is not a Python identifier")
|
|
143
|
+
if keyword.iskeyword(part):
|
|
144
|
+
raise InvalidClassNameError(short_name, f"module {part!r} is a Python keyword")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""The ``make:*`` commands, one module each.
|
|
2
|
+
|
|
3
|
+
Nothing is imported here: each module imports the library it writes code for,
|
|
4
|
+
and the bundle loads it only when that library's bundle is active.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__all__: list[str] = []
|