pysvnlite 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ **/__pycache__/
2
+ .pytest_cache/
3
+ .mypy_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ dist/
7
+ .release-smoke/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 yuanzhen qiao
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: pysvnlite
3
+ Version: 0.2.0
4
+ Summary: Lightweight, non-interactive Subversion CLI wrapper and XML parsers
5
+ Project-URL: Homepage, https://github.com/narutozb/pysvnlite
6
+ Project-URL: Documentation, https://github.com/narutozb/pysvnlite/tree/main/docs
7
+ Project-URL: Repository, https://github.com/narutozb/pysvnlite.git
8
+ Project-URL: Issues, https://github.com/narutozb/pysvnlite/issues
9
+ Author: yuanzhen qiao
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Requires-Python: >=3.9
13
+ Provides-Extra: dev
14
+ Requires-Dist: build>=1.2; extra == 'dev'
15
+ Requires-Dist: mypy<2.0,>=1.11; extra == 'dev'
16
+ Requires-Dist: packaging>=23.2; extra == 'dev'
17
+ Requires-Dist: pytest>=8.0; extra == 'dev'
18
+ Requires-Dist: ruff>=0.6; extra == 'dev'
19
+ Requires-Dist: tomli>=2.0.1; (python_version < '3.11') and extra == 'dev'
20
+ Provides-Extra: release
21
+ Requires-Dist: build>=1.2; extra == 'release'
22
+ Requires-Dist: packaging>=23.2; extra == 'release'
23
+ Requires-Dist: tomli>=2.0.1; (python_version < '3.11') and extra == 'release'
24
+ Requires-Dist: twine>=5.1; extra == 'release'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # pysvnlite
28
+
29
+ 轻量、非交互的 Subversion 命令行封装和 XML 解析库,适合自动化脚本、资产历史查询和工作副本管理。
30
+
31
+ 要求 Python >=3.9,并在 PATH 中提供原生 `svn` 客户端。库本身没有第三方运行时依赖,也不安装 `svnpypi` CLI。
32
+
33
+ ```bash
34
+ python -m pip install pysvnlite
35
+ ```
36
+
37
+ ```python
38
+ from pysvnlite import SVNRepo
39
+
40
+ repo = SVNRepo("https://svn.example.com/project/trunk", timeout=30)
41
+ for entry in repo.log(limit=10, verbose=True):
42
+ print(entry.revision, entry.author, entry.changed_paths)
43
+ ```
44
+
45
+ ## 能力与边界
46
+
47
+ - `info/log/list/status/blame` 等结构化读取,返回类型化模型。
48
+ - 工作副本检出、更新、增删、属性、提交、冲突检查;URL `mkdir/delete` 支持提交信息。
49
+ - 历史 peg revision、二进制读取、原子文件下载。
50
+ - 可选超时,有界日志滚盘、增量 XML 与路径事件;通过 `CAPABILITIES` 检查 `bounded_verbose_log_v1`。
51
+ - 所有 SVN 调用添加 `--non-interactive`;认证、ACL、传输安全和凭据缓存由原生 SVN 管理。
52
+ - 不提供服务端、Web UI、依赖解析器或自动删除策略。
53
+
54
+ 完整示例见 [API 使用指南](https://github.com/narutozb/pysvnlite/blob/main/docs/basic-usage.md),平台限制见 [Windows 路径说明](https://github.com/narutozb/pysvnlite/blob/main/docs/windows-paths.md)。
55
+
56
+ ## 从旧版 svnpypi 迁移
57
+
58
+ 本库从 `narutozb/svnpypi` 的 `v0.1.7`(提交 `ce6191be4122eb54bbfbbed16bad46413f222e62`)拆出,保留 MIT 许可和作者信息。独立发行从 `0.2.0` 开始,`from pysvnlite import SVNRepo` 等现有导入保持不变。
59
+
60
+ **不要对旧捆绑版本直接执行普通升级。** 本机已验证:从 `svnpypi 0.1.7` 直接升级时,pip 先安装新依赖,再卸载旧包,会删除新库的同名文件并导致导入失败。`pip check` 仅检查元数据,不能证明文件完整。旧 `svnpypi<=0.1.7` 曾直接拥有 `pysvnlite/` 文件;不要混装或在拆分后降级安装旧发行包。推荐新建虚拟环境;需要原地迁移时,先卸载旧发行包,再重新安装:
61
+
62
+ ```bash
63
+ python -m pip uninstall -y svnpypi pysvnlite
64
+ python -m pip install "pysvnlite==0.2.0"
65
+ python -m pip check
66
+ ```
67
+
68
+ 仍需要包注册 CLI 时,最后安装 `svnpypi>=0.2.0`,由其依赖声明安装本库。维护人员应先发布本库,再发布依赖它的 `svnpypi`。
69
+
70
+ ## 开发与发布
71
+
72
+ ```bash
73
+ python -m pip install -e ".[dev,release]"
74
+ python -m ruff check src tests scripts
75
+ python -m mypy src scripts
76
+ python -m pytest -q
77
+ python scripts/release_pypi.py
78
+ ```
79
+
80
+ 测试仅写入临时 `file://` SVN 仓库;缺少 `svn` 或 `svnadmin` 时集成测试跳过。CI 覆盖 Linux Python 3.9/3.12/3.14 与 Windows Python 3.12。Python 3.9 仅保留兼容性,生产使用仍受官方维护的运行时。
81
+
82
+ 发布脚本默认不上传,检查 wheel/sdist 内容白名单、`py.typed`、Twine 元数据,并通过 `python -I -S` 从临时安装目录导入,避免本机环境掩盖漏包。AI 文件、测试、CI、发布脚本和 `svnpypi` 源码均不进入构建包;sdist 允许 Hatchling 为重建附带的 `.gitignore`。
83
+
84
+ 正式上传必须具有干净工作区、完整检查和已原样推送的 annotated 标签:
85
+
86
+ ```bash
87
+ git tag -a v0.2.0 -m "pysvnlite 0.2.0"
88
+ git push origin v0.2.0
89
+ python scripts/release_pypi.py --upload --repository pypi --confirm-version 0.2.0
90
+ ```
91
+
92
+ 首次发布建议先用独立的 TestPyPI 凭据验证:`python scripts/release_pypi.py --upload --repository testpypi`。正式 PyPI token 不能用于 TestPyPI。只在隐藏密码提示中输入 token,不写入源码、命令行或日志。
@@ -0,0 +1,66 @@
1
+ # pysvnlite
2
+
3
+ 轻量、非交互的 Subversion 命令行封装和 XML 解析库,适合自动化脚本、资产历史查询和工作副本管理。
4
+
5
+ 要求 Python >=3.9,并在 PATH 中提供原生 `svn` 客户端。库本身没有第三方运行时依赖,也不安装 `svnpypi` CLI。
6
+
7
+ ```bash
8
+ python -m pip install pysvnlite
9
+ ```
10
+
11
+ ```python
12
+ from pysvnlite import SVNRepo
13
+
14
+ repo = SVNRepo("https://svn.example.com/project/trunk", timeout=30)
15
+ for entry in repo.log(limit=10, verbose=True):
16
+ print(entry.revision, entry.author, entry.changed_paths)
17
+ ```
18
+
19
+ ## 能力与边界
20
+
21
+ - `info/log/list/status/blame` 等结构化读取,返回类型化模型。
22
+ - 工作副本检出、更新、增删、属性、提交、冲突检查;URL `mkdir/delete` 支持提交信息。
23
+ - 历史 peg revision、二进制读取、原子文件下载。
24
+ - 可选超时,有界日志滚盘、增量 XML 与路径事件;通过 `CAPABILITIES` 检查 `bounded_verbose_log_v1`。
25
+ - 所有 SVN 调用添加 `--non-interactive`;认证、ACL、传输安全和凭据缓存由原生 SVN 管理。
26
+ - 不提供服务端、Web UI、依赖解析器或自动删除策略。
27
+
28
+ 完整示例见 [API 使用指南](https://github.com/narutozb/pysvnlite/blob/main/docs/basic-usage.md),平台限制见 [Windows 路径说明](https://github.com/narutozb/pysvnlite/blob/main/docs/windows-paths.md)。
29
+
30
+ ## 从旧版 svnpypi 迁移
31
+
32
+ 本库从 `narutozb/svnpypi` 的 `v0.1.7`(提交 `ce6191be4122eb54bbfbbed16bad46413f222e62`)拆出,保留 MIT 许可和作者信息。独立发行从 `0.2.0` 开始,`from pysvnlite import SVNRepo` 等现有导入保持不变。
33
+
34
+ **不要对旧捆绑版本直接执行普通升级。** 本机已验证:从 `svnpypi 0.1.7` 直接升级时,pip 先安装新依赖,再卸载旧包,会删除新库的同名文件并导致导入失败。`pip check` 仅检查元数据,不能证明文件完整。旧 `svnpypi<=0.1.7` 曾直接拥有 `pysvnlite/` 文件;不要混装或在拆分后降级安装旧发行包。推荐新建虚拟环境;需要原地迁移时,先卸载旧发行包,再重新安装:
35
+
36
+ ```bash
37
+ python -m pip uninstall -y svnpypi pysvnlite
38
+ python -m pip install "pysvnlite==0.2.0"
39
+ python -m pip check
40
+ ```
41
+
42
+ 仍需要包注册 CLI 时,最后安装 `svnpypi>=0.2.0`,由其依赖声明安装本库。维护人员应先发布本库,再发布依赖它的 `svnpypi`。
43
+
44
+ ## 开发与发布
45
+
46
+ ```bash
47
+ python -m pip install -e ".[dev,release]"
48
+ python -m ruff check src tests scripts
49
+ python -m mypy src scripts
50
+ python -m pytest -q
51
+ python scripts/release_pypi.py
52
+ ```
53
+
54
+ 测试仅写入临时 `file://` SVN 仓库;缺少 `svn` 或 `svnadmin` 时集成测试跳过。CI 覆盖 Linux Python 3.9/3.12/3.14 与 Windows Python 3.12。Python 3.9 仅保留兼容性,生产使用仍受官方维护的运行时。
55
+
56
+ 发布脚本默认不上传,检查 wheel/sdist 内容白名单、`py.typed`、Twine 元数据,并通过 `python -I -S` 从临时安装目录导入,避免本机环境掩盖漏包。AI 文件、测试、CI、发布脚本和 `svnpypi` 源码均不进入构建包;sdist 允许 Hatchling 为重建附带的 `.gitignore`。
57
+
58
+ 正式上传必须具有干净工作区、完整检查和已原样推送的 annotated 标签:
59
+
60
+ ```bash
61
+ git tag -a v0.2.0 -m "pysvnlite 0.2.0"
62
+ git push origin v0.2.0
63
+ python scripts/release_pypi.py --upload --repository pypi --confirm-version 0.2.0
64
+ ```
65
+
66
+ 首次发布建议先用独立的 TestPyPI 凭据验证:`python scripts/release_pypi.py --upload --repository testpypi`。正式 PyPI token 不能用于 TestPyPI。只在隐藏密码提示中输入 token,不写入源码、命令行或日志。
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pysvnlite"
7
+ version = "0.2.0"
8
+ description = "Lightweight, non-interactive Subversion CLI wrapper and XML parsers"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "yuanzhen qiao" }]
13
+ dependencies = []
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/narutozb/pysvnlite"
17
+ Documentation = "https://github.com/narutozb/pysvnlite/tree/main/docs"
18
+ Repository = "https://github.com/narutozb/pysvnlite.git"
19
+ Issues = "https://github.com/narutozb/pysvnlite/issues"
20
+
21
+ [project.optional-dependencies]
22
+ dev = ["build>=1.2", "mypy>=1.11,<2.0", "pytest>=8.0", "ruff>=0.6", "packaging>=23.2", "tomli>=2.0.1; python_version < '3.11'"]
23
+ release = ["build>=1.2", "twine>=5.1", "packaging>=23.2", "tomli>=2.0.1; python_version < '3.11'"]
24
+
25
+ [tool.hatch.build.targets.wheel]
26
+ core-metadata-version = "2.4"
27
+ packages = ["src/pysvnlite"]
28
+
29
+ [tool.hatch.build.targets.sdist]
30
+ core-metadata-version = "2.4"
31
+ include = ["/LICENSE", "/README.md", "/pyproject.toml", "/src/pysvnlite"]
32
+
33
+ [tool.ruff]
34
+ target-version = "py39"
35
+ line-length = 100
36
+
37
+ [tool.ruff.lint]
38
+ select = ["E4", "E7", "E9", "F"]
39
+
40
+ [tool.mypy]
41
+ python_version = "3.9"
42
+ check_untyped_defs = true
43
+ files = ["src", "scripts"]
@@ -0,0 +1,35 @@
1
+ from .repo import CAPABILITIES, SVNRepo
2
+ from .models import (
3
+ BlameLine,
4
+ CommitResult,
5
+ CommitSummary,
6
+ ListEntry,
7
+ LogEntry,
8
+ LogEntryEnd,
9
+ LogEntryStart,
10
+ LogEvent,
11
+ LogPathChange,
12
+ LogPathChangeEvent,
13
+ RepoInfo,
14
+ StatusItem,
15
+ )
16
+ from .exceptions import SVNCommandError, SVNOutputLimitError
17
+
18
+ __all__ = [
19
+ "SVNRepo",
20
+ "CAPABILITIES",
21
+ "RepoInfo",
22
+ "LogEntry",
23
+ "LogEvent",
24
+ "LogEntryStart",
25
+ "LogPathChangeEvent",
26
+ "LogEntryEnd",
27
+ "LogPathChange",
28
+ "CommitResult",
29
+ "CommitSummary",
30
+ "StatusItem",
31
+ "ListEntry",
32
+ "BlameLine",
33
+ "SVNCommandError",
34
+ "SVNOutputLimitError",
35
+ ]
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import List
6
+
7
+
8
+ _URL_USERINFO_RE = re.compile(
9
+ r"(?i)([a-z][a-z0-9+.-]*://)([^/@\s]+)@"
10
+ )
11
+
12
+
13
+ def redact_url_credentials(text: str) -> str:
14
+ """Hide URL userinfo before text is written to logs or records."""
15
+ return _URL_USERINFO_RE.sub(r"\1***@", text)
16
+
17
+
18
+ def _contains_any(text: str, patterns: tuple[str, ...]) -> bool:
19
+ lowered = text.lower()
20
+ return any(pattern in lowered for pattern in patterns)
21
+
22
+
23
+ @dataclass
24
+ class SVNCommandError(RuntimeError):
25
+ """
26
+ 在调用 svn 子进程时,如果返回码 != 0,用这个异常包装。
27
+ """
28
+
29
+ cmd: List[str]
30
+ returncode: int
31
+ stdout: str
32
+ stderr: str
33
+
34
+ @property
35
+ def output_text(self) -> str:
36
+ return f"{self.stderr}\n{self.stdout}"
37
+
38
+ @property
39
+ def is_path_not_found(self) -> bool:
40
+ return self.is_local_repository_not_found or _contains_any(
41
+ self.output_text,
42
+ (
43
+ "e160013",
44
+ "e200009",
45
+ "path not found",
46
+ "file not found",
47
+ "not found in repository",
48
+ "non-existent in revision",
49
+ "doesn't exist",
50
+ "does not exist",
51
+ "not a valid url",
52
+ "could not list all targets because some targets don't exist",
53
+ ),
54
+ )
55
+
56
+ @property
57
+ def is_local_repository_not_found(self) -> bool:
58
+ combined_text = "\n".join([*self.cmd, self.output_text]).lower()
59
+ return "file://" in combined_text and _contains_any(
60
+ self.output_text,
61
+ (
62
+ "e180001",
63
+ "unable to open repository",
64
+ ),
65
+ )
66
+
67
+ @property
68
+ def is_authentication_error(self) -> bool:
69
+ return _contains_any(
70
+ self.output_text,
71
+ (
72
+ "e215004",
73
+ "authentication failed",
74
+ "could not authenticate",
75
+ "authorization failed: could not authenticate",
76
+ "no more credentials",
77
+ ),
78
+ )
79
+
80
+ @property
81
+ def is_authorization_error(self) -> bool:
82
+ if self.is_authentication_error:
83
+ return False
84
+ return _contains_any(
85
+ self.output_text,
86
+ (
87
+ "e170001",
88
+ "authorization failed",
89
+ "not authorized",
90
+ "access denied",
91
+ "forbidden",
92
+ "permission denied",
93
+ ),
94
+ )
95
+
96
+ @property
97
+ def is_network_error(self) -> bool:
98
+ return _contains_any(
99
+ self.output_text,
100
+ (
101
+ "e000110",
102
+ "e670002",
103
+ "e730061",
104
+ "unable to connect",
105
+ "could not resolve hostname",
106
+ "name or service not known",
107
+ "connection refused",
108
+ "connection timed out",
109
+ "network connection closed",
110
+ "no route to host",
111
+ ),
112
+ )
113
+
114
+ @property
115
+ def is_timeout_error(self) -> bool:
116
+ return "svn command timed out after" in self.output_text.lower()
117
+
118
+ @property
119
+ def is_already_exists_error(self) -> bool:
120
+ return _contains_any(
121
+ self.output_text,
122
+ (
123
+ "e160020",
124
+ "already exists",
125
+ "already exist",
126
+ "file already exists",
127
+ "path already exists",
128
+ "is already under version control",
129
+ ),
130
+ )
131
+
132
+ @property
133
+ def is_out_of_date_error(self) -> bool:
134
+ return _contains_any(
135
+ self.output_text,
136
+ (
137
+ "e155011",
138
+ "e160028",
139
+ "out of date",
140
+ "out-of-date",
141
+ "working copy is out of date",
142
+ "base checksum mismatch",
143
+ ),
144
+ )
145
+
146
+ @property
147
+ def is_property_not_found(self) -> bool:
148
+ lowered = self.output_text.lower()
149
+ return "w200017" in lowered or (
150
+ "property" in lowered and "not found" in lowered
151
+ )
152
+
153
+ @property
154
+ def category(self) -> str:
155
+ if self.is_timeout_error:
156
+ return "timeout"
157
+ if self.is_authentication_error:
158
+ return "authentication"
159
+ if self.is_authorization_error:
160
+ return "authorization"
161
+ if self.is_local_repository_not_found:
162
+ return "not_found"
163
+ if self.is_network_error:
164
+ return "network"
165
+ if self.is_property_not_found:
166
+ return "property_not_found"
167
+ if self.is_path_not_found:
168
+ return "not_found"
169
+ return "unknown"
170
+
171
+ def __str__(self) -> str:
172
+ return redact_url_credentials(
173
+ f"svn command failed (code {self.returncode}): {self.cmd}\n"
174
+ f"STDERR:\n{self.stderr}\n"
175
+ f"STDOUT:\n{self.stdout}"
176
+ )
177
+
178
+
179
+ class SVNOutputLimitError(SVNCommandError):
180
+ """Raised when a bounded SVN stdout stream exceeds its configured limit."""
181
+
182
+ @property
183
+ def category(self) -> str:
184
+ return "output_limit"
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import List, Optional, Union
7
+
8
+
9
+ @dataclass
10
+ class RepoInfo:
11
+ """结构化的 svn info 结果"""
12
+
13
+ url: str
14
+ repo_root_url: Optional[str]
15
+ repo_uuid: Optional[str]
16
+
17
+ wc_root: Optional[Path] # 如果是工作拷贝路径则会有
18
+ revision: Optional[int]
19
+ node_kind: Optional[str] # 'dir' / 'file'
20
+ last_changed_rev: Optional[int]
21
+ last_changed_author: Optional[str]
22
+ last_changed_date: Optional[datetime]
23
+
24
+
25
+ @dataclass
26
+ class LogPathChange:
27
+ """单条日志中某个文件/路径的变更信息"""
28
+ action: str # 'A' 'M' 'D' 'R' ...
29
+ path: str
30
+ copy_from_path: Optional[str] = None
31
+ copy_from_rev: Optional[int] = None
32
+
33
+
34
+ @dataclass
35
+ class LogEntry:
36
+ """svn log 的一条提交记录"""
37
+ revision: int
38
+ author: Optional[str]
39
+ date: Optional[datetime]
40
+ message: str
41
+ changed_paths: List[LogPathChange] = field(default_factory=list)
42
+
43
+
44
+ @dataclass
45
+ class LogEntryStart:
46
+ """Marks the beginning of one revision in a path-level log stream."""
47
+
48
+ revision: int
49
+
50
+
51
+ @dataclass
52
+ class LogPathChangeEvent:
53
+ """One changed path associated with a streamed revision."""
54
+
55
+ revision: int
56
+ change: LogPathChange
57
+
58
+
59
+ @dataclass
60
+ class LogEntryEnd:
61
+ """Completes one revision after all of its path events were emitted."""
62
+
63
+ revision: int
64
+ author: Optional[str]
65
+ date: Optional[datetime]
66
+ message: str
67
+ changed_paths_count: int
68
+
69
+
70
+ LogEvent = Union[LogEntryStart, LogPathChangeEvent, LogEntryEnd]
71
+
72
+
73
+ @dataclass
74
+ class CommitSummary:
75
+ """提交前的变更摘要(来自 svn status --xml)"""
76
+ added: List[str] = field(default_factory=list)
77
+ modified: List[str] = field(default_factory=list)
78
+ deleted: List[str] = field(default_factory=list)
79
+ missing: List[str] = field(default_factory=list)
80
+ conflicted: List[str] = field(default_factory=list)
81
+ tree_conflicted: List[str] = field(default_factory=list)
82
+ unversioned: List[str] = field(default_factory=list)
83
+
84
+ @dataclass
85
+ class CommitResult:
86
+ """
87
+ svn commit 的结果(增强版)。
88
+ - success: returncode==0
89
+ - revision: 解析到的修订号;可能为 None(本地化导致解析失败时)
90
+ - pre_summary: 提交前的状态摘要
91
+ - changed_paths: 若成功,来自 `svn log -v --xml -r <rev>` 的本次变更路径
92
+ """
93
+ success: bool
94
+ revision: Optional[int]
95
+ stdout: str
96
+ stderr: str
97
+ returncode: int
98
+ pre_summary: CommitSummary = field(default_factory=CommitSummary)
99
+ changed_paths: List["LogPathChange"] = field(default_factory=list)
100
+
101
+
102
+ @dataclass
103
+ class StatusItem:
104
+ """
105
+ 一条工作拷贝状态信息(对应 svn status --xml 的 <entry>)
106
+ """
107
+ path: str # 本地路径
108
+ wc_status: str # item 的内容变更状态: 'modified'|'added'|'deleted'|'unversioned'|'missing'|'conflicted'|'external'|...
109
+ repos_status: Optional[str] # 远端对该项的状态(可能 None)
110
+ locked: bool # 是否被 WC 锁定
111
+ switched: bool # 是否切换到非相对 URL
112
+ copied: bool # 是否 copy 来的
113
+ tree_conflicted: bool # 是否树冲突
114
+ revision: Optional[int] # 本地工作拷贝记录的 rev
115
+ commit_rev: Optional[int] # 最后一次提交 rev(来自 <commit revision=...>)
116
+ commit_author: Optional[str] # 最后一次提交作者
117
+ commit_date: Optional["datetime"] # 最后一次提交时间(aware, UTC)
118
+
119
+
120
+ @dataclass
121
+ class ListEntry:
122
+ """svn list 的一条记录"""
123
+ kind: str # 'file' or 'dir'
124
+ name: str # 文件名/目录名
125
+ size: Optional[int] # 文件大小(目录无)
126
+ commit_rev: Optional[int] # 最后一次提交 rev
127
+ commit_author: Optional[str] # 最后一次提交作者
128
+ commit_date: Optional[datetime] # 最后一次提交时间
129
+
130
+
131
+ @dataclass
132
+ class BlameLine:
133
+ """svn blame 的一行信息"""
134
+ line_number: int # 行号
135
+ revision: int # 该行最后修改的版本号
136
+ author: str # 该行最后修改的作者
137
+ date: Optional[datetime] # 该行最后修改的时间
138
+ content: str # 行内容(注意:blame --xml 不含内容,需结合 cat 才能拼出完整信息,或仅返回元数据)
139
+ # 注:svn blame --xml 输出不包含行内容本身!只包含 rev, author, date。
140
+ # 通常 blame 还需要展示内容,但 svn blame --xml 确实不给。
141
+ # 如果要像 IDE 那样展示,需要另外 cat 文件内容并合并。
142
+ # 这里我们暂且只存储元数据,或者在 parser 里尝试读取文件内容(成本较高)。
143
+ # 按照轻量级原则,我们先只解析 XML 返回的元数据。
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ import xml.etree.ElementTree as ET
4
+ from typing import List, Optional
5
+
6
+ from .models import BlameLine
7
+ from .utils import parse_svn_datetime
8
+
9
+
10
+ def parse_blame_xml(xml_text: str) -> List[BlameLine]:
11
+ """
12
+ 解析 `svn blame --xml` 的输出。
13
+ XML 结构:
14
+ <target path="...">
15
+ <entry line-number="1">
16
+ <commit revision="2">
17
+ <author>user</author>
18
+ <date>...</date>
19
+ </commit>
20
+ </entry>
21
+ ...
22
+ </target>
23
+
24
+ 注意:XML 输出不包含行内容,只有元数据!
25
+ """
26
+ try:
27
+ root = ET.fromstring(xml_text)
28
+ except ET.ParseError:
29
+ return []
30
+
31
+ lines: List[BlameLine] = []
32
+
33
+ # 通常只有一个 target
34
+ for target in root.findall("target"):
35
+ for entry in target.findall("entry"):
36
+ line_str = entry.get("line-number")
37
+ line_number = int(line_str) if line_str else 0
38
+
39
+ commit_el = entry.find("commit")
40
+ rev = -1
41
+ author = ""
42
+ date_dt = None
43
+
44
+ if commit_el is not None:
45
+ rev_str = commit_el.get("revision")
46
+ rev = int(rev_str) if rev_str else -1
47
+ author = _text(commit_el.find("author")) or ""
48
+ date_str = _text(commit_el.find("date"))
49
+ if date_str:
50
+ date_dt = parse_svn_datetime(date_str)
51
+
52
+ lines.append(BlameLine(
53
+ line_number=line_number,
54
+ revision=rev,
55
+ author=author,
56
+ date=date_dt,
57
+ content="" # XML output doesn't include content
58
+ ))
59
+
60
+ return lines
61
+
62
+
63
+ def _text(el: Optional[ET.Element]) -> Optional[str]:
64
+ return el.text if (el is not None and el.text is not None) else None