Skip to content

Commit fc9d8cf

Browse files
mwebbersclaude
andcommitted
Add shared flag + prefix-required routine-own keys (F-001/F-006/F-007)
Env helpers gain shared: bool = False. With a prefix set, the unprefixed fallback now applies only when shared=True; shared=False (default) reads only <prefix>_<key>, so routine-own knobs cannot leak between sibling routines in a shared environment. A one-time stderr warning fires when a prefix-required lookup misses but the unprefixed form is set. The no-prefix standalone path is unchanged. BREAKING behaviour change -> 0.3.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b4f1514 commit fc9d8cf

5 files changed

Lines changed: 215 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,27 @@ adheres to semantic versioning.
88

99
## [Unreleased]
1010

11+
## [0.3.0] - 2026-06-01
12+
13+
### Changed
14+
- **[F-001]****BREAKING (behaviour).** The env helpers (`env_required`,
15+
`env_opt`/`env_get`, `env_int`, `env_float`) gain a `shared: bool = False`
16+
keyword. With a prefix set, the unprefixed fallback now applies **only** when
17+
`shared=True`; with `shared=False` (the new default) a prefixed lookup reads
18+
**only** `<prefix>_<key>`. The no-prefix (standalone) path is unchanged. Mark
19+
family-shared credentials/infra (`WC_*`, `DROPBOX_*`) with `shared=True`;
20+
routine-own knobs stay prefix-only so they cannot leak between sibling routines
21+
sharing one environment. Consumers roll forward by bumping the pin and marking
22+
their shared keys.
23+
24+
### Added
25+
- **[F-007]** Prefix-required routine-own keys with a one-time migration warning.
26+
When a prefix-required lookup (`shared=False`) misses but the unprefixed form
27+
**is** set, the helpers emit a one-time `WARNING:` to stderr naming both forms
28+
(the stray value is ignored), so an old-style unprefixed value surfaces loudly
29+
instead of silently reverting to the default. The numeric helpers (F-006) honour
30+
the same flag and warning.
31+
1132
## [0.2.0] - 2026-06-01
1233

1334
### Added

SCOPE.md

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,15 @@ their vendor-specific parts.
3030
Each feature is testable. The ID in brackets is referenced by tests via
3131
`@pytest.mark.feature("F-00X")`.
3232

33-
- **[F-001] Environment helpers with project-prefix fallback.** `env_required(key,
34-
*, prefix="")` and `env_opt(key, default, *, prefix="")` — with `env_get` as a
35-
convenience alias of `env_opt` — resolve a variable by trying `<prefix>_<key>`
36-
first and falling back to the unprefixed `<key>` (plain `<key>` with no prefix,
37-
backward compatible). `env_required` aborts with a clear `SystemExit` naming the
38-
variable when neither form is set; `env_opt`/`env_get` return `default`. This
39-
lets a family of routines share one environment: shared values set once
40-
unprefixed, per-routine values set prefixed so they never collide.
33+
- **[F-001] Environment helpers with project prefix.** `env_required(key, *,
34+
prefix="", shared=False)` and `env_opt(key, default, *, prefix="", shared=False)`
35+
— with `env_get` as a convenience alias of `env_opt` — resolve a variable using
36+
the project-prefix convention. With **no prefix** it is a plain `<key>` lookup
37+
(backward compatible). With a prefix and `shared=True` it tries `<prefix>_<key>`
38+
first and falls back to the unprefixed `<key>` (for family-shared
39+
credentials/infra). With a prefix and `shared=False` (default) it reads **only**
40+
`<prefix>_<key>` — see F-007. `env_required` aborts with a clear `SystemExit`
41+
naming the variable when it is not set; `env_opt`/`env_get` return `default`.
4142

4243
- **[F-002] Tolerant number parsing.** `parse_num()` accepts comma **thousands**
4344
separators (`"1,234"``1234.0`), plain numbers and numeric strings, and
@@ -63,15 +64,27 @@ Each feature is testable. The ID in brackets is referenced by tests via
6364
line to stdout — the shared run-log format the routines use.
6465

