Skip to content

Commit 47acc05

Browse files
authored
Merge branch 'main' into remove-attrs
2 parents dbebdc3 + eed75f4 commit 47acc05

20 files changed

Lines changed: 1032 additions & 581 deletions

.github/workflows/update-plugin-list.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ jobs:
3737
run: python scripts/update_plugin_list.py
3838

3939
- name: Create Pull Request
40-
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
40+
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0
4141
with:
4242
commit-message: '[automated] Update plugin list'
4343
author: 'Tobias Raabe <tobiasraabe@users.noreply.github.com>'

.pre-commit-config.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ repos:
2525
- id: python-no-log-warn
2626
- id: text-unicode-replacement-char
2727
- repo: https://github.com/astral-sh/ruff-pre-commit
28-
rev: v0.14.10
28+
rev: v0.14.14
2929
hooks:
3030
- id: ruff-format
3131
- id: ruff-check
3232
- repo: https://github.com/astral-sh/uv-pre-commit
33-
rev: 0.9.18
33+
rev: 0.9.28
3434
hooks:
3535
- id: uv-lock
3636
- repo: https://github.com/executablebooks/mdformat
@@ -54,12 +54,12 @@ repos:
5454
]
5555
files: (docs/.)
5656
- repo: https://github.com/kynan/nbstripout
57-
rev: 0.8.2
57+
rev: 0.9.0
5858
hooks:
5959
- id: nbstripout
6060
exclude: (docs)
6161
- repo: https://github.com/crate-ci/typos
62-
rev: typos-dict-v0.13.13
62+
rev: v1
6363
hooks:
6464
- id: typos
6565
exclude: (\.ipynb)

CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ releases are available on [PyPI](https://pypi.org/project/pytask) and
77

88
## Unreleased
99

10-
- Removed the direct dependency on attrs and migrated internal models to dataclasses.
10+
- {pull}`744` Removed the direct dependency on attrs and migrated internal models to
11+
dataclasses.
12+
- {pull}`766` moves runtime profiling persistence from SQLite to a JSON snapshot plus
13+
append-only journal in `.pytask/`, keeping runtime data resilient to crashes and
14+
compacted on normal build exits.
1115

1216
## 0.5.8 - 2025-12-30
1317

justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ test-cov *FLAGS:
1212

1313
# Run type checking
1414
typing:
15-
uv run --group typing --group test --isolated ty check src/ tests/
15+
uv run --group typing --group test --isolated ty check
1616

1717
# Run linting
1818
lint:

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ dynamic = ["version"]
2323
dependencies = [
2424
"click>=8.1.8,!=8.2.0",
2525
"click-default-group>=1.2.4",
26+
"msgspec>=0.18.6",
2627
"networkx>=2.4.0",
2728
"optree>=0.9.0",
2829
"packaging>=23.0.0",
@@ -74,7 +75,7 @@ test = [
7475
"pytest-xdist>=3.6.1",
7576
"syrupy>=4.5.0",
7677
"aiohttp>=3.11.0", # For HTTPPath tests.
77-
"coiled>=1.42.0",
78+
"coiled>=1.42.0; python_version < '3.14'",
7879
"pygraphviz>=1.12;platform_system=='Linux'",
7980
]
8081
typing = ["ty>=0.0.8"]
@@ -177,6 +178,7 @@ include = [
177178
unused-ignore-comment = "ignore"
178179

179180
[tool.ty.src]
181+
include = ["src", "tests"]
180182
exclude = ["src/_pytask/_hashlib.py"]
181183

182184
[tool.ty.terminal]

src/_pytask/build.py

Lines changed: 8 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
import json
66
import sys
77
from contextlib import suppress
8-
from pathlib import Path
98
from typing import TYPE_CHECKING
109
from typing import Any
1110
from typing import Literal
@@ -16,8 +15,7 @@
1615
from _pytask.capture_utils import CaptureMethod
1716
from _pytask.capture_utils import ShowCapture
1817
from _pytask.click import ColoredCommand
19-
from _pytask.config_utils import find_project_root_and_config
20-
from _pytask.config_utils import read_config
18+
from _pytask.config_utils import normalize_programmatic_config
2119
from _pytask.console import console
2220
from _pytask.dag import create_dag
2321
from _pytask.exceptions import CollectionError
@@ -30,13 +28,12 @@
3028
from _pytask.pluginmanager import hookimpl
3129
from _pytask.pluginmanager import storage
3230
from _pytask.session import Session
33-
from _pytask.shared import parse_paths
34-
from _pytask.shared import to_list
3531
from _pytask.traceback import Traceback
3632

3733
if TYPE_CHECKING:
3834
from collections.abc import Callable
3935
from collections.abc import Iterable
36+
from pathlib import Path
4037
from typing import NoReturn
4138

4239
from _pytask.node_protocols import PTask
@@ -66,7 +63,7 @@ def pytask_unconfigure(session: Session) -> None:
6663
path.write_text(json.dumps(HashPathCache._cache))
6764

6865

69-
def build( # noqa: C901, PLR0912, PLR0913, PLR0915
66+
def build( # noqa: PLR0913
7067
*,
7168
capture: Literal["fd", "no", "sys", "tee-sys"] | CaptureMethod = CaptureMethod.FD,
7269
check_casing_of_paths: bool = True,
@@ -225,47 +222,14 @@ def build( # noqa: C901, PLR0912, PLR0913, PLR0915
225222

226223
# If someone called the programmatic interface, we need to do some parsing.
227224
if "command" not in raw_config:
228-
raw_config["command"] = "build"
229225
# Add defaults from cli.
230226
from _pytask.cli import DEFAULTS_FROM_CLI # noqa: PLC0415
231227

232-
raw_config = {**DEFAULTS_FROM_CLI, **raw_config}
233-
234-
paths_value = raw_config["paths"]
235-
# Convert tuple to list since parse_paths expects Path | list[Path]
236-
if isinstance(paths_value, tuple):
237-
paths_value = list(paths_value)
238-
if not isinstance(paths_value, (Path, list)):
239-
msg = f"paths must be Path or list, got {type(paths_value)}"
240-
raise TypeError(msg) # noqa: TRY301
241-
# Cast is justified - we validated at runtime
242-
raw_config["paths"] = parse_paths(cast("Path | list[Path]", paths_value))
243-
244-
if raw_config["config"] is not None:
245-
config_value = raw_config["config"]
246-
if not isinstance(config_value, (str, Path)):
247-
msg = f"config must be str or Path, got {type(config_value)}"
248-
raise TypeError(msg) # noqa: TRY301
249-
raw_config["config"] = Path(config_value).resolve()
250-
raw_config["root"] = raw_config["config"].parent
251-
else:
252-
(
253-
raw_config["root"],
254-
raw_config["config"],
255-
) = find_project_root_and_config(raw_config["paths"])
256-
257-
if raw_config["config"] is not None:
258-
config_from_file = read_config(raw_config["config"])
259-
260-
if "paths" in config_from_file:
261-
paths = config_from_file["paths"]
262-
paths = [
263-
raw_config["config"].parent.joinpath(path).resolve()
264-
for path in to_list(paths)
265-
]
266-
config_from_file["paths"] = paths
267-
268-
raw_config = {**raw_config, **config_from_file}
228+
raw_config = normalize_programmatic_config(
229+
raw_config,
230+
command="build",
231+
defaults_from_cli=cast("dict[str, Any]", DEFAULTS_FROM_CLI),
232+
)
269233

270234
config_ = pm.hook.pytask_configure(pm=pm, raw_config=raw_config)
271235

src/_pytask/capture.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def __init__(self, other: TextIO) -> None:
156156
self._other = other
157157
super().__init__()
158158

159-
def write(self, s: str) -> int: # ty: ignore[invalid-method-override]
159+
def write(self, s: str) -> int:
160160
super().write(s)
161161
return self._other.write(s)
162162

src/_pytask/config_utils.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import enum
56
import os
67
import sys
78
from pathlib import Path
@@ -12,6 +13,7 @@
1213
import click
1314

1415
from _pytask.shared import parse_paths
16+
from _pytask.shared import to_list
1517

1618
if TYPE_CHECKING:
1719
from collections.abc import Sequence
@@ -22,7 +24,12 @@
2224
import tomli as tomllib # ty: ignore[unresolved-import]
2325

2426

25-
__all__ = ["find_project_root_and_config", "read_config", "set_defaults_from_config"]
27+
__all__ = [
28+
"find_project_root_and_config",
29+
"normalize_programmatic_config",
30+
"read_config",
31+
"set_defaults_from_config",
32+
]
2633

2734

2835
def set_defaults_from_config(
@@ -78,6 +85,73 @@ def set_defaults_from_config(
7885
return context.params["config"]
7986

8087

88+
def normalize_programmatic_config(
89+
raw_config: dict[str, Any],
90+
*,
91+
command: str,
92+
defaults_from_cli: dict[str, Any],
93+
) -> dict[str, Any]:
94+
"""Normalize programmatic raw_config inputs."""
95+
raw_config = {**defaults_from_cli, **raw_config}
96+
raw_config["command"] = command
97+
raw_config["paths"] = _normalize_paths_value(raw_config["paths"])
98+
_normalize_config_value(raw_config)
99+
100+
if raw_config["config"] is not None:
101+
config_from_file = read_config(raw_config["config"])
102+
103+
if "paths" in config_from_file:
104+
paths = config_from_file["paths"]
105+
paths = [
106+
raw_config["config"].parent.joinpath(path).resolve()
107+
for path in to_list(paths)
108+
]
109+
config_from_file["paths"] = paths
110+
111+
raw_config = {**raw_config, **config_from_file}
112+
113+
return raw_config
114+
115+
116+
def _normalize_paths_value(paths_value: Any) -> list[Path]:
117+
"""Normalize paths from programmatic inputs."""
118+
if isinstance(paths_value, tuple):
119+
paths_value = list(paths_value)
120+
if not isinstance(paths_value, (Path, list)):
121+
msg = f"paths must be Path or list, got {type(paths_value)}"
122+
raise TypeError(msg)
123+
# Cast is justified - we validated at runtime
124+
return parse_paths(cast("Path | list[Path]", paths_value))
125+
126+
127+
def _normalize_config_value(raw_config: dict[str, Any]) -> None:
128+
"""Normalize config value from programmatic inputs."""
129+
config_value = raw_config["config"]
130+
if _is_click_sentinel(config_value):
131+
config_value = None
132+
raw_config["config"] = None
133+
if config_value is not None:
134+
if not isinstance(config_value, (str, Path)):
135+
msg = f"config must be str or Path, got {type(config_value)}"
136+
raise TypeError(msg)
137+
raw_config["config"] = Path(config_value).resolve()
138+
raw_config["root"] = raw_config["config"].parent
139+
else:
140+
raw_config["root"], raw_config["config"] = find_project_root_and_config(
141+
raw_config["paths"]
142+
)
143+
144+
145+
def _is_click_sentinel(value: Any) -> bool:
146+
"""Return True if value looks like Click's Sentinel.UNSET."""
147+
return (
148+
isinstance(value, enum.Enum)
149+
and value.name == "UNSET"
150+
and value.__class__.__name__ == "Sentinel"
151+
and value.__class__.__module__ == "click._utils"
152+
)
153+
154+
81155
def find_project_root_and_config(
82156
paths: Sequence[Path] | None,
83157
) -> tuple[Path, Path | None]:

src/_pytask/dag_command.py

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
from _pytask.click import EnumChoice
1717
from _pytask.compat import check_for_optional_program
1818
from _pytask.compat import import_optional_dependency
19-
from _pytask.config_utils import find_project_root_and_config
20-
from _pytask.config_utils import read_config
19+
from _pytask.config_utils import normalize_programmatic_config
2120
from _pytask.console import console
2221
from _pytask.dag import create_dag
2322
from _pytask.exceptions import CollectionError
@@ -28,9 +27,7 @@
2827
from _pytask.pluginmanager import hookimpl
2928
from _pytask.pluginmanager import storage
3029
from _pytask.session import Session
31-
from _pytask.shared import parse_paths
3230
from _pytask.shared import reduce_names_of_multiple_nodes
33-
from _pytask.shared import to_list
3431
from _pytask.traceback import Traceback
3532

3633

@@ -150,47 +147,14 @@ def build_dag(raw_config: dict[str, Any]) -> nx.DiGraph:
150147

151148
# If someone called the programmatic interface, we need to do some parsing.
152149
if "command" not in raw_config:
153-
raw_config["command"] = "dag"
154150
# Add defaults from cli.
155151
from _pytask.cli import DEFAULTS_FROM_CLI # noqa: PLC0415
156152

157-
raw_config = {**DEFAULTS_FROM_CLI, **raw_config} # ty: ignore[invalid-assignment]
158-
159-
paths_value = raw_config["paths"]
160-
# Convert tuple to list since parse_paths expects Path | list[Path]
161-
if isinstance(paths_value, tuple):
162-
paths_value = list(paths_value)
163-
if not isinstance(paths_value, (Path, list)):
164-
msg = f"paths must be Path or list, got {type(paths_value)}"
165-
raise TypeError(msg) # noqa: TRY301
166-
# Cast is justified - we validated at runtime
167-
raw_config["paths"] = parse_paths(cast("Path | list[Path]", paths_value))
168-
169-
if raw_config["config"] is not None:
170-
config_value = raw_config["config"]
171-
if not isinstance(config_value, (str, Path)):
172-
msg = f"config must be str or Path, got {type(config_value)}"
173-
raise TypeError(msg) # noqa: TRY301
174-
raw_config["config"] = Path(config_value).resolve()
175-
raw_config["root"] = raw_config["config"].parent
176-
else:
177-
(
178-
raw_config["root"],
179-
raw_config["config"],
180-
) = find_project_root_and_config(raw_config["paths"])
181-
182-
if raw_config["config"] is not None:
183-
config_from_file = read_config(raw_config["config"])
184-
185-
if "paths" in config_from_file:
186-
paths = config_from_file["paths"]
187-
paths = [
188-
raw_config["config"].parent.joinpath(path).resolve()
189-
for path in to_list(paths)
190-
]
191-
config_from_file["paths"] = paths
192-
193-
raw_config = {**raw_config, **config_from_file}
153+
raw_config = normalize_programmatic_config(
154+
raw_config,
155+
command="dag",
156+
defaults_from_cli=cast("dict[str, Any]", DEFAULTS_FROM_CLI),
157+
)
194158

195159
config = pm.hook.pytask_configure(pm=pm, raw_config=raw_config)
196160

src/_pytask/database.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@
1414
@hookimpl
1515
def pytask_parse_config(config: dict[str, Any]) -> None:
1616
"""Parse the configuration."""
17+
database_url = config["database_url"]
1718
# Set default.
18-
if not config["database_url"]:
19+
if not database_url:
1920
config["database_url"] = make_url(
2021
f"sqlite:///{config['root'].joinpath('.pytask').as_posix()}/pytask.sqlite3"
2122
)
23+
elif isinstance(database_url, str):
24+
config["database_url"] = make_url(database_url)
2225

2326
if (
2427
config["database_url"].drivername == "sqlite"

0 commit comments

Comments
 (0)