arclith-cli 0.20.0__tar.gz → 0.21.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 (58) hide show
  1. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/PKG-INFO +41 -10
  2. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/README.md +39 -8
  3. arclith_cli-0.21.0/arclith_cli/__init__.py +1 -0
  4. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/catalogs/persistence.py +4 -81
  5. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/catalogs/repository_facets.py +3 -3
  6. arclith_cli-0.21.0/arclith_cli/catalogs/repository_postgresql.py +107 -0
  7. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/core_scaffold.py +155 -40
  8. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/init_project.py +1 -1
  9. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/main.py +41 -3
  10. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/recipe.py +14 -1
  11. arclith_cli-0.21.0/arclith_cli/scaffold_interactive.py +105 -0
  12. arclith_cli-0.21.0/arclith_cli/scaffold_templates.py +140 -0
  13. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/pyproject.toml +2 -2
  14. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/e2e_manual.sh +1 -1
  15. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/test_add_adapter.py +17 -11
  16. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/test_capabilities.py +2 -0
  17. arclith_cli-0.21.0/tests/test_core_scaffold.py +566 -0
  18. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/test_e2e_scaffold.py +3 -1
  19. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/test_embedding_capability.py +3 -3
  20. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/test_recipe.py +2 -2
  21. arclith_cli-0.21.0/tests/test_scaffold_cli.py +216 -0
  22. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/test_vector_store_capability.py +1 -1
  23. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/uv.lock +2 -2
  24. arclith_cli-0.20.0/arclith_cli/__init__.py +0 -1
  25. arclith_cli-0.20.0/tests/test_core_scaffold.py +0 -248
  26. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/.gitignore +0 -0
  27. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/LICENSE +0 -0
  28. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/Makefile +0 -0
  29. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/adapter_config.py +0 -0
  30. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/adapter_generator.py +0 -0
  31. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/adapter_parameters.py +0 -0
  32. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/adapter_rendering.py +0 -0
  33. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/adapter_selection.py +0 -0
  34. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/adapter_templates.py +0 -0
  35. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/add_adapter.py +0 -0
  36. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/capabilities.py +0 -0
  37. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/capability_models.py +0 -0
  38. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/catalogs/__init__.py +0 -0
  39. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/catalogs/ai.py +0 -0
  40. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/catalogs/channels.py +0 -0
  41. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/catalogs/core.py +0 -0
  42. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/catalogs/observability.py +0 -0
  43. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/catalogs/security.py +0 -0
  44. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/catalogs/transports.py +0 -0
  45. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/entity_scanner.py +0 -0
  46. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/export_config.py +0 -0
  47. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/new_project.py +0 -0
  48. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/project_paths.py +0 -0
  49. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/recipe_cli.py +0 -0
  50. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/recipe_models.py +0 -0
  51. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/rename.py +0 -0
  52. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/runtime_templates.py +0 -0
  53. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/scaffold.py +0 -0
  54. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/arclith_cli/updater.py +0 -0
  55. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/__init__.py +0 -0
  56. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/test_adapter_templates.py +0 -0
  57. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/test_project_paths.py +0 -0
  58. {arclith_cli-0.20.0 → arclith_cli-0.21.0}/tests/test_rename.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arclith-cli
3
- Version: 0.20.0
3
+ Version: 0.21.0
4
4
  Summary: CLI scaffolding tool for arclith — hexagonal architecture framework
5
5
  Author-email: Killian KOPP <killiankopp@gmail.com>
6
6
  License: Apache License
@@ -186,7 +186,7 @@ License: Apache License
186
186
  License-File: LICENSE
187
187
  Keywords: arclith,cli,ddd,hexagonal-architecture,scaffold
188
188
  Requires-Python: >=3.13
189
- Requires-Dist: arclith>=0.23.0
189
+ Requires-Dist: arclith>=0.24.0
190
190
  Requires-Dist: httpx>=0.27.0
191
191
  Requires-Dist: rich>=13.0.0
192
192
  Requires-Dist: typer>=0.15.0
@@ -252,7 +252,10 @@ template est régénéré côté CLI pour appliquer le contrat `runtime/docker-i
252
252
 
253
253
  ### `add-entity` — Ajouter une entité métier
254
254
 
255
- Crée uniquement le fichier minimal d'une entité dans `src/<package>/domain/models/`.
255
+ Crée un squelette minimal et guidé dans `src/<package>/domain/models/`. Le fichier
256
+ signale où déclarer les champs et invariants, rappelle les champs déjà fournis par
257
+ `Entity` et renvoie vers les guides Arclith et Pydantic. L'exemple `Field(...)`
258
+ reste commenté, donc aucun import inutilisé n'est ajouté.
256
259
 
257
260
  ```bash
