geneweb-py 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. geneweb_py/__init__.py +23 -0
  2. geneweb_py/analytics/__init__.py +5 -0
  3. geneweb_py/analytics/network.py +261 -0
  4. geneweb_py/api/__init__.py +11 -0
  5. geneweb_py/api/dependencies.py +75 -0
  6. geneweb_py/api/family_payload.py +64 -0
  7. geneweb_py/api/limits.py +87 -0
  8. geneweb_py/api/main.py +222 -0
  9. geneweb_py/api/middleware/__init__.py +3 -0
  10. geneweb_py/api/middleware/error_handler.py +153 -0
  11. geneweb_py/api/middleware/logging.py +100 -0
  12. geneweb_py/api/models/__init__.py +3 -0
  13. geneweb_py/api/models/event.py +192 -0
  14. geneweb_py/api/models/family.py +178 -0
  15. geneweb_py/api/models/organization.py +154 -0
  16. geneweb_py/api/models/person.py +311 -0
  17. geneweb_py/api/models/responses.py +122 -0
  18. geneweb_py/api/person_payload.py +67 -0
  19. geneweb_py/api/rate_limit.py +22 -0
  20. geneweb_py/api/router_helpers.py +32 -0
  21. geneweb_py/api/routers/__init__.py +8 -0
  22. geneweb_py/api/routers/events.py +503 -0
  23. geneweb_py/api/routers/families.py +374 -0
  24. geneweb_py/api/routers/genealogy.py +515 -0
  25. geneweb_py/api/routers/organizations.py +150 -0
  26. geneweb_py/api/routers/persons.py +431 -0
  27. geneweb_py/api/routers/sessions.py +133 -0
  28. geneweb_py/api/serialization.py +344 -0
  29. geneweb_py/api/services/__init__.py +3 -0
  30. geneweb_py/api/services/export_bundle.py +30 -0
  31. geneweb_py/api/services/genealogy_service.py +1552 -0
  32. geneweb_py/api/services/organization_candidates.py +445 -0
  33. geneweb_py/api/services/organization_exports.py +223 -0
  34. geneweb_py/api/services/organization_tables.py +253 -0
  35. geneweb_py/api/session_store.py +132 -0
  36. geneweb_py/api/static/app.js +2393 -0
  37. geneweb_py/api/static/index.html +273 -0
  38. geneweb_py/api/static/style.css +860 -0
  39. geneweb_py/api/static/toldot-logo.png +0 -0
  40. geneweb_py/api/static/vendor/bootstrap.bundle.min.js +7 -0
  41. geneweb_py/api/static/vendor/bootstrap.min.css +6 -0
  42. geneweb_py/api/static/vendor/tabulator.min.css +2 -0
  43. geneweb_py/api/static/vendor/tabulator.min.js +3 -0
  44. geneweb_py/cli/__init__.py +9 -0
  45. geneweb_py/cli/commands.py +196 -0
  46. geneweb_py/cli/entry.py +15 -0
  47. geneweb_py/core/__init__.py +9 -0
  48. geneweb_py/core/date.py +620 -0
  49. geneweb_py/core/event.py +265 -0
  50. geneweb_py/core/event_to_organization.py +1099 -0
  51. geneweb_py/core/exceptions.py +648 -0
  52. geneweb_py/core/family.py +399 -0
  53. geneweb_py/core/genealogy.py +697 -0
  54. geneweb_py/core/models.py +42 -0
  55. geneweb_py/core/note_leads.py +191 -0
  56. geneweb_py/core/organization.py +209 -0
  57. geneweb_py/core/parser/__init__.py +39 -0
  58. geneweb_py/core/parser/gw_parser.py +3780 -0
  59. geneweb_py/core/parser/lexical.py +825 -0
  60. geneweb_py/core/parser/multipass_parser.py +65 -0
  61. geneweb_py/core/parser/place_tokens.py +173 -0
  62. geneweb_py/core/parser/reconcile.py +359 -0
  63. geneweb_py/core/parser/streaming.py +326 -0
  64. geneweb_py/core/parser/syntax.py +1668 -0
  65. geneweb_py/core/parser/wikitext.py +144 -0
  66. geneweb_py/core/parser/words.py +167 -0
  67. geneweb_py/core/person.py +365 -0
  68. geneweb_py/core/place.py +69 -0
  69. geneweb_py/core/shared_experiences.py +548 -0
  70. geneweb_py/core/validation.py +449 -0
  71. geneweb_py/formats/__init__.py +39 -0
  72. geneweb_py/formats/base.py +156 -0
  73. geneweb_py/formats/gedcom.py +1234 -0
  74. geneweb_py/formats/json.py +429 -0
  75. geneweb_py/formats/leads_table.py +158 -0
  76. geneweb_py/formats/org_sidecar.py +103 -0
  77. geneweb_py/formats/table_writers.py +229 -0
  78. geneweb_py/formats/tables.py +389 -0
  79. geneweb_py/formats/xml.py +808 -0
  80. geneweb_py/py.typed +0 -0
  81. geneweb_py-0.1.0.dist-info/METADATA +541 -0
  82. geneweb_py-0.1.0.dist-info/RECORD +86 -0
  83. geneweb_py-0.1.0.dist-info/WHEEL +5 -0
  84. geneweb_py-0.1.0.dist-info/entry_points.txt +2 -0
  85. geneweb_py-0.1.0.dist-info/licenses/LICENSE +21 -0
  86. geneweb_py-0.1.0.dist-info/top_level.txt +1 -0
