Skip to content

Commit a84b5e0

Browse files
committed
Support per---python venv setup customization.
1 parent 20664ba commit a84b5e0

File tree

9 files changed

+532
-197
lines changed

9 files changed

+532
-197
lines changed

CHANGES.md

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

3+
## 0.22.0
4+
5+
Add support for per-`--python` venv setup customization. This allows for vagaries in older Pythons
6+
and the Pips that support them.
7+
8+
Also provide finer-grained venv caching control with the new
9+
`[tool.dev-cmd.python.requirements] input-keys` for selecting just a subset of pyproject.toml's
10+
values to form the cache key.
11+
312
## 0.21.1
413

514
Fix `when` environment marker evaluation to take place in the requested Python's environment when a

README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ export-command = [
354354
"-o", "{requirements.txt}"
355355
]
356356
extra-requirements = [
357-
"-e .",
357+
"-e", ".",
358358
"subproject @ ./subproject"
359359
]
360360
```
@@ -364,13 +364,31 @@ exports hashes for these which Pip does not support for directories. To work aro
364364
these two local projects in `extra-requirements` and they get installed as-is without a hash check
365365
after the exported requirements are installed.
366366

367-
Venvs are created under a `.dev-cmd` directory and are cached base on the full contents of
368-
`pyproject.toml` by default. To change the list of files used to form the cache key for the venvs,
369-
use the `input-files` key. Here invalidation is turned off with an empty list:
367+
You may find the need to vary venv setup per Python `--version`. This is supported by specifying
368+
`extra-requirements` as a list of tables instead of a list of requirement strings. For example:
369+
```toml
370+
[[tool.dev-cmd.python.requirements.extra-requirements]]
371+
when = "python_version < '3.7'"
372+
pip-req = "pip<23"
373+
install-opts = ["--no-use-pep517"]
374+
reqs = ["-e", "./"]
375+
```
376+
377+
You must ensure just one `extra-requirements` entry is selected per `--python` via a `when`
378+
environment marker. You can then customise the version of Pip selected for the venv via `pip-req`,
379+
the extra `reqs` to install and any custom `pip install` options you need.
380+
381+
Venvs are created under a `.dev-cmd` directory and are cached based on the values of the
382+
"build-system", "project" and "project.optional-dependencies" in `pyproject.toml` by default. To
383+
change the default input keys, you can specify `input-keys`. You can also mix the full contents of
384+
any other files into the venv cache key using `input-files`. Here, combining both of these options,
385+
we turn off pyproject.toml inputs to the venv cache key and just rely on the contents of `uv.lock`,
386+
which is what the export command is powered by:
370387
```toml
371388
[tool.dev-cmd.python.requirements]
372389
export-command = ["uv", "export", "-q", "--no-emit-project", "-o", "{requirements.txt}"]
373-
input-files = []
390+
input-keys = []
391+
input-files = ["uv.lock"]
374392
```
375393

376394
## Execution

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.21.1"
4+
__version__ = "0.22.0"

dev_cmd/model.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dataclasses import dataclass
88
from enum import Enum
99
from pathlib import PurePath
10-
from typing import Any, Container, Mapping, MutableMapping
10+
from typing import Any, Container, Iterable, Mapping, MutableMapping
1111

1212
from packaging.markers import Marker
1313

@@ -74,11 +74,32 @@ def __str__(self) -> str:
7474
return self.value
7575

7676

77+
@dataclass(frozen=True)
78+
class ExtraRequirements:
79+
@classmethod
80+
def create(
81+
cls,
82+
reqs: Iterable[str] | None = None,
83+
pip_req: str | None = None,
84+
install_opts: Iterable[str] | None = None,
85+
) -> ExtraRequirements:
86+
return cls(
87+
reqs=tuple(reqs or ["-e", "."]),
88+
pip_req=pip_req or "pip",
89+
install_opts=tuple(install_opts) if install_opts else (),
90+
)
91+
92+
reqs: tuple[str, ...]
93+
pip_req: str
94+
install_opts: tuple[str, ...]
95+
96+
7797
@dataclass(frozen=True)
7898
class PythonConfig:
99+
input_data: bytes
79100
input_files: tuple[str, ...]
80101
requirements_export_command: tuple[str, ...]
81-
extra_requirements: tuple[str, ...]
102+
extra_requirements: ExtraRequirements
82103

83104

84105
@dataclass(frozen=True)

dev_cmd/parse.py

Lines changed: 147 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import dataclasses
77
import itertools
8+
import json
89
import os
910
from collections import defaultdict
1011
from pathlib import Path
@@ -19,6 +20,7 @@
1920
Command,
2021
Configuration,
2122
ExitStyle,
23+
ExtraRequirements,
2224
Factor,
2325
FactorDescription,
2426
Group,
@@ -48,6 +50,24 @@ def _assert_dict_str_keys(obj: Any, *, path: str) -> dict[str, Any]:
4850
return cast("dict[str, Any]", obj)
4951

5052

53+
def _parse_when(data: dict[str, Any], table_path: str) -> Marker | None:
54+
raw_when = data.pop("when", None)
55+
if raw_when and not isinstance(raw_when, str):
56+
raise InvalidModelError(
57+
f"The {table_path} `when` value must be a string, "
58+
f"given: {raw_when} of type {type(raw_when)}."
59+
)
60+
try:
61+
return Marker(raw_when) if raw_when else None
62+
except InvalidMarker as e:
63+
raise InvalidModelError(
64+
f"The {table_path} `when` value is not a valid marker "
65+
f"expression: {e}{os.linesep}"
66+
f"See: https://packaging.python.org/en/latest/specifications/"
67+
f"dependency-specifiers/#environment-markers"
68+
)
69+
70+
5171
def _parse_commands(
5272
commands: dict[str, Any] | None,
5373
required_steps: dict[str, list[tuple[Factor, ...]]],
@@ -154,21 +174,7 @@ def _parse_commands(
154174
)
155175
factor_descriptions[Factor(factor_name)] = factor_desc
156176

157-
raw_when = command.pop("when", None)
158-
if raw_when and not isinstance(raw_when, str):
159-
raise InvalidModelError(
160-
f"The [tool.dev-cmd.commands.{name}] `when` value must be a string, "
161-
f"given: {raw_when} of type {type(raw_when)}."
162-
)
163-
try:
164-
when = Marker(raw_when) if raw_when else None
165-
except InvalidMarker as e:
166-
raise InvalidModelError(
167-
f"The [tool.dev-cmd.commands.{name}] `when` value is not a valid marker "
168-
f"expression: {e}{os.linesep}"
169-
f"See: https://packaging.python.org/en/latest/specifications/"
170-
f"dependency-specifiers/#environment-markers"
171-
)
177+
when = _parse_when(command, table_path=f"[tool.dev-cmd.commands.{name}]")
172178

173179
if data:
174180
raise InvalidModelError(
@@ -370,21 +376,7 @@ def _parse_tasks(
370376
f"given: {description} of type {type(description)}."
371377
)
372378

373-
raw_when = data.pop("when", None)
374-
if raw_when and not isinstance(raw_when, str):
375-
raise InvalidModelError(
376-
f"The [tool.dev-cmd.tasks.{name}] `when` value must be a string, "
377-
f"given: {raw_when} of type {type(raw_when)}."
378-
)
379-
try:
380-
when = Marker(raw_when) if raw_when else None
381-
except InvalidMarker as e:
382-
raise InvalidModelError(
383-
f"The [tool.dev-cmd.tasks.{name}] `when` value is not a valid marker "
384-
f"expression: {e}{os.linesep}"
385-
f"See: https://packaging.python.org/en/latest/specifications/"
386-
f"dependency-specifiers/#environment-markers"
387-
)
379+
when = _parse_when(data, table_path=f"[tool.dev-cmd.tasks.{name}]")
388380

389381
if data:
390382
raise InvalidModelError(
@@ -498,11 +490,19 @@ def _parse_grace_period(grace_period: Any) -> float | None:
498490
return float(grace_period)
499491

500492

501-
def _parse_python(python: Any) -> PythonConfig | None:
493+
def _parse_python(
494+
python: str | None, python_config_data: Any, pyproject_data: dict[str, Any]
495+
) -> Venv | None:
502496
if python is None:
503497
return None
498+
if not python_config_data:
499+
raise InvalidArgumentError(
500+
f"You requested a custom Python of {python} but have not configured "
501+
f"`[tool.dev-cmd.python]`.\n"
502+
f"See: https://github.com/jsirois/dev-cmd/blob/main/README.md#custom-pythons"
503+
)
504504

505-
python_data = _assert_dict_str_keys(python, path="[tool.dev-cmd.python]")
505+
python_data = _assert_dict_str_keys(python_config_data, path="[tool.dev-cmd.python]")
506506
requirements = python_data.pop("requirements", None)
507507
if requirements is None:
508508
raise InvalidModelError(
@@ -527,32 +527,131 @@ def _parse_python(python: Any) -> PythonConfig | None:
527527
)
528528

529529
extra_requirements_data = requirements_data.pop("extra-requirements", None)
530+
input_keys_data = requirements_data.pop("input-keys", None)
530531
input_files_data = requirements_data.pop("input-files", None)
531532
if requirements_data:
532533
raise InvalidModelError(
533534
f"Unexpected configuration keys in the [tool.dev-cmd.python.requirements] table: "
534535
f"{' '.join(requirements_data)}"
535536
)
536537

537-
input_files = (
538+
input_files = set(
538539
_assert_list_str(input_files_data, path="[tool.dev-cmd.python.requirements] `input-files`")
539540
if input_files_data is not None
540-
else ["pyproject.toml"]
541+
else ()
541542
)
542543

543-
extra_requirements = (
544-
_assert_list_str(
545-
extra_requirements_data, path="[tool.dev-cmd.python.requirements] `extra-requirements`"
544+
extra_requirements = ExtraRequirements.create()
545+
if extra_requirements_data:
546+
if not isinstance(extra_requirements_data, list):
547+
raise InvalidModelError(
548+
f"Expected [tool.dev-cmd.python.requirements] `extra-requirements` to be either a list "
549+
f"of strings or a list of tables but given: {extra_requirements_data} of type "
550+
f"{type(extra_requirements_data)}."
551+
)
552+
553+
if all(isinstance(item, dict) for item in extra_requirements_data):
554+
marker_environment = venv.marker_environment(python)
555+
activated_index: int | None = None
556+
for index, entry in enumerate(extra_requirements_data):
557+
entry_data = cast(dict[str, Any], entry)
558+
when = _parse_when(
559+
entry_data,
560+
table_path=f"[tool.dev-cmd.python.requirements] `extra-requirements[{index}]`",
561+
)
562+
if when and not when.evaluate(marker_environment):
563+
continue
564+
if activated_index is not None:
565+
raise InvalidModelError(
566+
f"The `extra-requirements` entries at index {activated_index} and {index} "
567+
f"are both active.{os.linesep}"
568+
f"You can define multiple `extra-requirements`, but you must ensure that "
569+
f"they all define mutually exclusive `when` marker expressions."
570+
)
571+
572+
reqs_data = entry_data.pop("reqs", None)
573+
pip_req = entry_data.pop("pip-req", None)
574+
install_ops_data = entry_data.pop("install-opts", None)
575+
576+
if entry_data:
577+
raise InvalidModelError(
578+
f"Unexpected configuration keys in the [tool.dev-cmd.python.requirements] "
579+
f"`extra-requirements[{index}] table: {' '.join(entry_data)}"
580+
)
581+
582+
reqs: list[str] | None = None
583+
if reqs_data:
584+
reqs = _assert_list_str(
585+
reqs_data,
586+
path=f"[tool.dev-cmd.python.requirements] `extra-requirements[{index}].reqs`",
587+
)
588+
589+
if pip_req and not isinstance(pip_req, str):
590+
raise InvalidModelError(
591+
f"The [tool.dev-cmd.python.requirements] "
592+
f"`extra-requirements[{index}].pip-req` value must be a string, but given: "
593+
f"{pip_req} of type {type(pip_req)}."
594+
)
595+
596+
install_opts: list[str] | None = None
597+
if install_ops_data:
598+
install_opts = _assert_list_str(
599+
install_ops_data,
600+
path=(
601+
f"[tool.dev-cmd.python.requirements] "
602+
f"`extra-requirements[{index}].install-opts`"
603+
),
604+
)
605+
606+
extra_requirements = ExtraRequirements.create(
607+
reqs=reqs, pip_req=pip_req, install_opts=install_opts
608+
)
609+
activated_index = index
610+
else:
611+
extra_requirements = ExtraRequirements.create(
612+
reqs=_assert_list_str(
613+
extra_requirements_data,
614+
path="[tool.dev-cmd.python.requirements] `extra-requirements`",
615+
),
616+
)
617+
618+
input_object = {
619+
"extra_requirements": {
620+
"reqs": extra_requirements.reqs,
621+
"pip-req": extra_requirements.pip_req,
622+
"install-opts": extra_requirements.install_opts,
623+
}
624+
}
625+
if input_keys_data is None or input_keys_data:
626+
input_files.discard("pyproject.toml")
627+
input_keys = (
628+
_assert_list_str(
629+
input_keys_data, path="[tool.dev-cmd.python.requirements] `input-keys`"
630+
)
631+
if input_keys_data is not None
632+
else ["build-system", "project", "project.optional-dependencies"]
546633
)
547-
if extra_requirements_data is not None
548-
else ["-e", "./"]
549-
)
634+
input_item_data: dict[str, Any] = {}
635+
for key in input_keys:
636+
value = pyproject_data
637+
for component in key.split("."):
638+
value = value.get(component, None)
639+
if value is None:
640+
raise InvalidModelError(
641+
f"The [tool.dev-cmd.python.requirements] `input-keys` key of {key} could "
642+
f"not be found in pyproject.toml."
643+
)
644+
input_item_data[key] = value
645+
input_object["input-keys"] = input_item_data
646+
input_data = json.dumps(input_object, sort_keys=True).encode()
550647

