Skip to content

Commit 1b5713d

Browse files
committed
Support a {--hashseed} placeholder & CLI option.
This can be used to populate the `PYTHONHASHSEED` env var for Python commands to a random value on old pythons and a fixed value on new Pythons by pairing a fixed value passed via `dev-cmd --hashseed`.
1 parent 8c0ffc3 commit 1b5713d

9 files changed

Lines changed: 72 additions & 16 deletions

File tree

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Release Notes
22

3+
## 0.31.0
4+
5+
Add support for a `{--hashseed}` placeholder that is substituted with the value passed to `dev-cmd`
6+
via `--hashseed` or else a random value suitable for use in PYTHONHASHSEED.
7+
38
## 0.30.3
49

510
Fix `--py` / `--python` venv creation on Windows.

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,13 +125,15 @@ args = ["scripts/query-windows-registry.py"]
125125

126126
A command's python, arguments and env values can be parameterized with values from the execution
127127
environment. Parameters are introduced in between brackets with an optional default value:
128-
`{<key>(:<default>)?}`. Parameters can draw from three sources:
128+
`{<key>(:<default>)?}`. Parameters can draw from four sources:
129129
1. Environment variables via `{env.<name>}`; e.g.: `{env.HOME}`
130130
2. The current Python interpreter's marker environment via `{markers.<name>}`; e.g.:
131131
`{markers.python_version}`
132132
3. Factors via `{-<name>}`; e.g.: `{-py:{markers.python_version}}`
133+
4. A hash seed via `{--hashseed}`. The value comes from `dev-cmd --hashseed` if passed; otherwise a
134+
random hash seed suitable for use with `PYTHONHASHSEED` is generated.
133135

134-
In all three cases, the parameter name can itself come from a nested parameterization; e.g.:
136+
In the first three cases, the parameter name can itself come from a nested parameterization; e.g.:
135137
`{markers.{-marker:{env.MARKER:python_version}}}` selects the environment marker value for the
136138
environment marker named by the `marker` factor if defined; otherwise the `MARKER` environment
137139
variable if defined and finally falling back to `python_version` if none of these are defined.

dev_cmd/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# Copyright 2024 John Sirois.
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
33

4-
__version__ = "0.30.3"
4+
__version__ = "0.31.0"

dev_cmd/parse.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
PythonConfig,
2929
Task,
3030
)
31-
from dev_cmd.placeholder import DEFAULT_ENVIRONMENT, Substitution
31+
from dev_cmd.placeholder import Environment, Substitution
3232
from dev_cmd.project import PyProjectToml
3333

3434

