partest-gen 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. partest_gen-1.0.0/CHANGELOG.md +96 -0
  2. partest_gen-1.0.0/LICENSE +21 -0
  3. partest_gen-1.0.0/MANIFEST.in +22 -0
  4. partest_gen-1.0.0/PKG-INFO +129 -0
  5. partest_gen-1.0.0/partest_gen/__init__.py +31 -0
  6. partest_gen-1.0.0/partest_gen/cli.py +340 -0
  7. partest_gen-1.0.0/partest_gen/docs/__init__.py +40 -0
  8. partest_gen-1.0.0/partest_gen/docs/__main__.py +59 -0
  9. partest_gen-1.0.0/partest_gen/docs/components-generator.md +145 -0
  10. partest_gen-1.0.0/partest_gen/docs/components-ui-layer.md +78 -0
  11. partest_gen-1.0.0/partest_gen/docs/concepts-generated-contract.md +76 -0
  12. partest_gen-1.0.0/partest_gen/docs/howto-after-generation.md +101 -0
  13. partest_gen-1.0.0/partest_gen/docs/howto-resync.md +65 -0
  14. partest_gen-1.0.0/partest_gen/docs/howto-scaffold.md +100 -0
  15. partest_gen-1.0.0/partest_gen/emitters/__init__.py +5 -0
  16. partest_gen-1.0.0/partest_gen/emitters/collections.py +187 -0
  17. partest_gen-1.0.0/partest_gen/emitters/paths.py +129 -0
  18. partest_gen-1.0.0/partest_gen/emitters/payloads.py +108 -0
  19. partest_gen-1.0.0/partest_gen/emitters/resources.py +303 -0
  20. partest_gen-1.0.0/partest_gen/emitters/schema_py.py +277 -0
  21. partest_gen-1.0.0/partest_gen/emitters/tests_default.py +206 -0
  22. partest_gen-1.0.0/partest_gen/emitters/tests_p1.py +517 -0
  23. partest_gen-1.0.0/partest_gen/emitters/util.py +101 -0
  24. partest_gen-1.0.0/partest_gen/emitters/validations.py +126 -0
  25. partest_gen-1.0.0/partest_gen/ir.py +371 -0
  26. partest_gen-1.0.0/partest_gen/models_collections.py +354 -0
  27. partest_gen-1.0.0/partest_gen/models_endpoints.py +348 -0
  28. partest_gen-1.0.0/partest_gen/models_payloads.py +217 -0
  29. partest_gen-1.0.0/partest_gen/models_tests.py +314 -0
  30. partest_gen-1.0.0/partest_gen/models_validates.py +263 -0
  31. partest_gen-1.0.0/partest_gen/openapi_load.py +62 -0
  32. partest_gen-1.0.0/partest_gen/py.typed +0 -0
  33. partest_gen-1.0.0/partest_gen/root_files.py +299 -0
  34. partest_gen-1.0.0/partest_gen/skeleton.py +612 -0
  35. partest_gen-1.0.0/partest_gen/ui_layout.py +652 -0
  36. partest_gen-1.0.0/partest_gen.egg-info/PKG-INFO +129 -0
  37. partest_gen-1.0.0/partest_gen.egg-info/SOURCES.txt +42 -0
  38. partest_gen-1.0.0/partest_gen.egg-info/dependency_links.txt +1 -0
  39. partest_gen-1.0.0/partest_gen.egg-info/entry_points.txt +2 -0
  40. partest_gen-1.0.0/partest_gen.egg-info/requires.txt +6 -0
  41. partest_gen-1.0.0/partest_gen.egg-info/top_level.txt +1 -0
  42. partest_gen-1.0.0/setup.cfg +4 -0
  43. partest_gen-1.0.0/setup.py +88 -0
