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.
- partest_gen-1.0.0/CHANGELOG.md +96 -0
- partest_gen-1.0.0/LICENSE +21 -0
- partest_gen-1.0.0/MANIFEST.in +22 -0
- partest_gen-1.0.0/PKG-INFO +129 -0
- partest_gen-1.0.0/partest_gen/__init__.py +31 -0
- partest_gen-1.0.0/partest_gen/cli.py +340 -0
- partest_gen-1.0.0/partest_gen/docs/__init__.py +40 -0
- partest_gen-1.0.0/partest_gen/docs/__main__.py +59 -0
- partest_gen-1.0.0/partest_gen/docs/components-generator.md +145 -0
- partest_gen-1.0.0/partest_gen/docs/components-ui-layer.md +78 -0
- partest_gen-1.0.0/partest_gen/docs/concepts-generated-contract.md +76 -0
- partest_gen-1.0.0/partest_gen/docs/howto-after-generation.md +101 -0
- partest_gen-1.0.0/partest_gen/docs/howto-resync.md +65 -0
- partest_gen-1.0.0/partest_gen/docs/howto-scaffold.md +100 -0
- partest_gen-1.0.0/partest_gen/emitters/__init__.py +5 -0
- partest_gen-1.0.0/partest_gen/emitters/collections.py +187 -0
- partest_gen-1.0.0/partest_gen/emitters/paths.py +129 -0
- partest_gen-1.0.0/partest_gen/emitters/payloads.py +108 -0
- partest_gen-1.0.0/partest_gen/emitters/resources.py +303 -0
- partest_gen-1.0.0/partest_gen/emitters/schema_py.py +277 -0
- partest_gen-1.0.0/partest_gen/emitters/tests_default.py +206 -0
- partest_gen-1.0.0/partest_gen/emitters/tests_p1.py +517 -0
- partest_gen-1.0.0/partest_gen/emitters/util.py +101 -0
- partest_gen-1.0.0/partest_gen/emitters/validations.py +126 -0
- partest_gen-1.0.0/partest_gen/ir.py +371 -0
- partest_gen-1.0.0/partest_gen/models_collections.py +354 -0
- partest_gen-1.0.0/partest_gen/models_endpoints.py +348 -0
- partest_gen-1.0.0/partest_gen/models_payloads.py +217 -0
- partest_gen-1.0.0/partest_gen/models_tests.py +314 -0
- partest_gen-1.0.0/partest_gen/models_validates.py +263 -0
- partest_gen-1.0.0/partest_gen/openapi_load.py +62 -0
- partest_gen-1.0.0/partest_gen/py.typed +0 -0
- partest_gen-1.0.0/partest_gen/root_files.py +299 -0
- partest_gen-1.0.0/partest_gen/skeleton.py +612 -0
- partest_gen-1.0.0/partest_gen/ui_layout.py +652 -0
- partest_gen-1.0.0/partest_gen.egg-info/PKG-INFO +129 -0
- partest_gen-1.0.0/partest_gen.egg-info/SOURCES.txt +42 -0
- partest_gen-1.0.0/partest_gen.egg-info/dependency_links.txt +1 -0
- partest_gen-1.0.0/partest_gen.egg-info/entry_points.txt +2 -0
- partest_gen-1.0.0/partest_gen.egg-info/requires.txt +6 -0
- partest_gen-1.0.0/partest_gen.egg-info/top_level.txt +1 -0
- partest_gen-1.0.0/setup.cfg +4 -0
- 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__))
|