258
261
  cd my-recipe-service
@@ -265,21 +268,41 @@ Fichier généré :
265
268
  src/<package>/domain/models/shopping_item.py
266
269
  ```
267
270
 
268
- La commande ne génère aucun CRUD, aucun port repository, aucun adapter et aucun endpoint. Elle pose seulement le point d'ancrage du modèle métier ; le développeur complète ensuite les champs et invariants de l'entité.
271
+ La commande ne génère aucun CRUD, aucun port repository, aucun adapter et aucun
272
+ endpoint. Elle pose seulement le point d'ancrage du modèle métier ; le
273
+ développeur complète ensuite les champs et invariants de l'entité.
269
274
 
270
275
  ---
271
276
 
272
277
  ### `add-usecase` — Ajouter un cas d'usage
273
278
 
274
- Crée le port inbound minimal dans `src/<package>/domain/ports/inbound/`, puis le fichier minimal du
275
- cas d'usage dans `src/<package>/application/use_cases/`.
279
+ Crée un port inbound guidé dans `src/<package>/domain/ports/inbound/`, puis le
280
+ cas d'usage dans `src/<package>/application/use_cases/`. Sans option de liaison,
281
+ la commande interactive propose les entités détectées par analyse AST, la
282
+ création d'une nouvelle entité ou un cas d'usage transverse.
276
283
 
277
284
  ```bash
278
285
  cd my-recipe-service
286
+
287
+ # Mode interactif : choisir une entité détectée, en créer une ou rester transverse
279
288
  arclith-cli add-usecase PlanShoppingList
280
- arclith-cli add-usecase find-by-name
289
+
290
+ # Modes directs, complets pour les agents et la CI
291
+ arclith-cli add-usecase CreateTodo --entity Todo
292
+ arclith-cli add-usecase CreateRecipe --new-entity Recipe
293
+ arclith-cli add-usecase RunMaintenance --no-entity
281
294
  ```
282
295
 
296
+ | Option | Effet |
297
+ |---|---|
298
+ | `--entity Todo` | lie le use case à une entité détectée et échoue si elle est absente |
299
+ | `--new-entity Todo` | crée l'entité si nécessaire, puis génère le use case lié |
300
+ | `--no-entity` | génère un `Command`, un `Result` et un use case transverse sans repository |
301
+
302
+ Ces options sont mutuellement exclusives. `--new-entity` réutilise une entité
303
+ valide déjà présente, mais refuse un fichier homonyme qui ne déclare pas la
304
+ classe `Entity` attendue.
305
+
283
306
  Fichier généré :
284
307
 
285
308
  ```text
@@ -287,9 +310,17 @@ src/<package>/domain/ports/inbound/plan_shopping_list.py
287
310
  src/<package>/application/use_cases/plan_shopping_list.py
288
311
  ```
289
312
 
290
- Le nom peut être fourni en PascalCase, snake_case ou kebab-case. Le suffixe `UseCase` est normalisé : `PlanShoppingListUseCase` et `plan-shopping-list-use-case` génèrent tous les deux `PlanShoppingListUseCase`.
291
-
292
- Comme `add-entity`, cette commande ne câble pas FastAPI, FastMCP, LangGraph, un repository ou un service. Les adapters se branchent ensuite explicitement avec `add-adapter` et devraient dépendre du port inbound généré.
313
+ Le nom peut être fourni en PascalCase, snake_case ou kebab-case. Le suffixe
314
+ `UseCase` est normalisé : `PlanShoppingListUseCase` et
315
+ `plan-shopping-list-use-case` génèrent tous les deux
316
+ `PlanShoppingListUseCase`.
317
+
318
+ Pour une entité principale, le squelette injecte explicitement
319
+ `Repository[Entity]` et type `execute` avec un `Command` Pydantic et l'entité en
320
+ retour. Le mode transverse génère plutôt un `Command` et un `Result` Pydantic,
321
+ sans repository implicite. Aucun mode ne câble FastAPI, FastMCP ou LangGraph.
322
+ Les exemples complets et les règles de séparation sont dans le
323
+ [deep dive du scaffold CLI](https://karned-rekipe.github.io/arclith/deep-dives/cli-scaffold/).
293
324
 
294
325
  ---
295
326
 
@@ -58,7 +58,10 @@ template est régénéré côté CLI pour appliquer le contrat `runtime/docker-i
58
58
 
