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.
Files changed (69) hide show
  1. xtr_maker/.agents/skills/xtr-maker/SKILL.md +192 -0
  2. xtr_maker/__init__.py +60 -0
  3. xtr_maker/bundle/__init__.py +8 -0
  4. xtr_maker/bundle/maker_bundle.py +112 -0
  5. xtr_maker/bundle/maker_config.py +36 -0
  6. xtr_maker/class_name.py +144 -0
  7. xtr_maker/command/__init__.py +9 -0
  8. xtr_maker/command/_request_matchers.py +134 -0
  9. xtr_maker/command/_schedule_source.py +143 -0
  10. xtr_maker/command/_webhook_names.py +136 -0
  11. xtr_maker/command/make_command_command.py +262 -0
  12. xtr_maker/command/make_entity_command.py +259 -0
  13. xtr_maker/command/make_listener_command.py +362 -0
  14. xtr_maker/command/make_message_command.py +226 -0
  15. xtr_maker/command/make_migration_command.py +116 -0
  16. xtr_maker/command/make_schedule_command.py +282 -0
  17. xtr_maker/command/make_webhook_command.py +182 -0
  18. xtr_maker/command_parameter.py +205 -0
  19. xtr_maker/command_source.py +187 -0
  20. xtr_maker/entity/__init__.py +42 -0
  21. xtr_maker/entity/entity_field.py +206 -0
  22. xtr_maker/entity/entity_interviewer.py +132 -0
  23. xtr_maker/entity/entity_relation.py +116 -0
  24. xtr_maker/entity/entity_source_editor.py +260 -0
  25. xtr_maker/entity/entity_workspace.py +137 -0
  26. xtr_maker/entity/field_type.py +50 -0
  27. xtr_maker/entity/id_type.py +53 -0
  28. xtr_maker/entity/import_merger.py +268 -0
  29. xtr_maker/entity/mapped_attribute.py +47 -0
  30. xtr_maker/entity/needed_import.py +121 -0
  31. xtr_maker/entity/property_name.py +50 -0
  32. xtr_maker/entity/relation_kind.py +48 -0
  33. xtr_maker/entity/relation_writer.py +239 -0
  34. xtr_maker/event/__init__.py +17 -0
  35. xtr_maker/event/event_registry.py +275 -0
  36. xtr_maker/event/known_event.py +38 -0
  37. xtr_maker/exception/__init__.py +27 -0
  38. xtr_maker/exception/app_package_not_found_error.py +34 -0
  39. xtr_maker/exception/generated_source_error.py +33 -0
  40. xtr_maker/exception/invalid_argument_error.py +14 -0
  41. xtr_maker/exception/invalid_class_name_error.py +29 -0
  42. xtr_maker/exception/maker_error.py +14 -0
  43. xtr_maker/exception/target_exists_error.py +28 -0
  44. xtr_maker/exception/unsafe_path_error.py +32 -0
  45. xtr_maker/file_manager.py +158 -0
  46. xtr_maker/generator.py +159 -0
  47. xtr_maker/import_collector.py +202 -0
  48. xtr_maker/name_converter.py +187 -0
  49. xtr_maker/py.typed +0 -0
  50. xtr_maker/recipe/__init__.py +14 -0
  51. xtr_maker/recipe/manifest.toml +20 -0
  52. xtr_maker/template_renderer.py +77 -0
  53. xtr_maker/templates/command/command.py.jinja +30 -0
  54. xtr_maker/templates/entity/association_table.py.jinja +15 -0
  55. xtr_maker/templates/entity/entity.py.jinja +20 -0
  56. xtr_maker/templates/entity/repository.py.jinja +26 -0
  57. xtr_maker/templates/listener/event_listener.py.jinja +19 -0
  58. xtr_maker/templates/listener/event_subscriber.py.jinja +24 -0
  59. xtr_maker/templates/message/message.py.jinja +18 -0
  60. xtr_maker/templates/message/message_handler.py.jinja +19 -0
  61. xtr_maker/templates/scheduler/schedule.py.jinja +35 -0
  62. xtr_maker/templates/webhook/request_parser.py.jinja +40 -0
  63. xtr_maker/templates/webhook/webhook_config.py.jinja +22 -0
  64. xtr_maker/templates/webhook/webhook_consumer.py.jinja +19 -0
  65. xtr_maker-6.0.0.dist-info/METADATA +547 -0
  66. xtr_maker-6.0.0.dist-info/RECORD +69 -0
  67. xtr_maker-6.0.0.dist-info/WHEEL +4 -0
  68. xtr_maker-6.0.0.dist-info/entry_points.txt +6 -0
  69. 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,8 @@
1
+ """The xtr-dependency-injection bundle for xtr-maker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .maker_bundle import MakerBundle
6
+ from .maker_config import MakerConfig
7
+
8
+ __all__ = ["MakerBundle", "MakerConfig"]
@@ -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)
@@ -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] = []