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/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