59
59
  ### `add-entity` — Ajouter une entité métier
60
60
 
61
- Crée uniquement le fichier minimal d'une entité dans `src/<package>/domain/models/`.
61
+ Crée un squelette minimal et guidé dans `src/<package>/domain/models/`. Le fichier
62
+ signale où déclarer les champs et invariants, rappelle les champs déjà fournis par
63
+ `Entity` et renvoie vers les guides Arclith et Pydantic. L'exemple `Field(...)`
64
+ reste commenté, donc aucun import inutilisé n'est ajouté.
62
65
 
63
66
  ```bash
64
67
  cd my-recipe-service
@@ -71,21 +74,41 @@ Fichier généré :
71
74
  src/<package>/domain/models/shopping_item.py
72
75
  ```
73
76
 
74
- La commande ne génère aucun CRUD, aucun port repository, aucun adapter et aucun endpoint. Elle pose seulement le point d'ancrage du modèle métier ; le développeur complète ensuite les champs et invariants de l'entité.
77
+ La commande ne génère aucun CRUD, aucun port repository, aucun adapter et aucun
78
+ endpoint. Elle pose seulement le point d'ancrage du modèle métier ; le
79
+ développeur complète ensuite les champs et invariants de l'entité.
75
80
 
76
81
  ---
77
82
 
78
83
  ### `add-usecase` — Ajouter un cas d'usage
79
84
 
80
- Crée le port inbound minimal dans `src/<package>/domain/ports/inbound/`, puis le fichier minimal du
81
- cas d'usage dans `src/<package>/application/use_cases/`.
85
+ Crée un port inbound guidé dans `src/<package>/domain/ports/inbound/`, puis le
86
+ cas d'usage dans `src/<package>/application/use_cases/`. Sans option de liaison,
87
+ la commande interactive propose les entités détectées par analyse AST, la
88
+ création d'une nouvelle entité ou un cas d'usage transverse.
82
89
 
83
90
  ```bash
84
91
  cd my-recipe-service
92
+
93
+ # Mode interactif : choisir une entité détectée, en créer une ou rester transverse
85
94
  arclith-cli add-usecase PlanShoppingList
86
- arclith-cli add-usecase find-by-name
95
+
96
+ # Modes directs, complets pour les agents et la CI
97
+ arclith-cli add-usecase CreateTodo --entity Todo
98
+ arclith-cli add-usecase CreateRecipe --new-entity Recipe
99
+ arclith-cli add-usecase RunMaintenance --no-entity
87
100
  ```
88
101
 
102
+ | Option | Effet |
103
+ |---|---|
104
+ | `--entity Todo` | lie le use case à une entité détectée et échoue si elle est absente |
105
+ | `--new-entity Todo` | crée l'entité si nécessaire, puis génère le use case lié |
106
+ | `--no-entity` | génère un `Command`, un `Result` et un use case transverse sans repository |
107
+
108
+ Ces options sont mutuellement exclusives. `--new-entity` réutilise une entité
109
+ valide déjà présente, mais refuse un fichier homonyme qui ne déclare pas la
110
+ classe `Entity` attendue.
111
+
89
112
  Fichier généré :
90
113
 
91
114
  ```text
@@ -93,9 +116,17 @@ src/<package>/domain/ports/inbound/plan_shopping_list.py
93
116
  src/<package>/application/use_cases/plan_shopping_list.py
94
117
  ```
95
118
 
