bpskit 0.2.0__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,7 @@
1
+ /.[!.]*/
2
+ !/.github/
3
+ __pycache__/
4
+ *.py[cod]
5
+ /dist/
6
+ /build/
7
+ *.egg-info/
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to bpskit. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the versions
5
+ [Semantic Versioning](https://semver.org/): until 1.0, a minor release may
6
+ change the public API (the names in each module's `__all__`).
7
+
8
+ ## [0.2.0]
9
+
10
+ ### Added
11
+ - `bpskit.synthetic`: a synthetic BPS delivery with planted structure
12
+ (`make_delivery`, `config`, `FILES`), and `bpskit synthetic` on the command line.
13
+ - `examples/quickstart.py`, a worked example run in CI.
14
+ - A declared public API (`__all__` in every module), type hints and `py.typed`.
15
+ - Packaging metadata, a release workflow publishing to PyPI by trusted
16
+ publishing, `CITATION.cff`.
17
+
18
+ ### Changed
19
+ - The version is read from `bpskit/__init__.py` (one place to bump).
20
+
21
+ ## [0.1.0]
22
+
23
+ ### Added
24
+ - `delivery`, `icd`, `persontime`, `stats` and `transfer`, extracted from the
25
+ BPS sex-differences workflow, and the `bpskit pullable` / `bpskit check-logs`
26
+ command line.
27
+
28
+ [0.2.0]: https://github.com/loucerac/bpskit/compare/v0.1.0...v0.2.0
29
+ [0.1.0]: https://github.com/loucerac/bpskit/releases/tag/v0.1.0
@@ -0,0 +1,16 @@
1
+ cff-version: 1.2.0
2
+ message: "If you use bpskit, please cite it as below."
3
+ title: "bpskit: tools for studies on a BPS health-records delivery"
4
+ type: software
5
+ authors:
6
+ - family-names: Loucera
7
+ given-names: Carlos
8
+ email: clou@us.es
9
+ license: MIT
10
+ repository-code: "https://github.com/loucerac/bpskit"
11
+ version: 0.2.0
12
+ keywords:
13
+ - epidemiology
14
+ - electronic health records
15
+ - ICD-10
16
+ - disclosure control
bpskit-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carlos Loucera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
bpskit-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.5
2
+ Name: bpskit
3
+ Version: 0.2.0
4
+ Summary: Tools for studies on a BPS (Base Poblacional de Salud) health-records delivery, including an identifier check for outputs leaving a secure data environment
5
+ Project-URL: Homepage, https://github.com/loucerac/bpskit
6
+ Project-URL: Changelog, https://github.com/loucerac/bpskit/blob/main/CHANGELOG.md
7
+ Project-URL: Issues, https://github.com/loucerac/bpskit/issues
8
+ Author-email: Carlos Loucera <clou@us.es>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ICD-10,ICD-9,disclosure control,electronic health records,epidemiology,person-time,snakemake,trusted research environment
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Operating System :: POSIX :: Linux
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: icd-mappings<0.7,>=0.6.2
22
+ Requires-Dist: numpy>=2
23
+ Requires-Dist: pandas>=2.2
24
+ Requires-Dist: pyarrow>=15
25
+ Requires-Dist: pyyaml>=6
26
+ Requires-Dist: scipy>=1.11
27
+ Description-Content-Type: text/markdown
28
+
29
+ # bpskit
30
+
31
+ Tools for studies on a BPS (Base Poblacional de Salud, Servicio Andaluz de
32
+ Salud) health-records delivery, and for working with pseudonymised
33
+ health-record extracts in a secure data environment generally.
34
+
35
+ - **Read the delivery.** Pipe-delimited files read as text, sentinel codes for
36
+ "unknown", month- and day-precision dates, streaming in fixed-size blocks so a
37
+ 400-million-row file never sits in memory.
38
+ - **ICD codes.** Code specifications like `A15-A19, H35.3`, ICD-10 chapters,
39
+ and ICD-9 → ICD-10 mapping with the CMS GEM. That includes the V/E
40
+ supplementary codes, which are also valid ICD-10 and can only be placed by
41
+ the record's date relative to a changeover.
42
+ - **Person-time.** Dates as days since 1970, age bands, Lexis splitting,
43
+ person-sorted streaming.
44
+ - **Exact tests.** Poisson rate ratios (Garwood intervals), binomial tests,
45
+ BH and Bonferroni.
46
+ - **An output check for leaving the secure environment.** `bpskit.transfer`
47
+ refuses any file that contains an identifier the workflow read, or a
48
+ per-person column, and fails closed when it cannot read something.
49
+ - **A synthetic delivery** with BPS column names and planted structure, for
50
+ tests and examples.
51
+
52
+ ## Installing
53
+
54
+ ```bash
55
+ pip install "bpskit @ git+https://github.com/loucerac/bpskit.git@v0.2.0"
56
+ ```
57
+
58
+ In a pixi project (`pixi.toml`):
59
+
60
+ ```toml
61
+ [pypi-dependencies]
62
+ bpskit = { git = "https://github.com/loucerac/bpskit.git", tag = "v0.2.0" }
63
+ ```
64
+
65
+ In a Snakemake rule environment (`workflow/envs/*.yaml`):
66
+
67
+ ```yaml
68
+ dependencies:
69
+ - pip
70
+ - pip:
71
+ - git+https://github.com/loucerac/bpskit.git@v0.2.0
72
+ ```
73
+
74
+ Pin a release. Bumping the tag changes the environment file, and Snakemake's
75
+ `software-env` rerun trigger reruns the rules that use it. A module imported
76
+ from a sibling folder gets no such tracking.
77
+
78
+ ## A worked example
79
+
80
+ [`examples/quickstart.py`](examples/quickstart.py) runs in a few seconds with no
81
+ real data:
82
+
83
+ 1. it writes a 5,000-person synthetic delivery;
84
+ 2. it measures the incidence of type 2 diabetes (E11) in primary care in
85
+ 2018-2025;
86
+ 3. it compares women with the whole population;
87
+ 4. it runs the transfer check on the result.
88
+
89
+ The core of it:
90
+
91
+ ```python
92
+ from bpskit import transfer
93
+ from bpskit.delivery import normalise_code, parse_date, read_delivery, stream_csv
94
+ from bpskit.icd import expand_spec, prefix_mask
95
+ from bpskit.persontime import day_of, follow_up, to_days
96
+ from bpskit.stats import poisson_rr
97
+ from bpskit.synthetic import make_delivery
98
+
99
+ cfg = make_delivery("work/delivery", n_persons=5_000) # files + how to read them
100
+
101
+ # people: birth and death as days since 1970 (NaN = alive)
102
+ u = cfg["universe"]
103
+ people = read_delivery(f"work/delivery/{u['file']}", [u["id"], u["sex"], u["birth"], u["death"]])
104
+ birth = to_days(parse_date(people[u["birth"]], u["date_format"], ["", "-1"], mid_month_day=15))
105
+
106
+ # first E11 record per person, streamed block by block
107
+ ap = cfg["sources"]["ap"]
108
+ for block in stream_csv(
109
+ f"work/delivery/{ap['file']}", [ap["id"], ap["date"], ap["code"]], 16, "utf-8"
110
+ ):
111
+ hits = block[prefix_mask(normalise_code(block[ap["code"]]), expand_spec("E11"))]
112
+ ...
113
+
114
+ # follow-up, then observed vs expected with an exact interval
115
+ entry, exit_, in_period = follow_up(birth, death, day_of("2018-01-01"), day_of("2025-12-31"))
116
+ ratio, lo, hi, p = poisson_rr(observed, expected, alpha=0.05)
117
+
118
+ # nothing leaves the machine that names a patient
119
+ n, problems = transfer.check(
120
+ ["work/results/out"],
121
+ ids=set(people[u["id"]]),
122
+ strict=True,
123
+ forbidden=transfer.forbidden_columns(cfg),
124
+ )
125
+ ```
126
+
127
+ ```
128
+ E11 incidence 1,258 per 100,000 person-years; women vs all: 1.12 (0.98-1.27)
129
+ aggregate table: 1 file checked, 0 problem(s)
130
+ with a leaked file:
131
+ .../first_cases.csv: contains 3 patient identifier(s)
132
+ .../first_cases.csv: per-person or identifier column(s) ['patient']
133
+ ```
134
+
135
+ ## The transfer check
136
+
137
+ The rule it enforces: no row carrying a patient identifier leaves the machine
138
+ that holds the delivery. It checks the bytes of every file against the
139
+ identifiers the workflow actually read, not against a guessed id format. A
140
+ workflow writes those identifiers with `transfer.write_ids`:
141
+
142
+ - `results/work/ids/universe.parquet`: every id in the people file, written
143
+ before anyone is dropped;
144
+ - `results/work/ids/<source>.parquet`: ids in a diagnosis file but not in the
145
+ people file.
146
+
147
+ Where an id file is missing (a run that died early), the check reads the
148
+ delivery file instead. An unreadable file, an unknown suffix or an empty id set
149
+ all refuse. With `strict=True` (for a results folder copied off wholesale),
150
+ only csv/txt/png pass. CSVs whose header holds a per-person key, one of the
151
+ delivery's id or date columns, or an identifier-like name are refused too.
152
+
153
+ From a workflow directory on the data machine:
154
+
155
+ ```bash
156
+ bpskit pullable --strict results/out # exit 0 only if results/out/ may leave
157
+ bpskit check-logs . # list the logs that may leave
158
+ bpskit synthetic data/synthetic --persons 2000
159
+ ```
160
+
161
+ `pullable` and `check-logs` read the workflow's `config/config.yaml`
162
+ (`--config` to change it). Its `universe` and `sources` sections, in the shape
163
+ `bpskit.synthetic.config()` returns, name the id columns.
164
+
165
+ ## Developing
166
+
167
+ ```bash
168
+ pixi run test # pytest
169
+ pixi run lint # ruff check + format check
170
+ pixi run example # the worked example
171
+ pixi run build # sdist + wheel, twine check
172
+ ```
173
+
174
+ Releasing: bump `__version__` in `src/bpskit/__init__.py`, add the release to
175
+ `CHANGELOG.md`, commit, then tag `vX.Y.Z` and push the tag.
176
+ `.github/workflows/release.yml` checks that the tag matches the version,
177
+ builds, and publishes to PyPI by trusted publishing.
178
+
179
+ ## Citing
180
+
181
+ See [`CITATION.cff`](CITATION.cff). MIT licensed.
bpskit-0.2.0/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # bpskit
2
+
3
+ Tools for studies on a BPS (Base Poblacional de Salud, Servicio Andaluz de
4
+ Salud) health-records delivery, and for working with pseudonymised
5
+ health-record extracts in a secure data environment generally.
6
+
7
+ - **Read the delivery.** Pipe-delimited files read as text, sentinel codes for
8
+ "unknown", month- and day-precision dates, streaming in fixed-size blocks so a
9
+ 400-million-row file never sits in memory.
10
+ - **ICD codes.** Code specifications like `A15-A19, H35.3`, ICD-10 chapters,
11
+ and ICD-9 → ICD-10 mapping with the CMS GEM. That includes the V/E
12
+ supplementary codes, which are also valid ICD-10 and can only be placed by
13
+ the record's date relative to a changeover.
14
+ - **Person-time.** Dates as days since 1970, age bands, Lexis splitting,
15
+ person-sorted streaming.
16
+ - **Exact tests.** Poisson rate ratios (Garwood intervals), binomial tests,
17
+ BH and Bonferroni.
18
+ - **An output check for leaving the secure environment.** `bpskit.transfer`
19
+ refuses any file that contains an identifier the workflow read, or a
20
+ per-person column, and fails closed when it cannot read something.
21
+ - **A synthetic delivery** with BPS column names and planted structure, for
22
+ tests and examples.
23
+
24
+ ## Installing
25
+
26
+ ```bash
27
+ pip install "bpskit @ git+https://github.com/loucerac/bpskit.git@v0.2.0"
28
+ ```
29
+
30
+ In a pixi project (`pixi.toml`):
31
+
32
+ ```toml
33
+ [pypi-dependencies]
34
+ bpskit = { git = "https://github.com/loucerac/bpskit.git", tag = "v0.2.0" }
35
+ ```
36
+
37
+ In a Snakemake rule environment (`workflow/envs/*.yaml`):
38
+
39
+ ```yaml
40
+ dependencies:
41
+ - pip
42
+ - pip:
43
+ - git+https://github.com/loucerac/bpskit.git@v0.2.0
44
+ ```
45
+
46
+ Pin a release. Bumping the tag changes the environment file, and Snakemake's
47
+ `software-env` rerun trigger reruns the rules that use it. A module imported
48
+ from a sibling folder gets no such tracking.
49
+
50
+ ## A worked example
51
+
52
+ [`examples/quickstart.py`](examples/quickstart.py) runs in a few seconds with no
53
+ real data:
54
+
55
+ 1. it writes a 5,000-person synthetic delivery;
56
+ 2. it measures the incidence of type 2 diabetes (E11) in primary care in
57
+ 2018-2025;
58
+ 3. it compares women with the whole population;
59
+ 4. it runs the transfer check on the result.
60
+
61
+ The core of it:
62
+
63
+ ```python
64
+ from bpskit import transfer
65
+ from bpskit.delivery import normalise_code, parse_date, read_delivery, stream_csv
66
+ from bpskit.icd import expand_spec, prefix_mask
67
+ from bpskit.persontime import day_of, follow_up, to_days
68
+ from bpskit.stats import poisson_rr
69
+ from bpskit.synthetic import make_delivery
70
+
71
+ cfg = make_delivery("work/delivery", n_persons=5_000) # files + how to read them
72
+
73
+ # people: birth and death as days since 1970 (NaN = alive)
74
+ u = cfg["universe"]
75
+ people = read_delivery(f"work/delivery/{u['file']}", [u["id"], u["sex"], u["birth"], u["death"]])
76
+ birth = to_days(parse_date(people[u["birth"]], u["date_format"], ["", "-1"], mid_month_day=15))
77
+
78
+ # first E11 record per person, streamed block by block
79
+ ap = cfg["sources"]["ap"]
80
+ for block in stream_csv(
81
+ f"work/delivery/{ap['file']}", [ap["id"], ap["date"], ap["code"]], 16, "utf-8"
82
+ ):
83
+ hits = block[prefix_mask(normalise_code(block[ap["code"]]), expand_spec("E11"))]
84
+ ...
85
+
86
+ # follow-up, then observed vs expected with an exact interval
87
+ entry, exit_, in_period = follow_up(birth, death, day_of("2018-01-01"), day_of("2025-12-31"))
88
+ ratio, lo, hi, p = poisson_rr(observed, expected, alpha=0.05)
89
+
90
+ # nothing leaves the machine that names a patient
91
+ n, problems = transfer.check(
92
+ ["work/results/out"],
93
+ ids=set(people[u["id"]]),
94
+ strict=True,
95
+ forbidden=transfer.forbidden_columns(cfg),
96
+ )
97
+ ```
98
+
99
+ ```
100
+ E11 incidence 1,258 per 100,000 person-years; women vs all: 1.12 (0.98-1.27)
101
+ aggregate table: 1 file checked, 0 problem(s)
102
+ with a leaked file:
103
+ .../first_cases.csv: contains 3 patient identifier(s)
104
+ .../first_cases.csv: per-person or identifier column(s) ['patient']
105
+ ```
106
+
107
+ ## The transfer check
108
+
109
+ The rule it enforces: no row carrying a patient identifier leaves the machine
110
+ that holds the delivery. It checks the bytes of every file against the
111
+ identifiers the workflow actually read, not against a guessed id format. A
112
+ workflow writes those identifiers with `transfer.write_ids`:
113
+
114
+ - `results/work/ids/universe.parquet`: every id in the people file, written
115
+ before anyone is dropped;
116
+ - `results/work/ids/<source>.parquet`: ids in a diagnosis file but not in the
117
+ people file.
118
+
119
+ Where an id file is missing (a run that died early), the check reads the
120
+ delivery file instead. An unreadable file, an unknown suffix or an empty id set
121
+ all refuse. With `strict=True` (for a results folder copied off wholesale),
122
+ only csv/txt/png pass. CSVs whose header holds a per-person key, one of the
123
+ delivery's id or date columns, or an identifier-like name are refused too.
124
+
125
+ From a workflow directory on the data machine:
126
+
127
+ ```bash
128
+ bpskit pullable --strict results/out # exit 0 only if results/out/ may leave
129
+ bpskit check-logs . # list the logs that may leave
130
+ bpskit synthetic data/synthetic --persons 2000
131
+ ```
132
+
133
+ `pullable` and `check-logs` read the workflow's `config/config.yaml`
134
+ (`--config` to change it). Its `universe` and `sources` sections, in the shape
135
+ `bpskit.synthetic.config()` returns, name the id columns.
136
+
137
+ ## Developing
138
+
139
+ ```bash
140
+ pixi run test # pytest
141
+ pixi run lint # ruff check + format check
142
+ pixi run example # the worked example
143
+ pixi run build # sdist + wheel, twine check
144
+ ```
145
+
146
+ Releasing: bump `__version__` in `src/bpskit/__init__.py`, add the release to
147
+ `CHANGELOG.md`, commit, then tag `vX.Y.Z` and push the tag.
148
+ `.github/workflows/release.yml` checks that the tag matches the version,
149
+ builds, and publishes to PyPI by trusted publishing.
150
+
151
+ ## Citing
152
+
153
+ See [`CITATION.cff`](CITATION.cff). MIT licensed.
@@ -0,0 +1,91 @@
1
+ """bpskit end to end on a synthetic delivery, in a few seconds.
2
+
3
+ Writes a 5,000-person synthetic BPS delivery, measures the incidence of type 2
4
+ diabetes (ICD-10 E11) in primary care in 2018-2025, compares women with the
5
+ whole population, and runs the transfer check on the result table: once on the
6
+ aggregate table (it passes), once on a file that names a patient (it is refused).
7
+
8
+ python examples/quickstart.py [work_dir]
9
+ """
10
+
11
+ import sys
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ from bpskit import transfer
19
+ from bpskit.delivery import normalise_code, parse_date, read_delivery, stream_csv
20
+ from bpskit.icd import expand_spec, prefix_mask
21
+ from bpskit.persontime import DAYS_PER_YEAR, day_of, follow_up, to_days
22
+ from bpskit.stats import poisson_rr
23
+ from bpskit.synthetic import make_delivery
24
+
25
+ work = Path(sys.argv[1] if len(sys.argv) > 1 else tempfile.mkdtemp())
26
+ cfg = make_delivery(work / "delivery", n_persons=5_000)
27
+ root = Path(cfg["delivery_root"])
28
+ sentinels = ["", "-1", "-2", "-9"]
29
+
30
+ # 1. People: sex, and birth and death as days since 1970 (NaN = alive).
31
+ u = cfg["universe"]
32
+ people = read_delivery(root / u["file"], [u["id"], u["sex"], u["birth"], u["death"]])
33
+ is_f = people[u["sex"]].map(u["sex_values"]).eq("F").to_numpy()
34
+ birth = to_days(parse_date(people[u["birth"]], u["date_format"], sentinels, 15))
35
+ death = to_days(parse_date(people[u["death"]], u["date_format"], sentinels, 15))
36
+
37
+ # 2. Each person's first primary-care record of E11, streamed block by block.
38
+ ap = cfg["sources"]["ap"]
39
+ prefixes = expand_spec("E11")
40
+ first: dict[str, float] = {}
41
+ for block in stream_csv(root / ap["file"], [ap["id"], ap["date"], ap["code"]], 16, "utf-8"):
42
+ hit = block[prefix_mask(normalise_code(block[ap["code"]]), prefixes)]
43
+ days = to_days(parse_date(hit[ap["date"]], ap["date_format"], sentinels, 15))
44
+ for pid, d in zip(hit[ap["id"]], days):
45
+ first[pid] = min(d, first.get(pid, np.inf))
46
+ event = people[u["id"]].map(first).to_numpy(float)
47
+
48
+ # 3. Follow-up 2018-2025. A record before the start makes a person prevalent and
49
+ # removes them from the risk set; an incident record ends their follow-up.
50
+ entry, exit_, in_period = follow_up(birth, death, day_of("2018-01-01"), day_of("2025-12-31"))
51
+ at_risk = in_period & ~(event < entry)
52
+ incident = at_risk & (event <= exit_)
53
+ exit_ = np.where(incident, event, exit_)
54
+ years = np.where(at_risk, (exit_ - entry) / DAYS_PER_YEAR, 0.0)
55
+
56
+ # 4. Women against the whole population: observed cases over those expected at
57
+ # the overall rate, with an exact Poisson interval.
58
+ rate = incident.sum() / years.sum()
59
+ obs, exp = incident[is_f].sum(), years[is_f].sum() * rate
60
+ ratio, lo, hi, p = poisson_rr(obs, exp, alpha=0.05)
61
+ table = pd.DataFrame(
62
+ {
63
+ "group": ["women"],
64
+ "cases": [obs],
65
+ "expected": [exp],
66
+ "ratio": [ratio],
67
+ "ratio_lo": [lo],
68
+ "ratio_hi": [hi],
69
+ "p": [p],
70
+ }
71
+ )
72
+ print(
73
+ f"E11 incidence {rate * 1e5:,.0f} per 100,000 person-years; women vs all: "
74
+ f"{float(ratio):.2f} ({float(lo):.2f}-{float(hi):.2f})"
75
+ )
76
+
77
+ # 5. The transfer check: no file leaving the machine may name a patient.
78
+ out = work / "results/out"
79
+ out.mkdir(parents=True, exist_ok=True)
80
+ table.to_csv(out / "e11_incidence.csv", index=False)
81
+ ids = set(people[u["id"]])
82
+ forbidden = transfer.forbidden_columns(cfg)
83
+ n, problems = transfer.check([out], ids, strict=True, forbidden=forbidden)
84
+ print(f"aggregate table: {n} file checked, {len(problems)} problem(s)")
85
+ assert not problems
86
+
87
+ leak = out / "first_cases.csv"
88
+ pd.DataFrame({"patient": list(first)[:3]}).to_csv(leak, index=False)
89
+ n, problems = transfer.check([out], ids, strict=True, forbidden=forbidden)
90
+ print("with a leaked file:", *problems, sep="\n ")
91
+ assert problems and all(str(leak) in p for p in problems)
@@ -0,0 +1,56 @@
1
+ [project]
2
+ name = "bpskit"
3
+ dynamic = ["version"]
4
+ description = "Tools for studies on a BPS (Base Poblacional de Salud) health-records delivery, including an identifier check for outputs leaving a secure data environment"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "Carlos Loucera", email = "clou@us.es" }]
9
+ requires-python = ">=3.11"
10
+ keywords = ["epidemiology", "electronic health records", "ICD-10", "ICD-9", "person-time",
11
+ "trusted research environment", "disclosure control", "snakemake"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Science/Research",
15
+ "Operating System :: POSIX :: Linux",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "numpy>=2",
24
+ "pandas>=2.2",
25
+ "scipy>=1.11",
26
+ "pyarrow>=15",
27
+ "pyyaml>=6",
28
+ "icd-mappings>=0.6.2,<0.7",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/loucerac/bpskit"
33
+ Changelog = "https://github.com/loucerac/bpskit/blob/main/CHANGELOG.md"
34
+ Issues = "https://github.com/loucerac/bpskit/issues"
35
+
36
+ [project.scripts]
37
+ bpskit = "bpskit.cli:main"
38
+
39
+ [build-system]
40
+ requires = ["hatchling>=1.26"]
41
+ build-backend = "hatchling.build"
42
+
43
+ [tool.hatch.version]
44
+ path = "src/bpskit/__init__.py"
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+
49
+ [tool.ruff]
50
+ line-length = 100
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E9", "E501", "F", "I"]
54
+
55
+ [tool.hatch.build.targets.sdist]
56
+ include = ["src", "tests", "examples", "README.md", "CHANGELOG.md", "CITATION.cff", "LICENSE"]
@@ -0,0 +1,14 @@
1
+ """Shared tools for studies on a BPS (Base Poblacional de Salud, SAS) delivery.
2
+
3
+ bpskit.delivery read the pipe-delimited delivery: sentinels, dates, streaming
4
+ bpskit.icd ICD-10 code specs and chapters, ICD-9 -> ICD-10 mapping (CMS GEM)
5
+ bpskit.persontime dates as numbers, age bands, Lexis splitting, person streaming
6
+ bpskit.stats exact Poisson and binomial tests, BH / Bonferroni
7
+ bpskit.transfer the check that no file leaving the data machine names a patient
8
+ bpskit.synthetic a synthetic BPS delivery with planted structure, for tests
9
+
10
+ The command line (`bpskit pullable`, `bpskit check-logs`) runs the transfer
11
+ check on the data machine.
12
+ """
13
+
14
+ __version__ = "0.2.0"
@@ -0,0 +1,102 @@
1
+ """The transfer checks, run on the data machine before anything is copied off it.
2
+
3
+ bpskit pullable [--strict] <path> ... exit 0 only if no file names a patient
4
+ bpskit check-logs [workflow_dir] list the logs that may leave
5
+ bpskit synthetic <out_dir> write a synthetic delivery (bpskit.synthetic)
6
+
7
+ pullable and check-logs run from the workflow directory and read its config
8
+ (default config/config.yaml) for the delivery's id columns and the id files
9
+ under results/work/ids/.
10
+ """
11
+
12
+ import argparse
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ from bpskit import transfer
17
+ from bpskit.delivery import load_config
18
+
19
+
20
+ def pullable(args: argparse.Namespace) -> int:
21
+ root = Path.cwd()
22
+ cfg = load_config(root / args.config)
23
+ ids = transfer.ids_for_root(root, cfg)
24
+ n, problems = transfer.check(args.paths, ids, args.strict, transfer.forbidden_columns(cfg))
25
+ print(f"[CHECK] {n} file(s), {len(problems)} problem(s)")
26
+ for p in problems:
27
+ print(f"[REFUSE] {p}")
28
+ if problems:
29
+ print("No transfer: these files stay on this machine.")
30
+ return 1
31
+ print("[PASS ] no file names a patient")
32
+ return 0
33
+
34
+
35
+ def check_logs(args: argparse.Namespace) -> int:
36
+ """Write the clean logs to results/work/logs_pullable.txt (which sync.sh copies).
37
+
38
+ A traceback can print a data row, so a log containing an id is WITHHELD,
39
+ not redacted. The list is emptied first, so any failure leaves nothing listed.
40
+ """
41
+ root = Path(args.workflow_dir).resolve()
42
+ out = root / "results/work/logs_pullable.txt"
43
+ out.parent.mkdir(parents=True, exist_ok=True)
44
+ out.write_text("")
45
+ ids = transfer.ids_for_root(root, load_config(root / args.config))
46
+ candidates = sorted(
47
+ list((root / "results/work/logs").rglob("*.log"))
48
+ + list((root / ".snakemake/log").glob("*.snakemake.log"))
49
+ + [p for p in [root / "results_run.txt"] if p.exists()]
50
+ )
51
+ clean, withheld = [], []
52
+ for f in candidates:
53
+ try:
54
+ why = transfer.check_file(f, ids, strict=False, forbidden=set())
55
+ except Exception as exc: # noqa: BLE001 -- fail closed: any error withholds
56
+ why = [f"could not be checked: {exc}"]
57
+ (withheld if why else clean).append((f, why))
58
+ out.write_text("".join(f"{f.relative_to(root).as_posix()}\n" for f, _ in clean))
59
+ for f, why in withheld:
60
+ print(f"WITHHELD ({'; '.join(why)}): {f.relative_to(root)}")
61
+ print(f"{len(clean)} of {len(candidates)} logs cleared")
62
+ return 0
63
+
64
+
65
+ def synthetic(args: argparse.Namespace) -> int:
66
+ from bpskit.synthetic import make_delivery
67
+
68
+ make_delivery(args.out_dir, args.persons, args.seed)
69
+ print(f"{args.persons:,} synthetic persons -> {args.out_dir}")
70
+ return 0
71
+
72
+
73
+ def main(argv: list[str] | None = None) -> int:
74
+ parser = argparse.ArgumentParser(prog="bpskit", description=__doc__.split("\n")[0])
75
+ parser.add_argument(
76
+ "--config",
77
+ default="config/config.yaml",
78
+ help="workflow config, relative to the workflow directory",
79
+ )
80
+ sub = parser.add_subparsers(dest="command", required=True)
81
+ p = sub.add_parser("pullable", help="refuse files that name a patient")
82
+ p.add_argument(
83
+ "--strict",
84
+ action="store_true",
85
+ help="results/out/ rules: csv/txt/png only, no per-person columns",
86
+ )
87
+ p.add_argument("paths", nargs="+")
88
+ p.set_defaults(func=pullable)
89
+ c = sub.add_parser("check-logs", help="list the logs free of patient ids")
90
+ c.add_argument("workflow_dir", nargs="?", default=".")
91
+ c.set_defaults(func=check_logs)
92
+ s = sub.add_parser("synthetic", help="write a synthetic BPS delivery")
93
+ s.add_argument("out_dir")
94
+ s.add_argument("--persons", type=int, default=50_000)
95
+ s.add_argument("--seed", type=int, default=1)
96
+ s.set_defaults(func=synthetic)
97
+ args = parser.parse_args(argv)
98
+ return args.func(args)
99
+
100
+
101
+ if __name__ == "__main__":
102
+ sys.exit(main())