geneweb_py/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """
2
+ geneweb-py : Librairie Python pour le format GeneWeb (.gw)
3
+
4
+ Cette librairie permet de parser, manipuler et convertir les fichiers
5
+ généalogiques au format GeneWeb (.gw).
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __author__ = "Guillaume Cayeux"
10
+ __email__ = "guillaume.cayeux@relais4x100a2.fr"
11
+
12
+ from .core.models import Date, Event, Family, Genealogy, Person
13
+ from .core.parser import GeneWebParser, MultiPassParser
14
+
15
+ __all__ = [
16
+ "Person",
17
+ "Family",
18
+ "Event",
19
+ "Date",
20
+ "Genealogy",
21
+ "GeneWebParser",
22
+ "MultiPassParser",
23
+ ]
@@ -0,0 +1,5 @@
1
+ """Analyses prosopographiques (réseaux). Dépendances graphiques : extra analytics."""
2
+
3
+ from .network import Network, NetworkEdge, NetworkNode, build_network
4
+
5
+ __all__ = ["Network", "NetworkEdge", "NetworkNode", "build_network"]
@@ -0,0 +1,261 @@
1
+ """Réseau prosopographique : personnes, organisations et événements.
2
+
3
+ Remplace le script historique de ``Geneweb`` (V2, ``data_export/network.py``) qui
4
+ reconstruisait le graphe depuis des CSV. Ici le graphe est construit directement depuis
5
+ ``Genealogy`` et ses ``Organization`` / ``OrganizationLink``.
6
+
7
+ La construction (``build_network``) et l'export JSON (vis.js) n'ont aucune dépendance.
8
+ ``to_networkx``, GraphML, GEXF et Pyvis exigent l'extra ``analytics``.
9
+ """
10
+
11
+ import importlib
12
+ import json
13
+ from dataclasses import dataclass, field
14
+ from itertools import combinations
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from ..core.genealogy import Genealogy
20
+
21
+ NODE_PERSON = "person"
22
+ NODE_ORG = "organization"
23
+ NODE_EVENT = "event"
24
+
25
+ EDGE_MEMBER = "member"
26
+ EDGE_PARTICIPANT = "participant"
27
+ EDGE_ORGANIZATION = "organization"
28
+ EDGE_WITNESS = "witness"
29
+ EDGE_SPOUSE = "spouse"
30
+ EDGE_PARENT = "parent"
31
+ EDGE_CO_MEMBER = "co_member"
32
+
33
+ PathLike = str | Path
34
+
35
+
36
+ def _require(module: str) -> Any:
37
+ try:
38
+ return importlib.import_module(module)
39
+ except ImportError as exc:
40
+ raise ImportError(
41
+ f"'{module}' est requis pour cet export : "
42
+ "installer l'extra avec `uv sync --extra analytics`."
43
+ ) from exc
44
+
45
+
46
+ @dataclass
47
+ class NetworkNode:
48
+ id: str
49
+ label: str
50
+ kind: str
51
+ attrs: dict[str, Any] = field(default_factory=dict)
52
+
53
+
54
+ @dataclass
55
+ class NetworkEdge:
56
+ source: str
57
+ target: str
58
+ kind: str
59
+ weight: int = 1
60
+
61
+
62
+ @dataclass
63
+ class Network:
64
+ """Graphe non orienté, indépendant de toute bibliothèque."""
65
+
66
+ nodes: list[NetworkNode] = field(default_factory=list)
67
+ edges: list[NetworkEdge] = field(default_factory=list)
68
+
69
+ def node(self, node_id: str) -> NetworkNode:
70
+ return next(n for n in self.nodes if n.id == node_id)
71
+
72
+ def edge(self, a: str, b: str) -> NetworkEdge:
73
+ """Arête entre ``a`` et ``b`` (dans un sens ou dans l'autre)."""
74
+ return next(e for e in self.edges if {e.source, e.target} == {a, b})
75
+
76
+ def co_membership(self) -> "Network":
77
+ """Projection sur les personnes : lien pondéré par le nombre d'organisations
78
+ partagées."""
79
+ members: dict[str, set[str]] = {}
80
+ for e in self.edges:
81
+ if e.kind == EDGE_MEMBER:
82
+ members.setdefault(e.target, set()).add(e.source)
83
+ shared: dict[tuple[str, str], int] = {}
84
+ for people in members.values():
85
+ for pair in combinations(sorted(people), 2):
86
+ shared[pair] = shared.get(pair, 0) + 1
87
+ used = {p for pair in shared for p in pair}
88
+ return Network(
89
+ nodes=[n for n in self.nodes if n.id in used],
90
+ edges=[
91
+ NetworkEdge(a, b, EDGE_CO_MEMBER, weight)
92
+ for (a, b), weight in shared.items()
93
+ ],
94
+ )
95
+
96
+ # -- Exports ----------------------------------------------------------
97
+ def export_json(self, path: PathLike) -> Path:
98
+ """JSON ``{"nodes": [...], "edges": [...]}`` directement lisible par vis.js."""
99
+ payload = {
100
+ "nodes": [
101
+ {"id": n.id, "label": n.label, "group": n.kind, **n.attrs}
102
+ for n in self.nodes
103
+ ],
104
+ "edges": [
105
+ {"from": e.source, "to": e.target, "value": e.weight, "title": e.kind}
106
+ for e in self.edges
107
+ ],
108
+ }
109
+ target = Path(path)
110
+ target.write_text(
111
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
112
+ )
113
+ return target
114
+
115
+ def to_networkx(self) -> Any:
116
+ """``networkx.Graph`` (attributs ``kind``, ``label``, ``weight``)."""
117
+ nx = _require("networkx")
118
+ graph = nx.Graph()
119
+ for n in self.nodes:
120
+ graph.add_node(n.id, label=n.label, kind=n.kind, **n.attrs)
121
+ for e in self.edges:
122
+ graph.add_edge(e.source, e.target, kind=e.kind, weight=e.weight)
123
+ return graph
124
+
125
+ def export_graphml(self, path: PathLike) -> Path:
126
+ nx = _require("networkx")
127
+ nx.write_graphml(self.to_networkx(), str(path), encoding="utf-8")
128
+ return Path(path)
129
+
130
+ def export_gexf(self, path: PathLike) -> Path:
131
+ nx = _require("networkx")
132
+ nx.write_gexf(self.to_networkx(), str(path), encoding="utf-8")
133
+ return Path(path)
134
+
135
+ def export_pyvis_html(self, path: PathLike, height: str = "750px") -> Path:
136
+ """Page HTML autonome (bibliothèque embarquée, aucun accès réseau)."""
137
+ pyvis_network = _require("pyvis.network")
138
+ vis = pyvis_network.Network(
139
+ height=height, width="100%", cdn_resources="in_line", notebook=False
140
+ )
141
+ for n in self.nodes:
142
+ vis.add_node(n.id, label=n.label, group=n.kind)
143
+ for e in self.edges:
144
+ vis.add_edge(e.source, e.target, value=e.weight, title=e.kind)
145
+ target = Path(path)
146
+ vis.write_html(str(target), notebook=False)
147
+ return target
148
+
149
+
150
+ def _person_label(person: Any) -> str:
151
+ return f"{person.last_name} {person.first_name}".strip()
152
+
153
+
154
+ def build_network(
155
+ genealogy: "Genealogy",
156
+ include_events: bool = False,
157
+ include_families: bool = False,
158
+ ) -> Network:
159
+ """Construit le réseau prosopographique d'une généalogie.
160
+
161
+ Nœuds ``person:<id>`` et ``org:<id>`` ; arête ``member`` personne -> organisation
162
+ pondérée par le nombre de ``OrganizationLink``.
163
+
164
+ Args:
165
+ include_events: ajoute un nœud par événement à l'origine d'un lien,
166
+ relié à son titulaire (``participant``), à l'organisation (``organization``)
167
+ et à ses témoins (``witness``).
168
+ include_families: ajoute les liens de parenté (``spouse``, ``parent``).
169
+ """
170
+ network = Network()
171
+ node_ids: set[str] = set()
172
+ weights: dict[tuple[str, str, str], int] = {}
173
+
174
+ def add_node(node: NetworkNode) -> None:
175
+ if node.id not in node_ids:
176
+ node_ids.add(node.id)
177
+ network.nodes.append(node)
178
+
179
+ def add_edge(source: str, target: str, kind: str) -> None:
180
+ weights[(source, target, kind)] = weights.get((source, target, kind), 0) + 1
181
+
182
+ for person_id, person in genealogy.persons.items():
183
+ add_node(NetworkNode(f"person:{person_id}", _person_label(person), NODE_PERSON))
184
+
185
+ for org in genealogy.organizations.values():
186
+ attrs = {
187
+ "type": org.type.value,
188
+ "place": org.place,
189
+ "confidence": org.confidence,
190
+ "needs_review": org.needs_review,
191
+ }
192
+ add_node(
193
+ NetworkNode(
194
+ f"org:{org.id}",
195
+ org.name,
196
+ NODE_ORG,
197
+ {k: v for k, v in attrs.items() if v is not None},
198
+ )
199
+ )
200
+
201
+ for link in genealogy.organization_links:
202
+ add_edge(f"person:{link.person_id}", f"org:{link.organization_id}", EDGE_MEMBER)
203
+
204
+ if include_events:
205
+ _add_events(genealogy, add_node, add_edge)
206
+
207
+ if include_families:
208
+ for family in genealogy.families.values():
209
+ if family.husband_id and family.wife_id:
210
+ add_edge(
211
+ f"person:{family.husband_id}",
212
+ f"person:{family.wife_id}",
213
+ EDGE_SPOUSE,
214
+ )
215
+ for child in family.children:
216
+ for parent_id in (family.husband_id, family.wife_id):
217
+ if parent_id:
218
+ add_edge(
219
+ f"person:{parent_id}",
220
+ f"person:{child.person_id}",
221
+ EDGE_PARENT,
222
+ )
223
+
224
+ network.edges = [
225
+ NetworkEdge(source, target, kind, weight)
226
+ for (source, target, kind), weight in weights.items()
227
+ if source in node_ids and target in node_ids
228
+ ]
229
+ return network
230
+
231
+
232
+ def _add_events(genealogy: "Genealogy", add_node: Any, add_edge: Any) -> None:
233
+ for person_id, person in genealogy.persons.items():
234
+ links = genealogy.get_organization_links(person_id=person_id)
235
+ for index, event in enumerate(person.events):
236
+ date = event.date.display_text if event.date else None
237
+ matching = [
238
+ link
239
+ for link in links
240
+ if link.source_event_type == event.event_type_raw
241
+ and (link.start_date.display_text if link.start_date else None) == date
242
+ ]
243
+ if not matching:
244
+ continue
245
+ event_id = f"event:{person_id}:{index}"
246
+ label = " ".join(p for p in (event.event_type_raw, date) if p)
247
+ add_node(
248
+ NetworkNode(
249
+ event_id,
250
+ label,
251
+ NODE_EVENT,
252
+ {"place": event.place} if event.place else {},
253
+ )
254
+ )
255
+ add_edge(f"person:{person_id}", event_id, EDGE_PARTICIPANT)
256
+ for link in matching:
257
+ add_edge(event_id, f"org:{link.organization_id}", EDGE_ORGANIZATION)
258
+ for witness in event.witnesses:
259
+ witness_id = witness.get("person_id")
260
+ if witness_id:
261
+ add_edge(f"person:{witness_id}", event_id, EDGE_WITNESS)
@@ -0,0 +1,11 @@
1
+ """
2
+ Module API FastAPI pour geneweb-py.
3
+
4
+ Ce module fournit une API REST moderne pour manipuler les données généalogiques
5
+ au format GeneWeb (.gw) avec FastAPI.
6
+ """
7
+
8
+ from .main import app, create_app
9
+ from .services.genealogy_service import GenealogyService
10
+
11
+ __all__ = ["app", "create_app", "GenealogyService"]
@@ -0,0 +1,75 @@
1
+ """Dépendances FastAPI pour l'API geneweb-py."""
2
+
3
+ from fastapi import Depends, Header, HTTPException, Request, Response, status
4
+
5
+ import geneweb_py.api.limits as _limits_mod
6
+
7
+ from .services.genealogy_service import GenealogyService
8
+ from .session_store import SessionStore
9
+
10
+
11
+ def get_store(request: Request) -> SessionStore:
12
+ """Retourne le SessionStore stocké dans app.state."""
13
+ return request.app.state.session_store
14
+
15
+
16
+ # Temps restant (secondes) renvoyé à chaque requête de session : le client recale son
17
+ # compte à rebours sans dépendre de son horloge.
18
+ SESSION_EXPIRES_IN_HEADER = "X-Session-Expires-In"
19
+
20
+
21
+ def get_session_service(
22
+ response: Response,
23
+ x_session_token: str = Header(..., alias="X-Session-Token"),
24
+ store: SessionStore = Depends(get_store),
25
+ ) -> GenealogyService:
26
+ """Résout le token de session (et prolonge son TTL) ; retourne son GenealogyService.
27
+
28
+ Le service est conservé avec la session : ses caches (index des candidates,
29
+ témoins) et ses réglages (mots ignorés) survivent d'une requête à l'autre.
30
+ """
31
+ touched = store.service(x_session_token)
32
+ if touched is None:
33
+ raise HTTPException(
34
+ status_code=status.HTTP_401_UNAUTHORIZED,
35
+ detail="Session inconnue ou expirée",
36
+ )
37
+ service, expires_at = touched
38
+ response.headers[SESSION_EXPIRES_IN_HEADER] = str(store.seconds_left(expires_at))
39
+ return service
40
+
41
+
42
+ def require_write_mode() -> None:
43
+ """Lève 405 si l'API est en mode lecture seule (READ_ONLY=true)."""
44
+ if _limits_mod.READ_ONLY:
45
+ raise HTTPException(
46
+ status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
47
+ detail="API en mode lecture seule",
48
+ )
49
+
50
+
51
+ def get_pagination_params(
52
+ page: int = 1, size: int = 20, max_size: int = 100
53
+ ) -> tuple[int, int]:
54
+ """Valide et retourne les paramètres de pagination."""
55
+ if page < 1:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_400_BAD_REQUEST,
58
+ detail="Le numéro de page doit être supérieur à 0",
59
+ )
60
+ if size < 1 or size > max_size:
61
+ raise HTTPException(
62
+ status_code=status.HTTP_400_BAD_REQUEST,
63
+ detail=f"La taille de la page doit être entre 1 et {max_size}",
64
+ )
65
+ return page, size
66
+
67
+
68
+ def get_search_limit(limit: int = 50, max_limit: int = 100) -> int:
69
+ """Valide et retourne la limite de recherche."""
70
+ if limit < 1 or limit > max_limit:
71
+ raise HTTPException(
72
+ status_code=status.HTTP_400_BAD_REQUEST,
73
+ detail=f"La limite doit être entre 1 et {max_limit}",
74
+ )
75
+ return limit
@@ -0,0 +1,64 @@
1
+ """
2
+ Construction des charges utiles Family pour les réponses API.
3
+ """
4
+
5
+ from ..core.family import Family
6
+ from .models.family import FamilyListSchema, FamilySchema
7
+ from .serialization import child_to_schema, date_to_api_string, stable_event_id
8
+
9
+
10
+ def _divorce_place_from_family(family: Family) -> str | None:
11
+ """Lieu de divorce si stocké en métadonnées du parseur."""
12
+ if not family.metadata:
13
+ return None
14
+ raw = family.metadata.get("divorce_place")
15
+ if raw is None:
16
+ return None
17
+ text = str(raw).strip()
18
+ return text or None
19
+
20
+
21
+ def family_to_family_schema(family: Family) -> FamilySchema:
22
+ """Convertit une famille domaine en schéma API complet."""
23
+ src_list: list[str] = []
24
+ if family.family_source and str(family.family_source).strip():
25
+ src_list.append(str(family.family_source).strip())
26
+
27
+ event_ids: list[str] = []
28
+ for idx, ev in enumerate(family.events):
29
+ uid = getattr(ev, "unique_id", None)
30
+ if uid is not None:
31
+ event_ids.append(str(uid))
32
+ else:
33
+ event_ids.append(
34
+ stable_event_id(
35
+ ev, scope="family", scope_key=family.family_id, index=idx
36
+ )
37
+ )
38
+
39
+ return FamilySchema(
40
+ id=family.family_id,
41
+ husband_id=family.husband_id,
42
+ wife_id=family.wife_id,
43
+ children=[child_to_schema(c) for c in family.children],
44
+ marriage_status=family.marriage_status,
45
+ notes=family.comments,
46
+ sources=src_list,
47
+ marriage_date=date_to_api_string(family.marriage_date),
48
+ marriage_place=family.marriage_place,
49
+ divorce_date=date_to_api_string(family.divorce_date),
50
+ divorce_place=_divorce_place_from_family(family),
51
+ events=event_ids,
52
+ )
53
+
54
+
55
+ def family_to_list_schema(family: Family) -> FamilyListSchema:
56
+ """Schéma liste famille avec date de mariage."""
57
+ return FamilyListSchema(
58
+ id=family.family_id,
59
+ husband_id=family.husband_id,
60
+ wife_id=family.wife_id,
61
+ children_count=len(family.children),
62
+ marriage_date=date_to_api_string(family.marriage_date),
63
+ marriage_status=family.marriage_status,
64
+ )
@@ -0,0 +1,87 @@
1
+ """
2
+ Limites et seuils de sécurité pour l'API HTTP.
3
+
4
+ Les valeurs peuvent être surchargées via les variables d'environnement
5
+ pour l'adaptation au déploiement.
6
+ """
7
+
8
+ import os
9
+
10
+ # Origines autorisées en développement / test local (frontends sur le port 3000).
11
+ DEFAULT_DEV_CORS_ORIGINS: list[str] = [
12
+ "http://127.0.0.1:3000",
13
+ "http://localhost:3000",
14
+ ]
15
+
16
+
17
+ def _parse_int_env(name: str, default: int) -> int:
18
+ """Lit une variable d'environnement entière avec repli sur défaut."""
19
+ raw = os.getenv(name)
20
+ if raw is None or raw.strip() == "":
21
+ return default
22
+ try:
23
+ return int(raw)
24
+ except ValueError:
25
+ return default
26
+
27
+
28
+ def _split_csv_origins(raw: str) -> list[str]:
29
+ """Découpe une chaîne d'origines séparées par des virgules."""
30
+ return [item.strip() for item in raw.split(",") if item.strip()]
31
+
32
+
33
+ def _get_api_env() -> str:
34
+ """Retourne le mode d'exécution API (dev, test, prod)."""
35
+ raw = os.getenv("GENEWEB_API_ENV", "dev")
36
+ return raw.strip().lower() or "dev"
37
+
38
+
39
+ def get_cors_allow_origins() -> list[str]:
40
+ """
41
+ Retourne la liste des origines CORS autorisées selon l'environnement.
42
+
43
+ Priorité des variables d'environnement :
44
+ 1. ``ALLOWED_ORIGINS`` (liste séparée par des virgules) ;
45
+ 2. ``CORS_ORIGINS`` (rétrocompatibilité) ;
46
+ 3. Si aucune n'est définie et que ``GENEWEB_API_ENV=prod`` : liste vide
47
+ (aucune origine cross-origin tant que l'opérateur ne configure pas
48
+ ``ALLOWED_ORIGINS``) ;
49
+ 4. Sinon : ``DEFAULT_DEV_CORS_ORIGINS``.
50
+
51
+ En production, une origine ``*`` est ignorée (incompatible avec
52
+ ``allow_credentials=True`` et interdit par les critères de sécurité).
53
+
54
+ Returns:
55
+ Liste d'URL d'origine sans slash final parasite.
56
+ """
57
+ api_env = _get_api_env()
58
+ origins: list[str]
59
+
60
+ if "ALLOWED_ORIGINS" in os.environ:
61
+ raw = os.environ["ALLOWED_ORIGINS"]
62
+ if raw.strip() == "":
63
+ origins = []
64
+ else:
65
+ origins = _split_csv_origins(raw)
66
+ elif "CORS_ORIGINS" in os.environ:
67
+ raw = os.environ["CORS_ORIGINS"]
68
+ if raw.strip() == "":
69
+ origins = []
70
+ else:
71
+ origins = _split_csv_origins(raw)
72
+ elif api_env == "prod":
73
+ origins = []
74
+ else:
75
+ origins = list(DEFAULT_DEV_CORS_ORIGINS)
76
+
77
+ if api_env == "prod":
78
+ origins = [o for o in origins if o != "*"]
79
+
80
+ return origins
81
+
82
+
83
+ MAX_UPLOAD_BYTES: int = _parse_int_env("GENEWEB_MAX_UPLOAD_BYTES", 50 * 1024 * 1024)
84
+
85
+ SESSION_MAX_SESSIONS: int = _parse_int_env("SESSION_MAX_SESSIONS", 10)
86
+ SESSION_TTL_SECONDS: int = _parse_int_env("SESSION_TTL_SECONDS", 3600)
87
+ READ_ONLY: bool = os.getenv("READ_ONLY", "false").strip().lower() == "true"