96
- Le nom peut être fourni en PascalCase, snake_case ou kebab-case. Le suffixe `UseCase` est normalisé : `PlanShoppingListUseCase` et `plan-shopping-list-use-case` génèrent tous les deux `PlanShoppingListUseCase`.
97
-
98
- Comme `add-entity`, cette commande ne câble pas FastAPI, FastMCP, LangGraph, un repository ou un service. Les adapters se branchent ensuite explicitement avec `add-adapter` et devraient dépendre du port inbound généré.
119
+ Le nom peut être fourni en PascalCase, snake_case ou kebab-case. Le suffixe
120
+ `UseCase` est normalisé : `PlanShoppingListUseCase` et
121
+ `plan-shopping-list-use-case` génèrent tous les deux
122
+ `PlanShoppingListUseCase`.
123
+
124
+ Pour une entité principale, le squelette injecte explicitement
125
+ `Repository[Entity]` et type `execute` avec un `Command` Pydantic et l'entité en
126
+ retour. Le mode transverse génère plutôt un `Command` et un `Result` Pydantic,
127
+ sans repository implicite. Aucun mode ne câble FastAPI, FastMCP ou LangGraph.
128
+ Les exemples complets et les règles de séparation sont dans le
129
+ [deep dive du scaffold CLI](https://karned-rekipe.github.io/arclith/deep-dives/cli-scaffold/).
99
130
 
100
131
  ---
101
132
 
@@ -0,0 +1 @@
1
+ __version__ = "0.21.0"
@@ -11,7 +11,9 @@ from arclith_cli.catalogs.repository_facets import (
11
11
  MARIADB_FACETS,
12
12
  MEMORY_FACETS,
13
13
  MONGODB_FACETS,
14
- POSTGRESQL_FACETS,
14
+ )
15
+ from arclith_cli.catalogs.repository_postgresql import (
16
+ POSTGRESQL_REPOSITORY_ADAPTER,
15
17
  )
16
18
 
17
19
  REPOSITORY_CAPABILITY = CapabilitySpec(
@@ -154,86 +156,7 @@ multitenant: false
154
156
  ),
155
157
  facets=MARIADB_FACETS,
156
158
  ),
157
- AdapterSpec(
158
- name="postgresql",
159
- capability="repository",
160
- layer="outbound",
161
- description="Repository PostgreSQL async optionnel, pilote par SQLAlchemy et asyncpg, avec payload JSONB generique.",
162
- config_path="config/adapters/outbound/postgresql.yaml",
163
- config_template="""\
164
- url: null # à mapper via config/secrets.yaml ou resolver env/vault si vous fournissez une URL complète
165
- host: {host}
166
- port: {port}
167
- database: {database}
168
- user: {user}
169
- password: null # à mapper via config/secrets.yaml ou resolver env/vault
170
- schema: {schema}
171
- driver: {driver}
172
- table_prefix: "{table_prefix}"
173
- multitenant: {multitenant}
174
- """,
175
- secret_mappings=(
176
- SecretMappingSpec(
177
- field_path="adapters.postgresql.url",
178
- secret_key="POSTGRESQL_URL",
179
- ),
180
- SecretMappingSpec(
181
- field_path="adapters.postgresql.password",
182
- secret_key="POSTGRESQL_PASSWORD",
183
- ),
184
- ),
185
- parameters=(
186
- ParameterSpec(
187
- name="host",
188
- kind="string",
189
- prompt="host",
190
- default="127.0.0.1",
191
- ),
192
- ParameterSpec(
193
- name="port",
194
- kind="string",
195
- prompt="port",
196
- default="5432",
197
- ),
198
- ParameterSpec(
199
- name="database",
200
- kind="string",
201
- prompt="database",
202
- default_from_project_name=True,
203
- ),
204
- ParameterSpec(
205
- name="user",
206
- kind="string",
207
- prompt="user",
208
- default="app",
209
- ),
210
- ParameterSpec(
211
- name="schema",
212
- kind="string",
213
- prompt="schema",
214
- default="public",
215
- ),
216
- ParameterSpec(
217
- name="driver",
218
- kind="string",
219
- prompt="driver",
220
- default="asyncpg",
221
- ),
222
- ParameterSpec(
223
- name="table_prefix",
224
- kind="string",
225
- prompt="table_prefix",
226
- default="",
227
- ),
228
- ParameterSpec(
229
- name="multitenant",
230
- kind="boolean",
231
- prompt="multitenant",
232
- default=False,
233
- ),
234
- ),
235
- facets=POSTGRESQL_FACETS,
236
- ),
159
+ POSTGRESQL_REPOSITORY_ADAPTER,
237
160
  ),
238
161
  )
239
162
 
@@ -77,11 +77,11 @@ POSTGRESQL_FACETS = AdapterFacets(
77
77
  schema_strategy="json_table",
78
78
  recommended_for=(
79
79
  "services nécessitant un SQL serveur robuste",
80
- "état partagé multi-process avec payload JSONB",
80
+ "payload JSONB par défaut ou colonnes typées explicitement mappées",
81
81
  ),
82
82
  limits=(
83
- "entité stockée comme payload JSONB générique",
84
- "pas de relations, joins ou migrations métier via le port",
83
+ "le mapping structuré est opt-in et fourni par l'application",
84
+ "pas de relations, joins ou migrations automatiques via le port",
85
85
  ),
86
86
  )
