arclith-cli 0.7.1__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.
- arclith_cli/__init__.py +2 -0
- arclith_cli/adapter_templates.py +173 -0
- arclith_cli/add_adapter.py +288 -0
- arclith_cli/entity_scanner.py +67 -0
- arclith_cli/export_config.py +69 -0
- arclith_cli/main.py +199 -0
- arclith_cli/rename.py +159 -0
- arclith_cli/scaffold.py +65 -0
- arclith_cli/updater.py +33 -0
- arclith_cli-0.7.1.dist-info/METADATA +192 -0
- arclith_cli-0.7.1.dist-info/RECORD +14 -0
- arclith_cli-0.7.1.dist-info/WHEEL +4 -0
- arclith_cli-0.7.1.dist-info/entry_points.txt +2 -0
- arclith_cli-0.7.1.dist-info/licenses/LICENSE +180 -0
arclith_cli/__init__.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
# ── Supported adapters ────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
SUPPORTED_ADAPTERS = ["memory", "mongodb", "duckdb"]
|
|
8
|
+
|
|
9
|
+
# ── Config YAML templates (scoped — no root key) ──────────────────────────────
|
|
10
|
+
|
|
11
|
+
CONFIG_YAML: dict[str, str] = {
|
|
12
|
+
"memory": "", # memory needs no config file
|
|
13
|
+
"mongodb": """\
|
|
14
|
+
multitenant: false # true = URI + db_name résolus par requête via JWT → Vault
|
|
15
|
+
db_name: {db_name} # uri → secrets.yaml ou Vault (fallback single-tenant)
|
|
16
|
+
""",
|
|
17
|
+
"duckdb": """\
|
|
18
|
+
multitenant: false
|
|
19
|
+
path: {path}
|
|
20
|
+
""",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# ── Python repository subclass templates ─────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
REPO_PYTHON: dict[str, str] = {
|
|
26
|
+
"memory": """\
|
|
27
|
+
from arclith.adapters.output.memory.repository import InMemoryRepository
|
|
28
|
+
from domain.models.{snake} import {pascal}
|
|
29
|
+
from domain.ports.output.{snake}_repository import {pascal}Repository
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InMemory{pascal}Repository(InMemoryRepository[{pascal}], {pascal}Repository):
|
|
33
|
+
pass # TODO: add custom query methods if needed
|
|
34
|
+
""",
|
|
35
|
+
"mongodb": """\
|
|
36
|
+
from arclith.adapters.output.mongodb.config import MongoDBConfig
|
|
37
|
+
from arclith.adapters.output.mongodb.repository import MongoDBRepository
|
|
38
|
+
from arclith.domain.ports.logger import Logger
|
|
39
|
+
from domain.models.{snake} import {pascal}
|
|
40
|
+
from domain.ports.output.{snake}_repository import {pascal}Repository
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MongoDB{pascal}Repository(MongoDBRepository[{pascal}], {pascal}Repository):
|
|
44
|
+
def __init__(self, config: MongoDBConfig, logger: Logger) -> None:
|
|
45
|
+
super().__init__(config, {pascal}, logger)
|
|
46
|
+
|
|
47
|
+
# TODO: add custom query methods here
|
|
48
|
+
# async def find_by_name(self, name: str) -> list[{pascal}]:
|
|
49
|
+
# async with self._collection() as col:
|
|
50
|
+
# return [
|
|
51
|
+
# self._from_doc(doc)
|
|
52
|
+
# async for doc in col.find({{"name": name, "deleted_at": None}})
|
|
53
|
+
# ]
|
|
54
|
+
""",
|
|
55
|
+
"duckdb": """\
|
|
56
|
+
from arclith.adapters.output.duckdb.repository import DuckDBRepository
|
|
57
|
+
from domain.models.{snake} import {pascal}
|
|
58
|
+
from domain.ports.output.{snake}_repository import {pascal}Repository
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DuckDB{pascal}Repository(DuckDBRepository[{pascal}], {pascal}Repository):
|
|
62
|
+
def __init__(self, path: str) -> None:
|
|
63
|
+
super().__init__(path, {pascal})
|
|
64
|
+
|
|
65
|
+
# TODO: add custom query methods here
|
|
66
|
+
# async def find_by_name(self, name: str) -> list[{pascal}]:
|
|
67
|
+
# rows = self._fetch(
|
|
68
|
+
# f"SELECT * FROM {{self._table}} WHERE deleted_at IS NULL AND lower(name) LIKE ?",
|
|
69
|
+
# [f"%{{name.lower()}}%"],
|
|
70
|
+
# )
|
|
71
|
+
# return [self._row_to_entity(r) for r in rows]
|
|
72
|
+
""",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# ── repository.py re-export template ─────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
REPO_REEXPORT: dict[str, str] = {
|
|
78
|
+
"memory": """\
|
|
79
|
+
from adapters.output.memory.repositories.{snake}_repository import InMemory{pascal}Repository
|
|
80
|
+
|
|
81
|
+
__all__ = ["InMemory{pascal}Repository"]
|
|
82
|
+
""",
|
|
83
|
+
"mongodb": """\
|
|
84
|
+
from adapters.output.mongodb.repositories.{snake}_repository import MongoDB{pascal}Repository
|
|
85
|
+
|
|
86
|
+
__all__ = ["MongoDB{pascal}Repository"]
|
|
87
|
+
""",
|
|
88
|
+
"duckdb": """\
|
|
89
|
+
from adapters.output.duckdb.repositories.{snake}_repository import DuckDB{pascal}Repository
|
|
90
|
+
|
|
91
|
+
__all__ = ["DuckDB{pascal}Repository"]
|
|
92
|
+
""",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# ── Container template (full file, regenerated with all installed adapters) ───
|
|
96
|
+
|
|
97
|
+
_CONTAINER_HEADER = """\
|
|
98
|
+
from __future__ import annotations
|
|
99
|
+
|
|
100
|
+
from application.services.{snake}_service import {pascal}Service
|
|
101
|
+
from arclith import Arclith, AdapterRegistry
|
|
102
|
+
from arclith.infrastructure.config import AppConfig
|
|
103
|
+
from arclith.domain.ports.logger import Logger
|
|
104
|
+
from domain.models.{snake} import {pascal}
|
|
105
|
+
from domain.ports.output.{snake}_repository import {pascal}Repository
|
|
106
|
+
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
_CONTAINER_FACTORY: dict[str, str] = {
|
|
110
|
+
"memory": """\
|
|
111
|
+
def _build_memory(cfg: AppConfig, log: Logger) -> {pascal}Repository:
|
|
112
|
+
from adapters.output.memory.repository import InMemory{pascal}Repository
|
|
113
|
+
return InMemory{pascal}Repository()
|
|
114
|
+
|
|
115
|
+
""",
|
|
116
|
+
"mongodb": """\
|
|
117
|
+
def _build_mongodb(cfg: AppConfig, log: Logger) -> {pascal}Repository:
|
|
118
|
+
from adapters.output.mongodb.repository import MongoDB{pascal}Repository
|
|
119
|
+
from arclith.adapters.output.mongodb.config import MongoDBConfig
|
|
120
|
+
mongo = cfg.adapters.mongodb
|
|
121
|
+
if mongo is None:
|
|
122
|
+
raise RuntimeError("MongoDB settings are required when repository=mongodb")
|
|
123
|
+
return MongoDB{pascal}Repository(MongoDBConfig(uri=mongo.uri, db_name=mongo.db_name), log)
|
|
124
|
+
|
|
125
|
+
""",
|
|
126
|
+
"duckdb": """\
|
|
127
|
+
def _build_duckdb(cfg: AppConfig, log: Logger) -> {pascal}Repository:
|
|
128
|
+
from adapters.output.duckdb.repository import DuckDB{pascal}Repository
|
|
129
|
+
duckdb = cfg.adapters.duckdb
|
|
130
|
+
if duckdb is None:
|
|
131
|
+
raise RuntimeError("DuckDB settings are required when repository=duckdb")
|
|
132
|
+
return DuckDB{pascal}Repository(duckdb.path)
|
|
133
|
+
|
|
134
|
+
""",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
_CONTAINER_FOOTER = """\
|
|
138
|
+
_registry: AdapterRegistry[{pascal}] = (
|
|
139
|
+
AdapterRegistry()
|
|
140
|
+
{registrations}
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def build_{snake}_service(arclith: Arclith) -> tuple[{pascal}Service, Logger]:
|
|
145
|
+
arclith.logger.info("🗄️ Repository adapter selected", adapter=arclith.config.adapters.repository)
|
|
146
|
+
repo: {pascal}Repository = _registry.build(arclith.config, arclith.logger)
|
|
147
|
+
return {pascal}Service(repo, arclith.logger, arclith.config.soft_delete.retention_days), arclith.logger
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def render_container(pascal: str, snake: str, installed_adapters: list[str]) -> str:
|
|
152
|
+
"""Generate the full container file content for a given entity and its adapters."""
|
|
153
|
+
# memory is always included (arclith built-in, needs no extra files)
|
|
154
|
+
adapters = list(dict.fromkeys(["memory"] + installed_adapters))
|
|
155
|
+
|
|
156
|
+
header = _CONTAINER_HEADER.format(pascal=pascal, snake=snake)
|
|
157
|
+
factories = "".join(
|
|
158
|
+
_CONTAINER_FACTORY[a].format(pascal=pascal, snake=snake)
|
|
159
|
+
for a in adapters
|
|
160
|
+
if a in _CONTAINER_FACTORY
|
|
161
|
+
)
|
|
162
|
+
registrations = "\n".join(
|
|
163
|
+
f" .register(\"{a}\", _build_{a})"
|
|
164
|
+
for a in adapters
|
|
165
|
+
if a in _CONTAINER_FACTORY
|
|
166
|
+
)
|
|
167
|
+
footer = _CONTAINER_FOOTER.format(pascal=pascal, snake=snake, registrations=registrations)
|
|
168
|
+
return header + factories + footer
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def render(template: str, vars: dict[str, Any]) -> str:
|
|
172
|
+
return template.format(**vars)
|
|
173
|
+
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.prompt import Confirm, Prompt
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from .adapter_templates import (
|
|
12
|
+
CONFIG_YAML,
|
|
13
|
+
REPO_PYTHON,
|
|
14
|
+
REPO_REEXPORT,
|
|
15
|
+
SUPPORTED_ADAPTERS,
|
|
16
|
+
render,
|
|
17
|
+
render_container,
|
|
18
|
+
)
|
|
19
|
+
from .entity_scanner import EntityInfo, scan_entities, scan_installed_adapters
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── Entry point ───────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
def add_adapter_cmd() -> None:
|
|
27
|
+
"""Wizard interactif pour scaffolder un nouvel adapter output."""
|
|
28
|
+
project_dir = Path.cwd()
|
|
29
|
+
|
|
30
|
+
_assert_arclith_project(project_dir)
|
|
31
|
+
|
|
32
|
+
adapter = _prompt_adapter_type()
|
|
33
|
+
entities = _prompt_entities(project_dir)
|
|
34
|
+
params = _prompt_adapter_params(adapter, project_dir)
|
|
35
|
+
activate = Confirm.ask(
|
|
36
|
+
f"\n [bold]Activer[/bold] [green]{adapter}[/green] maintenant ?",
|
|
37
|
+
default=True,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
_show_recap(project_dir, adapter, entities, params, activate)
|
|
41
|
+
|
|
42
|
+
if not Confirm.ask("\n [bold]Confirmer la génération ?[/bold]", default=True):
|
|
43
|
+
console.print("[yellow]Annulé.[/yellow]")
|
|
44
|
+
raise typer.Exit(0)
|
|
45
|
+
|
|
46
|
+
_generate(project_dir, adapter, entities, params, activate)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ── Validation ────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def _assert_arclith_project(project_dir: Path) -> None:
|
|
52
|
+
if not (project_dir / "domain" / "models").exists():
|
|
53
|
+
console.print(
|
|
54
|
+
"[red]✗[/red] Aucun dossier [bold]domain/models/[/bold] trouvé.\n"
|
|
55
|
+
" Exécutez [bold]arclith-cli add-adapter[/bold] depuis la racine d'un projet arclith."
|
|
56
|
+
)
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
if not (project_dir / "config" / "adapters").exists():
|
|
59
|
+
console.print(
|
|
60
|
+
"[red]✗[/red] Aucun dossier [bold]config/adapters/[/bold] trouvé.\n"
|
|
61
|
+
" Le projet doit utiliser la structure [bold]config/[/bold] directory."
|
|
62
|
+
)
|
|
63
|
+
raise typer.Exit(1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── Step 1 : adapter type ─────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def _prompt_adapter_type() -> str:
|
|
69
|
+
console.print("\n[bold]① Type d'adapter[/bold]")
|
|
70
|
+
for i, name in enumerate(SUPPORTED_ADAPTERS, 1):
|
|
71
|
+
console.print(f" [bold cyan]{i}[/bold cyan] {name}")
|
|
72
|
+
|
|
73
|
+
while True:
|
|
74
|
+
raw = Prompt.ask("\n Votre choix [dim](numéro ou nom)[/dim]").strip()
|
|
75
|
+
if raw.isdigit():
|
|
76
|
+
idx = int(raw) - 1
|
|
77
|
+
if 0 <= idx < len(SUPPORTED_ADAPTERS):
|
|
78
|
+
return SUPPORTED_ADAPTERS[idx]
|
|
79
|
+
elif raw in SUPPORTED_ADAPTERS:
|
|
80
|
+
return raw
|
|
81
|
+
console.print(f" [red]Choix invalide.[/red] Entrez 1-{len(SUPPORTED_ADAPTERS)} ou le nom.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ── Step 2 : entity selection ─────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
def _prompt_entities(project_dir: Path) -> list[EntityInfo]:
|
|
87
|
+
entities = scan_entities(project_dir)
|
|
88
|
+
if not entities:
|
|
89
|
+
console.print("[red]✗[/red] Aucune entité trouvée dans [bold]domain/models/[/bold].")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
|
|
92
|
+
console.print("\n[bold]② Entité(s) cible(s)[/bold]")
|
|
93
|
+
for i, e in enumerate(entities, 1):
|
|
94
|
+
console.print(f" [bold cyan]{i}[/bold cyan] {e.pascal} [dim]({e.snake})[/dim]")
|
|
95
|
+
console.print(f" [bold cyan]{len(entities) + 1}[/bold cyan] [italic]toutes[/italic]")
|
|
96
|
+
|
|
97
|
+
while True:
|
|
98
|
+
raw = Prompt.ask("\n Votre choix [dim](numéro(s) séparés par virgule, ou nom)[/dim]").strip()
|
|
99
|
+
selected = _parse_entity_choice(raw, entities)
|
|
100
|
+
if selected is not None:
|
|
101
|
+
return selected
|
|
102
|
+
console.print(" [red]Choix invalide.[/red]")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_entity_choice(raw: str, entities: list[EntityInfo]) -> list[EntityInfo] | None:
|
|
106
|
+
all_idx = len(entities) + 1
|
|
107
|
+
parts = [p.strip() for p in raw.split(",") if p.strip()]
|
|
108
|
+
result: list[EntityInfo] = []
|
|
109
|
+
for part in parts:
|
|
110
|
+
if part.isdigit():
|
|
111
|
+
idx = int(part)
|
|
112
|
+
if idx == all_idx:
|
|
113
|
+
return list(entities)
|
|
114
|
+
if 1 <= idx <= len(entities):
|
|
115
|
+
e = entities[idx - 1]
|
|
116
|
+
if e not in result:
|
|
117
|
+
result.append(e)
|
|
118
|
+
else:
|
|
119
|
+
return None
|
|
120
|
+
else:
|
|
121
|
+
matched = [e for e in entities if e.pascal == part or e.snake == part]
|
|
122
|
+
if not matched:
|
|
123
|
+
return None
|
|
124
|
+
for e in matched:
|
|
125
|
+
if e not in result:
|
|
126
|
+
result.append(e)
|
|
127
|
+
return result or None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ── Step 3 : adapter-specific params ─────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
def _prompt_adapter_params(adapter: str, project_dir: Path) -> dict:
|
|
133
|
+
console.print(f"\n[bold]③ Paramètres [green]{adapter}[/green][/bold]")
|
|
134
|
+
|
|
135
|
+
if adapter == "mongodb":
|
|
136
|
+
project_name = project_dir.name
|
|
137
|
+
db_name = Prompt.ask(
|
|
138
|
+
" db_name",
|
|
139
|
+
default=project_name,
|
|
140
|
+
).strip()
|
|
141
|
+
multitenant = Confirm.ask(" multitenant", default=False)
|
|
142
|
+
return {"db_name": db_name, "multitenant": multitenant}
|
|
143
|
+
|
|
144
|
+
if adapter == "duckdb":
|
|
145
|
+
path = Prompt.ask(" path", default="data/").strip()
|
|
146
|
+
return {"path": path}
|
|
147
|
+
|
|
148
|
+
console.print(" [dim](aucun paramètre requis)[/dim]")
|
|
149
|
+
return {}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── Step 4 : recap ────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def _show_recap(
|
|
155
|
+
project_dir: Path,
|
|
156
|
+
adapter: str,
|
|
157
|
+
entities: list[EntityInfo],
|
|
158
|
+
params: dict,
|
|
159
|
+
activate: bool,
|
|
160
|
+
) -> None:
|
|
161
|
+
installed = scan_installed_adapters(project_dir)
|
|
162
|
+
files = _list_generated_files(project_dir, adapter, entities, installed)
|
|
163
|
+
|
|
164
|
+
table = Table(show_header=True, header_style="bold blue", box=None, padding=(0, 2))
|
|
165
|
+
table.add_column("Fichier")
|
|
166
|
+
table.add_column("Action", style="dim")
|
|
167
|
+
|
|
168
|
+
for path, action in files:
|
|
169
|
+
style = "yellow" if action == "remplacé ⚠" else "green"
|
|
170
|
+
table.add_row(str(path.relative_to(project_dir)), f"[{style}]{action}[/{style}]")
|
|
171
|
+
|
|
172
|
+
if activate:
|
|
173
|
+
cfg_path = project_dir / "config" / "adapters" / "adapters.yaml"
|
|
174
|
+
table.add_row(
|
|
175
|
+
str(cfg_path.relative_to(project_dir)),
|
|
176
|
+
"[cyan]mis à jour (repository)[/cyan]",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
console.print()
|
|
180
|
+
console.print(Panel(table, title=f"[bold]Récapitulatif — adapter [green]{adapter}[/green][/bold]"))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _list_generated_files(
|
|
184
|
+
project_dir: Path,
|
|
185
|
+
adapter: str,
|
|
186
|
+
entities: list[EntityInfo],
|
|
187
|
+
installed: list[str],
|
|
188
|
+
) -> list[tuple[Path, str]]:
|
|
189
|
+
files: list[tuple[Path, str]] = []
|
|
190
|
+
|
|
191
|
+
if adapter != "memory" and CONFIG_YAML.get(adapter):
|
|
192
|
+
cfg = project_dir / "config" / "adapters" / "output" / f"{adapter}.yaml"
|
|
193
|
+
files.append((cfg, "remplacé ⚠" if cfg.exists() else "créé"))
|
|
194
|
+
|
|
195
|
+
for entity in entities:
|
|
196
|
+
base = project_dir / "adapters" / "output" / adapter
|
|
197
|
+
repo_dir = base / "repositories"
|
|
198
|
+
repo_file = repo_dir / f"{entity.snake}_repository.py"
|
|
199
|
+
reexport = base / "repository.py"
|
|
200
|
+
init = base / "__init__.py"
|
|
201
|
+
container = project_dir / "infrastructure" / "containers" / f"{entity.snake}_container.py"
|
|
202
|
+
|
|
203
|
+
files.append((init, "remplacé ⚠" if init.exists() else "créé"))
|
|
204
|
+
files.append((repo_file, "remplacé ⚠" if repo_file.exists() else "créé"))
|
|
205
|
+
files.append((reexport, "remplacé ⚠" if reexport.exists() else "créé"))
|
|
206
|
+
files.append((container, "remplacé ⚠" if container.exists() else "créé"))
|
|
207
|
+
|
|
208
|
+
return files
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ── Step 5 : generate ─────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def _generate(
|
|
214
|
+
project_dir: Path,
|
|
215
|
+
adapter: str,
|
|
216
|
+
entities: list[EntityInfo],
|
|
217
|
+
params: dict,
|
|
218
|
+
activate: bool,
|
|
219
|
+
) -> None:
|
|
220
|
+
installed = scan_installed_adapters(project_dir)
|
|
221
|
+
if adapter not in installed:
|
|
222
|
+
installed = sorted(installed + [adapter])
|
|
223
|
+
|
|
224
|
+
# Config YAML (skip memory — no config needed)
|
|
225
|
+
if adapter != "memory":
|
|
226
|
+
yaml_content = CONFIG_YAML.get(adapter, "")
|
|
227
|
+
if yaml_content:
|
|
228
|
+
cfg_path = project_dir / "config" / "adapters" / "output" / f"{adapter}.yaml"
|
|
229
|
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
cfg_path.write_text(render(yaml_content, params))
|
|
231
|
+
console.print(f"[green]✓[/green] {cfg_path.relative_to(project_dir)}")
|
|
232
|
+
|
|
233
|
+
for entity in entities:
|
|
234
|
+
vars = {"pascal": entity.pascal, "snake": entity.snake, **params}
|
|
235
|
+
base = project_dir / "adapters" / "output" / adapter
|
|
236
|
+
repo_dir = base / "repositories"
|
|
237
|
+
repo_dir.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
|
|
239
|
+
# __init__.py
|
|
240
|
+
init_file = base / "__init__.py"
|
|
241
|
+
if not init_file.exists():
|
|
242
|
+
init_file.write_text("")
|
|
243
|
+
# repositories/__init__.py
|
|
244
|
+
repo_init_file = repo_dir / "__init__.py"
|
|
245
|
+
if not repo_init_file.exists():
|
|
246
|
+
repo_init_file.write_text("")
|
|
247
|
+
console.print(f"[green]✓[/green] {repo_init_file.relative_to(project_dir)}")
|
|
248
|
+
|
|
249
|
+
# Repository subclass
|
|
250
|
+
repo_file = repo_dir / f"{entity.snake}_repository.py"
|
|
251
|
+
repo_file.write_text(render(REPO_PYTHON[adapter], vars))
|
|
252
|
+
console.print(f"[green]✓[/green] {repo_file.relative_to(project_dir)}")
|
|
253
|
+
|
|
254
|
+
# Re-export
|
|
255
|
+
reexport = base / "repository.py"
|
|
256
|
+
reexport.write_text(render(REPO_REEXPORT[adapter], vars))
|
|
257
|
+
console.print(f"[green]✓[/green] {reexport.relative_to(project_dir)}")
|
|
258
|
+
|
|
259
|
+
# Container (full regeneration)
|
|
260
|
+
container = project_dir / "infrastructure" / "containers" / f"{entity.snake}_container.py"
|
|
261
|
+
existed = container.exists()
|
|
262
|
+
container.parent.mkdir(parents=True, exist_ok=True)
|
|
263
|
+
container.write_text(render_container(entity.pascal, entity.snake, installed))
|
|
264
|
+
action = "[yellow]remplacé ⚠[/yellow]" if existed else "[green]créé[/green]"
|
|
265
|
+
console.print(f"{action} {container.relative_to(project_dir)}")
|
|
266
|
+
|
|
267
|
+
# Activate: update config/adapters/adapters.yaml
|
|
268
|
+
if activate:
|
|
269
|
+
_update_active_adapter(project_dir, adapter)
|
|
270
|
+
|
|
271
|
+
console.print(f"\n[bold green]✓ Adapter [cyan]{adapter}[/cyan] scaffoldé avec succès.[/bold green]")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _update_active_adapter(project_dir: Path, adapter: str) -> None:
|
|
275
|
+
import re
|
|
276
|
+
cfg = project_dir / "config" / "adapters" / "adapters.yaml"
|
|
277
|
+
if not cfg.exists():
|
|
278
|
+
cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
cfg.write_text(f"repository: {adapter}\n")
|
|
280
|
+
else:
|
|
281
|
+
text = cfg.read_text()
|
|
282
|
+
if re.search(r"(?m)^repository:", text):
|
|
283
|
+
text = re.sub(r"(?m)^(repository:\s*).*$", rf"\g<1>{adapter}", text)
|
|
284
|
+
else:
|
|
285
|
+
text = text.rstrip("\n") + f"\nrepository: {adapter}\n"
|
|
286
|
+
cfg.write_text(text)
|
|
287
|
+
console.print(f"[cyan]↺[/cyan] config/adapters/adapters.yaml → repository: {adapter}")
|
|
288
|
+
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class EntityInfo:
|
|
10
|
+
pascal: str # Ingredient
|
|
11
|
+
snake: str # ingredient
|
|
12
|
+
file_path: Path
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scan_entities(project_dir: Path) -> list[EntityInfo]:
|
|
16
|
+
"""Scan domain/models/*.py via AST to find Entity subclasses.
|
|
17
|
+
|
|
18
|
+
Extracts any class that directly names 'Entity' as a base.
|
|
19
|
+
Parsing errors are silently skipped so a broken file never blocks the wizard.
|
|
20
|
+
"""
|
|
21
|
+
models_dir = project_dir / "domain" / "models"
|
|
22
|
+
if not models_dir.exists():
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
entities: list[EntityInfo] = []
|
|
26
|
+
for py_file in sorted(models_dir.glob("*.py")):
|
|
27
|
+
if py_file.name.startswith("_"):
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
tree = ast.parse(py_file.read_text(encoding="utf-8"))
|
|
31
|
+
except (SyntaxError, UnicodeDecodeError):
|
|
32
|
+
continue
|
|
33
|
+
for node in ast.walk(tree):
|
|
34
|
+
if not isinstance(node, ast.ClassDef):
|
|
35
|
+
continue
|
|
36
|
+
base_names = {
|
|
37
|
+
b.id if isinstance(b, ast.Name)
|
|
38
|
+
else b.attr if isinstance(b, ast.Attribute)
|
|
39
|
+
else ""
|
|
40
|
+
for b in node.bases
|
|
41
|
+
}
|
|
42
|
+
if "Entity" in base_names:
|
|
43
|
+
entities.append(EntityInfo(
|
|
44
|
+
pascal=node.name,
|
|
45
|
+
snake=_to_snake(node.name),
|
|
46
|
+
file_path=py_file,
|
|
47
|
+
))
|
|
48
|
+
return entities
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def scan_installed_adapters(project_dir: Path) -> list[str]:
|
|
52
|
+
"""Return adapter names found under adapters/output/ (subdirectory names)."""
|
|
53
|
+
output_dir = project_dir / "adapters" / "output"
|
|
54
|
+
if not output_dir.exists():
|
|
55
|
+
return []
|
|
56
|
+
return sorted(
|
|
57
|
+
p.name for p in output_dir.iterdir()
|
|
58
|
+
if p.is_dir() and not p.name.startswith("_")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _to_snake(pascal: str) -> str:
|
|
63
|
+
import re
|
|
64
|
+
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", pascal)
|
|
65
|
+
s = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s)
|
|
66
|
+
return s.lower()
|
|
67
|
+
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
|
|
10
|
+
console = Console()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def export_config_cmd(
|
|
14
|
+
output: Annotated[
|
|
15
|
+
Path,
|
|
16
|
+
typer.Option("--output", "-o", help="Chemin du fichier YAML généré"),
|
|
17
|
+
] = Path("config.yaml"),
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Générer un [bold]config.yaml[/bold] unifié depuis le dossier [bold]config/[/bold].
|
|
20
|
+
|
|
21
|
+
Utile pour le déploiement Kubernetes — le fichier généré peut être monté
|
|
22
|
+
directement comme [bold]ConfigMap[/bold]. Arclith l'accepte en lecture via
|
|
23
|
+
[dim]Arclith("config.yaml")[/dim] au même titre que le dossier [dim]config/[/dim].
|
|
24
|
+
"""
|
|
25
|
+
project_dir = Path.cwd()
|
|
26
|
+
config_dir = project_dir / "config"
|
|
27
|
+
|
|
28
|
+
if not config_dir.is_dir():
|
|
29
|
+
console.print(
|
|
30
|
+
"[red]✗[/red] Aucun dossier [bold]config/[/bold] trouvé.\n"
|
|
31
|
+
" Exécutez depuis la racine d'un projet arclith."
|
|
32
|
+
)
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
output_path = output if output.is_absolute() else project_dir / output
|
|
36
|
+
|
|
37
|
+
if output_path.exists():
|
|
38
|
+
from rich.prompt import Confirm
|
|
39
|
+
if not Confirm.ask(f" [yellow]{output_path.relative_to(project_dir)}[/yellow] existe déjà. Écraser ?", default=True):
|
|
40
|
+
console.print("[yellow]Annulé.[/yellow]")
|
|
41
|
+
raise typer.Exit(0)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from arclith.infrastructure.config import export_config_yaml
|
|
45
|
+
export_config_yaml(config_dir, output_path)
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
console.print(f"[red]✗ Erreur :[/red] {exc}")
|
|
48
|
+
raise typer.Exit(1) from exc
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
rel = output_path.relative_to(project_dir)
|
|
52
|
+
display_path = str(rel)
|
|
53
|
+
except ValueError:
|
|
54
|
+
# output_path is outside project_dir (e.g., /tmp/config.yaml)
|
|
55
|
+
display_path = str(output_path)
|
|
56
|
+
|
|
57
|
+
console.print(
|
|
58
|
+
Panel.fit(
|
|
59
|
+
f"[green]✓[/green] [bold]{display_path}[/bold] généré depuis [dim]config/[/dim]\n\n"
|
|
60
|
+
f" [bold cyan]Kubernetes[/bold cyan] Monter ce fichier comme ConfigMap\n"
|
|
61
|
+
f" [bold cyan]Arclith[/bold cyan] [dim]Arclith(\"{display_path}\")[/dim] ← identique à [dim]Arclith(\"config/\")[/dim]\n\n"
|
|
62
|
+
f" [dim]⚠ Fichier généré — ne pas éditer manuellement.[/dim]\n"
|
|
63
|
+
f" [dim] Ajouter [bold]config.yaml[/bold] à .gitignore[/dim]",
|
|
64
|
+
border_style="green",
|
|
65
|
+
title="[bold]export-config[/bold]",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
)
|
|
69
|
+
|