instabox 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.
instabox/__init__.py ADDED
@@ -0,0 +1,84 @@
1
+ """Python SDK for the InstaBox API (https://instabox.sh).
2
+
3
+ InstaBox gives every agent a Linux machine of its own in the cloud.
4
+ This SDK wraps the REST API: create, start, stop, pause and resume machines,
5
+ run commands, read and write files, take snapshots, publish ports and track
6
+ async operations.
7
+
8
+ Quick start::
9
+
10
+ from instabox import Client
11
+
12
+ client = Client(api_key="ibk_...")
13
+ op = client.machines.create(name="dev")
14
+ op = client.operations.wait(op.id)
15
+ result = client.machines.exec("dev", cmd="echo hello")
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from ._policy import ToolPolicy
21
+ from .async_client import AsyncClient
22
+ from .async_machine import AsyncMachineHandle
23
+ from .client import Client
24
+ from .exceptions import (
25
+ APIError,
26
+ AuthenticationError,
27
+ BadRequestError,
28
+ ConflictError,
29
+ InstaboxError,
30
+ NotFoundError,
31
+ PaymentRequiredError,
32
+ PermissionDeniedError,
33
+ RateLimitError,
34
+ ServerError,
35
+ TimeoutExceededError,
36
+ )
37
+ from .machine import MachineHandle
38
+ from .models import (
39
+ CurrentUser,
40
+ ExecResult,
41
+ FileEntry,
42
+ FileListing,
43
+ Machine,
44
+ Operation,
45
+ Port,
46
+ Snapshot,
47
+ Spec,
48
+ Template,
49
+ UploadUrl,
50
+ Usage,
51
+ )
52
+
53
+ __version__ = "0.1.0"
54
+ __all__ = [
55
+ "APIError",
56
+ "AsyncClient",
57
+ "AsyncMachineHandle",
58
+ "AuthenticationError",
59
+ "BadRequestError",
60
+ "Client",
61
+ "ConflictError",
62
+ "CurrentUser",
63
+ "ExecResult",
64
+ "FileEntry",
65
+ "FileListing",
66
+ "InstaboxError",
67
+ "Machine",
68
+ "MachineHandle",
69
+ "NotFoundError",
70
+ "Operation",
71
+ "PaymentRequiredError",
72
+ "PermissionDeniedError",
73
+ "Port",
74
+ "RateLimitError",
75
+ "ServerError",
76
+ "Snapshot",
77
+ "Spec",
78
+ "Template",
79
+ "TimeoutExceededError",
80
+ "ToolPolicy",
81
+ "UploadUrl",
82
+ "Usage",
83
+ "__version__",
84
+ ]
instabox/_base.py ADDED
@@ -0,0 +1,67 @@
1
+ """Shared internals: headers, options and small helpers used by both clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typing as t
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ DEFAULT_BASE_URL = "https://api.instabox.sh"
11
+ LONG_POLL_SECONDS = "25s" # max allowed by GET /v1/operations?wait=
12
+ UPLOAD_LIMIT_BYTES = 5 * 1024 * 1024 * 1024 # 5 GiB
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class ClientOptions:
17
+ """Common configuration shared by the sync and async clients."""
18
+
19
+ api_key: str
20
+ base_url: str = DEFAULT_BASE_URL
21
+ timeout: float = 60.0
22
+ default_headers: dict[str, str] | None = None
23
+
24
+
25
+ def new_idempotency_key() -> str:
26
+ """Generate a fresh idempotency key (any unique string works)."""
27
+ return str(uuid.uuid4())
28
+
29
+
30
+ def build_headers(api_key: str, extra: dict[str, str] | None = None) -> dict[str, str]:
31
+ """Build authentication and content headers for a request."""
32
+ headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
33
+ if extra:
34
+ headers.update(extra)
35
+ return headers
36
+
37
+
38
+ def json_body(data: dict[str, Any] | None) -> dict[str, Any]:
39
+ """Drop ``None`` values from a request body so omitted fields stay unchanged."""
40
+ return {k: v for k, v in (data or {}).items() if v is not None}
41
+
42
+
43
+ def parse_sse(lines: t.Iterable[str]) -> t.Iterator[tuple[str, str]]:
44
+ """Parse a ``text/event-stream`` into ``(event, data)`` pairs.
45
+
46
+ Handles multi-line ``data:`` payloads (joined with newlines), comments and
47
+ blank-line event separators, per the SSE specification.
48
+ """
49
+ event = ""
50
+ data: list[str] = []
51
+ for line in lines:
52
+ line = line.rstrip("\r")
53
+ if not line:
54
+ if data:
55
+ yield event or "message", "\n".join(data)
56
+ event, data = "", []
57
+ continue
58
+ if line.startswith(":"):
59
+ continue # comment
60
+ field, _, value = line.partition(":")
61
+ value = value.lstrip(" ")
62
+ if field == "event":
63
+ event = value
64
+ elif field == "data":
65
+ data.append(value)
66
+ if data:
67
+ yield event or "message", "\n".join(data)
instabox/_policy.py ADDED
@@ -0,0 +1,142 @@
1
+ """SDK-level tool policy: restrict which operations a client may perform.
2
+
3
+ Two layers of access control exist for InstaBox:
4
+
5
+ 1. **API key scopes** (server-side, authoritative): keys carry scopes such as
6
+ ``machines:read``, ``machines:write``, ``machines:delete``, ``images:read``
7
+ and ``workspace:admin``. A request outside a key's scopes fails with
8
+ ``403 missing_scope``. Create scoped keys in the dashboard or with
9
+ ``instabox keys create --scope ...``.
10
+
11
+ 2. **SDK tool policy** (client-side, this module): an allow/deny list of
12
+ operation names such as ``machines.create`` or ``machines.exec``. Use it
13
+ when one code path (e.g. an AI agent) shares a broader key but must not
14
+ touch certain operations — the SDK raises
15
+ :class:`instabox.exceptions.PermissionDeniedError` before any network
16
+ request is made.
17
+
18
+ Operation names follow ``<resource>.<method>``: ``machines.*``,
19
+ ``operations.*``, ``snapshots.*``, ``ports.*``, ``account.*``, plus
20
+ ``machines.ssh_tokens.*``, ``machines.secrets.*`` and ``orgs.secrets.*``
21
+ aliases for the nested resources.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import builtins
27
+ import inspect
28
+ from collections.abc import Callable
29
+ from typing import Any
30
+
31
+ from .exceptions import PermissionDeniedError
32
+
33
+ # Nested namespaces that are checked as their own prefix, in addition to the
34
+ # resource class name under which they live.
35
+ _ALIASES = {
36
+ "SshTokens": "machines.ssh_tokens",
37
+ "MachineSecrets": "machines.secrets",
38
+ "OrgSecrets": "orgs.secrets",
39
+ }
40
+
41
+ _POLICY_ATTR = "_instabox_policy_prefix"
42
+
43
+
44
+ def operation_name(resource_class_name: str, method_name: str) -> str:
45
+ """Return the dotted operation name for a resource method."""
46
+ base = resource_class_name.lstrip("_")
47
+ if base.startswith("Async"):
48
+ base = base[len("Async") :]
49
+ prefix = _ALIASES.get(resource_class_name, base.lower())
50
+ return f"{prefix}.{method_name}"
51
+
52
+
53
+ def check_allowed(policy: ToolPolicy | None, name: str) -> None:
54
+ """Raise :class:`PermissionDeniedError` if the policy disallows ``name``."""
55
+ if policy is not None and not policy.allows(name):
56
+ raise PermissionDeniedError(
57
+ status=403,
58
+ code="sdk_not_allowed",
59
+ message=(
60
+ f"operation '{name}' is blocked by this client's tool policy "
61
+ f"(allowed: {builtins.sorted(policy.allow) or '<none>'})"
62
+ ),
63
+ )
64
+
65
+
66
+ class ToolPolicy:
67
+ """Allow/deny list of operation names (``resource.method`` strings).
68
+
69
+ Args:
70
+ allow: If given, only these operations (or prefixes ending in ``.*``)
71
+ are permitted. ``None`` means everything is allowed unless denied.
72
+ deny: Operations (or prefixes) always forbidden; wins over ``allow``.
73
+
74
+ Example::
75
+
76
+ policy = ToolPolicy(
77
+ allow=["machines.exec", "machines.read_file", "operations.*"],
78
+ deny=["machines.delete"],
79
+ )
80
+ client = Client(api_key=key, policy=policy)
81
+ """
82
+
83
+ __slots__ = ("allow", "deny")
84
+
85
+ def __init__(
86
+ self,
87
+ allow: builtins.set[str] | builtins.list[str] | None = None,
88
+ deny: builtins.set[str] | builtins.list[str] | None = None,
89
+ ) -> None:
90
+ self.allow: frozenset[str] = frozenset(allow or ())
91
+ self.deny: frozenset[str] = frozenset(deny or ())
92
+
93
+ def _match(self, names: frozenset[str], name: str) -> bool:
94
+ if name in names:
95
+ return True
96
+ prefix = name + "."
97
+ return any(n.endswith(".*") and prefix.startswith(n[:-1]) for n in names)
98
+
99
+ def allows(self, name: str) -> bool:
100
+ if self._match(self.deny, name):
101
+ return False
102
+ if not self.allow: # nothing specified -> allow all (minus deny)
103
+ return True
104
+ return self._match(self.allow, name)
105
+
106
+
107
+ def wrap_resource_methods(resource: Any) -> None:
108
+ """Wrap each public method of ``resource`` with a policy check.
109
+
110
+ The operation name is derived from the resource class name and the method
111
+ name; the check runs before any request is made.
112
+ """
113
+ cls = resource.__class__
114
+ if getattr(cls, "_policy_wrapped", False):
115
+ return
116
+ cls._policy_wrapped = True
117
+ prefix = operation_name(cls.__name__, "")
118
+ for attr, value in vars(cls).items():
119
+ if attr.startswith("_") or not inspect.isfunction(value):
120
+ continue
121
+ name = f"{prefix}{attr}"
122
+
123
+ def make_wrapper(func: Callable[..., Any], op_name: str) -> Callable[..., Any]:
124
+ def wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
125
+ check_allowed(getattr(self, "_policy", None), op_name)
126
+ return func(self, *args, **kwargs)
127
+
128
+ if inspect.iscoroutinefunction(func):
129
+
130
+ async def async_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
131
+ check_allowed(getattr(self, "_policy", None), op_name)
132
+ return await func(self, *args, **kwargs)
133
+
134
+ async_wrapper.__name__ = func.__name__
135
+ async_wrapper.__doc__ = func.__doc__
136
+ return async_wrapper
137
+
138
+ wrapper.__name__ = func.__name__
139
+ wrapper.__doc__ = func.__doc__
140
+ return wrapper
141
+
142
+ setattr(cls, attr, make_wrapper(value, name))