87
87
 
@@ -0,0 +1,107 @@
1
+ from arclith_cli.capability_models import (
2
+ AdapterSpec,
3
+ ParameterSpec,
4
+ SecretMappingSpec,
5
+ )
6
+ from arclith_cli.catalogs.repository_facets import POSTGRESQL_FACETS
7
+
8
+ POSTGRESQL_REPOSITORY_ADAPTER = AdapterSpec(
9
+ name="postgresql",
10
+ capability="repository",
11
+ layer="outbound",
12
+ description=(
13
+ "Repository PostgreSQL async optionnel avec JSONB par défaut ou "
14
+ "mapping structuré explicite."
15
+ ),
16
+ config_path="config/adapters/outbound/postgresql.yaml",
17
+ config_template="""\
18
+ url: null # à mapper via config/secrets.yaml ou resolver env/vault si vous fournissez une URL complète
19
+ host: {host}
20
+ port: {port}
21
+ database: {database}
22
+ user: {user}
23
+ password: null # à mapper via config/secrets.yaml ou resolver env/vault
24
+ schema: {schema}
25
+ driver: {driver}
26
+ table_prefix: "{table_prefix}"
27
+ mapping_strategy: {mapping_strategy}
28
+ auto_create_schema: {auto_create_schema}
29
+ multitenant: {multitenant}
30
+ """,
31
+ secret_mappings=(
32
+ SecretMappingSpec(
33
+ field_path="adapters.postgresql.url",
34
+ secret_key="POSTGRESQL_URL",
35
+ ),
36
+ SecretMappingSpec(
37
+ field_path="adapters.postgresql.password",
38
+ secret_key="POSTGRESQL_PASSWORD",
39
+ ),
40
+ ),
41
+ parameters=(
42
+ ParameterSpec(
43
+ name="host",
44
+ kind="string",
45
+ prompt="host",
46
+ default="127.0.0.1",
47
+ ),
48
+ ParameterSpec(
49
+ name="port",
50
+ kind="string",
51
+ prompt="port",
52
+ default="5432",
53
+ ),
54
+ ParameterSpec(
55
+ name="database",
56
+ kind="string",
57
+ prompt="database",
58
+ default_from_project_name=True,
59
+ ),
60
+ ParameterSpec(
61
+ name="user",
62
+ kind="string",
63
+ prompt="user",
64
+ default="app",
65
+ ),
66
+ ParameterSpec(
67
+ name="schema",
68
+ kind="string",
69
+ prompt="schema",
70
+ default="public",
71
+ ),
72
+ ParameterSpec(
73
+ name="driver",
74
+ kind="string",
75
+ prompt="driver",
76
+ default="asyncpg",
77
+ ),
78
+ ParameterSpec(
79
+ name="table_prefix",
80
+ kind="string",
81
+ prompt="table_prefix",
82
+ default="",
83
+ ),
84
+ ParameterSpec(
85
+ name="mapping_strategy",
86
+ kind="string",
87
+ prompt="mapping_strategy",
88
+ default="generic_json",
89
+ choices=("generic_json", "structured"),
90
+ ),
91
+ ParameterSpec(
92
+ name="auto_create_schema",
93
+ kind="boolean",
94
+ prompt="auto_create_schema",
95
+ default=True,
96
+ ),
97
+ ParameterSpec(
98
+ name="multitenant",
99
+ kind="boolean",
100
+ prompt="multitenant",
101
+ default=False,
102
+ ),
103
+ ),
104
+ facets=POSTGRESQL_FACETS,
105
+ )
106
+
107
+ __all__ = ["POSTGRESQL_REPOSITORY_ADAPTER"]
@@ -7,37 +7,21 @@ from pathlib import Path
7
7
  import typer
8
8
  from rich.console import Console
9
9
 
10
+ from .entity_scanner import EntityInfo, scan_entities
10
11
  from .project_paths import ProjectPaths, detect_project_paths
11
12
  from .rename import EntityNames
13
+ from .scaffold_templates import (
14
+ render_entity_inbound_port_template,
15
+ render_entity_template,
16
+ render_entity_use_case_template,
17
+ render_transverse_inbound_port_template,
18
+ render_transverse_use_case_template,
19
+ )
12
20
 