@@ -0,0 +1,96 @@
1
+ # Changelog
2
+
3
+ All notable changes to `partest-gen`. Format follows [Keep a Changelog](https://keepachangelog.com/),
4
+ versioning follows [Semantic Versioning](https://semver.org/) — with one addition specific to a
5
+ code generator: the **names and locations of the files it writes** are part of the public API.
6
+ Renaming a generated module breaks hand-written imports in every project that ran the previous
7
+ version, so it is a major change even though no exported name moved.
8
+
9
+ ## [1.0.0] — unreleased
10
+
11
+ First release as a standalone distribution. The generator itself is not new: it shipped inside
12
+ `partest` as `partest.project_gen` through waves G0–G6 and arrives here unchanged.
13
+
14
+ ### Extracted from partest
15
+
16
+ - `partest.project_gen.**` → `partest_gen.**`. Moved with `git subtree split`, so the history
17
+ of the directory came along instead of collapsing into one initial commit.
18
+ - The `partest-gen` console script is now declared by this distribution, the one that
19
+ implements it. `pip install partest` alone no longer provides the command.
20
+ - `partest` keeps `partest.project_gen` as a deprecated bridge that re-exports from here, and
21
+ a `partest[gen]` extra that installs both. The bridge is removed in a major `partest`
22
+ release, not before.
23
+
24
+ Rationale and the alternatives that were rejected:
25
+ `docs/wiki/decisions/separate-package.md`.
26
+
27
+ ### Added
28
+
29
+ - `partest-gen --version`. Generated trees look alike; the version that produced one is the
30
+ first thing a bug report needs.
31
+ - `python -m partest_gen.docs list | show <page> | path` — the user-facing pages ship inside
32
+ the wheel, generated from the wiki by `tools/docs_build_wheel.py`.
33
+ - `py.typed`: the package ships its annotations (PEP 561).
34
+ - Documentation wiki, linter and CI carried over from `partest`: frontmatter with `sources:`
35
+ and `verified:`, staleness detection against git history, wheel-drift checks, and
36
+ `tools/check_all.py` as the single command CI and a laptop both run.
37
+
38
+ ### Changed
39
+
40
+ - Requires `partest>=2.0.0`. The generator reads the methodology to decide which cases each
41
+ operation needs, and `partest` 2.0.0 split it into two areas: the modules this package
42
+ imports now live under `partest.methodology.api.*`. Nothing inside them was renamed — only
43
+ the import path — but the new paths do not exist in 1.x at all, so an older harness fails
44
+ as an `ImportError` while the suite is being collected. A suite generated here must also be
45
+ countable by that harness.
46
+
47
+ **Publication order:** this release cannot be uploaded before `partest` 2.0.0 is on PyPI.
48
+ Until then the floor names a version `pip` cannot resolve.
49
+
50
+ - Generated projects no longer turn certificate verification off. `partest` 2.0.0 verifies TLS
51
+ by default; a generator that keeps writing the old default teaches it, and the warning about
52
+ an unverified run then points at a line nobody wrote by hand.
53
+
54
+ - The root `conftest.py` of the flat scaffold (`RootFilesContainer`) creates
55
+ `ApiClient(domain=domain)` and names `PARTEST_TLS_VERIFY` in a comment.
56
+ - The generated `confpartest.py` carries a commented `tls_verify` hint — the CA-bundle form
57
+ first, because it keeps the check — so the first run against a self-signed stand has its
58
+ answer in the project, not only in the library's migration guide.
59
+ - The UI seed client (`src/ui/fixtures/api_seed.py`) resolves `verify=` through
60
+ `partest.tls.resolve_verify(None, env_only=True)`. `env_only` is the UI road: the
61
+ environment is read, `confpartest` is not, so the isolation of the UI layer holds.
62
+
63
+ - Generated dependency floors follow the same release: `requirements/api.txt` asks for
64
+ `partest>=2.0.0` and `requirements/ui.txt` for `partest[ui]>=2.0.0`.
65
+
66
+ - The `requirements.txt` of the flat scaffold matches what `partest` declares today: no
67
+ `swagger-parser` (dropped there as unresolvable and imported by nobody), and the audited
68
+ floors for `urllib3`, `requests`, `idna` and `pytest`. A new project used to start with the
69
+ dependency set an audit had already rejected.
70
+ - `python_requires=">=3.10"`. The emitters write files with `Path.write_text(newline=...)`,
71
+ which does not exist earlier. Unchanged from the last `partest` release.
72
+
73
+ ### Unchanged, deliberately
74
+
75
+ - The shape of what is emitted: the same files in the same places, with the same names. The
76
+ extraction itself was a packaging change, not a rewrite; the only content that moved since
77
+ is listed under `Changed`, and all of it follows `partest` 2.0.0 rather than introducing
78
+ anything of its own.
79
+ - The overwrite policy: only files carrying `AUTO-GENERATED by partest-gen` are rewritten by
80
+ `sync-openapi`. See `docs/wiki/decisions/banner-overwrite.md`.
81
+
82
+ ### Migration
83
+
84
+ ```bash
85
+ pip install partest-gen
86
+ ```
87
+
88
+ Then, in code that imported the old path:
89
+
90
+ ```python
91
+ from partest.project_gen.cli import main # deprecated, still works with partest-gen installed
92
+ from partest_gen.cli import main # supported
93
+ ```
94
+
95
+ Nothing changes for projects that only use the CLI, and nothing changes in an already
96
+ generated tree — no regeneration is needed.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Parshin Ewgeniy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,22 @@
1
+ # What ships to PyPI. Anything listed here is public forever.
2
+ include partest_gen/py.typed
3
+ recursive-include partest_gen/docs *.md
4
+ include LICENSE
5
+ include CHANGELOG.md
6
+
7
+ # Deliberately NOT shipped:
8
+ # tests/ — several need repo-only files (tools/, docs/wiki/, .claude/) and cannot
9
+ # pass from an sdist anyway
10
+ # README.md — repo-facing; it links to docs/wiki/, AGENTS.md and .claude/skills/,
11
+ # none of which ship. The PyPI description comes from docs/PYPI.md
12
+ # docs/ — the wiki is written for maintainers of this repository
13
+ prune tests
14
+ prune docs
15
+ prune tools
16
+ prune .claude
17
+ prune .github
18
+ exclude README.md
19
+ exclude AGENTS.md
20
+ exclude CLAUDE.md
21
+ exclude pytest.ini
22
+ exclude .private-names
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.1
2
+ Name: partest-gen
3
+ Version: 1.0.0
4
+ Summary: Scaffold a runnable pytest suite from OpenAPI: endpoints, payloads, validations and the priority-one test matrix partest's methodology asks for.
5
+ Home-page: https://github.com/Dec01/partest-gen
6
+ Author: dec01
7
+ Author-email: parshin.ewgeniy@yandex.ru
8
+ License: MIT
9
+ Project-URL: Source, https://github.com/Dec01/partest-gen
10
+ Project-URL: Issues, https://github.com/Dec01/partest-gen/issues
11
+ Project-URL: Changelog, https://github.com/Dec01/partest-gen/blob/master/CHANGELOG.md
12
+ Project-URL: Documentation, https://github.com/Dec01/partest-gen/blob/master/docs/wiki/index.md
13
+ Project-URL: PyPI, https://pypi.org/project/partest-gen/
14
+ Project-URL: partest, https://github.com/Dec01/partest
15
+ Keywords: autotest api openapi scaffold generator partest pytest codegen
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: License :: OSI Approved :: MIT License
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Framework :: Pytest
23
+ Classifier: Topic :: Software Development :: Testing
24
+ Classifier: Topic :: Software Development :: Code Generators
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: partest>=2.0.0
29
+ Requires-Dist: pyyaml>=6.0.2
30
+ Requires-Dist: requests>=2.31.0
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
33
+
34
+ # partest-gen
35
+
36
+ Turn an OpenAPI document into a **runnable pytest suite**: endpoints, payloads, validations,
37
+ collection facades, and the priority-one test matrix that
38
+ [partest](https://pypi.org/project/partest/)'s coverage methodology asks each operation for.
39
+
40
+ ```bash
41
+ pip install partest-gen
42
+ ```
43
+
44
+ `partest` comes with it — the generated suite runs on that harness. Python 3.10+.
45
+
46
+ ## One command
47
+
48
+ ```bash
49
+ partest-gen from-openapi ./my-suite --file openapi.yaml --depth p1 --with-ui
50
+ cd my-suite && pytest src/api/tests --collect-only -q
51
+ ```
52
+
53
+ A green collect means every emitted module imports and every case is discovered. That is the
54
+ acceptance test for a generation; the rest of the work is writing the assertions.
55
+
56
+ ## What you get
57
+
58
+ ```text
59
+ my-suite/
60
+ ├── requirements/{base,api,ui,local}.txt
61
+ ├── confpartest.py, conftest.py, env.example, pytest.ini
62
+ ├── .partest/{suite_ir.json, openapi_summary.md}
63
+ └── src/
64
+ ├── api/
65
+ │ ├── resources/{endpoints,payloads,validations,collections,rbac,security}
66
+ │ └── tests/{conftest, test_zorro, <tag>/…}
67
+ └── ui/ # with --with-ui: pages, fixtures, baselines, tests
68
+ ```
69
+
70
+ - **Classified operations.** Each endpoint gets a method subtype, and the subtype decides
71
+ which test cases it needs. The classification comes from `partest.methodology`, so the
72
+ generator and the coverage report never disagree about what an endpoint is.
73
+ - **Stubs with the right shape.** Every emitted call carries an explicit `type=` and lands in
74
+ the correct cell of the coverage matrix. Most are skipped with a `TODO` — a generated
75
+ assertion that passes on any response would raise the coverage number without testing
76
+ anything.
77
+ - **Facades instead of strings.** `models.<tag>.paths.*`, `.payload.*()`, `.validate.*` —
78
+ regenerated with the specification, so they cannot drift away from it.
79
+ - **A per-tag checklist.** `P1_CHECKLIST.md` and `.partest/openapi_summary.md` are the backlog.
80
+ - **An isolated UI tree.** `src/ui` never loads OpenAPI or the API coverage session.
81
+
82
+ ## Commands
83
+
84
+ ```bash
85
+ partest-gen from-openapi ./suite --file openapi.yaml --depth p1 # or --url
86
+ partest-gen sync-openapi ./suite --depth p1 # the spec changed
87
+ partest-gen init-ui ./suite # add the UI tree later
88
+ partest-gen dump-ir openapi.yaml -o suite_ir.json # inspect, write nothing
89
+ partest-gen --version
90
+ ```
91
+
92
+ | `--depth` | Emits |
93
+ |---|---|
94
+ | `resources` | paths + collection facades |
95
+ | `default` | + payloads, validations, Default and NotAllowed tests |
96
+ | `p1` | + the full priority-one stub set, plus a checklist per tag |
97
+
98
+ ## Your files stay yours
99
+
100
+ Files the generator owns start with `AUTO-GENERATED by partest-gen`. `sync-openapi` rewrites
101
+ those and nothing else; files without the banner are left alone unless you pass `--force`.
102
+
103
+ So do not hand-edit a banner file — the edit survives until the next sync and then vanishes.
104
+ Put your code in a sibling module and import it.
105
+
106
+ ## Documentation
107
+
108
+ Source and issues: **https://github.com/Dec01/partest-gen**
109
+
110
+ The user-facing pages also ship **inside the package**, so they are readable in the terminal
111
+ where you are actually running the tool:
112
+
113
+ ```bash
114
+ python -m partest_gen.docs list # what is available
115
+ python -m partest_gen.docs show howto-scaffold # read a page
116
+ python -m partest_gen.docs path # where the files live
117
+ ```
118
+
119
+ Included: the first run end to end, turning stubs into tests, re-syncing after the spec
120
+ changes, the contract of generated code, the parts of the generator, and the UI layer.
121
+
122
+ ## Requirements
123
+
124
+ Python 3.10+, `partest>=2.0.0`, `pyyaml`, `requests` (only for `--url`). The generated UI tree
125
+ additionally needs `pip install 'partest[ui]'` and a Playwright browser.
126
+
127
+ ## License
128
+
129
+ MIT.
@@ -0,0 +1,31 @@
1
+ """Scaffold a runnable pytest + partest suite from an OpenAPI specification.
2
+
3
+ Turns a specification into an intermediate representation (:mod:`partest_gen.ir`), then
4
+ emits a monorepo: endpoints, payloads, validations, collection facades and the priority-one
5
+ test stubs the methodology in ``partest.methodology`` says each operation needs. An optional
6
+ UI layer is generated alongside, deliberately isolated from the API session.
7
+
8
+ Entry points: the ``partest-gen`` command (:mod:`partest_gen.cli`) for people, and
9
+ :func:`load_openapi` + :func:`emit_resources` for scripts.
10
+
11
+ Legacy modules ``models_*`` remain for compatibility; new code uses IR + CLI.
12
+ """
13
+
14
+ from partest_gen.emitters.resources import emit_resources
15
+ from partest_gen.ir import OpIR, ParamIR, SuiteIR, build_ir_from_openapi_dict
16
+ from partest_gen.openapi_load import load_openapi
17
+ from partest_gen.ui_layout import build_ui_files
18
+
19
+ # The one place the version lives. setup.py reads it; nothing else may repeat it.
20
+ __version__ = "1.0.0"
21
+
22
+ __all__ = [
23
+ "OpIR",
24
+ "ParamIR",
25
+ "SuiteIR",
26
+ "build_ir_from_openapi_dict",
27
+ "load_openapi",
28
+ "emit_resources",
29
+ "build_ui_files",
30
+ "__version__",
31
+ ]
@@ -0,0 +1,340 @@
1
+ """CLI: ``partest-gen`` — scaffold and re-sync a partest suite from OpenAPI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import List, Optional, Sequence
10
+
11
+ from partest_gen import __version__
12
+
13
+
14
+ def _parse_entities(raw: Optional[str]) -> Optional[List[str]]:
15
+ if not raw:
16
+ return None
17
+ parts = [p.strip() for p in raw.replace(";", ",").split(",")]
18
+ return [p for p in parts if p]
19
+
20
+
21
+ def _resolve_openapi(args: argparse.Namespace) -> Optional[str]:
22
+ if getattr(args, "openapi", None):
23
+ return args.openapi
24
+ if getattr(args, "file", None):
25
+ return args.file
26
+ if getattr(args, "url", None):
27
+ return args.url
28
+ return None
29
+
30
+
31
+ def _print_result(result, *, verbose: bool) -> None:
32
+ print(f"Project root: {result.root}")
33
+ print(f"Created/updated: {len(result.created)} Skipped: {len(result.skipped)}")
34
+ if verbose:
35
+ for p in result.created:
36
+ print(f" + {p}")
37
+ for p in result.skipped:
38
+ print(f" = {p}")
39
+
40
+
41
+ def _cmd_init(args: argparse.Namespace) -> int:
42
+ from partest_gen.skeleton import init_skeleton
43
+
44
+ suite_ir = None
45
+ openapi = _resolve_openapi(args)
46
+
47
+ if openapi:
48
+ from partest_gen.openapi_load import load_openapi
49
+
50
+ suite_ir = load_openapi(openapi)
51
+ print(
52
+ f"Loaded OpenAPI: {suite_ir.title} "
53
+ f"({len(suite_ir.operations)} ops, tags={suite_ir.tags()})"
54
+ )
55
+
56
+ result = init_skeleton(
57
+ args.path,
58
+ name=args.name or Path(args.path).name or "api-suite",
59
+ with_ui=bool(getattr(args, "with_ui", False)),
60
+ force=bool(args.force),
61
+ openapi_source=None,
62
+ suite_ir=suite_ir,
63
+ )
64
+
65
+ # copy local openapi into docs/
66
+ if openapi and not str(openapi).startswith("http"):
67
+ src = Path(openapi)
68
+ if src.is_file():
69
+ dest = Path(result.root) / "docs" / "openapi.yaml"
70
+ if args.force or not dest.exists() or dest.stat().st_size < 500:
71
+ dest.parent.mkdir(parents=True, exist_ok=True)
72
+ dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
73
+ rel = "docs/openapi.yaml (from --openapi)"
74
+ if rel not in result.created:
75
+ result.created.append(rel)
76
+
77
+ # G2/G3: emit paths + collections (+ payloads/tests)
78
+ if suite_ir is not None:
79
+ from partest_gen.emitters.resources import emit_resources
80
+
81
+ entities = _parse_entities(getattr(args, "entities", None))
82
+ depth = getattr(args, "depth", None) or "default"
83
+ r2 = emit_resources(
84
+ result.root,
85
+ suite_ir,
86
+ force=bool(args.force),
87
+ tags_filter=entities,
88
+ write_ir=True,
89
+ depth=depth,
90
+ )
91
+ result.created.extend(r2.created)
92
+ result.skipped.extend(r2.skipped)
93
+ print(
94
+ f"Resources (depth={depth}): {len(r2.created)} files "
95
+ f"(tags={entities or suite_ir.tags()})"
96
+ )
97
+
98
+ _print_result(result, verbose=bool(args.verbose))
99
+ print("Next: pip install -r requirements/api.txt && cp env.example .env")
100
+ if suite_ir is not None:
101
+ print("See .partest/openapi_summary.md for paths registry + P1 matrix")
102
+ return 0
103
+
104
+
105
+ def _cmd_dump_ir(args: argparse.Namespace) -> int:
106
+ from partest_gen.openapi_load import load_openapi
107
+
108
+ ir = load_openapi(args.openapi)
109
+ out = Path(args.out) if args.out else None
110
+ payload = ir.to_dict()
111
+ text = json.dumps(payload, ensure_ascii=False, indent=2)
112
+ if out:
113
+ out.parent.mkdir(parents=True, exist_ok=True)
114
+ out.write_text(text, encoding="utf-8")
115
+ print(f"Wrote IR: {out} ({len(ir.operations)} operations)")
116
+ else:
117
+ print(text)
118
+ return 0
119
+
120
+
121
+ def _cmd_from_openapi(args: argparse.Namespace) -> int:
122
+ """Skeleton (if needed) + G2/G3 resources from OpenAPI."""
123
+ openapi = _resolve_openapi(args)
124
+ if not openapi:
125
+ print("error: provide --file or --url", file=sys.stderr)
126
+ return 2
127
+
128
+ args.openapi = openapi
129
+ if not getattr(args, "name", None):
130
+ args.name = Path(args.path).name or "api-suite"
131
+ depth = getattr(args, "depth", None) or "default"
132
+ print(f"from-openapi: skeleton + IR + resources (depth={depth})")
133
+ return _cmd_init(args)
134
+
135
+
136
+ def _cmd_init_ui(args: argparse.Namespace) -> int:
137
+ """Add / refresh G5 UI tree on an existing project."""
138
+ from partest_gen.skeleton import apply_ui_layout
139
+
140
+ root = Path(args.path).resolve()
141
+ if not root.is_dir():
142
+ print(f"error: project path not found: {root}", file=sys.stderr)
143
+ return 2
144
+ name = args.name or root.name or "api-suite"
145
+ result = apply_ui_layout(root, name=name, force=bool(args.force))
146
+ print(f"G5 UI layout → {result.root}")
147
+ _print_result(result, verbose=bool(args.verbose))
148
+ print("Next: pip install -r requirements/ui.txt && playwright install chromium")
149
+ print("Docs: docs/UI_GUIDE.md")
150
+ return 0
151
+
152
+
153
+ def _cmd_init_package_exports(args: argparse.Namespace) -> int:
154
+ """Generate recursive ``__init__.py`` re-exports (L1.11)."""
155
+ from partest.tools.generate_init import generate_init_for_directory
156
+
157
+ root = Path(args.directory).resolve()
158
+ try:
159
+ written = generate_init_for_directory(
160
+ root,
161
+ recursive=not bool(getattr(args, "no_recursive", False)),
162
+ )
163
+ except FileNotFoundError as e:
164
+ print(f"error: {e}", file=sys.stderr)
165
+ return 2
166
+ print(f"init-package-exports: wrote {len(written)} file(s) under {root}")
167
+ if args.verbose:
168
+ for p in written:
169
+ print(f" + {p}")
170
+ return 0
171
+
172
+
173
+ def _cmd_sync_openapi(args: argparse.Namespace) -> int:
174
+ """Refresh paths/collections/IR for an existing project (no full re-init)."""
175
+ from partest_gen.emitters.resources import emit_resources
176
+ from partest_gen.openapi_load import load_openapi
177
+ from partest_gen.skeleton import WriteResult
178
+
179
+ openapi = _resolve_openapi(args)
180
+ root = Path(args.path).resolve()
181
+ if not openapi:
182
+ # try project docs
183
+ candidate = root / "docs" / "openapi.yaml"
184
+ if candidate.is_file():
185
+ openapi = str(candidate)
186
+ else:
187
+ print(
188
+ "error: provide --file/--url or place docs/openapi.yaml in project",
189
+ file=sys.stderr,
190
+ )
191
+ return 2
192
+
193
+ if not root.is_dir():
194
+ print(f"error: project path not found: {root}", file=sys.stderr)
195
+ return 2
196
+
197
+ suite = load_openapi(openapi)
198
+ print(
199
+ f"Sync OpenAPI: {suite.title} ({len(suite.operations)} ops) → {root}"
200
+ )
201
+
202
+ # copy local openapi into docs if different
203
+ if not str(openapi).startswith("http"):
204
+ src = Path(openapi)
205
+ if src.is_file():
206
+ dest = root / "docs" / "openapi.yaml"
207
+ dest.parent.mkdir(parents=True, exist_ok=True)
208
+ if args.force or not dest.exists() or src.resolve() != dest.resolve():
209
+ dest.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
210
+
211
+ entities = _parse_entities(getattr(args, "entities", None))
212
+ depth = getattr(args, "depth", None) or "default"
213
+ result = emit_resources(
214
+ root,
215
+ suite,
216
+ force=bool(args.force),
217
+ tags_filter=entities,
218
+ write_ir=True,
219
+ depth=depth,
220
+ )
221
+ # mark copy
222
+ if (root / "docs" / "openapi.yaml").is_file():
223
+ if "docs/openapi.yaml" not in result.created:
224
+ # only note if we may have written
225
+ pass
226
+
227
+ _print_result(result, verbose=bool(args.verbose))
228
+ print("Updated .partest/suite_ir.json and openapi_summary.md")
229
+ return 0
230
+
231
+
232
+ def build_parser() -> argparse.ArgumentParser:
233
+ parser = argparse.ArgumentParser(
234
+ prog="partest-gen",
235
+ description="Scaffold partest API suites (monorepo layout).",
236
+ )
237
+ # Asked for in bug reports before anything else: generated trees look alike, and the
238
+ # only way to tell which emitter produced one is the version that ran.
239
+ parser.add_argument("--version", action="version", version=f"partest-gen {__version__}")
240
+ sub = parser.add_subparsers(dest="command", required=True)
241
+
242
+ def add_common(p):
243
+ p.add_argument(
244
+ "--entities",
245
+ default=None,
246
+ help="Comma-separated tags to emit (default: all)",
247
+ )
248
+ p.add_argument(
249
+ "--depth",
250
+ default="default",
251
+ choices=["resources", "default", "p1"],
252
+ help=(
253
+ "resources=G2 only; default=G3 payloads+Default/NotAllowed; "
254
+ "p1=G4 full P1 matrix stubs"
255
+ ),
256
+ )
257
+ p.add_argument("--force", action="store_true", help="Overwrite non-generated files too")
258
+ p.add_argument("-v", "--verbose", action="store_true")
259
+
260
+ p_init = sub.add_parser("init", help="Create monorepo skeleton (+ G2 if --openapi)")
261
+ p_init.add_argument("path", help="Target directory")
262
+ p_init.add_argument("--name", default="api-suite", help="Project / service name")
263
+ p_init.add_argument(
264
+ "--openapi",
265
+ default=None,
266
+ help="Optional OpenAPI file or URL — IR + G2 resources",
267
+ )
268
+ p_init.add_argument("--with-ui", action="store_true", help="Include src/ui skeleton")
269
+ add_common(p_init)
270
+ p_init.set_defaults(func=_cmd_init)
271
+
272
+ p_ir = sub.add_parser("dump-ir", help="Parse OpenAPI to SuiteIR JSON")
273
+ p_ir.add_argument("openapi", help="OpenAPI file path or URL")
274
+ p_ir.add_argument("-o", "--out", default=None, help="Output JSON path")
275
+ p_ir.set_defaults(func=_cmd_dump_ir)
276
+
277
+ p_fo = sub.add_parser(
278
+ "from-openapi",
279
+ help="Skeleton + IR + G2 paths/collections from OpenAPI",
280
+ )
281
+ p_fo.add_argument("path", help="Target project directory")
282
+ p_fo.add_argument("--name", default=None, help="Project name (default: dir name)")
283
+ p_fo.add_argument("--file", default=None, help="Local OpenAPI path")
284
+ p_fo.add_argument("--url", default=None, help="OpenAPI URL")
285
+ p_fo.add_argument("--with-ui", action="store_true")
286
+ add_common(p_fo)
287
+ p_fo.set_defaults(func=_cmd_from_openapi)
288
+
289
+ p_ui = sub.add_parser(
290
+ "init-ui",
291
+ help="G5: add deep UI monorepo tree (isolated from API session)",
292
+ )
293
+ p_ui.add_argument("path", help="Existing project directory")
294
+ p_ui.add_argument("--name", default=None, help="Project name for docs")
295
+ p_ui.add_argument("--force", action="store_true")
296
+ p_ui.add_argument("-v", "--verbose", action="store_true")
297
+ p_ui.set_defaults(func=_cmd_init_ui)
298
+
299
+ p_sync = sub.add_parser(
300
+ "sync-openapi",
301
+ help="Refresh paths/collections/IR in existing project (G2)",
302
+ )
303
+ p_sync.add_argument("path", help="Existing project directory")
304
+ p_sync.add_argument("--file", default=None, help="Local OpenAPI path")
305
+ p_sync.add_argument("--url", default=None, help="OpenAPI URL")
306
+ p_sync.add_argument(
307
+ "--openapi",
308
+ default=None,
309
+ help="Alias for --file/--url",
310
+ )
311
+ add_common(p_sync)
312
+ p_sync.set_defaults(func=_cmd_sync_openapi)
313
+
314
+ p_init_pkg = sub.add_parser(
315
+ "init-package-exports",
316
+ help="Generate recursive __init__.py re-exports (from .mod import *)",
317
+ )
318
+ p_init_pkg.add_argument(
319
+ "directory",
320
+ help="Package root to scan (e.g. src/api/resources)",
321
+ )
322
+ p_init_pkg.add_argument(
323
+ "--no-recursive",
324
+ action="store_true",
325
+ help="Only top-level directory",
326
+ )
327
+ p_init_pkg.add_argument("-v", "--verbose", action="store_true")
328
+ p_init_pkg.set_defaults(func=_cmd_init_package_exports)
329
+
330
+ return parser
331
+
332
+
333
+ def main(argv: Optional[Sequence[str]] = None) -> int:
334
+ parser = build_parser()
335
+ args = parser.parse_args(list(argv) if argv is not None else None)
336
+ return int(args.func(args))
337
+
338
+
339
+ if __name__ == "__main__":
340
+ raise SystemExit(main())
@@ -0,0 +1,40 @@
1
+ """Documentation shipped inside the installed package.
2
+
3
+ A generator is used from a terminal, often on a machine that has the package but not the
4
+ repository, so the wheel carries the pages a user needs. Files here are generated from the
5
+ project wiki by ``tools/docs_build_wheel.py`` — do not edit them by hand.
6
+
7
+ Access from Python::
8
+
9
+ from partest_gen.docs import list_docs, read_doc
10
+ print(read_doc("howto-scaffold.md")[:200])
11
+
12
+ Or from the command line::
13
+
14
+ python -m partest_gen.docs list
15
+ python -m partest_gen.docs show howto-scaffold
16
+ python -m partest_gen.docs path
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from importlib import resources
22
+ from typing import List
23
+
24
+
25
+ def list_docs() -> List[str]:
26
+ root = resources.files(__name__)
27
+ return sorted(p.name for p in root.iterdir() if p.name.endswith(".md"))
28
+
29
+
30
+ def read_doc(name: str) -> str:
31
+ """Read a shipped markdown doc by file name, with or without the ``.md`` suffix."""
32
+ if not name.endswith(".md"):
33
+ name = name + ".md"
34
+ path = resources.files(__name__).joinpath(name)
35
+ return path.read_text(encoding="utf-8")
36
+
37
+
38
+ def docs_path() -> str:
39
+ """Filesystem location of the shipped docs, for opening them in an editor."""
40
+ return str(resources.files(__name__))