@@ -134,6 +134,7 @@ def _parse_commands(
134134
project_dir: Path,
135135
python: Python | None,
136136
marker_environment: dict[str, str] | None,
137+
placeholder_env: Environment,
137138
) -> Iterator[Command | DeactivatedCommand]:
138139
if not commands:
139140
raise InvalidModelError(
@@ -270,7 +271,7 @@ def _parse_commands(
270271
used_factors: set[Factor] = set()
271272

272273
def substitute(text: str) -> Substitution:
273-
substitution = DEFAULT_ENVIRONMENT.substitute(text, *factors)
274+
substitution = placeholder_env.substitute(text, *factors)
274275
seen_factors.update(
275276
(
276277
seen_factor.factor,
@@ -909,7 +910,10 @@ def _gather_all_required_step_names(
909910

910911

911912
def parse_dev_config(
912-
pyproject_toml: PyProjectToml, *requested_steps: str, requested_python: Python | None = None
913+
pyproject_toml: PyProjectToml,
914+
*requested_steps: str,
915+
placeholder_env: Environment,
916+
requested_python: Python | None = None,
913917
) -> tuple[Configuration, tuple[str, ...]]:
914918
pyproject_data = pyproject_toml.parse()
915919
try:
@@ -990,6 +994,7 @@ def pop_dict(key: str, *, path: str) -> dict[str, Any] | None:
990994
project_dir=pyproject_toml.path.parent,
991995
python=requested_python,
992996
marker_environment=marker_environment,
997+
placeholder_env=placeholder_env,
993998
):
994999
existing = commands.setdefault(cmd.name, cmd)
9951000
if isinstance(existing, DeactivatedCommand) and isinstance(cmd, Command):

dev_cmd/placeholder.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class Environment(Substituter[State, str]):
5959
markers: Mapping[str, str] = field(
6060
default_factory=cast(Callable[[], Mapping[str, str]], markers.default_environment)
6161
)
62+
hashseed: int = 0
6263

6364
def substitute(self, text: str, *factors: Factor) -> Substitution:
6465
state = State(factors)
@@ -84,7 +85,14 @@ def substitution(self, text: str, section: slice, state: State) -> None:
8485
)
8586
default = deflt if sep else None
8687
value: str | None
87-
if key.startswith("-"):
88+
if key == "--hashseed":
89+
if default is not None:
90+
raise ValueError(
91+
f"The {{--hashseed}} placeholder does not accept a default. Found placeholder: "
92+
f"{{{symbol}}}"
93+
)
94+
value = str(self.hashseed)
95+
elif key.startswith("-"):
8896
flag, flag_sep, rest = symbol.partition("?")
8997
if flag_sep:
9098
substitution = self.substitute(rest)

dev_cmd/run.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from dataclasses import dataclass
1717
from multiprocessing.pool import ThreadPool
1818
from typing import Any, Collection, DefaultDict, Iterable, Iterator, Mapping
19+
from uuid import uuid4
1920

2021
from dev_cmd import __version__, color, parse, venv
2122
from dev_cmd.color import ColorChoice
@@ -35,7 +36,7 @@
3536
VenvConfig,
3637
)
3738
from dev_cmd.parse import parse_dev_config
38-
from dev_cmd.placeholder import DEFAULT_ENVIRONMENT
39+
from dev_cmd.placeholder import Environment
3940
from dev_cmd.project import find_pyproject_toml
4041

4142
DEFAULT_EXIT_STYLE = ExitStyle.AFTER_STEP
@@ -208,12 +209,19 @@ class Options:
208209
quiet: bool
209210
parallel: bool
210211
timings: bool
212+
hashseed: int
211213
extra_args: tuple[str, ...]
212214
python: str | None = None
213215
exit_style: ExitStyle | None = None
214216
grace_period: float | None = None
215217

216218

219+
def _random_hashseed() -> int:
220+
# The PYTHONHASHSEED is an integer in the range 0 to 4294967295. We use the time_low field of
221+
# the UUID which is 32 bits.
222+
return min(4294967295, uuid4().time_low)
223+
224+
217225
def _parse_args() -> Options:
218226
parser = ArgumentParser(
219227
description=(
@@ -263,6 +271,12 @@ def _parse_args() -> Options:
263271
action="store_true",
264272
help="Emit timing information for each command run.",
265273
)
274+
parser.add_argument(
275+
"--hashseed",
276+
type=int,
277+
default=_random_hashseed(),
278+
help="Set the {--hashseed} command placeholder value.",
279+
)
266280

267281
if venv.AVAILABLE:
268282
parser.add_argument(
@@ -373,6 +387,7 @@ def _parse_args() -> Options:
373387
quiet=options.quiet,
374388
parallel=parallel,
375389
timings=options.timings,
390+
hashseed=options.hashseed,
376391
extra_args=tuple(extra_args) if extra_args is not None else (),
377392
python=getattr(options, "python", None),
378393
exit_style=options.exit_style,
@@ -383,6 +398,7 @@ def _parse_args() -> Options:
383398
def _list(
384399
console, # type: Console
385400
config, # type: Configuration
401+
placeholder_env, # type: Environment
386402
):
387403
# type: (...) -> Any
388404

@@ -423,11 +439,11 @@ def _list(
423439
extra_info = ""
424440
if flag_value is not None:
425441
extra_info = f"{flag_value} "
426-
substituted_flag_value = DEFAULT_ENVIRONMENT.substitute(flag_value).value
442+
substituted_flag_value = placeholder_env.substitute(flag_value).value
427443
if substituted_flag_value != flag_value:
428444
extra_info += f"(currently {substituted_flag_value}) "
429445
if default is not None:
430-
substituted_default = DEFAULT_ENVIRONMENT.substitute(default).value
446+
substituted_default = placeholder_env.substitute(default).value
431447
if substituted_default != default:
432448
extra_info += f"[default: {default} (currently {substituted_default})]"
433449
else:
@@ -476,14 +492,17 @@ def main() -> Any:
476492
options = _parse_args()
477493
console = Console(quiet=options.quiet)
478494
python = Python(options.python) if options.python else None
495+
placeholder_env = Environment(hashseed=options.hashseed)
479496
try:
480497
pyproject_toml = find_pyproject_toml()
481-
config, steps = parse_dev_config(pyproject_toml, *options.steps, requested_python=python)
498+
config, steps = parse_dev_config(
499+
pyproject_toml, *options.steps, placeholder_env=placeholder_env, requested_python=python
500+
)
482501
except DevCmdError as e:
483502
return 1 if console.quiet else f"{color.red('Configuration error')}: {color.yellow(str(e))}"
484503

485504
if options.list:
486-
return _list(console, config)
505+
return _list(console, config, placeholder_env)
487506

488507
success = False
489508
try:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ dependency-group = "type-check"
125125

126126
[tool.dev-cmd.commands.test]
127127
python = "{-py:}"
128+
env = {"PYTHONHASHSEED" = "{--hashseed}"}
128129
args = ["pytest"]
129130
cwd = "tests"
130131
accepts-extra-args = true

tests/test_parse.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from dev_cmd.errors import InvalidModelError
1515
from dev_cmd.model import Command, Configuration, Group, Task
1616
from dev_cmd.parse import parse_dev_config
17+
from dev_cmd.placeholder import Environment
1718
from dev_cmd.project import PyProjectToml
1819

1920

@@ -26,7 +27,9 @@ def parse_config(tmp_path: Path) -> Iterator[ConfigurationParser]:
2627
def parse(content: str, *requested_steps: str) -> Configuration:
2728
pyproject_toml = tmp_path / "pyproject.toml"
2829
pyproject_toml.write_text(content)
29-
return parse_dev_config(PyProjectToml(pyproject_toml), *requested_steps)[0]
30+
return parse_dev_config(
31+
PyProjectToml(pyproject_toml), *requested_steps, placeholder_env=Environment(hashseed=0)
32+
)[0]
3033

3134
yield parse
3235

tests/test_placeholder.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
11
# Copyright 2025 John Sirois.
22
# Licensed under the Apache License, Version 2.0 (see LICENSE).
3-
43
import re
54
import sys
65

76
import pytest
87
from packaging.markers import default_environment
98

109
from dev_cmd.model import Factor
11-
from dev_cmd.placeholder import DEFAULT_ENVIRONMENT, Environment, SeenFactor, Substitution
10+
from dev_cmd.placeholder import Environment, SeenFactor, Substitution
1211

1312

1413
@pytest.fixture
1514
def env() -> Environment:
16-
return DEFAULT_ENVIRONMENT
15+
return Environment()
1716

1817

1918
def substitute(env: Environment, text: str) -> str:
@@ -126,3 +125,17 @@ def test_substitute_intra_recursive() -> None:
126125
seen_factors=[SeenFactor(Factor("py"))],
127126
used_factors=[Factor("py{markers.{env.USE_MARKER}}")],
128127
) == env.substitute("{-{env.USE_FACTOR}}", Factor("py{markers.{env.USE_MARKER}}"))
128+
129+
130+
def test_substitute_hashseed() -> None:
131+
env = Environment(hashseed=42)
132+
assert Substitution.create("42") == env.substitute("{--hashseed}")
133+
134+
with pytest.raises(
135+
ValueError,
136+
match=re.escape(
137+
"The {--hashseed} placeholder does not accept a default. Found placeholder: "
138+
"{--hashseed:137}"
139+
),
140+
):
141+
env.substitute("{--hashseed:137}")

0 commit comments

Comments
 (0)