carrier-python 0.1.15__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.
- carrier/__init__.py +52 -0
- carrier/adapters.py +121 -0
- carrier/catalog.py +108 -0
- carrier/cli.py +70 -0
- carrier/client.py +63 -0
- carrier/data/cli.json +3032 -0
- carrier/data/operations.json +3042 -0
- carrier/errors.py +51 -0
- carrier/http.py +135 -0
- carrier/idempotency.py +46 -0
- carrier/mcp.py +237 -0
- carrier/models.py +213 -0
- carrier/operation_ids.py +188 -0
- carrier/operations.py +192 -0
- carrier/py.typed +0 -0
- carrier/records.py +117 -0
- carrier/resources.py +201 -0
- carrier/signature.py +57 -0
- carrier/version.py +3 -0
- carrier_python-0.1.15.dist-info/METADATA +87 -0
- carrier_python-0.1.15.dist-info/RECORD +23 -0
- carrier_python-0.1.15.dist-info/WHEEL +5 -0
- carrier_python-0.1.15.dist-info/top_level.txt +1 -0
carrier/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Python client for the Carrier API, MCP server, and CLI."""
|
|
2
|
+
|
|
3
|
+
from carrier.adapters import cli_tools, mcp_tools, rest_tools
|
|
4
|
+
from carrier.catalog import cli_commands, operations
|
|
5
|
+
from carrier.cli import Cli, CliResult
|
|
6
|
+
from carrier.client import Carrier
|
|
7
|
+
from carrier.errors import CarrierError
|
|
8
|
+
from carrier.mcp import DEFAULT_MCP_URL, Mcp
|
|
9
|
+
from carrier.operation_ids import OperationId
|
|
10
|
+
from carrier.models import (
|
|
11
|
+
ConnectivityEsim,
|
|
12
|
+
ConnectivityIntent,
|
|
13
|
+
ConnectivityPlan,
|
|
14
|
+
IntentError,
|
|
15
|
+
PlanPrice,
|
|
16
|
+
)
|
|
17
|
+
from carrier.records import (
|
|
18
|
+
ConnectivityEvent,
|
|
19
|
+
ConnectivityUsageRecord,
|
|
20
|
+
DeletedWebhookEndpoint,
|
|
21
|
+
WebhookEndpoint,
|
|
22
|
+
WebhookEndpointCreated,
|
|
23
|
+
)
|
|
24
|
+
from carrier.signature import verify_webhook_signature
|
|
25
|
+
from carrier.version import __version__
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"DEFAULT_MCP_URL",
|
|
29
|
+
"Carrier",
|
|
30
|
+
"CarrierError",
|
|
31
|
+
"Cli",
|
|
32
|
+
"CliResult",
|
|
33
|
+
"Mcp",
|
|
34
|
+
"OperationId",
|
|
35
|
+
"ConnectivityEsim",
|
|
36
|
+
"ConnectivityEvent",
|
|
37
|
+
"ConnectivityIntent",
|
|
38
|
+
"ConnectivityPlan",
|
|
39
|
+
"ConnectivityUsageRecord",
|
|
40
|
+
"DeletedWebhookEndpoint",
|
|
41
|
+
"IntentError",
|
|
42
|
+
"PlanPrice",
|
|
43
|
+
"WebhookEndpoint",
|
|
44
|
+
"WebhookEndpointCreated",
|
|
45
|
+
"__version__",
|
|
46
|
+
"cli_commands",
|
|
47
|
+
"cli_tools",
|
|
48
|
+
"mcp_tools",
|
|
49
|
+
"operations",
|
|
50
|
+
"rest_tools",
|
|
51
|
+
"verify_webhook_signature",
|
|
52
|
+
]
|
carrier/adapters.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Dependency-free tool definitions for LangGraph, OpenAI, and the Vercel AI SDK.
|
|
2
|
+
|
|
3
|
+
Each tool is a plain dict with `name`, `description`, `inputSchema`, and
|
|
4
|
+
`execute`. Pass `inputSchema` to an AI SDK `jsonSchema()` helper, or use the
|
|
5
|
+
dict as a LangGraph / OpenAI function tool. Nothing in this module imports
|
|
6
|
+
those libraries.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from typing import TypedDict
|
|
13
|
+
|
|
14
|
+
from carrier.catalog import Operation, cli_commands, operations
|
|
15
|
+
from carrier.cli import Cli, CliResult
|
|
16
|
+
from carrier.client import Carrier
|
|
17
|
+
from carrier.errors import CarrierError
|
|
18
|
+
from carrier.mcp import Mcp
|
|
19
|
+
from carrier.operations import arguments_to_call, input_schema
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Tool(TypedDict):
|
|
23
|
+
"""One callable tool definition."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
description: str
|
|
27
|
+
inputSchema: dict[str, object]
|
|
28
|
+
execute: object
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def rest_tools(client: Carrier) -> list[Tool]:
|
|
32
|
+
"""One tool per OpenAPI operation. `execute` calls `client.call`."""
|
|
33
|
+
tools: list[Tool] = []
|
|
34
|
+
for spec in operations():
|
|
35
|
+
|
|
36
|
+
def execute(arguments: Mapping[str, object], spec: Operation = spec) -> object:
|
|
37
|
+
prepared = arguments_to_call(spec, arguments)
|
|
38
|
+
return client.call(spec["id"], **_call_kwargs(prepared))
|
|
39
|
+
|
|
40
|
+
tools.append(
|
|
41
|
+
{
|
|
42
|
+
"name": spec["id"],
|
|
43
|
+
"description": f"{spec['summary']} ({spec['method']} {spec['path']}, {spec['security']})",
|
|
44
|
+
"inputSchema": input_schema(spec),
|
|
45
|
+
"execute": execute,
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
return tools
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def mcp_tools(session: Mcp) -> list[Tool]:
|
|
52
|
+
"""One tool per MCP tool advertised by `session.list_tools()`."""
|
|
53
|
+
tools: list[Tool] = []
|
|
54
|
+
for advertised in session.list_tools():
|
|
55
|
+
if not isinstance(advertised, dict) or not isinstance(advertised.get("name"), str):
|
|
56
|
+
continue
|
|
57
|
+
name = advertised["name"]
|
|
58
|
+
description = advertised.get("description")
|
|
59
|
+
schema = advertised.get("inputSchema")
|
|
60
|
+
input_schema_value = schema if isinstance(schema, dict) else {"type": "object", "properties": {}}
|
|
61
|
+
|
|
62
|
+
def execute(arguments: Mapping[str, object], tool_name: str = name) -> dict[str, object]:
|
|
63
|
+
return session.call_tool(tool_name, arguments)
|
|
64
|
+
|
|
65
|
+
tools.append(
|
|
66
|
+
{
|
|
67
|
+
"name": name,
|
|
68
|
+
"description": description if isinstance(description, str) and description else name,
|
|
69
|
+
"inputSchema": input_schema_value,
|
|
70
|
+
"execute": execute,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
return tools
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cli_tools(cli: Cli) -> list[Tool]:
|
|
77
|
+
"""One tool per catalogued CLI command. Extra flags go in `args`."""
|
|
78
|
+
tools: list[Tool] = []
|
|
79
|
+
for command in cli_commands():
|
|
80
|
+
tokens = command["invocation"].split()[1:]
|
|
81
|
+
option_text = ", ".join(option["flags"] for option in command["options"]) or "no flags"
|
|
82
|
+
description = f"{command['summary']} Flags: {option_text}."
|
|
83
|
+
|
|
84
|
+
def execute(arguments: Mapping[str, object], tokens: list[str] = tokens) -> CliResult:
|
|
85
|
+
extra = arguments.get("args", [])
|
|
86
|
+
if extra is None:
|
|
87
|
+
extra = []
|
|
88
|
+
if not isinstance(extra, list) or not all(isinstance(item, str) for item in extra):
|
|
89
|
+
raise CarrierError(400, "invalid_argument", "CLI tool args must be a list of strings.")
|
|
90
|
+
return cli.run([*tokens, *extra])
|
|
91
|
+
|
|
92
|
+
tools.append(
|
|
93
|
+
{
|
|
94
|
+
"name": "cli_" + "_".join(tokens),
|
|
95
|
+
"description": description,
|
|
96
|
+
"inputSchema": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {
|
|
99
|
+
"args": {
|
|
100
|
+
"type": "array",
|
|
101
|
+
"items": {"type": "string"},
|
|
102
|
+
"description": "Flags and values after the command name.",
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"additionalProperties": False,
|
|
106
|
+
},
|
|
107
|
+
"execute": execute,
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
return tools
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _call_kwargs(prepared: Mapping[str, object]) -> dict[str, object]:
|
|
114
|
+
kwargs: dict[str, object] = {}
|
|
115
|
+
for key in ("path", "query", "headers", "body", "idempotency_key"):
|
|
116
|
+
if key in prepared:
|
|
117
|
+
kwargs[key] = prepared[key]
|
|
118
|
+
return kwargs
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
__all__ = ["Tool", "cli_tools", "mcp_tools", "rest_tools"]
|
carrier/catalog.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Operation and CLI catalogs generated from the public contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from typing import TypedDict, cast
|
|
9
|
+
|
|
10
|
+
from carrier.errors import CarrierError
|
|
11
|
+
from carrier.operation_ids import OperationId
|
|
12
|
+
|
|
13
|
+
HEADER_FIELDS = {
|
|
14
|
+
"Idempotency-Key": "idempotency_key",
|
|
15
|
+
"X-Idempotency-Key": "idempotency_key",
|
|
16
|
+
"X-Confirm-Token": "confirm_token",
|
|
17
|
+
"stripe-signature": "stripe_signature",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Param(TypedDict):
|
|
22
|
+
"""One path, query, or header parameter."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
required: bool
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Operation(TypedDict):
|
|
29
|
+
"""One OpenAPI operation the generic caller can invoke."""
|
|
30
|
+
|
|
31
|
+
id: OperationId
|
|
32
|
+
method: str
|
|
33
|
+
path: str
|
|
34
|
+
tag: str
|
|
35
|
+
summary: str
|
|
36
|
+
security: str
|
|
37
|
+
body: bool
|
|
38
|
+
bodyRequired: bool
|
|
39
|
+
pathParams: list[Param]
|
|
40
|
+
queryParams: list[Param]
|
|
41
|
+
headerParams: list[Param]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CliOption(TypedDict):
|
|
45
|
+
"""One flag on a carrier CLI command."""
|
|
46
|
+
|
|
47
|
+
flags: str
|
|
48
|
+
description: str
|
|
49
|
+
required: bool
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CliCommand(TypedDict):
|
|
53
|
+
"""One command from the published CLI catalog."""
|
|
54
|
+
|
|
55
|
+
invocation: str
|
|
56
|
+
domain: str
|
|
57
|
+
summary: str
|
|
58
|
+
write: bool
|
|
59
|
+
transport: str
|
|
60
|
+
tool: str | None
|
|
61
|
+
restMethod: str | None
|
|
62
|
+
restPath: str | None
|
|
63
|
+
options: list[CliOption]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@lru_cache(maxsize=1)
|
|
67
|
+
def operations() -> tuple[Operation, ...]:
|
|
68
|
+
"""Every operation in the public OpenAPI document, in spec order."""
|
|
69
|
+
return tuple(cast(list[Operation], _load("operations.json")))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@lru_cache(maxsize=1)
|
|
73
|
+
def cli_commands() -> tuple[CliCommand, ...]:
|
|
74
|
+
"""Every command in the published CLI catalog."""
|
|
75
|
+
return tuple(cast(list[CliCommand], _load("cli.json")))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def operation(operation_id: str) -> Operation:
|
|
79
|
+
"""Return one operation, or raise when the id is not in the catalog."""
|
|
80
|
+
found = _operation_index().get(operation_id)
|
|
81
|
+
if found is None:
|
|
82
|
+
raise CarrierError(400, "unknown_operation", f"Unknown operation {operation_id}.")
|
|
83
|
+
return found
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def command_for_args(args: list[str]) -> CliCommand | None:
|
|
87
|
+
"""Match the longest catalog command that is a prefix of args."""
|
|
88
|
+
best: CliCommand | None = None
|
|
89
|
+
best_len = -1
|
|
90
|
+
for command in cli_commands():
|
|
91
|
+
tokens = command["invocation"].split()[1:]
|
|
92
|
+
if args[: len(tokens)] == tokens and len(tokens) > best_len:
|
|
93
|
+
best = command
|
|
94
|
+
best_len = len(tokens)
|
|
95
|
+
return best
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@lru_cache(maxsize=1)
|
|
99
|
+
def _operation_index() -> dict[str, Operation]:
|
|
100
|
+
return {item["id"]: item for item in operations()}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _load(name: str) -> list[object]:
|
|
104
|
+
raw = files("carrier").joinpath("data", name).read_text(encoding="utf-8")
|
|
105
|
+
parsed = json.loads(raw)
|
|
106
|
+
if not isinstance(parsed, list):
|
|
107
|
+
raise CarrierError(500, "invalid_catalog", f"{name} is not a list.")
|
|
108
|
+
return cast(list[object], parsed)
|
carrier/cli.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Runner for the carrier CLI. Arguments are passed as a list, never through a shell."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from carrier.catalog import CliCommand, cli_commands, command_for_args
|
|
10
|
+
from carrier.errors import CarrierError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CliResult:
|
|
15
|
+
"""Stdout and stderr from one carrier invocation."""
|
|
16
|
+
|
|
17
|
+
code: int
|
|
18
|
+
stdout: str
|
|
19
|
+
stderr: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
Runner = Callable[[str, list[str]], CliResult]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Cli:
|
|
26
|
+
"""Run catalogued `carrier` commands."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, *, binary: str = "carrier", runner: Runner | None = None) -> None:
|
|
29
|
+
if binary.strip() == "" or "\x00" in binary:
|
|
30
|
+
raise CarrierError(400, "invalid_cli", "The carrier binary path is not valid.")
|
|
31
|
+
self.binary = binary
|
|
32
|
+
self._runner = runner if runner is not None else _subprocess_runner
|
|
33
|
+
|
|
34
|
+
def commands(self) -> tuple[CliCommand, ...]:
|
|
35
|
+
"""Every command in the published catalog."""
|
|
36
|
+
return cli_commands()
|
|
37
|
+
|
|
38
|
+
def describe(self, invocation: str) -> CliCommand:
|
|
39
|
+
"""Return one catalog entry, such as `carrier subscribers list`."""
|
|
40
|
+
for command in cli_commands():
|
|
41
|
+
if command["invocation"] == invocation:
|
|
42
|
+
return command
|
|
43
|
+
raise CarrierError(400, "unknown_command", f"Unknown command {invocation}.")
|
|
44
|
+
|
|
45
|
+
def run(self, args: Sequence[str], *, strict: bool = True) -> CliResult:
|
|
46
|
+
"""Run `carrier` with args. Strict mode rejects commands missing from the catalog."""
|
|
47
|
+
argv = _validate_args(args)
|
|
48
|
+
if strict and command_for_args(argv) is None:
|
|
49
|
+
joined = " ".join(argv) if argv else "(empty)"
|
|
50
|
+
raise CarrierError(400, "unknown_command", f"Unknown command carrier {joined}.")
|
|
51
|
+
return self._runner(self.binary, argv)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_args(args: Sequence[str]) -> list[str]:
|
|
55
|
+
argv: list[str] = []
|
|
56
|
+
for arg in args:
|
|
57
|
+
if not isinstance(arg, str) or "\x00" in arg:
|
|
58
|
+
raise CarrierError(400, "invalid_argument", "CLI arguments must be strings without NUL bytes.")
|
|
59
|
+
argv.append(arg)
|
|
60
|
+
return argv
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _subprocess_runner(binary: str, args: list[str]) -> CliResult:
|
|
64
|
+
try:
|
|
65
|
+
completed = subprocess.run([binary, *args], capture_output=True, text=True, check=False)
|
|
66
|
+
except FileNotFoundError as error:
|
|
67
|
+
raise CarrierError(0, "cli_not_found", f"Could not run {binary}.") from error
|
|
68
|
+
except OSError as error:
|
|
69
|
+
raise CarrierError(0, "cli_not_found", str(error) or f"Could not run {binary}.") from error
|
|
70
|
+
return CliResult(completed.returncode, completed.stdout, completed.stderr)
|
carrier/client.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Typed client for the Carrier connectivity API."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
|
|
5
|
+
from carrier.errors import CarrierError
|
|
6
|
+
from carrier.http import DEFAULT_BASE_URL, Http, Transport, urllib_transport
|
|
7
|
+
from carrier.operation_ids import OperationId
|
|
8
|
+
from carrier.operations import call_operation
|
|
9
|
+
from carrier.resources import Events, Intents, Plans, Products, UsageRecords, WebhookEndpoints
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Carrier:
|
|
13
|
+
"""Client for one Carrier API key.
|
|
14
|
+
|
|
15
|
+
`intents.create` issues one eSIM. `call` reaches every public OpenAPI
|
|
16
|
+
operation id. The price on a product is catalogue data. Neither the intent
|
|
17
|
+
nor the product call debits a wallet.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
api_key: str,
|
|
23
|
+
*,
|
|
24
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
25
|
+
transport: Transport | None = None,
|
|
26
|
+
timeout: float = 30,
|
|
27
|
+
) -> None:
|
|
28
|
+
if api_key.strip() == "":
|
|
29
|
+
raise CarrierError(400, "invalid_api_key", "A Carrier API key is required.")
|
|
30
|
+
http = Http(
|
|
31
|
+
api_key,
|
|
32
|
+
base_url or DEFAULT_BASE_URL,
|
|
33
|
+
transport if transport is not None else urllib_transport(timeout),
|
|
34
|
+
timeout,
|
|
35
|
+
)
|
|
36
|
+
self._http = http
|
|
37
|
+
self.intents = Intents(http)
|
|
38
|
+
self.products = Products(http)
|
|
39
|
+
self.plans = Plans(http)
|
|
40
|
+
self.webhook_endpoints = WebhookEndpoints(http)
|
|
41
|
+
self.events = Events(http)
|
|
42
|
+
self.usage_records = UsageRecords(http)
|
|
43
|
+
|
|
44
|
+
def call(
|
|
45
|
+
self,
|
|
46
|
+
operation_id: OperationId,
|
|
47
|
+
*,
|
|
48
|
+
path: Mapping[str, object] | None = None,
|
|
49
|
+
query: Mapping[str, object] | None = None,
|
|
50
|
+
headers: Mapping[str, object] | None = None,
|
|
51
|
+
body: object | None = None,
|
|
52
|
+
idempotency_key: str | None = None,
|
|
53
|
+
) -> object:
|
|
54
|
+
"""Call any operation id from the public OpenAPI document."""
|
|
55
|
+
return call_operation(
|
|
56
|
+
self._http,
|
|
57
|
+
operation_id,
|
|
58
|
+
path=path,
|
|
59
|
+
query=query,
|
|
60
|
+
headers=headers,
|
|
61
|
+
body=body,
|
|
62
|
+
idempotency_key=idempotency_key,
|
|
63
|
+
)
|