oculr 0.1.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.
oculr-0.1.0/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .DS_Store
oculr-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: oculr
3
+ Version: 0.1.0
4
+ Summary: Client for the Oculr local Automation API: launch real browser profiles, pick a per-launch stealth level, and connect over CDP.
5
+ Project-URL: Homepage, https://oculr.ai
6
+ Project-URL: Documentation, https://oculr.ai/developers
7
+ Project-URL: Repository, https://github.com/oculr-ai/oculr-sdk
8
+ License: MIT
9
+ Keywords: ai-agent,ai-agents,anti-detect,antidetect,antidetect-browser,automation,browser,browser-automation,cdp,fingerprint,mcp,model-context-protocol,oculr,playwright,puppeteer,stealth
10
+ Requires-Python: >=3.9
11
+ Provides-Extra: playwright
12
+ Requires-Dist: playwright; extra == 'playwright'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # oculr (Python)
16
+
17
+ Thin Python client for the **Oculr local Automation API**. Launch real browser
18
+ profiles on your own machine, choose a per-launch stealth level, and connect over
19
+ CDP with Playwright or Selenium.
20
+
21
+ Zero runtime dependencies (stdlib only). Playwright is optional, for the
22
+ `connect_playwright()` helper.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install oculr
28
+ # optional, for connect_playwright():
29
+ pip install playwright
30
+ ```
31
+
32
+ ## Usage
33
+
34
+ ```python
35
+ import os
36
+ from oculr import OculrClient
37
+ from playwright.sync_api import sync_playwright
38
+
39
+ # The desktop app shows the port and token under Settings.
40
+ oculr = OculrClient("http://127.0.0.1:8378", token=os.environ["OCULR_TOKEN"])
41
+
42
+ # Low-level: launch and get a ready CDP endpoint + driver recipes.
43
+ res = oculr.launch("de-store-01", stealth="balanced", headless=False)
44
+ print(res["ws_endpoint"])
45
+
46
+ # High-level: launch and connect a Playwright browser in one call.
47
+ with sync_playwright() as p:
48
+ browser, _ = oculr.connect_playwright(p, "de-store-01", stealth="max")
49
+ page = browser.contexts[0].pages[0]
50
+ page.goto("https://example.com")
51
+ oculr.stop("de-store-01")
52
+ ```
53
+
54
+ ### Stealth dial
55
+
56
+ `stealth` is chosen per launch: `"off"` (clean Chromium, no fingerprint
57
+ injection), `"balanced"` (native fingerprints, the default) or `"max"` (strongest
58
+ noise). Overrides apply to that launch only and are never written back to the
59
+ profile.
60
+
61
+ ## API
62
+
63
+ `list_profiles()`, `create_profile(**body)`, `get_profile(id)`,
64
+ `update_profile(id, **body)`, `delete_profile(id)`, `generate_profile(**body)`,
65
+ `launch(id, **options)`, `stop(id)`, `status()`,
66
+ `connect_playwright(playwright, id, **options)`.
67
+
68
+ Errors raise `OculrError` with `.status` and `.body`.
oculr-0.1.0/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # oculr (Python)
2
+
3
+ Thin Python client for the **Oculr local Automation API**. Launch real browser
4
+ profiles on your own machine, choose a per-launch stealth level, and connect over
5
+ CDP with Playwright or Selenium.
6
+
7
+ Zero runtime dependencies (stdlib only). Playwright is optional, for the
8
+ `connect_playwright()` helper.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install oculr
14
+ # optional, for connect_playwright():
15
+ pip install playwright
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```python
21
+ import os
22
+ from oculr import OculrClient
23
+ from playwright.sync_api import sync_playwright
24
+
25
+ # The desktop app shows the port and token under Settings.
26
+ oculr = OculrClient("http://127.0.0.1:8378", token=os.environ["OCULR_TOKEN"])
27
+
28
+ # Low-level: launch and get a ready CDP endpoint + driver recipes.
29
+ res = oculr.launch("de-store-01", stealth="balanced", headless=False)
30
+ print(res["ws_endpoint"])
31
+
32
+ # High-level: launch and connect a Playwright browser in one call.
33
+ with sync_playwright() as p:
34
+ browser, _ = oculr.connect_playwright(p, "de-store-01", stealth="max")
35
+ page = browser.contexts[0].pages[0]
36
+ page.goto("https://example.com")
37
+ oculr.stop("de-store-01")
38
+ ```
39
+
40
+ ### Stealth dial
41
+
42
+ `stealth` is chosen per launch: `"off"` (clean Chromium, no fingerprint
43
+ injection), `"balanced"` (native fingerprints, the default) or `"max"` (strongest
44
+ noise). Overrides apply to that launch only and are never written back to the
45
+ profile.
46
+
47
+ ## API
48
+
49
+ `list_profiles()`, `create_profile(**body)`, `get_profile(id)`,
50
+ `update_profile(id, **body)`, `delete_profile(id)`, `generate_profile(**body)`,
51
+ `launch(id, **options)`, `stop(id)`, `status()`,
52
+ `connect_playwright(playwright, id, **options)`.
53
+
54
+ Errors raise `OculrError` with `.status` and `.body`.
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "oculr"
7
+ version = "0.1.0"
8
+ description = "Client for the Oculr local Automation API: launch real browser profiles, pick a per-launch stealth level, and connect over CDP."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["oculr", "browser", "automation", "browser-automation", "antidetect", "anti-detect", "antidetect-browser", "fingerprint", "stealth", "playwright", "puppeteer", "cdp", "mcp", "model-context-protocol", "ai-agent", "ai-agents"]
13
+ dependencies = []
14
+
15
+ [project.urls]
16
+ Homepage = "https://oculr.ai"
17
+ Documentation = "https://oculr.ai/developers"
18
+ Repository = "https://github.com/oculr-ai/oculr-sdk"
19
+
20
+ [project.optional-dependencies]
21
+ playwright = ["playwright"]
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/oculr"]
@@ -0,0 +1,145 @@
1
+ """oculr — a thin client for the Oculr local Automation API.
2
+
3
+ Zero runtime dependencies (stdlib ``urllib``). Point it at your local agent (the
4
+ desktop app shows the port and token under Settings) and you get a ready CDP
5
+ endpoint back, which any driver can attach to.
6
+
7
+ from oculr import OculrClient
8
+ from playwright.sync_api import sync_playwright
9
+
10
+ oculr = OculrClient.from_agent_file() # auto-discovers base_url + token
11
+ res = oculr.launch("de-store-01", stealth="balanced", headless=False)
12
+
13
+ with sync_playwright() as p:
14
+ browser, _ = oculr.connect_playwright(p, "de-store-01", stealth="max")
15
+ page = browser.contexts[0].pages[0]
16
+ page.goto("https://example.com")
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import urllib.error
24
+ import urllib.request
25
+ from typing import Any, Optional
26
+
27
+ __all__ = ["OculrClient", "OculrError"]
28
+ __version__ = "0.1.0"
29
+
30
+
31
+ class OculrError(Exception):
32
+ """Raised on a non-2xx response from the Oculr API."""
33
+
34
+ def __init__(self, status: int, body: Any, message: str) -> None:
35
+ super().__init__(message)
36
+ self.status = status
37
+ self.body = body
38
+
39
+
40
+ class OculrClient:
41
+ """Client for the Oculr local Automation API.
42
+
43
+ :param base_url: Base URL of the local agent, e.g. ``http://127.0.0.1:8765``.
44
+ :param token: Bearer token from the desktop app Settings (omit if auth is off).
45
+ """
46
+
47
+ def __init__(self, base_url: str, token: Optional[str] = None, timeout: float = 30.0) -> None:
48
+ if not base_url:
49
+ raise ValueError("OculrClient: base_url is required")
50
+ self.base_url = base_url.rstrip("/")
51
+ self.token = token
52
+ self.timeout = timeout
53
+
54
+ @classmethod
55
+ def from_agent_file(cls, path: Optional[str] = None, timeout: float = 30.0) -> "OculrClient":
56
+ """Build a client from the running agent's 0600 runtime descriptor.
57
+
58
+ The desktop app (and ``oculr-agent serve``) writes an ``agent.json`` with
59
+ the agent's ``base_url`` and — when token publishing is on — its auth
60
+ ``token``, so you don't have to copy them from Settings. Searches
61
+ ``$OCULR_RUNTIME_FILE``, then ``$OCULR_DATA_DIR/agent.json``, then
62
+ ``~/.oculr/data/agent.json``. Raises :class:`OculrError` if none is found.
63
+ """
64
+ candidates = []
65
+ if path:
66
+ candidates.append(path)
67
+ if os.environ.get("OCULR_RUNTIME_FILE"):
68
+ candidates.append(os.environ["OCULR_RUNTIME_FILE"])
69
+ if os.environ.get("OCULR_DATA_DIR"):
70
+ candidates.append(os.path.join(os.environ["OCULR_DATA_DIR"], "agent.json"))
71
+ candidates.append(os.path.expanduser("~/.oculr/data/agent.json"))
72
+ for candidate in candidates:
73
+ try:
74
+ with open(candidate, encoding="utf-8") as fh:
75
+ desc = json.load(fh)
76
+ except (OSError, ValueError):
77
+ continue
78
+ base = desc.get("base_url")
79
+ if base:
80
+ return cls(base, token=desc.get("token"), timeout=timeout)
81
+ raise OculrError(0, None, "no Oculr agent descriptor found (is the app running?)")
82
+
83
+ def _request(self, method: str, path: str, body: Any = None) -> Any:
84
+ data = json.dumps(body).encode() if body is not None else None
85
+ req = urllib.request.Request(self.base_url + path, data=data, method=method)
86
+ req.add_header("Content-Type", "application/json")
87
+ if self.token:
88
+ req.add_header("Authorization", f"Bearer {self.token}")
89
+ try:
90
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
91
+ text = resp.read().decode()
92
+ return json.loads(text) if text else None
93
+ except urllib.error.HTTPError as e: # pragma: no cover - exercised via mock
94
+ text = e.read().decode()
95
+ parsed = json.loads(text) if text else None
96
+ raise OculrError(e.code, parsed, f"Oculr {method} {path} failed: {e.code}") from None
97
+
98
+ # ── Profiles ────────────────────────────────────────────────────────────
99
+ def list_profiles(self) -> Any:
100
+ return self._request("GET", "/api/profiles")
101
+
102
+ def create_profile(self, **body: Any) -> Any:
103
+ return self._request("POST", "/api/profiles", body)
104
+
105
+ def get_profile(self, profile_id: str) -> Any:
106
+ return self._request("GET", f"/api/profiles/{profile_id}")
107
+
108
+ def update_profile(self, profile_id: str, **body: Any) -> Any:
109
+ return self._request("PUT", f"/api/profiles/{profile_id}", body)
110
+
111
+ def delete_profile(self, profile_id: str) -> Any:
112
+ return self._request("DELETE", f"/api/profiles/{profile_id}")
113
+
114
+ def generate_profile(self, **body: Any) -> Any:
115
+ """Generate a coherent random fingerprint."""
116
+ return self._request("POST", "/api/profiles/generate", body or {})
117
+
118
+ # ── Lifecycle ───────────────────────────────────────────────────────────
119
+ def launch(self, profile_id: str, **options: Any) -> Any:
120
+ """Launch a profile. With no options the stored profile defaults are used.
121
+
122
+ Options (all per-launch, never written back): ``stealth`` ("off" |
123
+ "balanced" | "max"), ``headless``, ``proxy``, ``args``, ``window``,
124
+ ``automation``, ``ephemeral``.
125
+ """
126
+ return self._request("POST", f"/api/profiles/{profile_id}/launch", options or None)
127
+
128
+ def stop(self, profile_id: str) -> Any:
129
+ return self._request("POST", f"/api/profiles/{profile_id}/stop")
130
+
131
+ def status(self) -> Any:
132
+ return self._request("GET", "/api/status")
133
+
134
+ # ── High-level ──────────────────────────────────────────────────────────
135
+ def connect_playwright(self, playwright: Any, profile_id: str, **options: Any):
136
+ """Launch a profile and connect a Playwright browser over CDP.
137
+
138
+ Pass the value from ``sync_playwright()``. Returns ``(browser, response)``.
139
+ """
140
+ res = self.launch(profile_id, **options)
141
+ ws = (res or {}).get("ws_endpoint")
142
+ if not ws:
143
+ raise OculrError(0, res, "launch did not return a ws_endpoint")
144
+ browser = playwright.chromium.connect_over_cdp(ws)
145
+ return browser, res
@@ -0,0 +1,76 @@
1
+ import json
2
+
3
+ import urllib.error
4
+ import urllib.request
5
+
6
+ import pytest
7
+
8
+ from oculr import OculrClient, OculrError
9
+
10
+
11
+ class _FakeResp:
12
+ def __init__(self, text: str):
13
+ self._t = text
14
+
15
+ def read(self):
16
+ return self._t.encode()
17
+
18
+ def __enter__(self):
19
+ return self
20
+
21
+ def __exit__(self, *a):
22
+ return False
23
+
24
+
25
+ def test_launch_posts_options_and_parses(monkeypatch):
26
+ captured = {}
27
+
28
+ def fake_urlopen(req, timeout=None):
29
+ captured["req"] = req
30
+ return _FakeResp(json.dumps({
31
+ "profile_id": "p1",
32
+ "status": "running",
33
+ "ws_endpoint": "ws://127.0.0.1:8378/api/profiles/p1/cdp",
34
+ }))
35
+
36
+ monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
37
+ client = OculrClient("http://127.0.0.1:8378/", token="tok")
38
+ res = client.launch("p1", stealth="max", headless=True)
39
+
40
+ assert res["ws_endpoint"].endswith("/cdp")
41
+ req = captured["req"]
42
+ assert req.full_url == "http://127.0.0.1:8378/api/profiles/p1/launch"
43
+ assert req.get_method() == "POST"
44
+ assert req.headers["Authorization"] == "Bearer tok"
45
+ assert json.loads(req.data.decode()) == {"stealth": "max", "headless": True}
46
+
47
+
48
+ def test_launch_without_options_sends_no_body(monkeypatch):
49
+ captured = {}
50
+
51
+ def fake_urlopen(req, timeout=None):
52
+ captured["req"] = req
53
+ return _FakeResp(json.dumps({"profile_id": "p1", "status": "running"}))
54
+
55
+ monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
56
+ client = OculrClient("http://127.0.0.1:8378")
57
+ client.launch("p1")
58
+ assert captured["req"].data is None
59
+
60
+
61
+ def test_http_error_raises_oculr_error(monkeypatch):
62
+ def fake_urlopen(req, timeout=None):
63
+ raise urllib.error.HTTPError(req.full_url, 404, "Not Found", {}, _io_bytes(b'{"detail":"nope"}'))
64
+
65
+ monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
66
+ client = OculrClient("http://127.0.0.1:8378")
67
+ with pytest.raises(OculrError) as ei:
68
+ client.get_profile("nope")
69
+ assert ei.value.status == 404
70
+ assert ei.value.body == {"detail": "nope"}
71
+
72
+
73
+ def _io_bytes(b: bytes):
74
+ import io
75
+
76
+ return io.BytesIO(b)