6566
- **[F-006] Numeric environment helpers with clear errors.** `env_int(key,
66-
default=None, *, prefix="")` and `env_float(key, default=None, *, prefix="")`
67-
resolve a variable via the same project-prefix-with-fallback lookup as `env_opt`,
67+
default=None, *, prefix="", shared=False)` and `env_float(key, default=None, *,
68+
prefix="", shared=False)` resolve a variable via the same project-prefix lookup
69+
(incl. the `shared` flag, F-001/F-007) as `env_opt`,
6870
return `default` when unset, and parse the value as an `int`/`float`. When the
6971
value is **set but malformed**, they abort with a clear `SystemExit` naming the
7072
variable (`Invalid <KEY>='<value>': expected an integer.` / `… a number.`)
7173
instead of raising an uncaught `ValueError` mid-run — so a typo in a numeric knob
7274
fails a routine's `--dry-run` config validation cleanly. Replaces the per-routine
7375
`_int`/`_float` helpers the WooCommerce family each carried (review Step 9).
7476

77+
- **[F-007] Prefix-required routine-own keys with a migration warning.** With a
78+
prefix set and `shared=False` (the default), the `env_*` helpers read **only**
79+
`<prefix>_<key>`; the unprefixed form is not read, so a routine-own knob set
80+
unprefixed in an environment shared with sibling routines cannot leak between
81+
them. `shared=True` restores the unprefixed fallback for family-shared
82+
credentials/infra (e.g. `WC_*`, `DROPBOX_*`). When a prefix-required lookup misses
83+
but the unprefixed form **is** set, the helpers emit a one-time (`per (prefix,
84+
key)` per process) `WARNING:` to **stderr** naming both forms — telling the
85+
operator the stray value is being ignored and to rename it. The numeric helpers
86+
(F-006) honour the same flag and warning.
87+
7588
## Out of scope
7689

7790
Anything vendor-specific (a WooCommerce/Shopify/… client, shop meta keys);

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "claude-code-commons"
7-
version = "0.2.0"
7+
version = "0.3.0"
88
description = "Vendor-agnostic, standard-library-only helpers (env lookup, tolerant parsing, currency symbols, remote-path builder, run log) shared by Claude routine repos"
99
# Standard library only — a broad floor so it installs in any routine runtime,
1010
# including a Python 3.11 scheduled-task sandbox. Verified on 3.9 and 3.12.

src/code_commons.py

Lines changed: 72 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from __future__ import annotations
1818

1919
import os
20+
import sys
2021
from datetime import datetime
2122
from typing import Any
2223

@@ -26,39 +27,79 @@
2627
# ---------------------------------------------------------------------------
2728

2829

29-
def _env_lookup(key: str, prefix: str) -> str | None:
30-
"""Project-prefix-with-fallback lookup: try ``<prefix>_<key>`` first, then the
31-
unprefixed ``<key>``. Returns the first set & non-empty (stripped) value, or
32-
None. With no prefix this is a plain ``<key>`` lookup (backward compatible).
30+
# Tracks (prefix, key) pairs already warned about, so the migration warning (F-007)
31+
# fires once per process per variable rather than on every lookup. Exposed as a
32+
# module global so tests can reset it between cases.
33+
_WARNED_PREFIX_KEYS: set[tuple[str, str]] = set()
3334

34-
This lets a family of routines share one environment: shared values
35-
(credentials, tokens, common knobs) are set once unprefixed and reached via
36-
the fallback, while per-routine values are set prefixed so they never collide.
35+
36+
def _env_lookup(key: str, prefix: str, shared: bool = False) -> str | None:
37+
"""Resolve a variable, honouring the project-prefix convention (F-001, F-007).
38+
39+
With no prefix this is a plain ``<key>`` lookup (backward compatible). With a
40+
prefix set, behaviour depends on ``shared``:
41+
42+
- ``shared=True`` — a family-shared credential/infra value: try
43+
``<prefix>_<key>`` first, then fall back to the unprefixed ``<key>``, so a
44+
family of routines can share one environment with the shared values set once
45+
unprefixed.
46+
- ``shared=False`` (default) — a routine-own knob: read **only**
47+
``<prefix>_<key>``. The unprefixed form is not read, so a knob set unprefixed
48+
in a shared environment cannot leak between sibling routines. If the
49+
unprefixed form *is* set while the prefixed one is not, emit a one-time
50+
stderr warning naming both forms (the stray value is still ignored).
51+
52+
Returns the first set & non-empty (stripped) value, or None.
3753
"""
38-
names = (f"{prefix}_{key}", key) if prefix else (key,)
54+
if not prefix:
55+
names: tuple[str, ...] = (key,)
56+
elif shared:
57+
names = (f"{prefix}_{key}", key)
58+
else:
59+
names = (f"{prefix}_{key}",)
3960
for name in names:
4061
v = os.environ.get(name, "").strip()
4162
if v:
4263
return v
64+
# Prefix-required miss: warn once if a stray unprefixed value is being ignored.
65+
if prefix and not shared and os.environ.get(key, "").strip():
66+
pair = (prefix, key)
67+
if pair not in _WARNED_PREFIX_KEYS:
68+
_WARNED_PREFIX_KEYS.add(pair)
69+
print(
70+
f"WARNING: {prefix}_{key} is not set; ignoring a plain {key} in the "
71+
f"environment. Routine-own keys are read only with the {prefix} "
72+
f"prefix — rename it to {prefix}_{key} (shared family credentials "
73+
f"still allow the unprefixed form).",
74+
file=sys.stderr,
75+
flush=True,
76+
)
4377
return None
4478

4579

46-
def env_required(key: str, *, prefix: str = "") -> str:
47-
"""Return a required env var, stripped, via the project-prefix-with-fallback
48-
lookup. Aborts the run with a clear SystemExit naming the variable when
49-
neither the prefixed nor the unprefixed form is set. A routine never falls
50-
back to a placeholder for a credential or URL."""
51-
v = _env_lookup(key, prefix)
80+
def env_required(key: str, *, prefix: str = "", shared: bool = False) -> str:
81+
"""Return a required env var, stripped, via the project-prefix lookup. Aborts
82+
the run with a clear SystemExit naming the variable when it is not set. A
83+
routine never falls back to a placeholder for a credential or URL.
84+
85+
Pass ``shared=True`` for family-shared credentials/infra so the unprefixed
86+
form is still read; routine-own keys (the default) are prefix-only — see
87+
:func:`_env_lookup`."""
88+
v = _env_lookup(key, prefix, shared)
5289
if not v:
53-
suffix = f" (or {prefix}_{key})" if prefix else ""
54-
raise SystemExit(f"Missing required env var: {key}{suffix}")
90+
suffix = f" (or {prefix}_{key})" if prefix and shared else ""
91+
name = f"{prefix}_{key}" if prefix and not shared else key
92+
raise SystemExit(f"Missing required env var: {name}{suffix}")
5593
return v
5694

5795

58-
def env_opt(key: str, default: str | None = None, *, prefix: str = "") -> str | None:
59-
"""Return an optional env var, stripped, via the project-prefix-with-fallback
60-
lookup, or ``default`` when neither form is set/non-empty."""
61-
v = _env_lookup(key, prefix)
96+
def env_opt(
97+
key: str, default: str | None = None, *, prefix: str = "", shared: bool = False
98+
) -> str | None:
99+
"""Return an optional env var, stripped, via the project-prefix lookup, or
100+
``default`` when not set. Pass ``shared=True`` for family-shared values (see
101+
:func:`_env_lookup`)."""
102+
v = _env_lookup(key, prefix, shared)
62103
return v if v is not None else default
63104

64105

@@ -67,16 +108,19 @@ def env_opt(key: str, default: str | None = None, *, prefix: str = "") -> str |
67108
env_get = env_opt
68109

69110

70-
def env_int(key: str, default: int | None = None, *, prefix: str = "") -> int | None:
71-
"""Optional env var parsed as an ``int``, via the project-prefix-with-fallback
72-
lookup. Returns ``default`` when neither form is set.
111+
def env_int(
112+
key: str, default: int | None = None, *, prefix: str = "", shared: bool = False
113+
) -> int | None:
114+
"""Optional env var parsed as an ``int``, via the project-prefix lookup. Returns
115+
``default`` when unset (``shared`` controls the unprefixed fallback, see
116+
:func:`_env_lookup`).
73117
74118
When the value **is** set but is not a valid integer, this aborts with a clear
75119
``SystemExit`` naming the variable (``Invalid <KEY>='<value>': expected an
76120
integer.``) instead of raising an uncaught ``ValueError`` mid-run — so a typo in
77121
a numeric knob fails a routine's ``--dry-run`` config check cleanly. ``default``
78122
is returned as-is and never re-parsed (F-006)."""
79-
raw = _env_lookup(key, prefix)
123+
raw = _env_lookup(key, prefix, shared)
80124
if raw is None:
81125
return default
82126
try:
@@ -85,11 +129,13 @@ def env_int(key: str, default: int | None = None, *, prefix: str = "") -> int |
85129
raise SystemExit(f"Invalid {key}={raw!r}: expected an integer.")
86130

87131

88-
def env_float(key: str, default: float | None = None, *, prefix: str = "") -> float | None:
132+
def env_float(
133+
key: str, default: float | None = None, *, prefix: str = "", shared: bool = False
134+
) -> float | None:
89135
"""Optional env var parsed as a ``float`` (see :func:`env_int`). Returns
90136
``default`` when unset; aborts with a clear ``SystemExit`` naming the variable
91137
(``Invalid <KEY>='<value>': expected a number.``) when set but malformed (F-006)."""
92-
raw = _env_lookup(key, prefix)
138+
raw = _env_lookup(key, prefix, shared)
93139
if raw is None:
94140
return default
95141
try:

0 commit comments

Comments
 (0)