open-pharma-plugins 2.2.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.
- mcp_framework.py +495 -0
- open_pharma_plugins-2.2.0.dist-info/METADATA +135 -0
- open_pharma_plugins-2.2.0.dist-info/RECORD +136 -0
- open_pharma_plugins-2.2.0.dist-info/WHEEL +5 -0
- open_pharma_plugins-2.2.0.dist-info/entry_points.txt +8 -0
- open_pharma_plugins-2.2.0.dist-info/licenses/LICENSE +202 -0
- open_pharma_plugins-2.2.0.dist-info/top_level.txt +8 -0
- open_pharma_plugins_campaign_studio/__init__.py +14 -0
- open_pharma_plugins_campaign_studio/__main__.py +11 -0
- open_pharma_plugins_campaign_studio/_campaign_store.py +162 -0
- open_pharma_plugins_campaign_studio/_claim_engine.py +262 -0
- open_pharma_plugins_campaign_studio/_renderer.py +119 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/legal.json +21 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/logo.svg +4 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/palette.json +11 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/product.png +1 -0
- open_pharma_plugins_campaign_studio/fixtures/brand_kit/typography.json +14 -0
- open_pharma_plugins_campaign_studio/fixtures/sample_approved_claims.json +119 -0
- open_pharma_plugins_campaign_studio/models/__init__.py +34 -0
- open_pharma_plugins_campaign_studio/models/_common.py +12 -0
- open_pharma_plugins_campaign_studio/models/brief.py +72 -0
- open_pharma_plugins_campaign_studio/models/claims.py +15 -0
- open_pharma_plugins_campaign_studio/models/copy.py +47 -0
- open_pharma_plugins_campaign_studio/models/journey.py +21 -0
- open_pharma_plugins_campaign_studio/models/message.py +25 -0
- open_pharma_plugins_campaign_studio/models/mlr.py +29 -0
- open_pharma_plugins_campaign_studio/models/validation.py +32 -0
- open_pharma_plugins_campaign_studio/policy/rules.json +79 -0
- open_pharma_plugins_campaign_studio/templates/banner.svg.j2 +26 -0
- open_pharma_plugins_campaign_studio/templates/email.html.j2 +54 -0
- open_pharma_plugins_campaign_studio/tools/__init__.py +0 -0
- open_pharma_plugins_campaign_studio/tools/create_campaign_brief.py +212 -0
- open_pharma_plugins_campaign_studio/tools/generate_audience_journey.py +129 -0
- open_pharma_plugins_campaign_studio/tools/generate_channel_copy.py +199 -0
- open_pharma_plugins_campaign_studio/tools/generate_message_architecture.py +121 -0
- open_pharma_plugins_campaign_studio/tools/package_mlr_submission.py +230 -0
- open_pharma_plugins_campaign_studio/tools/render_banner.py +99 -0
- open_pharma_plugins_campaign_studio/tools/render_email.py +101 -0
- open_pharma_plugins_campaign_studio/tools/render_poster.py +222 -0
- open_pharma_plugins_campaign_studio/tools/retrieve_approved_claims.py +71 -0
- open_pharma_plugins_campaign_studio/tools/retrieve_brand_components.py +76 -0
- open_pharma_plugins_campaign_studio/tools/validate_claims_and_fair_balance.py +285 -0
- open_pharma_plugins_competitive_intelligence/__init__.py +13 -0
- open_pharma_plugins_competitive_intelligence/__main__.py +11 -0
- open_pharma_plugins_competitive_intelligence/_artifacts.py +87 -0
- open_pharma_plugins_competitive_intelligence/_cache.py +144 -0
- open_pharma_plugins_competitive_intelligence/_clinical_trials.py +569 -0
- open_pharma_plugins_competitive_intelligence/_dailymed.py +260 -0
- open_pharma_plugins_competitive_intelligence/_fda.py +255 -0
- open_pharma_plugins_competitive_intelligence/_pubmed.py +342 -0
- open_pharma_plugins_competitive_intelligence/_regulatory.py +140 -0
- open_pharma_plugins_competitive_intelligence/_runs.py +278 -0
- open_pharma_plugins_competitive_intelligence/_transport.py +83 -0
- open_pharma_plugins_competitive_intelligence/_watchlist.py +113 -0
- open_pharma_plugins_competitive_intelligence/_web_search.py +331 -0
- open_pharma_plugins_competitive_intelligence/models.py +525 -0
- open_pharma_plugins_competitive_intelligence/tools/__init__.py +0 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_extract_events.py +247 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_landscape.py +195 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_refresh.py +101 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_report.py +402 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_news.py +48 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_publications.py +46 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_regulatory.py +64 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_scan_trials.py +85 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_status.py +106 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_timeline.py +453 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_track.py +121 -0
- open_pharma_plugins_competitive_intelligence/tools/ci_trial_detail.py +55 -0
- open_pharma_plugins_field_training/__init__.py +13 -0
- open_pharma_plugins_field_training/__main__.py +11 -0
- open_pharma_plugins_field_training/_content_store.py +131 -0
- open_pharma_plugins_field_training/_grounding.py +75 -0
- open_pharma_plugins_field_training/_html_renderers.py +546 -0
- open_pharma_plugins_field_training/fixtures/sample_product_message.pdf +156 -0
- open_pharma_plugins_field_training/fixtures/sample_training_deck.pptx +0 -0
- open_pharma_plugins_field_training/models.py +265 -0
- open_pharma_plugins_field_training/tools/__init__.py +0 -0
- open_pharma_plugins_field_training/tools/get_document_page.py +67 -0
- open_pharma_plugins_field_training/tools/ingest_document.py +147 -0
- open_pharma_plugins_field_training/tools/list_documents.py +51 -0
- open_pharma_plugins_field_training/tools/render_output.py +118 -0
- open_pharma_plugins_field_training/tools/search_content.py +57 -0
- open_pharma_plugins_hcp_intelligence/__init__.py +14 -0
- open_pharma_plugins_hcp_intelligence/__main__.py +11 -0
- open_pharma_plugins_hcp_intelligence/_crm_store.py +70 -0
- open_pharma_plugins_hcp_intelligence/batch.py +891 -0
- open_pharma_plugins_hcp_intelligence/batch_cli.py +221 -0
- open_pharma_plugins_hcp_intelligence/batch_csv.py +206 -0
- open_pharma_plugins_hcp_intelligence/fixtures/sample_accounts.csv +27 -0
- open_pharma_plugins_hcp_intelligence/models.py +356 -0
- open_pharma_plugins_hcp_intelligence/tools/__init__.py +0 -0
- open_pharma_plugins_hcp_intelligence/tools/get_account.py +48 -0
- open_pharma_plugins_hcp_intelligence/tools/list_accounts.py +66 -0
- open_pharma_plugins_hcp_intelligence/tools/search_clinical_trials.py +175 -0
- open_pharma_plugins_hcp_intelligence/tools/search_congresses.py +134 -0
- open_pharma_plugins_hcp_intelligence/tools/search_grants.py +181 -0
- open_pharma_plugins_hcp_intelligence/tools/search_guidelines.py +238 -0
- open_pharma_plugins_hcp_intelligence/tools/search_hco_web.py +73 -0
- open_pharma_plugins_hcp_intelligence/tools/search_hcp_web.py +199 -0
- open_pharma_plugins_hcp_intelligence/tools/search_orcid.py +214 -0
- open_pharma_plugins_hcp_intelligence/tools/search_publications.py +207 -0
- open_pharma_plugins_hcp_intelligence/tools/update_account.py +84 -0
- open_pharma_plugins_next_best_engagement/__init__.py +14 -0
- open_pharma_plugins_next_best_engagement/__main__.py +11 -0
- open_pharma_plugins_next_best_engagement/_optimizer.py +400 -0
- open_pharma_plugins_next_best_engagement/_renderer.py +149 -0
- open_pharma_plugins_next_best_engagement/_scoring.py +82 -0
- open_pharma_plugins_next_best_engagement/_universe.py +145 -0
- open_pharma_plugins_next_best_engagement/fixtures/sample_universe.csv +81 -0
- open_pharma_plugins_next_best_engagement/models.py +135 -0
- open_pharma_plugins_next_best_engagement/tools/__init__.py +0 -0
- open_pharma_plugins_next_best_engagement/tools/load_universe.py +47 -0
- open_pharma_plugins_next_best_engagement/tools/recommend_engagements.py +80 -0
- open_pharma_plugins_next_best_engagement/tools/render_plan.py +90 -0
- open_pharma_plugins_territory_alignment/__init__.py +14 -0
- open_pharma_plugins_territory_alignment/__main__.py +11 -0
- open_pharma_plugins_territory_alignment/data.py +300 -0
- open_pharma_plugins_territory_alignment/fixtures/constraints.csv +11 -0
- open_pharma_plugins_territory_alignment/fixtures/current_alignment.csv +81 -0
- open_pharma_plugins_territory_alignment/fixtures/hcps.csv +81 -0
- open_pharma_plugins_territory_alignment/fixtures/reps.csv +9 -0
- open_pharma_plugins_territory_alignment/geo.py +175 -0
- open_pharma_plugins_territory_alignment/models.py +201 -0
- open_pharma_plugins_territory_alignment/scoring.py +125 -0
- open_pharma_plugins_territory_alignment/solver.py +504 -0
- open_pharma_plugins_territory_alignment/tools/__init__.py +0 -0
- open_pharma_plugins_territory_alignment/tools/ta_align.py +138 -0
- open_pharma_plugins_territory_alignment/tools/ta_cluster.py +247 -0
- open_pharma_plugins_territory_alignment/tools/ta_compare.py +173 -0
- open_pharma_plugins_territory_alignment/tools/ta_evaluate.py +119 -0
- open_pharma_plugins_territory_alignment/tools/ta_status.py +34 -0
- open_pharma_plugins_territory_alignment/tools/ta_visualize.py +504 -0
- shared/__init__.py +11 -0
- shared/env.py +217 -0
- shared/filesystem.py +110 -0
shared/env.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Environment access for the shared library and every capability.
|
|
2
|
+
|
|
3
|
+
Import `get_env` (call-time accessor for any env var) instead of calling os.getenv across the
|
|
4
|
+
codebase, so this stays the one place that reads the environment. Capability-private env vars
|
|
5
|
+
keep their own defaults in the capability that owns them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from collections.abc import Iterable
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_env(name: str, default: str | None = None) -> str | None:
|
|
15
|
+
"""Env config, read at CALL time. Precedence: environment > user config file > default."""
|
|
16
|
+
val = os.environ.get(name)
|
|
17
|
+
return val if val is not None else _config().get(name, default)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── User config file (~/.open-pharma-plugins/config): KEY=VALUE lines, read when a var isn't in the
|
|
21
|
+
# environment. Location is fixed (not per-OS like cache_dir): "where is the config" can't live in
|
|
22
|
+
# the config, and pointing at it via env var would reintroduce the inheritance problem it solves. ──
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def config_dir() -> str:
|
|
26
|
+
"""Fixed config dir (~/.open-pharma-plugins), overridable via OPEN_PHARMA_CONFIG_DIR."""
|
|
27
|
+
return os.path.expanduser(os.environ.get("OPEN_PHARMA_CONFIG_DIR") or "~/.open-pharma-plugins")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def config_file() -> str:
|
|
31
|
+
"""Config file path, overridable via OPEN_PHARMA_CONFIG (full path)."""
|
|
32
|
+
override = os.environ.get("OPEN_PHARMA_CONFIG")
|
|
33
|
+
return os.path.expanduser(override) if override else os.path.join(config_dir(), "config")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parse_config(text: str) -> dict[str, str]:
|
|
37
|
+
"""Minimal dotenv parse: KEY=VALUE per line; skip blank/# lines; strip `export ` and quotes.
|
|
38
|
+
Stdlib-only on purpose — the package floor is 3.10, so tomllib (3.11+) isn't guaranteed."""
|
|
39
|
+
out: dict[str, str] = {}
|
|
40
|
+
for line in text.splitlines():
|
|
41
|
+
line = line.strip().removeprefix("export ").lstrip()
|
|
42
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
43
|
+
continue
|
|
44
|
+
key, _, val = line.partition("=")
|
|
45
|
+
val = val.strip()
|
|
46
|
+
if len(val) >= 2 and val[0] == val[-1] and val[0] in "'\"":
|
|
47
|
+
val = val[1:-1]
|
|
48
|
+
if key.strip():
|
|
49
|
+
out[key.strip()] = val
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_config_cache: dict[str, str] | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _config() -> dict[str, str]:
|
|
57
|
+
"""Parsed config, loaded once and cached (empty if missing/unreadable)."""
|
|
58
|
+
global _config_cache
|
|
59
|
+
if _config_cache is None:
|
|
60
|
+
try:
|
|
61
|
+
with open(config_file(), encoding="utf-8") as f:
|
|
62
|
+
_config_cache = _parse_config(f.read())
|
|
63
|
+
except (OSError, UnicodeDecodeError):
|
|
64
|
+
_config_cache = {}
|
|
65
|
+
return _config_cache
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_CONFIG_HEADER = "# open-pharma-plugins config — KEY=VALUE per line, read when the var isn't in the environment.\n\n"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _write_config(path: str, merged: dict[str, str]) -> None:
|
|
72
|
+
"""Atomically write `merged` as a sorted dotenv file (0600) and invalidate the cache."""
|
|
73
|
+
global _config_cache
|
|
74
|
+
from shared.filesystem import atomic_write_text, ensure_private_dir
|
|
75
|
+
|
|
76
|
+
ensure_private_dir(os.path.dirname(path))
|
|
77
|
+
content = _CONFIG_HEADER + "".join(f"{key}={merged[key]}\n" for key in sorted(merged))
|
|
78
|
+
atomic_write_text(path, content)
|
|
79
|
+
_config_cache = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_config(values: dict[str, str | None]) -> str:
|
|
83
|
+
"""Merge non-None `values` into the config file (atomic, 0600) and return its path."""
|
|
84
|
+
path = config_file()
|
|
85
|
+
try:
|
|
86
|
+
with open(path, encoding="utf-8") as f:
|
|
87
|
+
merged = _parse_config(f.read())
|
|
88
|
+
except OSError:
|
|
89
|
+
merged = {}
|
|
90
|
+
merged.update({k: v for k, v in values.items() if v is not None})
|
|
91
|
+
_write_config(path, merged)
|
|
92
|
+
return path
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def del_config(keys: Iterable[str]) -> str:
|
|
96
|
+
"""Remove `keys` from the config file (atomic, 0600) and return its path."""
|
|
97
|
+
path = config_file()
|
|
98
|
+
try:
|
|
99
|
+
with open(path, encoding="utf-8") as f:
|
|
100
|
+
merged = _parse_config(f.read())
|
|
101
|
+
except OSError:
|
|
102
|
+
return path
|
|
103
|
+
present = [k for k in keys if k in merged]
|
|
104
|
+
if not present:
|
|
105
|
+
return path
|
|
106
|
+
for k in present:
|
|
107
|
+
del merged[k]
|
|
108
|
+
_write_config(path, merged)
|
|
109
|
+
return path
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Config-field catalog: the ONE declarative list of user-settable config vars, driving the
|
|
113
|
+
# interactive `--setup` (grouped) and documenting what belongs in the config file. install.sh mirrors
|
|
114
|
+
# this list (CONFIG_SPEC) for its own editor — keep the two in sync when adding a var. ──
|
|
115
|
+
CONFIG_FIELDS: list[tuple[str, bool, str, str, str]] = [
|
|
116
|
+
# Web Search (used by hcp-intelligence and competitive-intelligence)
|
|
117
|
+
(
|
|
118
|
+
"OPEN_PHARMA_SEARCH_BACKEND",
|
|
119
|
+
False,
|
|
120
|
+
"Web Search",
|
|
121
|
+
"auto",
|
|
122
|
+
"text search backend (auto: serper > tavily > exa; or choose one)",
|
|
123
|
+
),
|
|
124
|
+
("SERPER_API_KEY", True, "Web Search", "", "Serper web search API key"),
|
|
125
|
+
("TAVILY_API_KEY", True, "Web Search", "", "Tavily web search API key"),
|
|
126
|
+
("EXA_API_KEY", True, "Web Search", "", "Exa web search API key"),
|
|
127
|
+
# HCP Intelligence
|
|
128
|
+
(
|
|
129
|
+
"OPENROUTER_API_KEY",
|
|
130
|
+
True,
|
|
131
|
+
"HCP Intelligence",
|
|
132
|
+
"",
|
|
133
|
+
"OpenRouter API key for optional batch profile synthesis",
|
|
134
|
+
),
|
|
135
|
+
(
|
|
136
|
+
"OPENROUTER_BASE_URL",
|
|
137
|
+
False,
|
|
138
|
+
"HCP Intelligence",
|
|
139
|
+
"https://openrouter.ai/api/v1",
|
|
140
|
+
"OpenRouter-compatible API base URL for optional batch profile synthesis",
|
|
141
|
+
),
|
|
142
|
+
(
|
|
143
|
+
"NCBI_API_KEY",
|
|
144
|
+
False,
|
|
145
|
+
"HCP Intelligence",
|
|
146
|
+
"",
|
|
147
|
+
"NCBI E-utilities API key (optional; raises PubMed rate limit from 3 to 10 req/s)",
|
|
148
|
+
),
|
|
149
|
+
(
|
|
150
|
+
"OPEN_PHARMA_HCP_DATA_DIR",
|
|
151
|
+
False,
|
|
152
|
+
"HCP Intelligence",
|
|
153
|
+
"",
|
|
154
|
+
"mutable enrichment data directory (default: ~/.open-pharma-plugins/hcp-intelligence)",
|
|
155
|
+
),
|
|
156
|
+
# Field Training
|
|
157
|
+
(
|
|
158
|
+
"OPEN_PHARMA_TRAINING_CONTENT_DIR",
|
|
159
|
+
False,
|
|
160
|
+
"Field Training",
|
|
161
|
+
"",
|
|
162
|
+
"content store directory for ingested training documents (default: ~/.open-pharma-plugins/training-content)",
|
|
163
|
+
),
|
|
164
|
+
# Campaign Studio
|
|
165
|
+
(
|
|
166
|
+
"OPEN_PHARMA_CAMPAIGN_STORE_DIR",
|
|
167
|
+
False,
|
|
168
|
+
"Campaign Studio",
|
|
169
|
+
"",
|
|
170
|
+
"root directory for campaign briefs, claims, and rendered assets (default: ~/.open-pharma-plugins/campaign-studio)",
|
|
171
|
+
),
|
|
172
|
+
# Next Best Engagement
|
|
173
|
+
(
|
|
174
|
+
"OPEN_PHARMA_NBE_OUTPUT_DIR",
|
|
175
|
+
False,
|
|
176
|
+
"Next Best Engagement",
|
|
177
|
+
"",
|
|
178
|
+
"directory for exported engagement plans (default: ~/.open-pharma-plugins/next-best-engagement)",
|
|
179
|
+
),
|
|
180
|
+
# Territory Alignment
|
|
181
|
+
(
|
|
182
|
+
"OPEN_PHARMA_TA_DATA_DIR",
|
|
183
|
+
False,
|
|
184
|
+
"Territory Alignment",
|
|
185
|
+
"",
|
|
186
|
+
"directory containing hcps.csv, reps.csv, current_alignment.csv, constraints.csv (default: built-in fixtures)",
|
|
187
|
+
),
|
|
188
|
+
(
|
|
189
|
+
"OPEN_PHARMA_TA_SCENARIOS_DIR",
|
|
190
|
+
False,
|
|
191
|
+
"Territory Alignment",
|
|
192
|
+
"",
|
|
193
|
+
"directory for saved alignment scenarios (default: ~/.open-pharma-plugins/territory-alignment/scenarios)",
|
|
194
|
+
),
|
|
195
|
+
# Competitive Intelligence
|
|
196
|
+
(
|
|
197
|
+
"OPEN_PHARMA_CI_DATA_DIR",
|
|
198
|
+
False,
|
|
199
|
+
"Competitive Intelligence",
|
|
200
|
+
"",
|
|
201
|
+
"watchlist, report, and cache directory (default: ~/.open-pharma-plugins/competitive-intelligence)",
|
|
202
|
+
),
|
|
203
|
+
(
|
|
204
|
+
"OPENFDA_API_KEY",
|
|
205
|
+
False,
|
|
206
|
+
"Competitive Intelligence",
|
|
207
|
+
"",
|
|
208
|
+
"openFDA API key (optional; raises rate limit from 40 to 240 req/min)",
|
|
209
|
+
),
|
|
210
|
+
(
|
|
211
|
+
"CI_CACHE_TTL_HOURS",
|
|
212
|
+
False,
|
|
213
|
+
"Competitive Intelligence",
|
|
214
|
+
"24",
|
|
215
|
+
"hours before cached API responses expire (default: 24)",
|
|
216
|
+
),
|
|
217
|
+
]
|
shared/filesystem.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Private, traversal-safe filesystem primitives for capability runtime data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
11
|
+
|
|
12
|
+
_SENSITIVE_FRAGMENTS = ("api_key", "apikey", "access_token", "auth_token", "authorization")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def ensure_private_dir(path: str | Path) -> Path:
|
|
16
|
+
"""Create a directory and restrict it to the current user where supported."""
|
|
17
|
+
directory = Path(path).expanduser()
|
|
18
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
if os.name != "nt":
|
|
20
|
+
directory.chmod(0o700)
|
|
21
|
+
return directory
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_component(value: str, *, label: str = "path component") -> str:
|
|
25
|
+
"""Accept one non-special path component, never a path fragment."""
|
|
26
|
+
if not isinstance(value, str) or not value or value in {".", ".."}:
|
|
27
|
+
raise ValueError(f"{label} must be a non-empty file-name component")
|
|
28
|
+
if "\x00" in value or "/" in value or "\\" in value or Path(value).name != value:
|
|
29
|
+
raise ValueError(f"{label} must not contain path separators")
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def contained_path(root: str | Path, *components: str) -> Path:
|
|
34
|
+
"""Build a path from validated components and prove it remains under root."""
|
|
35
|
+
base = Path(root).expanduser().resolve()
|
|
36
|
+
clean = [validate_component(component) for component in components]
|
|
37
|
+
candidate = base.joinpath(*clean).resolve()
|
|
38
|
+
if not candidate.is_relative_to(base):
|
|
39
|
+
raise ValueError("path escapes its configured root")
|
|
40
|
+
return candidate
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def atomic_write_text(path: str | Path, content: str, *, encoding: str = "utf-8") -> Path:
|
|
44
|
+
return _atomic_write(Path(path), content.encode(encoding))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def atomic_write_bytes(path: str | Path, content: bytes) -> Path:
|
|
48
|
+
return _atomic_write(Path(path), content)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def atomic_write_json(path: str | Path, data: Any) -> Path:
|
|
52
|
+
return atomic_write_text(path, json.dumps(data, indent=2, ensure_ascii=False, default=str))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _atomic_write(path: Path, content: bytes) -> Path:
|
|
56
|
+
parent = ensure_private_dir(path.expanduser().parent)
|
|
57
|
+
destination = parent / path.name
|
|
58
|
+
fd, temporary = tempfile.mkstemp(prefix=f".{destination.name}.", dir=parent)
|
|
59
|
+
temporary_path = Path(temporary)
|
|
60
|
+
try:
|
|
61
|
+
if os.name != "nt":
|
|
62
|
+
os.fchmod(fd, 0o600)
|
|
63
|
+
with os.fdopen(fd, "wb") as handle:
|
|
64
|
+
handle.write(content)
|
|
65
|
+
handle.flush()
|
|
66
|
+
os.fsync(handle.fileno())
|
|
67
|
+
os.replace(temporary_path, destination)
|
|
68
|
+
if os.name != "nt":
|
|
69
|
+
destination.chmod(0o600)
|
|
70
|
+
finally:
|
|
71
|
+
temporary_path.unlink(missing_ok=True)
|
|
72
|
+
return destination
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sanitize_url(url: str) -> str:
|
|
76
|
+
"""Return a URL with credential-like query parameters removed."""
|
|
77
|
+
try:
|
|
78
|
+
parsed = urlsplit(url)
|
|
79
|
+
query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if not _sensitive(key)]
|
|
80
|
+
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment))
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
return "redacted-source"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sanitize_mapping(value: Any) -> Any:
|
|
86
|
+
"""Recursively remove credential-bearing mapping entries before persistence."""
|
|
87
|
+
if isinstance(value, dict):
|
|
88
|
+
return {key: sanitize_mapping(item) for key, item in value.items() if not _sensitive(str(key))}
|
|
89
|
+
if isinstance(value, list):
|
|
90
|
+
return [sanitize_mapping(item) for item in value]
|
|
91
|
+
if isinstance(value, tuple):
|
|
92
|
+
return [sanitize_mapping(item) for item in value]
|
|
93
|
+
return value
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def json_for_html_script(value: Any) -> str:
|
|
97
|
+
"""Serialize data for a script block without allowing an HTML end-tag breakout."""
|
|
98
|
+
return (
|
|
99
|
+
json.dumps(value, ensure_ascii=False, default=str)
|
|
100
|
+
.replace("&", "\\u0026")
|
|
101
|
+
.replace("<", "\\u003c")
|
|
102
|
+
.replace(">", "\\u003e")
|
|
103
|
+
.replace("\u2028", "\\u2028")
|
|
104
|
+
.replace("\u2029", "\\u2029")
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _sensitive(key: str) -> bool:
|
|
109
|
+
normalized = key.lower().replace("-", "_")
|
|
110
|
+
return any(fragment in normalized for fragment in _SENSITIVE_FRAGMENTS)
|