clinical-scope 0.4.2__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 (76) hide show
  1. clinical_scope/__init__.py +15 -0
  2. clinical_scope/build_info/assemble_bundle.py +92 -0
  3. clinical_scope/build_info/generate_third_party_licenses.py +471 -0
  4. clinical_scope/config/__init__.py +1 -0
  5. clinical_scope/config/parsing.py +84 -0
  6. clinical_scope/constants.py +178 -0
  7. clinical_scope/dash_api/__init__.py +0 -0
  8. clinical_scope/dash_api/annotations/__init__.py +1 -0
  9. clinical_scope/dash_api/annotations/io.py +106 -0
  10. clinical_scope/dash_api/annotations/model.py +143 -0
  11. clinical_scope/dash_api/annotations/renderer.py +353 -0
  12. clinical_scope/dash_api/callbacks/__init__.py +79 -0
  13. clinical_scope/dash_api/callbacks/annotation_callbacks.py +1429 -0
  14. clinical_scope/dash_api/callbacks/data_callbacks.py +1215 -0
  15. clinical_scope/dash_api/callbacks/loop_callbacks.py +85 -0
  16. clinical_scope/dash_api/core_api.py +674 -0
  17. clinical_scope/dash_api/helper_api.py +52 -0
  18. clinical_scope/dash_api/io.py +39 -0
  19. clinical_scope/dash_api/styles.py +267 -0
  20. clinical_scope/dash_api/ui_components.py +154 -0
  21. clinical_scope/dash_api/validation.py +151 -0
  22. clinical_scope/database_options_parser.py +216 -0
  23. clinical_scope/database_options_xlsx.py +394 -0
  24. clinical_scope/datasource/__init__.py +20 -0
  25. clinical_scope/datasource/base.py +593 -0
  26. clinical_scope/datasource/formatting/__init__.py +1 -0
  27. clinical_scope/datasource/formatting/timezone.py +278 -0
  28. clinical_scope/datasource/inspection.py +225 -0
  29. clinical_scope/datasource/registry.py +222 -0
  30. clinical_scope/datasource/sources/eit/__init__.py +0 -0
  31. clinical_scope/datasource/sources/eit/find_load_format.py +355 -0
  32. clinical_scope/datasource/sources/eit/options.py +68 -0
  33. clinical_scope/datasource/sources/fluxmed_parameters/__init__.py +0 -0
  34. clinical_scope/datasource/sources/fluxmed_parameters/find_load_format.py +110 -0
  35. clinical_scope/datasource/sources/fluxmed_parameters/options.py +47 -0
  36. clinical_scope/datasource/sources/fluxmed_signals/__init__.py +0 -0
  37. clinical_scope/datasource/sources/fluxmed_signals/find_load_format.py +103 -0
  38. clinical_scope/datasource/sources/fluxmed_signals/options.py +31 -0
  39. clinical_scope/datasource/sources/mindray_respi_numerics/__init__.py +1 -0
  40. clinical_scope/datasource/sources/mindray_respi_numerics/find_load_format.py +84 -0
  41. clinical_scope/datasource/sources/mindray_respi_numerics/options.py +51 -0
  42. clinical_scope/datasource/sources/mindray_respi_waves/__init__.py +1 -0
  43. clinical_scope/datasource/sources/mindray_respi_waves/find_load_format.py +139 -0
  44. clinical_scope/datasource/sources/mindray_respi_waves/options.py +51 -0
  45. clinical_scope/datasource/sources/mindray_scope/__init__.py +1 -0
  46. clinical_scope/datasource/sources/mindray_scope/find_load_format.py +292 -0
  47. clinical_scope/datasource/sources/mindray_scope/options.py +27 -0
  48. clinical_scope/datasource/sources/other/__init__.py +0 -0
  49. clinical_scope/datasource/sources/other/find_load_format.py +391 -0
  50. clinical_scope/datasource/sources/other/options.py +45 -0
  51. clinical_scope/datasource/sources/philips_numerics/__init__.py +0 -0
  52. clinical_scope/datasource/sources/philips_numerics/find_load_format.py +48 -0
  53. clinical_scope/datasource/sources/philips_numerics/options.py +43 -0
  54. clinical_scope/datasource/sources/philips_waves/__init__.py +0 -0
  55. clinical_scope/datasource/sources/philips_waves/find_load_format.py +55 -0
  56. clinical_scope/datasource/sources/philips_waves/options.py +31 -0
  57. clinical_scope/datasource/sources/servo_u/__init__.py +0 -0
  58. clinical_scope/datasource/sources/servo_u/find_load_format.py +185 -0
  59. clinical_scope/datasource/sources/servo_u/options.py +31 -0
  60. clinical_scope/datasource/sources/syringe/__init__.py +0 -0
  61. clinical_scope/datasource/sources/syringe/find_load_format.py +68 -0
  62. clinical_scope/datasource/sources/syringe/options.py +42 -0
  63. clinical_scope/datasource/timing.py +37 -0
  64. clinical_scope/hover_formatters.py +101 -0
  65. clinical_scope/io/__init__.py +1 -0
  66. clinical_scope/io/file_utils.py +230 -0
  67. clinical_scope/io/paths.py +42 -0
  68. clinical_scope/logger_config.py +111 -0
  69. clinical_scope/signal_container.py +902 -0
  70. clinical_scope/wrapper.py +659 -0
  71. clinical_scope-0.4.2.dist-info/METADATA +243 -0
  72. clinical_scope-0.4.2.dist-info/RECORD +76 -0
  73. clinical_scope-0.4.2.dist-info/WHEEL +5 -0
  74. clinical_scope-0.4.2.dist-info/entry_points.txt +2 -0
  75. clinical_scope-0.4.2.dist-info/licenses/LICENSE +203 -0
  76. clinical_scope-0.4.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,15 @@
