unitport 0.0.1__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,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: unitport
3
+ Version: 0.0.1
4
+ Summary: UnitPort local helper — headless daemon bridging local Isaac Lab / MuJoCo / resources to the UnitPort cloud canvas.
5
+ Author: UnitPort
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://unitport.ai
8
+ Keywords: unitport,isaac-lab,mujoco,reinforcement-learning,robotics
9
+ Classifier: Environment :: No Input/Output (Daemon)
10
+ Classifier: Operating System :: Microsoft :: Windows
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: websockets>=12
16
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=7; extra == "dev"
19
+
20
+ # unitport — UnitPort local helper
21
+
22
+ Headless daemon that connects a local **GPU / Isaac Lab host** (workstation *or*
23
+ the user's own cloud server) to the UnitPort **cloud canvas**. The canvas is the
24
+ remote control (遥控器); this process has **no GUI** — only terminal output.
25
+
26
+ > Authoritative design: `../knowledges/local_helper_design.md`
27
+ > (rulings LH-D1…LH-D6, contracts **C10** pairing / **C11** control protocol).
28
+
29
+ ## What it is (and is not)
30
+
31
+ - **Is:** an outbound-only WebSocket agent exposing a *fixed* verb set to the
32
+ cloud (report capabilities, publish an asset index, receive a bundle from the
33
+ user's own canvas, start/stop a training run, stream telemetry).
34
+ - **Is not:** a shell, an exec server, or a GUI. The cloud can never send an
35
+ arbitrary command — only the fixed verbs, only from a paired session of the
36
+ **same account**, only bundles that originate from the user's own canvas
37
+ (least-privilege red line, LH-D4 / C11).
38
+
39
+ The same binary serves execution target ① (`UserServerTarget`, user cloud
40
+ server) and ③ (`LocalHelperTarget`, local host) — only the host differs (LH-D5).
41
+
42
+ ## Install
43
+
44
+ ```
45
+ pip install unitport
46
+ ```
47
+
48
+ Cross-platform (Windows + Linux). Runtime deps: `websockets` (the live link) and,
49
+ on Python < 3.11, `tomli` (TOML reader). Everything else is stdlib.
50
+
51
+ ## CLI (headless — everything else is driven remotely from the canvas)
52
+
53
+ | Command | Purpose |
54
+ |---|---|
55
+ | `unitport login` | Browser-approve device pairing; stores a refreshable helper token |
56
+ | `unitport up` (`serve`) | Dial out to the cloud relay and serve the fixed verb set |
57
+ | `unitport status` | Connection / run state, paired account, config summary |
58
+ | `unitport config` | Declare Isaac/MuJoCo interpreter paths + resource roots |
59
+ | `unitport doctor` | Fail-loud self-check (interpreters, deps, cloud, token) |
60
+
61
+ There are deliberately **no** `pull` / `train` / `push` local subcommands — those
62
+ are *remote* verbs, triggered from the canvas (C11 §4).
63
+
64
+ ## Config
65
+
66
+ - Config dir: `%APPDATA%\unitport\` (Windows) · `$XDG_CONFIG_HOME/unitport/`
67
+ or `~/.config/unitport/` (POSIX). Override with `UNITPORT_HOME`.
68
+ - `config.toml` — cloud base URL, engine interpreter paths, `resource_roots`,
69
+ workspace dir. Human-editable; also managed by `unitport config`.
70
+ - `token.json` — the helper token (secret; `chmod 600` on POSIX). Never printed,
71
+ never logged.
72
+ - `device.json` — the persistent (non-secret) `device_id`.
73
+
74
+ ## Status
75
+
76
+ Under active construction (phases LH-P1…LH-P8, Isaac Lab first). See the design
77
+ doc §12 for the phase map. This tree currently implements the local foundation
78
+ (config, capability detection, doctor, CLI) plus the pairing/transport clients;
79
+ the `workers/relay` Durable Object cloud side lives in `../workers/relay`.
@@ -0,0 +1,60 @@
1
+ # unitport — UnitPort local helper
2
+
3
+ Headless daemon that connects a local **GPU / Isaac Lab host** (workstation *or*
4
+ the user's own cloud server) to the UnitPort **cloud canvas**. The canvas is the
5
+ remote control (遥控器); this process has **no GUI** — only terminal output.
6
+
7
+ > Authoritative design: `../knowledges/local_helper_design.md`
8
+ > (rulings LH-D1…LH-D6, contracts **C10** pairing / **C11** control protocol).
9
+
10
+ ## What it is (and is not)
11
+
12
+ - **Is:** an outbound-only WebSocket agent exposing a *fixed* verb set to the
13
+ cloud (report capabilities, publish an asset index, receive a bundle from the
14
+ user's own canvas, start/stop a training run, stream telemetry).
15
+ - **Is not:** a shell, an exec server, or a GUI. The cloud can never send an
16
+ arbitrary command — only the fixed verbs, only from a paired session of the
17
+ **same account**, only bundles that originate from the user's own canvas
18
+ (least-privilege red line, LH-D4 / C11).
19
+
20
+ The same binary serves execution target ① (`UserServerTarget`, user cloud
21
+ server) and ③ (`LocalHelperTarget`, local host) — only the host differs (LH-D5).
22
+
23
+ ## Install
24
+
25
+ ```
26
+ pip install unitport
27
+ ```
28
+
29
+ Cross-platform (Windows + Linux). Runtime deps: `websockets` (the live link) and,
30
+ on Python < 3.11, `tomli` (TOML reader). Everything else is stdlib.
31
+
32
+ ## CLI (headless — everything else is driven remotely from the canvas)
33
+
34
+ | Command | Purpose |
35
+ |---|---|
36
+ | `unitport login` | Browser-approve device pairing; stores a refreshable helper token |
37
+ | `unitport up` (`serve`) | Dial out to the cloud relay and serve the fixed verb set |
38
+ | `unitport status` | Connection / run state, paired account, config summary |
39
+ | `unitport config` | Declare Isaac/MuJoCo interpreter paths + resource roots |
40
+ | `unitport doctor` | Fail-loud self-check (interpreters, deps, cloud, token) |
41
+
42
+ There are deliberately **no** `pull` / `train` / `push` local subcommands — those
43
+ are *remote* verbs, triggered from the canvas (C11 §4).
44
+
45
+ ## Config
46
+
47
+ - Config dir: `%APPDATA%\unitport\` (Windows) · `$XDG_CONFIG_HOME/unitport/`
48
+ or `~/.config/unitport/` (POSIX). Override with `UNITPORT_HOME`.
49
+ - `config.toml` — cloud base URL, engine interpreter paths, `resource_roots`,
50
+ workspace dir. Human-editable; also managed by `unitport config`.
51
+ - `token.json` — the helper token (secret; `chmod 600` on POSIX). Never printed,
52
+ never logged.
53
+ - `device.json` — the persistent (non-secret) `device_id`.
54
+
55
+ ## Status
56
+
57
+ Under active construction (phases LH-P1…LH-P8, Isaac Lab first). See the design
58
+ doc §12 for the phase map. This tree currently implements the local foundation
59
+ (config, capability detection, doctor, CLI) plus the pairing/transport clients;
60
+ the `workers/relay` Durable Object cloud side lives in `../workers/relay`.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "unitport"
7
+ version = "0.0.1"
8
+ description = "UnitPort local helper — headless daemon bridging local Isaac Lab / MuJoCo / resources to the UnitPort cloud canvas."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Proprietary" }
12
+ authors = [{ name = "UnitPort" }]
13
+ keywords = ["unitport", "isaac-lab", "mujoco", "reinforcement-learning", "robotics"]
14
+ classifiers = [
15
+ "Environment :: No Input/Output (Daemon)",
16
+ "Operating System :: Microsoft :: Windows",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Programming Language :: Python :: 3",
19
+ ]
20
+
21
+ # Runtime deps kept minimal on purpose (LH-D1 "lightweight"):
22
+ # * websockets — the outbound live link (LH-D2); lazily imported so config/
23
+ # doctor/capabilities work without it installed.
24
+ # * tomli — TOML *reader* backport for Python < 3.11 (stdlib tomllib on
25
+ # 3.11+). We hand-roll the writer, so no writer dep.
26
+ # Everything else (pairing HTTP, atomic IO, capability probes) is pure stdlib.
27
+ dependencies = [
28
+ "websockets>=12",
29
+ "tomli>=2.0 ; python_version < '3.11'",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ dev = ["pytest>=7"]
34
+
35
+ [project.scripts]
36
+ unitport = "unitport.cli:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://unitport.ai"
40
+
41
+ [tool.setuptools]
42
+ packages = ["unitport"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,71 @@
1
+ """Least-privilege boundary tests (C11 §7): own-canvas provenance + helper://
2
+ path-escape guard. These must fail LOUD, never degrade."""
3
+
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from unitport import bundle as bundlemod
9
+ from unitport import config as cfgmod
10
+ from unitport import security
11
+ from unitport.errors import SecurityError, VerbError
12
+
13
+
14
+ @pytest.fixture
15
+ def home(tmp_path, monkeypatch):
16
+ monkeypatch.setenv("UNITPORT_HOME", str(tmp_path))
17
+ return tmp_path
18
+
19
+
20
+ def _bundle(uid):
21
+ return {
22
+ "run_id": "r1",
23
+ "backend": "sb3_mujoco",
24
+ "provenance": {"owner_uid": uid},
25
+ "env_cfg": {"source": "# cfg\n"},
26
+ "spec": {},
27
+ "assets": [],
28
+ }
29
+
30
+
31
+ def test_foreign_bundle_rejected(home):
32
+ cfgmod.set_workspace(str(home / "ws"))
33
+ with pytest.raises(SecurityError):
34
+ bundlemod.materialize(_bundle("someone-else"), paired_uid="me", cfg=cfgmod.load_config())
35
+
36
+
37
+ def test_missing_owner_rejected(home):
38
+ b = _bundle("")
39
+ with pytest.raises(SecurityError):
40
+ bundlemod.materialize(b, paired_uid="me", cfg=cfgmod.load_config())
41
+
42
+
43
+ def test_helper_uri_inside_root_ok(home):
44
+ root = home / "assets"
45
+ root.mkdir()
46
+ f = root / "go2.xml"
47
+ f.write_text("<mujoco/>", encoding="utf-8")
48
+ got = security.resolve_helper_uri(f"helper://{f.as_posix()}", [str(root)])
49
+ assert got == f.resolve()
50
+
51
+
52
+ def test_helper_uri_escape_rejected(home):
53
+ root = home / "assets"
54
+ root.mkdir()
55
+ outside = home / "secret.txt"
56
+ outside.write_text("nope", encoding="utf-8")
57
+ with pytest.raises(SecurityError):
58
+ security.resolve_helper_uri(f"helper://{outside.as_posix()}", [str(root)])
59
+
60
+
61
+ def test_helper_uri_no_roots_rejected(home):
62
+ with pytest.raises(SecurityError):
63
+ security.resolve_helper_uri("helper:///anything", [])
64
+
65
+
66
+ def test_unknown_backend_rejected(home):
67
+ cfgmod.set_workspace(str(home / "ws"))
68
+ b = _bundle("me")
69
+ b["backend"] = "tensorflow_magic"
70
+ with pytest.raises(VerbError):
71
+ bundlemod.materialize(b, paired_uid="me", cfg=cfgmod.load_config())
@@ -0,0 +1,35 @@
1
+ import pytest
2
+
3
+ from unitport import CAPABILITIES_SCHEMA
4
+ from unitport import capabilities as caps
5
+
6
+
7
+ @pytest.fixture
8
+ def home(tmp_path, monkeypatch):
9
+ monkeypatch.setenv("UNITPORT_HOME", str(tmp_path))
10
+ return tmp_path
11
+
12
+
13
+ def test_manifest_shape(home):
14
+ m = caps.build_manifest()
15
+ assert m["schema"] == CAPABILITIES_SCHEMA
16
+ assert m["host"]["os"] in ("windows", "linux", "darwin", "unknown")
17
+ assert set(m["engines"]) == {"isaac_lab", "mujoco", "sb3"}
18
+ for eng in m["engines"].values():
19
+ assert "installed" in eng
20
+ assert isinstance(m["gpu"]["nvidia_smi"], list)
21
+ assert isinstance(m["conda_envs"], list)
22
+ assert m["reported_at"]
23
+
24
+
25
+ def test_isaac_absent_when_unconfigured(home):
26
+ # No interpreter declared → not installed, never raises.
27
+ got = caps.detect_isaac_lab({"installs": {}})
28
+ assert got["installed"] is False
29
+ assert got["python"] is None
30
+
31
+
32
+ def test_isaac_missing_path(home):
33
+ got = caps.detect_isaac_lab({"installs": {"isaac_lab": {"python": "/no/such/python"}}})
34
+ assert got["installed"] is False
35
+ assert got["python_exists"] is False
@@ -0,0 +1,59 @@
1
+ import os
2
+ import stat
3
+
4
+ import pytest
5
+
6
+ from unitport import config as cfgmod
7
+ from unitport.errors import ConfigError
8
+
9
+
10
+ @pytest.fixture
11
+ def home(tmp_path, monkeypatch):
12
+ monkeypatch.setenv("UNITPORT_HOME", str(tmp_path))
13
+ monkeypatch.delenv("UNITPORT_BASE_URL", raising=False)
14
+ return tmp_path
15
+
16
+
17
+ def test_config_roundtrip_nested_tables(home):
18
+ cfg = cfgmod.set_engine_python("isaac_lab", r"C:\isaac\python.exe")
19
+ assert cfg["installs"]["isaac_lab"]["python"] == "C:/isaac/python.exe"
20
+ # Reloading parses the TOML we wrote back into the same structure.
21
+ reloaded = cfgmod.load_config()
22
+ assert reloaded["installs"]["isaac_lab"]["python"] == "C:/isaac/python.exe"
23
+
24
+
25
+ def test_add_resource_root_dedupes(home):
26
+ cfgmod.add_resource_root(r"A:\robots")
27
+ cfg = cfgmod.add_resource_root("A:/robots") # same, forward-slash form
28
+ assert cfg["resource"]["roots"] == ["A:/robots"]
29
+
30
+
31
+ def test_base_url_precedence(home, monkeypatch):
32
+ cfgmod.set_base_url("https://config.example")
33
+ assert cfgmod.base_url() == "https://config.example"
34
+ monkeypatch.setenv("UNITPORT_BASE_URL", "https://env.example/")
35
+ assert cfgmod.base_url() == "https://env.example"
36
+
37
+
38
+ def test_device_id_stable(home):
39
+ a = cfgmod.get_or_create_device_id()
40
+ b = cfgmod.get_or_create_device_id()
41
+ assert a == b and len(a) == 32
42
+
43
+
44
+ def test_token_roundtrip_and_perms(home):
45
+ cfgmod.save_token({"access_token": "secret", "uid": "u1", "device_id": "d1"})
46
+ tok = cfgmod.load_token()
47
+ assert tok["access_token"] == "secret"
48
+ if os.name != "nt": # POSIX: token must be 0600
49
+ mode = stat.S_IMODE(os.stat(cfgmod.token_path()).st_mode)
50
+ assert mode == 0o600
51
+ cfgmod.clear_token()
52
+ assert cfgmod.load_token() is None
53
+
54
+
55
+ def test_malformed_toml_fails_loud(home):
56
+ cfgmod.config_path().parent.mkdir(parents=True, exist_ok=True)
57
+ cfgmod.config_path().write_text("this is = = not toml", encoding="utf-8")
58
+ with pytest.raises(ConfigError):
59
+ cfgmod.load_config()
@@ -0,0 +1,83 @@
1
+ """In-process end-to-end: receive-bundle → materialize → start-run → telemetry →
2
+ push-results, driven against the mock trainer (no relay, no GPU). Proves the
3
+ LH-P4/P5/P6 plumbing deterministically."""
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from unitport import bundle as bundlemod
11
+ from unitport import config as cfgmod
12
+ from unitport.verbs import HelperContext
13
+
14
+ MOCK = str(Path(__file__).parent / "mock_trainer.py")
15
+
16
+
17
+ @pytest.fixture
18
+ def home(tmp_path, monkeypatch):
19
+ monkeypatch.setenv("UNITPORT_HOME", str(tmp_path))
20
+ return tmp_path
21
+
22
+
23
+ def _make_bundle(uid, source, run_id="smoke-1"):
24
+ return {
25
+ "run_id": run_id,
26
+ "backend": "sb3_mujoco",
27
+ "provenance": {"owner_uid": uid},
28
+ "env_cfg": {"file_name": "unitport_env_cfg.py", "source": source},
29
+ "spec": {"max_iterations": 3},
30
+ "assets": [],
31
+ }
32
+
33
+
34
+ def test_receive_start_results(home):
35
+ cfgmod.save_token({"access_token": "x", "uid": "uid-test", "device_id": "d1"})
36
+ cfgmod.set_engine_field("sb3_mujoco", "launcher", MOCK)
37
+ cfgmod.set_engine_field("sb3_mujoco", "python", sys.executable)
38
+ cfgmod.set_workspace(str(home / "ws"))
39
+
40
+ ctx = HelperContext()
41
+ events = []
42
+ ctx.emit = events.append
43
+ assert ctx.paired_uid == "uid-test"
44
+
45
+ # 1) receive-bundle → materialize
46
+ run = bundlemod.materialize(_make_bundle("uid-test", "# env cfg\n"), ctx.paired_uid, ctx.cfg)
47
+ assert (run.run_dir / "unitport_env_cfg.py").read_text(encoding="utf-8") == "# env cfg\n"
48
+ assert (run.run_dir / "spec.json").exists()
49
+ assert (run.run_dir / "run.json").exists()
50
+ ctx.runs[run.run_id] = {"run": run}
51
+
52
+ # 2) start-run → real subprocess (mock trainer) → 3) telemetry
53
+ runner = ctx.get_runner()
54
+ runner.start(run)
55
+ runner.wait(run.run_id, timeout=30)
56
+
57
+ tail = [e["payload"] for e in events if e.get("verb") == "tail-logs"]
58
+ kinds = {p.get("kind") for p in tail}
59
+ assert "progress" in kinds
60
+ assert "cmdmetrics" in kinds
61
+ assert "done_sentinel" in kinds
62
+ # a progress frame carries iteration/total
63
+ prog = [p for p in tail if p.get("kind") == "progress"]
64
+ assert prog and prog[-1]["iteration"] == 3 and prog[-1]["total"] == 3
65
+
66
+ # 4) push-results emitted with a checkpoint manifest
67
+ pushes = [e for e in events if e.get("verb") == "push-results"]
68
+ assert pushes, "no push-results emitted"
69
+ payload = pushes[0]["payload"]
70
+ assert Path(payload["checkpoint"]).exists()
71
+ man = payload["manifest"]
72
+ assert man["schema"] == "unitport.resource/bundles@1"
73
+ assert man["payload"]["bundle_kind"] == "checkpoint"
74
+ assert man["payload"]["checkpoint_ref"].startswith("helper://")
75
+ assert man["payload"]["run_id"] == "smoke-1"
76
+ assert len(man["id"]) == 12 # 12-hex SKU
77
+
78
+ # run.json reflects completion
79
+ import json
80
+
81
+ rj = json.loads((run.run_dir / "run.json").read_text(encoding="utf-8"))
82
+ assert rj["status"] == "completed"
83
+ assert rj["returncode"] == 0
@@ -0,0 +1,53 @@
1
+ import pytest
2
+
3
+ from unitport import protocol
4
+ from unitport.errors import ProtocolError, SecurityError
5
+ from unitport.protocol import Verb
6
+
7
+
8
+ def test_envelope_roundtrip():
9
+ env = protocol.make_req(Verb.RECEIVE_BUNDLE, id="1", payload={"a": 1}, run_id="r1")
10
+ raw = protocol.dumps(env)
11
+ back = protocol.parse_envelope(raw)
12
+ assert back["verb"] == Verb.RECEIVE_BUNDLE
13
+ assert back["run_id"] == "r1"
14
+ assert back["kind"] == protocol.KIND_REQ
15
+ assert back["v"] == protocol.PROTOCOL_VERSION
16
+
17
+
18
+ def test_parse_rejects_garbage():
19
+ with pytest.raises(ProtocolError):
20
+ protocol.parse_envelope("not json")
21
+ with pytest.raises(ProtocolError):
22
+ protocol.parse_envelope("[]") # not an object
23
+ with pytest.raises(ProtocolError):
24
+ protocol.parse_envelope('{"v":999,"kind":"req"}') # bad version
25
+
26
+
27
+ def test_gate_allows_cloud_to_helper_verb():
28
+ env = protocol.make_req(Verb.RECEIVE_BUNDLE, id="1")
29
+ assert protocol.assert_inbound_allowed(env) == Verb.RECEIVE_BUNDLE
30
+
31
+
32
+ def test_gate_rejects_unknown_verb():
33
+ env = protocol.make_req("rm -rf /", id="1")
34
+ with pytest.raises(SecurityError):
35
+ protocol.assert_inbound_allowed(env)
36
+
37
+
38
+ def test_gate_rejects_wrong_direction_verb():
39
+ # The cloud must not be able to *invoke* a helper→cloud-only verb.
40
+ env = protocol.make_req(Verb.REPORT_CAPABILITIES, id="1")
41
+ with pytest.raises(SecurityError):
42
+ protocol.assert_inbound_allowed(env)
43
+
44
+
45
+ def test_gate_lets_responses_through():
46
+ env = protocol.make_res(id="1", payload={"ok": True})
47
+ assert protocol.assert_inbound_allowed(env) == ""
48
+
49
+
50
+ def test_gate_rejects_missing_verb_on_req():
51
+ env = protocol.make_envelope(protocol.KIND_REQ, id="1")
52
+ with pytest.raises(SecurityError):
53
+ protocol.assert_inbound_allowed(env)
@@ -0,0 +1,26 @@
1
+ # -*- coding: utf-8 -*-
2
+ """unitport — UnitPort local helper (headless daemon).
3
+
4
+ Package-level constants shared across modules. Keep this import cheap: NO heavy
5
+ third-party imports here (websockets etc. are imported lazily where used) so that
6
+ ``config`` / ``doctor`` / ``capabilities`` stay usable even in a broken env.
7
+
8
+ Authoritative design: ``knowledges/local_helper_design.md`` (LH-D1…D6, C10/C11).
9
+ """
10
+
11
+ __version__ = "0.0.1"
12
+
13
+ # Wire / schema identifiers — must match the design doc and the cloud edge.
14
+ CAPABILITIES_SCHEMA = "unitport.helper.capabilities@1" # design §5
15
+ PROTOCOL_VERSION = 1 # envelope { v, kind, id, verb, run_id?, payload, ts } §2
16
+
17
+ # Cloud edge (Cloudflare Workers, design §11). Overridable via config.toml
18
+ # [cloud].base_url or the UNITPORT_BASE_URL env var — never hard-required here.
19
+ DEFAULT_BASE_URL = "https://svc.unitport.ai"
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ "CAPABILITIES_SCHEMA",
24
+ "PROTOCOL_VERSION",
25
+ "DEFAULT_BASE_URL",
26
+ ]
@@ -0,0 +1,9 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Enable ``python -m unitport`` (mirrors the ``unitport`` console script)."""
3
+
4
+ import sys
5
+
6
+ from .cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())