Skip to content

Commit 4fb32f5

Browse files
Refactor TOML IO Management (#359)
* Major refactoring to handle new class structure * Fix locking bug * Add pyproject.toml dump for CI debugging * Bump uv Github action * Enable uv caching in CI * Skip failing test for debugging * Restore disabled test * Configure subprocess verbosity * Temporarily set subprocess verbosity for debugging * Consistently use `--quiet` where possible, but not for `uv python list` * Set python version in no-pyproject-toml test * Add tests cases for when uv subprocess fails
1 parent 683a1bd commit 4fb32f5

44 files changed

Lines changed: 639 additions & 679 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ jobs:
1919
git config --global user.email placeholder@example.com
2020
2121
- name: Set up uv
22-
uses: astral-sh/setup-uv@e779db74266a80753577425b0f4ee823649f251d # v3.2.3
22+
uses: astral-sh/setup-uv@f94ec6bedd8674c4426838e6b50417d36b6ab231 # v5.3.1
2323
with:
2424
version: "latest"
25+
enable-cache: true
2526

2627
- name: Set up Python
2728
uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0

src/usethis/_config.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class UsethisConfig(BaseModel):
1111
offline: bool
1212
quiet: bool
1313
frozen: bool = False
14+
subprocess_verbose: bool = False
1415

1516
@contextmanager
1617
def set(
@@ -19,33 +20,42 @@ def set(
1920
offline: bool | None = None,
2021
quiet: bool | None = None,
2122
frozen: bool | None = None,
23+
subprocess_verbose: bool | None = None,
2224
) -> Generator[None, None, None]:
2325
"""Temporarily change command options."""
2426
old_offline = self.offline
2527
old_quiet = self.quiet
2628
old_frozen = self.frozen
29+
old_subprocess_verbose = self.subprocess_verbose
2730

2831
if offline is None:
2932
offline = old_offline
3033
if quiet is None:
3134
quiet = old_quiet
3235
if frozen is None:
3336
frozen = old_frozen
37+
if subprocess_verbose is None:
38+
subprocess_verbose = old_subprocess_verbose
3439

3540
self.offline = offline
3641
self.quiet = quiet
3742
self.frozen = frozen
43+
self.subprocess_verbose = subprocess_verbose
3844
yield
3945
self.offline = old_offline
4046
self.quiet = old_quiet
4147
self.frozen = old_frozen
48+
self.subprocess_verbose = old_subprocess_verbose
4249

4350

4451
_OFFLINE_DEFAULT = False
4552
_QUIET_DEFAULT = False
4653

4754
usethis_config = UsethisConfig(
48-
offline=_OFFLINE_DEFAULT, quiet=_QUIET_DEFAULT, frozen=False
55+
offline=_OFFLINE_DEFAULT,
56+
quiet=_QUIET_DEFAULT,
57+
frozen=False,
58+
subprocess_verbose=False,
4959
)
5060

5161
offline_opt = typer.Option(_OFFLINE_DEFAULT, "--offline", help="Disable network access")

src/usethis/_integrations/pre_commit/hooks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def _get_placeholder_repo_config() -> LocalRepo:
137137
HookDefinition(
138138
id=_PLACEHOLDER_ID,
139139
name="Placeholder - add your own hooks!",
140-
entry="""uv run --frozen python -c "print('hello world!')\"""",
140+
entry="""uv run --isolated --frozen python -c "print('hello world!')\"""",
141141
language=Language("system"),
142142
)
143143
],

src/usethis/_integrations/pyproject_toml/core.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@
44
PyprojectTOMLValueAlreadySetError,
55
PyprojectTOMLValueMissingError,
66
)
7-
from usethis._integrations.pyproject_toml.io_ import (
8-
read_pyproject_toml,
9-
write_pyproject_toml,
10-
)
7+
from usethis._integrations.pyproject_toml.io_ import PyprojectTOMLManager
118
from usethis._integrations.toml.core import (
129
do_toml_id_keys_exist,
1310
extend_toml_list,
@@ -23,7 +20,7 @@
2320

2421

2522
def get_pyproject_value(id_keys: list[str]) -> Any:
26-
pyproject = read_pyproject_toml()
23+
pyproject = PyprojectTOMLManager().get()
2724

2825
return get_toml_value(toml_document=pyproject, id_keys=id_keys)
2926

@@ -32,7 +29,7 @@ def set_pyproject_value(
3229
id_keys: list[str], value: Any, *, exists_ok: bool = False
3330
) -> None:
3431
"""Set a value in the pyproject.toml configuration file."""
35-
pyproject = read_pyproject_toml()
32+
pyproject = PyprojectTOMLManager().get()
3633

3734
try:
3835
pyproject = set_toml_value(
@@ -41,7 +38,7 @@ def set_pyproject_value(
4138
except TOMLValueAlreadySetError as err:
4239
raise PyprojectTOMLValueAlreadySetError(err)
4340

44-
write_pyproject_toml(pyproject)
41+
PyprojectTOMLManager().commit(pyproject)
4542

4643

4744
def remove_pyproject_value(
@@ -50,7 +47,7 @@ def remove_pyproject_value(
5047
missing_ok: bool = False,
5148
) -> None:
5249
"""Remove a value from the pyproject.toml configuration file."""
53-
pyproject = read_pyproject_toml()
50+
pyproject = PyprojectTOMLManager().get()
5451

5552
try:
5653
pyproject = remove_toml_value(toml_document=pyproject, id_keys=id_keys)
@@ -59,39 +56,39 @@ def remove_pyproject_value(
5956
raise PyprojectTOMLValueMissingError(err)
6057
# Otherwise, no changes are needed so skip the write step.
6158
return
62-
write_pyproject_toml(pyproject)
59+
PyprojectTOMLManager().commit(pyproject)
6360

6461

6562
def extend_pyproject_list(
6663
id_keys: list[str],
6764
values: list[Any],
6865
) -> None:
6966
"""Append values to a list in the pyproject.toml configuration file."""
70-
pyproject = read_pyproject_toml()
67+
pyproject = PyprojectTOMLManager().get()
7168

7269
pyproject = extend_toml_list(
7370
toml_document=pyproject,
7471
id_keys=id_keys,
7572
values=values,
7673
)
7774

78-
write_pyproject_toml(pyproject)
75+
PyprojectTOMLManager().commit(pyproject)
7976

8077

8178
def remove_from_pyproject_list(id_keys: list[str], values: list[str]) -> None:
82-
pyproject = read_pyproject_toml()
79+
pyproject = PyprojectTOMLManager().get()
8380

8481
pyproject = remove_from_toml_list(
8582
toml_document=pyproject,
8683
id_keys=id_keys,
8784
values=values,
8885
)
8986

90-
write_pyproject_toml(pyproject)
87+
PyprojectTOMLManager().commit(pyproject)
9188

9289

9390
def do_pyproject_id_keys_exist(id_keys: list[str]) -> bool:
94-
pyproject = read_pyproject_toml()
91+
pyproject = PyprojectTOMLManager().get()
9592

9693
return do_toml_id_keys_exist(
9794
toml_document=pyproject,
Lines changed: 72 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pathlib import Path
2+
from typing import ClassVar
23

34
from tomlkit.api import dumps, parse
45
from tomlkit.exceptions import TOMLKitError
@@ -11,88 +12,105 @@
1112
)
1213

1314

14-
def read_pyproject_toml() -> TOMLDocument:
15-
return pyproject_toml_io_manager._opener.read()
15+
class UnexpectedPyprojectTOMLOpenError(Exception):
16+
"""Raised when the 'pyproject.toml' opener is accessed unexpectedly."""
17+
1618

19+
class UnexpectedPyprojectTOMLCloseError(Exception):
20+
"""Raised when the 'pyproject.toml' opener is closed unexpectedly."""
1721

18-
def write_pyproject_toml(toml_document: TOMLDocument) -> None:
19-
return pyproject_toml_io_manager._opener.write(toml_document)
2022

23+
class UnexpectedPyprojectTOMLIOError(Exception):
24+
"""Raised when an unexpected attempt is made to read or write 'pyproject.toml'."""
2125

22-
class UnexpectedPyprojectTOMLReadError(Exception):
23-
"""Raised when the pyproject.toml is read unexpectedly."""
2426

27+
class PyprojectTOMLManager:
28+
_content_by_path: ClassVar[dict[Path, TOMLDocument | None]] = {}
2529

26-
class PyprojectTOMLOpener:
2730
def __init__(self) -> None:
28-
self.path = Path.cwd() / "pyproject.toml"
29-
self.content = TOMLDocument()
30-
self.open = False
31-
self._set = False
32-
33-
def read(self) -> TOMLDocument:
34-
if not self._set:
35-
msg = """The pyproject.toml opener has not been set yet."""
31+
self._path = (Path.cwd() / "pyproject.toml").resolve()
32+
33+
def __enter__(self) -> Self:
34+
if self.is_locked():
35+
msg = (
36+
f"The 'pyproject.toml' file is already in use by another instance of "
37+
f"'{self.__class__.__name__}'."
38+
)
3639
raise UnexpectedPyprojectTOMLOpenError(msg)
3740

38-
if not self.open:
41+
self.lock()
42+
return self
43+
44+
def __exit__(self, exc_type: None, exc_value: None, traceback: None) -> None:
45+
if not self.is_locked():
46+
# This could happen if we decide to delete the file.
47+
return
48+
49+
self.write_file()
50+
self.unlock()
51+
52+
def get(self) -> TOMLDocument:
53+
self._validate_lock()
54+
55+
if self._content is None:
3956
self.read_file()
40-
self.open = True
57+
assert self._content is not None
4158

42-
return self.content
59+
return self._content
4360

44-
def write(self, toml_document: TOMLDocument) -> None:
45-
if not self._set:
46-
msg = """The pyproject.toml opener has not been set yet."""
47-
raise UnexpectedPyprojectTOMLOpenError(msg)
61+
def commit(self, toml_document: TOMLDocument) -> None:
62+
self._validate_lock()
4863

49-
self.content = toml_document
64+
self._content = toml_document
5065

5166
def write_file(self) -> None:
52-
self.path.write_text(dumps(self.content))
67+
self._validate_lock()
68+
69+
if self._content is None:
70+
# No changes made, nothing to write.
71+
return
72+
73+
self._path.write_text(dumps(self._content))
5374

5475
def read_file(self) -> None:
76+
self._validate_lock()
77+
78+
if self._content is not None:
79+
msg = (
80+
"The 'pyproject.toml' file has already been read, use 'get()' to "
81+
"access the content."
82+
)
83+
raise UnexpectedPyprojectTOMLIOError(msg)
5584
try:
56-
self.content = parse(self.path.read_text())
85+
self._content = parse(self._path.read_text())
5786
except FileNotFoundError:
5887
msg = "'pyproject.toml' not found in the current directory."
5988
raise PyprojectTOMLNotFoundError(msg)
6089
except TOMLKitError as err:
6190
msg = f"Failed to decode 'pyproject.toml': {err}"
6291
raise PyprojectTOMLDecodeError(msg) from None
6392

64-
def __enter__(self) -> Self:
65-
self._set = True
66-
return self
67-
68-
def __exit__(self, exc_type: None, exc_value: None, traceback: None) -> None:
69-
self.write_file()
70-
self._set = False
71-
72-
73-
class UnexpectedPyprojectTOMLOpenError(Exception):
74-
"""Raised when the pyproject.toml opener is accessed unexpectedly."""
75-
76-
77-
class PyprojectTOMLIOManager:
78-
def __init__(self) -> None:
79-
self._opener = PyprojectTOMLOpener()
80-
self._set = False
81-
8293
@property
83-
def opener(self) -> PyprojectTOMLOpener:
84-
if not self._opener._set:
85-
self._set = False
94+
def _content(self) -> TOMLDocument | None:
95+
return self._content_by_path[self._path]
8696

87-
if not self._set:
88-
msg = """The pyproject.toml opener has not been set to open yet."""
89-
raise UnexpectedPyprojectTOMLOpenError(msg)
97+
@_content.setter
98+
def _content(self, value: TOMLDocument | None) -> None:
99+
self._content_by_path[self._path] = value
90100

91-
return self._opener
101+
def _validate_lock(self) -> None:
102+
if not self.is_locked():
103+
msg = (
104+
f"The 'pyproject.toml' file has not been opened yet. Please enter the "
105+
f"context manager, e.g. 'with {self.__class__.__name__}():'"
106+
)
107+
raise UnexpectedPyprojectTOMLIOError(msg)
92108

93-
def open(self) -> PyprojectTOMLOpener:
94-
self._opener = PyprojectTOMLOpener()
95-
return self._opener
109+
def is_locked(self) -> bool:
110+
return self._path in self._content_by_path
96111

112+
def lock(self) -> None:
113+
self._content = None
97114

98-
pyproject_toml_io_manager = PyprojectTOMLIOManager()
115+
def unlock(self) -> None:
116+
self._content_by_path.pop(self._path, None)

src/usethis/_integrations/pyproject_toml/project.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
from pydantic import TypeAdapter, ValidationError
44

55
from usethis._integrations.pyproject_toml.errors import PyprojectTOMLProjectSectionError
6-
from usethis._integrations.pyproject_toml.io_ import read_pyproject_toml
6+
from usethis._integrations.pyproject_toml.io_ import PyprojectTOMLManager
77

88

99
def get_project_dict() -> dict[str, Any]:
10-
pyproject = read_pyproject_toml().value
10+
pyproject = PyprojectTOMLManager().get().value
1111

1212
try:
1313
project = TypeAdapter(dict).validate_python(pyproject["project"])
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from pathlib import Path
22

33
from usethis._console import tick_print
4-
from usethis._integrations.pyproject_toml.io_ import pyproject_toml_io_manager
4+
from usethis._integrations.pyproject_toml.io_ import PyprojectTOMLManager
55

66

77
def remove_pyproject_toml() -> None:
88
path = Path.cwd() / "pyproject.toml"
99
if path.exists() and path.is_file():
1010
tick_print("Removing 'pyproject.toml' file")
11-
pyproject_toml_io_manager._opener.write_file()
12-
pyproject_toml_io_manager._opener._set = False
11+
PyprojectTOMLManager().write_file()
12+
PyprojectTOMLManager().unlock()
1313
path.unlink()

src/usethis/_integrations/pyproject_toml/requires_python.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
from packaging.specifiers import SpecifierSet
22
from pydantic import TypeAdapter
33

4-
from usethis._integrations.pyproject_toml.io_ import read_pyproject_toml
4+
from usethis._integrations.pyproject_toml.io_ import PyprojectTOMLManager
55

66

77
class MissingRequiresPythonError(Exception):
88
"""Raised when the 'requires-python' key is missing."""
99

1010

1111
def get_requires_python() -> SpecifierSet:
12-
pyproject = read_pyproject_toml()
12+
pyproject = PyprojectTOMLManager().get()
1313

1414
try:
1515
requires_python = TypeAdapter(str).validate_python(

0 commit comments

Comments
 (0)