ChatPyPI 0.1.1__py3-none-any.whl
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.
- chatpypi/__init__.py +43 -0
- chatpypi/cli.py +541 -0
- chatpypi/main.py +1779 -0
- chatpypi-0.1.1.dist-info/METADATA +77 -0
- chatpypi-0.1.1.dist-info/RECORD +9 -0
- chatpypi-0.1.1.dist-info/WHEEL +5 -0
- chatpypi-0.1.1.dist-info/entry_points.txt +2 -0
- chatpypi-0.1.1.dist-info/licenses/LICENSE +21 -0
- chatpypi-0.1.1.dist-info/top_level.txt +1 -0
chatpypi/main.py
ADDED
|
@@ -0,0 +1,1779 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import importlib
|
|
6
|
+
import json
|
|
7
|
+
import importlib.util
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import textwrap
|
|
13
|
+
from urllib import error as urllib_error
|
|
14
|
+
from urllib import parse as urllib_parse
|
|
15
|
+
from urllib import request as urllib_request
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
import tomllib
|
|
19
|
+
except ModuleNotFoundError: # pragma: no cover
|
|
20
|
+
import tomli as tomllib # type: ignore
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
DEFAULT_DIST_DIRNAME = "dist"
|
|
24
|
+
LICENSE_TEMPLATES = {
|
|
25
|
+
"MIT": """MIT License
|
|
26
|
+
|
|
27
|
+
Copyright (c) {year} {author}
|
|
28
|
+
|
|
29
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
30
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
31
|
+
in the Software without restriction, including without limitation the rights
|
|
32
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
33
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
34
|
+
furnished to do so, subject to the following conditions:
|
|
35
|
+
|
|
36
|
+
The above copyright notice and this permission notice shall be included in all
|
|
37
|
+
copies or substantial portions of the Software.
|
|
38
|
+
|
|
39
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
40
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
41
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
42
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
43
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
44
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
45
|
+
SOFTWARE.
|
|
46
|
+
""",
|
|
47
|
+
"Apache-2.0": """Apache License
|
|
48
|
+
Version 2.0, January 2004
|
|
49
|
+
https://www.apache.org/licenses/
|
|
50
|
+
|
|
51
|
+
Copyright {year} {author}
|
|
52
|
+
|
|
53
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
54
|
+
you may not use this file except in compliance with the License.
|
|
55
|
+
You may obtain a copy of the License at
|
|
56
|
+
|
|
57
|
+
https://www.apache.org/licenses/LICENSE-2.0
|
|
58
|
+
|
|
59
|
+
Unless required by applicable law or agreed to in writing, software
|
|
60
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
61
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
62
|
+
See the License for the specific language governing permissions and
|
|
63
|
+
limitations under the License.
|
|
64
|
+
""",
|
|
65
|
+
"BSD-3-Clause": """BSD 3-Clause License
|
|
66
|
+
|
|
67
|
+
Copyright (c) {year}, {author}
|
|
68
|
+
All rights reserved.
|
|
69
|
+
|
|
70
|
+
Redistribution and use in source and binary forms, with or without
|
|
71
|
+
modification, are permitted provided that the following conditions are met:
|
|
72
|
+
|
|
73
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
74
|
+
list of conditions and the following disclaimer.
|
|
75
|
+
|
|
76
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
77
|
+
this list of conditions and the following disclaimer in the documentation
|
|
78
|
+
and/or other materials provided with the distribution.
|
|
79
|
+
|
|
80
|
+
3. Neither the name of the copyright holder nor the names of its contributors
|
|
81
|
+
may be used to endorse or promote products derived from this software
|
|
82
|
+
without specific prior written permission.
|
|
83
|
+
|
|
84
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
85
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
86
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
87
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
88
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
89
|
+
DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE.
|
|
90
|
+
""",
|
|
91
|
+
"GPL-3.0-only": """GNU GENERAL PUBLIC LICENSE
|
|
92
|
+
Version 3, 29 June 2007
|
|
93
|
+
|
|
94
|
+
Copyright (c) {year} {author}
|
|
95
|
+
|
|
96
|
+
This project is licensed under the GNU General Public License version 3.
|
|
97
|
+
See https://www.gnu.org/licenses/gpl-3.0.en.html for the full license text.
|
|
98
|
+
""",
|
|
99
|
+
"Proprietary": """Proprietary License
|
|
100
|
+
|
|
101
|
+
Copyright (c) {year} {author}. All rights reserved.
|
|
102
|
+
|
|
103
|
+
This software is proprietary and confidential. Unauthorized copying,
|
|
104
|
+
distribution, modification, or use of this software is prohibited without
|
|
105
|
+
prior written permission.
|
|
106
|
+
""",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class PyPICommandError(RuntimeError):
|
|
111
|
+
"""Raised when a package operation cannot be completed safely."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class ProjectMetadata:
|
|
116
|
+
name: str | None
|
|
117
|
+
version: str | None
|
|
118
|
+
version_source: str | None
|
|
119
|
+
readme: str | None
|
|
120
|
+
requires_python: str | None
|
|
121
|
+
license_text: str | None
|
|
122
|
+
dynamic_fields: list[str]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class DoctorCheck:
|
|
127
|
+
label: str
|
|
128
|
+
status: str
|
|
129
|
+
detail: str
|
|
130
|
+
hint: str | None = None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class CommandResult:
|
|
135
|
+
args: list[str]
|
|
136
|
+
returncode: int
|
|
137
|
+
stdout: str
|
|
138
|
+
stderr: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class ScaffoldResult:
|
|
143
|
+
project_dir: Path
|
|
144
|
+
package_name: str
|
|
145
|
+
module_name: str
|
|
146
|
+
created_files: list[Path]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class RepositoryCheck:
|
|
151
|
+
label: str
|
|
152
|
+
status: str
|
|
153
|
+
detail: str
|
|
154
|
+
hint: str | None = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _extract_project_snippets(payload: dict | None) -> list[RepositoryCheck]:
|
|
158
|
+
if not isinstance(payload, dict):
|
|
159
|
+
return []
|
|
160
|
+
info = payload.get("info")
|
|
161
|
+
if not isinstance(info, dict):
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
snippets: list[RepositoryCheck] = []
|
|
165
|
+
version = info.get("version")
|
|
166
|
+
if isinstance(version, str) and version.strip():
|
|
167
|
+
snippets.append(RepositoryCheck("latest version", "info", version.strip()))
|
|
168
|
+
|
|
169
|
+
release_entries = payload.get("urls")
|
|
170
|
+
if not isinstance(release_entries, list):
|
|
171
|
+
releases = payload.get("releases")
|
|
172
|
+
if isinstance(releases, dict) and isinstance(version, str) and version.strip():
|
|
173
|
+
release_entries = releases.get(version.strip())
|
|
174
|
+
|
|
175
|
+
timestamps: list[tuple[datetime, str]] = []
|
|
176
|
+
for release_item in release_entries if isinstance(release_entries, list) else []:
|
|
177
|
+
if not isinstance(release_item, dict):
|
|
178
|
+
continue
|
|
179
|
+
uploaded = release_item.get("upload_time_iso_8601") or release_item.get(
|
|
180
|
+
"upload_time"
|
|
181
|
+
)
|
|
182
|
+
if not isinstance(uploaded, str) or not uploaded.strip():
|
|
183
|
+
continue
|
|
184
|
+
normalized = uploaded.strip().replace("Z", "+00:00")
|
|
185
|
+
try:
|
|
186
|
+
parsed = datetime.fromisoformat(normalized)
|
|
187
|
+
except ValueError:
|
|
188
|
+
continue
|
|
189
|
+
if parsed.tzinfo is None:
|
|
190
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
191
|
+
timestamps.append((parsed, uploaded.strip()))
|
|
192
|
+
if timestamps:
|
|
193
|
+
_, latest_uploaded = max(timestamps, key=lambda item: item[0])
|
|
194
|
+
snippets.append(RepositoryCheck("latest release date", "info", latest_uploaded))
|
|
195
|
+
|
|
196
|
+
summary = info.get("summary")
|
|
197
|
+
if isinstance(summary, str) and summary.strip():
|
|
198
|
+
snippets.append(RepositoryCheck("summary", "info", summary.strip()))
|
|
199
|
+
|
|
200
|
+
author = info.get("author")
|
|
201
|
+
if isinstance(author, str) and author.strip():
|
|
202
|
+
snippets.append(RepositoryCheck("author", "info", author.strip()))
|
|
203
|
+
|
|
204
|
+
author_email = info.get("author_email")
|
|
205
|
+
if isinstance(author_email, str) and author_email.strip():
|
|
206
|
+
snippets.append(RepositoryCheck("author email", "info", author_email.strip()))
|
|
207
|
+
|
|
208
|
+
requires_python = info.get("requires_python")
|
|
209
|
+
if isinstance(requires_python, str) and requires_python.strip():
|
|
210
|
+
snippets.append(
|
|
211
|
+
RepositoryCheck("requires python", "info", requires_python.strip())
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
project_url = info.get("project_url") or info.get("home_page")
|
|
215
|
+
if isinstance(project_url, str) and project_url.strip():
|
|
216
|
+
snippets.append(RepositoryCheck("project url", "info", project_url.strip()))
|
|
217
|
+
|
|
218
|
+
return snippets
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _normalized_project_name(name: str) -> str:
|
|
222
|
+
return name.strip().lower().replace("_", "-").replace(".", "-")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def resolve_dist_dir(project_dir: Path, dist_dir: Path | None = None) -> Path:
|
|
226
|
+
if dist_dir is None:
|
|
227
|
+
return project_dir / DEFAULT_DIST_DIRNAME
|
|
228
|
+
return dist_dir
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def normalize_module_name(package_name: str) -> str:
|
|
232
|
+
normalized = package_name.strip().replace("-", "_").replace(" ", "_")
|
|
233
|
+
parts = [char if (char.isalnum() or char == "_") else "_" for char in normalized]
|
|
234
|
+
module_name = "".join(parts).strip("_").lower()
|
|
235
|
+
while "__" in module_name:
|
|
236
|
+
module_name = module_name.replace("__", "_")
|
|
237
|
+
if not module_name:
|
|
238
|
+
raise PyPICommandError(
|
|
239
|
+
"Package name must contain at least one valid letter or digit."
|
|
240
|
+
)
|
|
241
|
+
if module_name[0].isdigit():
|
|
242
|
+
raise PyPICommandError("Module name cannot start with a digit.")
|
|
243
|
+
return module_name
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _toml_escape(value: str) -> str:
|
|
247
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _py_string_literal(value: str) -> str:
|
|
251
|
+
return json.dumps(value)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _pascal_identifier(value: str) -> str:
|
|
255
|
+
parts = [part for part in re.split(r"[^A-Za-z0-9]+", value) if part]
|
|
256
|
+
if not parts:
|
|
257
|
+
return "Config"
|
|
258
|
+
return "".join(part[:1].upper() + part[1:].lower() for part in parts)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _workflow_python_version(requires_python: str) -> str:
|
|
262
|
+
match = re.search(r">=\s*(\d+\.\d+)", requires_python)
|
|
263
|
+
if match:
|
|
264
|
+
return match.group(1)
|
|
265
|
+
return "3.10"
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _license_template_content(license_name: str, author: str | None) -> str:
|
|
269
|
+
from datetime import date
|
|
270
|
+
|
|
271
|
+
normalized = license_name.strip() or "MIT"
|
|
272
|
+
template = LICENSE_TEMPLATES.get(normalized)
|
|
273
|
+
if template is None:
|
|
274
|
+
template = LICENSE_TEMPLATES["Proprietary"]
|
|
275
|
+
if normalized.lower() not in {"proprietary", "unlicensed"}:
|
|
276
|
+
return f"{normalized}\n\nCopyright (c) {date.today().year} {author or 'PROJECT OWNER'}.\n"
|
|
277
|
+
return template.format(year=date.today().year, author=author or "PROJECT OWNER")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _ensure_empty_or_missing(project_dir: Path) -> None:
|
|
281
|
+
if not project_dir.exists():
|
|
282
|
+
return
|
|
283
|
+
if not project_dir.is_dir():
|
|
284
|
+
raise PyPICommandError(
|
|
285
|
+
f"Target path exists and is not a directory: {project_dir}"
|
|
286
|
+
)
|
|
287
|
+
if any(project_dir.iterdir()):
|
|
288
|
+
raise PyPICommandError(f"Target directory is not empty: {project_dir}")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _build_pyproject_content(
|
|
292
|
+
package_name: str,
|
|
293
|
+
module_name: str,
|
|
294
|
+
description: str,
|
|
295
|
+
requires_python: str,
|
|
296
|
+
license_name: str,
|
|
297
|
+
author: str | None,
|
|
298
|
+
email: str | None,
|
|
299
|
+
) -> str:
|
|
300
|
+
lines = [
|
|
301
|
+
"[build-system]",
|
|
302
|
+
'requires = ["setuptools>=61.0", "wheel"]',
|
|
303
|
+
'build-backend = "setuptools.build_meta"',
|
|
304
|
+
"",
|
|
305
|
+
"[project]",
|
|
306
|
+
f'name = "{_toml_escape(package_name)}"',
|
|
307
|
+
'dynamic = ["version"]',
|
|
308
|
+
f'description = "{_toml_escape(description)}"',
|
|
309
|
+
'readme = "README.md"',
|
|
310
|
+
f'requires-python = "{_toml_escape(requires_python)}"',
|
|
311
|
+
f'license = "{_toml_escape(license_name)}"',
|
|
312
|
+
]
|
|
313
|
+
if author and email:
|
|
314
|
+
lines.append(
|
|
315
|
+
f'authors = [{{name = "{_toml_escape(author)}", email = "{_toml_escape(email)}"}}]'
|
|
316
|
+
)
|
|
317
|
+
elif author:
|
|
318
|
+
lines.append(f'authors = [{{name = "{_toml_escape(author)}"}}]')
|
|
319
|
+
elif email:
|
|
320
|
+
lines.append(f'authors = [{{email = "{_toml_escape(email)}"}}]')
|
|
321
|
+
lines.extend(
|
|
322
|
+
[
|
|
323
|
+
f'keywords = ["{_toml_escape(module_name)}"]',
|
|
324
|
+
"classifiers = [",
|
|
325
|
+
' "Programming Language :: Python :: 3",',
|
|
326
|
+
' "Operating System :: OS Independent",',
|
|
327
|
+
"]",
|
|
328
|
+
"",
|
|
329
|
+
"[tool.setuptools.dynamic]",
|
|
330
|
+
f'version = {{attr = "{module_name}.__version__"}}',
|
|
331
|
+
"",
|
|
332
|
+
"[tool.setuptools.packages.find]",
|
|
333
|
+
'where = ["src"]',
|
|
334
|
+
"",
|
|
335
|
+
"[tool.setuptools]",
|
|
336
|
+
"include-package-data = true",
|
|
337
|
+
"",
|
|
338
|
+
]
|
|
339
|
+
)
|
|
340
|
+
return "\n".join(lines)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _build_chatarch_pyproject_content(
|
|
344
|
+
package_name: str,
|
|
345
|
+
module_name: str,
|
|
346
|
+
description: str,
|
|
347
|
+
requires_python: str,
|
|
348
|
+
license_name: str,
|
|
349
|
+
author: str | None,
|
|
350
|
+
email: str | None,
|
|
351
|
+
include_mkdocs: bool = True,
|
|
352
|
+
chatenv_provider_name: str | None = None,
|
|
353
|
+
) -> str:
|
|
354
|
+
repo_slug = _chatarch_repo_slug(package_name)
|
|
355
|
+
docs_url = _chatarch_docs_url(package_name)
|
|
356
|
+
lines = [
|
|
357
|
+
"[build-system]",
|
|
358
|
+
'requires = ["setuptools>=61.0", "wheel"]',
|
|
359
|
+
'build-backend = "setuptools.build_meta"',
|
|
360
|
+
"",
|
|
361
|
+
"[project]",
|
|
362
|
+
f'name = "{_toml_escape(package_name)}"',
|
|
363
|
+
'dynamic = ["version"]',
|
|
364
|
+
f'description = "{_toml_escape(description)}"',
|
|
365
|
+
'readme = "README.md"',
|
|
366
|
+
f'requires-python = "{_toml_escape(requires_python)}"',
|
|
367
|
+
f'license = "{_toml_escape(license_name)}"',
|
|
368
|
+
'dependencies = ["click>=8.0", "chatstyle>=0.1.0,<0.2.0", "chatenv>=0.2.0,<0.3.0"]',
|
|
369
|
+
]
|
|
370
|
+
if author and email:
|
|
371
|
+
lines.append(
|
|
372
|
+
f'authors = [{{name = "{_toml_escape(author)}", email = "{_toml_escape(email)}"}}]'
|
|
373
|
+
)
|
|
374
|
+
elif author:
|
|
375
|
+
lines.append(f'authors = [{{name = "{_toml_escape(author)}"}}]')
|
|
376
|
+
elif email:
|
|
377
|
+
lines.append(f'authors = [{{email = "{_toml_escape(email)}"}}]')
|
|
378
|
+
lines.extend(
|
|
379
|
+
[
|
|
380
|
+
f'keywords = ["{_toml_escape(module_name)}", "chatarch", "cli"]',
|
|
381
|
+
"classifiers = [",
|
|
382
|
+
' "Programming Language :: Python :: 3",',
|
|
383
|
+
' "Operating System :: OS Independent",',
|
|
384
|
+
"]",
|
|
385
|
+
"",
|
|
386
|
+
"[project.urls]",
|
|
387
|
+
f'Homepage = "https://github.com/{_toml_escape(repo_slug)}"',
|
|
388
|
+
f'Repository = "https://github.com/{_toml_escape(repo_slug)}"',
|
|
389
|
+
]
|
|
390
|
+
)
|
|
391
|
+
if include_mkdocs:
|
|
392
|
+
lines.append(f'Documentation = "{_toml_escape(docs_url)}"')
|
|
393
|
+
lines.extend(
|
|
394
|
+
[
|
|
395
|
+
"",
|
|
396
|
+
"[project.scripts]",
|
|
397
|
+
f'{module_name} = "{module_name}.cli:main"',
|
|
398
|
+
]
|
|
399
|
+
)
|
|
400
|
+
if chatenv_provider_name:
|
|
401
|
+
lines.extend(
|
|
402
|
+
[
|
|
403
|
+
"",
|
|
404
|
+
'[project.entry-points."chatenv.configs"]',
|
|
405
|
+
f'{chatenv_provider_name} = "{module_name}.config"',
|
|
406
|
+
]
|
|
407
|
+
)
|
|
408
|
+
lines.extend(
|
|
409
|
+
[
|
|
410
|
+
"",
|
|
411
|
+
"[project.optional-dependencies]",
|
|
412
|
+
'dev = ["build", "pytest", "twine"]',
|
|
413
|
+
]
|
|
414
|
+
)
|
|
415
|
+
if include_mkdocs:
|
|
416
|
+
lines.append('docs = ["mkdocs>=1.4.0", "mkdocs-material>=9.0.0", "mike>=2.0.0"]')
|
|
417
|
+
lines.extend(
|
|
418
|
+
[
|
|
419
|
+
"",
|
|
420
|
+
"[tool.setuptools.dynamic]",
|
|
421
|
+
f'version = {{attr = "{module_name}.__version__"}}',
|
|
422
|
+
"",
|
|
423
|
+
"[tool.setuptools.packages.find]",
|
|
424
|
+
'where = ["src"]',
|
|
425
|
+
"",
|
|
426
|
+
"[tool.setuptools]",
|
|
427
|
+
"include-package-data = true",
|
|
428
|
+
"",
|
|
429
|
+
]
|
|
430
|
+
)
|
|
431
|
+
return "\n".join(lines)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _build_chatarch_chatenv_config_py(
|
|
435
|
+
package_name: str,
|
|
436
|
+
module_name: str,
|
|
437
|
+
provider_name: str,
|
|
438
|
+
) -> str:
|
|
439
|
+
class_name = f"{_pascal_identifier(module_name)}Config"
|
|
440
|
+
storage_dir = _pascal_identifier(provider_name)
|
|
441
|
+
env_key_prefix = module_name.upper()
|
|
442
|
+
aliases = [provider_name]
|
|
443
|
+
if module_name not in aliases:
|
|
444
|
+
aliases.append(module_name)
|
|
445
|
+
aliases_text = ", ".join(_py_string_literal(alias) for alias in aliases)
|
|
446
|
+
env_key = f"{env_key_prefix}_API_KEY"
|
|
447
|
+
return (
|
|
448
|
+
textwrap.dedent(
|
|
449
|
+
f'''\
|
|
450
|
+
{_py_string_literal(f"Typed environment configuration for {package_name}.")}
|
|
451
|
+
|
|
452
|
+
from chatenv import BaseEnvConfig, EnvField
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class {class_name}(BaseEnvConfig):
|
|
456
|
+
{_py_string_literal(f"{package_name} ChatEnv configuration.")}
|
|
457
|
+
|
|
458
|
+
_title = {_py_string_literal(f"{package_name} Configuration")}
|
|
459
|
+
_aliases = [{aliases_text}]
|
|
460
|
+
_storage_dir = {_py_string_literal(storage_dir)}
|
|
461
|
+
|
|
462
|
+
{env_key_prefix}_API_KEY = EnvField(
|
|
463
|
+
{_py_string_literal(env_key)},
|
|
464
|
+
desc="API key",
|
|
465
|
+
is_sensitive=True,
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
__all__ = ["{class_name}"]
|
|
470
|
+
'''
|
|
471
|
+
).strip()
|
|
472
|
+
+ "\n"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _chatarch_repo_slug(package_name: str) -> str:
|
|
477
|
+
return f"ChatArch/{package_name}"
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _chatarch_docs_url(package_name: str) -> str:
|
|
481
|
+
return f"https://ChatArch.github.io/{package_name}"
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _chatarch_badge_block(
|
|
485
|
+
package_name: str, *, include_mkdocs: bool, include_workflows: bool
|
|
486
|
+
) -> str:
|
|
487
|
+
repo_slug = _chatarch_repo_slug(package_name)
|
|
488
|
+
docs_url = _chatarch_docs_url(package_name)
|
|
489
|
+
lines = [
|
|
490
|
+
'<div align="center">',
|
|
491
|
+
f' <a href="https://pypi.python.org/pypi/{package_name}">',
|
|
492
|
+
f' <img src="https://img.shields.io/pypi/v/{package_name}.svg" alt="PyPI version" />',
|
|
493
|
+
" </a>",
|
|
494
|
+
]
|
|
495
|
+
if include_workflows:
|
|
496
|
+
lines.extend(
|
|
497
|
+
[
|
|
498
|
+
f' <a href="https://github.com/{repo_slug}/actions/workflows/ci.yml">',
|
|
499
|
+
f' <img src="https://github.com/{repo_slug}/actions/workflows/ci.yml/badge.svg" alt="Tests" />',
|
|
500
|
+
" </a>",
|
|
501
|
+
]
|
|
502
|
+
)
|
|
503
|
+
if include_mkdocs:
|
|
504
|
+
lines.extend(
|
|
505
|
+
[
|
|
506
|
+
f' <a href="{docs_url}">',
|
|
507
|
+
' <img src="https://img.shields.io/badge/docs-mkdocs-blue.svg" alt="Documentation" />',
|
|
508
|
+
" </a>",
|
|
509
|
+
]
|
|
510
|
+
)
|
|
511
|
+
lines.append("</div>")
|
|
512
|
+
return "\n".join(lines)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _chatarch_layout_lines(*, include_mkdocs: bool) -> str:
|
|
516
|
+
lines = [
|
|
517
|
+
"- `src/`:包源码",
|
|
518
|
+
"- `tests/code-tests/`:代码测试和历史测试迁移",
|
|
519
|
+
"- `tests/cli-tests/`:真实 CLI 测试,doc-first",
|
|
520
|
+
"- `tests/mock-cli-tests/`:mock/fake CLI 测试,doc-first",
|
|
521
|
+
]
|
|
522
|
+
if include_mkdocs:
|
|
523
|
+
lines.append("- `docs/`:长期维护文档,由 mkdocs 构建")
|
|
524
|
+
return "\n".join(lines)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _chatarch_layout_lines_en(*, include_mkdocs: bool) -> str:
|
|
528
|
+
lines = [
|
|
529
|
+
"- `src/`: package source code",
|
|
530
|
+
"- `tests/code-tests/`: code tests and migrated historical tests",
|
|
531
|
+
"- `tests/cli-tests/`: real CLI tests, doc-first",
|
|
532
|
+
"- `tests/mock-cli-tests/`: mock/fake CLI tests, doc-first",
|
|
533
|
+
]
|
|
534
|
+
if include_mkdocs:
|
|
535
|
+
lines.append("- `docs/`: long-lived project docs built by mkdocs")
|
|
536
|
+
return "\n".join(lines)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _build_chatarch_readme(
|
|
540
|
+
package_name: str,
|
|
541
|
+
module_name: str,
|
|
542
|
+
description: str,
|
|
543
|
+
*,
|
|
544
|
+
include_mkdocs: bool = True,
|
|
545
|
+
include_workflows: bool = True,
|
|
546
|
+
) -> str:
|
|
547
|
+
badges = _chatarch_badge_block(
|
|
548
|
+
package_name,
|
|
549
|
+
include_mkdocs=include_mkdocs,
|
|
550
|
+
include_workflows=include_workflows,
|
|
551
|
+
)
|
|
552
|
+
layout = _chatarch_layout_lines(include_mkdocs=include_mkdocs)
|
|
553
|
+
return f"""\
|
|
554
|
+
{badges}
|
|
555
|
+
|
|
556
|
+
<div align="center">
|
|
557
|
+
|
|
558
|
+
[English](README.en.md) | [简体中文](README.md)
|
|
559
|
+
</div>
|
|
560
|
+
|
|
561
|
+
# {package_name}
|
|
562
|
+
|
|
563
|
+
{description}
|
|
564
|
+
|
|
565
|
+
## 快速开始
|
|
566
|
+
|
|
567
|
+
```bash
|
|
568
|
+
pip install -e ".[dev]"
|
|
569
|
+
{module_name} hello ChatArch
|
|
570
|
+
python -m pytest -q
|
|
571
|
+
python -m build
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
## CLI 规范
|
|
575
|
+
|
|
576
|
+
这个模板默认依赖 `chatstyle>=0.1.0,<0.2.0` 和 `chatenv>=0.2.0,<0.3.0`,新的命令应优先使用:
|
|
577
|
+
|
|
578
|
+
- `CommandSchema` / `CommandField` 描述输入。
|
|
579
|
+
- `add_interactive_option()` 提供统一 `-i/-I`。
|
|
580
|
+
- `resolve_command_inputs()` 统一缺参补问、默认值、TTY 与校验。
|
|
581
|
+
|
|
582
|
+
## 目录结构
|
|
583
|
+
|
|
584
|
+
{layout}
|
|
585
|
+
|
|
586
|
+
## 开发说明
|
|
587
|
+
|
|
588
|
+
扩展脚手架前,先阅读 `DEVELOP.md` 和 `AGENTS.md`。
|
|
589
|
+
"""
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _build_chatarch_readme_en(
|
|
593
|
+
package_name: str,
|
|
594
|
+
module_name: str,
|
|
595
|
+
description: str,
|
|
596
|
+
*,
|
|
597
|
+
include_mkdocs: bool = True,
|
|
598
|
+
include_workflows: bool = True,
|
|
599
|
+
) -> str:
|
|
600
|
+
badges = _chatarch_badge_block(
|
|
601
|
+
package_name,
|
|
602
|
+
include_mkdocs=include_mkdocs,
|
|
603
|
+
include_workflows=include_workflows,
|
|
604
|
+
)
|
|
605
|
+
layout = _chatarch_layout_lines_en(include_mkdocs=include_mkdocs)
|
|
606
|
+
return f"""\
|
|
607
|
+
{badges}
|
|
608
|
+
|
|
609
|
+
<div align="center">
|
|
610
|
+
|
|
611
|
+
[English](README.en.md) | [简体中文](README.md)
|
|
612
|
+
</div>
|
|
613
|
+
|
|
614
|
+
# {package_name}
|
|
615
|
+
|
|
616
|
+
{description}
|
|
617
|
+
|
|
618
|
+
## Quick Start
|
|
619
|
+
|
|
620
|
+
```bash
|
|
621
|
+
pip install -e ".[dev]"
|
|
622
|
+
{module_name} hello ChatArch
|
|
623
|
+
python -m pytest -q
|
|
624
|
+
python -m build
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
## CLI Contract
|
|
628
|
+
|
|
629
|
+
This template depends on `chatstyle>=0.1.0,<0.2.0` and `chatenv>=0.2.0,<0.3.0`. New commands should prefer:
|
|
630
|
+
|
|
631
|
+
- `CommandSchema` / `CommandField` for inputs.
|
|
632
|
+
- `add_interactive_option()` for the shared `-i/-I` switch.
|
|
633
|
+
- `resolve_command_inputs()` for missing args, defaults, TTY behavior, and validation.
|
|
634
|
+
|
|
635
|
+
## Layout
|
|
636
|
+
|
|
637
|
+
{layout}
|
|
638
|
+
|
|
639
|
+
## Development Notes
|
|
640
|
+
|
|
641
|
+
See `DEVELOP.md` and `AGENTS.md` before expanding the scaffold.
|
|
642
|
+
"""
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _build_chatarch_develop_md() -> str:
|
|
646
|
+
return (
|
|
647
|
+
textwrap.dedent(
|
|
648
|
+
"""
|
|
649
|
+
# Development Guide
|
|
650
|
+
|
|
651
|
+
## CLI Rules
|
|
652
|
+
|
|
653
|
+
- Use `chatstyle>=0.1.0,<0.2.0` and `chatenv>=0.2.0,<0.3.0` as the canonical CLI interaction runtime.
|
|
654
|
+
- Prefer `CommandSchema`, `CommandField`, `add_interactive_option()`, and `resolve_command_inputs()` for new commands.
|
|
655
|
+
- Missing required args should auto-enter interactive mode when recoverable.
|
|
656
|
+
- `-i` forces interactive mode; `-I` disables prompting and must fail fast.
|
|
657
|
+
- Prompt defaults must match actual execution defaults.
|
|
658
|
+
- Sensitive values must stay masked in prompts and summaries.
|
|
659
|
+
- Prefer lazy imports in CLI wiring and keep implementation imports local when possible.
|
|
660
|
+
|
|
661
|
+
## Docs and Tests
|
|
662
|
+
|
|
663
|
+
- Use doc-first CLI testing.
|
|
664
|
+
- Put real CLI coverage under `tests/cli-tests/`.
|
|
665
|
+
- Put mock/fake CLI coverage under `tests/mock-cli-tests/`.
|
|
666
|
+
- Keep `README.md`, `docs/`, and `CHANGELOG.md` in sync with user-facing changes.
|
|
667
|
+
|
|
668
|
+
## Automation
|
|
669
|
+
|
|
670
|
+
- Keep automation small and reviewable.
|
|
671
|
+
- Prefer commands that can run in CI without interactive prompts.
|
|
672
|
+
- Ensure generated defaults are safe for local development.
|
|
673
|
+
"""
|
|
674
|
+
).strip()
|
|
675
|
+
+ "\n"
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _build_chatarch_changelog() -> str:
|
|
680
|
+
return "# Changelog\n\n## YYYY-MM-DD\n\n### Added\n\n### Changed\n\n### Fixed\n"
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _build_chatarch_cli_py(module_name: str) -> str:
|
|
684
|
+
return (
|
|
685
|
+
textwrap.dedent(
|
|
686
|
+
f"""
|
|
687
|
+
\"\"\"CLI entrypoint for {module_name}.\"\"\"
|
|
688
|
+
|
|
689
|
+
import click
|
|
690
|
+
from chatstyle import (
|
|
691
|
+
CommandField,
|
|
692
|
+
CommandSchema,
|
|
693
|
+
add_interactive_option,
|
|
694
|
+
render_success,
|
|
695
|
+
resolve_command_inputs,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
HELLO_SCHEMA = CommandSchema(
|
|
700
|
+
name="hello",
|
|
701
|
+
fields=(CommandField("name", prompt="name", required=True),),
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
@click.group()
|
|
706
|
+
def main() -> None:
|
|
707
|
+
\"\"\"{module_name} command line interface.\"\"\"
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@main.command()
|
|
711
|
+
@click.argument("name", required=False)
|
|
712
|
+
@add_interactive_option
|
|
713
|
+
def hello(name: str | None, interactive: bool | None) -> None:
|
|
714
|
+
\"\"\"Print a greeting with ChatStyle-backed input resolution.\"\"\"
|
|
715
|
+
|
|
716
|
+
values = resolve_command_inputs(
|
|
717
|
+
schema=HELLO_SCHEMA,
|
|
718
|
+
provided={{"name": name}},
|
|
719
|
+
interactive=interactive,
|
|
720
|
+
usage="Usage: {module_name} hello [NAME]",
|
|
721
|
+
)
|
|
722
|
+
render_success(f"Hello, {{values['name']}}!")
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
if __name__ == "__main__":
|
|
726
|
+
main()
|
|
727
|
+
"""
|
|
728
|
+
).strip()
|
|
729
|
+
+ "\n"
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _build_chatarch_test_cli_py(module_name: str) -> str:
|
|
734
|
+
return (
|
|
735
|
+
textwrap.dedent(
|
|
736
|
+
f"""
|
|
737
|
+
from click.testing import CliRunner
|
|
738
|
+
|
|
739
|
+
from {module_name}.cli import main
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def test_hello_command_accepts_explicit_name():
|
|
743
|
+
result = CliRunner().invoke(main, ["hello", "ChatArch"])
|
|
744
|
+
|
|
745
|
+
assert result.exit_code == 0
|
|
746
|
+
assert "Hello, ChatArch!" in result.output
|
|
747
|
+
"""
|
|
748
|
+
).strip()
|
|
749
|
+
+ "\n"
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _build_chatarch_docs_index(package_name: str) -> str:
|
|
754
|
+
return (
|
|
755
|
+
textwrap.dedent(
|
|
756
|
+
f"""
|
|
757
|
+
# {package_name} 文档
|
|
758
|
+
|
|
759
|
+
这里收纳 `{package_name}` 的长期维护文档。
|
|
760
|
+
|
|
761
|
+
## 本地预览
|
|
762
|
+
|
|
763
|
+
```bash
|
|
764
|
+
pip install -e ".[docs]"
|
|
765
|
+
mkdocs serve
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
英文版见:[index.en.md](index.en.md)。
|
|
769
|
+
"""
|
|
770
|
+
).strip()
|
|
771
|
+
+ "\n"
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def _build_chatarch_docs_index_en(package_name: str) -> str:
|
|
776
|
+
return (
|
|
777
|
+
textwrap.dedent(
|
|
778
|
+
f"""
|
|
779
|
+
# {package_name} Docs
|
|
780
|
+
|
|
781
|
+
Long-lived documentation for `{package_name}` lives here.
|
|
782
|
+
|
|
783
|
+
## Local Preview
|
|
784
|
+
|
|
785
|
+
```bash
|
|
786
|
+
pip install -e ".[docs]"
|
|
787
|
+
mkdocs serve
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
Chinese version: [index.md](index.md).
|
|
791
|
+
"""
|
|
792
|
+
).strip()
|
|
793
|
+
+ "\n"
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _build_chatarch_mkdocs_yml(package_name: str) -> str:
|
|
798
|
+
repo_slug = _chatarch_repo_slug(package_name)
|
|
799
|
+
docs_url = _chatarch_docs_url(package_name)
|
|
800
|
+
return (
|
|
801
|
+
textwrap.dedent(
|
|
802
|
+
f"""
|
|
803
|
+
site_name: {package_name} 文档
|
|
804
|
+
site_url: {docs_url}
|
|
805
|
+
repo_url: https://github.com/{repo_slug}
|
|
806
|
+
theme:
|
|
807
|
+
name: material
|
|
808
|
+
language: zh
|
|
809
|
+
nav:
|
|
810
|
+
- 首页: index.md
|
|
811
|
+
- English: index.en.md
|
|
812
|
+
"""
|
|
813
|
+
).strip()
|
|
814
|
+
+ "\n"
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _build_chatarch_agends_md() -> str:
|
|
819
|
+
return (
|
|
820
|
+
textwrap.dedent(
|
|
821
|
+
"""
|
|
822
|
+
# Agent Notes
|
|
823
|
+
|
|
824
|
+
## Development Expectations
|
|
825
|
+
|
|
826
|
+
- Keep changes minimal and reviewable.
|
|
827
|
+
- Prefer doc-first CLI tests.
|
|
828
|
+
- Sync docs and changelog with user-facing behavior.
|
|
829
|
+
- Use interactive prompts only when arguments are missing and recoverable.
|
|
830
|
+
"""
|
|
831
|
+
).strip()
|
|
832
|
+
+ "\n"
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def scaffold_package(
|
|
837
|
+
package_name: str,
|
|
838
|
+
project_dir: Path,
|
|
839
|
+
*,
|
|
840
|
+
initial_version: str = "0.1.0",
|
|
841
|
+
description: str | None = None,
|
|
842
|
+
requires_python: str = ">=3.9",
|
|
843
|
+
license_name: str = "MIT",
|
|
844
|
+
author: str | None = None,
|
|
845
|
+
email: str | None = None,
|
|
846
|
+
template: str = "default",
|
|
847
|
+
include_mkdocs: bool | None = None,
|
|
848
|
+
include_workflows: bool | None = None,
|
|
849
|
+
include_chatenv_provider: bool = False,
|
|
850
|
+
chatenv_provider_name: str | None = None,
|
|
851
|
+
) -> ScaffoldResult:
|
|
852
|
+
package_name = package_name.strip()
|
|
853
|
+
if not package_name:
|
|
854
|
+
raise PyPICommandError("Package name is required.")
|
|
855
|
+
if template == "chatarch" and requires_python == ">=3.9":
|
|
856
|
+
requires_python = ">=3.10"
|
|
857
|
+
if include_mkdocs is None:
|
|
858
|
+
include_mkdocs = template == "chatarch"
|
|
859
|
+
if include_workflows is None:
|
|
860
|
+
include_workflows = template == "chatarch"
|
|
861
|
+
if chatenv_provider_name and not include_chatenv_provider:
|
|
862
|
+
raise PyPICommandError(
|
|
863
|
+
"chatenv_provider_name requires include_chatenv_provider=True."
|
|
864
|
+
)
|
|
865
|
+
if include_chatenv_provider and template != "chatarch":
|
|
866
|
+
raise PyPICommandError(
|
|
867
|
+
"include_chatenv_provider is only supported by the chatarch template."
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
module_name = normalize_module_name(package_name)
|
|
871
|
+
resolved_chatenv_provider_name = (
|
|
872
|
+
normalize_module_name(chatenv_provider_name or module_name)
|
|
873
|
+
if include_chatenv_provider
|
|
874
|
+
else None
|
|
875
|
+
)
|
|
876
|
+
workflow_python_version = _workflow_python_version(requires_python)
|
|
877
|
+
project_dir = Path(project_dir)
|
|
878
|
+
_ensure_empty_or_missing(project_dir)
|
|
879
|
+
project_dir.mkdir(parents=True, exist_ok=True)
|
|
880
|
+
|
|
881
|
+
description = description or f"{package_name} package"
|
|
882
|
+
src_dir = project_dir / "src" / module_name
|
|
883
|
+
tests_dir = project_dir / "tests"
|
|
884
|
+
created_files: list[Path] = []
|
|
885
|
+
|
|
886
|
+
src_dir.mkdir(parents=True, exist_ok=True)
|
|
887
|
+
tests_dir.mkdir(parents=True, exist_ok=True)
|
|
888
|
+
|
|
889
|
+
file_map = {
|
|
890
|
+
project_dir / "pyproject.toml": _build_pyproject_content(
|
|
891
|
+
package_name=package_name,
|
|
892
|
+
module_name=module_name,
|
|
893
|
+
description=description,
|
|
894
|
+
requires_python=requires_python,
|
|
895
|
+
license_name=license_name,
|
|
896
|
+
author=author,
|
|
897
|
+
email=email,
|
|
898
|
+
),
|
|
899
|
+
project_dir / "README.md": textwrap.dedent(f"""
|
|
900
|
+
# {package_name}
|
|
901
|
+
|
|
902
|
+
{description}
|
|
903
|
+
|
|
904
|
+
## Quick Start
|
|
905
|
+
|
|
906
|
+
```bash
|
|
907
|
+
chattool pypi build --project-dir .
|
|
908
|
+
chattool pypi check --project-dir .
|
|
909
|
+
chattool pypi upload --project-dir .
|
|
910
|
+
```
|
|
911
|
+
""").strip()
|
|
912
|
+
+ "\n",
|
|
913
|
+
project_dir / "LICENSE": _license_template_content(license_name, author),
|
|
914
|
+
project_dir / ".gitignore": textwrap.dedent("""
|
|
915
|
+
__pycache__/
|
|
916
|
+
.pytest_cache/
|
|
917
|
+
.venv/
|
|
918
|
+
build/
|
|
919
|
+
dist/
|
|
920
|
+
*.egg-info/
|
|
921
|
+
""").strip()
|
|
922
|
+
+ "\n",
|
|
923
|
+
src_dir / "__init__.py": textwrap.dedent(f'''
|
|
924
|
+
"""{package_name} package."""
|
|
925
|
+
|
|
926
|
+
__all__ = ["__version__"]
|
|
927
|
+
|
|
928
|
+
__version__ = "{initial_version}"
|
|
929
|
+
''').strip()
|
|
930
|
+
+ "\n",
|
|
931
|
+
tests_dir / "conftest.py": textwrap.dedent("""
|
|
932
|
+
from pathlib import Path
|
|
933
|
+
import sys
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
937
|
+
SRC = ROOT / "src"
|
|
938
|
+
if str(SRC) not in sys.path:
|
|
939
|
+
sys.path.insert(0, str(SRC))
|
|
940
|
+
""").strip()
|
|
941
|
+
+ "\n",
|
|
942
|
+
tests_dir / "test_version.py": textwrap.dedent(f"""
|
|
943
|
+
from {module_name} import __version__
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def test_version_present():
|
|
947
|
+
assert __version__ == "{initial_version}"
|
|
948
|
+
""").strip()
|
|
949
|
+
+ "\n",
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
if template == "chatarch":
|
|
953
|
+
(tests_dir / "cli-tests").mkdir(parents=True, exist_ok=True)
|
|
954
|
+
(tests_dir / "mock-cli-tests").mkdir(parents=True, exist_ok=True)
|
|
955
|
+
(tests_dir / "code-tests").mkdir(parents=True, exist_ok=True)
|
|
956
|
+
if include_mkdocs:
|
|
957
|
+
(project_dir / "docs").mkdir(parents=True, exist_ok=True)
|
|
958
|
+
if include_workflows:
|
|
959
|
+
(project_dir / ".github" / "workflows").mkdir(parents=True, exist_ok=True)
|
|
960
|
+
file_map.update(
|
|
961
|
+
{
|
|
962
|
+
project_dir / "pyproject.toml": _build_chatarch_pyproject_content(
|
|
963
|
+
package_name=package_name,
|
|
964
|
+
module_name=module_name,
|
|
965
|
+
description=description,
|
|
966
|
+
requires_python=requires_python,
|
|
967
|
+
license_name=license_name,
|
|
968
|
+
author=author,
|
|
969
|
+
email=email,
|
|
970
|
+
include_mkdocs=include_mkdocs,
|
|
971
|
+
chatenv_provider_name=resolved_chatenv_provider_name,
|
|
972
|
+
),
|
|
973
|
+
project_dir / "README.md": _build_chatarch_readme(
|
|
974
|
+
package_name,
|
|
975
|
+
module_name,
|
|
976
|
+
description,
|
|
977
|
+
include_mkdocs=include_mkdocs,
|
|
978
|
+
include_workflows=include_workflows,
|
|
979
|
+
),
|
|
980
|
+
project_dir / "README.en.md": _build_chatarch_readme_en(
|
|
981
|
+
package_name,
|
|
982
|
+
module_name,
|
|
983
|
+
description,
|
|
984
|
+
include_mkdocs=include_mkdocs,
|
|
985
|
+
include_workflows=include_workflows,
|
|
986
|
+
),
|
|
987
|
+
project_dir / "DEVELOP.md": _build_chatarch_develop_md(),
|
|
988
|
+
project_dir / "CHANGELOG.md": _build_chatarch_changelog(),
|
|
989
|
+
project_dir / "AGENTS.md": _build_chatarch_agends_md(),
|
|
990
|
+
project_dir / "mkdocs.yml": _build_chatarch_mkdocs_yml(
|
|
991
|
+
package_name
|
|
992
|
+
),
|
|
993
|
+
project_dir / "docs" / "index.md": _build_chatarch_docs_index(
|
|
994
|
+
package_name
|
|
995
|
+
),
|
|
996
|
+
project_dir / "docs" / "index.en.md": _build_chatarch_docs_index_en(
|
|
997
|
+
package_name
|
|
998
|
+
),
|
|
999
|
+
tests_dir
|
|
1000
|
+
/ "cli-tests"
|
|
1001
|
+
/ "README.md": "# CLI Tests\n\nReal CLI tests live here.\n",
|
|
1002
|
+
tests_dir
|
|
1003
|
+
/ "mock-cli-tests"
|
|
1004
|
+
/ "README.md": "# Mock CLI Tests\n\nMock/fake CLI tests live here.\n",
|
|
1005
|
+
tests_dir
|
|
1006
|
+
/ "code-tests"
|
|
1007
|
+
/ "README.md": "# Code Tests\n\nNon-CLI code tests live here.\n",
|
|
1008
|
+
src_dir / "cli.py": _build_chatarch_cli_py(module_name),
|
|
1009
|
+
tests_dir / "test_cli.py": _build_chatarch_test_cli_py(module_name),
|
|
1010
|
+
project_dir / ".github" / "workflows" / "ci.yml": textwrap.dedent(
|
|
1011
|
+
"""
|
|
1012
|
+
name: CI
|
|
1013
|
+
|
|
1014
|
+
on:
|
|
1015
|
+
push:
|
|
1016
|
+
branches:
|
|
1017
|
+
- main
|
|
1018
|
+
- master
|
|
1019
|
+
pull_request:
|
|
1020
|
+
|
|
1021
|
+
jobs:
|
|
1022
|
+
test:
|
|
1023
|
+
runs-on: ubuntu-latest
|
|
1024
|
+
steps:
|
|
1025
|
+
- uses: actions/checkout@v4
|
|
1026
|
+
- name: Configure Git Credentials
|
|
1027
|
+
run: |
|
|
1028
|
+
git config user.name github-actions[bot]
|
|
1029
|
+
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
|
1030
|
+
- uses: actions/setup-python@v5
|
|
1031
|
+
with:
|
|
1032
|
+
python-version: "{workflow_python_version}"
|
|
1033
|
+
- run: python -m pip install --upgrade pip
|
|
1034
|
+
- run: python -m pip install -e ".[dev,docs]"
|
|
1035
|
+
- run: python -m pytest -q
|
|
1036
|
+
- run: python -m build
|
|
1037
|
+
- run: mkdocs build --strict
|
|
1038
|
+
"""
|
|
1039
|
+
)
|
|
1040
|
+
.replace("{workflow_python_version}", workflow_python_version)
|
|
1041
|
+
.strip()
|
|
1042
|
+
+ "\n",
|
|
1043
|
+
project_dir / ".github" / "workflows" / "publish.yml": textwrap.dedent(
|
|
1044
|
+
"""
|
|
1045
|
+
name: Publish Package
|
|
1046
|
+
|
|
1047
|
+
on:
|
|
1048
|
+
push:
|
|
1049
|
+
tags:
|
|
1050
|
+
- "v*"
|
|
1051
|
+
workflow_dispatch:
|
|
1052
|
+
|
|
1053
|
+
permissions:
|
|
1054
|
+
contents: write
|
|
1055
|
+
id-token: write
|
|
1056
|
+
|
|
1057
|
+
jobs:
|
|
1058
|
+
publish:
|
|
1059
|
+
runs-on: ubuntu-latest
|
|
1060
|
+
environment: pypi
|
|
1061
|
+
steps:
|
|
1062
|
+
- uses: actions/checkout@v4
|
|
1063
|
+
with:
|
|
1064
|
+
fetch-depth: 0
|
|
1065
|
+
- uses: actions/setup-python@v5
|
|
1066
|
+
with:
|
|
1067
|
+
python-version: "{workflow_python_version}"
|
|
1068
|
+
- name: Resolve package version
|
|
1069
|
+
id: meta
|
|
1070
|
+
run: |
|
|
1071
|
+
python - <<'PY'
|
|
1072
|
+
import ast
|
|
1073
|
+
import os
|
|
1074
|
+
from pathlib import Path
|
|
1075
|
+
|
|
1076
|
+
module = ast.parse(Path("src/{module_name}/__init__.py").read_text(encoding="utf-8"))
|
|
1077
|
+
for stmt in module.body:
|
|
1078
|
+
if not isinstance(stmt, ast.Assign):
|
|
1079
|
+
continue
|
|
1080
|
+
if any(isinstance(target, ast.Name) and target.id == "__version__" for target in stmt.targets):
|
|
1081
|
+
version = ast.literal_eval(stmt.value)
|
|
1082
|
+
break
|
|
1083
|
+
else:
|
|
1084
|
+
raise SystemExit("__version__ not found in src/{module_name}/__init__.py")
|
|
1085
|
+
|
|
1086
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
|
1087
|
+
print(f"version={version}", file=output)
|
|
1088
|
+
print(f"tag=v{version}", file=output)
|
|
1089
|
+
PY
|
|
1090
|
+
- name: Check tag matches package version
|
|
1091
|
+
if: github.event_name == 'push'
|
|
1092
|
+
env:
|
|
1093
|
+
RELEASE_TAG: ${{ steps.meta.outputs.tag }}
|
|
1094
|
+
run: |
|
|
1095
|
+
if [ "${GITHUB_REF_NAME}" != "${RELEASE_TAG}" ]; then
|
|
1096
|
+
echo "Tag ${GITHUB_REF_NAME} does not match package version ${RELEASE_TAG}."
|
|
1097
|
+
exit 1
|
|
1098
|
+
fi
|
|
1099
|
+
- name: Check PyPI version
|
|
1100
|
+
id: pypi
|
|
1101
|
+
env:
|
|
1102
|
+
PACKAGE_NAME: "{package_name}"
|
|
1103
|
+
PACKAGE_VERSION: ${{ steps.meta.outputs.version }}
|
|
1104
|
+
run: |
|
|
1105
|
+
python - <<'PY'
|
|
1106
|
+
import os
|
|
1107
|
+
import urllib.error
|
|
1108
|
+
import urllib.parse
|
|
1109
|
+
import urllib.request
|
|
1110
|
+
|
|
1111
|
+
package = os.environ["PACKAGE_NAME"]
|
|
1112
|
+
version = os.environ["PACKAGE_VERSION"]
|
|
1113
|
+
url = f"https://pypi.org/pypi/{urllib.parse.quote(package)}/{urllib.parse.quote(version)}/json"
|
|
1114
|
+
exists = "false"
|
|
1115
|
+
try:
|
|
1116
|
+
urllib.request.urlopen(url, timeout=10)
|
|
1117
|
+
except urllib.error.HTTPError as exc:
|
|
1118
|
+
if exc.code != 404:
|
|
1119
|
+
raise
|
|
1120
|
+
else:
|
|
1121
|
+
exists = "true"
|
|
1122
|
+
|
|
1123
|
+
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
|
1124
|
+
print(f"exists={exists}", file=output)
|
|
1125
|
+
PY
|
|
1126
|
+
- name: Stop when version is already on PyPI
|
|
1127
|
+
if: steps.pypi.outputs.exists == 'true'
|
|
1128
|
+
run: echo "{package_name} ${{ steps.meta.outputs.version }} is already on PyPI; skipping publish."
|
|
1129
|
+
- name: Build distribution
|
|
1130
|
+
if: steps.pypi.outputs.exists == 'false'
|
|
1131
|
+
run: |
|
|
1132
|
+
python -m pip install --upgrade pip build twine
|
|
1133
|
+
python -m build
|
|
1134
|
+
python -m twine check dist/*
|
|
1135
|
+
- name: Publish to PyPI
|
|
1136
|
+
if: steps.pypi.outputs.exists == 'false'
|
|
1137
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
1138
|
+
"""
|
|
1139
|
+
)
|
|
1140
|
+
.replace("{workflow_python_version}", workflow_python_version)
|
|
1141
|
+
.replace("{package_name}", package_name)
|
|
1142
|
+
.replace("{module_name}", module_name)
|
|
1143
|
+
.strip()
|
|
1144
|
+
+ "\n",
|
|
1145
|
+
project_dir / ".github" / "workflows" / "deploy.yaml": textwrap.dedent(
|
|
1146
|
+
"""
|
|
1147
|
+
name: Deploy Docs
|
|
1148
|
+
|
|
1149
|
+
on:
|
|
1150
|
+
push:
|
|
1151
|
+
branches:
|
|
1152
|
+
- main
|
|
1153
|
+
- master
|
|
1154
|
+
|
|
1155
|
+
permissions:
|
|
1156
|
+
contents: write
|
|
1157
|
+
|
|
1158
|
+
jobs:
|
|
1159
|
+
deploy:
|
|
1160
|
+
runs-on: ubuntu-latest
|
|
1161
|
+
steps:
|
|
1162
|
+
- uses: actions/checkout@v4
|
|
1163
|
+
- uses: actions/setup-python@v5
|
|
1164
|
+
with:
|
|
1165
|
+
python-version: "{workflow_python_version}"
|
|
1166
|
+
- run: python -m pip install --upgrade pip
|
|
1167
|
+
- run: python -m pip install -e ".[docs]"
|
|
1168
|
+
- run: mkdocs gh-deploy --force
|
|
1169
|
+
"""
|
|
1170
|
+
)
|
|
1171
|
+
.replace("{workflow_python_version}", workflow_python_version)
|
|
1172
|
+
.strip()
|
|
1173
|
+
+ "\n",
|
|
1174
|
+
project_dir / ".github" / "workflows" / "preview.yaml": textwrap.dedent(
|
|
1175
|
+
"""
|
|
1176
|
+
name: Preview Docs
|
|
1177
|
+
|
|
1178
|
+
on:
|
|
1179
|
+
pull_request:
|
|
1180
|
+
branches:
|
|
1181
|
+
- main
|
|
1182
|
+
- master
|
|
1183
|
+
|
|
1184
|
+
permissions:
|
|
1185
|
+
contents: write
|
|
1186
|
+
pull-requests: write
|
|
1187
|
+
|
|
1188
|
+
jobs:
|
|
1189
|
+
deploy:
|
|
1190
|
+
runs-on: ubuntu-latest
|
|
1191
|
+
if: ${{ !github.event.pull_request.head.repo.fork }}
|
|
1192
|
+
steps:
|
|
1193
|
+
- uses: actions/checkout@v4
|
|
1194
|
+
- name: Configure Git Credentials
|
|
1195
|
+
run: |
|
|
1196
|
+
git config user.name github-actions[bot]
|
|
1197
|
+
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
|
1198
|
+
- uses: actions/setup-python@v5
|
|
1199
|
+
with:
|
|
1200
|
+
python-version: "{workflow_python_version}"
|
|
1201
|
+
- run: python -m pip install --upgrade pip
|
|
1202
|
+
- run: python -m pip install -e ".[docs]"
|
|
1203
|
+
- run: |
|
|
1204
|
+
git fetch origin
|
|
1205
|
+
mike deploy dev -p --allow-empty
|
|
1206
|
+
owner="${GITHUB_REPOSITORY_OWNER}"
|
|
1207
|
+
repo="${GITHUB_REPOSITORY#*/}"
|
|
1208
|
+
preview_url="https://${owner}.github.io/${repo}/dev/"
|
|
1209
|
+
echo "Preview URL: ${preview_url}" >> "$GITHUB_STEP_SUMMARY"
|
|
1210
|
+
|
|
1211
|
+
- name: Comment PR with Preview Link
|
|
1212
|
+
uses: actions/github-script@v6
|
|
1213
|
+
with:
|
|
1214
|
+
script: |
|
|
1215
|
+
const { payload, repo } = context;
|
|
1216
|
+
const previewLink = `https://${repo.owner}.github.io/${repo.repo}/dev/`;
|
|
1217
|
+
const comments = await github.rest.issues.listComments({
|
|
1218
|
+
owner: repo.owner,
|
|
1219
|
+
repo: repo.repo,
|
|
1220
|
+
issue_number: payload.number,
|
|
1221
|
+
});
|
|
1222
|
+
const existingComment = comments.data.find(comment => comment.body.includes(previewLink));
|
|
1223
|
+
if (!existingComment) {
|
|
1224
|
+
await github.rest.issues.createComment({
|
|
1225
|
+
owner: repo.owner,
|
|
1226
|
+
repo: repo.repo,
|
|
1227
|
+
issue_number: payload.number,
|
|
1228
|
+
body: `Preview available at: ${previewLink}`,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
"""
|
|
1232
|
+
)
|
|
1233
|
+
.replace("{workflow_python_version}", workflow_python_version)
|
|
1234
|
+
.strip()
|
|
1235
|
+
+ "\n",
|
|
1236
|
+
}
|
|
1237
|
+
)
|
|
1238
|
+
if resolved_chatenv_provider_name:
|
|
1239
|
+
file_map[src_dir / "config.py"] = _build_chatarch_chatenv_config_py(
|
|
1240
|
+
package_name=package_name,
|
|
1241
|
+
module_name=module_name,
|
|
1242
|
+
provider_name=resolved_chatenv_provider_name,
|
|
1243
|
+
)
|
|
1244
|
+
if not include_mkdocs:
|
|
1245
|
+
for optional_path in (
|
|
1246
|
+
project_dir / "mkdocs.yml",
|
|
1247
|
+
project_dir / "docs" / "index.md",
|
|
1248
|
+
project_dir / "docs" / "index.en.md",
|
|
1249
|
+
project_dir / ".github" / "workflows" / "deploy.yaml",
|
|
1250
|
+
project_dir / ".github" / "workflows" / "preview.yaml",
|
|
1251
|
+
):
|
|
1252
|
+
file_map.pop(optional_path, None)
|
|
1253
|
+
ci_path = project_dir / ".github" / "workflows" / "ci.yml"
|
|
1254
|
+
if ci_path in file_map:
|
|
1255
|
+
file_map[ci_path] = file_map[ci_path].replace(
|
|
1256
|
+
'python -m pip install -e ".[dev,docs]"',
|
|
1257
|
+
'python -m pip install -e ".[dev]"',
|
|
1258
|
+
).replace("\n - run: mkdocs build --strict", "")
|
|
1259
|
+
if not include_workflows:
|
|
1260
|
+
for optional_path in list(file_map):
|
|
1261
|
+
if ".github" in optional_path.parts:
|
|
1262
|
+
file_map.pop(optional_path, None)
|
|
1263
|
+
|
|
1264
|
+
for path, content in file_map.items():
|
|
1265
|
+
path.write_text(content, encoding="utf-8")
|
|
1266
|
+
created_files.append(path)
|
|
1267
|
+
|
|
1268
|
+
return ScaffoldResult(
|
|
1269
|
+
project_dir=project_dir,
|
|
1270
|
+
package_name=package_name,
|
|
1271
|
+
module_name=module_name,
|
|
1272
|
+
created_files=sorted(created_files),
|
|
1273
|
+
)
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def _load_pyproject(project_dir: Path) -> dict:
|
|
1277
|
+
pyproject_path = project_dir / "pyproject.toml"
|
|
1278
|
+
if not pyproject_path.exists():
|
|
1279
|
+
raise PyPICommandError(f"pyproject.toml not found under {project_dir}")
|
|
1280
|
+
try:
|
|
1281
|
+
return tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
|
1282
|
+
except Exception as exc: # pragma: no cover
|
|
1283
|
+
raise PyPICommandError(f"Failed to parse {pyproject_path}: {exc}") from exc
|
|
1284
|
+
|
|
1285
|
+
|
|
1286
|
+
def _extract_license_text(license_value) -> str | None:
|
|
1287
|
+
if isinstance(license_value, str):
|
|
1288
|
+
return license_value
|
|
1289
|
+
if isinstance(license_value, dict):
|
|
1290
|
+
if license_value.get("text"):
|
|
1291
|
+
return str(license_value["text"])
|
|
1292
|
+
if license_value.get("file"):
|
|
1293
|
+
return f"file:{license_value['file']}"
|
|
1294
|
+
return None
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def _extract_readme_path(readme_value) -> str | None:
|
|
1298
|
+
if isinstance(readme_value, str):
|
|
1299
|
+
return readme_value
|
|
1300
|
+
if isinstance(readme_value, dict) and readme_value.get("file"):
|
|
1301
|
+
return str(readme_value["file"])
|
|
1302
|
+
return None
|
|
1303
|
+
|
|
1304
|
+
|
|
1305
|
+
def _resolve_dynamic_version_source(
|
|
1306
|
+
pyproject: dict, dynamic_fields: list[str]
|
|
1307
|
+
) -> str | None:
|
|
1308
|
+
if "version" not in dynamic_fields:
|
|
1309
|
+
return None
|
|
1310
|
+
tool_data = pyproject.get("tool", {})
|
|
1311
|
+
setuptools_data = (
|
|
1312
|
+
tool_data.get("setuptools", {}) if isinstance(tool_data, dict) else {}
|
|
1313
|
+
)
|
|
1314
|
+
dynamic_data = (
|
|
1315
|
+
setuptools_data.get("dynamic", {}) if isinstance(setuptools_data, dict) else {}
|
|
1316
|
+
)
|
|
1317
|
+
version_data = (
|
|
1318
|
+
dynamic_data.get("version") if isinstance(dynamic_data, dict) else None
|
|
1319
|
+
)
|
|
1320
|
+
if isinstance(version_data, dict):
|
|
1321
|
+
if version_data.get("attr"):
|
|
1322
|
+
return f"dynamic via attr={version_data['attr']}"
|
|
1323
|
+
if version_data.get("file"):
|
|
1324
|
+
return f"dynamic via file={version_data['file']}"
|
|
1325
|
+
return "dynamic"
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def _load_attr_version(project_dir: Path, attr_path: str) -> str | None:
|
|
1329
|
+
module_path, _, attribute = attr_path.rpartition(".")
|
|
1330
|
+
if not module_path or not attribute:
|
|
1331
|
+
return None
|
|
1332
|
+
relative_parts = module_path.split(".")
|
|
1333
|
+
candidate_files = []
|
|
1334
|
+
for base_dir in (project_dir / "src", project_dir):
|
|
1335
|
+
candidate_files.append(base_dir.joinpath(*relative_parts, "__init__.py"))
|
|
1336
|
+
candidate_files.append(base_dir.joinpath(*relative_parts).with_suffix(".py"))
|
|
1337
|
+
|
|
1338
|
+
for candidate in candidate_files:
|
|
1339
|
+
if not candidate.exists():
|
|
1340
|
+
continue
|
|
1341
|
+
try:
|
|
1342
|
+
spec = importlib.util.spec_from_file_location(
|
|
1343
|
+
f"_chattool_pypi_dynamic_{candidate.stem}_{abs(hash(candidate))}",
|
|
1344
|
+
candidate,
|
|
1345
|
+
)
|
|
1346
|
+
if spec is None or spec.loader is None:
|
|
1347
|
+
continue
|
|
1348
|
+
module = importlib.util.module_from_spec(spec)
|
|
1349
|
+
spec.loader.exec_module(module)
|
|
1350
|
+
value = getattr(module, attribute, None)
|
|
1351
|
+
except Exception: # pragma: no cover
|
|
1352
|
+
continue
|
|
1353
|
+
if value is not None:
|
|
1354
|
+
return str(value)
|
|
1355
|
+
return None
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _load_file_version(project_dir: Path, relative_path: str) -> str | None:
|
|
1359
|
+
target = project_dir / relative_path
|
|
1360
|
+
if not target.exists():
|
|
1361
|
+
return None
|
|
1362
|
+
content = target.read_text(encoding="utf-8").strip()
|
|
1363
|
+
return content or None
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def _resolve_dynamic_version_value(
|
|
1367
|
+
project_dir: Path, pyproject: dict, dynamic_fields: list[str]
|
|
1368
|
+
) -> str | None:
|
|
1369
|
+
if "version" not in dynamic_fields:
|
|
1370
|
+
return None
|
|
1371
|
+
tool_data = pyproject.get("tool", {})
|
|
1372
|
+
setuptools_data = (
|
|
1373
|
+
tool_data.get("setuptools", {}) if isinstance(tool_data, dict) else {}
|
|
1374
|
+
)
|
|
1375
|
+
dynamic_data = (
|
|
1376
|
+
setuptools_data.get("dynamic", {}) if isinstance(setuptools_data, dict) else {}
|
|
1377
|
+
)
|
|
1378
|
+
version_data = (
|
|
1379
|
+
dynamic_data.get("version") if isinstance(dynamic_data, dict) else None
|
|
1380
|
+
)
|
|
1381
|
+
if isinstance(version_data, dict):
|
|
1382
|
+
attr_path = version_data.get("attr")
|
|
1383
|
+
if isinstance(attr_path, str):
|
|
1384
|
+
return _load_attr_version(project_dir, attr_path)
|
|
1385
|
+
file_path = version_data.get("file")
|
|
1386
|
+
if isinstance(file_path, str):
|
|
1387
|
+
return _load_file_version(project_dir, file_path)
|
|
1388
|
+
return None
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
def read_project_metadata(project_dir: Path) -> ProjectMetadata:
|
|
1392
|
+
pyproject = _load_pyproject(project_dir)
|
|
1393
|
+
project_data = pyproject.get("project")
|
|
1394
|
+
if not isinstance(project_data, dict):
|
|
1395
|
+
raise PyPICommandError("Missing [project] table in pyproject.toml")
|
|
1396
|
+
|
|
1397
|
+
dynamic_fields = [
|
|
1398
|
+
field for field in project_data.get("dynamic", []) if isinstance(field, str)
|
|
1399
|
+
]
|
|
1400
|
+
version = project_data.get("version")
|
|
1401
|
+
version_source = None
|
|
1402
|
+
if not version:
|
|
1403
|
+
version_source = _resolve_dynamic_version_source(pyproject, dynamic_fields)
|
|
1404
|
+
version = _resolve_dynamic_version_value(project_dir, pyproject, dynamic_fields)
|
|
1405
|
+
|
|
1406
|
+
return ProjectMetadata(
|
|
1407
|
+
name=project_data.get("name"),
|
|
1408
|
+
version=version if isinstance(version, str) else None,
|
|
1409
|
+
version_source=version_source,
|
|
1410
|
+
readme=_extract_readme_path(project_data.get("readme")),
|
|
1411
|
+
requires_python=project_data.get("requires-python"),
|
|
1412
|
+
license_text=_extract_license_text(project_data.get("license")),
|
|
1413
|
+
dynamic_fields=dynamic_fields,
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _module_available(name: str) -> bool:
|
|
1418
|
+
return importlib.util.find_spec(name) is not None
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _find_license_file(project_dir: Path) -> Path | None:
|
|
1422
|
+
for candidate in ("LICENSE", "LICENSE.txt", "LICENSE.md"):
|
|
1423
|
+
path = project_dir / candidate
|
|
1424
|
+
if path.exists():
|
|
1425
|
+
return path
|
|
1426
|
+
return None
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def collect_doctor_checks(
|
|
1430
|
+
project_dir: Path, dist_dir: Path | None = None
|
|
1431
|
+
) -> list[DoctorCheck]:
|
|
1432
|
+
project_dir = Path(project_dir)
|
|
1433
|
+
dist_dir = resolve_dist_dir(project_dir, dist_dir)
|
|
1434
|
+
pyproject_path = project_dir / "pyproject.toml"
|
|
1435
|
+
|
|
1436
|
+
checks: list[DoctorCheck] = []
|
|
1437
|
+
if not pyproject_path.exists():
|
|
1438
|
+
return [
|
|
1439
|
+
DoctorCheck(
|
|
1440
|
+
label="pyproject.toml",
|
|
1441
|
+
status="fail",
|
|
1442
|
+
detail=f"missing: {pyproject_path}",
|
|
1443
|
+
hint="Create pyproject.toml before using chattool pypi.",
|
|
1444
|
+
)
|
|
1445
|
+
]
|
|
1446
|
+
|
|
1447
|
+
checks.append(DoctorCheck("pyproject.toml", "ok", f"found: {pyproject_path.name}"))
|
|
1448
|
+
|
|
1449
|
+
try:
|
|
1450
|
+
metadata = read_project_metadata(project_dir)
|
|
1451
|
+
except PyPICommandError as exc:
|
|
1452
|
+
checks.append(DoctorCheck("project metadata", "fail", str(exc)))
|
|
1453
|
+
return checks
|
|
1454
|
+
|
|
1455
|
+
checks.append(
|
|
1456
|
+
DoctorCheck(
|
|
1457
|
+
"project.name",
|
|
1458
|
+
"ok" if metadata.name else "fail",
|
|
1459
|
+
metadata.name or "missing [project].name",
|
|
1460
|
+
)
|
|
1461
|
+
)
|
|
1462
|
+
if metadata.version:
|
|
1463
|
+
version_detail = metadata.version
|
|
1464
|
+
if metadata.version_source:
|
|
1465
|
+
version_detail = f"{metadata.version} ({metadata.version_source})"
|
|
1466
|
+
status = "ok"
|
|
1467
|
+
elif metadata.version_source:
|
|
1468
|
+
version_detail = metadata.version_source
|
|
1469
|
+
status = "ok"
|
|
1470
|
+
else:
|
|
1471
|
+
version_detail = "missing version or dynamic version configuration"
|
|
1472
|
+
status = "fail"
|
|
1473
|
+
checks.append(DoctorCheck("project.version", status, version_detail))
|
|
1474
|
+
checks.append(
|
|
1475
|
+
DoctorCheck(
|
|
1476
|
+
"project.readme",
|
|
1477
|
+
"ok" if metadata.readme else "fail",
|
|
1478
|
+
metadata.readme or "missing [project].readme",
|
|
1479
|
+
)
|
|
1480
|
+
)
|
|
1481
|
+
checks.append(
|
|
1482
|
+
DoctorCheck(
|
|
1483
|
+
"project.requires-python",
|
|
1484
|
+
"ok" if metadata.requires_python else "fail",
|
|
1485
|
+
metadata.requires_python or "missing [project].requires-python",
|
|
1486
|
+
)
|
|
1487
|
+
)
|
|
1488
|
+
checks.append(
|
|
1489
|
+
DoctorCheck(
|
|
1490
|
+
"project.license",
|
|
1491
|
+
"ok" if metadata.license_text else "fail",
|
|
1492
|
+
metadata.license_text or "missing [project].license",
|
|
1493
|
+
)
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
if metadata.readme:
|
|
1497
|
+
readme_path = project_dir / metadata.readme
|
|
1498
|
+
checks.append(
|
|
1499
|
+
DoctorCheck(
|
|
1500
|
+
"README file",
|
|
1501
|
+
"ok" if readme_path.exists() else "fail",
|
|
1502
|
+
str(readme_path.relative_to(project_dir))
|
|
1503
|
+
if readme_path.exists()
|
|
1504
|
+
else f"missing: {metadata.readme}",
|
|
1505
|
+
)
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
license_path = _find_license_file(project_dir)
|
|
1509
|
+
build_available = _module_available("build")
|
|
1510
|
+
twine_available = _module_available("twine")
|
|
1511
|
+
|
|
1512
|
+
checks.append(
|
|
1513
|
+
DoctorCheck(
|
|
1514
|
+
"LICENSE file",
|
|
1515
|
+
"ok" if license_path else "fail",
|
|
1516
|
+
license_path.name
|
|
1517
|
+
if license_path
|
|
1518
|
+
else "missing LICENSE / LICENSE.txt / LICENSE.md",
|
|
1519
|
+
)
|
|
1520
|
+
)
|
|
1521
|
+
checks.append(
|
|
1522
|
+
DoctorCheck(
|
|
1523
|
+
"build module",
|
|
1524
|
+
"ok" if build_available else "fail",
|
|
1525
|
+
"installed" if build_available else "python -m build unavailable",
|
|
1526
|
+
hint='Install with `pip install build` or `pip install "chattool[pypi]"`.',
|
|
1527
|
+
)
|
|
1528
|
+
)
|
|
1529
|
+
checks.append(
|
|
1530
|
+
DoctorCheck(
|
|
1531
|
+
"twine module",
|
|
1532
|
+
"ok" if twine_available else "fail",
|
|
1533
|
+
"installed" if twine_available else "python -m twine unavailable",
|
|
1534
|
+
hint='Install with `pip install twine` or `pip install "chattool[pypi]"`.',
|
|
1535
|
+
)
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
existing_artifacts = find_distributions(dist_dir)
|
|
1539
|
+
if existing_artifacts:
|
|
1540
|
+
checks.append(
|
|
1541
|
+
DoctorCheck(
|
|
1542
|
+
"dist artifacts",
|
|
1543
|
+
"warn",
|
|
1544
|
+
f"{len(existing_artifacts)} existing file(s) under {dist_dir}",
|
|
1545
|
+
hint="Use `chattool pypi build --clean` to replace old build artifacts.",
|
|
1546
|
+
)
|
|
1547
|
+
)
|
|
1548
|
+
else:
|
|
1549
|
+
checks.append(
|
|
1550
|
+
DoctorCheck(
|
|
1551
|
+
"dist artifacts", "ok", f"no existing artifacts under {dist_dir}"
|
|
1552
|
+
)
|
|
1553
|
+
)
|
|
1554
|
+
return checks
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
def doctor_has_failures(checks: list[DoctorCheck]) -> bool:
|
|
1558
|
+
return any(check.status == "fail" for check in checks)
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def find_distributions(dist_dir: Path) -> list[Path]:
|
|
1562
|
+
dist_dir = Path(dist_dir)
|
|
1563
|
+
if not dist_dir.exists():
|
|
1564
|
+
return []
|
|
1565
|
+
found: list[Path] = []
|
|
1566
|
+
for pattern in ("*.whl", "*.tar.gz", "*.zip"):
|
|
1567
|
+
found.extend(dist_dir.glob(pattern))
|
|
1568
|
+
return sorted(set(path.resolve() for path in found))
|
|
1569
|
+
|
|
1570
|
+
|
|
1571
|
+
def _repository_json_base(repository: str, repository_url: str | None = None) -> str:
|
|
1572
|
+
if repository_url:
|
|
1573
|
+
parsed = urllib_parse.urlparse(repository_url)
|
|
1574
|
+
host = parsed.netloc.lower()
|
|
1575
|
+
if host == "upload.pypi.org":
|
|
1576
|
+
return "https://pypi.org"
|
|
1577
|
+
if host == "test.pypi.org":
|
|
1578
|
+
return "https://test.pypi.org"
|
|
1579
|
+
return f"{parsed.scheme}://{parsed.netloc}"
|
|
1580
|
+
if repository == "pypi":
|
|
1581
|
+
return "https://pypi.org"
|
|
1582
|
+
return "https://test.pypi.org"
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def _fetch_repository_json(url: str, timeout: float = 5.0) -> tuple[int, dict | None]:
|
|
1586
|
+
request = urllib_request.Request(
|
|
1587
|
+
url,
|
|
1588
|
+
headers={"Accept": "application/json"},
|
|
1589
|
+
)
|
|
1590
|
+
try:
|
|
1591
|
+
with urllib_request.urlopen(request, timeout=timeout) as response:
|
|
1592
|
+
payload = response.read().decode("utf-8")
|
|
1593
|
+
return response.status, json.loads(payload)
|
|
1594
|
+
except urllib_error.HTTPError as exc:
|
|
1595
|
+
if exc.code == 404:
|
|
1596
|
+
return 404, None
|
|
1597
|
+
detail = exc.read().decode("utf-8", errors="replace").strip()
|
|
1598
|
+
raise PyPICommandError(
|
|
1599
|
+
f"Repository query failed for {url}: HTTP {exc.code} {detail or exc.reason}"
|
|
1600
|
+
) from exc
|
|
1601
|
+
except urllib_error.URLError as exc:
|
|
1602
|
+
raise PyPICommandError(
|
|
1603
|
+
f"Repository query failed for {url}: {exc.reason}"
|
|
1604
|
+
) from exc
|
|
1605
|
+
except TimeoutError as exc:
|
|
1606
|
+
raise PyPICommandError(f"Repository query failed for {url}: timeout") from exc
|
|
1607
|
+
except json.JSONDecodeError as exc:
|
|
1608
|
+
raise PyPICommandError(
|
|
1609
|
+
f"Repository query returned invalid JSON for {url}: {exc}"
|
|
1610
|
+
) from exc
|
|
1611
|
+
|
|
1612
|
+
|
|
1613
|
+
def check_repository_conflicts(
|
|
1614
|
+
package_name: str,
|
|
1615
|
+
*,
|
|
1616
|
+
repository: str = "pypi",
|
|
1617
|
+
repository_url: str | None = None,
|
|
1618
|
+
timeout: float = 5.0,
|
|
1619
|
+
fetcher=_fetch_repository_json,
|
|
1620
|
+
) -> list[RepositoryCheck]:
|
|
1621
|
+
package_name = package_name.strip()
|
|
1622
|
+
if not package_name:
|
|
1623
|
+
raise PyPICommandError(
|
|
1624
|
+
"Package name is required for repository conflict checks."
|
|
1625
|
+
)
|
|
1626
|
+
|
|
1627
|
+
base_url = _repository_json_base(repository, repository_url)
|
|
1628
|
+
package_url = f"{base_url}/pypi/{urllib_parse.quote(package_name)}/json"
|
|
1629
|
+
package_status, payload = fetcher(package_url, timeout=timeout)
|
|
1630
|
+
target_label = repository_url or repository
|
|
1631
|
+
|
|
1632
|
+
checks: list[RepositoryCheck] = []
|
|
1633
|
+
if package_status == 404:
|
|
1634
|
+
return [
|
|
1635
|
+
RepositoryCheck(
|
|
1636
|
+
label="package name",
|
|
1637
|
+
status="ok",
|
|
1638
|
+
detail=f"{package_name} is available on {target_label}",
|
|
1639
|
+
hint="Exact project-name check. This does not use PyPI search results.",
|
|
1640
|
+
),
|
|
1641
|
+
RepositoryCheck(
|
|
1642
|
+
label="result",
|
|
1643
|
+
status="ok",
|
|
1644
|
+
detail=f"name is available on {target_label}",
|
|
1645
|
+
hint="Use this as a first-pass name check before publishing.",
|
|
1646
|
+
),
|
|
1647
|
+
]
|
|
1648
|
+
else:
|
|
1649
|
+
checks.append(
|
|
1650
|
+
RepositoryCheck(
|
|
1651
|
+
label="package name",
|
|
1652
|
+
status="fail",
|
|
1653
|
+
detail=f"{package_name} already exists on {target_label}",
|
|
1654
|
+
hint="Choose another package name for a new package. Only keep this name if you own the existing project.",
|
|
1655
|
+
)
|
|
1656
|
+
)
|
|
1657
|
+
checks.append(
|
|
1658
|
+
RepositoryCheck(
|
|
1659
|
+
label="result",
|
|
1660
|
+
status="fail",
|
|
1661
|
+
detail=f"blocked for a new package: {package_name} already exists on {target_label}",
|
|
1662
|
+
hint="Choose another package name unless you own the existing project.",
|
|
1663
|
+
)
|
|
1664
|
+
)
|
|
1665
|
+
checks.extend(_extract_project_snippets(payload))
|
|
1666
|
+
return checks
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def _clean_dist_dir(dist_dir: Path) -> None:
|
|
1670
|
+
if not dist_dir.exists():
|
|
1671
|
+
return
|
|
1672
|
+
for path in dist_dir.iterdir():
|
|
1673
|
+
if path.is_file() or path.is_symlink():
|
|
1674
|
+
path.unlink()
|
|
1675
|
+
|
|
1676
|
+
|
|
1677
|
+
def run_command(
|
|
1678
|
+
args: list[str], cwd: Path, env: dict[str, str] | None = None
|
|
1679
|
+
) -> CommandResult:
|
|
1680
|
+
process = subprocess.run(
|
|
1681
|
+
args,
|
|
1682
|
+
cwd=str(cwd),
|
|
1683
|
+
env=env,
|
|
1684
|
+
capture_output=True,
|
|
1685
|
+
text=True,
|
|
1686
|
+
check=False,
|
|
1687
|
+
)
|
|
1688
|
+
return CommandResult(
|
|
1689
|
+
args=list(args),
|
|
1690
|
+
returncode=process.returncode,
|
|
1691
|
+
stdout=process.stdout,
|
|
1692
|
+
stderr=process.stderr,
|
|
1693
|
+
)
|
|
1694
|
+
|
|
1695
|
+
|
|
1696
|
+
def _ensure_success(result: CommandResult, action: str) -> CommandResult:
|
|
1697
|
+
if result.returncode == 0:
|
|
1698
|
+
return result
|
|
1699
|
+
detail = result.stderr.strip() or result.stdout.strip() or "no output"
|
|
1700
|
+
raise PyPICommandError(f"{action} failed: {detail}")
|
|
1701
|
+
|
|
1702
|
+
|
|
1703
|
+
def build_package(
|
|
1704
|
+
project_dir: Path,
|
|
1705
|
+
dist_dir: Path | None = None,
|
|
1706
|
+
*,
|
|
1707
|
+
clean: bool = True,
|
|
1708
|
+
sdist: bool = False,
|
|
1709
|
+
wheel: bool = False,
|
|
1710
|
+
runner=run_command,
|
|
1711
|
+
) -> tuple[CommandResult, list[Path]]:
|
|
1712
|
+
project_dir = Path(project_dir)
|
|
1713
|
+
dist_dir = resolve_dist_dir(project_dir, dist_dir)
|
|
1714
|
+
if not (project_dir / "pyproject.toml").exists():
|
|
1715
|
+
raise PyPICommandError(f"pyproject.toml not found under {project_dir}")
|
|
1716
|
+
|
|
1717
|
+
if clean:
|
|
1718
|
+
_clean_dist_dir(dist_dir)
|
|
1719
|
+
dist_dir.mkdir(parents=True, exist_ok=True)
|
|
1720
|
+
|
|
1721
|
+
args = [sys.executable, "-m", "build", "--outdir", str(dist_dir)]
|
|
1722
|
+
if sdist and not wheel:
|
|
1723
|
+
args.append("--sdist")
|
|
1724
|
+
elif wheel and not sdist:
|
|
1725
|
+
args.append("--wheel")
|
|
1726
|
+
|
|
1727
|
+
result = _ensure_success(runner(args, project_dir), "Build")
|
|
1728
|
+
files = find_distributions(dist_dir)
|
|
1729
|
+
if not files:
|
|
1730
|
+
raise PyPICommandError(
|
|
1731
|
+
f"Build completed but no distributions were found under {dist_dir}"
|
|
1732
|
+
)
|
|
1733
|
+
return result, files
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def check_distributions(
|
|
1737
|
+
project_dir: Path,
|
|
1738
|
+
dist_dir: Path | None = None,
|
|
1739
|
+
*,
|
|
1740
|
+
strict: bool = False,
|
|
1741
|
+
runner=run_command,
|
|
1742
|
+
) -> tuple[CommandResult, list[Path]]:
|
|
1743
|
+
project_dir = Path(project_dir)
|
|
1744
|
+
dist_dir = resolve_dist_dir(project_dir, dist_dir)
|
|
1745
|
+
files = find_distributions(dist_dir)
|
|
1746
|
+
if not files:
|
|
1747
|
+
raise PyPICommandError(
|
|
1748
|
+
f"No distributions found under {dist_dir}. Run `chattool pypi build` first."
|
|
1749
|
+
)
|
|
1750
|
+
|
|
1751
|
+
args = [sys.executable, "-m", "twine", "check"]
|
|
1752
|
+
if strict:
|
|
1753
|
+
args.append("--strict")
|
|
1754
|
+
args.extend(str(path) for path in files)
|
|
1755
|
+
result = _ensure_success(runner(args, project_dir), "Twine check")
|
|
1756
|
+
return result, files
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
def upload_distributions(
|
|
1760
|
+
project_dir: Path,
|
|
1761
|
+
dist_dir: Path | None = None,
|
|
1762
|
+
*,
|
|
1763
|
+
skip_existing: bool = False,
|
|
1764
|
+
runner=run_command,
|
|
1765
|
+
) -> tuple[CommandResult, list[Path]]:
|
|
1766
|
+
project_dir = Path(project_dir)
|
|
1767
|
+
dist_dir = resolve_dist_dir(project_dir, dist_dir)
|
|
1768
|
+
files = find_distributions(dist_dir)
|
|
1769
|
+
if not files:
|
|
1770
|
+
raise PyPICommandError(
|
|
1771
|
+
f"No distributions found under {dist_dir}. Run `chattool pypi build` first."
|
|
1772
|
+
)
|
|
1773
|
+
|
|
1774
|
+
args = [sys.executable, "-m", "twine", "upload"]
|
|
1775
|
+
if skip_existing:
|
|
1776
|
+
args.append("--skip-existing")
|
|
1777
|
+
args.extend(str(path) for path in files)
|
|
1778
|
+
result = _ensure_success(runner(args, project_dir), "Twine upload")
|
|
1779
|
+
return result, files
|