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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0.3"
@@ -0,0 +1,4 @@
1
+ from symbol_tool.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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,2 @@
1
+ [console_scripts]
2
+ symbol-tool = symbol_tool.main:main
@@ -0,0 +1,10 @@
1
+ requests
2
+ symstore
3
+ tqdm
4
+
5
+ [dev]
6
+ build
7
+ pytest
8
+ pytest-cov
9
+ pyinstaller
10
+ pyinstaller-versionfile
@@ -0,0 +1,2 @@
1
+ assets
2
+ symbol_tool
@@ -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"]