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 ADDED
@@ -0,0 +1,43 @@
1
+ """ChatPyPI package lifecycle helpers.
2
+
3
+ ChatPyPI provides importable Python APIs for scaffolding, building, checking,
4
+ probing, and uploading Python package distributions. The ``chatpypi`` CLI is a
5
+ thin adapter over these APIs.
6
+ """
7
+
8
+ from .main import (
9
+ CommandResult,
10
+ DoctorCheck,
11
+ ProjectMetadata,
12
+ PyPICommandError,
13
+ RepositoryCheck,
14
+ ScaffoldResult,
15
+ build_package,
16
+ check_distributions,
17
+ check_repository_conflicts,
18
+ normalize_module_name,
19
+ read_project_metadata,
20
+ resolve_dist_dir,
21
+ scaffold_package,
22
+ upload_distributions,
23
+ )
24
+
25
+ __all__ = [
26
+ "__version__",
27
+ "CommandResult",
28
+ "DoctorCheck",
29
+ "ProjectMetadata",
30
+ "PyPICommandError",
31
+ "RepositoryCheck",
32
+ "ScaffoldResult",
33
+ "build_package",
34
+ "check_distributions",
35
+ "check_repository_conflicts",
36
+ "normalize_module_name",
37
+ "read_project_metadata",
38
+ "resolve_dist_dir",
39
+ "scaffold_package",
40
+ "upload_distributions",
41
+ ]
42
+
43
+ __version__ = "0.1.1"
chatpypi/cli.py ADDED
@@ -0,0 +1,541 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import subprocess
5
+ import sys
6
+
7
+ import click
8
+
9
+ from chatstyle import INTERACTIVE_OPTION_HELP
10
+ from chatstyle import (
11
+ abort_if_force_without_tty,
12
+ abort_if_missing_without_tty,
13
+ ask_confirm,
14
+ ask_select,
15
+ ask_text,
16
+ resolve_interactive_mode,
17
+ )
18
+
19
+ from chatpypi.main import (
20
+ PyPICommandError,
21
+ _ensure_empty_or_missing,
22
+ build_package,
23
+ check_distributions,
24
+ check_repository_conflicts,
25
+ read_project_metadata,
26
+ resolve_dist_dir,
27
+ scaffold_package,
28
+ upload_distributions,
29
+ )
30
+
31
+
32
+ def _project_options(func):
33
+ func = click.option(
34
+ "--dist-dir",
35
+ type=click.Path(path_type=Path, file_okay=False),
36
+ default=None,
37
+ help="Distribution directory. Defaults to <project-dir>/dist.",
38
+ )(func)
39
+ func = click.option(
40
+ "--project-dir",
41
+ type=click.Path(path_type=Path, file_okay=False),
42
+ default=Path("."),
43
+ show_default=True,
44
+ help="Project directory containing pyproject.toml.",
45
+ )(func)
46
+ return func
47
+
48
+
49
+ def _echo_result_output(result) -> None:
50
+ stdout = result.stdout.strip()
51
+ stderr = result.stderr.strip()
52
+ if stdout:
53
+ click.echo(stdout)
54
+ if stderr:
55
+ click.echo(stderr, err=True)
56
+
57
+
58
+ def _print_files(files: list[Path], title: str) -> None:
59
+ click.echo(title)
60
+ for path in files:
61
+ click.echo(f"- {path}")
62
+
63
+
64
+ def _raise_click_error(exc: Exception) -> None:
65
+ raise click.ClickException(str(exc)) from exc
66
+
67
+
68
+ def _normalize_optional_text(value: str | None) -> str | None:
69
+ if value is None:
70
+ return None
71
+ value = value.strip()
72
+ return value or None
73
+
74
+
75
+ def _resolve_init_inputs(
76
+ *,
77
+ name: str | None,
78
+ description: str | None,
79
+ initial_version: str,
80
+ requires_python: str,
81
+ license_name: str,
82
+ author: str | None,
83
+ email: str | None,
84
+ project_dir: Path | None,
85
+ template: str = "default",
86
+ ) -> tuple[str, str | None, str, str, str, str | None, str | None, Path]:
87
+ package_name = _normalize_optional_text(name)
88
+ if not package_name and project_dir is not None and project_dir.name:
89
+ package_name = project_dir.name
90
+ if not package_name:
91
+ raise click.ClickException(
92
+ "Package name is required. Pass NAME or --project-dir."
93
+ )
94
+
95
+ target_dir = (project_dir or Path(package_name)).resolve()
96
+ return (
97
+ package_name,
98
+ _normalize_optional_text(description) or f"{package_name} package",
99
+ initial_version or "0.1.0",
100
+ requires_python or _default_requires_python(template),
101
+ license_name or "MIT",
102
+ _normalize_optional_text(author),
103
+ _normalize_optional_text(email),
104
+ target_dir,
105
+ )
106
+
107
+
108
+ def _default_requires_python(template: str) -> str:
109
+ if template == "chatarch":
110
+ return ">=3.10"
111
+ return ">=3.9"
112
+
113
+
114
+ def _option_was_default(name: str) -> bool:
115
+ ctx = click.get_current_context(silent=True)
116
+ if not ctx:
117
+ return False
118
+ try:
119
+ return ctx.get_parameter_source(name) == click.core.ParameterSource.DEFAULT
120
+ except Exception:
121
+ return False
122
+
123
+
124
+ def _is_name_missing(name: str | None, project_dir: Path | None) -> bool:
125
+ package_name = _normalize_optional_text(name)
126
+ return not package_name and not (project_dir is not None and project_dir.name)
127
+
128
+
129
+ def _read_git_config(key: str) -> str | None:
130
+ try:
131
+ result = subprocess.run(
132
+ ["git", "config", "--get", key],
133
+ check=False,
134
+ capture_output=True,
135
+ text=True,
136
+ )
137
+ except Exception:
138
+ return None
139
+ if result.returncode != 0:
140
+ return None
141
+ value = result.stdout.strip()
142
+ return value or None
143
+
144
+
145
+ def _resolve_project_dir(project_dir: Path) -> Path:
146
+ return project_dir.resolve()
147
+
148
+
149
+ def _resolve_project_and_dist_dirs(
150
+ project_dir: Path, dist_dir: Path | None
151
+ ) -> tuple[Path, Path]:
152
+ resolved_project_dir = _resolve_project_dir(project_dir)
153
+ resolved_dist_dir = resolve_dist_dir(
154
+ resolved_project_dir,
155
+ dist_dir.resolve() if dist_dir else None,
156
+ )
157
+ return resolved_project_dir, resolved_dist_dir
158
+
159
+
160
+ @click.group(name="chatpypi")
161
+ def cli():
162
+ """Python package scaffold/build/check/upload helpers."""
163
+ pass
164
+
165
+
166
+ @cli.command(name="init")
167
+ @click.argument("name", required=False)
168
+ @click.option(
169
+ "-t",
170
+ "--template",
171
+ type=click.Choice(["default", "chatarch"]),
172
+ default="default",
173
+ show_default=True,
174
+ help="Project scaffold template.",
175
+ )
176
+ @click.option("--email", default=None, help="Author email to record in pyproject.toml.")
177
+ @click.option("--author", default=None, help="Author name to record in pyproject.toml.")
178
+ @click.option(
179
+ "--license",
180
+ "license_name",
181
+ default="MIT",
182
+ show_default=True,
183
+ help="Project license template: MIT, Apache-2.0, BSD-3-Clause, GPL-3.0-only, or Proprietary.",
184
+ )
185
+ @click.option(
186
+ "--version",
187
+ "initial_version",
188
+ default="0.1.0",
189
+ show_default=True,
190
+ help="Initial package version written to src/<module>/__init__.py.",
191
+ )
192
+ @click.option(
193
+ "--python",
194
+ "requires_python",
195
+ default=">=3.9",
196
+ show_default=True,
197
+ help="Supported Python version specifier.",
198
+ )
199
+ @click.option("--description", default=None, help="Project description.")
200
+ @click.option(
201
+ "--project-dir",
202
+ type=click.Path(path_type=Path, file_okay=False),
203
+ default=None,
204
+ help="Target directory to create. Defaults to ./{name}.",
205
+ )
206
+ @click.option(
207
+ "--with-mkdocs/--without-mkdocs",
208
+ "include_mkdocs",
209
+ default=None,
210
+ help="Create mkdocs files for chatarch template. Defaults to on for chatarch.",
211
+ )
212
+ @click.option(
213
+ "--with-workflows/--without-workflows",
214
+ "include_workflows",
215
+ default=None,
216
+ help="Create GitHub workflow files for chatarch template. Defaults to on for chatarch.",
217
+ )
218
+ @click.option(
219
+ "--with-chatenv-provider/--without-chatenv-provider",
220
+ "include_chatenv_provider",
221
+ default=False,
222
+ show_default=True,
223
+ help="Create ChatEnv provider config.py and chatenv.configs entry point for chatarch template.",
224
+ )
225
+ @click.option(
226
+ "--chatenv-provider-name",
227
+ default=None,
228
+ help="Entry point name for --with-chatenv-provider. Defaults to the module name.",
229
+ )
230
+ @click.option(
231
+ "--interactive/--no-interactive",
232
+ "interactive",
233
+ "-i/-I",
234
+ default=None,
235
+ help=INTERACTIVE_OPTION_HELP,
236
+ )
237
+ def init(
238
+ name: str | None,
239
+ template: str,
240
+ description: str | None,
241
+ initial_version: str,
242
+ requires_python: str,
243
+ license_name: str,
244
+ author: str | None,
245
+ email: str | None,
246
+ project_dir: Path | None,
247
+ include_mkdocs: bool | None,
248
+ include_workflows: bool | None,
249
+ include_chatenv_provider: bool,
250
+ chatenv_provider_name: str | None,
251
+ interactive: bool | None,
252
+ ):
253
+ """Scaffold a minimal src-layout Python package."""
254
+ if _option_was_default("requires_python"):
255
+ requires_python = _default_requires_python(template)
256
+
257
+ missing_required = _is_name_missing(name, project_dir)
258
+ usage = (
259
+ "Usage: chatpypi init [NAME] [-t default|chatarch] [--project-dir PATH] "
260
+ "[--description TEXT] [--version TEXT] [--python TEXT] [--license TEXT] "
261
+ "[--author TEXT] [--email TEXT] [-i|-I]"
262
+ )
263
+ resolution = resolve_interactive_mode(
264
+ interactive=interactive,
265
+ auto_prompt_condition=missing_required,
266
+ )
267
+ interactive = resolution.interactive
268
+ can_prompt = resolution.can_prompt
269
+ force_interactive = resolution.force_interactive
270
+ need_prompt = resolution.need_prompt
271
+ abort_if_force_without_tty(force_interactive, can_prompt, usage)
272
+ abort_if_missing_without_tty(
273
+ missing_required=missing_required,
274
+ interactive=interactive,
275
+ can_prompt=can_prompt,
276
+ message="Package name is required. Pass NAME or --project-dir.",
277
+ usage=usage,
278
+ )
279
+
280
+ if need_prompt:
281
+ template = ask_select(
282
+ "选择模板",
283
+ choices=[
284
+ "default - minimal Python package",
285
+ "chatarch - ChatArch CLI/docs/tests/automation scaffold",
286
+ ],
287
+ ).split(" - ", 1)[0]
288
+ name_default = _normalize_optional_text(name) or (
289
+ project_dir.name if project_dir is not None and project_dir.name else ""
290
+ )
291
+ name = ask_text("package_name", default=name_default)
292
+ normalized_name = _normalize_optional_text(name)
293
+ if not normalized_name:
294
+ raise click.ClickException(
295
+ "Package name is required. Pass NAME or --project-dir."
296
+ )
297
+
298
+ project_dir_default = str(project_dir or Path(normalized_name))
299
+ project_dir = Path(
300
+ ask_text("project_dir", default=project_dir_default)
301
+ ).expanduser()
302
+ try:
303
+ _ensure_empty_or_missing(project_dir)
304
+ except PyPICommandError as exc:
305
+ _raise_click_error(exc)
306
+ description = ask_text(
307
+ "description",
308
+ default=_normalize_optional_text(description)
309
+ or f"{normalized_name} package",
310
+ )
311
+ initial_version = ask_text("version", default=initial_version or "0.1.0")
312
+ if _option_was_default("requires_python"):
313
+ requires_python = _default_requires_python(template)
314
+ requires_python = ask_text("requires_python", default=requires_python)
315
+ license_name = ask_text("license", default=license_name or "MIT")
316
+ if include_mkdocs is None and template == "chatarch":
317
+ include_mkdocs = ask_confirm(
318
+ "Create mkdocs documentation files?",
319
+ default=True,
320
+ )
321
+ elif include_mkdocs is None:
322
+ include_mkdocs = False
323
+ if include_workflows is None and template == "chatarch":
324
+ include_workflows = ask_confirm(
325
+ "Create GitHub workflow files?",
326
+ default=True,
327
+ )
328
+ elif include_workflows is None:
329
+ include_workflows = False
330
+ author = ask_text(
331
+ "author",
332
+ default=_normalize_optional_text(author)
333
+ or _read_git_config("user.name")
334
+ or "",
335
+ )
336
+ email = ask_text(
337
+ "email",
338
+ default=_normalize_optional_text(email)
339
+ or _read_git_config("user.email")
340
+ or "",
341
+ )
342
+
343
+ (
344
+ package_name,
345
+ description,
346
+ initial_version,
347
+ requires_python,
348
+ license_name,
349
+ author,
350
+ email,
351
+ target_dir,
352
+ ) = _resolve_init_inputs(
353
+ name=name,
354
+ description=description,
355
+ initial_version=initial_version,
356
+ requires_python=requires_python,
357
+ license_name=license_name,
358
+ author=author,
359
+ email=email,
360
+ project_dir=project_dir,
361
+ template=template,
362
+ )
363
+ if chatenv_provider_name and not include_chatenv_provider:
364
+ raise click.ClickException(
365
+ "--chatenv-provider-name requires --with-chatenv-provider."
366
+ )
367
+ if include_chatenv_provider and template != "chatarch":
368
+ raise click.ClickException(
369
+ "--with-chatenv-provider is only supported by the chatarch template."
370
+ )
371
+ try:
372
+ result = scaffold_package(
373
+ package_name=package_name,
374
+ project_dir=target_dir,
375
+ initial_version=initial_version,
376
+ description=description,
377
+ requires_python=requires_python,
378
+ license_name=license_name,
379
+ author=author,
380
+ email=email,
381
+ template=template,
382
+ include_mkdocs=include_mkdocs,
383
+ include_workflows=include_workflows,
384
+ include_chatenv_provider=include_chatenv_provider,
385
+ chatenv_provider_name=chatenv_provider_name,
386
+ )
387
+ except PyPICommandError as exc:
388
+ _raise_click_error(exc)
389
+
390
+ click.echo(f"Created Python package scaffold: {result.package_name}")
391
+ click.echo(f"project_dir={result.project_dir}")
392
+ click.echo(f"module_name={result.module_name}")
393
+ _print_files(result.created_files, "Created files:")
394
+
395
+
396
+ @cli.command(name="build")
397
+ @click.option("--wheel", is_flag=True, help="Build wheel only.")
398
+ @click.option("--sdist", is_flag=True, help="Build source distribution only.")
399
+ @click.option(
400
+ "--clean/--no-clean",
401
+ default=True,
402
+ show_default=True,
403
+ help="Clean old files in dist directory first.",
404
+ )
405
+ @_project_options
406
+ def build(
407
+ project_dir: Path, dist_dir: Path | None, clean: bool, sdist: bool, wheel: bool
408
+ ):
409
+ """Build wheel and/or source distribution with python -m build."""
410
+ project_dir, dist_dir = _resolve_project_and_dist_dirs(project_dir, dist_dir)
411
+ click.echo(f"Building distributions from {project_dir} into {dist_dir}...")
412
+ try:
413
+ result, files = build_package(
414
+ project_dir,
415
+ dist_dir,
416
+ clean=clean,
417
+ sdist=sdist,
418
+ wheel=wheel,
419
+ )
420
+ except PyPICommandError as exc:
421
+ _raise_click_error(exc)
422
+ _echo_result_output(result)
423
+ _print_files(files, "Built distributions:")
424
+
425
+
426
+ @cli.command(name="check")
427
+ @click.option(
428
+ "--strict", is_flag=True, help="Fail on warnings reported by twine check."
429
+ )
430
+ @_project_options
431
+ def check(project_dir: Path, dist_dir: Path | None, strict: bool):
432
+ """Validate built distributions with twine check."""
433
+ project_dir, dist_dir = _resolve_project_and_dist_dirs(project_dir, dist_dir)
434
+ try:
435
+ result, files = check_distributions(
436
+ project_dir,
437
+ dist_dir,
438
+ strict=strict,
439
+ )
440
+ except PyPICommandError as exc:
441
+ _raise_click_error(exc)
442
+ _echo_result_output(result)
443
+ _print_files(files, "Checked distributions:")
444
+
445
+
446
+ @cli.command(name="upload")
447
+ @click.option(
448
+ "--skip-existing", is_flag=True, help="Pass --skip-existing to twine upload."
449
+ )
450
+ @_project_options
451
+ def upload(project_dir: Path, dist_dir: Path | None, skip_existing: bool):
452
+ """Upload built distributions with the default twine upload behavior."""
453
+ project_dir, dist_dir = _resolve_project_and_dist_dirs(project_dir, dist_dir)
454
+ click.echo(f"Uploading distributions from {dist_dir} with `twine upload`...")
455
+ try:
456
+ result, files = upload_distributions(
457
+ project_dir,
458
+ dist_dir,
459
+ skip_existing=skip_existing,
460
+ )
461
+ except PyPICommandError as exc:
462
+ _raise_click_error(exc)
463
+ _echo_result_output(result)
464
+ _print_files(files, "Uploaded distributions:")
465
+
466
+
467
+ @cli.command(name="probe")
468
+ @click.argument("package_name", required=False)
469
+ @click.option(
470
+ "--repository-url",
471
+ default=None,
472
+ help="Custom repository URL. Overrides --repository.",
473
+ )
474
+ @click.option(
475
+ "--repository",
476
+ type=click.Choice(["testpypi", "pypi"]),
477
+ default="pypi",
478
+ show_default=True,
479
+ help="Target repository for exact project/version releaseability checks.",
480
+ )
481
+ @click.option(
482
+ "--project-dir",
483
+ type=click.Path(path_type=Path, file_okay=False),
484
+ default=Path("."),
485
+ show_default=True,
486
+ help="Project directory containing pyproject.toml for default metadata lookup.",
487
+ )
488
+ def probe(
489
+ project_dir: Path,
490
+ repository: str,
491
+ repository_url: str | None,
492
+ package_name: str | None,
493
+ ):
494
+ """Check whether an exact package name is available on PyPI."""
495
+ project_dir = _resolve_project_dir(project_dir)
496
+ try:
497
+ metadata = read_project_metadata(project_dir)
498
+ except PyPICommandError:
499
+ metadata = None
500
+
501
+ target_name = _normalize_optional_text(package_name) or (
502
+ metadata.name if metadata else None
503
+ )
504
+ if not target_name:
505
+ raise click.ClickException(
506
+ "Package name is required. Pass NAME or provide a readable pyproject.toml."
507
+ )
508
+
509
+ try:
510
+ repository_checks = check_repository_conflicts(
511
+ target_name,
512
+ repository=repository,
513
+ repository_url=repository_url,
514
+ )
515
+ except PyPICommandError as exc:
516
+ _raise_click_error(exc)
517
+
518
+ for item in repository_checks:
519
+ click.echo(f"[{item.status.upper()}] {item.label}: {item.detail}")
520
+ if item.hint:
521
+ click.echo(f" hint: {item.hint}")
522
+ if any(item.status == "fail" for item in repository_checks):
523
+ raise click.ClickException("Repository conflict checks found blocking issues.")
524
+
525
+
526
+ KNOWN_COMMANDS = {"init", "build", "check", "upload", "probe", "--help", "-h"}
527
+
528
+
529
+ def main() -> None:
530
+ """Console-script entry point with chatpypi shortcut routing."""
531
+
532
+ args = sys.argv[1:]
533
+ if args:
534
+ first = args[0]
535
+ if first not in KNOWN_COMMANDS and not first.startswith("-"):
536
+ args = ["init", *args]
537
+ cli.main(args=args, prog_name="chatpypi")
538
+
539
+
540
+ if __name__ == "__main__":
541
+ main()