urirun 0.3.17__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.
Files changed (84) hide show
  1. urirun-0.3.17/PKG-INFO +93 -0
  2. urirun-0.3.17/README.md +71 -0
  3. urirun-0.3.17/pyproject.toml +60 -0
  4. urirun-0.3.17/setup.cfg +4 -0
  5. urirun-0.3.17/tests/test_adopt_pack.py +75 -0
  6. urirun-0.3.17/tests/test_agent_command.py +57 -0
  7. urirun-0.3.17/tests/test_compat.py +99 -0
  8. urirun-0.3.17/tests/test_connect_catalog.py +165 -0
  9. urirun-0.3.17/tests/test_connector_scaffold.py +59 -0
  10. urirun-0.3.17/tests/test_connector_sdk.py +62 -0
  11. urirun-0.3.17/tests/test_connector_smoke.py +82 -0
  12. urirun-0.3.17/tests/test_declarative.py +102 -0
  13. urirun-0.3.17/tests/test_domain_monitor.py +161 -0
  14. urirun-0.3.17/tests/test_errors.py +290 -0
  15. urirun-0.3.17/tests/test_host_dashboard.py +96 -0
  16. urirun-0.3.17/tests/test_host_db.py +112 -0
  17. urirun-0.3.17/tests/test_mesh.py +63 -0
  18. urirun-0.3.17/tests/test_minimal_imports.py +90 -0
  19. urirun-0.3.17/tests/test_planfile_adapter.py +342 -0
  20. urirun-0.3.17/tests/test_scheduler.py +61 -0
  21. urirun-0.3.17/tests/test_secrets.py +92 -0
  22. urirun-0.3.17/tests/test_urihandler.py +169 -0
  23. urirun-0.3.17/tests/test_v2_mcp.py +46 -0
  24. urirun-0.3.17/urirun/__init__.py +256 -0
  25. urirun-0.3.17/urirun/_registry.py +8 -0
  26. urirun-0.3.17/urirun/_runtime.py +8 -0
  27. urirun-0.3.17/urirun/_scan.py +8 -0
  28. urirun-0.3.17/urirun/compat.py +8 -0
  29. urirun-0.3.17/urirun/connect_catalog.py +5 -0
  30. urirun-0.3.17/urirun/connector_scaffold.py +5 -0
  31. urirun-0.3.17/urirun/connector_sdk.py +5 -0
  32. urirun-0.3.17/urirun/connector_smoke.py +5 -0
  33. urirun-0.3.17/urirun/connectors/__init__.py +1 -0
  34. urirun-0.3.17/urirun/connectors/connect_catalog.py +236 -0
  35. urirun-0.3.17/urirun/connectors/connector_scaffold.py +386 -0
  36. urirun-0.3.17/urirun/connectors/connector_sdk.py +87 -0
  37. urirun-0.3.17/urirun/connectors/connector_smoke.py +81 -0
  38. urirun-0.3.17/urirun/connectors/declarative.py +95 -0
  39. urirun-0.3.17/urirun/domain_monitor.py +5 -0
  40. urirun-0.3.17/urirun/errors.py +8 -0
  41. urirun-0.3.17/urirun/host/__init__.py +1 -0
  42. urirun-0.3.17/urirun/host/domain_monitor.py +393 -0
  43. urirun-0.3.17/urirun/host/host_dashboard.py +614 -0
  44. urirun-0.3.17/urirun/host/host_db.py +475 -0
  45. urirun-0.3.17/urirun/host/host_integrations.py +382 -0
  46. urirun-0.3.17/urirun/host/planfile_adapter.py +261 -0
  47. urirun-0.3.17/urirun/host/scheduler.py +133 -0
  48. urirun-0.3.17/urirun/host/task_planner.py +344 -0
  49. urirun-0.3.17/urirun/host_dashboard.py +5 -0
  50. urirun-0.3.17/urirun/host_db.py +5 -0
  51. urirun-0.3.17/urirun/host_integrations.py +5 -0
  52. urirun-0.3.17/urirun/mesh.py +5 -0
  53. urirun-0.3.17/urirun/node/__init__.py +1 -0
  54. urirun-0.3.17/urirun/node/mesh.py +1092 -0
  55. urirun-0.3.17/urirun/planfile_adapter.py +5 -0
  56. urirun-0.3.17/urirun/runtime/__init__.py +1 -0
  57. urirun-0.3.17/urirun/runtime/_registry.py +701 -0
  58. urirun-0.3.17/urirun/runtime/_runtime.py +471 -0
  59. urirun-0.3.17/urirun/runtime/_scan.py +670 -0
  60. urirun-0.3.17/urirun/runtime/adopt_pack.py +173 -0
  61. urirun-0.3.17/urirun/runtime/agent.py +107 -0
  62. urirun-0.3.17/urirun/runtime/compat.py +199 -0
  63. urirun-0.3.17/urirun/runtime/errors.py +511 -0
  64. urirun-0.3.17/urirun/runtime/secrets.py +149 -0
  65. urirun-0.3.17/urirun/runtime/v1.py +423 -0
  66. urirun-0.3.17/urirun/runtime/v2.py +1627 -0
  67. urirun-0.3.17/urirun/runtime/v2_adopt.py +195 -0
  68. urirun-0.3.17/urirun/runtime/v2_grpc.py +205 -0
  69. urirun-0.3.17/urirun/runtime/v2_mcp.py +205 -0
  70. urirun-0.3.17/urirun/runtime/v2_service.py +103 -0
  71. urirun-0.3.17/urirun/scheduler.py +5 -0
  72. urirun-0.3.17/urirun/task_planner.py +5 -0
  73. urirun-0.3.17/urirun/v1.py +8 -0
  74. urirun-0.3.17/urirun/v2.py +8 -0
  75. urirun-0.3.17/urirun/v2_adopt.py +8 -0
  76. urirun-0.3.17/urirun/v2_grpc.py +8 -0
  77. urirun-0.3.17/urirun/v2_mcp.py +8 -0
  78. urirun-0.3.17/urirun/v2_service.py +8 -0
  79. urirun-0.3.17/urirun.egg-info/PKG-INFO +93 -0
  80. urirun-0.3.17/urirun.egg-info/SOURCES.txt +82 -0
  81. urirun-0.3.17/urirun.egg-info/dependency_links.txt +1 -0
  82. urirun-0.3.17/urirun.egg-info/entry_points.txt +4 -0
  83. urirun-0.3.17/urirun.egg-info/requires.txt +19 -0
  84. urirun-0.3.17/urirun.egg-info/top_level.txt +1 -0
