xtr-maker 6.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. xtr_maker-6.0.0/LICENSE +22 -0
  2. xtr_maker-6.0.0/PKG-INFO +547 -0
  3. xtr_maker-6.0.0/README.md +513 -0
  4. xtr_maker-6.0.0/pyproject.toml +171 -0
  5. xtr_maker-6.0.0/pyproject.toml.orig +181 -0
  6. xtr_maker-6.0.0/src/xtr_maker/.agents/skills/xtr-maker/SKILL.md +192 -0
  7. xtr_maker-6.0.0/src/xtr_maker/__init__.py +60 -0
  8. xtr_maker-6.0.0/src/xtr_maker/bundle/__init__.py +8 -0
  9. xtr_maker-6.0.0/src/xtr_maker/bundle/maker_bundle.py +112 -0
  10. xtr_maker-6.0.0/src/xtr_maker/bundle/maker_config.py +36 -0
  11. xtr_maker-6.0.0/src/xtr_maker/class_name.py +144 -0
  12. xtr_maker-6.0.0/src/xtr_maker/command/__init__.py +9 -0
  13. xtr_maker-6.0.0/src/xtr_maker/command/_request_matchers.py +134 -0
  14. xtr_maker-6.0.0/src/xtr_maker/command/_schedule_source.py +143 -0
  15. xtr_maker-6.0.0/src/xtr_maker/command/_webhook_names.py +136 -0
  16. xtr_maker-6.0.0/src/xtr_maker/command/make_command_command.py +262 -0
  17. xtr_maker-6.0.0/src/xtr_maker/command/make_entity_command.py +259 -0
  18. xtr_maker-6.0.0/src/xtr_maker/command/make_listener_command.py +362 -0
  19. xtr_maker-6.0.0/src/xtr_maker/command/make_message_command.py +226 -0
  20. xtr_maker-6.0.0/src/xtr_maker/command/make_migration_command.py +116 -0
  21. xtr_maker-6.0.0/src/xtr_maker/command/make_schedule_command.py +282 -0
  22. xtr_maker-6.0.0/src/xtr_maker/command/make_webhook_command.py +182 -0
  23. xtr_maker-6.0.0/src/xtr_maker/command_parameter.py +205 -0
  24. xtr_maker-6.0.0/src/xtr_maker/command_source.py +187 -0
  25. xtr_maker-6.0.0/src/xtr_maker/entity/__init__.py +42 -0
  26. xtr_maker-6.0.0/src/xtr_maker/entity/entity_field.py +206 -0
  27. xtr_maker-6.0.0/src/xtr_maker/entity/entity_interviewer.py +132 -0
  28. xtr_maker-6.0.0/src/xtr_maker/entity/entity_relation.py +116 -0
  29. xtr_maker-6.0.0/src/xtr_maker/entity/entity_source_editor.py +260 -0
  30. xtr_maker-6.0.0/src/xtr_maker/entity/entity_workspace.py +137 -0
  31. xtr_maker-6.0.0/src/xtr_maker/entity/field_type.py +50 -0
  32. xtr_maker-6.0.0/src/xtr_maker/entity/id_type.py +53 -0
  33. xtr_maker-6.0.0/src/xtr_maker/entity/import_merger.py +268 -0
  34. xtr_maker-6.0.0/src/xtr_maker/entity/mapped_attribute.py +47 -0
  35. xtr_maker-6.0.0/src/xtr_maker/entity/needed_import.py +121 -0
  36. xtr_maker-6.0.0/src/xtr_maker/entity/property_name.py +50 -0
  37. xtr_maker-6.0.0/src/xtr_maker/entity/relation_kind.py +48 -0
  38. xtr_maker-6.0.0/src/xtr_maker/entity/relation_writer.py +239 -0
  39. xtr_maker-6.0.0/src/xtr_maker/event/__init__.py +17 -0
  40. xtr_maker-6.0.0/src/xtr_maker/event/event_registry.py +275 -0
  41. xtr_maker-6.0.0/src/xtr_maker/event/known_event.py +38 -0
  42. xtr_maker-6.0.0/src/xtr_maker/exception/__init__.py +27 -0
  43. xtr_maker-6.0.0/src/xtr_maker/exception/app_package_not_found_error.py +34 -0
  44. xtr_maker-6.0.0/src/xtr_maker/exception/generated_source_error.py +33 -0
  45. xtr_maker-6.0.0/src/xtr_maker/exception/invalid_argument_error.py +14 -0
  46. xtr_maker-6.0.0/src/xtr_maker/exception/invalid_class_name_error.py +29 -0
  47. xtr_maker-6.0.0/src/xtr_maker/exception/maker_error.py +14 -0
  48. xtr_maker-6.0.0/src/xtr_maker/exception/target_exists_error.py +28 -0
  49. xtr_maker-6.0.0/src/xtr_maker/exception/unsafe_path_error.py +32 -0
  50. xtr_maker-6.0.0/src/xtr_maker/file_manager.py +158 -0
  51. xtr_maker-6.0.0/src/xtr_maker/generator.py +159 -0
  52. xtr_maker-6.0.0/src/xtr_maker/import_collector.py +202 -0
  53. xtr_maker-6.0.0/src/xtr_maker/name_converter.py +187 -0
  54. xtr_maker-6.0.0/src/xtr_maker/py.typed +0 -0
  55. xtr_maker-6.0.0/src/xtr_maker/recipe/__init__.py +14 -0
  56. xtr_maker-6.0.0/src/xtr_maker/recipe/manifest.toml +20 -0
  57. xtr_maker-6.0.0/src/xtr_maker/template_renderer.py +77 -0
  58. xtr_maker-6.0.0/src/xtr_maker/templates/command/command.py.jinja +30 -0
  59. xtr_maker-6.0.0/src/xtr_maker/templates/entity/association_table.py.jinja +15 -0
  60. xtr_maker-6.0.0/src/xtr_maker/templates/entity/entity.py.jinja +20 -0
  61. xtr_maker-6.0.0/src/xtr_maker/templates/entity/repository.py.jinja +26 -0
  62. xtr_maker-6.0.0/src/xtr_maker/templates/listener/event_listener.py.jinja +19 -0
  63. xtr_maker-6.0.0/src/xtr_maker/templates/listener/event_subscriber.py.jinja +24 -0
  64. xtr_maker-6.0.0/src/xtr_maker/templates/message/message.py.jinja +18 -0
  65. xtr_maker-6.0.0/src/xtr_maker/templates/message/message_handler.py.jinja +19 -0
  66. xtr_maker-6.0.0/src/xtr_maker/templates/scheduler/schedule.py.jinja +35 -0
  67. xtr_maker-6.0.0/src/xtr_maker/templates/webhook/request_parser.py.jinja +40 -0
  68. xtr_maker-6.0.0/src/xtr_maker/templates/webhook/webhook_config.py.jinja +22 -0
  69. xtr_maker-6.0.0/src/xtr_maker/templates/webhook/webhook_consumer.py.jinja +19 -0
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xterr
4
+ Copyright (c) 2004-2020 Fabien Potencier
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,547 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-maker
3
+ Version: 6.0.0
4
+ Summary: Console commands that write application code for the xtr libraries: messages, schedules, webhooks, entities, commands and listeners.
5
+ Keywords: generator,scaffolding,code generation,console,templates
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Typing :: Typed
17
+ Requires-Dist: jinja2>=3.1
18
+ Requires-Dist: libcst>=1.4
19
+ Requires-Dist: typing-extensions>=4.4
20
+ Requires-Dist: xtr-console>=6.0,<7
21
+ Requires-Dist: xtr-dependency-injection>=6.0,<7
22
+ Requires-Dist: xtr-event-dispatcher>=6.0,<7 ; extra == 'event-dispatcher'
23
+ Requires-Dist: xtr-messenger>=6.0,<7 ; extra == 'messenger'
24
+ Requires-Dist: xtr-orm>=6.0,<7 ; extra == 'orm'
25
+ Requires-Dist: xtr-scheduler>=6.0,<7 ; extra == 'scheduler'
26
+ Requires-Dist: xtr-webhook>=6.0,<7 ; extra == 'webhook'
27
+ Requires-Python: >=3.11
28
+ Provides-Extra: event-dispatcher
29
+ Provides-Extra: messenger
30
+ Provides-Extra: orm
31
+ Provides-Extra: scheduler
32
+ Provides-Extra: webhook
33
+ Description-Content-Type: text/markdown
34
+
35
+ <div align="center">
36
+
37
+ # xtr-maker
38
+
39
+ **Console commands that write application code for the xtr libraries.**
40
+
41
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
42
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
43
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
44
+
45
+ </div>
46
+
47
+ ---
48
+
49
+ ## Why?
50
+
51
+ Every message, schedule, webhook, entity, command or listener starts as the same few files: a
52
+ class in the right folder, its imports, its decorator, a handler beside it. The `make:*`
53
+ commands write those files — named and placed the way the rest of the application is, formatted
54
+ the way the linter wants, and refused rather than overwritten when one already exists.
55
+
56
+ They are development tools: install the package as a development dependency and list its bundle
57
+ for the `dev` environment. Nothing reaches production, and nothing is written until a maker runs.
58
+
59
+ ## Install
60
+
61
+ ```sh
62
+ uv add --dev xtr-maker
63
+ ```
64
+
65
+ The makers write code for libraries, so each one is offered only when that library's bundle is
66
+ active. An extra installs the library beside the makers, for an application that has not got it
67
+ yet:
68
+
69
+ ```sh
70
+ uv add --dev "xtr-maker[messenger,scheduler,webhook,orm,event-dispatcher]"
71
+ ```
72
+
73
+ ## The makers
74
+
75
+ | Command | Writes | Listed when this bundle is active |
76
+ | --- | --- | --- |
77
+ | [`make:message`](#makemessage) | A message and its handler | `messenger` |
78
+ | [`make:schedule`](#makeschedule) | The provider of a schedule | `scheduler` |
79
+ | [`make:webhook`](#makewebhook) | A webhook's parser, consumer and route | `webhook` |
80
+ | [`make:entity`](#makeentity) | An entity and its repository, or fields on one | `orm` |
81
+ | [`make:command`](#makecommand) | A console command class | — (the console bundle alone) |
82
+ | [`make:listener`](#makelistener) | A listener or a subscriber of one event | `event_dispatcher` |
83
+ | [`make:migration`](#makemigration) | A revision of what the tables changed | `orm` |
84
+
85
+ `list make` shows the ones an application has. A maker whose bundle is not active is not
86
+ registered at all: there is no command that tells you to install something.
87
+
88
+ ## Where the code goes
89
+
90
+ Each class goes in the folder named after what it is, under the application package, one public
91
+ class per module:
92
+
93
+ | Folder | Module | Written by |
94
+ | --- | --- | --- |
95
+ | `entity/` | `<snake>.py` | `make:entity` |
96
+ | `repository/` | `<snake>_repository.py` | `make:entity` |
97
+ | `message/` | `<snake>.py` | `make:message` |
98
+ | `message_handler/` | `<snake>_handler.py` | `make:message` |
99
+ | `scheduler/` | `<snake>_schedule.py` | `make:schedule` |
100
+ | `webhook/` | `<snake>_request_parser.py` | `make:webhook` |
101
+ | `remote_event/` | `<snake>_webhook_consumer.py` | `make:webhook` |
102
+ | `config/packages/` | `webhook_<snake>.py` | `make:webhook` |
103
+ | `command/` | `<snake>_command.py` | `make:command` |
104
+ | `event_listener/` | `<snake>_listener.py` | `make:listener` |
105
+ | `event_subscriber/` | `<snake>_subscriber.py` | `make:listener` |
106
+
107
+ A name may carry a subpackage: `make:message ordering.PlaceOrder` writes
108
+ `message/ordering/place_order.py`. A folder with no `__init__.py` gets one declaring nothing, so
109
+ the module imports by its dotted name and the container's scan finds it.
110
+
111
+ ## `make:message`
112
+
113
+ ```
114
+ make:message [name] [--transport TRANSPORT] [--wire-name WIRE_NAME]
115
+ ```
116
+
117
+ Writes `message/<snake>.py` — a frozen slotted dataclass declared with `@as_message` — and
118
+ `message_handler/<snake>_handler.py`, a class whose `async def __call__(self, message)` takes it.
119
+
120
+ | Asked | Answer | Default |
121
+ | --- | --- | --- |
122
+ | The message's class name | `PlaceOrder`, `"place order"`, `ordering.PlaceOrder` | none: it must be answered |
123
+ | The transport it goes to when no route names one | `[no transport]`, or one of `MessageBusConfig.transports` | `[no transport]` |
124
+ | The name it travels under | any non-empty text | `<package>.<snake>.v1` |
125
+
126
+ - The transport question is skipped when the bus has no transport configured; a `--transport`
127
+ that names none is refused, listing the configured ones.
128
+ - The wire name is pinned in the decorator, so moving the class later changes nothing on the wire.
129
+ - With `-n`: the name must be on the command line, the transport is none unless `--transport`
130
+ names one, and the wire name is its default.
131
+
132
+ ```sh
133
+ $ uv run app make:message PlaceOrder --transport async -n
134
+ created: src/app/message/place_order.py
135
+ created: src/app/message_handler/place_order_handler.py
136
+
137
+ [OK] Wrote PlaceOrder and PlaceOrderHandler.
138
+
139
+ Next:
140
+ - Open src/app/message/place_order.py and add its fields.
141
+ - Open src/app/message_handler/place_order_handler.py and write what handling it does.
142
+ ```
143
+
144
+ ## `make:schedule`
145
+
146
+ ```
147
+ make:schedule [name] [--schedule SCHEDULE] [--message MESSAGE] [--stateful] [--lock]
148
+ ```
149
+
150
+ Writes `scheduler/<snake>_schedule.py`: an `@as_schedule("<schedule>")` provider deriving from
151
+ `ScheduleProviderInterface`, which builds its `Schedule` once in its constructor.
152
+
153
+ | Asked | Answer | Default |
154
+ | --- | --- | --- |
155
+ | The provider's class name | `ReportSchedule`, `"report"` — `Schedule` is appended if missing | none: it must be answered |
156
+ | The schedule it provides | letters, digits, `_`, `-` or `.` | `default` |
157
+ | The message it sends every hour | `[empty schedule]`, or one of the application's `@as_message` classes | `[empty schedule]` |
158
+ | Keep what already ran in the scheduler cache pool? | yes/no, asked only with the `cache` bundle active | no |
159
+ | Send from the worker holding a lock only? | yes/no, asked only with the `lock` bundle active | no |
160
+
161
+ - The messages offered are the `@as_message` classes of the application package, by class name,
162
+ or `module:Class` when two share one. A message whose constructor takes arguments is written as
163
+ a comment instead, so the module imports and the kernel boots before its fields are known.
164
+ - A schedule another declared provider already provides is refused. One that only tasks are
165
+ declared on is free: the tasks join this schedule.
166
+ - `--stateful` without the `cache` bundle, or `--lock` without the `lock` bundle, is refused,
167
+ naming the bundle to activate.
168
+ - With `-n`: the name must be given, the schedule is `default`, and nothing is sent, kept or
169
+ locked unless the options say so.
170
+
171
+ ```sh
172
+ $ uv run app make:schedule ReportSchedule --schedule reports -n
173
+ created: src/app/scheduler/report_schedule.py
174
+
175
+ [OK] Wrote ReportSchedule, which provides the reports schedule.
176
+
177
+ Next:
178
+ - Open src/app/scheduler/report_schedule.py and set what it sends, and how often.
179
+ - Run it: messenger:consume scheduler_reports
180
+ ```
181
+
182
+ ## `make:webhook`
183
+
184
+ ```
185
+ make:webhook [name] [--matcher MATCHER]...
186
+ ```
187
+
188
+ The name is the webhook type: the last segment of the URL its sender posts to, and the source
189
+ name of the events it carries. Three modules come out of it:
190
+
191
+ | Module | Holds |
192
+ | --- | --- |
193
+ | `webhook/<snake>_request_parser.py` | An `AbstractRequestParser`: `get_request_matcher` claims the sender's requests, `do_parse` compares the signature header with the secret and reads a `RemoteEvent` from the JSON body |
194
+ | `remote_event/<snake>_webhook_consumer.py` | An `@as_remote_event_consumer("<type>")` class with `async def consume(self, event)` |
195
+ | `config/packages/webhook_<snake>.py` | A `@configure` transform adding a `RouteConfig(parser=..., secret=env("<TYPE>_WEBHOOK_SECRET"))` to whatever routing is configured |
196
+
197
+ The matchers are asked for one at a time — `[done]`, then every one not picked yet — whether or
198
+ not the name was given, each written with placeholder arguments to replace:
199
+
200
+ `Attributes`, `Callable`, `Header`, `Host`, `Ips`, `IsJson`, `Method`, `Path`, `Port`,
201
+ `QueryParameter`, `Scheme`.
202
+
203
+ - One matcher is returned as it is; several are wrapped in a `ChainRequestMatcher`; none writes
204
+ an empty chain, with a comment saying it claims every request.
205
+ - `--matcher` takes a name with or without `RequestMatcher`, without case, repeatable; one that
206
+ is not offered is refused, listing them.
207
+ - A type the webhook configuration already routes is refused. The routed types are read as the
208
+ kernel builds, not from the injected configuration, so a secret that is not set yet does not
209
+ stop the next webhook from being made.
210
+ - With `-n`: the name must be given, and the matchers are `Method` and `IsJson` unless
211
+ `--matcher` names others.
212
+
213
+ ```sh
214
+ $ uv run app make:webhook stripe --matcher Method -n
215
+ created: src/app/webhook/stripe_request_parser.py
216
+ created: src/app/remote_event/stripe_webhook_consumer.py
217
+ created: src/app/config/packages/webhook_stripe.py
218
+
219
+ [OK] Wrote StripeRequestParser, StripeWebhookConsumer and the stripe route.
220
+
221
+ Next:
222
+ - Add STRIPE_WEBHOOK_SECRET to .env.local: the secret stripe shares with you.
223
+ - Open src/app/webhook/stripe_request_parser.py and replace the secret check with the one its sender's protocol asks for.
224
+ - Open src/app/remote_event/stripe_webhook_consumer.py and write what an event leads to.
225
+ - Make sure config/routes/webhook.py exists, with an @as_routes function including resources["webhook"]: the sender posts to /webhook/stripe.
226
+ ```
227
+
228
+ ## `make:entity`
229
+
230
+ ```
231
+ make:entity [name] [--field FIELD]... [--relation RELATION]... [--id uuid|uuid7|bigint] [--no-audit]
232
+ ```
233
+
234
+ A new entity gets `entity/<snake>.py`, stored in the plural of its name, and
235
+ `repository/<snake>_repository.py` — a `SQLAlchemyAsyncRepository` subclass with an `@as_service`
236
+ factory scoped like the `AsyncSession` it takes. An entity that exists gets only the new
237
+ properties, every other line kept as it was, and no repository.
238
+
239
+ The base class comes from `--id` and `--no-audit`:
240
+
241
+ | `--id` | Audited (the default) | `--no-audit` |
242
+ | --- | --- | --- |
243
+ | `uuid` (the default) | `UUIDAuditBase` | `UUIDBase` |
244
+ | `uuid7` | `UUIDv7AuditBase` | `UUIDv7Base` |
245
+ | `bigint` | `BigIntAuditBase` | `BigIntBase` |
246
+
247
+ Both only choose a new entity's base; either one given for an entity that exists is refused.
248
+
249
+ **Fields** — `--field name:type[:settings][?]`, repeatable; `?` makes the column nullable, which
250
+ also makes the attribute `T | None`:
251
+
252
+ | Type | Attribute | Column |
253
+ | --- | --- | --- |
254
+ | `string[:length]` | `str` | `String(length)`, 255 by default |
255
+ | `text` | `str` | `Text` |
256
+ | `integer`, `big_integer`, `small_integer` | `int` | `Integer`, `BigInteger`, `SmallInteger` |
257
+ | `boolean` | `bool` | `Boolean` |
258
+ | `float` | `float` | `Float` |
259
+ | `decimal[:precision[:scale]]` | `Decimal` | `Numeric(precision, scale)`, `10, 2` by default |
260
+ | `date`, `time` | `date`, `time` | `Date`, `Time` |
261
+ | `datetime` | `datetime` | `DateTimeUTC(timezone=True)` |
262
+ | `json` | `dict[str, object]` | `JSON` |
263
+ | `uuid` | `UUID` | `GUID` |
264
+ | `enum:<dotted class>` | the enum class | `Enum(<class>)` |
265
+ | `binary` | `bytes` | `LargeBinary` |
266
+
267
+ Asked for one at a time, the type offered is the one the name suggests — `_at` a `datetime`,
268
+ `_id` a `uuid`, `is_`/`has_` a `boolean`, anything else a `string` — and `?` at the prompt lists
269
+ them all.
270
+
271
+ **Relations** — `--relation name:kind:Target[?][,inverse=name|none]`, repeatable:
272
+
273
+ | Kind | This entity gets | The other entity gets |
274
+ | --- | --- | --- |
275
+ | `many_to_one` | `<name>_id` with a `ForeignKey`, and the `relationship` | a collection, named after this one in the plural |
276
+ | `one_to_many` | a collection | the key and the `relationship`; the inverse must be named |
277
+ | `one_to_one` | a unique `<name>_id` and the `relationship` | one of this entity |
278
+ | `many_to_many` | a collection through an association table, written in a module of its own | a collection |
279
+
280
+ `?` makes the foreign key nullable, which `many_to_many` has not got. `inverse=none` adds nothing
281
+ to the other entity; left out, the inverse is this entity's name, pluralized when that side
282
+ reaches many of it. The other entity is imported for annotations only: the relationship finds it
283
+ by name once every entity is mapped.
284
+
285
+ With `--field` or `--relation`, or with `-n`, nothing is asked. With neither and a terminal, the
286
+ questions run until a property name is left empty. Every name is checked before anything is
287
+ written: a property the entity already has, or an unimportable enum, leaves both entities as they
288
+ were.
289
+
290
+ ```sh
291
+ $ uv run app make:entity Book --field title:string:120 -n
292
+ created: src/app/entity/book.py
293
+ created: src/app/repository/book_repository.py
294
+
295
+ [OK] Wrote Book and BookRepository.
296
+
297
+ Next:
298
+ - Run make:migration to write the revision the change needs.
299
+ - Then run orm:migrations:migrate -n to apply it.
300
+ ```
301
+
302
+ ## `make:command`
303
+
304
+ ```
305
+ make:command [name] [--argument ARGUMENT]... [--option OPTION]...
306
+ ```
307
+
308
+ Writes `command/<snake>_command.py`: a class declared with `@as_command`, named after the last
309
+ part of the console name — `app:send-report` is `SendReportCommand`. Its `__call__` takes the
310
+ arguments, then the options after a bare `*`, each documented in the docstring `--help` reads,
311
+ and its body prints every value it was given, so the command runs as soon as it is written.
312
+
313
+ - `--argument name[?][:type]`: `?` makes it optional. Types are `str` (the default), `int`,
314
+ `float`, `list` (`list[str]`) and an enum class as `module.Class`.
315
+ - `--option name[:type]`: types are `bool` (the default) and the same others.
316
+ - A list argument takes every value left, so none is asked after it; a required argument may not
317
+ follow an optional one; `io` is reserved; names are unique.
318
+ - A name a command already answers to is refused, and so is one whose last part cannot name a
319
+ class.
320
+ - With neither `--argument` nor `--option` and a terminal, they are asked one at a time until a
321
+ name is left empty. With `-n` and neither, the command takes a `name` argument and a `yell`
322
+ option to start from.
323
+
324
+ ```sh
325
+ $ uv run app make:command app:send-report --argument recipient -n
326
+ created: src/app/command/send_report_command.py
327
+
328
+ [OK] Wrote SendReportCommand.
329
+
330
+ Next:
331
+ - Run app:send-report --help to see its arguments and options.
332
+ - Open src/app/command/send_report_command.py and write what it does.
333
+ ```
334
+
335
+ ## `make:listener`
336
+
337
+ ```
338
+ make:listener [name] [event]
339
+ ```
340
+
341
+ A name ending in `Listener` writes `event_listener/<snake>_listener.py`, a class whose
342
+ `async def on_<event>` method carries `@as_event_listener`. One ending in `Subscriber` writes
343
+ `event_subscriber/<snake>_subscriber.py`, deriving from `EventSubscriberInterface` and mapping the
344
+ event to that method in `get_subscribed_events`. Any other name is asked which, a listener by
345
+ default, and the suffix is appended.
346
+
347
+ The event is a class — by its name, as `module:Class`, or as a dotted path — or a name it is
348
+ dispatched under. When it is asked, the events found are listed: those of the installed xtr
349
+ packages, those of the application's own `event` package, and those the dispatcher already has a
350
+ listener for. A name close to some of them is answered with what it may have meant; a name that
351
+ matches none is offered as a plain dispatch name to confirm, and the generated code then imports
352
+ `Event` for the annotation.
353
+
354
+ With `-n` both the name and the event must be given, and the event must resolve: a plain dispatch
355
+ name needs the question, so it is refused.
356
+
357
+ ```sh
358
+ $ uv run app make:listener WorkerStatsSubscriber WorkerStartedEvent -n
359
+ created: src/app/event_subscriber/worker_stats_subscriber.py
360
+
361
+ [OK] Wrote WorkerStatsSubscriber, reacting to xtr_messenger.event:WorkerStartedEvent.
362
+
363
+ Next:
364
+ - Open src/app/event_subscriber/worker_stats_subscriber.py and write what on_worker_started_event does.
365
+ ```
366
+
367
+ ## `make:migration`
368
+
369
+ ```
370
+ make:migration [--message MESSAGE] [--connection CONNECTION]
371
+ ```
372
+
373
+ The only maker with no template of its own: it compares a connection's database with its table
374
+ definitions and lets the connection's migrator write the revision, into its migrations directory,
375
+ exactly as `orm:migrations:diff` does. The file is never opened or edited afterwards.
376
+
377
+ - `--message` names what the revision does, and its file; asked when left out, defaulting to
378
+ `auto`. An empty one is refused.
379
+ - `--connection` picks the connection; the default one when left out. One the registry does not
380
+ know is refused, listing the names it has.
381
+ - The database must have every revision applied first, or the comparison would see what is still
382
+ to run rather than what the tables add. A database that is behind reports how many revisions are
383
+ waiting, then `Run orm:migrations:migrate -n first.`
384
+ - Nothing differing is a note, `No database changes were detected.`, and a successful exit with
385
+ nothing written.
386
+
387
+ ```sh
388
+ $ uv run app make:migration --message books -n
389
+ created: migrations/20261029105403_c51185006457_books.py
390
+
391
+ [OK] Wrote the revision c51185006457.
392
+
393
+ Next:
394
+ - Review the new revision, then run orm:migrations:migrate -n.
395
+ ```
396
+
397
+ ## What every maker does, and never does
398
+
399
+ - **Nothing is overwritten.** A target that exists, or one another file of the same run already
400
+ claimed, ends the run before anything reaches the disk. The exception is `make:entity` on an
401
+ entity that exists: it adds the new properties and their imports and writes the module back,
402
+ every other line byte-identical.
403
+ - **All or nothing.** A run renders every file first and writes them together, so a name already
404
+ taken or a template that renders source which does not parse leaves the working tree as it was.
405
+ - **Configuration is never edited.** `make:webhook` writes a new `@configure` transform module
406
+ rather than touching the application's webhook configuration; every other maker writes no
407
+ configuration at all.
408
+ - **Secrets are never generated.** A maker that needs one prints the `.env.local` step instead.
409
+ - **Nothing is written outside the project.** A path that leaves the project directory, or is a
410
+ symbolic link, is refused, so what is typed at a prompt cannot reach elsewhere on disk.
411
+ - **Where the code goes** is the kernel's own package, `kernel.package`, unless
412
+ `MakerConfig.root_package` names another. Classes are marked `@final` unless
413
+ `MakerConfig.final_classes` is off.
414
+ - **The application package is found when a maker first needs it** — in the folder the import
415
+ system loads it from, whatever the project's layout — never when the console starts.
416
+
417
+ ## Generated code and the linters
418
+
419
+ Every module is written the way the formatter would write it: lines of at most 88 columns,
420
+ double quotes, imports grouped standard library, third-party then the application's own package
421
+ and sorted the way the import rules sort them, a statement too long for one line wrapped with a
422
+ trailing comma.
423
+ Each file opens with a docstring, `from __future__ import annotations` and its `__all__`.
424
+
425
+ Two things to know when the project's own settings are stricter:
426
+
427
+ - **Annotations read at runtime.** A console command's parameter types, and a container-hydrated
428
+ constructor's, and an entity's column types, are read at runtime, so they cannot move into
429
+ `if TYPE_CHECKING:`. The rules that would move them (`TC001`, `TC002`, `TC003`) have to be off
430
+ for such modules, as they are in an application on the container. Everything genuinely used for
431
+ annotations only is already in a `TYPE_CHECKING` block.
432
+ - **Two-way relations.** `make:entity` writes each side's import of the other under
433
+ `TYPE_CHECKING`, which basedpyright's `reportImportCycles` sees as a cycle all the same. The
434
+ entity therefore carries `# pyright: reportImportCycles=false`, with the reason, above that block.
435
+
436
+ A statement split over lines ends with a trailing comma, so a formatter set to a wider line length
437
+ keeps it split: the output passes `ruff format --check` at any width from 88 up.
438
+
439
+ ## Use in an application
440
+
441
+ Everything adding this package to an application on
442
+ [xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what removing
443
+ it undoes.
444
+
445
+ - **Install** — `uv run xtr-recipes recipes:add --dev xtr-maker`, or `uv add --dev xtr-maker`
446
+ then `uv run xtr-recipes recipes:sync`: the makers write code while you develop, so they are a
447
+ development dependency, never a runtime one. Add the extras of the libraries you want makers
448
+ for if the application has not got them yet.
449
+ - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate* step below, and only that: it
450
+ lists `MakerBundle` for the `dev` environment, which is what this package's recipe declares.
451
+ There is nothing for it to configure, no variable to write and nothing to ignore.
452
+ - **Activate** — import the bundle at the top of `<app>/config/bundles.py`, like every other one,
453
+ and list it for `dev`:
454
+
455
+ ```python
456
+ # <app>/config/bundles.py
457
+ from xtr_maker.bundle import MakerBundle
458
+
459
+ BUNDLES = {
460
+ MakerBundle: {"dev": True},
461
+ }
462
+ ```
463
+
464
+ Production still starts without the development dependencies installed: there the kernel stands
465
+ in for the bundle, which is not active, and never needs its package.
466
+ - **Brings along** — the console bundle, which the makers are commands of. The messenger,
467
+ scheduler, webhook, orm and event dispatcher bundles are not brought along: each is read, not
468
+ required, and decides which makers are registered.
469
+ - **Configure** — nothing to configure: the code goes into the kernel's own package and every
470
+ class is `@final`. To change either, put the settings in the `dev` environment folder,
471
+ `<app>/config/packages/dev/`: production does not install this package, and only a `dev` build
472
+ imports that folder, so a production start never imports `xtr_maker`.
473
+
474
+ ```python
475
+ # <app>/config/packages/dev/maker.py (beside an empty __init__.py)
476
+ from xtr_dependency_injection import configure
477
+ from xtr_maker.bundle import MakerConfig
478
+
479
+
480
+ @configure
481
+ def maker() -> MakerConfig:
482
+ return MakerConfig(final_classes=False)
483
+ ```
484
+
485
+ - **Environment** — nothing. A maker that needs a secret prints the variable to set in
486
+ `.env.local` rather than reading one itself.
487
+ - **Ignore** — nothing: what the makers write is code to commit.
488
+ - **Remove** — `uv run xtr-recipes recipes:remove xtr-maker`: it runs `uv remove --dev xtr-maker`,
489
+ and the sync after it drops the `BUNDLES` entry. Delete `<app>/config/packages/dev/maker.py` if
490
+ you wrote one. The code the makers wrote stays yours.
491
+ - **Check** — `debug:bundles` lists `maker` as active in `dev`; `list make` shows one `make:*`
492
+ command for each library whose bundle is active.
493
+
494
+ ## Kernel / bundle
495
+
496
+ `MakerBundle` is declared `@as_bundle("maker", config=MakerConfig)` and requires the console
497
+ bundle. Its `load_extension` registers three services and then the command module of each maker
498
+ whose library's bundle is active:
499
+
500
+ | Service | Lifetime | Is |
501
+ | --- | --- | --- |
502
+ | `TemplateRenderer` | singleton | The templates this package ships, rendered, then parsed before anything is queued |
503
+ | `FileManager` | singleton | The project directory and the application package, and the only way a maker writes |
504
+ | `Generator` | scoped | One maker run's queue of files: a failed run leaves nothing queued for the next |
505
+
506
+ `MakerConfig` is a frozen dataclass buildable with no arguments:
507
+
508
+ | Field | Default | Is |
509
+ | --- | --- | --- |
510
+ | `root_package` | `None` | The dotted application package the makers write into; `None` takes `kernel.package`. A value that is not a dotted Python identifier raises `InvalidArgumentError` |
511
+ | `final_classes` | `True` | Whether generated classes are marked `@final` |
512
+
513
+ ## Errors
514
+
515
+ All of them derive from `MakerError`, so one `except` catches anything that can stop a maker:
516
+
517
+ | Error | Raised when |
518
+ | --- | --- |
519
+ | `AppPackageNotFoundError` | The import system finds no package of that name; the message names `MakerConfig.root_package` |
520
+ | `GeneratedSourceError` | A template rendered Python that does not parse; it names the template |
521
+ | `InvalidArgumentError` | An argument, option or answer cannot be used — also a `ValueError` |
522
+ | `InvalidClassNameError` | What was typed cannot name a class: a keyword, no identifier, or shadowing a builtin |
523
+ | `TargetExistsError` | A file to write exists, or another file of the same run already claimed it — also a `FileExistsError` |
524
+ | `UnsafePathError` | A path leaves the project directory, or is a symbolic link |
525
+
526
+ A command turns them into an exit code rather than a traceback: `ExitCode.INVALID` for anything
527
+ refused before the files are rendered, `ExitCode.FAILURE` for a target that exists or a migration
528
+ that could not be written, `ExitCode.SUCCESS` otherwise — nothing to write included.
529
+
530
+ ## Development
531
+
532
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
533
+ `packages/xtr-maker`; run the commands below from there. The `python-xtr-maker`
534
+ repository is a read-only copy, so send issues and pull requests to the monorepo.
535
+
536
+ ```sh
537
+ uv sync
538
+ uv run ruff check
539
+ uv run ruff format --check
540
+ uv run basedpyright
541
+ uv run ty check
542
+ uv run pytest
543
+ ```
544
+
545
+ ## License
546
+
547
+ MIT — see [LICENSE](LICENSE).