13
21
  console = Console()
14
22
 
15
23
  _NAME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_\-]*$")
16
24
 
17
- _ENTITY_TEMPLATE = """from arclith.domain.models.entity import Entity
18
-
19
-
20
- class {class_name}(Entity):
21
- pass
22
- """
23
-
24
- _INBOUND_PORT_TEMPLATE = """from abc import ABC, abstractmethod
25
-
26
-
27
- class {class_name}Port(ABC):
28
- @abstractmethod
29
- async def execute(self) -> None:
30
- raise NotImplementedError
31
- """
32
-
33
- _USE_CASE_TEMPLATE = """from {inbound_port_module} import {class_name}Port
34
-
35
-
36
- class {class_name}UseCase({class_name}Port):
37
- async def execute(self) -> None:
38
- raise NotImplementedError("Implement {class_name}UseCase.execute")
39
- """
40
-
41
25
  _INTENT_INTERPRETER_TEMPLATE = """class {class_name}Interpreter:
42
26
  async def interpret(self, prompt: str) -> None:
43
27
  raise NotImplementedError("Implement {class_name}Interpreter.interpret")
@@ -55,7 +39,9 @@ class UseCaseNames:
55
39
  pascal = _strip_pascal_suffix(names.pascal)
56
40
  snake = _strip_snake_suffix(names.snake)
57
41
  if not pascal or not snake:
58
- console.print(f"[red]✗[/red] Nom de cas d'usage invalide : [bold]{raw}[/bold].")
42
+ console.print(
43
+ f"[red]✗[/red] Nom de cas d'usage invalide : [bold]{raw}[/bold]."
44
+ )
59
45
  raise typer.Exit(1)
60
46
  return cls(pascal=pascal, snake=snake)
61
47
 
@@ -71,7 +57,9 @@ class IntentInterpreterNames:
71
57
  pascal = _strip_pascal_intent_interpreter_suffix(names.pascal)
72
58
  snake = _strip_snake_intent_interpreter_suffix(names.snake)
73
59
  if not pascal or not snake:
74
- console.print(f"[red]✗[/red] Nom d'interpréteur d'intention invalide : [bold]{raw}[/bold].")
60
+ console.print(
61
+ f"[red]✗[/red] Nom d'interpréteur d'intention invalide : [bold]{raw}[/bold]."
62
+ )
75
63
  raise typer.Exit(1)
76
64
  return cls(pascal=pascal, snake=snake)
77
65
 
@@ -88,7 +76,9 @@ def add_entity_cmd(*, project_dir: Path | None = None, entity_name: str) -> Path
88
76
  _assert_missing(entity_file, project_dir)
89
77
 
90
78
  _ensure_package_dirs(paths, "domain", "models")
91
- entity_file.write_text(_ENTITY_TEMPLATE.format(class_name=names.pascal), encoding="utf-8")
79
+ entity_file.write_text(
80
+ render_entity_template(class_name=names.pascal), encoding="utf-8"
81
+ )
92
82
  console.print(
93
83
  f"[green]✓[/green] Entité {names.pascal} créée : "
94
84
  f"[bold]{entity_file.relative_to(project_dir)}[/bold]"
@@ -96,7 +86,13 @@ def add_entity_cmd(*, project_dir: Path | None = None, entity_name: str) -> Path
96
86
  return entity_file
97
87
 
98
88
 
99
- def add_usecase_cmd(*, project_dir: Path | None = None, usecase_name: str) -> Path:
89
+ def add_usecase_cmd(
90
+ *,
91
+ project_dir: Path | None = None,
92
+ usecase_name: str,
93
+ entity_name: str | None = None,
94
+ new_entity_name: str | None = None,
95
+ ) -> Path:
100
96
  project_dir = project_dir or Path.cwd()
101
97
  usecase_name = usecase_name.strip()
102
98
  _assert_project_root(project_dir, command="add-usecase")
@@ -108,17 +104,43 @@ def add_usecase_cmd(*, project_dir: Path | None = None, usecase_name: str) -> Pa
108
104
  inbound_port_file = paths.inbound_ports / f"{names.snake}.py"
109
105
  _assert_missing(usecase_file, project_dir)
110
106
  _assert_missing(inbound_port_file, project_dir)
107
+ entity = _resolve_usecase_entity(
108
+ project_dir=project_dir,
109
+ paths=paths,
110
+ entity_name=entity_name,
111
+ new_entity_name=new_entity_name,
112
+ )
111
113
 
112
114
  _ensure_package_dirs(paths, "domain", "ports", "inbound")
113
115
  _ensure_package_dirs(paths, "application", "use_cases")
114
- inbound_port_file.write_text(_INBOUND_PORT_TEMPLATE.format(class_name=names.pascal), encoding="utf-8")
115
- usecase_file.write_text(
116
- _USE_CASE_TEMPLATE.format(
116
+ inbound_port_module = paths.import_path("domain", "ports", "inbound", names.snake)
117
+ if entity is None:
118
+ inbound_port_content = render_transverse_inbound_port_template(
119
+ class_name=names.pascal
120
+ )
121
+ usecase_content = render_transverse_use_case_template(
117
122
  class_name=names.pascal,
118
- inbound_port_module=paths.import_path("domain", "ports", "inbound", names.snake),
119
- ),
120
- encoding="utf-8",
121
- )
123
+ inbound_port_module=inbound_port_module,
124
+ )
125
+ else:
126
+ entity_module = paths.import_path(
127
+ "domain",
128
+ "models",
129
+ entity.file_path.stem,
130
+ )
131
+ inbound_port_content = render_entity_inbound_port_template(
132
+ class_name=names.pascal,
133
+ entity_class=entity.pascal,
134
+ entity_module=entity_module,
135
+ )
136
+ usecase_content = render_entity_use_case_template(
137
+ class_name=names.pascal,
138
+ entity_class=entity.pascal,
139
+ entity_module=entity_module,
140
+ inbound_port_module=inbound_port_module,
141
+ )
142
+ inbound_port_file.write_text(inbound_port_content, encoding="utf-8")
143
+ usecase_file.write_text(usecase_content, encoding="utf-8")
122
144
  console.print(
123
145
  f"[green]✓[/green] Port inbound {names.pascal}Port créé : "
124
146
  f"[bold]{inbound_port_file.relative_to(project_dir)}[/bold]"
@@ -127,10 +149,93 @@ def add_usecase_cmd(*, project_dir: Path | None = None, usecase_name: str) -> Pa
127
149
  f"[green]✓[/green] Cas d'usage {names.pascal}UseCase créé : "
128
150
  f"[bold]{usecase_file.relative_to(project_dir)}[/bold]"
129
151
  )
152
+ if entity is not None:
153
+ console.print(
154
+ f"[green]✓[/green] Entité principale : [bold]{entity.pascal}[/bold]"
155
+ )
130
156
  return usecase_file
131
157
 
132
158
 
133
- def add_intent_interpreter_cmd(*, project_dir: Path | None = None, intent_name: str) -> Path:
159
+ def _resolve_usecase_entity(
160
+ *,
161
+ project_dir: Path,
162
+ paths: ProjectPaths,
163
+ entity_name: str | None,
164
+ new_entity_name: str | None,
165
+ ) -> EntityInfo | None:
166
+ if entity_name is not None and new_entity_name is not None:
167
+ console.print(
168
+ "[red]✗[/red] Une seule entité peut être choisie : "
169
+ "utilisez [bold]entity_name[/bold] ou [bold]new_entity_name[/bold]."
170
+ )
171
+ raise typer.Exit(1)
172
+ if entity_name is not None:
173
+ return _require_existing_entity(project_dir, entity_name)
174
+ if new_entity_name is None:
175
+ return None
176
+
177
+ raw_name = new_entity_name.strip()
178
+ _assert_valid_name(raw_name, label="entité")
179
+ names = EntityNames.from_input(raw_name)
180
+ existing_entity = _find_entity(project_dir, names.pascal)
181
+ if existing_entity is not None:
182
+ return existing_entity
183
+
184
+ expected_file = paths.domain_models / f"{names.snake}.py"
185
+ if expected_file.exists():
186
+ console.print(
187
+ f"[red]✗[/red] Le fichier [bold]{expected_file.relative_to(project_dir)}[/bold] "
188
+ f"existe mais ne déclare pas [bold]class {names.pascal}(Entity)[/bold]."
189
+ )
190
+ raise typer.Exit(1)
191
+
192
+ add_entity_cmd(project_dir=project_dir, entity_name=raw_name)
193
+ created_entity = _find_entity(project_dir, names.pascal)
194
+ if (
195
+ created_entity is None
196
+ ): # pragma: no cover - defensive check after our own template
197
+ console.print(
198
+ f"[red]✗[/red] Impossible de détecter l'entité créée {names.pascal}."
199
+ )
200
+ raise typer.Exit(1)
201
+ return created_entity
202
+
203
+
204
+ def _require_existing_entity(project_dir: Path, raw_name: str) -> EntityInfo:
205
+ normalized_name = raw_name.strip()
206
+ _assert_valid_name(normalized_name, label="entité")
207
+ names = EntityNames.from_input(normalized_name)
208
+ entities = scan_entities(project_dir)
209
+ entity = next(
210
+ (item for item in entities if item.pascal == names.pascal),
211
+ None,
212
+ )
213
+ if entity is not None:
214
+ return entity
215
+
216
+ detected = ", ".join(item.pascal for item in entities) or "aucune"
217
+ console.print(
218
+ f"[red]✗[/red] Entité introuvable : [bold]{names.pascal}[/bold]. "
219
+ f"Entités détectées : {detected}. Utilisez [bold]--new-entity {names.pascal}[/bold] "
220
+ "pour la créer."
221
+ )
222
+ raise typer.Exit(1)
223
+
224
+
225
+ def _find_entity(project_dir: Path, pascal_name: str) -> EntityInfo | None:
226
+ return next(
227
+ (
228
+ entity
229
+ for entity in scan_entities(project_dir)
230
+ if entity.pascal == pascal_name
231
+ ),
232
+ None,
233
+ )
234
+
235
+
236
+ def add_intent_interpreter_cmd(
237
+ *, project_dir: Path | None = None, intent_name: str
238
+ ) -> Path:
134
239
  project_dir = project_dir or Path.cwd()
135
240
  intent_name = intent_name.strip()
136
241
  _assert_project_root(project_dir, command="add-intent-interpreter")
@@ -142,7 +247,9 @@ def add_intent_interpreter_cmd(*, project_dir: Path | None = None, intent_name:
142
247
  _assert_missing(intent_file, project_dir)
143
248
 
144
249
  _ensure_package_dirs(paths, "application", "intent_interpreters")
145
- intent_file.write_text(_INTENT_INTERPRETER_TEMPLATE.format(class_name=names.pascal), encoding="utf-8")
250
+ intent_file.write_text(
251
+ _INTENT_INTERPRETER_TEMPLATE.format(class_name=names.pascal), encoding="utf-8"
252
+ )
146
253
  console.print(
147
254
  f"[green]✓[/green] Interpréteur d'intention {names.pascal}Interpreter créé : "
148
255
  f"[bold]{intent_file.relative_to(project_dir)}[/bold]"
@@ -152,7 +259,9 @@ def add_intent_interpreter_cmd(*, project_dir: Path | None = None, intent_name:
152
259
 
153
260
  def _assert_project_root(project_dir: Path, *, command: str) -> None:
154
261
  if not project_dir.exists() or not project_dir.is_dir():
155
- console.print(f"[red]✗[/red] Répertoire introuvable : [bold]{project_dir}[/bold].")
262
+ console.print(
263
+ f"[red]✗[/red] Répertoire introuvable : [bold]{project_dir}[/bold]."
264
+ )
156
265
  raise typer.Exit(1)
157
266
 
158
267
  has_project_marker = (
@@ -185,7 +294,9 @@ def _assert_missing(path: Path, project_dir: Path) -> None:
185
294
  if not path.exists():
186
295
  return
187
296
 
188
- console.print(f"[red]✗[/red] Le fichier existe déjà : [bold]{path.relative_to(project_dir)}[/bold].")
297
+ console.print(
298
+ f"[red]✗[/red] Le fichier existe déjà : [bold]{path.relative_to(project_dir)}[/bold]."
299
+ )
189
300
  raise typer.Exit(1)
190
301
 
191
302
 
@@ -193,7 +304,11 @@ def _ensure_package_dirs(paths: ProjectPaths, *relative_parts: str) -> None:
193
304
  target_dir = paths.package_root.joinpath(*relative_parts)
194
305
  target_dir.mkdir(parents=True, exist_ok=True)
195
306
 
196
- stop_at = paths.package_root.parent if paths.package_name is not None else paths.package_root
307
+ stop_at = (
308
+ paths.package_root.parent
309
+ if paths.package_name is not None
310
+ else paths.package_root
311
+ )
197
312
  init_dirs = [target_dir]
198
313
  for parent in target_dir.parents:
199
314
  if parent == stop_at: