symbol-tool 1.0.0.3__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.
- symbol_tool-1.0.0.3/PKG-INFO +62 -0
- symbol_tool-1.0.0.3/README.md +33 -0
- symbol_tool-1.0.0.3/pyproject.toml +66 -0
- symbol_tool-1.0.0.3/setup.cfg +4 -0
- symbol_tool-1.0.0.3/symbol_tool/__init__.py +1 -0
- symbol_tool-1.0.0.3/symbol_tool/__main__.py +4 -0
- symbol_tool-1.0.0.3/symbol_tool/clean.py +47 -0
- symbol_tool-1.0.0.3/symbol_tool/config.py +185 -0
- symbol_tool-1.0.0.3/symbol_tool/fileio.py +15 -0
- symbol_tool-1.0.0.3/symbol_tool/fix.py +63 -0
- symbol_tool-1.0.0.3/symbol_tool/main.py +99 -0
- symbol_tool-1.0.0.3/symbol_tool/query.py +30 -0
- symbol_tool-1.0.0.3/symbol_tool/symbols.py +32 -0
- symbol_tool-1.0.0.3/symbol_tool/transfer.py +77 -0
- symbol_tool-1.0.0.3/symbol_tool/upload.py +44 -0
- symbol_tool-1.0.0.3/symbol_tool.egg-info/PKG-INFO +62 -0
- symbol_tool-1.0.0.3/symbol_tool.egg-info/SOURCES.txt +22 -0
- symbol_tool-1.0.0.3/symbol_tool.egg-info/dependency_links.txt +1 -0
- symbol_tool-1.0.0.3/symbol_tool.egg-info/entry_points.txt +2 -0
- symbol_tool-1.0.0.3/symbol_tool.egg-info/not-zip-safe +1 -0
- symbol_tool-1.0.0.3/symbol_tool.egg-info/requires.txt +10 -0
- symbol_tool-1.0.0.3/symbol_tool.egg-info/top_level.txt +2 -0
- symbol_tool-1.0.0.3/tests/test_query.py +85 -0
- symbol_tool-1.0.0.3/tests/test_upload.py +113 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: symbol-tool
|
|
3
|
+
Version: 1.0.0.3
|
|
4
|
+
Summary: 符号管理工具
|
|
5
|
+
Author-email: Vorga <qiujuncheng@up3dtech.com>
|
|
6
|
+
Project-URL: Homepage, http://www.vorga.cn:8081/up3d/software/tool/symbol-tool
|
|
7
|
+
Keywords: up3d,symbol,tool
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: requests
|
|
21
|
+
Requires-Dist: symstore
|
|
22
|
+
Requires-Dist: tqdm
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: build; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
27
|
+
Requires-Dist: pyinstaller; extra == "dev"
|
|
28
|
+
Requires-Dist: pyinstaller-versionfile; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# symbol-tool
|
|
31
|
+
|
|
32
|
+
用于管理符号文件仓库的命令行工具。
|
|
33
|
+
|
|
34
|
+
## 快速开始
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e .
|
|
38
|
+
symbol-tool --version
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 打包
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pyinstaller symbol_tool.spec --clean
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 命令示例
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
symbol-tool upload -t test -p "./**/*.pdb"
|
|
51
|
+
symbol-tool fix -t test
|
|
52
|
+
symbol-tool transfer --source test --target test
|
|
53
|
+
symbol-tool clean -t test
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`target` 是别名。每个别名对应“某个服务器 + 某个路径”,定义在 [symbol_tool/config.py](symbol_tool/config.py)。
|
|
57
|
+
|
|
58
|
+
## 测试
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pytest tests
|
|
62
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# symbol-tool
|
|
2
|
+
|
|
3
|
+
用于管理符号文件仓库的命令行工具。
|
|
4
|
+
|
|
5
|
+
## 快速开始
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
symbol-tool --version
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 打包
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pyinstaller symbol_tool.spec --clean
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 命令示例
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
symbol-tool upload -t test -p "./**/*.pdb"
|
|
22
|
+
symbol-tool fix -t test
|
|
23
|
+
symbol-tool transfer --source test --target test
|
|
24
|
+
symbol-tool clean -t test
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`target` 是别名。每个别名对应“某个服务器 + 某个路径”,定义在 [symbol_tool/config.py](symbol_tool/config.py)。
|
|
28
|
+
|
|
29
|
+
## 测试
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pytest tests
|
|
33
|
+
```
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "symbol-tool"
|
|
7
|
+
description = "符号管理工具"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dynamic = ["version"]
|
|
11
|
+
authors = [
|
|
12
|
+
{ name = "Vorga", email = "qiujuncheng@up3dtech.com" },
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.7",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Operating System :: OS Independent",
|
|
25
|
+
]
|
|
26
|
+
keywords = ["up3d", "symbol", "tool"]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"requests",
|
|
29
|
+
"symstore",
|
|
30
|
+
"tqdm",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
dev = [
|
|
35
|
+
"build",
|
|
36
|
+
"pytest",
|
|
37
|
+
"pytest-cov",
|
|
38
|
+
"pyinstaller",
|
|
39
|
+
"pyinstaller-versionfile",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "http://www.vorga.cn:8081/up3d/software/tool/symbol-tool"
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
symbol-tool = "symbol_tool.main:main"
|
|
47
|
+
|
|
48
|
+
[tool.setuptools]
|
|
49
|
+
include-package-data = true
|
|
50
|
+
zip-safe = false
|
|
51
|
+
|
|
52
|
+
[tool.setuptools.packages.find]
|
|
53
|
+
exclude = ["tests", "tests.*", "build", "dist", "log"]
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.dynamic]
|
|
56
|
+
version = {attr = "symbol_tool.__version__"}
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
|
|
61
|
+
[tool.coverage.run]
|
|
62
|
+
source = ["symbol_tool"]
|
|
63
|
+
|
|
64
|
+
[tool.coverage.report]
|
|
65
|
+
show_missing = true
|
|
66
|
+
skip_covered = false
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0.3"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from .config import ArtifactoryClient, AqlResponseFileMeta, AqlRequestSearchData
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def process_clean_file(file_meta: AqlResponseFileMeta, client: ArtifactoryClient):
|
|
5
|
+
# 删除文件
|
|
6
|
+
file_url = client.get_url(f'{file_meta.repo}/{file_meta.path}/{file_meta.name}')
|
|
7
|
+
resp = client.delete(file_url)
|
|
8
|
+
if resp.status_code == 204:
|
|
9
|
+
print(f"{file_url} -> 删除成功")
|
|
10
|
+
process_clean_folder(file_meta, client)
|
|
11
|
+
else:
|
|
12
|
+
print(f"{file_url} -> 删除失败:{resp.status_code}")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def process_clean_folder(file_meta: AqlResponseFileMeta, client: ArtifactoryClient):
|
|
16
|
+
# 获取文件所在目录是否为空, 如果为空, 那么删除这个目录
|
|
17
|
+
data = AqlRequestSearchData()
|
|
18
|
+
data.type = "file"
|
|
19
|
+
data.repo = file_meta.repo
|
|
20
|
+
data.path = file_meta.path
|
|
21
|
+
data.name = '*'
|
|
22
|
+
file_list = client.get_list(data)
|
|
23
|
+
if len(file_list.results) != 0:
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
folder_url = client.get_url(f'{file_meta.repo}/{file_meta.path}')
|
|
27
|
+
resp = client.delete(folder_url)
|
|
28
|
+
if resp.status_code == 204:
|
|
29
|
+
print(f"{folder_url} -> 删除成功")
|
|
30
|
+
else:
|
|
31
|
+
print(f"{folder_url} -> 删除失败:{resp.status_code}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def process_repository(client: ArtifactoryClient):
|
|
35
|
+
# 查询所有文件
|
|
36
|
+
data = AqlRequestSearchData()
|
|
37
|
+
data.repo = client.repo
|
|
38
|
+
data.name = 'UP*'
|
|
39
|
+
data.updated_before = '180d'
|
|
40
|
+
|
|
41
|
+
result = client.get_list(data)
|
|
42
|
+
print(f"共找到 {len(result.results)} 个文件")
|
|
43
|
+
|
|
44
|
+
for item in result.results:
|
|
45
|
+
if item.name.startswith("UP"):
|
|
46
|
+
continue
|
|
47
|
+
process_clean_file(item, client)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from dataclasses import dataclass, field, asdict
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AqlRequestSearchData:
|
|
9
|
+
type: str = "file"
|
|
10
|
+
repo: str | None = None
|
|
11
|
+
path: str | None = None
|
|
12
|
+
name: str | None = None
|
|
13
|
+
nname: str | None = None
|
|
14
|
+
created_before: str | None = None
|
|
15
|
+
updated_before: str | None = None
|
|
16
|
+
limit: int | None = None
|
|
17
|
+
|
|
18
|
+
def build(self):
|
|
19
|
+
find_json: dict[str, Any] = {}
|
|
20
|
+
find_json["type"] = self.type
|
|
21
|
+
if self.repo:
|
|
22
|
+
find_json["repo"] = {"$match": self.repo}
|
|
23
|
+
if self.path:
|
|
24
|
+
find_json["path"] = {"$match": self.path}
|
|
25
|
+
if self.name:
|
|
26
|
+
find_json["name"] = {"$match": self.name}
|
|
27
|
+
if self.nname:
|
|
28
|
+
find_json["name"] = {"$nmatch": self.nname}
|
|
29
|
+
if self.created_before:
|
|
30
|
+
find_json["created"] = {"$before": self.created_before}
|
|
31
|
+
if self.updated_before:
|
|
32
|
+
find_json["updated"] = {"$before": self.updated_before}
|
|
33
|
+
aql = f'items.find({find_json}).include("repo", "path", "name", "created", "updated")'
|
|
34
|
+
if self.limit:
|
|
35
|
+
aql += f".limit({self.limit})"
|
|
36
|
+
return aql
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class AqlResponseFileMeta:
|
|
41
|
+
repo: str | None = None
|
|
42
|
+
path: str | None = None
|
|
43
|
+
name: str | None = None
|
|
44
|
+
created: str | None = None
|
|
45
|
+
updated: str | None = None
|
|
46
|
+
|
|
47
|
+
def to_path(self) -> str:
|
|
48
|
+
if self.path and self.name:
|
|
49
|
+
return f"{self.path}/{self.name}"
|
|
50
|
+
if self.name:
|
|
51
|
+
return f"{self.name}"
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class AqlResponseFileRange:
|
|
57
|
+
start_pos: int = 0
|
|
58
|
+
end_pos: int = 0
|
|
59
|
+
total: int = 0
|
|
60
|
+
limit: int = 0
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class AqlResponseSearch:
|
|
65
|
+
results: list[AqlResponseFileMeta]
|
|
66
|
+
range: AqlResponseFileRange
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, data: dict) -> "AqlResponseSearch":
|
|
70
|
+
"""从字典反序列化"""
|
|
71
|
+
parsed_results = [
|
|
72
|
+
AqlResponseFileMeta(**item) for item in data.get("results", [])
|
|
73
|
+
]
|
|
74
|
+
parsed_range = AqlResponseFileRange(**data.get("range", {}))
|
|
75
|
+
return cls(results=parsed_results, range=parsed_range)
|
|
76
|
+
|
|
77
|
+
def to_dict(self) -> dict:
|
|
78
|
+
"""序列化为字典"""
|
|
79
|
+
return asdict(self)
|
|
80
|
+
|
|
81
|
+
@classmethod
|
|
82
|
+
def from_json(cls, json_str: str) -> "AqlResponseSearch":
|
|
83
|
+
"""从JSON字符串反序列化"""
|
|
84
|
+
return cls.from_dict(json.loads(json_str))
|
|
85
|
+
|
|
86
|
+
def to_json(self) -> str:
|
|
87
|
+
"""序列化为JSON字符串"""
|
|
88
|
+
return json.dumps(self.to_dict(), ensure_ascii=False, indent=2)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class ArtifactoryClient:
|
|
93
|
+
base: str
|
|
94
|
+
repo: str
|
|
95
|
+
username: str
|
|
96
|
+
password: str
|
|
97
|
+
session: requests.Session
|
|
98
|
+
|
|
99
|
+
def __post_init__(self):
|
|
100
|
+
self.session.auth = (self.username, self.password)
|
|
101
|
+
|
|
102
|
+
def get_url(self, path):
|
|
103
|
+
return f"{self.base}/{self.repo}/{path}"
|
|
104
|
+
|
|
105
|
+
def get_list(self, data: AqlRequestSearchData) -> AqlResponseSearch:
|
|
106
|
+
request_url = f"{self.base}/api/search/aql"
|
|
107
|
+
request_data = data.build().replace("'", '"')
|
|
108
|
+
print(f"Requesting AQL: {request_data}")
|
|
109
|
+
response = self.post(request_url, data=request_data)
|
|
110
|
+
if response.status_code != 200:
|
|
111
|
+
return AqlResponseSearch(results=[], range=AqlResponseFileRange())
|
|
112
|
+
return AqlResponseSearch.from_dict(response.json())
|
|
113
|
+
|
|
114
|
+
def request(self, method, url, **kwargs):
|
|
115
|
+
return self.session.request(method=method, url=url, **kwargs)
|
|
116
|
+
|
|
117
|
+
def get(self, url, **kwargs):
|
|
118
|
+
return self.request("GET", url, **kwargs)
|
|
119
|
+
|
|
120
|
+
def post(self, url, **kwargs):
|
|
121
|
+
return self.request("POST", url, **kwargs)
|
|
122
|
+
|
|
123
|
+
def put(self, url, **kwargs):
|
|
124
|
+
return self.request("PUT", url, **kwargs)
|
|
125
|
+
|
|
126
|
+
def head(self, url, **kwargs):
|
|
127
|
+
return self.request("HEAD", url, **kwargs)
|
|
128
|
+
|
|
129
|
+
def delete(self, url, **kwargs):
|
|
130
|
+
return self.request("DELETE", url, **kwargs)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
CLIENTS = dict[str, ArtifactoryClient](
|
|
134
|
+
{
|
|
135
|
+
"local": ArtifactoryClient(
|
|
136
|
+
base="http://192.168.1.250:8081/artifactory",
|
|
137
|
+
repo="symbol-local",
|
|
138
|
+
username="qiujuncheng",
|
|
139
|
+
password="Qq1966409103",
|
|
140
|
+
session=requests.Session(),
|
|
141
|
+
),
|
|
142
|
+
"cam": ArtifactoryClient(
|
|
143
|
+
base="http://192.168.1.250:8081/artifactory",
|
|
144
|
+
repo="symbol-cam",
|
|
145
|
+
username="qiujuncheng",
|
|
146
|
+
password="Qq1966409103",
|
|
147
|
+
session=requests.Session(),
|
|
148
|
+
),
|
|
149
|
+
"chair": ArtifactoryClient(
|
|
150
|
+
base="http://192.168.1.250:8081/artifactory",
|
|
151
|
+
repo="symbol-chair",
|
|
152
|
+
username="qiujuncheng",
|
|
153
|
+
password="Qq1966409103",
|
|
154
|
+
session=requests.Session(),
|
|
155
|
+
),
|
|
156
|
+
"cad": ArtifactoryClient(
|
|
157
|
+
base="http://192.168.1.250:8081/artifactory",
|
|
158
|
+
repo="symbol-cad",
|
|
159
|
+
username="qiujuncheng",
|
|
160
|
+
password="Qq1966409103",
|
|
161
|
+
session=requests.Session(),
|
|
162
|
+
),
|
|
163
|
+
"sdk": ArtifactoryClient(
|
|
164
|
+
base="http://192.168.1.250:8081/artifactory",
|
|
165
|
+
repo="symbol-sdk",
|
|
166
|
+
username="qiujuncheng",
|
|
167
|
+
password="Qq1966409103",
|
|
168
|
+
session=requests.Session(),
|
|
169
|
+
),
|
|
170
|
+
"model-scan": ArtifactoryClient(
|
|
171
|
+
base="http://192.168.1.250:8081/artifactory",
|
|
172
|
+
repo="symbol-model-scan",
|
|
173
|
+
username="qiujuncheng",
|
|
174
|
+
password="Qq1966409103",
|
|
175
|
+
session=requests.Session(),
|
|
176
|
+
),
|
|
177
|
+
"other": ArtifactoryClient(
|
|
178
|
+
base="http://192.168.1.250:8081/artifactory",
|
|
179
|
+
repo="symbol-other",
|
|
180
|
+
username="qiujuncheng",
|
|
181
|
+
password="Qq1966409103",
|
|
182
|
+
session=requests.Session(),
|
|
183
|
+
),
|
|
184
|
+
},
|
|
185
|
+
)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
def sanitize_path_fragment(value):
|
|
2
|
+
return value.replace("/", "_").replace("\\", "_")
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def build_temp_filename(value, name=None, suffix=".tmp"):
|
|
6
|
+
safe_value = sanitize_path_fragment(value)
|
|
7
|
+
if name:
|
|
8
|
+
return f"{safe_value}_{name}{suffix}"
|
|
9
|
+
return f"{safe_value}{suffix}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def write_stream_to_file(response, file_path, chunk_size=8192):
|
|
13
|
+
with open(file_path, "wb") as file_handle:
|
|
14
|
+
for chunk in response.iter_content(chunk_size=chunk_size):
|
|
15
|
+
file_handle.write(chunk)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tqdm
|
|
3
|
+
|
|
4
|
+
from .config import ArtifactoryClient, AqlResponseFileMeta, AqlRequestSearchData
|
|
5
|
+
from .fileio import build_temp_filename, write_stream_to_file
|
|
6
|
+
from .symbols import file_hash
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fix_artifact(file_meta: AqlResponseFileMeta, client: ArtifactoryClient):
|
|
10
|
+
source_url = client.get_url(f'{file_meta.repo}/{file_meta.path}/{file_meta.name}')
|
|
11
|
+
source_response = client.get(source_url, stream=True)
|
|
12
|
+
if source_response.status_code != 200:
|
|
13
|
+
return f"Failed: get file, code={source_response.status_code}"
|
|
14
|
+
|
|
15
|
+
temp_name = build_temp_filename(file_meta.path, name=file_meta.name)
|
|
16
|
+
|
|
17
|
+
write_stream_to_file(source_response, temp_name)
|
|
18
|
+
|
|
19
|
+
# 获取文件的真实路径
|
|
20
|
+
target_hash = file_hash(temp_name)
|
|
21
|
+
target_url = client.get_url(f'{file_meta.repo}/{file_meta.name}/{target_hash}/{file_meta.name}')
|
|
22
|
+
|
|
23
|
+
# 如果路径相同, 那么不需要任何操作
|
|
24
|
+
if target_url == source_url:
|
|
25
|
+
os.remove(temp_name)
|
|
26
|
+
return f"Skipped: {file_meta.path}"
|
|
27
|
+
|
|
28
|
+
# 删除文件
|
|
29
|
+
client.delete(client.get_url(f'{file_meta.repo}/{file_meta.path}/{file_meta.name}'))
|
|
30
|
+
client.delete(client.get_url(f'{file_meta.repo}/{file_meta.path}'))
|
|
31
|
+
|
|
32
|
+
# 上传文件
|
|
33
|
+
with open(temp_name, 'rb') as f:
|
|
34
|
+
target_response = client.head(target_url)
|
|
35
|
+
if target_response.status_code == 200:
|
|
36
|
+
os.remove(temp_name)
|
|
37
|
+
return f"Skipped: target exists {target_hash}/{file_meta.name}"
|
|
38
|
+
target_response = client.put(target_url, data=f)
|
|
39
|
+
if target_response.status_code not in (200, 201):
|
|
40
|
+
os.remove(temp_name)
|
|
41
|
+
return f"Failed: upload file, code={target_response.status_code}"
|
|
42
|
+
|
|
43
|
+
os.remove(temp_name)
|
|
44
|
+
return f"Fixed: {file_meta.path} -> {target_hash}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def process_artifacts(client: ArtifactoryClient):
|
|
48
|
+
# 目标为重新计算文件的实际路径, 并进行重命名
|
|
49
|
+
data = AqlRequestSearchData()
|
|
50
|
+
data.repo = client.repo
|
|
51
|
+
|
|
52
|
+
result = client.get_list(data)
|
|
53
|
+
print(f"共找到 {len(result.results)} 个文件在仓库 '{client.repo}' 中...")
|
|
54
|
+
|
|
55
|
+
tqdm_bar = tqdm.tqdm(total=len(result.results), unit="file")
|
|
56
|
+
|
|
57
|
+
for file in result.results:
|
|
58
|
+
msg = fix_artifact(file, client)
|
|
59
|
+
if msg.startswith("Fixed"):
|
|
60
|
+
tqdm_bar.set_description(f"{msg:50}")
|
|
61
|
+
tqdm_bar.update(1)
|
|
62
|
+
|
|
63
|
+
tqdm_bar.close()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from . import __version__
|
|
4
|
+
from . import clean, fix, query, transfer, upload
|
|
5
|
+
from .config import CLIENTS
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_parser():
|
|
9
|
+
parser = argparse.ArgumentParser(description="symbol-tool 命令行工具")
|
|
10
|
+
parser.add_argument(
|
|
11
|
+
"-V", "--version", action="version", version=f"%(prog)s {__version__}"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
options = tuple(CLIENTS.keys())
|
|
15
|
+
|
|
16
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
|
|
18
|
+
upload_parser = subparsers.add_parser("upload", help="上传符号文件")
|
|
19
|
+
upload_parser.add_argument(
|
|
20
|
+
"-t", "--target", choices=options, required=True, help="目标别名"
|
|
21
|
+
)
|
|
22
|
+
upload_parser.add_argument(
|
|
23
|
+
"-p", "--pattern", required=True, help="文件匹配模式,例如 ../Bin/*.pdb"
|
|
24
|
+
)
|
|
25
|
+
upload_parser.set_defaults(handler=handle_upload_command)
|
|
26
|
+
|
|
27
|
+
fix_parser = subparsers.add_parser("fix", help="修复仓库中的符号路径")
|
|
28
|
+
fix_parser.add_argument(
|
|
29
|
+
"-t", "--target", choices=options, required=True, help="目标别名"
|
|
30
|
+
)
|
|
31
|
+
fix_parser.set_defaults(handler=handle_fix_command)
|
|
32
|
+
|
|
33
|
+
transfer_parser = subparsers.add_parser("transfer", help="迁移仓库中的符号文件")
|
|
34
|
+
transfer_parser.add_argument(
|
|
35
|
+
"-s", "--source", choices=options, required=True, help="源别名"
|
|
36
|
+
)
|
|
37
|
+
transfer_parser.add_argument(
|
|
38
|
+
"-t", "--target", choices=options, required=True, help="目标别名"
|
|
39
|
+
)
|
|
40
|
+
transfer_parser.set_defaults(handler=handle_transfer_command)
|
|
41
|
+
|
|
42
|
+
clean_parser = subparsers.add_parser("clean", help="清理仓库中的历史文件")
|
|
43
|
+
clean_parser.add_argument(
|
|
44
|
+
"-t", "--target", choices=options, required=True, help="目标别名"
|
|
45
|
+
)
|
|
46
|
+
clean_parser.set_defaults(handler=handle_clean_command)
|
|
47
|
+
|
|
48
|
+
query_parser = subparsers.add_parser("query", help="查询仓库中的符号文件")
|
|
49
|
+
query_parser.add_argument(
|
|
50
|
+
"-t", "--target", choices=options, required=True, help="目标别名"
|
|
51
|
+
)
|
|
52
|
+
query_parser.add_argument("-n", "--name", help="按文件名匹配,例如 *.pdb")
|
|
53
|
+
query_parser.add_argument("-nn", "--nname", help="按文件名排除匹配,例如 UP*")
|
|
54
|
+
query_parser.add_argument("-p", "--path", help="按路径匹配,例如 */release/*")
|
|
55
|
+
query_parser.add_argument(
|
|
56
|
+
"-cb", "--created-before", help="按创建时间经过多久匹配,例如 180d"
|
|
57
|
+
)
|
|
58
|
+
query_parser.add_argument(
|
|
59
|
+
"-ub", "--updated-before", help="按更新时间经过多久匹配,例如 180d"
|
|
60
|
+
)
|
|
61
|
+
query_parser.add_argument("-l", "--limit", type=int, help="限制返回的结果数量")
|
|
62
|
+
|
|
63
|
+
query_parser.set_defaults(handler=handle_query_command)
|
|
64
|
+
|
|
65
|
+
return parser
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def handle_upload_command(args):
|
|
69
|
+
return upload.process_upload(args.pattern, CLIENTS[args.target])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def handle_fix_command(args):
|
|
73
|
+
return fix.process_artifacts(CLIENTS[args.target])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def handle_transfer_command(args):
|
|
77
|
+
return transfer.process_artifacts(CLIENTS[args.source], CLIENTS[args.target])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def handle_clean_command(args):
|
|
81
|
+
return clean.process_repository(CLIENTS[args.target])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def handle_query_command(args):
|
|
85
|
+
return query.process_query(
|
|
86
|
+
CLIENTS[args.target],
|
|
87
|
+
name=args.name,
|
|
88
|
+
nname=args.nname,
|
|
89
|
+
created=args.created_before,
|
|
90
|
+
updated=args.updated_before,
|
|
91
|
+
path=args.path,
|
|
92
|
+
limit=args.limit,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main(argv=None):
|
|
97
|
+
parser = build_parser()
|
|
98
|
+
args = parser.parse_args(argv)
|
|
99
|
+
return args.handler(args)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from .config import ArtifactoryClient, AqlRequestSearchData
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def process_query(
|
|
5
|
+
client: ArtifactoryClient,
|
|
6
|
+
*,
|
|
7
|
+
name: str | None = None,
|
|
8
|
+
nname: str | None = None,
|
|
9
|
+
created: str | None = None,
|
|
10
|
+
updated: str | None = None,
|
|
11
|
+
path: str | None = None,
|
|
12
|
+
limit: int = 100,
|
|
13
|
+
):
|
|
14
|
+
data = AqlRequestSearchData()
|
|
15
|
+
data.repo = client.repo
|
|
16
|
+
data.path = path
|
|
17
|
+
data.name = name
|
|
18
|
+
data.nname = nname
|
|
19
|
+
data.created_before = created
|
|
20
|
+
data.updated_before = updated
|
|
21
|
+
data.limit = limit or 100
|
|
22
|
+
result = client.get_list(data)
|
|
23
|
+
|
|
24
|
+
print(f"共找到 {len(result.results)} 个符号文件")
|
|
25
|
+
for item in result.results:
|
|
26
|
+
print(item.to_path())
|
|
27
|
+
print(f" created: {item.created}")
|
|
28
|
+
print(f" updated: {item.updated}")
|
|
29
|
+
|
|
30
|
+
return result
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from symstore import pe, pdb
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def probe_pe_hash(file_name):
|
|
5
|
+
try:
|
|
6
|
+
pefile = pe.PEFile(file_name)
|
|
7
|
+
except pe.PESignatureNotFoundError:
|
|
8
|
+
return None
|
|
9
|
+
|
|
10
|
+
return "%.8X%.4x" % (pefile.TimeDateStamp, pefile.SizeOfImage)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def probe_pdb_hash(file_name):
|
|
14
|
+
try:
|
|
15
|
+
pdbfile = pdb.PDBFile(file_name)
|
|
16
|
+
except pdb.PDBInvalidSignature:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
age_str = "" if pdbfile.age is None else "%x" % pdbfile.age
|
|
20
|
+
return "%s%s" % (pdbfile.guid, age_str)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def file_hash(file_name):
|
|
24
|
+
pe_hash = probe_pe_hash(file_name)
|
|
25
|
+
if pe_hash is not None:
|
|
26
|
+
return pe_hash
|
|
27
|
+
|
|
28
|
+
pdb_hash = probe_pdb_hash(file_name)
|
|
29
|
+
if pdb_hash is not None:
|
|
30
|
+
return pdb_hash
|
|
31
|
+
|
|
32
|
+
raise ValueError("Unknown file type")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
2
|
+
import os
|
|
3
|
+
import tqdm
|
|
4
|
+
|
|
5
|
+
from .config import ArtifactoryClient, AqlResponseFileMeta, AqlRequestSearchData
|
|
6
|
+
from .fileio import build_temp_filename, write_stream_to_file
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def transfer_artifact(
|
|
10
|
+
file_meta: AqlResponseFileMeta,
|
|
11
|
+
source: ArtifactoryClient,
|
|
12
|
+
target: ArtifactoryClient,
|
|
13
|
+
):
|
|
14
|
+
file_path = file_meta.to_path()
|
|
15
|
+
source_url = source.get_url(file_path)
|
|
16
|
+
target_url = target.get_url(file_path)
|
|
17
|
+
|
|
18
|
+
source_response = source.head(source_url)
|
|
19
|
+
if source_response.status_code != 200:
|
|
20
|
+
return f"Failed: Source response is {source_response.status_code}"
|
|
21
|
+
|
|
22
|
+
target_response = target.head(target_url)
|
|
23
|
+
if target_response.status_code == 200:
|
|
24
|
+
# 如果目标已存在, 那么删除source中的文件
|
|
25
|
+
delete_response = source.session.delete(source_url)
|
|
26
|
+
if delete_response.status_code in (200, 204):
|
|
27
|
+
return f"Deleted: {file_path}"
|
|
28
|
+
return f"Skipped: {file_path}"
|
|
29
|
+
|
|
30
|
+
source_response = source.get(source_url, stream=True)
|
|
31
|
+
if source_response.status_code != 200:
|
|
32
|
+
return f"Failed: Source response is {source_response.status_code}"
|
|
33
|
+
|
|
34
|
+
temp_filename = build_temp_filename(file_path)
|
|
35
|
+
|
|
36
|
+
write_stream_to_file(source_response, temp_filename)
|
|
37
|
+
|
|
38
|
+
# 上传文件,使用原始文件路径
|
|
39
|
+
with open(temp_filename, "rb") as f:
|
|
40
|
+
target_response = target.put(target_url, data=f)
|
|
41
|
+
if target_response.status_code not in (200, 201):
|
|
42
|
+
os.remove(temp_filename)
|
|
43
|
+
return f"Failed: Target response is {target_response.status_code}"
|
|
44
|
+
|
|
45
|
+
os.remove(temp_filename)
|
|
46
|
+
# 如果转移成功, 那么删除source中的文件
|
|
47
|
+
delete_response = source.session.delete(source_url)
|
|
48
|
+
if delete_response.status_code not in (200, 204):
|
|
49
|
+
return f"Cannot Delete: {file_path}"
|
|
50
|
+
return f"Success: {file_path}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def process_artifacts(source: ArtifactoryClient, target: ArtifactoryClient):
|
|
54
|
+
data = AqlRequestSearchData()
|
|
55
|
+
data.repo = source.repo
|
|
56
|
+
data.nname = "UP*"
|
|
57
|
+
|
|
58
|
+
result = source.get_list(data)
|
|
59
|
+
print(f"Transferring {len(result.results)} files from '{source.repo}' to '{target.repo}'...")
|
|
60
|
+
|
|
61
|
+
tqdm_bar = tqdm.tqdm(total=len(result.results), desc="Transferring files", unit="file")
|
|
62
|
+
|
|
63
|
+
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
64
|
+
# 提交所有下载任务
|
|
65
|
+
futures = {}
|
|
66
|
+
|
|
67
|
+
for file in result.results:
|
|
68
|
+
future = executor.submit(transfer_artifact, file, source, target)
|
|
69
|
+
futures[future] = file
|
|
70
|
+
|
|
71
|
+
# 等待所有任务完成并处理结果
|
|
72
|
+
for future in as_completed(futures):
|
|
73
|
+
if future.result():
|
|
74
|
+
tqdm_bar.set_description(f"{future.result():50}")
|
|
75
|
+
tqdm_bar.update(1)
|
|
76
|
+
|
|
77
|
+
tqdm_bar.close()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import glob
|
|
3
|
+
import pathlib
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
|
|
6
|
+
from .config import ArtifactoryClient
|
|
7
|
+
from .symbols import file_hash
|
|
8
|
+
|
|
9
|
+
MAX_WORKERS = 16
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def process_upload_file(path, client: ArtifactoryClient):
|
|
13
|
+
hash = file_hash(path)
|
|
14
|
+
name = os.path.basename(path)
|
|
15
|
+
|
|
16
|
+
file_url = client.get_url(f"{name}/{hash}/{name}")
|
|
17
|
+
head_resp = client.head(file_url)
|
|
18
|
+
if head_resp.status_code == 200:
|
|
19
|
+
print(f"跳过 (已存在): {path}")
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
with open(path, "rb") as f:
|
|
23
|
+
response = client.put(file_url, data=f)
|
|
24
|
+
if response.status_code in (200, 201):
|
|
25
|
+
print(f"上传成功: {path} -> {file_url}")
|
|
26
|
+
else:
|
|
27
|
+
print(f"上传失败: {path} -> {response.status_code}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def process_upload(pattern: str, client: ArtifactoryClient):
|
|
31
|
+
files = [
|
|
32
|
+
pathlib.Path(f).resolve().as_posix()
|
|
33
|
+
for f in glob.glob(pattern, recursive=True)
|
|
34
|
+
if f.lower().endswith((".pdb", ".dll", ".exe"))
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
if not files:
|
|
38
|
+
print(f"没有匹配的文件: {pattern}")
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
|
42
|
+
futures = [executor.submit(process_upload_file, path, client) for path in files]
|
|
43
|
+
for future in as_completed(futures):
|
|
44
|
+
future.result()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: symbol-tool
|
|
3
|
+
Version: 1.0.0.3
|
|
4
|
+
Summary: 符号管理工具
|
|
5
|
+
Author-email: Vorga <qiujuncheng@up3dtech.com>
|
|
6
|
+
Project-URL: Homepage, http://www.vorga.cn:8081/up3d/software/tool/symbol-tool
|
|
7
|
+
Keywords: up3d,symbol,tool
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.7
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: requests
|
|
21
|
+
Requires-Dist: symstore
|
|
22
|
+
Requires-Dist: tqdm
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: build; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
27
|
+
Requires-Dist: pyinstaller; extra == "dev"
|
|
28
|
+
Requires-Dist: pyinstaller-versionfile; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# symbol-tool
|
|
31
|
+
|
|
32
|
+
用于管理符号文件仓库的命令行工具。
|
|
33
|
+
|
|
34
|
+
## 快速开始
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install -e .
|
|
38
|
+
symbol-tool --version
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 打包
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pyinstaller symbol_tool.spec --clean
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 命令示例
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
symbol-tool upload -t test -p "./**/*.pdb"
|
|
51
|
+
symbol-tool fix -t test
|
|
52
|
+
symbol-tool transfer --source test --target test
|
|
53
|
+
symbol-tool clean -t test
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`target` 是别名。每个别名对应“某个服务器 + 某个路径”,定义在 [symbol_tool/config.py](symbol_tool/config.py)。
|
|
57
|
+
|
|
58
|
+
## 测试
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pytest tests
|
|
62
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
symbol_tool/__init__.py
|
|
4
|
+
symbol_tool/__main__.py
|
|
5
|
+
symbol_tool/clean.py
|
|
6
|
+
symbol_tool/config.py
|
|
7
|
+
symbol_tool/fileio.py
|
|
8
|
+
symbol_tool/fix.py
|
|
9
|
+
symbol_tool/main.py
|
|
10
|
+
symbol_tool/query.py
|
|
11
|
+
symbol_tool/symbols.py
|
|
12
|
+
symbol_tool/transfer.py
|
|
13
|
+
symbol_tool/upload.py
|
|
14
|
+
symbol_tool.egg-info/PKG-INFO
|
|
15
|
+
symbol_tool.egg-info/SOURCES.txt
|
|
16
|
+
symbol_tool.egg-info/dependency_links.txt
|
|
17
|
+
symbol_tool.egg-info/entry_points.txt
|
|
18
|
+
symbol_tool.egg-info/not-zip-safe
|
|
19
|
+
symbol_tool.egg-info/requires.txt
|
|
20
|
+
symbol_tool.egg-info/top_level.txt
|
|
21
|
+
tests/test_query.py
|
|
22
|
+
tests/test_upload.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from unittest.mock import Mock
|
|
2
|
+
|
|
3
|
+
from symbol_tool import query
|
|
4
|
+
from symbol_tool.config import AqlResponseFileMeta, AqlResponseFileRange, AqlResponseSearch
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestQueryRequest:
|
|
8
|
+
def test_build_query_request_uses_client_repo_by_default(self):
|
|
9
|
+
client = Mock()
|
|
10
|
+
client.repo = "symbol-local"
|
|
11
|
+
|
|
12
|
+
data = query.build_query_request(client)
|
|
13
|
+
|
|
14
|
+
assert data.repo == "symbol-local"
|
|
15
|
+
assert data.path == "*"
|
|
16
|
+
assert data.name == "*"
|
|
17
|
+
assert data.created_before == "*"
|
|
18
|
+
assert data.updated_before == "*"
|
|
19
|
+
|
|
20
|
+
def test_build_query_request_accepts_all_filters(self):
|
|
21
|
+
client = Mock()
|
|
22
|
+
client.repo = "symbol-local"
|
|
23
|
+
|
|
24
|
+
data = query.build_query_request(
|
|
25
|
+
client,
|
|
26
|
+
name="*.pdb",
|
|
27
|
+
created="2026-04-01*",
|
|
28
|
+
updated="2026-04-02*",
|
|
29
|
+
path="*/release/*",
|
|
30
|
+
repo="symbol-*",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
assert data.repo == "symbol-*"
|
|
34
|
+
assert data.name == "*.pdb"
|
|
35
|
+
assert data.created_before == "2026-04-01*"
|
|
36
|
+
assert data.updated_before == "2026-04-02*"
|
|
37
|
+
assert data.path == "*/release/*"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestQueryProcess:
|
|
41
|
+
def test_process_query_prints_results(self, capsys):
|
|
42
|
+
client = Mock()
|
|
43
|
+
client.repo = "symbol-local"
|
|
44
|
+
client.get_list.return_value = AqlResponseSearch(
|
|
45
|
+
results=[
|
|
46
|
+
AqlResponseFileMeta(
|
|
47
|
+
repo="symbol-local",
|
|
48
|
+
path="demo.pdb/ABC123",
|
|
49
|
+
name="demo.pdb",
|
|
50
|
+
created="2026-04-01T10:00:00.000Z",
|
|
51
|
+
updated="2026-04-01T10:05:00.000Z",
|
|
52
|
+
)
|
|
53
|
+
],
|
|
54
|
+
range=AqlResponseFileRange(total=1),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
result = query.process_query(client, name="demo.pdb")
|
|
58
|
+
|
|
59
|
+
assert result.range.total == 1
|
|
60
|
+
output = capsys.readouterr().out
|
|
61
|
+
assert "共找到 1 个符号文件" in output
|
|
62
|
+
assert "symbol-local/demo.pdb/ABC123/demo.pdb" in output
|
|
63
|
+
assert "created: 2026-04-01T10:00:00.000Z" in output
|
|
64
|
+
assert "updated: 2026-04-01T10:05:00.000Z" in output
|
|
65
|
+
|
|
66
|
+
def test_process_query_passes_filters_to_client(self):
|
|
67
|
+
client = Mock()
|
|
68
|
+
client.repo = "symbol-local"
|
|
69
|
+
client.get_list.return_value = AqlResponseSearch(results=[], range=AqlResponseFileRange())
|
|
70
|
+
|
|
71
|
+
query.process_query(
|
|
72
|
+
client,
|
|
73
|
+
name="*.dll",
|
|
74
|
+
created="2026-04-*",
|
|
75
|
+
updated="2026-04-*",
|
|
76
|
+
path="*/bin/*",
|
|
77
|
+
repo="symbol-cam",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
request = client.get_list.call_args.args[0]
|
|
81
|
+
assert request.repo == "symbol-cam"
|
|
82
|
+
assert request.name == "*.dll"
|
|
83
|
+
assert request.created == "2026-04-*"
|
|
84
|
+
assert request.updated == "2026-04-*"
|
|
85
|
+
assert request.path == "*/bin/*"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest.mock import Mock
|
|
3
|
+
|
|
4
|
+
from symbol_tool import upload
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DummyResponse:
|
|
8
|
+
def __init__(self, status_code=200):
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ImmediateExecutor:
|
|
13
|
+
def __init__(self, *args, **kwargs):
|
|
14
|
+
self.submitted = []
|
|
15
|
+
|
|
16
|
+
def __enter__(self):
|
|
17
|
+
return self
|
|
18
|
+
|
|
19
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
def submit(self, func, *args, **kwargs):
|
|
23
|
+
result = func(*args, **kwargs)
|
|
24
|
+
future = Mock()
|
|
25
|
+
future.result.return_value = result
|
|
26
|
+
self.submitted.append((func, args, kwargs))
|
|
27
|
+
return future
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestUploadFile:
|
|
31
|
+
def test_process_upload_file_skips_existing(self, tmp_path, monkeypatch, capsys):
|
|
32
|
+
file_path = tmp_path / "demo.pdb"
|
|
33
|
+
file_path.write_bytes(b"data")
|
|
34
|
+
|
|
35
|
+
monkeypatch.setattr(upload, "file_hash", lambda _: "HASH")
|
|
36
|
+
|
|
37
|
+
client = Mock()
|
|
38
|
+
client.get_url.return_value = "http://repo/symbol-local/demo.pdb/HASH/demo.pdb"
|
|
39
|
+
client.head.return_value = DummyResponse(status_code=200)
|
|
40
|
+
|
|
41
|
+
upload.process_upload_file(str(file_path), client)
|
|
42
|
+
|
|
43
|
+
client.put.assert_not_called()
|
|
44
|
+
assert "跳过 (已存在)" in capsys.readouterr().out
|
|
45
|
+
|
|
46
|
+
def test_process_upload_file_upload_success(self, tmp_path, monkeypatch, capsys):
|
|
47
|
+
file_path = tmp_path / "demo.pdb"
|
|
48
|
+
file_path.write_bytes(b"data")
|
|
49
|
+
|
|
50
|
+
monkeypatch.setattr(upload, "file_hash", lambda _: "HASH")
|
|
51
|
+
|
|
52
|
+
client = Mock()
|
|
53
|
+
client.get_url.return_value = "http://repo/symbol-local/demo.pdb/HASH/demo.pdb"
|
|
54
|
+
client.head.return_value = DummyResponse(status_code=404)
|
|
55
|
+
client.put.return_value = DummyResponse(status_code=201)
|
|
56
|
+
|
|
57
|
+
upload.process_upload_file(str(file_path), client)
|
|
58
|
+
|
|
59
|
+
client.put.assert_called_once()
|
|
60
|
+
assert "上传成功" in capsys.readouterr().out
|
|
61
|
+
|
|
62
|
+
def test_process_upload_file_upload_failed(self, tmp_path, monkeypatch, capsys):
|
|
63
|
+
file_path = tmp_path / "demo.pdb"
|
|
64
|
+
file_path.write_bytes(b"data")
|
|
65
|
+
|
|
66
|
+
monkeypatch.setattr(upload, "file_hash", lambda _: "HASH")
|
|
67
|
+
|
|
68
|
+
client = Mock()
|
|
69
|
+
client.get_url.return_value = "http://repo/symbol-local/demo.pdb/HASH/demo.pdb"
|
|
70
|
+
client.head.return_value = DummyResponse(status_code=404)
|
|
71
|
+
client.put.return_value = DummyResponse(status_code=500)
|
|
72
|
+
|
|
73
|
+
upload.process_upload_file(str(file_path), client)
|
|
74
|
+
|
|
75
|
+
assert "上传失败" in capsys.readouterr().out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestUploadPattern:
|
|
79
|
+
def test_process_upload_reports_when_no_files(self, monkeypatch, capsys):
|
|
80
|
+
monkeypatch.setattr(upload.glob, "glob", lambda pattern, recursive: [])
|
|
81
|
+
|
|
82
|
+
client = Mock()
|
|
83
|
+
upload.process_upload("./*.pdb", client)
|
|
84
|
+
|
|
85
|
+
assert "没有匹配的文件" in capsys.readouterr().out
|
|
86
|
+
|
|
87
|
+
def test_process_upload_filters_extensions_and_submits_jobs(self, monkeypatch, tmp_path):
|
|
88
|
+
pdb_file = tmp_path / "a.pdb"
|
|
89
|
+
dll_file = tmp_path / "b.dll"
|
|
90
|
+
txt_file = tmp_path / "c.txt"
|
|
91
|
+
pdb_file.write_bytes(b"pdb")
|
|
92
|
+
dll_file.write_bytes(b"dll")
|
|
93
|
+
txt_file.write_bytes(b"txt")
|
|
94
|
+
|
|
95
|
+
monkeypatch.setattr(
|
|
96
|
+
upload.glob,
|
|
97
|
+
"glob",
|
|
98
|
+
lambda pattern, recursive: [str(pdb_file), str(dll_file), str(txt_file)],
|
|
99
|
+
)
|
|
100
|
+
monkeypatch.setattr(upload, "ThreadPoolExecutor", ImmediateExecutor)
|
|
101
|
+
monkeypatch.setattr(upload, "as_completed", lambda futures: futures)
|
|
102
|
+
|
|
103
|
+
submitted = []
|
|
104
|
+
|
|
105
|
+
def fake_process_upload_file(file_path, client):
|
|
106
|
+
submitted.append(Path(file_path).name)
|
|
107
|
+
|
|
108
|
+
monkeypatch.setattr(upload, "process_upload_file", fake_process_upload_file)
|
|
109
|
+
|
|
110
|
+
client = Mock()
|
|
111
|
+
upload.process_upload("./**/*", client)
|
|
112
|
+
|
|
113
|
+
assert submitted == ["a.pdb", "b.dll"]
|