1
+ from clinical_scope.wrapper import (
2
+ batch_extract,
3
+ extract_datasource,
4
+ extract_patient,
5
+ load_annotations,
6
+ load_database_annotations,
7
+ )
8
+
9
+ __all__ = [
10
+ "batch_extract",
11
+ "extract_datasource",
12
+ "extract_patient",
13
+ "load_annotations",
14
+ "load_database_annotations",
15
+ ]
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Finish a built ClinicalScope bundle: copy static assets in, write license notices.
4
+
5
+ PyInstaller produces the raw bundle (the executable + ``_internal/``); this script
6
+ takes over from there and is **shared by both build entry points** -- ``build.sh``
7
+ (local) and the GitHub Actions build workflow -- so the asset manifest and the
8
+ license step live in exactly one place instead of drifting between a bash script
9
+ and a YAML file. Each caller still runs PyInstaller itself, because their output
10
+ paths differ; they converge here.
11
+
12
+ Steps:
13
+
14
+ 1. Copy the static assets listed in ``ASSETS`` into the bundle root.
15
+ 2. Regenerate ``THIRD_PARTY_LICENSES.txt`` via ``generate_third_party_licenses``.
16
+
17
+ Exit code: ``0`` when every asset copied and attribution is complete; non-zero
18
+ when an asset was missing or the license notice has unresolved entries. Callers
19
+ treat a non-zero exit as a *warning*, not a hard failure -- see
20
+ ``docs/adr/0002-warn-only-on-unresolved-license-attribution.md``.
21
+
22
+ Usage::
23
+
24
+ python assemble_bundle.py --bundle-root <dir-with-_internal>
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import shutil
31
+ from pathlib import Path
32
+
33
+ from clinical_scope.build_info import generate_third_party_licenses as licenses
34
+
35
+ # Repo root: this file is at src/clinical_scope/build_info/assemble_bundle.py.
36
+ PROJECT_ROOT = Path(__file__).resolve().parents[3]
37
+
38
+ # (path relative to the repo root, "file" | "tree"). Trees drop the per-run cache.
39
+ ASSETS: list[tuple[str, str]] = [
40
+ ("docs/user_guide/ClinicalScope_UserGuide.pdf", "file"),
41
+ ("LICENSE", "file"),
42
+ ("DISCLAIMER.txt", "file"),
43
+ ("example/template_patient_data_structure", "tree"),
44
+ ("example/demo_database", "tree"),
45
+ ]
46
+ _TREE_IGNORE = shutil.ignore_patterns("clinical_scope_output")
47
+
48
+
49
+ def copy_assets(bundle_root: Path) -> list[str]:
50
+ """Copy each asset into ``bundle_root``; return the ones that were missing."""
51
+ missing: list[str] = []
52
+ for rel, kind in ASSETS:
53
+ src = PROJECT_ROOT / rel
54
+ if not src.exists():
55
+ print(f" WARNING: asset not found, skipped: {rel}")
56
+ missing.append(rel)
57
+ continue
58
+ dest = bundle_root / src.name
59
+ if kind == "tree":
60
+ shutil.copytree(src, dest, ignore=_TREE_IGNORE, dirs_exist_ok=True)
61
+ else:
62
+ shutil.copy(src, dest)
63
+ print(f" copied {rel}")
64
+ return missing
65
+
66
+
67
+ def main(argv: list[str] | None = None) -> int:
68
+ """Copy assets and write license notices into the bundle; return an exit code."""
69
+ ap = argparse.ArgumentParser(description=__doc__)
70
+ ap.add_argument(
71
+ "--bundle-root",
72
+ required=True,
73
+ type=Path,
74
+ help="Built bundle directory (contains the executable and _internal/).",
75
+ )
76
+ args = ap.parse_args(argv)
77
+ if not args.bundle_root.is_dir():
78
+ ap.error(f"bundle root does not exist: {args.bundle_root}")
79
+
80
+ print("Copying bundle assets...")
81
+ missing = copy_assets(args.bundle_root)
82
+ if missing:
83
+ print(f" MISSING ASSETS: {', '.join(missing)}")
84
+
85
+ print("Generating third-party license notices...")
86
+ lic_rc = licenses.main(["--bundle-root", str(args.bundle_root)])
87
+
88
+ return 1 if (missing or lic_rc) else 0
89
+
90
+
91
+ if __name__ == "__main__":
92
+ raise SystemExit(main())
@@ -0,0 +1,471 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate THIRD_PARTY_LICENSES.txt for a built ClinicalScope bundle.
4
+
5
+ The PyInstaller bundle redistributes third-party code and native libraries, all
6
+ under permissive / public-domain licenses whose only obligation is attribution.
7
+ PyInstaller does not collect their license texts, so this script assembles them:
8
+
9
+ 1. Python packages -- harvested from the *running interpreter's* installed
10
+ distributions (run this with the same venv that produced the build, so the
11
+ set matches exactly). Build-only tooling is excluded.
12
+ 2. Native libraries + the embedded interpreter -- discovered by scanning the
13
+ bundle's ``_internal/`` folder, then matched against a hand-maintained map.
14
+ Unknown native libraries are emitted as TODO entries rather than skipped.
15
+
16
+ Usage::
17
+
18
+ python generate_third_party_licenses.py --bundle-root <dir-with-_internal>
19
+
20
+ The output is written to ``<bundle-root>/THIRD_PARTY_LICENSES.txt``.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import importlib.metadata as im
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ # Build-only tooling that is NOT redistributed in the bundle -> no obligation.
31
+ # (PyInstaller's own bootloader ships under its separate distribution exception.)
32
+ BUILD_ONLY = {
33
+ "pyinstaller",
34
+ "pyinstaller-hooks-contrib",
35
+ "altgraph",
36
+ "macholib",
37
+ "pip",
38
+ "setuptools",
39
+ "wheel",
40
+ "clinical-scope", # our own code -> covered by the top-level LICENSE
41
+ }
42
+
43
+ LIC_HINTS = ("license", "licence", "copying", "notice", "authors")
44
+
45
+ # Copyleft policy for the build warning. Attribution discharges permissive
46
+ # licenses and weak file-level copyleft (MPL-2.0). Strong copyleft adds source
47
+ # obligations an attribution-only bundle can't meet -> flagged for review.
48
+ STRONG_COPYLEFT = (
49
+ "affero",
50
+ "agpl",
51
+ "lgpl",
52
+ "gpl",
53
+ "eclipse public",
54
+ "epl-",
55
+ "cddl",
56
+ "sspl",
57
+ "common public",
58
+ "cpl-",
59
+ "share-alike",
60
+ "sharealike",
61
+ "cc-by-sa",
62
+ )
63
+ WEAK_COPYLEFT = ("mozilla public", "mpl-2", "mpl 2") # noted, not flagged
64
+ # Longer ``License`` values are inlined license text, not an identifier -> skip.
65
+ MAX_LICENSE_ID_LEN = 80
66
+
67
+ # Strong-copyleft dists a human reviewed and accepts (name -> reason); listed here
68
+ # silences the warning, strong-copyleft and absent is reported as unexpected.
69
+ ACCEPTED_COPYLEFT: dict[str, str] = {}
70
+
71
+ # Standalone native shared libraries -> (display name, license notice). Keyed by
72
+ # a lowercase filename-stem prefix; matched across platforms (.dylib/.so/.dll).
73
+ # Extend this map when a build introduces a native lib not listed here.
74
+ NATIVE_LICENSES: dict[str, tuple[str, str]] = {
75
+ "libarrow": (
76
+ "Apache Arrow C++",
77
+ "License: Apache License 2.0. The full Apache-2.0 text and Arrow's NOTICE\n"
78
+ 'are reproduced above in the "pyarrow" section (same project).',
79
+ ),
80
+ "libparquet": (
81
+ "Apache Parquet C++ (part of Apache Arrow)",
82
+ 'License: Apache License 2.0. See the "pyarrow" section above.',
83
+ ),
84
+ "libcrypto": (
85
+ "OpenSSL",
86
+ "License: Apache License 2.0 (OpenSSL 3.x).\n"
87
+ "Copyright (c) The OpenSSL Project Authors. https://www.openssl.org",
88
+ ),
89
+ "libssl": (
90
+ "OpenSSL",
91
+ "License: Apache License 2.0 (OpenSSL 3.x).\n"
92
+ "Copyright (c) The OpenSSL Project Authors. https://www.openssl.org",
93
+ ),
94
+ "libsqlite3": (
95
+ "SQLite",
96
+ "SQLite is in the public domain. https://www.sqlite.org/copyright.html",
97
+ ),
98
+ "liblzma": (
99
+ "liblzma / xz",
100
+ "The liblzma core is public domain (0BSD). https://tukaani.org/xz/",
101
+ ),
102
+ "libmpdec": (
103
+ "libmpdec",
104
+ "License: BSD 2-Clause. Copyright (c) Stefan Krah. Used by Python decimal.",
105
+ ),
106
+ # Common cross-platform extras (may appear on Linux/Windows builds):
107
+ "libz": ("zlib", "License: zlib license. https://zlib.net"),
108
+ "libbz2": ("bzip2", "License: BSD-style. https://sourceware.org/bzip2/"),
109
+ "libffi": ("libffi", "License: MIT. https://github.com/libffi/libffi"),
110
+ "libtcl": ("Tcl", "License: Tcl/Tk BSD-style license. https://www.tcl.tk"),
111
+ "libtk": ("Tk", "License: Tcl/Tk BSD-style license. https://www.tcl.tk"),
112
+ # OpenBLAS, vendored into NumPy/SciPy wheels (same file stem on every OS):
113
+ "libscipy_openblas": (
114
+ "OpenBLAS",
115
+ "License: BSD 3-Clause. Copyright (c) 2011-present, The OpenBLAS contributors.\n"
116
+ "https://github.com/OpenMathLib/OpenBLAS/blob/develop/LICENSE",
117
+ ),
118
+ # Windows drops the `lib` prefix, so these mirror the lib* keys above.
119
+ "arrow": (
120
+ "Apache Arrow C++",
121
+ "License: Apache License 2.0. The full Apache-2.0 text and Arrow's NOTICE\n"
122
+ 'are reproduced above in the "pyarrow" section (same project).',
123
+ ),
124
+ "parquet": (
125
+ "Apache Parquet C++ (part of Apache Arrow)",
126
+ 'License: Apache License 2.0. See the "pyarrow" section above.',
127
+ ),
128
+ "sqlite3": (
129
+ "SQLite",
130
+ "SQLite is in the public domain. https://www.sqlite.org/copyright.html",
131
+ ),
132
+ "pywintypes": (
133
+ "pywin32 (pywintypes)",
134
+ "License: PSF License (BSD-style). Copyright (c) Mark Hammond and contributors.\n"
135
+ "https://github.com/mhammond/pywin32",
136
+ ),
137
+ # Linux GCC runtime libs; the GCC Runtime Library Exception permits non-GPL use.
138
+ "libgcc": (
139
+ "GCC runtime libraries",
140
+ "License: GPL-3.0-or-later WITH GCC-exception-3.1 (GCC Runtime Library\n"
141
+ "Exception), which permits distribution with independent software.\n"
142
+ "Copyright (c) Free Software Foundation, Inc. https://gcc.gnu.org",
143
+ ),
144
+ "libstdc++": (
145
+ "GCC runtime libraries",
146
+ "License: GPL-3.0-or-later WITH GCC-exception-3.1 (GCC Runtime Library\n"
147
+ "Exception), which permits distribution with independent software.\n"
148
+ "Copyright (c) Free Software Foundation, Inc. https://gcc.gnu.org",
149
+ ),
150
+ "libgfortran": (
151
+ "GCC runtime libraries",
152
+ "License: GPL-3.0-or-later WITH GCC-exception-3.1 (GCC Runtime Library\n"
153
+ "Exception), which permits distribution with independent software.\n"
154
+ "Copyright (c) Free Software Foundation, Inc. https://gcc.gnu.org",
155
+ ),
156
+ "libquadmath": (
157
+ "GCC libquadmath",
158
+ "License: LGPL-2.1-or-later. Copyright (c) Free Software Foundation, Inc.\n"
159
+ "https://gcc.gnu.org/onlinedocs/libquadmath/",
160
+ ),
161
+ "libtinfo": (
162
+ "ncurses (libtinfo)",
163
+ "License: MIT-style (ncurses license). Copyright (c) Free Software\n"
164
+ "Foundation, Inc. https://invisible-island.net/ncurses/",
165
+ ),
166
+ "libuuid": (
167
+ "util-linux libuuid",
168
+ "License: BSD 3-Clause. Copyright (c) Theodore Ts'o.\n"
169
+ "https://github.com/util-linux/util-linux",
170
+ ),
171
+ # GNU Readline is deliberately absent: GPL-3, excluded at build time (see the
172
+ # spec's `readline` exclude). Its absence makes any re-bundling surface as an
173
+ # UNRECOGNISED warning instead of being silently attributed.
174
+ # Windows Microsoft C/C++ runtimes, redistributable under the VS / SDK terms.
175
+ "vcruntime": (
176
+ "Microsoft Visual C++ Runtime",
177
+ "License: Microsoft Visual C++ Redistributable (distributable code).\n"
178
+ "Copyright (c) Microsoft Corporation. https://visualstudio.microsoft.com",
179
+ ),
180
+ "msvcp140": (
181
+ "Microsoft Visual C++ Runtime",
182
+ "License: Microsoft Visual C++ Redistributable (distributable code).\n"
183
+ "Copyright (c) Microsoft Corporation. https://visualstudio.microsoft.com",
184
+ ),
185
+ "api-ms-win": (
186
+ "Microsoft Universal C Runtime (UCRT)",
187
+ "License: Microsoft Windows SDK distributable code (Universal CRT).\n"
188
+ "Copyright (c) Microsoft Corporation. https://learn.microsoft.com",
189
+ ),
190
+ "ucrtbase": (
191
+ "Microsoft Universal C Runtime (UCRT)",
192
+ "License: Microsoft Windows SDK distributable code (Universal CRT).\n"
193
+ "Copyright (c) Microsoft Corporation. https://learn.microsoft.com",
194
+ ),
195
+ }
196
+
197
+ # Packages that ship no license file upstream -> supply a notice by hand here.
198
+ MANUAL_PKG: dict[str, str] = {}
199
+
200
+
201
+ def norm(name: str) -> str:
202
+ """
203
+ Lowercase a distribution name.
204
+
205
+ Hyphens and underscores are unified so lookups are spelling-insensitive.
206
+ """
207
+ return (name or "").lower().replace("_", "-")
208
+
209
+
210
+ def classify_license(dist: im.Distribution) -> tuple[str, str] | None:
211
+ """
212
+ Classify a distribution's license as copyleft, if it is.
213
+
214
+ Returns ``(severity, label)`` (severity ``"strong"``/``"weak"``) or ``None``
215
+ for permissive/unknown. Reads the standardized ``License ::`` trove
216
+ classifiers plus a short ``License``/``License-Expression`` field; a long
217
+ ``License`` value (inlined license text) is skipped so it can't false-match.
218
+ """
219
+ md = dist.metadata
220
+ parts = [
221
+ v
222
+ for k in ("License-Expression", "License")
223
+ if (v := md.get(k)) and len(v) < MAX_LICENSE_ID_LEN
224
+ ]
225
+ parts += [c for c in md.get_all("Classifier") or [] if c.startswith("License")]
226
+ blob = " ; ".join(parts).lower()
227
+ label = "; ".join(parts) or "?"
228
+ if any(m in blob for m in STRONG_COPYLEFT):
229
+ return "strong", label
230
+ if any(m in blob for m in WEAK_COPYLEFT):
231
+ return "weak", label
232
+ return None
233
+
234
+
235
+ def harvest_python_packages() -> tuple[list[str], list[str], list[str], list[tuple[str, str, str]]]:
236
+ """
237
+ Collect license sections for the running interpreter's distributions.
238
+
239
+ Returns ``(sections, covered, missing, copyleft)`` where ``copyleft`` lists
240
+ ``(severity, name-version, label)`` for non-permissive components.
241
+ """
242
+ excluded = {norm(b) for b in BUILD_ONLY}
243
+ seen: set[str] = set()
244
+ sections: list[str] = []
245
+ covered: list[str] = []
246
+ missing: list[str] = []
247
+ copyleft: list[tuple[str, str, str]] = []
248
+
249
+ dists = sorted(im.distributions(), key=lambda d: norm(d.metadata.get("Name") or ""))
250
+ for d in dists:
251
+ name = d.metadata.get("Name") or "<unknown>"
252
+ n = norm(name)
253
+ if n in excluded or n in seen:
254
+ continue
255
+ seen.add(n)
256
+ verdict = classify_license(d)
257
+ if verdict and not (verdict[0] == "strong" and n in ACCEPTED_COPYLEFT):
258
+ copyleft.append((verdict[0], f"{name} {d.version}", verdict[1]))
259
+ texts = _license_texts(d)
260
+ if not texts:
261
+ body = MANUAL_PKG.get(
262
+ n, "*** TODO: upstream ships no license file -- supply the notice. ***"
263
+ )
264
+ sections.append(_block(f"{name} {d.version} (manual notice)", [("notice", body)]))
265
+ missing.append(f"{name} {d.version}")
266
+ continue
267
+ sections.append(_block(f"{name} {d.version}", texts))
268
+ covered.append(f"{name} {d.version}")
269
+ return sections, covered, missing, copyleft
270
+
271
+
272
+ def _license_texts(dist: im.Distribution) -> list[tuple[str, str]]:
273
+ """Return ``(relative-name, text)`` for each license file a dist ships."""
274
+ out: list[tuple[str, str]] = []
275
+ for f in dist.files or []:
276
+ s = str(f).replace("\\", "/").lower()
277
+ if ".dist-info/" not in s:
278
+ continue
279
+ base = s.rsplit("/", 1)[-1]
280
+ if "/licenses/" in s or "/license_files/" in s or any(h in base for h in LIC_HINTS):
281
+ try:
282
+ text = dist.locate_file(f).read_text(encoding="utf-8", errors="replace")
283
+ except OSError as e:
284
+ text = f"<<could not read {f}: {e}>>"
285
+ out.append((str(f).split(".dist-info/", 1)[-1], text))
286
+ return out
287
+
288
+
289
+ def _block(title: str, parts: list[tuple[str, str]]) -> str:
290
+ bar = "=" * 78
291
+ body = "\n".join(f"--- {name} ---\n{text.rstrip()}\n" for name, text in parts)
292
+ return f"\n{bar}\n{title}\n{bar}\n\n{body}"
293
+
294
+
295
+ def discover_native_libs(internal: Path) -> list[Path]:
296
+ """
297
+ Return the standalone native shared libraries bundled in ``_internal``.
298
+
299
+ Extension modules (``*.cpython-*.so``, ``*.pyd``, ``*.cpython-*.dylib``) belong
300
+ to a Python package and are already attributed; only standalone ``lib*`` /
301
+ versioned shared objects need their own notice, so those are what we return.
302
+ """
303
+ standalone: list[Path] = []
304
+ ext_suffixes = (".pyd",)
305
+ for p in sorted(internal.rglob("*")):
306
+ if not p.is_file():
307
+ continue
308
+ name = p.name.lower()
309
+ is_shared = name.endswith((".dylib", ".dll")) or (
310
+ ".so" in name and (name.endswith(".so") or ".so." in name)
311
+ )
312
+ if not is_shared and not name.endswith(ext_suffixes):
313
+ continue
314
+ is_extension = "cpython-" in name or name.endswith(ext_suffixes) or "abi3" in name
315
+ if is_shared and not is_extension:
316
+ standalone.append(p)
317
+ return standalone
318
+
319
+
320
+ PSF_TODO = "*** TODO: paste PSF License text. https://docs.python.org/3/license.html ***"
321
+
322
+
323
+ def find_psf_text() -> str:
324
+ """Return the CPython PSF license text, or ``PSF_TODO`` if not found on disk."""
325
+ base = Path(sys.base_prefix)
326
+ ver = f"python{sys.version_info.major}.{sys.version_info.minor}"
327
+ candidates = [
328
+ base / "lib" / ver / "LICENSE.txt",
329
+ base / "LICENSE.txt",
330
+ base / "LICENSE",
331
+ ]
332
+ for c in candidates:
333
+ if c.is_file():
334
+ return c.read_text(encoding="utf-8", errors="replace").rstrip()
335
+ return PSF_TODO
336
+
337
+
338
+ def native_section(internal: Path) -> tuple[str, list[str]]:
339
+ """
340
+ Build the native-library + embedded-interpreter section for this bundle.
341
+
342
+ Returns ``(text, unrecognised)`` so the caller can flag unmatched native
343
+ libraries via the process exit code.
344
+ """
345
+ standalone = discover_native_libs(internal)
346
+ lines = [
347
+ "\n" + "#" * 78,
348
+ "NATIVE LIBRARIES AND EMBEDDED INTERPRETER",
349
+ "#" * 78,
350
+ "",
351
+ "Bundled as compiled shared libraries in `_internal/`; no Python metadata.",
352
+ "Discovered for THIS platform's build (re-run per platform).",
353
+ "",
354
+ ]
355
+ # The interpreter's own shared lib (libpython*.so / python3*.dll) is covered
356
+ # by the PSF block below, so route it there instead of flagging it.
357
+ interpreter_prefixes = ("libpython", "python3")
358
+ matched: dict[str, tuple[str, list[str]]] = {}
359
+ interpreter_libs: list[str] = []
360
+ unknown: list[str] = []
361
+ for p in standalone:
362
+ stem = p.name.lower()
363
+ if stem.startswith(interpreter_prefixes):
364
+ interpreter_libs.append(p.name)
365
+ continue
366
+ hit = next((k for k in NATIVE_LICENSES if stem.startswith(k)), None)
367
+ if hit:
368
+ disp, notice = NATIVE_LICENSES[hit]
369
+ matched.setdefault(disp, (notice, []))[1].append(p.name)
370
+ else:
371
+ unknown.append(p.name)
372
+
373
+ for disp, (notice, files) in sorted(matched.items()):
374
+ lines += ["-" * 78, f"{disp} ({', '.join(sorted(set(files)))})", "-" * 78, notice, ""]
375
+
376
+ # Embedded interpreter (PSF) -- always present in a PyInstaller bundle.
377
+ psf_text = find_psf_text()
378
+ if psf_text == PSF_TODO:
379
+ unknown.append("CPython PSF license text (interpreter)")
380
+ interp_files = f" ({', '.join(sorted(set(interpreter_libs)))})" if interpreter_libs else ""
381
+ lines += [
382
+ "-" * 78,
383
+ f"CPython {sys.version_info.major}.{sys.version_info.minor} interpreter "
384
+ f"(Python, base_library.zip, interpreter shared library){interp_files}",
385
+ "-" * 78,
386
+ "License: Python Software Foundation License Version 2 (PSF-2.0).",
387
+ "Copyright (c) 2001-present Python Software Foundation. All Rights Reserved.",
388
+ "",
389
+ psf_text,
390
+ "",
391
+ ]
392
+
393
+ if unknown:
394
+ lines += [
395
+ "-" * 78,
396
+ "UNRECOGNISED NATIVE LIBRARIES -- ACTION REQUIRED",
397
+ "-" * 78,
398
+ "Add these to NATIVE_LICENSES in generate_third_party_licenses.py:",
399
+ *[f" *** TODO: {u}" for u in sorted(unknown)],
400
+ "",
401
+ ]
402
+ return "\n".join(lines), unknown
403
+
404
+
405
+ def build_header(covered: int, missing: int) -> str:
406
+ """Return the file preamble summarising counts and provenance."""
407
+ return f"""\
408
+ THIRD-PARTY SOFTWARE NOTICES AND LICENSES
409
+ =========================================
410
+
411
+ ClinicalScope is distributed as a self-contained executable produced with
412
+ PyInstaller. The bundle (the `_internal/` folder next to the executable)
413
+ redistributes the third-party components listed below, each under its own
414
+ license. The full and unmodified text of every license follows; refer to each
415
+ notice for its specific terms.
416
+
417
+ Generated from the build interpreter: {sys.executable}
418
+
419
+ Python packages with reproduced license text: {covered}
420
+ Python packages needing a manual notice (listed inline below): {missing}
421
+ Native libraries + interpreter: see "NATIVE LIBRARIES" at the end.
422
+ """
423
+
424
+
425
+ def main(argv: list[str] | None = None) -> int:
426
+ """Parse arguments, assemble the file, and write it to the bundle root."""
427
+ ap = argparse.ArgumentParser(description=__doc__)
428
+ ap.add_argument(
429
+ "--bundle-root",
430
+ required=True,
431
+ type=Path,
432
+ help="Bundle directory containing the executable and _internal/.",
433
+ )
434
+ ap.add_argument("--output", type=Path, default=None, help="Override output path.")
435
+ args = ap.parse_args(argv)
436
+
437
+ internal = args.bundle_root / "_internal"
438
+ if not internal.is_dir():
439
+ ap.error(f"no _internal/ under {args.bundle_root} -- is this a built bundle?")
440
+ out = args.output or (args.bundle_root / "THIRD_PARTY_LICENSES.txt")
441
+
442
+ sections, covered, missing, copyleft = harvest_python_packages()
443
+ native_text, unrecognised = native_section(internal)
444
+ out.write_text(
445
+ build_header(len(covered), len(missing)) + "".join(sections) + native_text,
446
+ encoding="utf-8",
447
+ )
448
+ print(f"wrote {out} ({out.stat().st_size:,} bytes)")
449
+ print(f" python packages: {len(covered)} with text, {len(missing)} manual")
450
+ if missing:
451
+ print(f" MANUAL NEEDED: {', '.join(missing)}")
452
+ if unrecognised:
453
+ print(f" UNRECOGNISED NATIVE LIBS: {', '.join(sorted(unrecognised))}")
454
+
455
+ strong = [c for c in copyleft if c[0] == "strong"]
456
+ weak = [c for c in copyleft if c[0] == "weak"]
457
+ if weak:
458
+ print(f" weak copyleft (OK to ship unmodified): {', '.join(c[1] for c in weak)}")
459
+ if strong:
460
+ print(" UNEXPECTED STRONG-COPYLEFT PACKAGES -- review before publishing:")
461
+ for _, nv, label in strong:
462
+ print(f" {nv} [{label}]")
463
+
464
+ # Warn-only by design (see build_info/README.md): unresolved attribution or a
465
+ # new strong-copyleft dep must not pass silently, so signal via a non-zero exit
466
+ # code that build.sh surfaces -- without blocking an iterative build.
467
+ return 1 if (missing or unrecognised or strong) else 0
468
+
469
+
470
+ if __name__ == "__main__":
471
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ # config package — configuration parsing utilities.
@@ -0,0 +1,84 @@
1
+ """
2
+ Configuration file parsing utilities.
3
+
4
+ This module provides functions for loading JSON and XLSX configuration files,
5
+ including database options and patient options.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from pathlib import Path
11
+
12
+ from clinical_scope.database_options_parser import validate_database_options
13
+ from clinical_scope.database_options_xlsx import xlsx_to_database_options
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ # ==================================================================================================
19
+ def load_options(path: Path | None) -> dict:
20
+ """Load JSON options from a file if the path exists."""
21
+ if path and path.exists():
22
+ with path.open(encoding="utf-8") as file:
23
+ return json.load(file)
24
+ return {}
25
+
26
+
27
+ # ==================================================================================================
28
+ def build_patient_options(
29
+ patient_folder: str | Path,
30
+ path_patient_options: str | Path | None = None,
31
+ ) -> dict:
32
+ """
33
+ Build a patient_options dict from a folder path and an optional JSON file.
34
+
35
+ ``data_folder`` is always set from *patient_folder*.
36
+ Any other keys present in the JSON file are preserved.
37
+ """
38
+ opts = load_options(Path(path_patient_options)) if path_patient_options else {}
39
+ opts["data_folder"] = str(patient_folder)
40
+ return opts
41
+
42
+
43
+ # ==================================================================================================
44
+ def load_database_options_from_path(path: Path) -> dict:
45
+ """
46
+ Load database options from a JSON or XLSX file.
47
+
48
+ This is the canonical entry point for loading database options from a file
49
+ path, supporting both formats accepted by the Dash UI file upload.
50
+
51
+ Args:
52
+ path: Path to a ``.json`` or ``.xlsx`` database options file.
53
+
54
+ Returns:
55
+ Parsed database options dictionary.
56
+
57
+ Raises:
58
+ ValueError: If the file extension is not supported.
59
+ FileNotFoundError: If the path does not exist.
60
+
61
+ """
62
+
63
+ if not path.exists():
64
+ msg = f"Database options file not found: {path}"
65
+ raise FileNotFoundError(msg)
66
+
67
+ suffix = path.suffix.lower()
68
+ if suffix == ".json":
69
+ db_options = load_options(path)
70
+ elif suffix == ".xlsx":
71
+ db_options = xlsx_to_database_options(path)
72
+ else:
73
+ msg = f"Unsupported file extension '{suffix}'. Expected .json or .xlsx."
74
+ raise ValueError(msg)
75
+
76
+ for issue in validate_database_options(db_options):
77
+ if issue.severity == "error":
78
+ logger.error("database_options [%s]: %s", issue.path, issue.message)
79
+ elif issue.severity == "warning":
80
+ logger.warning("database_options [%s]: %s", issue.path, issue.message)
81
+ else:
82
+ logger.info("database_options [%s]: %s", issue.path, issue.message)
83
+
84
+ return db_options