From 77908369d2e8f8b13c294328a6640b8e0fbb3a6e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 13:22:45 -0500 Subject: [PATCH 01/18] chore(uv[cooldown]): Exempt ruff from release cooldown why: uv's global `exclude-newer = "3 days"` supply-chain cooldown hides ruff 0.16.0 (released 2026-07-23) from the resolver, so the version floor in the next commit cannot resolve. what: - Add `ruff = false` to `[tool.uv.exclude-newer-package]` Temporary. Revert before merge - the cooldown clears on its own and the `ruff>=0.16` floor is what actually holds the version. --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 224e301c..80bc4c1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,6 +141,10 @@ sphinx-gp-sitemap = false sphinx-gp-theme = false sphinx-ux-autodoc-layout = false sphinx-ux-badges = false +# TEMPORARY - REVERT BEFORE MERGE. ruff 0.16.0 released 2026-07-23 and is +# still inside the 3-day cooldown, so the resolver cannot see it. Drop this +# line once the cooldown clears; the version floor is what holds ruff 0.16. +ruff = false [tool.mypy] strict = true From abf09aa68991c8de125a8c9cccdc4aed79708b9e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 13:23:04 -0500 Subject: [PATCH 02/18] py(deps[dev]): Require ruff>=0.16.0 why: ruff 0.16.0 stabilizes rules inside prefixes this project already selects and starts formatting Python code blocks in Markdown. Pinning a floor keeps contributors and CI on the same diagnostics instead of splitting on whatever ruff each machine resolved. what: - Raise `ruff` to `>=0.16.0` in the `dev` and `lint` dependency groups - Relock `uv.lock`: ruff 0.15.22 -> 0.16.0 https://astral.sh/blog/ruff-v0.16.0 --- pyproject.toml | 4 ++-- uv.lock | 47 ++++++++++++++++++++++++----------------------- 2 files changed, 26 insertions(+), 25 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 80bc4c1f..e838e9db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ dev = [ "coverage", "pytest-cov", # Lint - "ruff", + "ruff>=0.16.0", "mypy", ] @@ -106,7 +106,7 @@ coverage =[ "pytest-cov", ] lint = [ - "ruff", + "ruff>=0.16.0", "mypy", ] diff --git a/uv.lock b/uv.lock index ae280bee..dd343b4f 100644 --- a/uv.lock +++ b/uv.lock @@ -24,6 +24,7 @@ sphinx-ux-badges = false gp-libs = false sphinx-autodoc-docutils = false gp-furo-theme = false +ruff = false sphinx-gp-sitemap = false sphinx-fonts = false sphinx-autodoc-typehints-gp = false @@ -660,7 +661,7 @@ dev = [ { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, { name = "pytest-xdist" }, - { name = "ruff" }, + { name = "ruff", specifier = ">=0.16.0" }, { name = "sphinx-autobuild" }, { name = "sphinx-autodoc-api-style", specifier = "==0.0.1a36" }, { name = "sphinx-autodoc-pytest-fixtures", specifier = "==0.0.1a36" }, @@ -674,7 +675,7 @@ docs = [ ] lint = [ { name = "mypy" }, - { name = "ruff" }, + { name = "ruff", specifier = ">=0.16.0" }, ] testing = [ { name = "gp-libs", specifier = ">=0.0.19" }, @@ -1166,27 +1167,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/06/ae069393fc66e8ff33036d4b368003833bf6e88ccf182e17e7a2f1c754fd/ruff-0.15.22.tar.gz", hash = "sha256:3f15175b1fb580126f58285a5dae6b2ea89000136d980c64499211f116b54809", size = 4785063, upload-time = "2026-07-16T15:14:13.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/23/18/ee54b7ae1e121be7a28ea6da4b67564ebb0530e183a54415ab7e3bcd2c4e/ruff-0.15.22-py3-none-linux_armv6l.whl", hash = "sha256:44423e73493737f5e7c5b41d475483898ff37afcdae38bc3da5085e29af1c2d8", size = 10781258, upload-time = "2026-07-16T15:13:19.452Z" }, - { url = "https://files.pythonhosted.org/packages/2f/d2/2520cb14761ddbeaf57642a76942fc36adcbdbe53b4532241995f6fc485c/ruff-0.15.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b82c6482946e9eda7ff2e091d25b8bad3f718684e1916d41bd56873cee05b697", size = 10999477, upload-time = "2026-07-16T15:13:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/c9/10/74e53572aa758dfaa678c2a2646b5c5515d884b7ca56be4d2ce03ca4b560/ruff-0.15.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:11c1c715af53a09f714e011106bffc419751ec8232fcb5da42173284ea3fec6f", size = 10466716, upload-time = "2026-07-16T15:13:26.162Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/44eaaf0844e028182f2d0a8f2190d0f359159aed0a9e5ab861d892f1ae2a/ruff-0.15.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:742a29cf29bddb7c8327895d6a10e0e6c5b38a96dd407af9b5d0857f809c0576", size = 10892644, upload-time = "2026-07-16T15:13:29.229Z" }, - { url = "https://files.pythonhosted.org/packages/9f/21/8edf559014d2b0f82beea19cfb713993ad802ccda16868769979c6090a84/ruff-0.15.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72af58b951b0ae395935ae79763dc349bc0eb706319d28f7a33ad2cfb3cfc178", size = 10576719, upload-time = "2026-07-16T15:13:32.35Z" }, - { url = "https://files.pythonhosted.org/packages/bf/1e/3a13abd392a3b50b62e5938a831f9ab6e588358cacad5c18545b716d2182/ruff-0.15.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d425005c1835eb24e2ee4161cb90e8db263415f4a71c8c72c33abaa6c0c224", size = 11376494, upload-time = "2026-07-16T15:13:35.958Z" }, - { url = "https://files.pythonhosted.org/packages/bf/3e/422d3d95bcf04dd78e1aeac22184d4f9a8fb2c01865d39d44618484a0317/ruff-0.15.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8b9b3f8779a4f08c969defc3c8c35abffaa757e601ed5ae66d6d1db6519969a", size = 12208370, upload-time = "2026-07-16T15:13:39.185Z" }, - { url = "https://files.pythonhosted.org/packages/1e/91/5d065a0e0a02bf4813f5119ad278462eed081d2b832eb7c021ade0ec9e65/ruff-0.15.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e0dd1b2e4d3d585f897a0d137cbf4eaf6223bef4e8ce34d6bb12556c5f9249e", size = 11581098, upload-time = "2026-07-16T15:13:42.132Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f9/a0d4871d12fae702eb1f41b686caf05f1f8b124dc6db6f784f53d74918fa/ruff-0.15.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:365523eb91d9224e1bcb03b022fbf0facb8f9e23792a2c53d9d4b3924bdbdebb", size = 11399422, upload-time = "2026-07-16T15:13:45.2Z" }, - { url = "https://files.pythonhosted.org/packages/18/80/c843a5176cddbceb0b7e8dd41cf9993490796c1c469348d384f5a5c13c56/ruff-0.15.22-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fabfd168afdf29fee5be98b831efa9683c94d7c5a3b58b9ce5a2e38444589a74", size = 11381683, upload-time = "2026-07-16T15:13:48.46Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/8485de0ae92239438a36cfc51350db9b9e85c9ebdfaea91b18e422706662/ruff-0.15.22-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:225dbf095a87f1d9f90f5fd7924d2613ee452a75a4308c63a8f50f761787aa7c", size = 10850295, upload-time = "2026-07-16T15:13:51.655Z" }, - { url = "https://files.pythonhosted.org/packages/fa/91/24977ec2ec72eaf15e4394ace2959fdff2dd1e14f03e005e838023407169/ruff-0.15.22-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1877d63b9d24ed278744f1523fd11b85540566d54641f97c566d7d9dc5ca5296", size = 10579640, upload-time = "2026-07-16T15:13:54.79Z" }, - { url = "https://files.pythonhosted.org/packages/9c/47/9b51216951974df1f263ac19da550d34252e0ed7218c25f10c5ef9ed7517/ruff-0.15.22-py3-none-musllinux_1_2_i686.whl", hash = "sha256:a1606c510bd7215680d32efab38965f7cdec3ef69f5170a3f4791404ffdd5262", size = 11105077, upload-time = "2026-07-16T15:13:57.915Z" }, - { url = "https://files.pythonhosted.org/packages/c2/47/20e9d4a3b8016778acea5fc32bb50d35d207500a17ddb529ffa6996feef8/ruff-0.15.22-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:630479b18625f5ffc373f77603a22a9f8ac0acd7ff0501178b5db28ec71e9c64", size = 11490980, upload-time = "2026-07-16T15:14:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/4d/76/3f72d8fc38c1cb77b38c56a70da9d0c17700cc1cc50f9649c9d3c8f5ba71/ruff-0.15.22-py3-none-win32.whl", hash = "sha256:e5ba0e4a13fd14abbed2a77b517a3911290c6c6c59ef67784328d1668fab76cf", size = 10789165, upload-time = "2026-07-16T15:14:04.16Z" }, - { url = "https://files.pythonhosted.org/packages/cb/46/4965251734c2b6fcdca1b1b187d20bcac3af0ee5b083b89c910bb961ce3a/ruff-0.15.22-py3-none-win_amd64.whl", hash = "sha256:9be63ba1eb936acd2d1342fb8337c356353706fce233b2a15a09a97037e6acde", size = 11938297, upload-time = "2026-07-16T15:14:07.316Z" }, - { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, ] [[package]] From 0f8684d54d34fc69092b3212ba66f61e2162940a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 14:28:20 -0500 Subject: [PATCH 03/18] _internal(style[types]): Sort None last in type unions why: ruff 0.16.0 stabilizes RUF036, which requires `None` to be the last member of a type union. It reads as the fallback case and matches how `Optional[X]` and typeshed order their unions. what: - Reorder `keygetter`'s return annotation to end in `| None` - Reorder the `_FILE` subprocess alias to end in `| None`, matching the identical alias in `_internal/run.py` - Reorder `test_create_project`'s `raises_exception` parameter https://docs.astral.sh/ruff/rules/none-not-at-end-of-union/ --- src/libvcs/_internal/query_list.py | 2 +- src/libvcs/_internal/subprocess.py | 2 +- tests/test_shortcuts.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libvcs/_internal/query_list.py b/src/libvcs/_internal/query_list.py index a76a22ea..2141c0b2 100644 --- a/src/libvcs/_internal/query_list.py +++ b/src/libvcs/_internal/query_list.py @@ -30,7 +30,7 @@ class ObjectDoesNotExist(Exception): def keygetter( obj: Mapping[str, t.Any], path: str, -) -> None | t.Any | str | list[str] | Mapping[str, str]: +) -> t.Any | str | list[str] | Mapping[str, str] | None: """Fetch values in objects and keys, supported nested data. **With dictionaries**: diff --git a/src/libvcs/_internal/subprocess.py b/src/libvcs/_internal/subprocess.py index 228598d7..b1d357c1 100644 --- a/src/libvcs/_internal/subprocess.py +++ b/src/libvcs/_internal/subprocess.py @@ -62,7 +62,7 @@ def __init__(self, output: str, *args: object) -> None: _ENV: t.TypeAlias = Mapping[str, str] else: _ENV: t.TypeAlias = Mapping[bytes, StrOrBytesPath] | Mapping[str, StrOrBytesPath] -_FILE: t.TypeAlias = None | int | t.IO[t.Any] +_FILE: t.TypeAlias = int | t.IO[t.Any] | None _TXT: t.TypeAlias = bytes | str #: Command _CMD: t.TypeAlias = StrOrBytesPath | Sequence[StrOrBytesPath] diff --git a/tests/test_shortcuts.py b/tests/test_shortcuts.py index db2547f8..d3b8d082 100644 --- a/tests/test_shortcuts.py +++ b/tests/test_shortcuts.py @@ -58,7 +58,7 @@ def test_create_project( tmp_path: pathlib.Path, repo_dict: CreateProjectKwargsDict, repo_class: type[SvnSync | GitSync | HgSync], - raises_exception: None | type[E] | tuple[type[E], ...], + raises_exception: type[E] | tuple[type[E], ...] | None, ) -> None: """Tests for create_project().""" # add parent_dir via fixture From 858a59af2e71f0a39ecda498957e8a319bef88ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 14:28:46 -0500 Subject: [PATCH 04/18] style(fmt[markdown]): Format Python blocks in Markdown why: ruff 0.16.0 formats Python and pycon code blocks inside Markdown by default, so `ruff format . --check` now fails on docs that were never run through the formatter. The churn is mechanical - quote style, argument wrapping, comment spacing, trailing whitespace. what: - Reformat the Python blocks in `README.md` - Reformat the Python blocks in `notes/2025-11-26-command-support.md` https://astral.sh/blog/ruff-v0.16.0 --- README.md | 36 +++++++++++++---------------- notes/2025-11-26-command-support.md | 20 ++++++++-------- 2 files changed, 26 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index abddc30b..00cfd238 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,7 @@ from libvcs.sync.git import GitSync repo = GitSync( url="https://github.com/vcs-python/libvcs", path=pathlib.Path.cwd() / "libvcs", - remotes={ - 'gitlab': 'https://gitlab.com/vcs-python/libvcs' - } + remotes={"gitlab": "https://gitlab.com/vcs-python/libvcs"}, ) # Clone (if not exists) or fetch & update (if exists) @@ -89,19 +87,19 @@ import pathlib from libvcs.cmd.git import Git # Initialize the wrapper -git = Git(path=pathlib.Path.cwd() / 'libvcs') +git = Git(path=pathlib.Path.cwd() / "libvcs") # Run commands directly -git.clone(url='https://github.com/vcs-python/libvcs.git') -git.checkout(ref='master') +git.clone(url="https://github.com/vcs-python/libvcs.git") +git.checkout(ref="master") # Traverse branches with ORM-like filtering -git.branches.create('feature/new-gui') +git.branches.create("feature/new-gui") print(git.branches.ls()) # Returns QueryList for filtering # Target specific entities with contextual commands -git.remotes.set_url(name='origin', url='git@github.com:vcs-python/libvcs.git') -git.tags.create(name='v1.0.0', message='Release version 1.0.0') +git.remotes.set_url(name="origin", url="git@github.com:vcs-python/libvcs.git") +git.tags.create(name="v1.0.0", message="Release version 1.0.0") ``` ### 3. URL Parsing @@ -113,17 +111,17 @@ Stop writing regex for Git URLs. Let `libvcs` handle the edge cases. from libvcs.url.git import GitURL # Validate URLs -GitURL.is_valid(url='https://github.com/vcs-python/libvcs.git') # True +GitURL.is_valid(url="https://github.com/vcs-python/libvcs.git") # True # Parse complex URLs -url = GitURL(url='git@github.com:vcs-python/libvcs.git') +url = GitURL(url="git@github.com:vcs-python/libvcs.git") -print(url.user) # 'git' +print(url.user) # 'git' print(url.hostname) # 'github.com' -print(url.path) # 'vcs-python/libvcs' +print(url.path) # 'vcs-python/libvcs' # Transform URLs -url.hostname = 'gitlab.com' +url.hostname = "gitlab.com" print(url.to_url()) # 'git@gitlab.com:vcs-python/libvcs.git' ``` @@ -137,18 +135,16 @@ import pathlib from libvcs.pytest_plugin import CreateRepoFn from libvcs.sync.git import GitSync -def test_my_git_tool( - create_git_remote_repo: CreateRepoFn, - tmp_path: pathlib.Path -): + +def test_my_git_tool(create_git_remote_repo: CreateRepoFn, tmp_path: pathlib.Path): # Spin up a real, temporary Git server git_server = create_git_remote_repo() - + # Clone it to a temporary directory checkout_path = tmp_path / "checkout" repo = GitSync(path=checkout_path, url=f"file://{git_server}") repo.obtain() - + assert checkout_path.exists() assert (checkout_path / ".git").is_dir() ``` diff --git a/notes/2025-11-26-command-support.md b/notes/2025-11-26-command-support.md index 32bc4be6..de26d22a 100644 --- a/notes/2025-11-26-command-support.md +++ b/notes/2025-11-26-command-support.md @@ -558,23 +558,23 @@ reflog_pattern = r"(?P[a-f0-9]+) (?P[^@]+)@\{(?P\d+)\}: (?P Date: Sun, 26 Jul 2026 14:32:23 -0500 Subject: [PATCH 05/18] docs(CHANGES[dev]): Note ruff 0.16 floor why: The ruff floor changes what contributors need installed and widens what `ruff format` rewrites, so it belongs in the unreleased entry. what: - Add a `#### Ruff moves to 0.16 (#549)` deliverable under `### Development` --- CHANGES | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES b/CHANGES index 896425e9..30e7e818 100644 --- a/CHANGES +++ b/CHANGES @@ -38,6 +38,13 @@ Workflow actions moved to their current major releases: `actions/checkout` v7, and `dorny/paths-filter` v4. Workflow behavior is unchanged, though setup-uv no longer prunes the uv cache, so the first run after this repopulates it. +#### Ruff moves to 0.16 (#549) + +Minimum `ruff>=0.16.0` (was unpinned). Contributors need the newer formatter: +`ruff format` descends into Python code blocks inside Markdown, so +`just ruff-format` now reaches `README.md` and the design notes alongside +`src/` and `tests/`. + ## libvcs 0.45.1 (2026-07-11) 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. From ffa757e59d20705de76381e1f06306eb560a810c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 14:39:23 -0500 Subject: [PATCH 06/18] Revert "chore(uv[cooldown]): Exempt ruff from release cooldown" why: ruff 0.16.0 published 2026-07-23T19:10Z and has now cleared uv's 3-day supply-chain cooldown, so the resolver reaches it unaided. The `ruff>=0.16.0` floor is what holds the version; leaving the exemption would permanently opt ruff out of the cooldown guard. what: - Drop `ruff = false` from `[tool.uv.exclude-newer-package]` - Relock: the setting is recorded in `uv.lock`, so removing it forces a re-resolve. ruff stays at 0.16.0 and no other package moves. This reverts commit 77908369d2e8f8b13c294328a6640b8e0fbb3a6e. --- pyproject.toml | 4 ---- uv.lock | 1 - 2 files changed, 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e838e9db..51e7d258 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -141,10 +141,6 @@ sphinx-gp-sitemap = false sphinx-gp-theme = false sphinx-ux-autodoc-layout = false sphinx-ux-badges = false -# TEMPORARY - REVERT BEFORE MERGE. ruff 0.16.0 released 2026-07-23 and is -# still inside the 3-day cooldown, so the resolver cannot see it. Drop this -# line once the cooldown clears; the version floor is what holds ruff 0.16. -ruff = false [tool.mypy] strict = true diff --git a/uv.lock b/uv.lock index dd343b4f..2f98003e 100644 --- a/uv.lock +++ b/uv.lock @@ -24,7 +24,6 @@ sphinx-ux-badges = false gp-libs = false sphinx-autodoc-docutils = false gp-furo-theme = false -ruff = false sphinx-gp-sitemap = false sphinx-fonts = false sphinx-autodoc-typehints-gp = false From 6e8111962e486dfb1141c0e6dc9829e5874137eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 15:04:18 -0500 Subject: [PATCH 07/18] chore(ruff[lint]): Adopt ruff's default rule set why: ruff 0.16 ships a curated 413-rule default set as its recommended baseline. An explicit `select` replaces that set rather than extending it, so this project was running 351 rules and silently opting out of the default set. `extend-select` layers the project's own linters on top of the default set instead of in place of it. what: - Replace `select` with `extend-select`, same entries, one per line - Note in the file why `select` stays unset Enabled rules go from 351 to 565. https://docs.astral.sh/ruff/linter/#rule-selection --- pyproject.toml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 51e7d258..53eef7e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,7 +176,10 @@ exclude_lines = [ target-version = "py310" [tool.ruff.lint] -select = [ +# `select` is deliberately unset: ruff 0.16 enables a curated default rule +# set, and an explicit `select` would replace it rather than extend it. +# `extend-select` layers this project's additional linters on top. +extend-select = [ "E", # pycodestyle "F", # pyflakes "I", # isort @@ -193,7 +196,7 @@ select = [ "PERF", # Perflint "RUF", # Ruff-specific rules "D", # pydocstyle - "FA100", # future annotations + "FA100", # future annotations ] ignore = [ "COM812", # missing trailing comma, ruff format conflict From e8097e34d7f9291aacbe78db6c6b150dd1145742 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 15:39:18 -0500 Subject: [PATCH 08/18] fix(exc[__init__]): Drop return from super().__init__() why: `__init__` must return None. `return super().__init__(...)` reads as if the base initializer produced a value worth propagating; it does not, and the form masks a genuine mistake if one is ever introduced there. what: - Call `super().__init__(...)` as a statement in every exception class https://docs.astral.sh/ruff/rules/return-in-init/ --- src/libvcs/_internal/query_list.py | 4 ++-- src/libvcs/_internal/shortcuts.py | 6 +++--- src/libvcs/_internal/subprocess.py | 2 +- src/libvcs/cmd/svn.py | 2 +- src/libvcs/pytest_plugin.py | 2 +- src/libvcs/sync/git.py | 8 ++++---- src/libvcs/sync/svn.py | 2 +- 7 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/libvcs/_internal/query_list.py b/src/libvcs/_internal/query_list.py index 2141c0b2..d465d054 100644 --- a/src/libvcs/_internal/query_list.py +++ b/src/libvcs/_internal/query_list.py @@ -311,12 +311,12 @@ def lookup_iregex( class PKRequiredException(Exception): def __init__(self, *args: object) -> None: - return super().__init__("items() require a pk_key exists") + super().__init__("items() require a pk_key exists") class OpNotFound(ValueError): def __init__(self, op: str, *args: object) -> None: - return super().__init__(f"{op} not in LOOKUP_NAME_MAP") + super().__init__(f"{op} not in LOOKUP_NAME_MAP") class QueryList(list[T], t.Generic[T]): diff --git a/src/libvcs/_internal/shortcuts.py b/src/libvcs/_internal/shortcuts.py index 9cd8a94e..f4ae20bd 100644 --- a/src/libvcs/_internal/shortcuts.py +++ b/src/libvcs/_internal/shortcuts.py @@ -18,17 +18,17 @@ class VCSNoMatchFoundForUrl(exc.LibVCSException): def __init__(self, url: str, *args: object) -> None: - return super().__init__(f"No VCS found for url: {url}") + super().__init__(f"No VCS found for url: {url}") class VCSMultipleMatchFoundForUrl(exc.LibVCSException): def __init__(self, url: str, *args: object) -> None: - return super().__init__(f"Multiple VCS found for url: {url}") + super().__init__(f"Multiple VCS found for url: {url}") class VCSNotSupported(exc.LibVCSException): def __init__(self, url: str, vcs: str, *args: object) -> None: - return super().__init__(f"VCS '{vcs}' not supported, based on URL: {url}") + super().__init__(f"VCS '{vcs}' not supported, based on URL: {url}") @t.overload diff --git a/src/libvcs/_internal/subprocess.py b/src/libvcs/_internal/subprocess.py index b1d357c1..df9561bb 100644 --- a/src/libvcs/_internal/subprocess.py +++ b/src/libvcs/_internal/subprocess.py @@ -55,7 +55,7 @@ class SubprocessCheckOutputError(Exception): def __init__(self, output: str, *args: object) -> None: - return super().__init__(f"output is not str or bytes: {output}") + super().__init__(f"output is not str or bytes: {output}") if sys.platform == "win32": diff --git a/src/libvcs/cmd/svn.py b/src/libvcs/cmd/svn.py index 3bc05832..8c32a821 100644 --- a/src/libvcs/cmd/svn.py +++ b/src/libvcs/cmd/svn.py @@ -29,7 +29,7 @@ class SvnPropsetValueOrValuePathRequired(exc.LibVCSException, TypeError): """Raised when required parameters are not passed.""" def __init__(self, *args: object) -> None: - return super().__init__("Must enter a value or value_path") + super().__init__("Must enter a value or value_path") class Svn: diff --git a/src/libvcs/pytest_plugin.py b/src/libvcs/pytest_plugin.py index e4a6fe4d..474ff552 100644 --- a/src/libvcs/pytest_plugin.py +++ b/src/libvcs/pytest_plugin.py @@ -24,7 +24,7 @@ class MaxUniqueRepoAttemptsExceeded(exc.LibVCSException): def __init__(self, attempts: int, *args: object) -> None: """Raise LibVCSException exception with message including attempts tried.""" - return super().__init__( + super().__init__( f"Could not find unused repo destination (attempts: {attempts})", ) diff --git a/src/libvcs/sync/git.py b/src/libvcs/sync/git.py index 71527109..c56c1486 100644 --- a/src/libvcs/sync/git.py +++ b/src/libvcs/sync/git.py @@ -42,7 +42,7 @@ class GitStatusParsingException(exc.LibVCSException): """Raised when git status output is not in the expected format.""" def __init__(self, git_status_output: str, *args: object) -> None: - return super().__init__( + super().__init__( "Could not find match for git-status(1)" + f"Output: {git_status_output}", ) @@ -51,21 +51,21 @@ class GitRemoteOriginMissing(exc.LibVCSException): """Raised when git origin remote was not found.""" def __init__(self, remotes: list[str], *args: object) -> None: - return super().__init__(f"Missing origin. Remotes: {', '.join(remotes)}") + super().__init__(f"Missing origin. Remotes: {', '.join(remotes)}") class GitRemoteSetError(exc.LibVCSException): """Raised when a git remote could not be set.""" def __init__(self, remote_name: str) -> None: - return super().__init__(f"Remote {remote_name} not found after setting") + super().__init__(f"Remote {remote_name} not found after setting") class GitNoBranchFound(exc.LibVCSException): """Raised with git branch could not be found.""" def __init__(self, *args: object) -> None: - return super().__init__("No branch found for git repository") + super().__init__("No branch found for git repository") class GitRemoteRefNotFound(exc.CommandError): diff --git a/src/libvcs/sync/svn.py b/src/libvcs/sync/svn.py index 49fd7818..ec7e855b 100644 --- a/src/libvcs/sync/svn.py +++ b/src/libvcs/sync/svn.py @@ -33,7 +33,7 @@ class SvnUrlRevFormattingError(ValueError): """Raised when SVN Revision output is not in the expected format.""" def __init__(self, data: str, *args: object) -> None: - return super().__init__(f"Badly formatted data: {data!r}") + super().__init__(f"Badly formatted data: {data!r}") class SvnSync(BaseSync): From 1edf159839d32ea39b266b6868034be8fa0b5682 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 15:39:33 -0500 Subject: [PATCH 09/18] refactor(subprocess[types]): Drop unused _TXT alias why: `_TXT` was a private type alias with no annotation referencing it, so it carried no meaning for readers or type checkers. what: - Remove the `_TXT` type alias from `libvcs._internal.subprocess` https://docs.astral.sh/ruff/rules/unused-private-type-alias/ --- src/libvcs/_internal/subprocess.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libvcs/_internal/subprocess.py b/src/libvcs/_internal/subprocess.py index df9561bb..c40715b3 100644 --- a/src/libvcs/_internal/subprocess.py +++ b/src/libvcs/_internal/subprocess.py @@ -63,7 +63,6 @@ def __init__(self, output: str, *args: object) -> None: else: _ENV: t.TypeAlias = Mapping[bytes, StrOrBytesPath] | Mapping[str, StrOrBytesPath] _FILE: t.TypeAlias = int | t.IO[t.Any] | None -_TXT: t.TypeAlias = bytes | str #: Command _CMD: t.TypeAlias = StrOrBytesPath | Sequence[StrOrBytesPath] From e124a4dad395a66b87921419a59f71445a76b07b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 15:39:54 -0500 Subject: [PATCH 10/18] fix(run[callback]): Stamp progress callbacks in UTC why: The `timestamp` handed to a progress callback came from a naive `datetime.now()`, so it carried the host's local wall clock with no offset attached. Two hosts in different zones produced timestamps that compare and serialize as if they were the same instant, and a DST rollover makes them ambiguous or non-monotonic on a single host. what: - Pass `tz=datetime.timezone.utc` to every `datetime.now()` feeding `ProgressCallbackProtocol` https://docs.astral.sh/ruff/rules/call-datetime-now-without-tzinfo/ --- src/libvcs/_internal/run.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/libvcs/_internal/run.py b/src/libvcs/_internal/run.py index 345ca7dd..c559116f 100644 --- a/src/libvcs/_internal/run.py +++ b/src/libvcs/_internal/run.py @@ -276,7 +276,10 @@ def progress_cb(output: t.AnyStr, timestamp: datetime.datetime) -> None: if callback and callable(callback) and proc.stderr is not None: line = console_to_str(proc.stderr.read(128)) if line: - callback(output=line, timestamp=datetime.datetime.now()) + callback( + output=line, + timestamp=datetime.datetime.now(tz=datetime.timezone.utc), + ) else: code, timeout_stdout, timeout_stderr = _wait_with_deadline( proc, @@ -286,7 +289,7 @@ def progress_cb(output: t.AnyStr, timestamp: datetime.datetime) -> None: cmd=_stringify_command(normalized_args), ) if callback and callable(callback): - callback(output="\r", timestamp=datetime.datetime.now()) + callback(output="\r", timestamp=datetime.datetime.now(tz=datetime.timezone.utc)) if proc.stdout is not None: raw_stdout: bytes = ( @@ -403,7 +406,9 @@ def _wait_with_deadline( ): callback( output=console_to_str(trailing), - timestamp=datetime.datetime.now(), + timestamp=datetime.datetime.now( + tz=datetime.timezone.utc + ), ) break @@ -464,7 +469,7 @@ def _wait_with_deadline( ): callback( output=console_to_str(chunk), - timestamp=datetime.datetime.now(), + timestamp=datetime.datetime.now(tz=datetime.timezone.utc), ) finally: # Restore blocking mode so any subsequent read by the caller behaves From b83a45f0b8b1c3a75112523ac8b1de08f1c9cdf7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 15:40:13 -0500 Subject: [PATCH 11/18] test(subprocess[run]): State check= explicitly why: `subprocess.run` defaults to `check=False`, so a reader cannot tell whether a call site meant to ignore the exit status or forgot to check it. Both of these deliberately inspect `returncode` themselves to attach stderr to the assertion message. what: - Pass `check=False` at the two call sites that assert on `returncode` https://docs.astral.sh/ruff/rules/subprocess-run-without-check/ --- tests/cmd/test_git.py | 1 + tests/test_pytest_plugin.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/cmd/test_git.py b/tests/cmd/test_git.py index c5288f92..09e9978f 100644 --- a/tests/cmd/test_git.py +++ b/tests/cmd/test_git.py @@ -2744,6 +2744,7 @@ def test_run_trim_false_preserves_diff(git_repo: GitSync) -> None: input=captured, capture_output=True, text=True, + check=False, ) assert check.returncode == 0, check.stderr diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 6c9373d2..df29d1b3 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -248,6 +248,7 @@ def test_gitconfig_submodule_file_protocol( cwd=main_repo, capture_output=True, text=True, + check=False, ) # Assert: submodule add should succeed (no "fatal" errors) From 07bdf9f631fb3d91d155755943ca4a3630f20d36 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 15:40:55 -0500 Subject: [PATCH 12/18] refactor(git[_ls]): Drop dead guards around submodule config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Each `.gitmodules` lookup passes `check_returncode=False`, which is precisely the switch that stops `run()` raising on a non-zero exit — a missing or unreadable `.gitmodules` already comes back as output text that the following `if` filters out. The wrapping `try`/`except Exception`/`pass` therefore guarded a failure mode the call cannot produce, while silently swallowing any real one. what: - Call `git config` directly for submodule name, url, and branch The same deletion clears the blind-except at these sites. https://docs.astral.sh/ruff/rules/try-except-pass/ https://docs.astral.sh/ruff/rules/blind-except/ --- src/libvcs/cmd/git.py | 49 ++++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/src/libvcs/cmd/git.py b/src/libvcs/cmd/git.py index fdebeaa2..e6f695f1 100644 --- a/src/libvcs/cmd/git.py +++ b/src/libvcs/cmd/git.py @@ -3482,35 +3482,26 @@ def _ls( branch = None # Try to get name and URL from git config - try: - name_result = self.cmd.run( - ["config", "-f", ".gitmodules", f"submodule.{path}.name"], - check_returncode=False, - ) - if name_result and "error" not in name_result.lower(): - submodule_name = name_result.strip() - except Exception: - pass - - try: - url_result = self.cmd.run( - ["config", "-f", ".gitmodules", f"submodule.{path}.url"], - check_returncode=False, - ) - if url_result and "error" not in url_result.lower(): - url = url_result.strip() - except Exception: - pass - - try: - branch_result = self.cmd.run( - ["config", "-f", ".gitmodules", f"submodule.{path}.branch"], - check_returncode=False, - ) - if branch_result and "error" not in branch_result.lower(): - branch = branch_result.strip() - except Exception: - pass + name_result = self.cmd.run( + ["config", "-f", ".gitmodules", f"submodule.{path}.name"], + check_returncode=False, + ) + if name_result and "error" not in name_result.lower(): + submodule_name = name_result.strip() + + url_result = self.cmd.run( + ["config", "-f", ".gitmodules", f"submodule.{path}.url"], + check_returncode=False, + ) + if url_result and "error" not in url_result.lower(): + url = url_result.strip() + + branch_result = self.cmd.run( + ["config", "-f", ".gitmodules", f"submodule.{path}.branch"], + check_returncode=False, + ) + if branch_result and "error" not in branch_result.lower(): + branch = branch_result.strip() submodules.append( { From ecc75fffbb977ff19c01680db94f9cf4798898c1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 15:41:20 -0500 Subject: [PATCH 13/18] chore(ruff[S102]): Allow exec in Sphinx conf why: `docs/conf.py` reads `__title__`, `__version__`, and friends by exec'ing `src/libvcs/__about__.py` into a dict. That is deliberate: it keeps the docs build from importing libvcs, so version metadata resolves even when the package itself is not installed. The exec'd file is this repository's own source, not untrusted input. what: - Per-file-ignore S102 for `docs/conf.py`, with the reason in the config https://docs.astral.sh/ruff/rules/exec-builtin/ --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 53eef7e4..5586c308 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -230,6 +230,10 @@ convention = "numpy" [tool.ruff.lint.per-file-ignores] "*/__init__.py" = ["F401"] "src/libvcs/_internal/subprocess.py" = ["A002"] +# Sphinx conf reads version metadata by exec'ing `__about__.py` rather than +# importing libvcs, so the docs build does not depend on the package being +# importable. The exec'd file is this repository's own source. +"docs/conf.py" = ["S102"] [tool.pytest.ini_options] addopts = [ From 103a8c0892928ca6dde7161df88c5e8f02d86d75 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 15:41:36 -0500 Subject: [PATCH 14/18] chore(ruff[PLW1509]): Allow preexec_fn pass-through why: `run()` reproduces `subprocess.Popen`'s signature so callers can reach any of it, and `preexec_fn` is one of those parameters. It defaults to None, so libvcs never forks with a preexec hook on its own; the thread-safety hazard the rule warns about belongs to a caller that opts in. Removing the parameter would break that caller with nothing to replace it. what: - Per-file-ignore PLW1509 for `src/libvcs/_internal/run.py`, with the reason in the config https://docs.astral.sh/ruff/rules/subprocess-popen-preexec-fn/ --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 5586c308..90dc947a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -234,6 +234,10 @@ convention = "numpy" # importing libvcs, so the docs build does not depend on the package being # importable. The exec'd file is this repository's own source. "docs/conf.py" = ["S102"] +# `run()` mirrors the full `subprocess.Popen` signature, `preexec_fn` +# included, and defaults it to None. Dropping the parameter would break +# callers who need it; whether it is safe to pass is the caller's call. +"src/libvcs/_internal/run.py" = ["PLW1509"] [tool.pytest.ini_options] addopts = [ From 4e78ec6f44dc80fdc9cf4e899bcb6bc0ff0f98de Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 15:41:53 -0500 Subject: [PATCH 15/18] chore(ruff[BLE001]): Allow blind except in QueryList why: QueryList filters arbitrary caller-supplied objects. Resolving a `food__istartswith` path means walking `__getattr__`, properties, and `__eq__` that libvcs did not write and cannot enumerate the failure modes of. An unresolvable lookup is a non-match, not an error, so `keygetter` and `parse_lookup` log at debug and return None while the `in`/`nin` predicates return False. Narrowing the handlers would let a foreign property abort an entire filter. what: - Per-file-ignore BLE001 for `src/libvcs/_internal/query_list.py`, with the reason in the config https://docs.astral.sh/ruff/rules/blind-except/ --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 90dc947a..fff718f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -238,6 +238,11 @@ convention = "numpy" # included, and defaults it to None. Dropping the parameter would break # callers who need it; whether it is safe to pass is the caller's call. "src/libvcs/_internal/run.py" = ["PLW1509"] +# QueryList traverses and compares caller-supplied objects, whose +# `__getattr__`, properties, and `__eq__` may raise anything at all. A +# lookup that cannot be resolved is a non-match, not an error, so each +# handler degrades to None/False after logging rather than propagating. +"src/libvcs/_internal/query_list.py" = ["BLE001"] [tool.pytest.ini_options] addopts = [ From 530ffadc88afebce7392fca5c53e671c0794cbe9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 15:42:10 -0500 Subject: [PATCH 16/18] chore(ruff[BLE001]): Allow blind except in SvnSync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: `SvnSync.url_rev` falls back to shelling out to `svn info --xml` and scraping the result when neither the `.svn/entries` nor the XML layout applies. What that can raise depends on the installed svn, the working copy layout, and the output format — a `CommandError`, a failed match, a non-integer revision. The method's contract is to answer `(None, 0)` for a working copy it cannot read, so the fallback absorbs all of them. what: - Per-file-ignore BLE001 for `src/libvcs/sync/svn.py`, with the reason in the config https://docs.astral.sh/ruff/rules/blind-except/ --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index fff718f2..7d32ad36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -243,6 +243,11 @@ convention = "numpy" # lookup that cannot be resolved is a non-match, not an error, so each # handler degrades to None/False after logging rather than propagating. "src/libvcs/_internal/query_list.py" = ["BLE001"] +# `SvnSync.url_rev` falls back to shelling out to `svn info --xml` and +# scraping it. Whether that path yields a URL depends on the installed +# svn, the working copy layout, and the output format; an unreadable +# working copy answers `(None, 0)` rather than raising at the caller. +"src/libvcs/sync/svn.py" = ["BLE001"] [tool.pytest.ini_options] addopts = [ From fcef94db89888fec421467cb4d465263e3d54deb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 16:05:09 -0500 Subject: [PATCH 17/18] docs(CHANGES[dev]): Note default rule set and UTC stamps why: Adopting ruff's default set changes what `just ruff` enforces for contributors, and the progress-callback timestamp change is visible to anyone who compares or serializes that value. what: - Extend the ruff 0.16 deliverable with the `extend-select` switch - Add a `### Fixes` deliverable for tz-aware callback timestamps --- CHANGES | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGES b/CHANGES index 30e7e818..ba893f33 100644 --- a/CHANGES +++ b/CHANGES @@ -20,6 +20,18 @@ $ uv add libvcs --prerelease allow _Notes on the upcoming release will go here._ +### Fixes + +#### Progress callback timestamps carry a time zone (#549) + +The `timestamp` passed to a +{class}`~libvcs._internal.run.ProgressCallbackProtocol` is now an aware +`datetime` in UTC. It was a naive local-time value, so timestamps taken on +hosts in different zones compared and serialized as though they named the +same instant, and a DST rollover made them ambiguous on a single host. +Callbacks that format the value for display are unaffected; callbacks that +compare, subtract, or serialize it will see the offset. + ### Documentation #### Class fields describe themselves in the API reference (#548) @@ -45,6 +57,15 @@ Minimum `ruff>=0.16.0` (was unpinned). Contributors need the newer formatter: `just ruff-format` now reaches `README.md` and the design notes alongside `src/` and `tests/`. +Linting also picks up ruff's own default rule set. The project used to name +its linters with `select`, which replaces the defaults rather than adding to +them; `extend-select` layers the same list on top instead, so `just ruff` now +enforces the rules ruff recommends out of the box as well as the ones libvcs +asks for. Rules that flag a deliberate idiom — the blind excepts that let +{class}`~libvcs._internal.query_list.QueryList` treat an unresolvable lookup +as a non-match, `subprocess.Popen`'s `preexec_fn` pass-through — are scoped +per file with the reason recorded in `pyproject.toml`. + ## libvcs 0.45.1 (2026-07-11) 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. From 491ed2549f13bab208692fad50525b466c87e805 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 26 Jul 2026 16:12:39 -0500 Subject: [PATCH 18/18] refactor(svn[module]): Drop shebang from library module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: `libvcs.sync.svn` is imported, never executed — it has no `__main__` guard and ships without the executable bit — so the `#!/usr/bin/env python` line advertised an entry point that does not exist. The rule is skipped on WSL, so it only surfaced on CI's Linux runners. what: - Remove the shebang from `src/libvcs/sync/svn.py` https://docs.astral.sh/ruff/rules/shebang-not-executable/ --- src/libvcs/sync/svn.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libvcs/sync/svn.py b/src/libvcs/sync/svn.py index ec7e855b..f4968ea0 100644 --- a/src/libvcs/sync/svn.py +++ b/src/libvcs/sync/svn.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python """Tool to manage a local SVN (Subversion) working copy from a repository. .. todo::