urirun-0.3.17/PKG-INFO ADDED
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: urirun
3
+ Version: 0.3.17
4
+ Summary: Language-agnostic URI to handler adapter
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: jsonschema>=4.18
9
+ Requires-Dist: pydantic>=2
10
+ Provides-Extra: grpc
11
+ Requires-Dist: grpcio>=1.60; extra == "grpc"
12
+ Provides-Extra: planfile
13
+ Requires-Dist: planfile>=0.1.103; extra == "planfile"
14
+ Provides-Extra: llm
15
+ Requires-Dist: litellm>=1.60; extra == "llm"
16
+ Provides-Extra: host
17
+ Requires-Dist: planfile>=0.1.103; extra == "host"
18
+ Requires-Dist: litellm>=1.60; extra == "host"
19
+ Provides-Extra: test
20
+ Requires-Dist: planfile>=0.1.103; extra == "test"
21
+ Requires-Dist: pytest>=7; extra == "test"
22
+
23
+ # urirun python adapter
24
+
25
+ Install directly from GitHub:
26
+
27
+ ```bash
28
+ pip install "git+https://github.com/if-uri/urirun.git@v0.3.13#subdirectory=adapters/python"
29
+ ```
30
+
31
+ Or install a GitHub Release wheel:
32
+
33
+ ```bash
34
+ pip install "https://github.com/if-uri/urirun/releases/download/v0.3.13/urirun-0.3.13-py3-none-any.whl"
35
+ ```
36
+
37
+ PyPI publishing is not required. The distribution is named `urirun`; the Python
38
+ import package remains `urirun`:
39
+
40
+ ```python
41
+ import urirun
42
+ ```
43
+
44
+ After installation the `urirun` CLI is available:
45
+
46
+ ```bash
47
+ urirun scan ./project --out .urirun/bindings.v2.json --registry-out .urirun/registry.merged.json
48
+ urirun validate .urirun/bindings.v2.json
49
+ urirun list .urirun/registry.merged.json
50
+ urirun run 'cli://local/git/status' .urirun/registry.merged.json
51
+ ```
52
+
53
+ `urirun-v1` and `urirun-v2` are also installed as explicit versioned entry
54
+ points for scripts that need a stable major-version command.
55
+
56
+ The optional v2 gRPC transport can be installed with:
57
+
58
+ ```bash
59
+ pip install "urirun[grpc] @ git+https://github.com/if-uri/urirun.git@v0.3.13#subdirectory=adapters/python"
60
+ ```
61
+
62
+ v2 can generate schema-first bindings and a compiled registry from existing
63
+ artifacts:
64
+
65
+ ```bash
66
+ urirun scan ./project \
67
+ --out generated/bindings.v2.json \
68
+ --registry-out generated/registry.json
69
+ urirun validate generated/bindings.v2.json
70
+ urirun list generated/registry.json
71
+ ```
72
+
73
+ Connector packages can generate bindings directly from decorated Python
74
+ functions. The shortest path is to declare the connector once and then attach
75
+ short URI paths to functions:
76
+
77
+ ```python
78
+ import urirun
79
+
80
+ connector = urirun.connector("http-check", scheme="httpcheck")
81
+
82
+ @connector.command("http/query/status")
83
+ def status_command(url: str, expectStatus: int = 200, timeout: float = 10.0):
84
+ return ["urirun-http-check", "status", "{url}", "--expect-status", "{expectStatus}"]
85
+
86
+ def urirun_bindings():
87
+ return connector.bindings()
88
+ ```
89
+
90
+
91
+ ## License
92
+
93
+ Licensed under Apache-2.0.
@@ -0,0 +1,71 @@
1
+ # urirun python adapter
2
+
3
+ Install directly from GitHub:
4
+
5
+ ```bash
6
+ pip install "git+https://github.com/if-uri/urirun.git@v0.3.13#subdirectory=adapters/python"
7
+ ```
8
+
9
+ Or install a GitHub Release wheel:
10
+
11
+ ```bash
12
+ pip install "https://github.com/if-uri/urirun/releases/download/v0.3.13/urirun-0.3.13-py3-none-any.whl"
13
+ ```
14
+
15
+ PyPI publishing is not required. The distribution is named `urirun`; the Python
16
+ import package remains `urirun`:
17
+
18
+ ```python
19
+ import urirun
20
+ ```
21
+
22
+ After installation the `urirun` CLI is available:
23
+
24
+ ```bash
25
+ urirun scan ./project --out .urirun/bindings.v2.json --registry-out .urirun/registry.merged.json
26
+ urirun validate .urirun/bindings.v2.json
27
+ urirun list .urirun/registry.merged.json
28
+ urirun run 'cli://local/git/status' .urirun/registry.merged.json
29
+ ```
30
+
31
+ `urirun-v1` and `urirun-v2` are also installed as explicit versioned entry
32
+ points for scripts that need a stable major-version command.
33
+
34
+ The optional v2 gRPC transport can be installed with:
35
+
36
+ ```bash
37
+ pip install "urirun[grpc] @ git+https://github.com/if-uri/urirun.git@v0.3.13#subdirectory=adapters/python"
38
+ ```
39
+
40
+ v2 can generate schema-first bindings and a compiled registry from existing
41
+ artifacts:
42
+
43
+ ```bash
44
+ urirun scan ./project \
45
+ --out generated/bindings.v2.json \
46
+ --registry-out generated/registry.json
47
+ urirun validate generated/bindings.v2.json
48
+ urirun list generated/registry.json
49
+ ```
50
+
51
+ Connector packages can generate bindings directly from decorated Python
52
+ functions. The shortest path is to declare the connector once and then attach
53
+ short URI paths to functions:
54
+
55
+ ```python
56
+ import urirun
57
+
58
+ connector = urirun.connector("http-check", scheme="httpcheck")
59
+
60
+ @connector.command("http/query/status")
61
+ def status_command(url: str, expectStatus: int = 200, timeout: float = 10.0):
62
+ return ["urirun-http-check", "status", "{url}", "--expect-status", "{expectStatus}"]
63
+
64
+ def urirun_bindings():
65
+ return connector.bindings()
66
+ ```
67
+
68
+
69
+ ## License
70
+
71
+ Licensed under Apache-2.0.
@@ -0,0 +1,60 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "urirun"
7
+ version = "0.3.17"
8
+ description = "Language-agnostic URI to handler adapter"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ dependencies = [
13
+ "jsonschema>=4.18",
14
+ "pydantic>=2",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ grpc = ["grpcio>=1.60"]
19
+ planfile = ["planfile>=0.1.103"]
20
+ llm = ["litellm>=1.60"]
21
+ host = ["planfile>=0.1.103", "litellm>=1.60"]
22
+ test = ["planfile>=0.1.103", "pytest>=7"]
23
+
24
+ [project.scripts]
25
+ urirun = "urirun.v2:main"
26
+ urirun-v1 = "urirun.v1:main"
27
+ urirun-v2 = "urirun.v2:main"
28
+
29
+ [tool.setuptools.packages.find]
30
+ where = ["."]
31
+ include = ["urirun*"]
32
+
33
+ [tool.pfix]
34
+ # Self-healing Python configuration
35
+ model = "openrouter/qwen/qwen3-coder-next"
36
+ auto_apply = false
37
+ auto_install_deps = true
38
+ auto_restart = false
39
+ max_retries = 3
40
+ create_backups = false
41
+ git_auto_commit = false
42
+
43
+ [tool.pfix.runtime_todo]
44
+ enabled = true
45
+ todo_file = "TODO.md"
46
+ min_severity = "low"
47
+ deduplicate = true
48
+
49
+ [tool.costs]
50
+ # AI Cost tracking configuration
51
+ badge = true
52
+ update_readme = true
53
+ readme_path = "README.md"
54
+ default_model = "openrouter/qwen/qwen3-coder-next"
55
+ analysis_mode = "byok"
56
+ full_history = true
57
+ max_commits = 500
58
+
59
+ # Cost thresholds for badge colors (USD)
60
+ badge_color_thresholds = { low = 1.0, medium = 5.0, high = 10.0, critical = 50.0 }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,75 @@
1
+ """Adopting a capability-pack manifest as bindings.v2 (least-invasive URI adoption)."""
2
+ import json
3
+ import subprocess
4
+ import sys
5
+ import unittest
6
+
7
+ from urirun import v2
8
+ from urirun.runtime import adopt_pack
9
+
10
+ MANIFEST = {
11
+ "id": "demopack",
12
+ "version": 1,
13
+ "scheme": "demo",
14
+ "uri_patterns": [
15
+ {"pattern": "demo://host/status/query/info", "kind": "query",
16
+ "operation": "demo.status.info", "side_effects": False, "approval": "not_required"},
17
+ {"pattern": "demo://host/task/command/run", "kind": "command",
18
+ "operation": "demo.task.run", "side_effects": True, "approval": "required"},
19
+ ],
20
+ "handlers": {"python": {
21
+ "demo.status.info": "python://demopack.handlers:status_info",
22
+ "demo.task.run": "python://demopack.handlers:task_run",
23
+ }},
24
+ }
25
+
26
+
27
+ class AdoptPackTests(unittest.TestCase):
28
+ def test_manifest_maps_to_bindings(self):
29
+ bindings = {b["uri"]: b for b in adopt_pack.manifest_bindings(MANIFEST)}
30
+ self.assertEqual(set(bindings), {"demo://host/status/query/info", "demo://host/task/command/run"})
31
+ q = bindings["demo://host/status/query/info"]
32
+ self.assertEqual(q["adapter"], "local-function")
33
+ self.assertEqual(q["ref"], "demopack.handlers:status_info")
34
+ self.assertEqual(q["meta"]["uriKind"], "query")
35
+ self.assertNotIn("policy", q) # no side effects / approval
36
+
37
+ def test_side_effects_and_approval_become_policy(self):
38
+ cmd = {b["uri"]: b for b in adopt_pack.manifest_bindings(MANIFEST)}["demo://host/task/command/run"]
39
+ self.assertEqual(cmd["policy"], {"approval": "required", "sideEffects": True})
40
+
41
+ def test_document_validates_and_compiles(self):
42
+ # write a JSON manifest so no YAML dependency is needed
43
+ import tempfile, os
44
+ fd, path = tempfile.mkstemp(suffix=".json")
45
+ os.write(fd, json.dumps(MANIFEST).encode())
46
+ os.close(fd)
47
+ try:
48
+ doc = adopt_pack.adopt(path)
49
+ self.assertEqual(doc["version"], v2.VERSION)
50
+ self.assertEqual(len(doc["bindings"]), 2)
51
+ registry = v2.compile_registry(doc) # must not raise
52
+ self.assertTrue(registry.get("routes") or registry.get("tree") or registry)
53
+ finally:
54
+ os.unlink(path)
55
+
56
+ def test_hydrated_route_executes(self):
57
+ from urirun import _registry as reg, _runtime as rt
58
+ import tempfile, os
59
+ fd, path = tempfile.mkstemp(suffix=".json")
60
+ os.write(fd, json.dumps(MANIFEST).encode())
61
+ os.close(fd)
62
+ try:
63
+ registry = v2.compile_registry(adopt_pack.adopt(path))
64
+ hydrated = reg.hydrate_registry(registry, {
65
+ "demopack.handlers:status_info": lambda t, a, p, d: {"ok": True, "where": t},
66
+ })
67
+ env = rt.run("demo://host/status/query/info", hydrated, mode="execute",
68
+ policy={"execute": {"allow": ["demo://*"]}})
69
+ self.assertTrue(env.get("ok"))
70
+ finally:
71
+ os.unlink(path)
72
+
73
+
74
+ if __name__ == "__main__":
75
+ unittest.main()
@@ -0,0 +1,57 @@
1
+ """Tests for `urirun agent` (action space + planner loop)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ import urirun
8
+ from urirun.runtime import agent
9
+
10
+
11
+ def _registry():
12
+ emit_json = [sys.executable, "-c", "print('{\"ok\": true, \"v\": 1}')"]
13
+ doc = {
14
+ "version": "urirun.bindings.v2",
15
+ "bindings": {
16
+ "demo://host/thing/query/read": {
17
+ "adapter": "argv-template", "kind": "command", "argv": emit_json,
18
+ "inputSchema": {"type": "object", "additionalProperties": False, "properties": {}},
19
+ "meta": {"connector": "demo", "label": "read"}, "uri": "demo://host/thing/query/read",
20
+ },
21
+ "demo://host/thing/command/write": {
22
+ "adapter": "argv-template", "kind": "command", "argv": emit_json,
23
+ "inputSchema": {"type": "object", "additionalProperties": False, "properties": {}},
24
+ "meta": {"connector": "demo", "label": "write"}, "uri": "demo://host/thing/command/write",
25
+ },
26
+ },
27
+ }
28
+ return urirun.compile_registry(doc)
29
+
30
+
31
+ def test_action_space_marks_query_and_command():
32
+ space = {r["uri"]: r for r in agent.action_space(_registry())}
33
+ assert space["demo://host/thing/query/read"]["kind"] == "query"
34
+ assert space["demo://host/thing/command/write"]["kind"] == "command"
35
+
36
+
37
+ def test_run_plan_runs_query_and_gates_command():
38
+ registry = _registry()
39
+ steps = [
40
+ {"uri": "demo://host/thing/query/read", "payload": {}},
41
+ {"uri": "demo://host/thing/command/write", "payload": {}},
42
+ ]
43
+ trace = agent.run_plan(registry, steps, allow_commands=False)
44
+ read, write = trace
45
+ assert read["ran"] is True and read["ok"] is True and read["data"]["v"] == 1
46
+ assert write["ran"] is False # command gated
47
+
48
+
49
+ def test_run_plan_allows_command_with_permission():
50
+ registry = _registry()
51
+ trace = agent.run_plan(registry, [{"uri": "demo://host/thing/command/write", "payload": {}}], allow_commands=True)
52
+ assert trace[0]["ran"] is True and trace[0]["ok"] is True
53
+
54
+
55
+ def test_load_planner_resolves_module_function():
56
+ fn = agent._load_planner("urirun.runtime.agent:action_space")
57
+ assert fn is agent.action_space
@@ -0,0 +1,99 @@
1
+ # Author: Tom Sapletta · https://tom.sapletta.com
2
+ # Part of the ifURI solution.
3
+
4
+ import contextlib
5
+ import io
6
+ import json
7
+ import unittest
8
+ from unittest.mock import patch
9
+
10
+ import urirun
11
+ from urirun import compat
12
+
13
+
14
+ def _healthy_importable(name):
15
+ # backend host/node layers stay in core; namecheap was extracted (removed),
16
+ # its connector replacement is installed.
17
+ if name == "urirun.namecheap_dns":
18
+ return False
19
+ if name == "urirun_connector_namecheap_dns":
20
+ return True
21
+ return bool(name and (name.startswith("urirun.host.") or name.startswith("urirun.node.")))
22
+
23
+
24
+ class CompatReportTests(unittest.TestCase):
25
+ def test_backend_layer_is_kept(self):
26
+ with patch.object(compat, "_entry_point_names", return_value={"namecheap-dns"}), \
27
+ patch.object(compat, "_importable", side_effect=_healthy_importable):
28
+ data = compat.report()
29
+
30
+ host_db = next(m for m in data["modules"] if m["module"] == "urirun.host.host_db")
31
+ self.assertEqual(host_db["owner"], "backend")
32
+ self.assertEqual(host_db["layer"], "host")
33
+ self.assertEqual(host_db["reusedBy"], "urirun-connector-sqlite-context")
34
+ self.assertTrue(host_db["currentImportable"])
35
+ self.assertEqual(host_db["status"], "kept")
36
+
37
+ def test_namecheap_is_extracted(self):
38
+ with patch.object(compat, "_entry_point_names", return_value={"namecheap-dns"}), \
39
+ patch.object(compat, "_importable", side_effect=_healthy_importable):
40
+ data = compat.report()
41
+
42
+ nc = next(m for m in data["modules"] if m["module"] == "urirun.namecheap_dns")
43
+ self.assertEqual(nc["owner"], "extracted")
44
+ self.assertFalse(nc["currentImportable"]) # removed from core
45
+ self.assertTrue(nc["replacementInstalled"])
46
+ self.assertEqual(nc["status"], "extracted")
47
+ self.assertTrue(data["ok"])
48
+
49
+ def test_top_level_api_exposes_compat_report(self):
50
+ data = urirun.compat_report()
51
+ self.assertTrue(data["ok"])
52
+ self.assertTrue(any(m["module"] == "urirun.host.host_integrations" for m in data["modules"]))
53
+
54
+ def test_cli_list_json_reports_node_layer(self):
55
+ with patch.object(compat, "_entry_point_names", return_value={"namecheap-dns"}), \
56
+ patch.object(compat, "_importable", side_effect=_healthy_importable):
57
+ buffer = io.StringIO()
58
+ with contextlib.redirect_stdout(buffer):
59
+ code = compat.main(["list", "--json"])
60
+
61
+ self.assertEqual(code, 0)
62
+ data = json.loads(buffer.getvalue())
63
+ self.assertTrue(data["ok"])
64
+ self.assertGreater(data["backendLayers"], 0)
65
+ self.assertEqual(data["extracted"], 1)
66
+ mesh = next(m for m in data["modules"] if m["module"] == "urirun.node.mesh")
67
+ self.assertEqual(mesh["layer"], "node")
68
+
69
+ def test_cli_check_ok_when_layers_present_and_namecheap_extracted(self):
70
+ with patch.object(compat, "_entry_point_names", return_value={"namecheap-dns"}), \
71
+ patch.object(compat, "_importable", side_effect=_healthy_importable), \
72
+ contextlib.redirect_stdout(io.StringIO()):
73
+ code = compat.main(["check"])
74
+
75
+ self.assertEqual(code, 0)
76
+
77
+ def test_cli_check_nonzero_when_namecheap_replacement_missing(self):
78
+ def importable(name):
79
+ # backend present, but the namecheap connector is NOT installed
80
+ return bool(name and (name.startswith("urirun.host.") or name.startswith("urirun.node.")))
81
+
82
+ with patch.object(compat, "_entry_point_names", return_value=set()), \
83
+ patch.object(compat, "_importable", side_effect=importable), \
84
+ contextlib.redirect_stdout(io.StringIO()):
85
+ code = compat.main(["check"])
86
+
87
+ self.assertEqual(code, 1)
88
+
89
+ def test_cli_check_nonzero_when_backend_layer_missing(self):
90
+ with patch.object(compat, "_entry_point_names", return_value={"namecheap-dns"}), \
91
+ patch.object(compat, "_importable", return_value=False), \
92
+ contextlib.redirect_stdout(io.StringIO()):
93
+ code = compat.main(["check"])
94
+
95
+ self.assertEqual(code, 1)
96
+
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()
@@ -0,0 +1,165 @@
1
+ """Tests for the connect.ifuri.com catalog client (no network)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+
8
+ import pytest
9
+
10
+ from urirun import connect_catalog
11
+
12
+
13
+ CATALOG = {
14
+ "version": "ifuri.connectors.v1",
15
+ "defaultPipSpec": "urirun @ git+https://example/urirun",
16
+ "connectors": [
17
+ {
18
+ "id": "planfile",
19
+ "name": "Planfile Tasks",
20
+ "status": "available",
21
+ "category": "Planning",
22
+ "summary": "Tasks via task:// URIs.",
23
+ "uriSchemes": ["task", "planfile"],
24
+ "routes": ["task://host/tickets/query/list"],
25
+ "install": {"mode": "urirun-extra", "pipSpec": "urirun-connector-planfile @ git+https://example/planfile"},
26
+ },
27
+ {
28
+ "id": "sqlite-context",
29
+ "name": "SQLite Context",
30
+ "status": "available",
31
+ "category": "Data",
32
+ "uriSchemes": ["data"],
33
+ "install": {"mode": "bundled"},
34
+ },
35
+ {
36
+ "id": "mqtt",
37
+ "name": "MQTT",
38
+ "status": "planned",
39
+ "category": "IoT",
40
+ "install": {"mode": "planned", "pipSpec": "urirun-connectors-mqtt"},
41
+ },
42
+ ],
43
+ }
44
+
45
+
46
+ def _args(**kw):
47
+ kw.setdefault("catalog", "https://connect.ifuri.com")
48
+ return argparse.Namespace(**kw)
49
+
50
+
51
+ def test_resolve_install_buckets():
52
+ plan = connect_catalog.resolve_install(CATALOG, ["planfile", "sqlite-context", "mqtt", "ghost"])
53
+ assert [item["pipSpec"] for item in plan["pipSpecs"]] == ["urirun-connector-planfile @ git+https://example/planfile"]
54
+ assert plan["bundled"] == ["sqlite-context"]
55
+ assert [s["id"] for s in plan["skipped"]] == ["mqtt"]
56
+ assert plan["unknown"] == ["ghost"]
57
+
58
+
59
+ def test_pip_install_command_uses_current_interpreter():
60
+ command = connect_catalog.pip_install_command(["pkg-a", "pkg-b"])
61
+ assert command[1:] == ["-m", "pip", "install", "pkg-a", "pkg-b"]
62
+
63
+
64
+ def test_install_dry_run_does_not_run_pip(monkeypatch, capsys):
65
+ monkeypatch.setattr(connect_catalog, "fetch_catalog", lambda base="": CATALOG)
66
+ ran = {"called": False}
67
+ monkeypatch.setattr(connect_catalog.subprocess, "run", lambda *a, **k: ran.__setitem__("called", True))
68
+
69
+ rc = connect_catalog.connectors_command(_args(connectors_command="install", ids=["planfile"], execute=False, json=False))
70
+
71
+ assert rc == 0
72
+ assert ran["called"] is False
73
+ out = capsys.readouterr().out
74
+ assert "dry-run" in out
75
+ assert "urirun-connector-planfile" in out
76
+
77
+
78
+ def test_install_execute_invokes_pip(monkeypatch):
79
+ monkeypatch.setattr(connect_catalog, "fetch_catalog", lambda base="": CATALOG)
80
+ calls = {}
81
+
82
+ class _Result:
83
+ returncode = 0
84
+
85
+ def fake_run(command, *a, **k):
86
+ calls["command"] = command
87
+ return _Result()
88
+
89
+ monkeypatch.setattr(connect_catalog.subprocess, "run", fake_run)
90
+
91
+ rc = connect_catalog.connectors_command(_args(connectors_command="install", ids=["planfile"], execute=True, json=False))
92
+
93
+ assert rc == 0
94
+ assert calls["command"][1:4] == ["-m", "pip", "install"]
95
+ assert "urirun-connector-planfile @ git+https://example/planfile" in calls["command"]
96
+
97
+
98
+ def test_install_unknown_only_returns_error(monkeypatch):
99
+ monkeypatch.setattr(connect_catalog, "fetch_catalog", lambda base="": CATALOG)
100
+ rc = connect_catalog.connectors_command(_args(connectors_command="install", ids=["ghost"], execute=False, json=False))
101
+ assert rc == 1
102
+
103
+
104
+ def test_list_available_filter(monkeypatch, capsys):
105
+ monkeypatch.setattr(connect_catalog, "fetch_catalog", lambda base="": CATALOG)
106
+ rc = connect_catalog.connectors_command(_args(connectors_command="list", available=True, json=False))
107
+ assert rc == 0
108
+ out = capsys.readouterr().out
109
+ assert "planfile" in out
110
+ assert "mqtt" not in out # planned filtered out
111
+
112
+
113
+ def test_show_json(monkeypatch, capsys):
114
+ monkeypatch.setattr(connect_catalog, "fetch_connector", lambda cid, base="": {"connector": CATALOG["connectors"][0], "installCommand": "curl ... | bash"})
115
+ rc = connect_catalog.connectors_command(_args(connectors_command="show", id="planfile", json=True))
116
+ assert rc == 0
117
+ assert '"id": "planfile"' in capsys.readouterr().out
118
+
119
+
120
+ def test_diff_manifest_in_sync():
121
+ entry = CATALOG["connectors"][0]
122
+ assert connect_catalog.diff_manifest(entry, entry) == []
123
+
124
+
125
+ def test_diff_manifest_detects_route_and_pipspec_drift():
126
+ local = json.loads(json.dumps(CATALOG["connectors"][0]))
127
+ local["routes"] = local["routes"] + ["task://host/extra/query/new"]
128
+ local["install"]["pipSpec"] = "urirun-connector-planfile @ git+https://example/planfile@v9"
129
+ diffs = connect_catalog.diff_manifest(local, CATALOG["connectors"][0])
130
+ fields = {d["field"] for d in diffs}
131
+ assert "routes" in fields
132
+ assert "install.pipSpec" in fields
133
+ routes_diff = next(d for d in diffs if d["field"] == "routes")
134
+ assert routes_diff["onlyLocal"] == ["task://host/extra/query/new"]
135
+
136
+
137
+ def test_check_in_sync(monkeypatch, tmp_path, capsys):
138
+ manifest = tmp_path / "connector.manifest.json"
139
+ manifest.write_text(json.dumps(CATALOG["connectors"][0]), encoding="utf-8")
140
+ monkeypatch.setattr(connect_catalog, "fetch_connector", lambda cid, base="": {"connector": CATALOG["connectors"][0]})
141
+ rc = connect_catalog.connectors_command(_args(connectors_command="check", manifest=str(manifest), json=False))
142
+ assert rc == 0
143
+ assert "in sync" in capsys.readouterr().out
144
+
145
+
146
+ def test_check_drift_returns_1(monkeypatch, tmp_path, capsys):
147
+ drifted = json.loads(json.dumps(CATALOG["connectors"][0]))
148
+ drifted["install"]["pipSpec"] = "urirun-connector-planfile @ git+https://example/planfile@vOLD"
149
+ manifest = tmp_path / "connector.manifest.json"
150
+ manifest.write_text(json.dumps(drifted), encoding="utf-8")
151
+ monkeypatch.setattr(connect_catalog, "fetch_connector", lambda cid, base="": {"connector": CATALOG["connectors"][0]})
152
+ rc = connect_catalog.connectors_command(_args(connectors_command="check", manifest=str(manifest), json=False))
153
+ assert rc == 1
154
+ assert "mismatch" in capsys.readouterr().err
155
+
156
+
157
+ def test_catalog_network_error_returns_1(monkeypatch):
158
+ import urllib.error
159
+
160
+ def boom(base="", timeout=10.0):
161
+ raise urllib.error.URLError("offline")
162
+
163
+ monkeypatch.setattr(connect_catalog, "fetch_catalog", boom)
164
+ rc = connect_catalog.connectors_command(_args(connectors_command="list", available=False, json=False))
165
+ assert rc == 1