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.
- pysvnlite-0.2.0/.gitignore +7 -0
- pysvnlite-0.2.0/LICENSE +21 -0
- pysvnlite-0.2.0/PKG-INFO +92 -0
- pysvnlite-0.2.0/README.md +66 -0
- pysvnlite-0.2.0/pyproject.toml +43 -0
- pysvnlite-0.2.0/src/pysvnlite/__init__.py +35 -0
- pysvnlite-0.2.0/src/pysvnlite/exceptions.py +184 -0
- pysvnlite-0.2.0/src/pysvnlite/models.py +143 -0
- pysvnlite-0.2.0/src/pysvnlite/parser_blame.py +64 -0
- pysvnlite-0.2.0/src/pysvnlite/parser_info.py +64 -0
- pysvnlite-0.2.0/src/pysvnlite/parser_list.py +78 -0
- pysvnlite-0.2.0/src/pysvnlite/parser_log.py +113 -0
- pysvnlite-0.2.0/src/pysvnlite/parser_props.py +50 -0
- pysvnlite-0.2.0/src/pysvnlite/parser_status.py +77 -0
- pysvnlite-0.2.0/src/pysvnlite/py.typed +0 -0
- pysvnlite-0.2.0/src/pysvnlite/repo.py +1047 -0
- pysvnlite-0.2.0/src/pysvnlite/runner.py +656 -0
- pysvnlite-0.2.0/src/pysvnlite/utils.py +27 -0
pysvnlite-0.2.0/LICENSE
ADDED
|
@@ -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.
|
pysvnlite-0.2.0/PKG-INFO
ADDED
|
@@ -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
|