messagefoundry 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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Managed, hot-reloadable **code sets** (reference lookup tables) for the message graph.
|
|
4
|
+
|
|
5
|
+
A code-first Router/Handler often needs a reference table — an Epic diet code → a food-service
|
|
6
|
+
system value, a facility code → a downstream mnemonic. Hand-maintained Python dicts in the config dir work, but they
|
|
7
|
+
aren't operated like config: they don't reload with the graph, they aren't surfaced as data, and an
|
|
8
|
+
edit means a code change. A **code set** is the managed alternative: drop a ``codesets/<name>.csv``
|
|
9
|
+
or ``codesets/<name>.toml`` next to the config bundle, then look it up with ``code_set("name")``.
|
|
10
|
+
|
|
11
|
+
A code set is **read-only reference data** — one frozen :class:`CodeSet` instance is shared by every
|
|
12
|
+
transform, so a Router/Handler must never mutate it. The lookup itself is pure (key in → value out),
|
|
13
|
+
which keeps it compatible with the staged-pipeline **pure-re-run** invariant (ADR 0001 / CLAUDE.md
|
|
14
|
+
§2). **One caveat:** a hot-reload that *changes* a code set between a run and a crash-re-run can make
|
|
15
|
+
the re-run derive a different output than the original. That is acceptable for reference data (a code
|
|
16
|
+
set is intentionally operator-editable, and a reload is an explicit, audited act) — but it is the one
|
|
17
|
+
way a transform's output can legitimately differ across a re-run, so document it where you document
|
|
18
|
+
the transform.
|
|
19
|
+
|
|
20
|
+
**Location.** ``codesets/`` is resolved **relative to the ``--config`` dir** (a config bundle carries
|
|
21
|
+
its own reference tables and reloads with it) — distinct from ``environments/`` (cwd-level endpoint
|
|
22
|
+
values for :func:`~messagefoundry.config.wiring.env`). A missing ``codesets/`` dir is fine (no code
|
|
23
|
+
sets); a referenced-but-missing *name* fails **loud** (:class:`~messagefoundry.config.wiring.WiringError`),
|
|
24
|
+
exactly like a missing ``env()`` key — surfaced by ``validate`` / ``check`` / reload.
|
|
25
|
+
|
|
26
|
+
**Formats** (auto-detected by extension; the code-set NAME is the filename stem):
|
|
27
|
+
|
|
28
|
+
* **CSV** — a header row; the **first column is the lookup key**. If exactly one other column remains,
|
|
29
|
+
the value is that scalar (``str``); if several remain, the value is a ``dict`` ``{header: cell}``.
|
|
30
|
+
Read via :class:`csv.DictReader`. A duplicate key is a load error (fail loud).
|
|
31
|
+
* **TOML** — a flat table ``key = value`` → ``{key: scalar}`` (mirroring
|
|
32
|
+
:mod:`messagefoundry.config.environments`); a nested table value → ``{key: {…}}``. Read via
|
|
33
|
+
:mod:`tomllib`.
|
|
34
|
+
|
|
35
|
+
**Resolution.** :func:`~messagefoundry.config.wiring.load_config` loads every code set into a registry
|
|
36
|
+
and makes it the **active** set *before* importing config modules (so a module-top-level
|
|
37
|
+
``DIET = code_set("epic_diets")`` resolves), and the :class:`RegistryRunner` re-publishes the live
|
|
38
|
+
registry's set while a Router/Handler runs (so a call-time ``code_set("epic_diets").get(x)`` inside a
|
|
39
|
+
handler resolves too). A reload swaps the active set atomically. Use :func:`activated` to scope an
|
|
40
|
+
active set; :func:`set_active` to publish one outside a ``with`` block.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import csv
|
|
46
|
+
import logging
|
|
47
|
+
import tomllib
|
|
48
|
+
from collections.abc import Iterator, Mapping
|
|
49
|
+
from contextlib import contextmanager
|
|
50
|
+
from contextvars import ContextVar
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
from typing import Any
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"CODESETS_DIR_NAME",
|
|
56
|
+
"CodeSet",
|
|
57
|
+
"load_code_set",
|
|
58
|
+
"load_code_sets",
|
|
59
|
+
"code_set",
|
|
60
|
+
"set_active",
|
|
61
|
+
"activated",
|
|
62
|
+
"CodeSetError",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
#: The code-set directory name, resolved relative to the ``--config`` dir.
|
|
66
|
+
CODESETS_DIR_NAME = "codesets"
|
|
67
|
+
|
|
68
|
+
_log = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CodeSetError(ValueError):
|
|
72
|
+
"""A code set is malformed, has a duplicate key, or was referenced but doesn't exist.
|
|
73
|
+
|
|
74
|
+
A subclass of :class:`ValueError`; :func:`messagefoundry.config.wiring.code_set` re-raises these
|
|
75
|
+
as :class:`~messagefoundry.config.wiring.WiringError` so a bad/missing code set is surfaced by
|
|
76
|
+
``validate`` / ``check`` / reload exactly like a missing ``env()`` key (fail loud)."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CodeSet(Mapping[str, Any]):
|
|
80
|
+
"""A frozen, read-only reference table: ``name`` + an immutable ``key → value`` mapping.
|
|
81
|
+
|
|
82
|
+
Behaves like a read-only ``dict`` (``cs[key]``, ``cs.get(key, default)``, ``key in cs``,
|
|
83
|
+
``len(cs)``, iteration) but rejects mutation — one instance is shared across every transform, so
|
|
84
|
+
reference data can't be edited from a handler. ``cs[missing]`` raises a :class:`KeyError` naming
|
|
85
|
+
the code set; ``cs.get(missing, default)`` returns the default."""
|
|
86
|
+
|
|
87
|
+
__slots__ = ("_name", "_data")
|
|
88
|
+
|
|
89
|
+
def __init__(self, name: str, data: Mapping[str, Any]) -> None:
|
|
90
|
+
self._name = name
|
|
91
|
+
self._data: dict[str, Any] = dict(data)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def name(self) -> str:
|
|
95
|
+
return self._name
|
|
96
|
+
|
|
97
|
+
def __getitem__(self, key: str) -> Any:
|
|
98
|
+
try:
|
|
99
|
+
return self._data[key]
|
|
100
|
+
except KeyError:
|
|
101
|
+
# Name the code set so a miss is self-explanatory in a transform traceback.
|
|
102
|
+
raise KeyError(f"key {key!r} not in code set {self._name!r}") from None
|
|
103
|
+
|
|
104
|
+
def __contains__(self, key: object) -> bool:
|
|
105
|
+
return key in self._data
|
|
106
|
+
|
|
107
|
+
def __iter__(self) -> Iterator[str]:
|
|
108
|
+
return iter(self._data)
|
|
109
|
+
|
|
110
|
+
def __len__(self) -> int:
|
|
111
|
+
return len(self._data)
|
|
112
|
+
|
|
113
|
+
def __repr__(self) -> str:
|
|
114
|
+
return f"CodeSet(name={self._name!r}, entries={len(self._data)})"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --- loading -----------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_code_set(path: str | Path) -> CodeSet:
|
|
121
|
+
"""Load one ``.csv``/``.toml`` file into a :class:`CodeSet` (the NAME is the file stem).
|
|
122
|
+
|
|
123
|
+
Auto-detects the format by extension. Raises :class:`CodeSetError` (naming the file) for an
|
|
124
|
+
unknown extension, a malformed file, or a duplicate key — never silently drops data."""
|
|
125
|
+
path = Path(path)
|
|
126
|
+
name = path.stem
|
|
127
|
+
suffix = path.suffix.lower()
|
|
128
|
+
if suffix == ".csv":
|
|
129
|
+
data = _load_csv(path)
|
|
130
|
+
elif suffix == ".toml":
|
|
131
|
+
data = _load_toml(path)
|
|
132
|
+
else:
|
|
133
|
+
raise CodeSetError(
|
|
134
|
+
f"code set {path.name!r}: unsupported extension {suffix!r} (use .csv or .toml)"
|
|
135
|
+
)
|
|
136
|
+
return CodeSet(name, data)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_code_sets(codesets_dir: str | Path) -> dict[str, CodeSet]:
|
|
140
|
+
"""Load every ``*.csv``/``*.toml`` in ``codesets_dir`` into a ``{name: CodeSet}`` registry.
|
|
141
|
+
|
|
142
|
+
A missing directory is **not** an error (returns ``{}`` — a config bundle need not ship any code
|
|
143
|
+
sets). Two files producing the same name (e.g. ``diets.csv`` and ``diets.toml``) is a
|
|
144
|
+
:class:`CodeSetError` (ambiguous), as is any malformed file."""
|
|
145
|
+
codesets_dir = Path(codesets_dir)
|
|
146
|
+
if not codesets_dir.is_dir():
|
|
147
|
+
return {}
|
|
148
|
+
out: dict[str, CodeSet] = {}
|
|
149
|
+
# Sorted for a deterministic load order, so a clash error names a stable "first" file.
|
|
150
|
+
for path in sorted(codesets_dir.iterdir()):
|
|
151
|
+
if not path.is_file() or path.suffix.lower() not in (".csv", ".toml"):
|
|
152
|
+
continue
|
|
153
|
+
cs = load_code_set(path)
|
|
154
|
+
if cs.name in out:
|
|
155
|
+
raise CodeSetError(
|
|
156
|
+
f"duplicate code set name {cs.name!r} in {codesets_dir} — two files (different "
|
|
157
|
+
"extensions) resolve to the same name; rename one"
|
|
158
|
+
)
|
|
159
|
+
out[cs.name] = cs
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _load_csv(path: Path) -> dict[str, Any]:
|
|
164
|
+
"""CSV with a header row: first column = key; one other column → scalar, several → ``{header: cell}``."""
|
|
165
|
+
data: dict[str, Any] = {}
|
|
166
|
+
with path.open(newline="", encoding="utf-8") as fh:
|
|
167
|
+
reader = csv.DictReader(fh)
|
|
168
|
+
fields = reader.fieldnames
|
|
169
|
+
if not fields:
|
|
170
|
+
raise CodeSetError(f"code set {path.name!r}: empty CSV (no header row)")
|
|
171
|
+
key_field, *value_fields = fields
|
|
172
|
+
if not value_fields:
|
|
173
|
+
raise CodeSetError(
|
|
174
|
+
f"code set {path.name!r}: CSV needs a key column plus at least one value column"
|
|
175
|
+
)
|
|
176
|
+
single = len(value_fields) == 1
|
|
177
|
+
for row in reader:
|
|
178
|
+
key = row.get(key_field)
|
|
179
|
+
if key is None:
|
|
180
|
+
continue # short/blank row — DictReader fills missing cells with None
|
|
181
|
+
if key in data:
|
|
182
|
+
raise CodeSetError(f"code set {path.name!r}: duplicate key {key!r}")
|
|
183
|
+
if single:
|
|
184
|
+
data[key] = row.get(value_fields[0])
|
|
185
|
+
else:
|
|
186
|
+
data[key] = {vf: row.get(vf) for vf in value_fields}
|
|
187
|
+
return data
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _load_toml(path: Path) -> dict[str, Any]:
|
|
191
|
+
"""Flat TOML table → ``{key: scalar}``; a nested table value → ``{key: {…}}`` (mirrors environments)."""
|
|
192
|
+
try:
|
|
193
|
+
with path.open("rb") as fh:
|
|
194
|
+
raw = tomllib.load(fh)
|
|
195
|
+
except (tomllib.TOMLDecodeError, OSError) as exc:
|
|
196
|
+
raise CodeSetError(f"code set {path.name!r}: invalid TOML — {exc}") from exc
|
|
197
|
+
# tomllib already rejects duplicate keys (TOMLDecodeError), so no extra dup check is needed.
|
|
198
|
+
return dict(raw)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --- active-set holder + accessor --------------------------------------------
|
|
202
|
+
|
|
203
|
+
# The active code sets, as a ContextVar so import-time (load_config publishes the set, then imports
|
|
204
|
+
# config modules in the same thread/context) AND call-time (the RegistryRunner re-publishes the live
|
|
205
|
+
# set around a router/handler run) both resolve, and a reload swaps cleanly by resetting the var (no
|
|
206
|
+
# stale set leaks — unlike a bare module-global, a ContextVar's reset token restores the prior value
|
|
207
|
+
# even if loads/reloads overlap). Defaults to None = "no active set" (a code_set() call then fails
|
|
208
|
+
# loud) rather than {} so "no codesets dir" and "called outside a load/run" stay distinguishable.
|
|
209
|
+
_active: ContextVar[dict[str, CodeSet] | None] = ContextVar("mefor_active_code_sets", default=None)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def set_active(code_sets: dict[str, CodeSet] | None) -> Any:
|
|
213
|
+
"""Publish ``code_sets`` as the active set and return a reset token (pass it to :func:`reset`).
|
|
214
|
+
|
|
215
|
+
Used by callers that can't bracket the active span with a ``with`` (e.g. an async worker that
|
|
216
|
+
publishes around a single transform call). Prefer :func:`activated` where a ``with`` block fits."""
|
|
217
|
+
return _active.set(code_sets)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def reset(token: Any) -> None:
|
|
221
|
+
"""Restore the active set to what it was before the matching :func:`set_active`."""
|
|
222
|
+
_active.reset(token)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@contextmanager
|
|
226
|
+
def activated(code_sets: dict[str, CodeSet] | None) -> Iterator[None]:
|
|
227
|
+
"""Make ``code_sets`` the active set for the duration of the ``with`` block, then restore.
|
|
228
|
+
|
|
229
|
+
The loader brackets config-module import with this; a runner brackets each router/handler run with
|
|
230
|
+
it — so ``code_set()`` resolves both at import time and at call time, and the prior set is always
|
|
231
|
+
restored (clean swap, no leak)."""
|
|
232
|
+
token = _active.set(code_sets)
|
|
233
|
+
try:
|
|
234
|
+
yield
|
|
235
|
+
finally:
|
|
236
|
+
_active.reset(token)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def code_set(name: str) -> CodeSet:
|
|
240
|
+
"""Return the active code set ``name`` (a frozen, read-only :class:`CodeSet`).
|
|
241
|
+
|
|
242
|
+
Call it at a config module's top level to capture a table once (``DIET = code_set("epic_diets")``)
|
|
243
|
+
or inside a handler at call time (``code_set("epic_diets").get(x)``) — both resolve against the
|
|
244
|
+
set the loader/runner has published. A missing code set raises :class:`CodeSetError` (fail loud);
|
|
245
|
+
:func:`messagefoundry.config.wiring.code_set` (the authoring surface) re-raises it as a
|
|
246
|
+
:class:`~messagefoundry.config.wiring.WiringError`."""
|
|
247
|
+
active = _active.get()
|
|
248
|
+
if active is None:
|
|
249
|
+
raise CodeSetError(
|
|
250
|
+
f"code_set({name!r}) called with no active code sets — code sets resolve only while a "
|
|
251
|
+
"config bundle is being loaded or its graph is running (load it via load_config())"
|
|
252
|
+
)
|
|
253
|
+
try:
|
|
254
|
+
return active[name]
|
|
255
|
+
except KeyError:
|
|
256
|
+
available = ", ".join(sorted(active)) or "(none)"
|
|
257
|
+
raise CodeSetError(
|
|
258
|
+
f"no such code set {name!r} — expected a file codesets/{name}.csv or "
|
|
259
|
+
f"codesets/{name}.toml relative to the --config dir; available: {available}"
|
|
260
|
+
) from None
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Write ``connections.toml`` — the comment/format-preserving editor behind the ``connection`` CLI
|
|
4
|
+
(ADR 0007).
|
|
5
|
+
|
|
6
|
+
The file is edited by hand **and** by the VS Code GUI, so a write must touch only the target
|
|
7
|
+
connection and leave every other table's comments + formatting byte-stable — hence ``tomlkit``
|
|
8
|
+
(style-preserving) rather than a plain serializer. Each mutation **validates the whole config dir**
|
|
9
|
+
(load + the connector/egress build-check) BEFORE it persists, and writes atomically (temp + replace,
|
|
10
|
+
owner-only perms); if validation fails the prior content is restored, so a bad edit never lands.
|
|
11
|
+
|
|
12
|
+
The validation is injected as a callback so this module stays free of service-settings/engine imports
|
|
13
|
+
(and is trivially testable); the ``connection`` CLI builds the callback from ``load_config`` +
|
|
14
|
+
``build_check_registry`` against the local ``[egress]`` allowlist and active environment.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Callable
|
|
22
|
+
|
|
23
|
+
import tomlkit
|
|
24
|
+
|
|
25
|
+
from messagefoundry.config.connections_file import CONNECTIONS_FILE_NAME
|
|
26
|
+
from messagefoundry.config.wiring import WiringError
|
|
27
|
+
|
|
28
|
+
# Scalar fields written (in this order) at the top of a connection table; sub-tables (settings/retry/
|
|
29
|
+
# buildup) follow. Only keys present in the input are emitted — the loader rejects misplaced ones.
|
|
30
|
+
_SCALAR_FIELDS = (
|
|
31
|
+
"name",
|
|
32
|
+
"transport",
|
|
33
|
+
"router",
|
|
34
|
+
"content_type",
|
|
35
|
+
"ack_mode",
|
|
36
|
+
"ack_after",
|
|
37
|
+
"strict",
|
|
38
|
+
"hl7_version",
|
|
39
|
+
"ordering",
|
|
40
|
+
"internal_error",
|
|
41
|
+
"simulate",
|
|
42
|
+
)
|
|
43
|
+
_SUB_TABLES = ("settings", "retry", "buildup")
|
|
44
|
+
|
|
45
|
+
Validate = Callable[[Path], None]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def list_connections(config_dir: str | Path) -> list[dict[str, Any]]:
|
|
49
|
+
"""The data-authored connections in ``config_dir``'s ``connections.toml`` (``[]`` if none).
|
|
50
|
+
|
|
51
|
+
Each entry is a plain dict with a ``direction`` key added — the editable set the GUI manages
|
|
52
|
+
(code-first ``inbound()``/``outbound()`` connections are read-only and not listed here)."""
|
|
53
|
+
path = _path(config_dir)
|
|
54
|
+
if not path.is_file():
|
|
55
|
+
return []
|
|
56
|
+
doc = tomlkit.parse(path.read_text(encoding="utf-8"))
|
|
57
|
+
out: list[dict[str, Any]] = []
|
|
58
|
+
for direction in ("inbound", "outbound"):
|
|
59
|
+
for table in doc.get(direction, []):
|
|
60
|
+
entry = dict(table.unwrap())
|
|
61
|
+
entry["direction"] = direction
|
|
62
|
+
out.append(entry)
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def upsert_connection(
|
|
67
|
+
config_dir: str | Path, obj: dict[str, Any], *, validate: Validate
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""Insert or replace the connection described by ``obj`` (the connection as a JSON object).
|
|
70
|
+
|
|
71
|
+
Replaces the same-named entry in place (preserving every *other* table's comments) or appends a
|
|
72
|
+
new one. Validates the whole dir before persisting; raises :class:`WiringError` on bad input or a
|
|
73
|
+
config that wouldn't load (a duplicate vs. a code-first name, unknown router, egress-denied host,
|
|
74
|
+
…) — leaving ``connections.toml`` untouched."""
|
|
75
|
+
_validate_input(obj)
|
|
76
|
+
path = _path(config_dir)
|
|
77
|
+
original = path.read_text(encoding="utf-8") if path.is_file() else None
|
|
78
|
+
doc = tomlkit.parse(original) if original is not None else tomlkit.document()
|
|
79
|
+
|
|
80
|
+
direction = obj["direction"]
|
|
81
|
+
aot = doc.get(direction)
|
|
82
|
+
if aot is None:
|
|
83
|
+
aot = tomlkit.aot()
|
|
84
|
+
doc[direction] = aot
|
|
85
|
+
new_table = _build_table(obj)
|
|
86
|
+
for index in range(len(aot)):
|
|
87
|
+
if aot[index].get("name") == obj["name"]:
|
|
88
|
+
aot[index] = new_table # replace in place — other entries' trivia untouched
|
|
89
|
+
break
|
|
90
|
+
else:
|
|
91
|
+
aot.append(new_table)
|
|
92
|
+
|
|
93
|
+
_write_validated(path, tomlkit.dumps(doc), original, validate)
|
|
94
|
+
return {"op": "upsert", "direction": direction, "name": obj["name"]}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def remove_connection(config_dir: str | Path, name: str, *, validate: Validate) -> dict[str, Any]:
|
|
98
|
+
"""Remove the data-authored connection ``name``. Raises :class:`WiringError` if it isn't in
|
|
99
|
+
``connections.toml`` (a code-first connection can't be removed here)."""
|
|
100
|
+
path = _path(config_dir)
|
|
101
|
+
if not path.is_file():
|
|
102
|
+
raise WiringError(f"no {CONNECTIONS_FILE_NAME} in {config_dir}")
|
|
103
|
+
original = path.read_text(encoding="utf-8")
|
|
104
|
+
doc = tomlkit.parse(original)
|
|
105
|
+
|
|
106
|
+
removed = False
|
|
107
|
+
for direction in ("inbound", "outbound"):
|
|
108
|
+
aot = doc.get(direction)
|
|
109
|
+
if aot is None:
|
|
110
|
+
continue
|
|
111
|
+
for index in range(len(aot)):
|
|
112
|
+
if aot[index].get("name") == name:
|
|
113
|
+
del aot[index]
|
|
114
|
+
removed = True
|
|
115
|
+
break
|
|
116
|
+
if removed:
|
|
117
|
+
break
|
|
118
|
+
if not removed:
|
|
119
|
+
raise WiringError(
|
|
120
|
+
f"connection {name!r} is not in {CONNECTIONS_FILE_NAME} "
|
|
121
|
+
"(a code-authored connection can't be removed here)"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
_write_validated(path, tomlkit.dumps(doc), original, validate)
|
|
125
|
+
return {"op": "remove", "name": name}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --- internals ---------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _path(config_dir: str | Path) -> Path:
|
|
132
|
+
return Path(config_dir) / CONNECTIONS_FILE_NAME
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _validate_input(obj: Any) -> None:
|
|
136
|
+
if not isinstance(obj, dict):
|
|
137
|
+
raise WiringError("connection must be a JSON object")
|
|
138
|
+
if obj.get("direction") not in ("inbound", "outbound"):
|
|
139
|
+
raise WiringError("connection 'direction' must be 'inbound' or 'outbound'")
|
|
140
|
+
if not isinstance(obj.get("name"), str) or not obj["name"]:
|
|
141
|
+
raise WiringError("connection 'name' must be a non-empty string")
|
|
142
|
+
if not isinstance(obj.get("transport"), str) or not obj["transport"]:
|
|
143
|
+
raise WiringError("connection 'transport' must be a non-empty string")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _build_table(obj: dict[str, Any]) -> Any:
|
|
147
|
+
table = tomlkit.table()
|
|
148
|
+
for key in _SCALAR_FIELDS:
|
|
149
|
+
if obj.get(key) is not None:
|
|
150
|
+
table[key] = obj[key]
|
|
151
|
+
for key in _SUB_TABLES:
|
|
152
|
+
value = obj.get(key)
|
|
153
|
+
if value:
|
|
154
|
+
table[key] = _sub_table(value)
|
|
155
|
+
return table
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _sub_table(data: dict[str, Any]) -> Any:
|
|
159
|
+
sub = tomlkit.table()
|
|
160
|
+
for key, value in data.items():
|
|
161
|
+
sub[key] = _toml_value(value)
|
|
162
|
+
return sub
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _toml_value(value: Any) -> Any:
|
|
166
|
+
"""A nested dict (an ``env()`` ref like ``{env = "k"}``, or REST headers) becomes an inline table;
|
|
167
|
+
everything else is written verbatim."""
|
|
168
|
+
if isinstance(value, dict):
|
|
169
|
+
inline = tomlkit.inline_table()
|
|
170
|
+
for key, item in value.items():
|
|
171
|
+
inline[key] = item
|
|
172
|
+
return inline
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _write_validated(path: Path, new_text: str, original: str | None, validate: Validate) -> None:
|
|
177
|
+
"""Atomically write ``new_text``, validate the dir, and roll back to ``original`` on failure."""
|
|
178
|
+
_atomic_write(path, new_text)
|
|
179
|
+
try:
|
|
180
|
+
validate(path.parent)
|
|
181
|
+
except BaseException:
|
|
182
|
+
if original is None:
|
|
183
|
+
path.unlink(missing_ok=True)
|
|
184
|
+
else:
|
|
185
|
+
_atomic_write(path, original)
|
|
186
|
+
raise
|
|
187
|
+
_secure_file(path)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
191
|
+
tmp = path.with_name(path.name + ".tmp")
|
|
192
|
+
tmp.write_text(text, encoding="utf-8")
|
|
193
|
+
os.replace(tmp, path)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _secure_file(path: Path) -> None:
|
|
197
|
+
# Owner-only permissions (defence in depth). Reuse the store's primitive; tolerate its absence.
|
|
198
|
+
from messagefoundry.store.store import _secure_file as _secure
|
|
199
|
+
|
|
200
|
+
_secure(path)
|