Skip to content

Commit 46b4d85

Browse files
authored
py(deps[dev]): Require ruff>=0.16.0 (#549)
ruff 0.16 ships a curated default rule set, and an explicit `select` replaces that set rather than extending it -- so the project's naming linters had silently opted it out of everything ruff enables by default. Leaving `select` unset and moving the project's own linters under `extend-select` takes the enabled rule count from 351 to 565, and this branch resolves every diagnostic that surfaced. - **Dependencies**: ruff floors at `>=0.16.0` in the `dev` and `lint` groups, with uv.lock relocked. A floor, not a pin, so contributors and CI agree on the diagnostics without freezing out patch releases. - **Rule selection**: `select` gives way to `extend-select`, one entry per linter, with the reason `select` stays unset recorded in pyproject.toml. - **Fixes**: `None` sorts last in type unions (RUF036); exception `__init__`s call `super().__init__()` as a statement instead of returning it (PLE0101); progress callbacks receive an aware UTC datetime rather than a naive local one (DTZ005); `GitSubmoduleManager._ls` drops a try/except/pass that guarded calls already passing `check_returncode=False` (S110, BLE001); the unused private `_TXT` alias is gone (PYI047); the two test `subprocess.run` calls that assert on `returncode` state `check=False` (PLW1510); and `libvcs.sync.svn` loses a shebang from a module that is only ever imported (EXE001). - **Scoped ignores**: S102 for the Sphinx conf's exec of `__about__.py`, PLW1509 for `run()`'s `preexec_fn` pass-through, and BLE001 for QueryList's filtering of caller-supplied objects and `SvnSync.url_rev`'s `svn info` fallback -- each scoped to its file, with the justification beside it in pyproject.toml. - **Formatting**: `ruff format` 0.16 descends into Markdown fences by default, so the Python blocks in README.md and notes/ are normalized.
2 parents 12e4469 + 491ed25 commit 46b4d85

17 files changed

Lines changed: 149 additions & 108 deletions

CHANGES

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,18 @@ $ uv add libvcs --prerelease allow
2020
_Notes on the upcoming release will go here._
2121
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
2222

23+
### Fixes
24+
25+
#### Progress callback timestamps carry a time zone (#549)
26+
27+
The `timestamp` passed to a
28+
{class}`~libvcs._internal.run.ProgressCallbackProtocol` is now an aware
29+
`datetime` in UTC. It was a naive local-time value, so timestamps taken on
30+
hosts in different zones compared and serialized as though they named the
31+
same instant, and a DST rollover made them ambiguous on a single host.
32+
Callbacks that format the value for display are unaffected; callbacks that
33+
compare, subtract, or serialize it will see the offset.
34+
2335
### Documentation
2436

2537
#### Class fields describe themselves in the API reference (#548)
@@ -38,6 +50,22 @@ Workflow actions moved to their current major releases: `actions/checkout` v7,
3850
and `dorny/paths-filter` v4. Workflow behavior is unchanged, though setup-uv no
3951
longer prunes the uv cache, so the first run after this repopulates it.
4052

53+
#### Ruff moves to 0.16 (#549)
54+
55+
Minimum `ruff>=0.16.0` (was unpinned). Contributors need the newer formatter:
56+
`ruff format` descends into Python code blocks inside Markdown, so
57+
`just ruff-format` now reaches `README.md` and the design notes alongside
58+
`src/` and `tests/`.
59+
60+
Linting also picks up ruff's own default rule set. The project used to name
61+
its linters with `select`, which replaces the defaults rather than adding to
62+
them; `extend-select` layers the same list on top instead, so `just ruff` now
63+
enforces the rules ruff recommends out of the box as well as the ones libvcs
64+
asks for. Rules that flag a deliberate idiom — the blind excepts that let
65+
{class}`~libvcs._internal.query_list.QueryList` treat an unresolvable lookup
66+
as a non-match, `subprocess.Popen`'s `preexec_fn` pass-through — are scoped
67+
per file with the reason recorded in `pyproject.toml`.
68+
4169
## libvcs 0.45.1 (2026-07-11)
4270

4371
libvcs 0.45.1 unbreaks test suites that build their documentation before running pytest: the bundled pytest plugin no longer overrides pytest's own collection-ignore rules, so generated `_build/` output stops aborting collection. The documentation now runs every Python example as a doctest against real temporary repositories, which surfaced and corrected examples that had drifted from the API.

README.md

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,7 @@ from libvcs.sync.git import GitSync
6464
repo = GitSync(
6565
url="https://github.com/vcs-python/libvcs",
6666
path=pathlib.Path.cwd() / "libvcs",
67-
remotes={
68-
'gitlab': 'https://gitlab.com/vcs-python/libvcs'
69-
}
67+
remotes={"gitlab": "https://gitlab.com/vcs-python/libvcs"},
7068
)
7169

7270
# Clone (if not exists) or fetch & update (if exists)
@@ -89,19 +87,19 @@ import pathlib
8987
from libvcs.cmd.git import Git
9088

9189
# Initialize the wrapper
92-
git = Git(path=pathlib.Path.cwd() / 'libvcs')
90+
git = Git(path=pathlib.Path.cwd() / "libvcs")
9391

9492
# Run commands directly
95-
git.clone(url='https://github.com/vcs-python/libvcs.git')
96-
git.checkout(ref='master')
93+
git.clone(url="https://github.com/vcs-python/libvcs.git")
94+
git.checkout(ref="master")
9795

9896
# Traverse branches with ORM-like filtering
99-
git.branches.create('feature/new-gui')
97+
git.branches.create("feature/new-gui")
10098
print(git.branches.ls()) # Returns QueryList for filtering
10199

102100
# Target specific entities with contextual commands
103-
git.remotes.set_url(name='origin', url='git@github.com:vcs-python/libvcs.git')
104-
git.tags.create(name='v1.0.0', message='Release version 1.0.0')
101+
git.remotes.set_url(name="origin", url="git@github.com:vcs-python/libvcs.git")
102+
git.tags.create(name="v1.0.0", message="Release version 1.0.0")
105103
```
106104

107105
### 3. URL Parsing
@@ -113,17 +111,17 @@ Stop writing regex for Git URLs. Let `libvcs` handle the edge cases.
113111
from libvcs.url.git import GitURL
114112

115113
# Validate URLs
116-
GitURL.is_valid(url='https://github.com/vcs-python/libvcs.git') # True
114+
GitURL.is_valid(url="https://github.com/vcs-python/libvcs.git") # True
117115

118116
# Parse complex URLs
119-
url = GitURL(url='git@github.com:vcs-python/libvcs.git')
117+
url = GitURL(url="git@github.com:vcs-python/libvcs.git")
120118

121-
print(url.user) # 'git'
119+
print(url.user) # 'git'
122120
print(url.hostname) # 'github.com'
123-
print(url.path) # 'vcs-python/libvcs'
121+
print(url.path) # 'vcs-python/libvcs'
124122

125123
# Transform URLs
126-
url.hostname = 'gitlab.com'
124+
url.hostname = "gitlab.com"
127125
print(url.to_url()) # 'git@gitlab.com:vcs-python/libvcs.git'
128126
```
129127

@@ -137,18 +135,16 @@ import pathlib
137135
from libvcs.pytest_plugin import CreateRepoFn
138136
from libvcs.sync.git import GitSync
139137

140-
def test_my_git_tool(
141-
create_git_remote_repo: CreateRepoFn,
142-
tmp_path: pathlib.Path
143-
):
138+
139+
def test_my_git_tool(create_git_remote_repo: CreateRepoFn, tmp_path: pathlib.Path):
144140
# Spin up a real, temporary Git server
145141
git_server = create_git_remote_repo()
146-
142+
147143
# Clone it to a temporary directory
148144
checkout_path = tmp_path / "checkout"
149145
repo = GitSync(path=checkout_path, url=f"file://{git_server}")
150146
repo.obtain()
151-
147+
152148
assert checkout_path.exists()
153149
assert (checkout_path / ".git").is_dir()
154150
```

notes/2025-11-26-command-support.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -558,23 +558,23 @@ reflog_pattern = r"(?P<sha>[a-f0-9]+) (?P<ref>[^@]+)@\{(?P<index>\d+)\}: (?P<act
558558
```python
559559
class Git:
560560
submodule: GitSubmoduleCmd
561-
remotes: GitRemoteManager # ✓ Manager pattern
561+
remotes: GitRemoteManager # ✓ Manager pattern
562562
stash: GitStashCmd
563-
branches: GitBranchManager # ✓ Manager pattern
563+
branches: GitBranchManager # ✓ Manager pattern
564564
```
565565

566566
### Planned
567567

568568
```python
569569
class Git:
570-
submodules: GitSubmoduleManager # Renamed + Manager pattern
571-
remotes: GitRemoteManager # ✓ Already done
572-
stash: GitStashManager # Refactored to Manager pattern
573-
branches: GitBranchManager # ✓ Already done
574-
tags: GitTagManager # New
575-
worktrees: GitWorktreeManager # New
576-
notes: GitNotesManager # New
577-
reflog: GitReflogManager # New
570+
submodules: GitSubmoduleManager # Renamed + Manager pattern
571+
remotes: GitRemoteManager # ✓ Already done
572+
stash: GitStashManager # Refactored to Manager pattern
573+
branches: GitBranchManager # ✓ Already done
574+
tags: GitTagManager # New
575+
worktrees: GitWorktreeManager # New
576+
notes: GitNotesManager # New
577+
reflog: GitReflogManager # New
578578
```
579579

580580
---

pyproject.toml

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ dev = [
8181
"coverage",
8282
"pytest-cov",
8383
# Lint
84-
"ruff",
84+
"ruff>=0.16.0",
8585
"mypy",
8686
]
8787

@@ -106,7 +106,7 @@ coverage =[
106106
"pytest-cov",
107107
]
108108
lint = [
109-
"ruff",
109+
"ruff>=0.16.0",
110110
"mypy",
111111
]
112112

@@ -176,7 +176,10 @@ exclude_lines = [
176176
target-version = "py310"
177177

178178
[tool.ruff.lint]
179-
select = [
179+
# `select` is deliberately unset: ruff 0.16 enables a curated default rule
180+
# set, and an explicit `select` would replace it rather than extend it.
181+
# `extend-select` layers this project's additional linters on top.
182+
extend-select = [
180183
"E", # pycodestyle
181184
"F", # pyflakes
182185
"I", # isort
@@ -193,7 +196,7 @@ select = [
193196
"PERF", # Perflint
194197
"RUF", # Ruff-specific rules
195198
"D", # pydocstyle
196-
"FA100", # future annotations
199+
"FA100", # future annotations
197200
]
198201
ignore = [
199202
"COM812", # missing trailing comma, ruff format conflict
@@ -227,6 +230,24 @@ convention = "numpy"
227230
[tool.ruff.lint.per-file-ignores]
228231
"*/__init__.py" = ["F401"]
229232
"src/libvcs/_internal/subprocess.py" = ["A002"]
233+
# Sphinx conf reads version metadata by exec'ing `__about__.py` rather than
234+
# importing libvcs, so the docs build does not depend on the package being
235+
# importable. The exec'd file is this repository's own source.
236+
"docs/conf.py" = ["S102"]
237+
# `run()` mirrors the full `subprocess.Popen` signature, `preexec_fn`
238+
# included, and defaults it to None. Dropping the parameter would break
239+
# callers who need it; whether it is safe to pass is the caller's call.
240+
"src/libvcs/_internal/run.py" = ["PLW1509"]
241+
# QueryList traverses and compares caller-supplied objects, whose
242+
# `__getattr__`, properties, and `__eq__` may raise anything at all. A
243+
# lookup that cannot be resolved is a non-match, not an error, so each
244+
# handler degrades to None/False after logging rather than propagating.
245+
"src/libvcs/_internal/query_list.py" = ["BLE001"]
246+
# `SvnSync.url_rev` falls back to shelling out to `svn info --xml` and
247+
# scraping it. Whether that path yields a URL depends on the installed
248+
# svn, the working copy layout, and the output format; an unreadable
249+
# working copy answers `(None, 0)` rather than raising at the caller.
250+
"src/libvcs/sync/svn.py" = ["BLE001"]
230251

231252
[tool.pytest.ini_options]
232253
addopts = [

src/libvcs/_internal/query_list.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class ObjectDoesNotExist(Exception):
3030
def keygetter(
3131
obj: Mapping[str, t.Any],
3232
path: str,
33-
) -> None | t.Any | str | list[str] | Mapping[str, str]:
33+
) -> t.Any | str | list[str] | Mapping[str, str] | None:
3434
"""Fetch values in objects and keys, supported nested data.
3535
3636
**With dictionaries**:
@@ -311,12 +311,12 @@ def lookup_iregex(
311311

312312
class PKRequiredException(Exception):
313313
def __init__(self, *args: object) -> None:
314-
return super().__init__("items() require a pk_key exists")
314+
super().__init__("items() require a pk_key exists")
315315

316316

317317
class OpNotFound(ValueError):
318318
def __init__(self, op: str, *args: object) -> None:
319-
return super().__init__(f"{op} not in LOOKUP_NAME_MAP")
319+
super().__init__(f"{op} not in LOOKUP_NAME_MAP")
320320

321321

322322
class QueryList(list[T], t.Generic[T]):

src/libvcs/_internal/run.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -276,7 +276,10 @@ def progress_cb(output: t.AnyStr, timestamp: datetime.datetime) -> None:
276276
if callback and callable(callback) and proc.stderr is not None:
277277
line = console_to_str(proc.stderr.read(128))
278278
if line:
279-
callback(output=line, timestamp=datetime.datetime.now())
279+
callback(
280+
output=line,
281+
timestamp=datetime.datetime.now(tz=datetime.timezone.utc),
282+
)
280283
else:
281284
code, timeout_stdout, timeout_stderr = _wait_with_deadline(
282285
proc,
@@ -286,7 +289,7 @@ def progress_cb(output: t.AnyStr, timestamp: datetime.datetime) -> None:
286289
cmd=_stringify_command(normalized_args),
287290
)
288291
if callback and callable(callback):
289-
callback(output="\r", timestamp=datetime.datetime.now())
292+
callback(output="\r", timestamp=datetime.datetime.now(tz=datetime.timezone.utc))
290293

291294
if proc.stdout is not None:
292295
raw_stdout: bytes = (
@@ -403,7 +406,9 @@ def _wait_with_deadline(
403406
):
404407
callback(
405408
output=console_to_str(trailing),
406-
timestamp=datetime.datetime.now(),
409+
timestamp=datetime.datetime.now(
410+
tz=datetime.timezone.utc
411+
),
407412
)
408413
break
409414

@@ -464,7 +469,7 @@ def _wait_with_deadline(
464469
):
465470
callback(
466471
output=console_to_str(chunk),
467-
timestamp=datetime.datetime.now(),
472+
timestamp=datetime.datetime.now(tz=datetime.timezone.utc),
468473
)
469474
finally:
470475
# Restore blocking mode so any subsequent read by the caller behaves

src/libvcs/_internal/shortcuts.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,17 @@
1818

1919
class VCSNoMatchFoundForUrl(exc.LibVCSException):
2020
def __init__(self, url: str, *args: object) -> None:
21-
return super().__init__(f"No VCS found for url: {url}")
21+
super().__init__(f"No VCS found for url: {url}")
2222

2323

2424
class VCSMultipleMatchFoundForUrl(exc.LibVCSException):
2525
def __init__(self, url: str, *args: object) -> None:
26-
return super().__init__(f"Multiple VCS found for url: {url}")
26+
super().__init__(f"Multiple VCS found for url: {url}")
2727

2828

2929
class VCSNotSupported(exc.LibVCSException):
3030
def __init__(self, url: str, vcs: str, *args: object) -> None:
31-
return super().__init__(f"VCS '{vcs}' not supported, based on URL: {url}")
31+
super().__init__(f"VCS '{vcs}' not supported, based on URL: {url}")
3232

3333

3434
@t.overload

src/libvcs/_internal/subprocess.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,14 @@
5555

5656
class SubprocessCheckOutputError(Exception):
5757
def __init__(self, output: str, *args: object) -> None:
58-
return super().__init__(f"output is not str or bytes: {output}")
58+
super().__init__(f"output is not str or bytes: {output}")
5959

6060

6161
if sys.platform == "win32":
6262
_ENV: t.TypeAlias = Mapping[str, str]
6363
else:
6464
_ENV: t.TypeAlias = Mapping[bytes, StrOrBytesPath] | Mapping[str, StrOrBytesPath]
65-
_FILE: t.TypeAlias = None | int | t.IO[t.Any]
66-
_TXT: t.TypeAlias = bytes | str
65+
_FILE: t.TypeAlias = int | t.IO[t.Any] | None
6766
#: Command
6867
_CMD: t.TypeAlias = StrOrBytesPath | Sequence[StrOrBytesPath]
6968

src/libvcs/cmd/git.py

Lines changed: 20 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3482,35 +3482,26 @@ def _ls(
34823482
branch = None
34833483

34843484
# Try to get name and URL from git config
3485-
try:
3486-
name_result = self.cmd.run(
3487-
["config", "-f", ".gitmodules", f"submodule.{path}.name"],
3488-
check_returncode=False,
3489-
)
3490-
if name_result and "error" not in name_result.lower():
3491-
submodule_name = name_result.strip()
3492-
except Exception:
3493-
pass
3494-
3495-
try:
3496-
url_result = self.cmd.run(
3497-
["config", "-f", ".gitmodules", f"submodule.{path}.url"],
3498-
check_returncode=False,
3499-
)
3500-
if url_result and "error" not in url_result.lower():
3501-
url = url_result.strip()
3502-
except Exception:
3503-
pass
3504-
3505-
try:
3506-
branch_result = self.cmd.run(
3507-
["config", "-f", ".gitmodules", f"submodule.{path}.branch"],
3508-
check_returncode=False,
3509-
)
3510-
if branch_result and "error" not in branch_result.lower():
3511-
branch = branch_result.strip()
3512-
except Exception:
3513-
pass
3485+
name_result = self.cmd.run(
3486+
["config", "-f", ".gitmodules", f"submodule.{path}.name"],
3487+
check_returncode=False,
3488+
)
3489+
if name_result and "error" not in name_result.lower():
3490+
submodule_name = name_result.strip()
3491+
3492+
url_result = self.cmd.run(
3493+
["config", "-f", ".gitmodules", f"submodule.{path}.url"],
3494+
check_returncode=False,
3495+
)
3496+
if url_result and "error" not in url_result.lower():
3497+
url = url_result.strip()
3498+
3499+
branch_result = self.cmd.run(
3500+
["config", "-f", ".gitmodules", f"submodule.{path}.branch"],
3501+
check_returncode=False,
3502+
)
3503+
if branch_result and "error" not in branch_result.lower():
3504+
branch = branch_result.strip()
35143505

35153506
submodules.append(
35163507
{

src/libvcs/cmd/svn.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class SvnPropsetValueOrValuePathRequired(exc.LibVCSException, TypeError):
2929
"""Raised when required parameters are not passed."""
3030

3131
def __init__(self, *args: object) -> None:
32-
return super().__init__("Must enter a value or value_path")
32+
super().__init__("Must enter a value or value_path")
3333

3434

3535
class Svn:

0 commit comments

Comments
 (0)