551-
return PythonConfig(
648+
python_config = PythonConfig(
649+
input_data=input_data,
552650
input_files=tuple(input_files),
553651
requirements_export_command=tuple(export_command),
554-
extra_requirements=tuple(extra_requirements),
652+
extra_requirements=extra_requirements,
555653
)
654+
return venv.ensure(python_config, python)
556655

557656

558657
def _iter_all_required_step_names(
@@ -597,17 +696,13 @@ def parse_dev_config(
597696
f"[tool.dev-cmd] table in {pyproject_toml}: {e}"
598697
)
599698

600-
python_config = _parse_python(dev_cmd_data.pop("python", None))
601-
python_venv: Venv | None = None
602699
marker_environment: dict[str, str] | None = None
603-
if requested_python:
604-
if not python_config:
605-
raise InvalidArgumentError(
606-
f"You requested a custom Python of {requested_python} but have not configured "
607-
f"`[tool.dev-cmd.python]`.\n"
608-
f"See: https://github.com/jsirois/dev-cmd/blob/main/README.md#custom-pythons"
609-
)
610-
python_venv = venv.ensure(python_config, requested_python)
700+
python_venv = _parse_python(
701+
python=requested_python,
702+
python_config_data=dev_cmd_data.pop("python", None),
703+
pyproject_data=pyproject_data,
704+
)
705+
if python_venv:
611706
marker_environment = dict(python_venv.marker_environment)
612707

613708
def pop_dict(key: str, *, path: str) -> dict[str, Any] | None:

0 commit comments

Comments
 (0)