forge-mvc-pivot 1.0.0b16__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.
@@ -0,0 +1,36 @@
1
+ Forge — Licence propriétaire / source disponible
2
+
3
+ Copyright (c) Roger Lequette.
4
+ Tous droits réservés.
5
+
6
+ Le code source de Forge est mis à disposition uniquement pour lecture, étude,
7
+ évaluation et usage éducatif personnel.
8
+
9
+ Sauf autorisation écrite préalable de Roger Lequette, il est interdit de :
10
+
11
+ - utiliser Forge dans un cadre professionnel, commercial ou institutionnel ;
12
+ - utiliser Forge pour une prestation client ;
13
+ - intégrer Forge dans un produit, service, SaaS, application vendue ou solution
14
+ déployée pour un tiers ;
15
+ - redistribuer Forge, modifié ou non ;
16
+ - publier une version modifiée de Forge ;
17
+ - vendre, louer, sous-licencier ou exploiter commercialement Forge ;
18
+ - supprimer ou modifier les mentions de copyright et de licence.
19
+
20
+ Les usages autorisés sans autorisation écrite préalable sont limités à :
21
+
22
+ - lire le code ;
23
+ - étudier son fonctionnement ;
24
+ - tester Forge à titre personnel ;
25
+ - l'utiliser dans un cadre éducatif personnel ou pédagogique non commercial ;
26
+ - évaluer le framework avant une éventuelle demande d'autorisation.
27
+
28
+ Toute utilisation non explicitement autorisée par cette licence nécessite une
29
+ autorisation écrite préalable de Roger Lequette.
30
+
31
+ Cette licence pourra évoluer ultérieurement. Toute version publiée de Forge
32
+ reste soumise à la licence présente dans son dépôt au moment de sa récupération.
33
+
34
+ LE LOGICIEL EST FOURNI « TEL QUEL », SANS GARANTIE D'AUCUNE SORTE, EXPRESSE OU
35
+ IMPLICITE. EN AUCUN CAS LE DÉTENTEUR DU COPYRIGHT NE POURRA ÊTRE TENU
36
+ RESPONSABLE DE TOUT DOMMAGE DÉCOULANT DE L'UTILISATION DE CE LOGICIEL.
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: forge-mvc-pivot
3
+ Version: 1.0.0b16
4
+ Summary: Forge pivot — tables pivot enrichies (many_to_many avec attributs) et generateur make:pivot-crud.
5
+ Author: Roger Lequette
6
+ License-Expression: LicenseRef-Forge-Proprietary
7
+ Project-URL: Homepage, https://github.com/caucrogeGit/Forge
8
+ Project-URL: Repository, https://github.com/caucrogeGit/Forge
9
+ Project-URL: Documentation, https://forgemvc.com/docs/forge/
10
+ Keywords: python,mvc,forge,pivot,many-to-many
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: forge-mvc<2,>=1.0.0b16
20
+ Dynamic: license-file
21
+
22
+ # forge-mvc-pivot
23
+
24
+ Opt-in Forge pour les **tables pivot enrichies** : associations `many_to_many`
25
+ portant des attributs (par exemple `position`, `note`) sur la ligne de jointure.
26
+
27
+ Extrait du core de Forge (ADR-021) : le core ne contient que les primitives
28
+ générales ; le pivot avancé est une brique spécialisée, optionnelle.
29
+
30
+ ## Contenu
31
+
32
+ - `PivotAdvancedService` : lecture et écriture d'associations pivot avec
33
+ attributs, contraintes déclaratives (`required`, `nullable`, `unique_pair`),
34
+ accès base injectables (`fetch_one`, `fetch_all`, `execute`, `insert_fn`).
35
+ - `PivotFieldConstraint`, `PivotRow`, `PivotFormError`, `PivotConstraintError`,
36
+ `pivot_error_to_form_error` : contraintes, résultats et erreurs structurées.
37
+ - Générateur `forge make:pivot-crud <EntitéSource> <nomRelation>` : échafaude un
38
+ sous-CRUD pivot à partir d'une relation `many_to_many` déclarée dans
39
+ `relations.json`.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install --pre forge-mvc-pivot
45
+ ```
46
+
47
+ Le code généré importe `forge_mvc_pivot` : installez le paquet avant de lancer
48
+ une application qui s'appuie sur un sous-CRUD pivot.
@@ -0,0 +1,27 @@
1
+ # forge-mvc-pivot
2
+
3
+ Opt-in Forge pour les **tables pivot enrichies** : associations `many_to_many`
4
+ portant des attributs (par exemple `position`, `note`) sur la ligne de jointure.
5
+
6
+ Extrait du core de Forge (ADR-021) : le core ne contient que les primitives
7
+ générales ; le pivot avancé est une brique spécialisée, optionnelle.
8
+
9
+ ## Contenu
10
+
11
+ - `PivotAdvancedService` : lecture et écriture d'associations pivot avec
12
+ attributs, contraintes déclaratives (`required`, `nullable`, `unique_pair`),
13
+ accès base injectables (`fetch_one`, `fetch_all`, `execute`, `insert_fn`).
14
+ - `PivotFieldConstraint`, `PivotRow`, `PivotFormError`, `PivotConstraintError`,
15
+ `pivot_error_to_form_error` : contraintes, résultats et erreurs structurées.
16
+ - Générateur `forge make:pivot-crud <EntitéSource> <nomRelation>` : échafaude un
17
+ sous-CRUD pivot à partir d'une relation `many_to_many` déclarée dans
18
+ `relations.json`.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ pip install --pre forge-mvc-pivot
24
+ ```
25
+
26
+ Le code généré importe `forge_mvc_pivot` : installez le paquet avant de lancer
27
+ une application qui s'appuie sur un sous-CRUD pivot.
@@ -0,0 +1,24 @@
1
+ """forge-mvc-pivot — Pivot advanced opt-in (extrait du core, ADR-021).
2
+
3
+ Service de persistance pour tables pivot enrichies (associations many_to_many
4
+ avec attributs) et générateur `forge make:pivot-crud`.
5
+ """
6
+ from forge_mvc_pivot.service import (
7
+ PivotAdvancedService,
8
+ PivotConstraintError,
9
+ PivotFieldConstraint,
10
+ PivotFormError,
11
+ PivotRow,
12
+ pivot_error_to_form_error,
13
+ )
14
+
15
+ __all__ = [
16
+ "PivotAdvancedService",
17
+ "PivotConstraintError",
18
+ "PivotFieldConstraint",
19
+ "PivotFormError",
20
+ "PivotRow",
21
+ "pivot_error_to_form_error",
22
+ ]
23
+
24
+ __version__ = "1.0.0b16"
@@ -0,0 +1,489 @@
1
+ """forge_mvc_pivot/make_pivot_crud.py — Générateur de sous-CRUD Pivot advanced (opt-in).
2
+
3
+ Commande : forge make:pivot-crud <EntitéSource> <nomRelation> [--dry-run]
4
+
5
+ Analyse une relation many_to_many avec pivot.fields[] et génère une structure
6
+ dédiée minimale sans modifier make:crud.
7
+
8
+ Décision : PIVOT-ADVANCED-004.
9
+ Service runtime : forge_mvc_pivot.PivotAdvancedService (PIVOT-ADVANCED-003).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import re
16
+ from dataclasses import dataclass, field as dc_field
17
+ from pathlib import Path
18
+
19
+ import forge_cli.output as out
20
+
21
+ _SNAKE_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
22
+
23
+
24
+ def _to_snake(name: str) -> str:
25
+ return _SNAKE_RE.sub("_", name).lower()
26
+
27
+
28
+ # ── Données de la relation résolue ────────────────────────────────────────────
29
+
30
+ @dataclass(frozen=True)
31
+ class ResolvedPivotRelation:
32
+ from_entity: str
33
+ to_entity: str
34
+ relation_name: str
35
+ pivot_table: str
36
+ from_key: str
37
+ to_key: str
38
+ pivot_fields: tuple[str, ...]
39
+
40
+
41
+ # ── Résultat ──────────────────────────────────────────────────────────────────
42
+
43
+ @dataclass
44
+ class MakePivotCrudResult:
45
+ created: list[Path] = dc_field(default_factory=list)
46
+ preserved: list[Path] = dc_field(default_factory=list)
47
+ dry_run: bool = False
48
+
49
+
50
+ # ── Résolution de la relation ─────────────────────────────────────────────────
51
+
52
+ def resolve_pivot_relation(
53
+ source_entity: str,
54
+ relation_name: str,
55
+ *,
56
+ relations_path: Path,
57
+ ) -> ResolvedPivotRelation:
58
+ """Charge relations.json et trouve la relation many_to_many cible.
59
+
60
+ Raises SystemExit avec message clair pour tous les cas d'erreur.
61
+ """
62
+ if not relations_path.exists():
63
+ print(out.error(
64
+ "mvc/entities/relations.json introuvable.\n"
65
+ "Créez d'abord vos relations avec forge make:relation."
66
+ ))
67
+ raise SystemExit(1)
68
+
69
+ try:
70
+ raw = json.loads(relations_path.read_text(encoding="utf-8"))
71
+ except json.JSONDecodeError as exc:
72
+ print(out.error(f"relations.json invalide (JSON malformé) : {exc}"))
73
+ raise SystemExit(1)
74
+
75
+ if not isinstance(raw, dict) or raw.get("schema_version") != "1.0":
76
+ print(out.error('relations.json invalide : schema_version "1.0" attendu.'))
77
+ raise SystemExit(1)
78
+
79
+ relations = raw.get("relations")
80
+ if not isinstance(relations, list):
81
+ print(out.error("relations.json invalide : clé \"relations\" manquante ou invalide."))
82
+ raise SystemExit(1)
83
+
84
+ candidates = [
85
+ r for r in relations
86
+ if isinstance(r, dict)
87
+ and r.get("type") == "many_to_many"
88
+ and r.get("from") == source_entity
89
+ and r.get("name") == relation_name
90
+ ]
91
+
92
+ if not candidates:
93
+ # Distinguer les cas d'erreur
94
+ source_exists = any(
95
+ isinstance(r, dict) and r.get("from") == source_entity
96
+ for r in relations
97
+ )
98
+ if not source_exists:
99
+ print(out.error(f"Entité source inconnue : {source_entity!r} n'apparaît dans aucune relation."))
100
+ else:
101
+ print(out.error(
102
+ f"Relation introuvable : {source_entity}.{relation_name}.\n"
103
+ f"Vérifiez le nom de la relation (clé \"name\" dans relations.json)."
104
+ ))
105
+ raise SystemExit(1)
106
+
107
+ if len(candidates) > 1:
108
+ print(out.error(f"Relation ambiguë : plusieurs relations {source_entity}.{relation_name} trouvées."))
109
+ raise SystemExit(1)
110
+
111
+ rel = candidates[0]
112
+
113
+ if rel.get("type") != "many_to_many":
114
+ print(out.error(
115
+ f"Relation {source_entity}.{relation_name} n'est pas many_to_many.\n"
116
+ "make:pivot-crud est réservé aux relations many_to_many avec pivot.fields[]."
117
+ ))
118
+ raise SystemExit(1)
119
+
120
+ pivot = rel.get("pivot")
121
+ if not isinstance(pivot, dict):
122
+ print(out.error(
123
+ f"Relation {source_entity}.{relation_name} n'a pas de bloc pivot.\n"
124
+ "make:pivot-crud est réservé aux pivots avec attributs."
125
+ ))
126
+ raise SystemExit(1)
127
+
128
+ fields = pivot.get("fields")
129
+ if not isinstance(fields, list) or len(fields) == 0:
130
+ print(out.error(
131
+ f"Relation {source_entity}.{relation_name} : pivot.fields[] est absent ou vide.\n"
132
+ "make:pivot-crud est réservé aux pivots avec attributs.\n"
133
+ "Utilisez make:crud pour les pivots simples."
134
+ ))
135
+ raise SystemExit(1)
136
+
137
+ pivot_field_names = tuple(
138
+ f["name"] for f in fields
139
+ if isinstance(f, dict) and isinstance(f.get("name"), str)
140
+ )
141
+
142
+ return ResolvedPivotRelation(
143
+ from_entity=source_entity,
144
+ to_entity=rel.get("to", ""),
145
+ relation_name=relation_name,
146
+ pivot_table=pivot.get("table", ""),
147
+ from_key=pivot.get("from_key", ""),
148
+ to_key=pivot.get("to_key", ""),
149
+ pivot_fields=pivot_field_names,
150
+ )
151
+
152
+
153
+ # ── Contenu généré ────────────────────────────────────────────────────────────
154
+
155
+ def _build_controller(rel: ResolvedPivotRelation) -> str:
156
+ src_snake = _to_snake(rel.from_entity)
157
+ tgt_snake = _to_snake(rel.to_entity)
158
+ fields_repr = repr(list(rel.pivot_fields))
159
+
160
+ return f'''\
161
+ """mvc/controllers/pivot/{src_snake}_{rel.relation_name}_pivot_controller.py
162
+
163
+ Sous-CRUD Pivot advanced pour {rel.from_entity}.{rel.relation_name}.
164
+ Généré par forge make:pivot-crud {rel.from_entity} {rel.relation_name}.
165
+
166
+ Ce contrôleur est opt-in — les routes (imbriquées sous la source) sont à
167
+ déclarer manuellement dans mvc/routes.py. Noms de routes au format ADR-029
168
+ (<contrôleur>-<méthode>) ; les identifiants sont lus depuis les paramètres
169
+ de route « source_id » et « target_id ».
170
+
171
+ Exemple de routes à ajouter :
172
+ with router.group("") as g:
173
+ g.add("GET", "/{src_snake}s/{{source_id}}/{rel.relation_name}", controller.index, name="{src_snake}_{rel.relation_name}_pivot-index")
174
+ g.add("GET", "/{src_snake}s/{{source_id}}/{rel.relation_name}/add", controller.add_form, name="{src_snake}_{rel.relation_name}_pivot-add_form")
175
+ g.add("POST", "/{src_snake}s/{{source_id}}/{rel.relation_name}/add", controller.add, name="{src_snake}_{rel.relation_name}_pivot-add")
176
+ g.add("GET", "/{src_snake}s/{{source_id}}/{rel.relation_name}/{{target_id}}/edit", controller.edit_form, name="{src_snake}_{rel.relation_name}_pivot-edit_form")
177
+ g.add("POST", "/{src_snake}s/{{source_id}}/{rel.relation_name}/{{target_id}}/edit", controller.edit, name="{src_snake}_{rel.relation_name}_pivot-edit")
178
+ g.add("POST", "/{src_snake}s/{{source_id}}/{rel.relation_name}/{{target_id}}/remove", controller.remove, name="{src_snake}_{rel.relation_name}_pivot-remove")
179
+ """
180
+ from __future__ import annotations
181
+
182
+ from forge_mvc_pivot import PivotAdvancedService, PivotConstraintError, pivot_error_to_form_error
183
+ from core.http.request import Request
184
+ from core.http.response import Response
185
+ from core.mvc.controller import BaseController
186
+
187
+ _SERVICE = PivotAdvancedService(
188
+ table={rel.pivot_table!r},
189
+ source_key={rel.from_key!r},
190
+ target_key={rel.to_key!r},
191
+ pivot_fields={fields_repr},
192
+ )
193
+
194
+ _TEMPLATE_INDEX = "pivot/{src_snake}_{rel.relation_name}/index.html"
195
+ _TEMPLATE_FORM = "pivot/{src_snake}_{rel.relation_name}/form.html"
196
+
197
+
198
+ class {rel.from_entity}{rel.relation_name.capitalize()}PivotController:
199
+
200
+ @staticmethod
201
+ def index(request: Request) -> Response:
202
+ """Liste les associations {rel.pivot_table}."""
203
+ {src_snake}_id = int(request.route("source_id"))
204
+ rows = _SERVICE.list_for_source({src_snake}_id)
205
+ return BaseController.render(_TEMPLATE_INDEX, context={{
206
+ "{src_snake}_id": {src_snake}_id,
207
+ "rows": rows,
208
+ }}, request=request)
209
+
210
+ @staticmethod
211
+ def add_form(request: Request) -> Response:
212
+ """Formulaire d'ajout d'une association."""
213
+ {src_snake}_id = int(request.route("source_id"))
214
+ return BaseController.render(_TEMPLATE_FORM, context={{
215
+ "{src_snake}_id": {src_snake}_id,
216
+ "row": None,
217
+ "action": f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}/add",
218
+ "error": None,
219
+ }}, request=request)
220
+
221
+ @staticmethod
222
+ def add(request: Request) -> Response:
223
+ """Crée une nouvelle association."""
224
+ {src_snake}_id = int(request.route("source_id"))
225
+ {tgt_snake}_id = int(request.form("{rel.to_key}"))
226
+ pivot_data = {{
227
+ field: request.form(field)
228
+ for field in {fields_repr}
229
+ if request.form(field) is not None
230
+ }}
231
+ try:
232
+ _SERVICE.attach({src_snake}_id, {tgt_snake}_id, pivot_data)
233
+ return BaseController.redirect(f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}", request=request)
234
+ except Exception as exc:
235
+ error = pivot_error_to_form_error(exc)
236
+ return BaseController.render(_TEMPLATE_FORM, context={{
237
+ "{src_snake}_id": {src_snake}_id,
238
+ "row": None,
239
+ "action": f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}/add",
240
+ "error": error,
241
+ }}, request=request)
242
+
243
+ @staticmethod
244
+ def edit_form(request: Request) -> Response:
245
+ """Formulaire de modification d'une association."""
246
+ {src_snake}_id = int(request.route("source_id"))
247
+ {tgt_snake}_id = int(request.route("target_id"))
248
+ row = _SERVICE.get({src_snake}_id, {tgt_snake}_id)
249
+ if row is None:
250
+ return BaseController.redirect(f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}", request=request)
251
+ return BaseController.render(_TEMPLATE_FORM, context={{
252
+ "{src_snake}_id": {src_snake}_id,
253
+ "row": row,
254
+ "action": f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}/{{{tgt_snake}_id}}/edit",
255
+ "error": None,
256
+ }}, request=request)
257
+
258
+ @staticmethod
259
+ def edit(request: Request) -> Response:
260
+ """Modifie les attributs pivot d'une association."""
261
+ {src_snake}_id = int(request.route("source_id"))
262
+ {tgt_snake}_id = int(request.route("target_id"))
263
+ pivot_data = {{
264
+ field: request.form(field)
265
+ for field in {fields_repr}
266
+ if request.form(field) is not None
267
+ }}
268
+ try:
269
+ _SERVICE.update({src_snake}_id, {tgt_snake}_id, pivot_data)
270
+ return BaseController.redirect(f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}", request=request)
271
+ except Exception as exc:
272
+ error = pivot_error_to_form_error(exc)
273
+ row = _SERVICE.get({src_snake}_id, {tgt_snake}_id)
274
+ return BaseController.render(_TEMPLATE_FORM, context={{
275
+ "{src_snake}_id": {src_snake}_id,
276
+ "row": row,
277
+ "action": f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}/{{{tgt_snake}_id}}/edit",
278
+ "error": error,
279
+ }}, request=request)
280
+
281
+ @staticmethod
282
+ def remove(request: Request) -> Response:
283
+ """Supprime une association."""
284
+ {src_snake}_id = int(request.route("source_id"))
285
+ {tgt_snake}_id = int(request.route("target_id"))
286
+ _SERVICE.detach({src_snake}_id, {tgt_snake}_id)
287
+ return BaseController.redirect(f"/{src_snake}s/{{{src_snake}_id}}/{rel.relation_name}", request=request)
288
+
289
+
290
+ controller = {rel.from_entity}{rel.relation_name.capitalize()}PivotController()
291
+ '''
292
+
293
+
294
+ def _build_index_template(rel: ResolvedPivotRelation) -> str:
295
+ src_snake = _to_snake(rel.from_entity)
296
+ tgt_snake = _to_snake(rel.to_entity)
297
+ field_headers = "".join(f" <th>{f}</th>\n" for f in rel.pivot_fields)
298
+ field_cells = "".join(
299
+ f" <td>{{{{ row.pivot_data.get('{f}', '') }}}}</td>\n"
300
+ for f in rel.pivot_fields
301
+ )
302
+
303
+ return f"""\
304
+ {{% extends "layouts/app.html" %}}
305
+ {{% block content %}}
306
+ <div class="flex justify-between items-center mb-6">
307
+ <h1 class="text-2xl font-bold text-gray-800">
308
+ {rel.from_entity} #{{{ src_snake}_id}} — {rel.relation_name}
309
+ </h1>
310
+ <a href="/{src_snake}s/{{{{ {src_snake}_id }}}}/{rel.relation_name}/add"
311
+ class="text-blue-600 hover:underline">Ajouter</a>
312
+ </div>
313
+
314
+ {{% if rows %}}
315
+ <table class="w-full bg-white shadow rounded">
316
+ <thead>
317
+ <tr>
318
+ <th>{tgt_snake}</th>
319
+ {field_headers} <th>Actions</th>
320
+ </tr>
321
+ </thead>
322
+ <tbody>
323
+ {{% for row in rows %}}
324
+ <tr>
325
+ <td>{{{{ row.target_id }}}}</td>
326
+ {field_cells} <td>
327
+ <a href="/{src_snake}s/{{{{ {src_snake}_id }}}}/{rel.relation_name}/{{{{ row.target_id }}}}/edit">Modifier</a>
328
+ <form method="post" action="/{src_snake}s/{{{{ {src_snake}_id }}}}/{rel.relation_name}/{{{{ row.target_id }}}}/remove" style="display:inline">
329
+ <input type="hidden" name="csrf_token" value="{{{{ csrf_token }}}}">
330
+ <button type="submit">Retirer</button>
331
+ </form>
332
+ </td>
333
+ </tr>
334
+ {{% endfor %}}
335
+ </tbody>
336
+ </table>
337
+ {{% else %}}
338
+ <p class="text-gray-500">Aucune association.</p>
339
+ {{% endif %}}
340
+ {{% endblock %}}
341
+ """
342
+
343
+
344
+ def _build_form_template(rel: ResolvedPivotRelation) -> str:
345
+ src_snake = _to_snake(rel.from_entity)
346
+ tgt_snake = _to_snake(rel.to_entity)
347
+ field_inputs = "".join(
348
+ f"""<div>
349
+ <label>{f}</label>
350
+ <input type="text" name="{f}" value="{{{{ row.pivot_data.get('{f}', '') if row else '' }}}}">
351
+ </div>
352
+ """
353
+ for f in rel.pivot_fields
354
+ )
355
+
356
+ return f"""\
357
+ {{% extends "layouts/app.html" %}}
358
+ {{% block content %}}
359
+ <h1 class="text-2xl font-bold text-gray-800 mb-6">
360
+ {{{{ "Modifier" if row else "Ajouter" }}}} — {rel.from_entity}.{rel.relation_name}
361
+ </h1>
362
+
363
+ {{% if error %}}
364
+ <div class="bg-red-50 border border-red-300 text-red-700 px-4 py-3 rounded mb-4">
365
+ {{{{ error.message }}}}
366
+ </div>
367
+ {{% endif %}}
368
+
369
+ <form method="post" action="{{{{ action }}}}">
370
+ <input type="hidden" name="csrf_token" value="{{{{ csrf_token }}}}">
371
+
372
+ {{% if not row %}}
373
+ <div>
374
+ <label>{tgt_snake}</label>
375
+ <input type="number" name="{rel.to_key}" required>
376
+ </div>
377
+ {{% endif %}}
378
+
379
+ {field_inputs}
380
+ <button type="submit">Enregistrer</button>
381
+ <a href="/{src_snake}s/{{{{ {src_snake}_id }}}}/{rel.relation_name}">Annuler</a>
382
+ </form>
383
+ {{% endblock %}}
384
+ """
385
+
386
+
387
+ # ── Écriture fichier ──────────────────────────────────────────────────────────
388
+
389
+ def _write_if_new(
390
+ path: Path,
391
+ content: str,
392
+ result: MakePivotCrudResult,
393
+ dry_run: bool,
394
+ ) -> None:
395
+ if path.exists():
396
+ result.preserved.append(path)
397
+ else:
398
+ if not dry_run:
399
+ path.parent.mkdir(parents=True, exist_ok=True)
400
+ path.write_text(content, encoding="utf-8")
401
+ result.created.append(path)
402
+
403
+
404
+ # ── Entrée principale ─────────────────────────────────────────────────────────
405
+
406
+ def make_pivot_crud(
407
+ source_entity: str,
408
+ relation_name: str,
409
+ *,
410
+ entities_root: Path,
411
+ output_root: Path,
412
+ dry_run: bool = False,
413
+ ) -> MakePivotCrudResult:
414
+ """Génère un sous-CRUD Pivot advanced pour une relation many_to_many."""
415
+ relations_path = entities_root / "relations.json"
416
+ rel = resolve_pivot_relation(
417
+ source_entity,
418
+ relation_name,
419
+ relations_path=relations_path,
420
+ )
421
+
422
+ src_snake = _to_snake(rel.from_entity)
423
+ controllers_dir = output_root / "mvc" / "controllers" / "pivot"
424
+ templates_dir = output_root / "mvc" / "templates" / "pivot" / f"{src_snake}_{rel.relation_name}"
425
+
426
+ controller_path = controllers_dir / f"{src_snake}_{rel.relation_name}_pivot_controller.py"
427
+ index_path = templates_dir / "index.html"
428
+ form_path = templates_dir / "form.html"
429
+
430
+ result = MakePivotCrudResult(dry_run=dry_run)
431
+
432
+ _write_if_new(controller_path, _build_controller(rel), result, dry_run)
433
+ _write_if_new(index_path, _build_index_template(rel), result, dry_run)
434
+ _write_if_new(form_path, _build_form_template(rel), result, dry_run)
435
+
436
+ return result
437
+
438
+
439
+ def cmd_make_pivot_crud_main(args: list[str]) -> None:
440
+ if len(args) < 2 or args[0].startswith("-"):
441
+ print("Usage : forge make:pivot-crud EntitéSource nomRelation [--dry-run]")
442
+ raise SystemExit(1)
443
+
444
+ source_entity = args[0]
445
+ relation_name = args[1]
446
+ dry_run = "--dry-run" in args
447
+ unknown = [a for a in args[2:] if a != "--dry-run"]
448
+ if unknown:
449
+ print(out.error(f"Arguments inconnus : {' '.join(unknown)}"))
450
+ raise SystemExit(1)
451
+
452
+ result = make_pivot_crud(
453
+ source_entity,
454
+ relation_name,
455
+ entities_root=Path("mvc") / "entities",
456
+ output_root=Path("."),
457
+ dry_run=dry_run,
458
+ )
459
+
460
+ if dry_run:
461
+ print(out.dry_run("Aucun fichier créé."))
462
+ if result.created:
463
+ print("Fichiers qui seraient générés :")
464
+ for p in result.created:
465
+ print(f" - {p.as_posix()}")
466
+ else:
467
+ for p in result.created:
468
+ print(out.created(p.as_posix()))
469
+ for p in result.preserved:
470
+ print(out.preserved(p.as_posix(), "← fichier existant, non touché"))
471
+
472
+ if result.created:
473
+ src_snake = _to_snake(source_entity)
474
+ print()
475
+ print(out.ok(f"Pivot advanced généré pour {source_entity}.{relation_name}"))
476
+ print()
477
+ ctrl_ref = f"{src_snake}_{relation_name}_ctrl"
478
+ name_prefix = f"{src_snake}_{relation_name}_pivot"
479
+ base = f"/{src_snake}s/{{source_id}}/{relation_name}"
480
+ print("Routes à ajouter dans mvc/routes.py :")
481
+ print(f' # Pivot {source_entity}.{relation_name}')
482
+ print(f' from mvc.controllers.pivot.{src_snake}_{relation_name}_pivot_controller import controller as {ctrl_ref}')
483
+ print(' with router.group("") as g:')
484
+ print(f' g.add("GET", "{base}", {ctrl_ref}.index, name="{name_prefix}-index")')
485
+ print(f' g.add("GET", "{base}/add", {ctrl_ref}.add_form, name="{name_prefix}-add_form")')
486
+ print(f' g.add("POST", "{base}/add", {ctrl_ref}.add, name="{name_prefix}-add")')
487
+ print(f' g.add("GET", "{base}/{{target_id}}/edit", {ctrl_ref}.edit_form, name="{name_prefix}-edit_form")')
488
+ print(f' g.add("POST", "{base}/{{target_id}}/edit", {ctrl_ref}.edit, name="{name_prefix}-edit")')
489
+ print(f' g.add("POST", "{base}/{{target_id}}/remove", {ctrl_ref}.remove, name="{name_prefix}-remove")')
@@ -0,0 +1,363 @@
1
+ """forge_mvc_pivot/service.py — Service de persistance pour tables pivot avec attributs (opt-in extrait du core, ADR-021).
2
+
3
+ Permet de gérer les associations many_to_many enrichies (pivot.fields[]) sans
4
+ modifier make:crud. Utilise le même pattern d'injection que MariaDbSessionStore :
5
+ fetch_one, fetch_all et execute sont injectables pour les tests.
6
+
7
+ Décision : PIVOT-ADVANCED-001 / PIVOT-ADVANCED-003.
8
+ Contraintes : PIVOT-ADVANCED-006.
9
+ UX erreurs : PIVOT-ADVANCED-007.
10
+ UX future : PIVOT-ADVANCED-004 (commande/générateur dédié).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field as dc_field
17
+ from typing import Callable
18
+
19
+ _IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
20
+
21
+
22
+ def _safe_identifier(name: str, label: str) -> str:
23
+ """Valide qu'un nom de table ou de colonne ne contient pas de caractères dangereux."""
24
+ if not _IDENTIFIER_RE.match(name):
25
+ raise ValueError(
26
+ f"{label} invalide : {name!r}. "
27
+ "Seuls les caractères [A-Za-z0-9_] sont autorisés, le premier doit être une lettre ou _."
28
+ )
29
+ return name
30
+
31
+
32
+ # ── Résultat ──────────────────────────────────────────────────────────────────
33
+
34
+ @dataclass
35
+ class PivotRow:
36
+ """Représente une ligne de table pivot avec ses attributs."""
37
+ source_id: int | str
38
+ target_id: int | str
39
+ pivot_data: dict = dc_field(default_factory=dict)
40
+
41
+
42
+ # ── Erreur UX ─────────────────────────────────────────────────────────────────
43
+
44
+ @dataclass
45
+ class PivotFormError:
46
+ """Erreur pivot normalisée — affichable dans un formulaire de sous-CRUD pivot.
47
+
48
+ code : identifiant stable du type d'erreur
49
+ message : message humain à afficher dans le formulaire
50
+ field : champ concerné si applicable, None sinon
51
+ """
52
+ code: str
53
+ message: str
54
+ field: str | None = None
55
+
56
+
57
+ class PivotConstraintError(ValueError):
58
+ """Levée quand une contrainte pivot est violée (required, nullable, unique_pair).
59
+
60
+ Porte un code stable et éventuellement le champ concerné pour
61
+ permettre un affichage structuré dans les formulaires pivot.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ message: str,
67
+ *,
68
+ code: str = "invalid_pivot_data",
69
+ field: str | None = None,
70
+ ) -> None:
71
+ super().__init__(message)
72
+ self.code = code
73
+ self.field = field
74
+
75
+
76
+ # ── Contraintes ───────────────────────────────────────────────────────────────
77
+
78
+ @dataclass
79
+ class PivotFieldConstraint:
80
+ """Contrainte déclarative sur un champ pivot.
81
+
82
+ required=True → le champ doit être présent dans pivot_data lors d'un attach.
83
+ nullable=False → la valeur None est refusée lors d'un attach ou update.
84
+ """
85
+ name: str
86
+ required: bool = False
87
+ nullable: bool = True
88
+
89
+
90
+ # ── Helper UX ─────────────────────────────────────────────────────────────────
91
+
92
+ def pivot_error_to_form_error(error: Exception) -> PivotFormError:
93
+ """Convertit une exception de PivotAdvancedService en PivotFormError affichable.
94
+
95
+ - PivotConstraintError → PivotFormError avec le code et le champ structurés.
96
+ - ValueError (hors PivotConstraintError) → code unknown_pivot_field.
97
+ - Autre exception → code invalid_pivot_data, message générique (pas de détail SQL).
98
+ """
99
+ if isinstance(error, PivotConstraintError):
100
+ return PivotFormError(
101
+ code=error.code,
102
+ message=str(error),
103
+ field=error.field,
104
+ )
105
+ if isinstance(error, ValueError):
106
+ return PivotFormError(
107
+ code="unknown_pivot_field",
108
+ message=str(error),
109
+ )
110
+ return PivotFormError(
111
+ code="invalid_pivot_data",
112
+ message="Une erreur est survenue lors de l'enregistrement.",
113
+ )
114
+
115
+
116
+ # ── Accesseurs DB par défaut (lazy — pas de connexion à l'import) ─────────────
117
+
118
+ def _default_fetch_one(sql: str, params: tuple) -> dict | None:
119
+ from core.database.db import fetch_one
120
+ return fetch_one(sql, params)
121
+
122
+
123
+ def _default_fetch_all(sql: str, params: tuple) -> list[dict]:
124
+ from core.database.db import fetch_all
125
+ return fetch_all(sql, params) or []
126
+
127
+
128
+ def _default_execute(sql: str, params: tuple) -> int:
129
+ from core.database.db import execute
130
+ return execute(sql, params)
131
+
132
+
133
+ def _default_insert(sql: str, params: tuple) -> int:
134
+ from core.database.db import insert
135
+ return insert(sql, params)
136
+
137
+
138
+ # ── Service ───────────────────────────────────────────────────────────────────
139
+
140
+ class PivotAdvancedService:
141
+ """Service générique pour lire et modifier des associations pivot avec attributs.
142
+
143
+ Paramètres :
144
+ table — nom de la table pivot (ex: "article_tag")
145
+ source_key — colonne de clé source (ex: "article_id")
146
+ target_key — colonne de clé cible (ex: "tag_id")
147
+ pivot_fields — liste des noms de colonnes métier autorisés (ex: ["position", "note"])
148
+ pivot_constraints — liste de PivotFieldConstraint pour les contraintes avancées
149
+ unique_pair — si True, vérifie l'unicité avant INSERT (default False)
150
+ id_field — nom de la colonne id technique (ex: "id"), active get_by_id etc.
151
+
152
+ Les callables fetch_one, fetch_all, execute et insert_fn sont injectables pour les tests.
153
+ En production ils délèguent aux helpers core.database.db.
154
+
155
+ API pivot_fields (ancienne) et pivot_constraints (nouvelle) sont mutuellement exclusives.
156
+ Si pivot_constraints est fourni, il prend la priorité et définit la liste des champs autorisés.
157
+ """
158
+
159
+ def __init__(
160
+ self,
161
+ table: str,
162
+ source_key: str,
163
+ target_key: str,
164
+ pivot_fields: list[str] | None = None,
165
+ *,
166
+ pivot_constraints: list[PivotFieldConstraint] | None = None,
167
+ unique_pair: bool = False,
168
+ id_field: str | None = None,
169
+ fetch_one: Callable | None = None,
170
+ fetch_all: Callable | None = None,
171
+ execute: Callable | None = None,
172
+ insert_fn: Callable | None = None,
173
+ ) -> None:
174
+ self._table = _safe_identifier(table, "table")
175
+ self._source_key = _safe_identifier(source_key, "source_key")
176
+ self._target_key = _safe_identifier(target_key, "target_key")
177
+ self._unique_pair = unique_pair
178
+ self._id_field = _safe_identifier(id_field, "id_field") if id_field else None
179
+
180
+ if pivot_constraints is not None:
181
+ for c in pivot_constraints:
182
+ _safe_identifier(c.name, f"pivot_constraints[{c.name}].name")
183
+ self._pivot_fields: tuple[str, ...] = tuple(c.name for c in pivot_constraints)
184
+ self._constraints: dict[str, PivotFieldConstraint] = {c.name: c for c in pivot_constraints}
185
+ else:
186
+ self._pivot_fields = tuple(
187
+ _safe_identifier(f, f"pivot_fields[{i}]")
188
+ for i, f in enumerate(pivot_fields or [])
189
+ )
190
+ self._constraints = {}
191
+
192
+ self._fetch_one = fetch_one or _default_fetch_one
193
+ self._fetch_all = fetch_all or _default_fetch_all
194
+ self._execute = execute or _default_execute
195
+ self._insert_fn = insert_fn or _default_insert
196
+
197
+ # ── helpers internes ──────────────────────────────────────────────────────
198
+
199
+ def _check_pivot_data(self, pivot_data: dict) -> None:
200
+ """Lève ValueError si pivot_data contient des clés inconnues."""
201
+ unknown = set(pivot_data) - set(self._pivot_fields)
202
+ if unknown:
203
+ raise ValueError(
204
+ f"Champs pivot inconnus : {sorted(unknown)}. "
205
+ f"Champs autorisés : {sorted(self._pivot_fields)}."
206
+ )
207
+
208
+ def _enforce_attach_constraints(self, pivot_data: dict) -> None:
209
+ """Vérifie required et nullable lors d'un attach."""
210
+ for field_name, constraint in self._constraints.items():
211
+ if constraint.required and field_name not in pivot_data:
212
+ raise PivotConstraintError(
213
+ f"Champ pivot requis absent : {field_name!r}.",
214
+ code="required_field_missing",
215
+ field=field_name,
216
+ )
217
+ if not constraint.nullable and field_name in pivot_data and pivot_data[field_name] is None:
218
+ raise PivotConstraintError(
219
+ f"Champ pivot non nullable reçoit None : {field_name!r}.",
220
+ code="nullable_field_rejected",
221
+ field=field_name,
222
+ )
223
+
224
+ def _enforce_update_constraints(self, pivot_data: dict) -> None:
225
+ """Vérifie nullable lors d'un update (required non vérifié — mise à jour partielle)."""
226
+ for field_name, constraint in self._constraints.items():
227
+ if not constraint.nullable and field_name in pivot_data and pivot_data[field_name] is None:
228
+ raise PivotConstraintError(
229
+ f"Champ pivot non nullable reçoit None : {field_name!r}.",
230
+ code="nullable_field_rejected",
231
+ field=field_name,
232
+ )
233
+
234
+ def _row_to_pivot_row(self, row: dict) -> PivotRow:
235
+ pivot_data = {k: v for k, v in row.items()
236
+ if k not in (self._source_key, self._target_key)}
237
+ return PivotRow(
238
+ source_id=row[self._source_key],
239
+ target_id=row[self._target_key],
240
+ pivot_data=pivot_data,
241
+ )
242
+
243
+ # ── API publique ──────────────────────────────────────────────────────────
244
+
245
+ def list_for_source(self, source_id: int | str) -> list[PivotRow]:
246
+ """Retourne toutes les associations liées à source_id."""
247
+ sql = f"SELECT * FROM {self._table} WHERE {self._source_key} = ?"
248
+ rows = self._fetch_all(sql, (source_id,))
249
+ return [self._row_to_pivot_row(r) for r in rows]
250
+
251
+ def get(self, source_id: int | str, target_id: int | str) -> PivotRow | None:
252
+ """Retourne l'association (source_id, target_id), ou None si absente."""
253
+ sql = (
254
+ f"SELECT * FROM {self._table} "
255
+ f"WHERE {self._source_key} = ? AND {self._target_key} = ?"
256
+ )
257
+ row = self._fetch_one(sql, (source_id, target_id))
258
+ return self._row_to_pivot_row(row) if row else None
259
+
260
+ def attach(
261
+ self,
262
+ source_id: int | str,
263
+ target_id: int | str,
264
+ pivot_data: dict | None = None,
265
+ ) -> int:
266
+ """Crée une association. Retourne le lastrowid.
267
+
268
+ Seuls les champs déclarés dans pivot_fields / pivot_constraints sont acceptés.
269
+ Si unique_pair=True, lève PivotConstraintError si l'association existe déjà.
270
+ """
271
+ pivot_data = pivot_data or {}
272
+ self._check_pivot_data(pivot_data)
273
+ self._enforce_attach_constraints(pivot_data)
274
+
275
+ if self._unique_pair and self.get(source_id, target_id) is not None:
276
+ raise PivotConstraintError(
277
+ f"Association ({source_id}, {target_id}) déjà existante dans {self._table}.",
278
+ code="duplicate_pair",
279
+ )
280
+
281
+ columns = [self._source_key, self._target_key] + [
282
+ f for f in self._pivot_fields if f in pivot_data
283
+ ]
284
+ placeholders = ", ".join("?" for _ in columns)
285
+ col_list = ", ".join(columns)
286
+ values = (source_id, target_id) + tuple(
287
+ pivot_data[f] for f in self._pivot_fields if f in pivot_data
288
+ )
289
+
290
+ sql = f"INSERT INTO {self._table} ({col_list}) VALUES ({placeholders})"
291
+ return self._insert_fn(sql, values)
292
+
293
+ def update(
294
+ self,
295
+ source_id: int | str,
296
+ target_id: int | str,
297
+ pivot_data: dict,
298
+ ) -> int:
299
+ """Modifie les attributs pivot d'une association existante. Retourne rowcount.
300
+
301
+ Ne modifie pas source_key ni target_key.
302
+ Seuls les champs déclarés dans pivot_fields / pivot_constraints sont acceptés.
303
+ required n'est pas vérifié (mise à jour partielle autorisée).
304
+ """
305
+ self._check_pivot_data(pivot_data)
306
+ self._enforce_update_constraints(pivot_data)
307
+ if not pivot_data:
308
+ return 0
309
+
310
+ set_clause = ", ".join(f"{f} = ?" for f in pivot_data)
311
+ values = tuple(pivot_data[f] for f in pivot_data) + (source_id, target_id)
312
+ sql = (
313
+ f"UPDATE {self._table} SET {set_clause} "
314
+ f"WHERE {self._source_key} = ? AND {self._target_key} = ?"
315
+ )
316
+ return self._execute(sql, values)
317
+
318
+ def detach(self, source_id: int | str, target_id: int | str) -> int:
319
+ """Supprime l'association (source_id, target_id). Retourne rowcount."""
320
+ sql = (
321
+ f"DELETE FROM {self._table} "
322
+ f"WHERE {self._source_key} = ? AND {self._target_key} = ?"
323
+ )
324
+ return self._execute(sql, (source_id, target_id))
325
+
326
+ # ── Méthodes par id technique (requiert id_field) ─────────────────────────
327
+
328
+ def get_by_id(self, pivot_id: int | str) -> PivotRow | None:
329
+ """Retourne la ligne pivot par son id technique. Requiert id_field."""
330
+ if not self._id_field:
331
+ raise PivotConstraintError(
332
+ "get_by_id requiert id_field sur PivotAdvancedService.",
333
+ code="missing_id_field",
334
+ )
335
+ sql = f"SELECT * FROM {self._table} WHERE {self._id_field} = ?"
336
+ row = self._fetch_one(sql, (pivot_id,))
337
+ return self._row_to_pivot_row(row) if row else None
338
+
339
+ def update_by_id(self, pivot_id: int | str, pivot_data: dict) -> int:
340
+ """Modifie les attributs d'une ligne pivot par son id technique. Requiert id_field."""
341
+ if not self._id_field:
342
+ raise PivotConstraintError(
343
+ "update_by_id requiert id_field sur PivotAdvancedService.",
344
+ code="missing_id_field",
345
+ )
346
+ self._check_pivot_data(pivot_data)
347
+ self._enforce_update_constraints(pivot_data)
348
+ if not pivot_data:
349
+ return 0
350
+ set_clause = ", ".join(f"{f} = ?" for f in pivot_data)
351
+ values = tuple(pivot_data[f] for f in pivot_data) + (pivot_id,)
352
+ sql = f"UPDATE {self._table} SET {set_clause} WHERE {self._id_field} = ?"
353
+ return self._execute(sql, values)
354
+
355
+ def delete_by_id(self, pivot_id: int | str) -> int:
356
+ """Supprime une ligne pivot par son id technique. Requiert id_field."""
357
+ if not self._id_field:
358
+ raise PivotConstraintError(
359
+ "delete_by_id requiert id_field sur PivotAdvancedService.",
360
+ code="missing_id_field",
361
+ )
362
+ sql = f"DELETE FROM {self._table} WHERE {self._id_field} = ?"
363
+ return self._execute(sql, (pivot_id,))
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.4
2
+ Name: forge-mvc-pivot
3
+ Version: 1.0.0b16
4
+ Summary: Forge pivot — tables pivot enrichies (many_to_many avec attributs) et generateur make:pivot-crud.
5
+ Author: Roger Lequette
6
+ License-Expression: LicenseRef-Forge-Proprietary
7
+ Project-URL: Homepage, https://github.com/caucrogeGit/Forge
8
+ Project-URL: Repository, https://github.com/caucrogeGit/Forge
9
+ Project-URL: Documentation, https://forgemvc.com/docs/forge/
10
+ Keywords: python,mvc,forge,pivot,many-to-many
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: forge-mvc<2,>=1.0.0b16
20
+ Dynamic: license-file
21
+
22
+ # forge-mvc-pivot
23
+
24
+ Opt-in Forge pour les **tables pivot enrichies** : associations `many_to_many`
25
+ portant des attributs (par exemple `position`, `note`) sur la ligne de jointure.
26
+
27
+ Extrait du core de Forge (ADR-021) : le core ne contient que les primitives
28
+ générales ; le pivot avancé est une brique spécialisée, optionnelle.
29
+
30
+ ## Contenu
31
+
32
+ - `PivotAdvancedService` : lecture et écriture d'associations pivot avec
33
+ attributs, contraintes déclaratives (`required`, `nullable`, `unique_pair`),
34
+ accès base injectables (`fetch_one`, `fetch_all`, `execute`, `insert_fn`).
35
+ - `PivotFieldConstraint`, `PivotRow`, `PivotFormError`, `PivotConstraintError`,
36
+ `pivot_error_to_form_error` : contraintes, résultats et erreurs structurées.
37
+ - Générateur `forge make:pivot-crud <EntitéSource> <nomRelation>` : échafaude un
38
+ sous-CRUD pivot à partir d'une relation `many_to_many` déclarée dans
39
+ `relations.json`.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install --pre forge-mvc-pivot
45
+ ```
46
+
47
+ Le code généré importe `forge_mvc_pivot` : installez le paquet avant de lancer
48
+ une application qui s'appuie sur un sous-CRUD pivot.
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ forge_mvc_pivot/__init__.py
5
+ forge_mvc_pivot/make_pivot_crud.py
6
+ forge_mvc_pivot/service.py
7
+ forge_mvc_pivot.egg-info/PKG-INFO
8
+ forge_mvc_pivot.egg-info/SOURCES.txt
9
+ forge_mvc_pivot.egg-info/dependency_links.txt
10
+ forge_mvc_pivot.egg-info/requires.txt
11
+ forge_mvc_pivot.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ forge-mvc<2,>=1.0.0b16
@@ -0,0 +1 @@
1
+ forge_mvc_pivot
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "forge-mvc-pivot"
7
+ version = "1.0.0b16"
8
+ description = "Forge pivot — tables pivot enrichies (many_to_many avec attributs) et generateur make:pivot-crud."
9
+ readme = { file = "README.md", content-type = "text/markdown" }
10
+ requires-python = ">=3.12"
11
+ authors = [
12
+ { name = "Roger Lequette" },
13
+ ]
14
+ license = "LicenseRef-Forge-Proprietary"
15
+ keywords = ["python", "mvc", "forge", "pivot", "many-to-many"]
16
+ dependencies = [
17
+ "forge-mvc>=1.0.0b16,<2",
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: 3.14",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/caucrogeGit/Forge"
29
+ Repository = "https://github.com/caucrogeGit/Forge"
30
+ Documentation = "https://forgemvc.com/docs/forge/"
31
+
32
+ [tool.setuptools.packages.find]
33
+ where = ["."]
34
+ include = ["forge_mvc_pivot*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+