codapact 0.1.0__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.
- codapact/__init__.py +3 -0
- codapact/__main__.py +83 -0
- codapact/cli.py +117 -0
- codapact/configuration.py +135 -0
- codapact/errors.py +30 -0
- codapact/generation.py +243 -0
- codapact/loading.py +121 -0
- codapact/model.py +83 -0
- codapact/net.py +100 -0
- codapact/openapi.py +294 -0
- codapact/profiles.py +171 -0
- codapact/project.py +173 -0
- codapact/py.typed +0 -0
- codapact/runtime.py +299 -0
- codapact/schemas/generation.v1.json +57 -0
- codapact/values.py +33 -0
- codapact-0.1.0.dist-info/METADATA +62 -0
- codapact-0.1.0.dist-info/RECORD +21 -0
- codapact-0.1.0.dist-info/WHEEL +4 -0
- codapact-0.1.0.dist-info/entry_points.txt +3 -0
- codapact-0.1.0.dist-info/licenses/LICENSE +21 -0
codapact/__init__.py
ADDED
codapact/__main__.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""CodaPact generator entry point."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from codapact import __version__
|
|
7
|
+
from codapact.configuration import resolve_generation
|
|
8
|
+
from codapact.errors import CodaError
|
|
9
|
+
from codapact.net import HttpTransport
|
|
10
|
+
from codapact.project import generate
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(description="CodaPact: contracts into callable tools")
|
|
15
|
+
parser.add_argument("--version", action="version", version=f"codapact {__version__}")
|
|
16
|
+
subparsers = parser.add_subparsers(dest="action", required=True)
|
|
17
|
+
command = subparsers.add_parser("generate", help="Generate a Python library and argparse CLI")
|
|
18
|
+
command.add_argument("source", nargs="?", help="Local OpenAPI path or HTTP(S) URL")
|
|
19
|
+
command.add_argument("--manifest", help="Local version 1 generation manifest")
|
|
20
|
+
fields = {
|
|
21
|
+
"manifest-version": ("version", int),
|
|
22
|
+
"contract-type": ("contract.type", str),
|
|
23
|
+
"output": ("output.directory", str),
|
|
24
|
+
"package": ("client.package", str),
|
|
25
|
+
"client-language": ("client.language", str),
|
|
26
|
+
"cli-style": ("cli.style", str),
|
|
27
|
+
"slug": ("cli.slug", str),
|
|
28
|
+
"cli-definition": ("cli.definition", str),
|
|
29
|
+
"runtime-config-dir": ("runtime.config_directory", str),
|
|
30
|
+
"runtime-base-url": ("runtime.defaults.base_url", str),
|
|
31
|
+
"runtime-timeout": ("runtime.defaults.timeout", float),
|
|
32
|
+
"runtime-policy-allow": ("runtime.defaults.policy.allow", str),
|
|
33
|
+
"runtime-policy-deny": ("runtime.defaults.policy.deny", str),
|
|
34
|
+
}
|
|
35
|
+
for flag, (_, kind) in fields.items():
|
|
36
|
+
aliases = ["--slug", "--prefix"] if flag == "slug" else ["--" + flag]
|
|
37
|
+
command.add_argument(*aliases, type=kind, default=argparse.SUPPRESS)
|
|
38
|
+
command.add_argument(
|
|
39
|
+
"--allow-private", action=argparse.BooleanOptionalAction, default=argparse.SUPPRESS
|
|
40
|
+
)
|
|
41
|
+
command.add_argument(
|
|
42
|
+
"--runtime-allow-private", action=argparse.BooleanOptionalAction, default=argparse.SUPPRESS
|
|
43
|
+
)
|
|
44
|
+
command.add_argument("--set", action="append", default=[], metavar="FIELD=JSON")
|
|
45
|
+
args = parser.parse_args(argv)
|
|
46
|
+
try:
|
|
47
|
+
overrides = {}
|
|
48
|
+
for flag, (path, _) in fields.items():
|
|
49
|
+
if hasattr(args, flag.replace("-", "_")):
|
|
50
|
+
value = getattr(args, flag.replace("-", "_"))
|
|
51
|
+
if flag.startswith("runtime-policy-"):
|
|
52
|
+
import json
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
value = json.loads(value)
|
|
56
|
+
except ValueError:
|
|
57
|
+
raise CodaError("policy flags require JSON arrays") from None
|
|
58
|
+
cursor = overrides
|
|
59
|
+
parts = path.split(".")
|
|
60
|
+
for part in parts[:-1]:
|
|
61
|
+
cursor = cursor.setdefault(part, {})
|
|
62
|
+
cursor[parts[-1]] = value
|
|
63
|
+
if args.source is not None:
|
|
64
|
+
overrides.setdefault("contract", {})["source"] = args.source
|
|
65
|
+
if hasattr(args, "allow_private"):
|
|
66
|
+
overrides.setdefault("contract", {})["allow_private"] = args.allow_private
|
|
67
|
+
if hasattr(args, "runtime_allow_private"):
|
|
68
|
+
overrides.setdefault("runtime", {}).setdefault("defaults", {})["allow_private"] = (
|
|
69
|
+
args.runtime_allow_private
|
|
70
|
+
)
|
|
71
|
+
options = resolve_generation(args.manifest, overrides, args.set)
|
|
72
|
+
allow_private = options.pop("allow_private")
|
|
73
|
+
generate(**options, transport=HttpTransport(allow_private=allow_private))
|
|
74
|
+
except (CodaError, OSError) as exc:
|
|
75
|
+
message = str(exc) if isinstance(exc, CodaError) else "cannot write generated project"
|
|
76
|
+
print(f"codapact: {message}", file=sys.stderr)
|
|
77
|
+
return 2
|
|
78
|
+
print(f"Generated {options['package']} in {options['output']}")
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
raise SystemExit(main())
|
codapact/cli.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Noninteractive CLI runtime shared by generated CLI styles."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from codapact.errors import ApiError, CodaError, PolicyError, ResponseError, TransportError
|
|
11
|
+
from codapact.loading import load
|
|
12
|
+
from codapact.profiles import JsonFileStore, StoredAuth, runtime_settings
|
|
13
|
+
from codapact.runtime import UNSET, ClientBase, EnvironmentAuth
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_cli(
|
|
17
|
+
factory: Callable[..., ClientBase],
|
|
18
|
+
document: dict[str, Any],
|
|
19
|
+
definition: dict[str, Any],
|
|
20
|
+
argv: list[str] | None = None,
|
|
21
|
+
*,
|
|
22
|
+
profile: dict[str, Any] | None = None,
|
|
23
|
+
) -> int:
|
|
24
|
+
parser = argparse.ArgumentParser(description="Generated CodaPact API CLI")
|
|
25
|
+
parser.add_argument("--config", help="Local JSON/YAML runtime configuration")
|
|
26
|
+
parser.add_argument("--config-dir", help="Override configuration directory")
|
|
27
|
+
parser.add_argument("--auth-file", help="Override auth.json path")
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--no-auth-file", action="store_true", help="Disable credential file discovery"
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--base-url")
|
|
32
|
+
parser.add_argument("--timeout", type=float)
|
|
33
|
+
parser.add_argument("--allow-private", action=argparse.BooleanOptionalAction, default=None)
|
|
34
|
+
parser.add_argument("--pretty", action="store_true", help="Indent JSON output")
|
|
35
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
36
|
+
operations = {op["id"]: op for op in document["operations"]}
|
|
37
|
+
mapping = {}
|
|
38
|
+
for command in definition["commands"]:
|
|
39
|
+
operation = operations[command["operation"]]
|
|
40
|
+
mapping[command["name"]] = operation
|
|
41
|
+
summary = operation["summary"].encode("unicode_escape").decode("ascii")
|
|
42
|
+
child = commands.add_parser(command["name"], help=summary)
|
|
43
|
+
for index, parameter in enumerate(operation["parameters"]):
|
|
44
|
+
child.add_argument(
|
|
45
|
+
"--arg-" + parameter["name"].replace("_", "-").lower(),
|
|
46
|
+
dest=f"p{index}",
|
|
47
|
+
required=parameter["required"],
|
|
48
|
+
)
|
|
49
|
+
if operation["body"] is not None:
|
|
50
|
+
child.add_argument(
|
|
51
|
+
"--body", required=operation["body_required"], help="JSON request body"
|
|
52
|
+
)
|
|
53
|
+
args = parser.parse_args(argv)
|
|
54
|
+
try:
|
|
55
|
+
if args.config and args.config.startswith(("http://", "https://")):
|
|
56
|
+
raise CodaError("runtime configuration must be local")
|
|
57
|
+
if profile is None:
|
|
58
|
+
settings = load(args.config) if args.config else {}
|
|
59
|
+
if args.config_dir or args.auth_file or args.no_auth_file:
|
|
60
|
+
raise CodaError("configuration discovery requires a generated runtime profile")
|
|
61
|
+
client = factory(settings=settings)
|
|
62
|
+
else:
|
|
63
|
+
overrides = {
|
|
64
|
+
key: getattr(args, key)
|
|
65
|
+
for key in ("base_url", "timeout", "allow_private")
|
|
66
|
+
if getattr(args, key) is not None
|
|
67
|
+
}
|
|
68
|
+
settings, directory = runtime_settings(profile, args.config_dir, args.config, overrides)
|
|
69
|
+
if args.auth_file and args.no_auth_file:
|
|
70
|
+
raise CodaError("--auth-file and --no-auth-file conflict")
|
|
71
|
+
if args.auth_file and "://" in args.auth_file:
|
|
72
|
+
raise CodaError("auth file must be local")
|
|
73
|
+
auth = (
|
|
74
|
+
EnvironmentAuth(settings.get("auth"))
|
|
75
|
+
if args.no_auth_file
|
|
76
|
+
else StoredAuth(
|
|
77
|
+
JsonFileStore(
|
|
78
|
+
Path(args.auth_file) if args.auth_file else directory / "auth.json",
|
|
79
|
+
optional=args.auth_file is None,
|
|
80
|
+
),
|
|
81
|
+
EnvironmentAuth(settings.get("auth")),
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
client = factory(settings=settings, auth=auth)
|
|
85
|
+
operation = mapping[args.command]
|
|
86
|
+
parameters: dict[str, Any] = {}
|
|
87
|
+
for index, parameter in enumerate(operation["parameters"]):
|
|
88
|
+
value = getattr(args, f"p{index}")
|
|
89
|
+
if value is not None:
|
|
90
|
+
parameters[parameter["name"]] = (
|
|
91
|
+
value if parameter["schema"]["type"] == "string" else json.loads(value)
|
|
92
|
+
)
|
|
93
|
+
body = getattr(args, "body", None)
|
|
94
|
+
result = client._execute(
|
|
95
|
+
operation["id"], parameters, json.loads(body) if body is not None else UNSET
|
|
96
|
+
)
|
|
97
|
+
print(
|
|
98
|
+
json.dumps(
|
|
99
|
+
result, indent=2 if args.pretty else None, ensure_ascii=True, allow_nan=False
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
return 0
|
|
103
|
+
except (CodaError, ValueError, OSError) as exc:
|
|
104
|
+
code = (
|
|
105
|
+
5
|
|
106
|
+
if isinstance(exc, ApiError)
|
|
107
|
+
else 3
|
|
108
|
+
if isinstance(exc, PolicyError)
|
|
109
|
+
else 4
|
|
110
|
+
if isinstance(exc, TransportError)
|
|
111
|
+
else 6
|
|
112
|
+
if isinstance(exc, ResponseError)
|
|
113
|
+
else 2
|
|
114
|
+
)
|
|
115
|
+
message = str(exc) if isinstance(exc, CodaError) else "invalid JSON input or output"
|
|
116
|
+
print(f"codapact: {message}", file=sys.stderr)
|
|
117
|
+
return code
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Data-only generation manifests, explicit overrides, and portable runtime profiles."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from jsonschema import Draft202012Validator
|
|
11
|
+
|
|
12
|
+
from codapact.errors import CodaError
|
|
13
|
+
from codapact.generation import package_name
|
|
14
|
+
from codapact.loading import load, parse
|
|
15
|
+
from codapact.net import validate_url
|
|
16
|
+
from codapact.values import default_slug, merge, slug_name
|
|
17
|
+
|
|
18
|
+
GENERATION_SCHEMA = json.loads(
|
|
19
|
+
files("codapact").joinpath("schemas/generation.v1.json").read_text("utf-8")
|
|
20
|
+
)
|
|
21
|
+
DEFAULTS: dict[str, Any] = {
|
|
22
|
+
"version": 1,
|
|
23
|
+
"contract": {"type": "openapi", "allow_private": False},
|
|
24
|
+
"output": {"directory": "generated"},
|
|
25
|
+
"client": {"language": "python"},
|
|
26
|
+
"cli": {"style": "argparse", "definition": None},
|
|
27
|
+
"runtime": {"config_directory": None, "defaults": {}},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def local_path(value: str, base: Path) -> Path:
|
|
32
|
+
if "://" in value:
|
|
33
|
+
raise CodaError("this generation path must be local")
|
|
34
|
+
path = Path(value).expanduser()
|
|
35
|
+
return path if path.is_absolute() else base / path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def runtime_directory(value: str | None) -> str | None:
|
|
39
|
+
"""Runtime directories are portable runtime values, never build-relative paths."""
|
|
40
|
+
if value is not None and not (
|
|
41
|
+
Path(value).is_absolute() or value == "~" or value.startswith("~/")
|
|
42
|
+
):
|
|
43
|
+
raise CodaError("runtime config directory must be absolute or start with ~/")
|
|
44
|
+
if value is not None and any(ord(character) < 32 for character in value):
|
|
45
|
+
raise CodaError("invalid runtime config directory")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def clean_defaults(value: dict[str, Any]) -> dict[str, Any]:
|
|
50
|
+
result = deepcopy(value)
|
|
51
|
+
if result.get("base_url") is None:
|
|
52
|
+
result.pop("base_url", None)
|
|
53
|
+
if result.get("policy", {}).get("allow", object()) is None:
|
|
54
|
+
result["policy"].pop("allow")
|
|
55
|
+
if "base_url" in result:
|
|
56
|
+
validate_url(result["base_url"], allow_query=False)
|
|
57
|
+
return result
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_generation(
|
|
61
|
+
manifest: str | None,
|
|
62
|
+
overrides: dict[str, Any],
|
|
63
|
+
settings: list[str] | None = None,
|
|
64
|
+
*,
|
|
65
|
+
cwd: Path | None = None,
|
|
66
|
+
) -> dict[str, Any]:
|
|
67
|
+
base = cwd or Path.cwd()
|
|
68
|
+
document: dict[str, Any] = {}
|
|
69
|
+
if manifest is not None:
|
|
70
|
+
path = local_path(manifest, base).absolute()
|
|
71
|
+
document = load(str(path))
|
|
72
|
+
base = path.parent
|
|
73
|
+
if type(document.get("version")) is not int or document["version"] != 1:
|
|
74
|
+
raise CodaError("generation manifest requires version: 1")
|
|
75
|
+
validator = Draft202012Validator(GENERATION_SCHEMA)
|
|
76
|
+
if not validator.is_valid(document):
|
|
77
|
+
raise CodaError("invalid generation manifest; unknown keys or unsupported values")
|
|
78
|
+
result = merge(merge(DEFAULTS, document), overrides)
|
|
79
|
+
for setting in settings or []:
|
|
80
|
+
key, separator, encoded = setting.partition("=")
|
|
81
|
+
if not separator or not re.fullmatch(r"[a-z_]+(?:\.[a-z_]+)*", key):
|
|
82
|
+
raise CodaError("--set requires a dotted manifest field and JSON value")
|
|
83
|
+
try:
|
|
84
|
+
value = parse(('{"value":' + encoded + "}").encode())["value"]
|
|
85
|
+
except CodaError:
|
|
86
|
+
raise CodaError("--set value must be valid JSON data") from None
|
|
87
|
+
# parse accepts YAML; --set intentionally accepts JSON only.
|
|
88
|
+
try:
|
|
89
|
+
json.loads(encoded)
|
|
90
|
+
except ValueError:
|
|
91
|
+
raise CodaError("--set value must be valid JSON data") from None
|
|
92
|
+
cursor = result
|
|
93
|
+
parts = key.split(".")
|
|
94
|
+
for part in parts[:-1]:
|
|
95
|
+
if not isinstance(cursor.get(part), dict):
|
|
96
|
+
cursor[part] = {}
|
|
97
|
+
cursor = cursor[part]
|
|
98
|
+
cursor[parts[-1]] = value
|
|
99
|
+
if not validator.is_valid(result):
|
|
100
|
+
raise CodaError("invalid generation overrides; unknown keys or unsupported values")
|
|
101
|
+
try:
|
|
102
|
+
source = result["contract"]["source"]
|
|
103
|
+
cli = result["cli"]
|
|
104
|
+
client = result["client"]
|
|
105
|
+
slug = slug_name(cli.get("slug", default_slug(client.get("package", "api"))))
|
|
106
|
+
package = package_name(client.get("package", slug.replace("-", "_") + "_client"))
|
|
107
|
+
if source.startswith(("http://", "https://")):
|
|
108
|
+
validate_url(source)
|
|
109
|
+
elif "://" in source:
|
|
110
|
+
raise CodaError("contract source must be a local path or HTTP(S) URL")
|
|
111
|
+
else:
|
|
112
|
+
source = str(local_path(source, base))
|
|
113
|
+
definition = cli.get("definition")
|
|
114
|
+
definition = load(str(local_path(definition, base))) if definition is not None else None
|
|
115
|
+
directory = runtime_directory(result["runtime"].get("config_directory"))
|
|
116
|
+
defaults = clean_defaults(result["runtime"].get("defaults", {}))
|
|
117
|
+
return {
|
|
118
|
+
"source": source,
|
|
119
|
+
"output": local_path(result["output"]["directory"], base),
|
|
120
|
+
"package": package,
|
|
121
|
+
"source_plugin": result["contract"]["type"],
|
|
122
|
+
"targets": (client["language"], cli["style"]),
|
|
123
|
+
"cli_definition": definition,
|
|
124
|
+
"allow_private": result["contract"]["allow_private"],
|
|
125
|
+
"profile": {
|
|
126
|
+
"version": 1,
|
|
127
|
+
"slug": slug,
|
|
128
|
+
"config_directory": directory,
|
|
129
|
+
"defaults": defaults,
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
except KeyError:
|
|
133
|
+
raise CodaError(
|
|
134
|
+
"generation requires contract.source and supported output/client/CLI fields"
|
|
135
|
+
) from None
|
codapact/errors.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Stable public errors with deliberately bounded diagnostics."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CodaError(Exception):
|
|
5
|
+
"""A safe user-facing configuration, validation, or generation error."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PolicyError(CodaError):
|
|
9
|
+
"""The configured client-side policy denied an operation."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AuthError(PolicyError):
|
|
13
|
+
"""Required credentials are unavailable or invalid."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TransportError(CodaError):
|
|
17
|
+
"""The bounded transport could not complete a request."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ResponseError(CodaError):
|
|
21
|
+
"""A response violated the operation contract."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiError(CodaError):
|
|
25
|
+
"""An HTTP error; the response body is never included in diagnostics."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, status: int, operation: str) -> None:
|
|
28
|
+
self.status = status
|
|
29
|
+
self.operation = operation
|
|
30
|
+
super().__init__(f"HTTP {status} for operation {operation}")
|
codapact/generation.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Deterministic Python and argparse generators; contract strings stay data."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import keyword
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from codapact import __version__
|
|
10
|
+
from codapact.errors import CodaError
|
|
11
|
+
from codapact.model import Contract
|
|
12
|
+
from codapact.values import slug_name
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def package_name(name: str) -> str:
|
|
16
|
+
if (
|
|
17
|
+
not re.fullmatch(r"[a-z][a-z0-9_]{0,49}", name)
|
|
18
|
+
or keyword.iskeyword(name)
|
|
19
|
+
or name in sys.stdlib_module_names
|
|
20
|
+
or name
|
|
21
|
+
in {
|
|
22
|
+
"codapact",
|
|
23
|
+
"yaml",
|
|
24
|
+
"jsonschema",
|
|
25
|
+
"referencing",
|
|
26
|
+
"rpds",
|
|
27
|
+
"attrs",
|
|
28
|
+
"attr",
|
|
29
|
+
"jsonschema_specifications",
|
|
30
|
+
"con",
|
|
31
|
+
"prn",
|
|
32
|
+
"aux",
|
|
33
|
+
"nul",
|
|
34
|
+
}
|
|
35
|
+
or re.fullmatch(r"(?:com|lpt)[0-9]", name)
|
|
36
|
+
):
|
|
37
|
+
raise CodaError("package must be a safe lowercase Python identifier")
|
|
38
|
+
return name
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def canonical(value: Any) -> str:
|
|
42
|
+
return json.dumps(value, sort_keys=True, indent=2, ensure_ascii=True, allow_nan=False) + "\n"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TypeEmitter:
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.lines: list[str] = []
|
|
48
|
+
|
|
49
|
+
def emit(self, schema: dict[str, Any], name: str) -> str:
|
|
50
|
+
kind = schema["type"]
|
|
51
|
+
nullable = isinstance(kind, list)
|
|
52
|
+
if nullable:
|
|
53
|
+
kind = next(value for value in kind if value != "null")
|
|
54
|
+
if kind == "object":
|
|
55
|
+
properties = schema.get("properties", {})
|
|
56
|
+
if properties:
|
|
57
|
+
fields = []
|
|
58
|
+
for index, (key, value) in enumerate(sorted(properties.items())):
|
|
59
|
+
annotation = self.emit(value, f"{name}Field{index}")
|
|
60
|
+
if key not in schema.get("required", []):
|
|
61
|
+
annotation = f"NotRequired[{annotation}]"
|
|
62
|
+
fields.append(f"{key!r}: {annotation}")
|
|
63
|
+
self.lines.append(f"{name} = TypedDict({name!r}, {{{', '.join(fields)}}})")
|
|
64
|
+
result = name
|
|
65
|
+
else:
|
|
66
|
+
additional = schema.get("additionalProperties", True)
|
|
67
|
+
item = (
|
|
68
|
+
self.emit(additional, name + "Value") if isinstance(additional, dict) else "Any"
|
|
69
|
+
)
|
|
70
|
+
result = f"dict[str, {item}]"
|
|
71
|
+
elif kind == "array":
|
|
72
|
+
result = f"list[{self.emit(schema['items'], name + 'Item')}]"
|
|
73
|
+
else:
|
|
74
|
+
result = {"string": "str", "integer": "int", "number": "float", "boolean": "bool"}[kind]
|
|
75
|
+
return result + " | None" if nullable else result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class PythonGenerator:
|
|
79
|
+
api_version = 1
|
|
80
|
+
requires = frozenset({"json-http-v1", "ir-v1"})
|
|
81
|
+
|
|
82
|
+
def generate(self, contract: Contract, package: str) -> dict[str, str]:
|
|
83
|
+
package = package_name(package)
|
|
84
|
+
command_name = package.replace("_", "-")
|
|
85
|
+
if "codapact.cli" in contract.metadata:
|
|
86
|
+
value = contract.metadata["codapact.cli"]
|
|
87
|
+
if not isinstance(value, str):
|
|
88
|
+
raise CodaError("invalid CLI command metadata")
|
|
89
|
+
command_name = slug_name(value)
|
|
90
|
+
if "codapact.runtime" in contract.metadata:
|
|
91
|
+
from codapact.profiles import validate_profile
|
|
92
|
+
|
|
93
|
+
validate_profile(contract.metadata["codapact.runtime"])
|
|
94
|
+
emitter = TypeEmitter()
|
|
95
|
+
methods = []
|
|
96
|
+
for operation in contract.operations:
|
|
97
|
+
prefix = f"Op_{operation.id}"
|
|
98
|
+
parameters = emitter.emit(
|
|
99
|
+
{
|
|
100
|
+
"type": "object",
|
|
101
|
+
"properties": {
|
|
102
|
+
parameter["name"]: parameter["schema"] for parameter in operation.parameters
|
|
103
|
+
},
|
|
104
|
+
"required": [p["name"] for p in operation.parameters if p["required"]],
|
|
105
|
+
},
|
|
106
|
+
prefix + "Params",
|
|
107
|
+
)
|
|
108
|
+
body = (
|
|
109
|
+
emitter.emit(operation.body, prefix + "Body")
|
|
110
|
+
if operation.body is not None
|
|
111
|
+
else "None"
|
|
112
|
+
)
|
|
113
|
+
responses = []
|
|
114
|
+
for status, schema in sorted(operation.responses.items()):
|
|
115
|
+
if status.startswith("2"):
|
|
116
|
+
responses.append(
|
|
117
|
+
emitter.emit(schema, prefix + "Response" + status)
|
|
118
|
+
if schema is not None
|
|
119
|
+
else "None"
|
|
120
|
+
)
|
|
121
|
+
response = " | ".join(dict.fromkeys(responses))
|
|
122
|
+
parameter_arg = (
|
|
123
|
+
f"parameters: {parameters}"
|
|
124
|
+
if any(p["required"] for p in operation.parameters)
|
|
125
|
+
else f"parameters: {parameters} | None = None"
|
|
126
|
+
)
|
|
127
|
+
body_arg = (
|
|
128
|
+
f", body: {body}"
|
|
129
|
+
if operation.body_required
|
|
130
|
+
else f", body: {body} | Unset = UNSET"
|
|
131
|
+
if operation.body is not None
|
|
132
|
+
else ""
|
|
133
|
+
)
|
|
134
|
+
body_value = "body" if operation.body is not None else "UNSET"
|
|
135
|
+
methods.append(
|
|
136
|
+
f" def {operation.id}(self, *, {parameter_arg}{body_arg}) -> {response}:\n"
|
|
137
|
+
f" return cast({response!r}, self._execute(\n"
|
|
138
|
+
f" {operation.id!r}, parameters, {body_value}))\n"
|
|
139
|
+
)
|
|
140
|
+
source = (
|
|
141
|
+
"from __future__ import annotations\n\n"
|
|
142
|
+
"import json\nfrom importlib.resources import files\n"
|
|
143
|
+
"from typing import Any, NotRequired, TypedDict, cast\n"
|
|
144
|
+
"from codapact.runtime import ClientBase, UNSET, Unset\n\n"
|
|
145
|
+
+ "\n".join(emitter.lines)
|
|
146
|
+
+ "\n\n"
|
|
147
|
+
"DOCUMENT = json.loads(\n"
|
|
148
|
+
" files(__package__).joinpath('contract.json').read_text('utf-8'))\n\n"
|
|
149
|
+
"class Client(ClientBase):\n"
|
|
150
|
+
" def __init__(self, **kwargs: Any) -> None:\n"
|
|
151
|
+
" super().__init__(DOCUMENT, **kwargs)\n\n" + "\n".join(methods)
|
|
152
|
+
)
|
|
153
|
+
return {
|
|
154
|
+
f"src/{package}/client.py": source,
|
|
155
|
+
f"src/{package}/__init__.py": "from .client import Client\n\n__all__ = ['Client']\n",
|
|
156
|
+
f"src/{package}/contract.json": canonical(contract.to_dict()),
|
|
157
|
+
f"src/{package}/py.typed": "",
|
|
158
|
+
"pyproject.toml": f'''[project]
|
|
159
|
+
name = "{package.replace("_", "-")}"
|
|
160
|
+
version = "0.1.0"
|
|
161
|
+
requires-python = ">=3.13"
|
|
162
|
+
dependencies = ["codapact=={__version__}"]
|
|
163
|
+
|
|
164
|
+
[project.scripts]
|
|
165
|
+
{command_name} = "{package}.__main__:main"
|
|
166
|
+
|
|
167
|
+
[build-system]
|
|
168
|
+
requires = ["uv_build>=0.11.1,<0.13"]
|
|
169
|
+
build-backend = "uv_build"
|
|
170
|
+
''',
|
|
171
|
+
".python-version": "3.13\n",
|
|
172
|
+
"README.md": (
|
|
173
|
+
"# Generated API client\n\nGenerated with CodaPact. "
|
|
174
|
+
"Install the matching CodaPact runtime first, then `uv pip install .`.\n\n"
|
|
175
|
+
f"Run `{command_name} --help` or import `{package}.Client`.\n"
|
|
176
|
+
"Runtime config accepts environment references; never embed credentials.\n"
|
|
177
|
+
),
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ArgparseGenerator:
|
|
182
|
+
api_version = 1
|
|
183
|
+
requires = frozenset({"json-http-v1", "ir-v1"})
|
|
184
|
+
|
|
185
|
+
def generate(self, contract: Contract, package: str) -> dict[str, str]:
|
|
186
|
+
return self.generate_with_definition(contract, package, None)
|
|
187
|
+
|
|
188
|
+
def generate_with_definition(
|
|
189
|
+
self, contract: Contract, package: str, definition: dict[str, Any] | None
|
|
190
|
+
) -> dict[str, str]:
|
|
191
|
+
package = package_name(package)
|
|
192
|
+
definition = (
|
|
193
|
+
{
|
|
194
|
+
"version": 1,
|
|
195
|
+
"commands": [
|
|
196
|
+
{"name": op.id.replace("_", "-"), "operation": op.id}
|
|
197
|
+
for op in contract.operations
|
|
198
|
+
],
|
|
199
|
+
}
|
|
200
|
+
if definition is None
|
|
201
|
+
else definition
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
if (
|
|
205
|
+
set(definition) != {"version", "commands"}
|
|
206
|
+
or type(definition["version"]) is not int
|
|
207
|
+
or definition["version"] != 1
|
|
208
|
+
):
|
|
209
|
+
raise CodaError("unsupported CLI definition schema")
|
|
210
|
+
names = set()
|
|
211
|
+
operations = {op.id for op in contract.operations}
|
|
212
|
+
for command in definition["commands"]:
|
|
213
|
+
if (
|
|
214
|
+
set(command) != {"name", "operation"}
|
|
215
|
+
or not re.fullmatch(r"[a-z][a-z0-9-]{0,79}", command["name"])
|
|
216
|
+
or command["name"] in names
|
|
217
|
+
or command["operation"] not in operations
|
|
218
|
+
):
|
|
219
|
+
raise CodaError("invalid or duplicate CLI command")
|
|
220
|
+
names.add(command["name"])
|
|
221
|
+
if {c["operation"] for c in definition["commands"]} != operations:
|
|
222
|
+
raise CodaError("CLI definition must cover every operation")
|
|
223
|
+
for operation in contract.operations:
|
|
224
|
+
flags = [p["name"].replace("_", "-").lower() for p in operation.parameters]
|
|
225
|
+
if len(set(flags)) != len(flags):
|
|
226
|
+
raise CodaError("CLI parameter flags collide after normalization")
|
|
227
|
+
except (TypeError, KeyError, AttributeError):
|
|
228
|
+
raise CodaError("malformed CLI definition") from None
|
|
229
|
+
source = (
|
|
230
|
+
"import json\nfrom importlib.resources import files\n"
|
|
231
|
+
"from codapact.cli import run_cli\n"
|
|
232
|
+
"from .client import Client, DOCUMENT\n\n"
|
|
233
|
+
"def main(argv=None):\n"
|
|
234
|
+
" definition = json.loads(\n"
|
|
235
|
+
" files(__package__).joinpath('cli.json').read_text('utf-8'))\n"
|
|
236
|
+
" return run_cli(Client, DOCUMENT, definition, argv,\n"
|
|
237
|
+
" profile=DOCUMENT.get('metadata', {}).get('codapact.runtime'))\n\n"
|
|
238
|
+
"if __name__ == '__main__':\n raise SystemExit(main())\n"
|
|
239
|
+
)
|
|
240
|
+
return {
|
|
241
|
+
f"src/{package}/__main__.py": source,
|
|
242
|
+
f"src/{package}/cli.json": canonical(definition),
|
|
243
|
+
}
|