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 ADDED
@@ -0,0 +1,3 @@
1
+ """CodaPact: contracts into callable tools."""
2
+
3
+ __version__ = "0.1.0" # x-release-please-version
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
+ }