impello 0.2.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.
@@ -0,0 +1,5 @@
1
+ .venv
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
impello-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.5
2
+ Name: impello
3
+ Version: 0.2.0
4
+ Summary: Python SDK for Impello sandboxes.
5
+ Project-URL: Homepage, https://impello.ai
6
+ Project-URL: Repository, https://github.com/21-Dreams/impello
7
+ License-Expression: MIT
8
+ Keywords: agents,code-interpreter,impello,microvm,sandbox
9
+ Requires-Python: >=3.9
10
+ Requires-Dist: e2b<3,>=2.46.0
11
+ Description-Content-Type: text/markdown
12
+
13
+ # `impello`
14
+
15
+ The Python SDK for Impello sandboxes.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install impello
21
+ ```
22
+
23
+ ## Use
24
+
25
+ ```python
26
+ from impello import Sandbox
27
+
28
+ sandbox = Sandbox.create()
29
+ result = sandbox.commands.run("echo hello")
30
+
31
+ print(result.stdout)
32
+
33
+ sandbox.pause()
34
+ ```
35
+
36
+ Set your key in the environment, or pass it to each call:
37
+
38
+ ```bash
39
+ export IMPELLO_API_KEY=imp_...
40
+ ```
41
+
42
+ ```python
43
+ Sandbox.list(api_key="imp_...")
44
+ ```
45
+
46
+ For a process that talks to more than one account or more than one fleet, bind
47
+ the settings to a client instead:
48
+
49
+ ```python
50
+ from impello import Impello
51
+
52
+ client = Impello(api_key="imp_...")
53
+ sandbox = client.Sandbox.create()
54
+ ```
55
+
56
+ ## Settings
57
+
58
+ Every setting reads an `IMPELLO_` name first, then the matching `E2B_` name.
59
+
60
+ | Variable | Default | What it does |
61
+ |---|---|---|
62
+ | `IMPELLO_API_KEY` | none | The key. Starts with `imp_` |
63
+ | `IMPELLO_DOMAIN` | `sandbox.impello.ai` | The domain the API and the sandboxes sit on |
64
+ | `IMPELLO_API_URL` | `https://api.<domain>` | The API address. Set this to reach a self-hosted API |
65
+ | `IMPELLO_SANDBOX_URL` | derived from the domain | The address a sandbox is reached at |
66
+ | `IMPELLO_DEBUG` | `false` | Talk to `http://localhost:3000` |
67
+
68
+ The `E2B_` fallback is the migration, not politeness. Callers already export
69
+ `E2B_API_KEY`, `E2B_API_URL` and `E2B_DOMAIN`. Rename when it suits you.
70
+ Nothing breaks if you never do.
71
+
72
+ Only the domain has a default. Without one the SDK falls back to E2B's own
73
+ `e2b.app`, and the failure is a connection to somebody else's fleet.
74
+
75
+ Params passed to a single call beat a client's params, which beat the
76
+ environment. That is E2B's own rule and this package keeps it.
77
+
78
+ ## Why this one is a wrapper, when the TypeScript one is a copy
79
+
80
+ The TypeScript client cannot talk to Impello at all. Its `validateApiKey` runs
81
+ inside the `ApiClient` constructor, is not exported, has no off switch, and
82
+ accepts only `e2b_`. So `@impello/sdk` had to be a copy.
83
+
84
+ Python has no `e2b_` pattern anywhere. It takes an `imp_` key today. So the
85
+ only thing missing is where to send the request, and that is all this package
86
+ supplies.
87
+
88
+ It supplies it through `ClientFactory._resolve_api_params`, which is E2B's own
89
+ seam for binding defaults. That means one method is overridden rather than the
90
+ twelve descriptors on `Sandbox`, and **`e2b` itself is untouched**: importing
91
+ `impello` does not retarget a plain `e2b` call in the same process, and it
92
+ never writes to `os.environ`. A test holds that.
93
+
94
+ ## This package will be replaced
95
+
96
+ A permanent wrapper is the worst outcome. `pip show impello` prints `e2b`, and
97
+ the client sends a `publisher: e2b` header to our own servers. So the wrapper
98
+ ships now and the full clone lands before we take payments, with
99
+ `e2b/api/client/` regenerated from our own spec.
100
+
101
+ See `docs/decisions/sdk-self-contained.md`.
102
+
103
+ ## Develop
104
+
105
+ ```bash
106
+ python -m venv .venv
107
+ ./.venv/bin/pip install -e . pytest
108
+ ./.venv/bin/python -m pytest
109
+ ```
@@ -0,0 +1,97 @@
1
+ # `impello`
2
+
3
+ The Python SDK for Impello sandboxes.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install impello
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```python
14
+ from impello import Sandbox
15
+
16
+ sandbox = Sandbox.create()
17
+ result = sandbox.commands.run("echo hello")
18
+
19
+ print(result.stdout)
20
+
21
+ sandbox.pause()
22
+ ```
23
+
24
+ Set your key in the environment, or pass it to each call:
25
+
26
+ ```bash
27
+ export IMPELLO_API_KEY=imp_...
28
+ ```
29
+
30
+ ```python
31
+ Sandbox.list(api_key="imp_...")
32
+ ```
33
+
34
+ For a process that talks to more than one account or more than one fleet, bind
35
+ the settings to a client instead:
36
+
37
+ ```python
38
+ from impello import Impello
39
+
40
+ client = Impello(api_key="imp_...")
41
+ sandbox = client.Sandbox.create()
42
+ ```
43
+
44
+ ## Settings
45
+
46
+ Every setting reads an `IMPELLO_` name first, then the matching `E2B_` name.
47
+
48
+ | Variable | Default | What it does |
49
+ |---|---|---|
50
+ | `IMPELLO_API_KEY` | none | The key. Starts with `imp_` |
51
+ | `IMPELLO_DOMAIN` | `sandbox.impello.ai` | The domain the API and the sandboxes sit on |
52
+ | `IMPELLO_API_URL` | `https://api.<domain>` | The API address. Set this to reach a self-hosted API |
53
+ | `IMPELLO_SANDBOX_URL` | derived from the domain | The address a sandbox is reached at |
54
+ | `IMPELLO_DEBUG` | `false` | Talk to `http://localhost:3000` |
55
+
56
+ The `E2B_` fallback is the migration, not politeness. Callers already export
57
+ `E2B_API_KEY`, `E2B_API_URL` and `E2B_DOMAIN`. Rename when it suits you.
58
+ Nothing breaks if you never do.
59
+
60
+ Only the domain has a default. Without one the SDK falls back to E2B's own
61
+ `e2b.app`, and the failure is a connection to somebody else's fleet.
62
+
63
+ Params passed to a single call beat a client's params, which beat the
64
+ environment. That is E2B's own rule and this package keeps it.
65
+
66
+ ## Why this one is a wrapper, when the TypeScript one is a copy
67
+
68
+ The TypeScript client cannot talk to Impello at all. Its `validateApiKey` runs
69
+ inside the `ApiClient` constructor, is not exported, has no off switch, and
70
+ accepts only `e2b_`. So `@impello/sdk` had to be a copy.
71
+
72
+ Python has no `e2b_` pattern anywhere. It takes an `imp_` key today. So the
73
+ only thing missing is where to send the request, and that is all this package
74
+ supplies.
75
+
76
+ It supplies it through `ClientFactory._resolve_api_params`, which is E2B's own
77
+ seam for binding defaults. That means one method is overridden rather than the
78
+ twelve descriptors on `Sandbox`, and **`e2b` itself is untouched**: importing
79
+ `impello` does not retarget a plain `e2b` call in the same process, and it
80
+ never writes to `os.environ`. A test holds that.
81
+
82
+ ## This package will be replaced
83
+
84
+ A permanent wrapper is the worst outcome. `pip show impello` prints `e2b`, and
85
+ the client sends a `publisher: e2b` header to our own servers. So the wrapper
86
+ ships now and the full clone lands before we take payments, with
87
+ `e2b/api/client/` regenerated from our own spec.
88
+
89
+ See `docs/decisions/sdk-self-contained.md`.
90
+
91
+ ## Develop
92
+
93
+ ```bash
94
+ python -m venv .venv
95
+ ./.venv/bin/pip install -e . pytest
96
+ ./.venv/bin/python -m pytest
97
+ ```
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "impello"
7
+ version = "0.2.0"
8
+ description = "Python SDK for Impello sandboxes."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ keywords = ["impello", "sandbox", "microvm", "agents", "code-interpreter"]
13
+ dependencies = [
14
+ # Pinned to a major. The next major may move `_resolve_api_params`, which is
15
+ # the one seam this package is built on. See src/impello/_defaults.py.
16
+ "e2b>=2.46.0,<3",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://impello.ai"
21
+ Repository = "https://github.com/21-Dreams/impello"
22
+
23
+ [dependency-groups]
24
+ dev = ["pytest>=8"]
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/impello"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
@@ -0,0 +1,84 @@
1
+ """The Python SDK for Impello sandboxes.
2
+
3
+ Impello runs its own build of E2B's open-source ``infra``, so the sandbox API
4
+ is the same API. Unlike the TypeScript client, the Python one needs no changes
5
+ to work against it: there is no ``e2b_`` key pattern anywhere in ``e2b``, so it
6
+ takes an ``imp_`` key today. All this package has to supply is where to send
7
+ the request.
8
+
9
+ So this is a wrapper, and it is deliberately thin. It will be replaced by a
10
+ full clone before we take payments, for the same reason the TypeScript client
11
+ is one: ``pip show impello`` printing ``e2b`` is a poor thing for a paid
12
+ product to say about itself, and the client sends a ``publisher: e2b`` header
13
+ to our own servers. See ``docs/decisions/sdk-self-contained.md``.
14
+
15
+ Usage is E2B's, unchanged::
16
+
17
+ from impello import Sandbox
18
+
19
+ sandbox = Sandbox.create()
20
+ result = sandbox.commands.run("echo hello")
21
+ sandbox.pause()
22
+ """
23
+
24
+ import e2b as _e2b
25
+ from e2b import * # noqa: F401,F403
26
+ from e2b.connection_config import ApiParams, merge_api_params
27
+
28
+ from ._defaults import DEFAULT_DOMAIN, defaults
29
+
30
+ __version__ = "0.2.0"
31
+
32
+
33
+ class _ImpelloDefaults:
34
+ """Bind Impello's settings underneath every call.
35
+
36
+ ``ClientFactory._resolve_api_params`` is E2B's own seam for this: every
37
+ class method funnels through it, and ``merge_api_params`` already gives
38
+ per-call params precedence over bound ones. So this overrides one method
39
+ instead of mirroring the twelve descriptors on ``Sandbox``, and it changes
40
+ nothing about ``e2b`` itself. Importing this package does not retarget a
41
+ plain ``e2b`` call in the same process, and it does not touch ``os.environ``.
42
+ """
43
+
44
+ @classmethod
45
+ def _resolve_api_params(cls, **opts: "ApiParams") -> "ApiParams":
46
+ return merge_api_params(defaults(), super()._resolve_api_params(**opts))
47
+
48
+
49
+ class Sandbox(_ImpelloDefaults, _e2b.Sandbox):
50
+ """An Impello sandbox."""
51
+
52
+
53
+ class AsyncSandbox(_ImpelloDefaults, _e2b.AsyncSandbox):
54
+ """An Impello sandbox, async."""
55
+
56
+
57
+ class Impello(_e2b.E2B):
58
+ """A client with its connection settings bound explicitly.
59
+
60
+ Use this instead of the module-level ``Sandbox`` when one process talks to
61
+ more than one account or more than one fleet. Clients are isolated from
62
+ each other. Params passed here beat the environment; params passed to a
63
+ single call beat both.
64
+
65
+ Example::
66
+
67
+ from impello import Impello
68
+
69
+ client = Impello(api_key="imp_...")
70
+ sandbox = client.Sandbox.create()
71
+ """
72
+
73
+ def __init__(self, **opts: "ApiParams"):
74
+ super().__init__(**merge_api_params(defaults(), opts))
75
+
76
+
77
+ __all__ = [
78
+ *[name for name in dir(_e2b) if not name.startswith("_")],
79
+ "Sandbox",
80
+ "AsyncSandbox",
81
+ "Impello",
82
+ "DEFAULT_DOMAIN",
83
+ "__version__",
84
+ ]
@@ -0,0 +1,57 @@
1
+ """Where an Impello setting comes from, and in what order.
2
+
3
+ Every setting reads an ``IMPELLO_`` name first, then the matching ``E2B_``
4
+ name. The fallback is the migration, not politeness: callers already export
5
+ ``E2B_API_KEY``, ``E2B_API_URL`` and ``E2B_DOMAIN``, and the flag day that
6
+ changes the key prefix is hard enough without renaming every environment on the
7
+ same afternoon. Rename when convenient. Nothing breaks if you never do.
8
+ """
9
+
10
+ import os
11
+ from typing import Optional
12
+
13
+ from e2b.connection_config import ApiParams
14
+
15
+ DEFAULT_DOMAIN = "sandbox.impello.ai"
16
+
17
+ # Each entry is one setting and the ``ApiParams`` key it fills.
18
+ _SETTINGS = (
19
+ ("API_KEY", "api_key"),
20
+ ("API_URL", "api_url"),
21
+ ("DOMAIN", "domain"),
22
+ ("SANDBOX_URL", "sandbox_url"),
23
+ )
24
+
25
+
26
+ def _read(name: str) -> Optional[str]:
27
+ """Read one setting, Impello's name first."""
28
+ return os.getenv(f"IMPELLO_{name}") or os.getenv(f"E2B_{name}")
29
+
30
+
31
+ def defaults() -> ApiParams:
32
+ """The params bound to every Impello call, resolved fresh each time.
33
+
34
+ Read at call time rather than at import, so a test or a caller that sets an
35
+ environment variable after importing this package still gets it.
36
+
37
+ A setting that is absent is left out of the result. It must not appear as
38
+ ``None``, because ``merge_api_params`` drops a ``None`` from the per-call
39
+ params but keeps one in the bound params, where it would clear the value
40
+ the SDK would otherwise resolve for itself.
41
+ """
42
+ params = {}
43
+
44
+ for name, key in _SETTINGS:
45
+ value = _read(name)
46
+ if value:
47
+ params[key] = value
48
+
49
+ debug = _read("DEBUG")
50
+ if debug:
51
+ params["debug"] = debug.lower() == "true"
52
+
53
+ # Only the domain has a default. Without one the SDK falls back to E2B's
54
+ # own `e2b.app`, and the failure is a connection to somebody else's fleet.
55
+ params.setdefault("domain", DEFAULT_DOMAIN)
56
+
57
+ return params
@@ -0,0 +1,81 @@
1
+ """How the settings reach a call, and what must still beat them."""
2
+
3
+ import e2b
4
+ import pytest
5
+
6
+ import impello
7
+ from impello._defaults import DEFAULT_DOMAIN
8
+
9
+ API_KEY = "imp_" + "0" * 40
10
+
11
+
12
+ @pytest.fixture(autouse=True)
13
+ def clean_env(monkeypatch):
14
+ for name in ("API_KEY", "API_URL", "DOMAIN", "SANDBOX_URL", "DEBUG"):
15
+ monkeypatch.delenv(f"IMPELLO_{name}", raising=False)
16
+ monkeypatch.delenv(f"E2B_{name}", raising=False)
17
+
18
+
19
+ def test_a_call_is_pointed_at_impello():
20
+ assert impello.Sandbox._resolve_api_params()["domain"] == DEFAULT_DOMAIN
21
+
22
+
23
+ def test_the_async_sandbox_is_pointed_there_too():
24
+ assert impello.AsyncSandbox._resolve_api_params()["domain"] == DEFAULT_DOMAIN
25
+
26
+
27
+ def test_a_per_call_param_still_wins():
28
+ """E2B's own precedence rule. Breaking it would make a self-hosted or
29
+ second-account call unreachable."""
30
+ resolved = impello.Sandbox._resolve_api_params(domain="elsewhere.test")
31
+ assert resolved["domain"] == "elsewhere.test"
32
+
33
+
34
+ def test_a_per_call_none_does_not_clear_the_default():
35
+ resolved = impello.Sandbox._resolve_api_params(domain=None)
36
+ assert resolved["domain"] == DEFAULT_DOMAIN
37
+
38
+
39
+ def test_the_environment_reaches_the_call(monkeypatch):
40
+ monkeypatch.setenv("IMPELLO_API_KEY", API_KEY)
41
+ assert impello.Sandbox._resolve_api_params()["api_key"] == API_KEY
42
+
43
+
44
+ def test_plain_e2b_is_left_alone():
45
+ """Importing this package must not retarget somebody else's e2b call in
46
+ the same process. That is why this binds a subclass instead of patching
47
+ ConnectionConfig or writing to os.environ."""
48
+ assert e2b.Sandbox._resolve_api_params() == {}
49
+ assert e2b.connection_config.ConnectionConfig().domain == "e2b.app"
50
+
51
+
52
+ def test_an_impello_client_binds_the_defaults():
53
+ client = impello.Impello(api_key=API_KEY)
54
+ assert client.Sandbox._resolve_api_params()["domain"] == DEFAULT_DOMAIN
55
+ assert client.Sandbox._resolve_api_params()["api_key"] == API_KEY
56
+
57
+
58
+ def test_an_impello_client_can_be_pointed_elsewhere():
59
+ client = impello.Impello(api_key=API_KEY, domain="elsewhere.test")
60
+ assert client.Sandbox._resolve_api_params()["domain"] == "elsewhere.test"
61
+
62
+
63
+ def test_the_sandbox_is_still_an_e2b_sandbox():
64
+ """The wrapper adds settings. It must not fork behaviour."""
65
+ assert issubclass(impello.Sandbox, e2b.Sandbox)
66
+ assert issubclass(impello.AsyncSandbox, e2b.AsyncSandbox)
67
+
68
+
69
+ def test_the_surface_is_re_exported():
70
+ for name in ("SandboxException", "SandboxNotFoundException", "SandboxQuery"):
71
+ assert getattr(impello, name) is getattr(e2b, name)
72
+
73
+
74
+ def test_the_resolved_params_build_the_impello_address():
75
+ """The end of the chain. Everything above is plumbing; this is the value
76
+ that decides which fleet the request reaches."""
77
+ from e2b.connection_config import ConnectionConfig
78
+
79
+ config = ConnectionConfig(**impello.Sandbox._resolve_api_params())
80
+
81
+ assert config.api_url == "https://api.sandbox.impello.ai"
@@ -0,0 +1,78 @@
1
+ """What a setting resolves to, and what it must never resolve to."""
2
+
3
+ import pytest
4
+
5
+ from impello._defaults import DEFAULT_DOMAIN, defaults
6
+
7
+ SETTINGS = [
8
+ ("API_KEY", "api_key"),
9
+ ("API_URL", "api_url"),
10
+ ("DOMAIN", "domain"),
11
+ ("SANDBOX_URL", "sandbox_url"),
12
+ ]
13
+
14
+
15
+ @pytest.fixture(autouse=True)
16
+ def clean_env(monkeypatch):
17
+ """Neither prefix is set unless a test sets it."""
18
+ for name, _ in SETTINGS + [("DEBUG", "debug")]:
19
+ monkeypatch.delenv(f"IMPELLO_{name}", raising=False)
20
+ monkeypatch.delenv(f"E2B_{name}", raising=False)
21
+
22
+
23
+ def test_the_domain_defaults_to_impello():
24
+ """Without this the SDK falls back to E2B's own `e2b.app`, and the failure
25
+ is a connection to somebody else's fleet."""
26
+ assert defaults()["domain"] == DEFAULT_DOMAIN
27
+
28
+
29
+ def test_nothing_else_is_invented():
30
+ """Only the domain has a default. Every other key must be absent, not
31
+ None: merge_api_params keeps a None in the bound params, where it would
32
+ clear a value the SDK would otherwise resolve for itself."""
33
+ assert set(defaults()) == {"domain"}
34
+
35
+
36
+ @pytest.mark.parametrize("name,key", SETTINGS)
37
+ def test_impello_name_is_read(monkeypatch, name, key):
38
+ monkeypatch.setenv(f"IMPELLO_{name}", "from-impello")
39
+ assert defaults()[key] == "from-impello"
40
+
41
+
42
+ @pytest.mark.parametrize("name,key", SETTINGS)
43
+ def test_e2b_name_is_the_fallback(monkeypatch, name, key):
44
+ """Callers already export the E2B names. Renaming every environment on
45
+ flag day is not a thing this package asks for."""
46
+ monkeypatch.setenv(f"E2B_{name}", "from-e2b")
47
+ assert defaults()[key] == "from-e2b"
48
+
49
+
50
+ @pytest.mark.parametrize("name,key", SETTINGS)
51
+ def test_impello_name_wins_when_both_are_set(monkeypatch, name, key):
52
+ monkeypatch.setenv(f"IMPELLO_{name}", "from-impello")
53
+ monkeypatch.setenv(f"E2B_{name}", "from-e2b")
54
+ assert defaults()[key] == "from-impello"
55
+
56
+
57
+ @pytest.mark.parametrize("name,key", SETTINGS)
58
+ def test_an_empty_value_is_not_a_value(monkeypatch, name, key):
59
+ monkeypatch.setenv(f"IMPELLO_{name}", "")
60
+ monkeypatch.setenv(f"E2B_{name}", "from-e2b")
61
+ assert defaults()[key] == "from-e2b"
62
+
63
+
64
+ def test_debug_is_read_as_a_flag(monkeypatch):
65
+ monkeypatch.setenv("IMPELLO_DEBUG", "TRUE")
66
+ assert defaults()["debug"] is True
67
+
68
+
69
+ def test_debug_is_false_when_it_says_anything_else(monkeypatch):
70
+ monkeypatch.setenv("IMPELLO_DEBUG", "yes")
71
+ assert defaults()["debug"] is False
72
+
73
+
74
+ def test_settings_are_read_at_call_time_not_import_time(monkeypatch):
75
+ """A caller that configures itself after importing still gets it."""
76
+ assert "api_key" not in defaults()
77
+ monkeypatch.setenv("IMPELLO_API_KEY", "imp_" + "0" * 40)
78
+ assert defaults()["api_key"] == "imp_" + "0" * 40