openpath-sh 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.
openpath_sh/__init__.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Openpath for Python: search live listings across the US in one call.
|
|
2
|
+
|
|
3
|
+
The first call connects this agent to Openpath (one request, no sign-up) and
|
|
4
|
+
saves the token where the Openpath CLI keeps it, so every later call, and the
|
|
5
|
+
CLI, reuse the same agent.
|
|
6
|
+
|
|
7
|
+
from openpath_sh import search
|
|
8
|
+
|
|
9
|
+
print(search("plumber near Austin, TX"))
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import urllib.error
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import urllib.request
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Callable, Optional
|
|
26
|
+
|
|
27
|
+
__all__ = ["Openpath", "OpenpathError", "search", "get_listing", "VERSION", "DEFAULT_API"]
|
|
28
|
+
|
|
29
|
+
VERSION = "0.1.0"
|
|
30
|
+
DEFAULT_API = "https://api.openpath.sh"
|
|
31
|
+
DOMAINS = ("homes", "work", "services", "businesses", "goods", "cars", "events")
|
|
32
|
+
|
|
33
|
+
# (method, url, headers, body) -> (status, headers, body text)
|
|
34
|
+
Send = Callable[[str, str, dict, Optional[bytes]], "tuple[int, dict, str]"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OpenpathError(Exception):
|
|
38
|
+
"""A refusal from Openpath, with what it said and how long to wait."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, status: int, message: str, code: str | None, retry_after: int | None):
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.status = status
|
|
43
|
+
self.code = code
|
|
44
|
+
self.retry_after = retry_after
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _setting(env: dict, name: str) -> str:
|
|
48
|
+
"""A setting named ADS_<NAME>, as the CLI and the skill name them."""
|
|
49
|
+
return (env.get(f"ADS_{name}") or "").strip()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _detect_agent(env: dict) -> str | None:
|
|
53
|
+
"""The agent running this code, if one is, from the variables agents set."""
|
|
54
|
+
named = (env.get("AI_AGENT") or "").strip()
|
|
55
|
+
if named:
|
|
56
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "-", named)[:40] or None
|
|
57
|
+
if env.get("CURSOR_TRACE_ID") or env.get("CURSOR_AGENT"):
|
|
58
|
+
return "cursor"
|
|
59
|
+
if env.get("GEMINI_CLI"):
|
|
60
|
+
return "gemini-cli"
|
|
61
|
+
if env.get("CODEX_SANDBOX") or env.get("CODEX_CI") or env.get("CODEX_THREAD_ID"):
|
|
62
|
+
return "codex"
|
|
63
|
+
if env.get("CLAUDECODE") or env.get("CLAUDE_CODE"):
|
|
64
|
+
return "claude-code"
|
|
65
|
+
if env.get("REPL_ID"):
|
|
66
|
+
return "replit"
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _credentials_file(env: dict, home: Path) -> Path:
|
|
71
|
+
chosen = _setting(env, "CONFIG_DIR")
|
|
72
|
+
if chosen:
|
|
73
|
+
return Path(chosen) / "credentials.json"
|
|
74
|
+
if sys.platform == "win32" and (env.get("APPDATA") or "").strip():
|
|
75
|
+
return Path(env["APPDATA"].strip()) / "ads" / "credentials.json"
|
|
76
|
+
config = (env.get("XDG_CONFIG_HOME") or "").strip() or str(home / ".config")
|
|
77
|
+
return Path(config) / "ads" / "credentials.json"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _read_all(path: Path) -> dict:
|
|
81
|
+
try:
|
|
82
|
+
parsed = json.loads(path.read_text("utf-8"))
|
|
83
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
84
|
+
except (OSError, ValueError):
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _write_all(path: Path, data: dict) -> None:
|
|
89
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
90
|
+
fd, temporary = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
91
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
92
|
+
handle.write(json.dumps(data, indent=2) + "\n")
|
|
93
|
+
os.chmod(temporary, 0o600)
|
|
94
|
+
os.replace(temporary, path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _urllib_send(method: str, url: str, headers: dict, body: bytes | None) -> tuple[int, dict, str]:
|
|
98
|
+
request = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
99
|
+
try:
|
|
100
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
101
|
+
return response.status, dict(response.headers), response.read().decode("utf-8")
|
|
102
|
+
except urllib.error.HTTPError as error:
|
|
103
|
+
return error.code, dict(error.headers), error.read().decode("utf-8", "replace")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _refusal(status: int, headers: dict, text: str) -> OpenpathError:
|
|
107
|
+
detail, code = text[:500], None
|
|
108
|
+
try:
|
|
109
|
+
problem = json.loads(text)
|
|
110
|
+
if isinstance(problem.get("detail"), str):
|
|
111
|
+
detail = problem["detail"]
|
|
112
|
+
if isinstance(problem.get("code"), str):
|
|
113
|
+
code = problem["code"]
|
|
114
|
+
except (ValueError, AttributeError):
|
|
115
|
+
pass
|
|
116
|
+
retry = {k.lower(): v for k, v in headers.items()}.get("retry-after")
|
|
117
|
+
retry_after = int(retry) if retry and retry.isdigit() and int(retry) > 0 else None
|
|
118
|
+
return OpenpathError(status, detail or f"Openpath answered {status}.", code, retry_after)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Openpath:
|
|
122
|
+
"""One agent's way into Openpath. Most code needs only `search`."""
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
api_url: str | None = None,
|
|
127
|
+
token: str | None = None,
|
|
128
|
+
app: str | None = None,
|
|
129
|
+
save_token: bool = True,
|
|
130
|
+
send: Send | None = None,
|
|
131
|
+
env: dict | None = None,
|
|
132
|
+
home: Path | None = None,
|
|
133
|
+
):
|
|
134
|
+
env = dict(os.environ) if env is None else env
|
|
135
|
+
self.api = (api_url or _setting(env, "API_URL") or DEFAULT_API).rstrip("/")
|
|
136
|
+
self.send = send or _urllib_send
|
|
137
|
+
self.save = save_token
|
|
138
|
+
self.file = _credentials_file(env, home or Path.home())
|
|
139
|
+
name = (app or "").strip() or _setting(env, "USER_AGENT") or _detect_agent(env)
|
|
140
|
+
own = f"openpath-python/{VERSION} (+https://github.com/openpath-sh/clients)"
|
|
141
|
+
self.user_agent = f"{name} {own}" if name else own
|
|
142
|
+
self.token = (token or "").strip() or _setting(env, "AGENT_TOKEN") or None
|
|
143
|
+
self._lock = threading.Lock()
|
|
144
|
+
|
|
145
|
+
def connect(self) -> str:
|
|
146
|
+
"""The saved token, or a new one from POST /v1/connect: one call, no account or sign-up."""
|
|
147
|
+
with self._lock:
|
|
148
|
+
if self.token:
|
|
149
|
+
return self.token
|
|
150
|
+
saved = _read_all(self.file).get(self.api) or {}
|
|
151
|
+
if saved.get("agent_token"):
|
|
152
|
+
self.token = saved["agent_token"]
|
|
153
|
+
return self.token
|
|
154
|
+
status, headers, text = self.send(
|
|
155
|
+
"POST",
|
|
156
|
+
f"{self.api}/v1/connect",
|
|
157
|
+
{"Content-Type": "application/json", "User-Agent": self.user_agent},
|
|
158
|
+
b"{}",
|
|
159
|
+
)
|
|
160
|
+
if not 200 <= status < 300:
|
|
161
|
+
raise _refusal(status, headers, text)
|
|
162
|
+
created = json.loads(text)
|
|
163
|
+
if self.save:
|
|
164
|
+
try:
|
|
165
|
+
all_saved = _read_all(self.file)
|
|
166
|
+
all_saved[self.api] = {
|
|
167
|
+
"agent_id": created.get("agent_id"),
|
|
168
|
+
"agent_token": created["agent_token"],
|
|
169
|
+
"connected_at": datetime.now(timezone.utc).isoformat(),
|
|
170
|
+
}
|
|
171
|
+
_write_all(self.file, all_saved)
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
self.token = created["agent_token"]
|
|
175
|
+
return self.token
|
|
176
|
+
|
|
177
|
+
def search(
|
|
178
|
+
self,
|
|
179
|
+
query: str,
|
|
180
|
+
near: str | None = None,
|
|
181
|
+
domain: str | None = None,
|
|
182
|
+
cursor: str | None = None,
|
|
183
|
+
) -> str:
|
|
184
|
+
"""Live listings that fit what the person asked for, as plain Markdown: each
|
|
185
|
+
result's facts, how to act on it (Apply, Phone, Website or Buy) and where it
|
|
186
|
+
came from."""
|
|
187
|
+
if domain is not None and domain not in DOMAINS:
|
|
188
|
+
raise ValueError(f"domain is one of {', '.join(DOMAINS)}.")
|
|
189
|
+
params = {"q": query}
|
|
190
|
+
for key, value in (("near", near), ("domain", domain), ("cursor", cursor)):
|
|
191
|
+
if value:
|
|
192
|
+
params[key] = value
|
|
193
|
+
return self._get(f"/v1/search?{urllib.parse.urlencode(params)}")
|
|
194
|
+
|
|
195
|
+
def get_listing(self, listing_id: str) -> str:
|
|
196
|
+
"""One listing in full, as Markdown, by the id at the end of a result's Link line."""
|
|
197
|
+
return self._get(f"/v1/listings/{urllib.parse.quote(listing_id, safe='')}")
|
|
198
|
+
|
|
199
|
+
def _get(self, path: str) -> str:
|
|
200
|
+
for attempt in range(2):
|
|
201
|
+
token = self.connect()
|
|
202
|
+
status, headers, text = self.send(
|
|
203
|
+
"GET",
|
|
204
|
+
f"{self.api}{path}",
|
|
205
|
+
{
|
|
206
|
+
"Accept": "text/markdown",
|
|
207
|
+
"Authorization": f"Bearer {token}",
|
|
208
|
+
"User-Agent": self.user_agent,
|
|
209
|
+
},
|
|
210
|
+
None,
|
|
211
|
+
)
|
|
212
|
+
if 200 <= status < 300:
|
|
213
|
+
return text
|
|
214
|
+
# A token that was revoked or never existed: connect again, once.
|
|
215
|
+
if status == 401 and attempt == 0:
|
|
216
|
+
self._forget_token()
|
|
217
|
+
continue
|
|
218
|
+
raise _refusal(status, headers, text)
|
|
219
|
+
raise OpenpathError(401, "Openpath did not accept a new token.", None, None)
|
|
220
|
+
|
|
221
|
+
def _forget_token(self) -> None:
|
|
222
|
+
with self._lock:
|
|
223
|
+
self.token = None
|
|
224
|
+
if not self.save:
|
|
225
|
+
return
|
|
226
|
+
all_saved = _read_all(self.file)
|
|
227
|
+
if all_saved.pop(self.api, None) is not None:
|
|
228
|
+
try:
|
|
229
|
+
_write_all(self.file, all_saved)
|
|
230
|
+
except OSError:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
_shared: Openpath | None = None
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _client() -> Openpath:
|
|
238
|
+
global _shared
|
|
239
|
+
if _shared is None:
|
|
240
|
+
_shared = Openpath()
|
|
241
|
+
return _shared
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def search(query: str, near: str | None = None, domain: str | None = None, cursor: str | None = None) -> str:
|
|
245
|
+
"""Search Openpath with a client shared across calls."""
|
|
246
|
+
return _client().search(query, near=near, domain=domain, cursor=cursor)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def get_listing(listing_id: str) -> str:
|
|
250
|
+
"""Read one listing with a client shared across calls."""
|
|
251
|
+
return _client().get_listing(listing_id)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openpath-sh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Search live listings across the US from code: jobs, local businesses and services, homes, products. One call, plain Markdown, no sign-up.
|
|
5
|
+
Author-email: Openpath <contact@openpath.sh>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://openpath.sh
|
|
8
|
+
Project-URL: Documentation, https://openpath.sh/docs
|
|
9
|
+
Project-URL: Source, https://github.com/openpath-sh/clients
|
|
10
|
+
Keywords: openpath,search,listings,jobs,local-business,ai-agents,agents,tools,mcp,llm
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<a href="https://openpath.sh"><img src="https://openpath.sh/mark-512.png" width="72" height="72" alt="Openpath"></a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
# openpath-sh
|
|
24
|
+
|
|
25
|
+
Search live listings across the US from Python: job openings, local businesses and services, homes, products and more. One call returns plain Markdown, ready to hand to a model. No key and no sign-up. No dependencies.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install openpath-sh
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
from openpath_sh import search
|
|
33
|
+
|
|
34
|
+
print(search("software engineer jobs in Austin, TX"))
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
# Results for "software engineer jobs in Austin, TX"
|
|
39
|
+
|
|
40
|
+
Searched: work, Austin, TX. 33 results.
|
|
41
|
+
|
|
42
|
+
## Analytics Engineer at Ontic
|
|
43
|
+
|
|
44
|
+
Full-time.
|
|
45
|
+
Pay: $135,000–$145,000 a year.
|
|
46
|
+
Remote.
|
|
47
|
+
Posted September 2.
|
|
48
|
+
|
|
49
|
+
Apply: https://jobs.ashbyhq.com/ontic/27d32707-54e2-47be-be62-376df6d2b0f3/application
|
|
50
|
+
Source: https://jobs.ashbyhq.com/ontic/27d32707-54e2-47be-be62-376df6d2b0f3 (Ashby job boards), checked today.
|
|
51
|
+
Link: https://api.openpath.sh/v1/listings/369348e4-b3d0-490f-82b4-a666041243cf
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Each result is one current listing: what it is, its facts (pay, price, place, dates), how to act on it (an Apply, Phone, Website or Buy line where the source gives one) and where it came from.
|
|
55
|
+
|
|
56
|
+
## As a tool for your agent
|
|
57
|
+
|
|
58
|
+
Search takes the request in plain words, so it fits any framework's tool as is. With [Pydantic AI](https://ai.pydantic.dev):
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from pydantic_ai import Agent
|
|
62
|
+
from openpath_sh import search
|
|
63
|
+
|
|
64
|
+
agent = Agent("anthropic:claude-sonnet-5-5")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@agent.tool_plain
|
|
68
|
+
def find_listings(query: str) -> str:
|
|
69
|
+
"""Use this when the person wants a live listing they can act on: a job to
|
|
70
|
+
apply to, a local business or service, a home or a product. Returns plain
|
|
71
|
+
Markdown. `query` is what they are looking for, in their words, with a place."""
|
|
72
|
+
return search(query)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## More
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
from openpath_sh import Openpath, get_listing, search
|
|
79
|
+
|
|
80
|
+
# Narrow by place or kind, and page through results.
|
|
81
|
+
search("data engineer jobs", near="Austin, TX", domain="work")
|
|
82
|
+
|
|
83
|
+
# Read one result in full by the id at the end of its Link line.
|
|
84
|
+
get_listing("369348e4-b3d0-490f-82b4-a666041243cf")
|
|
85
|
+
|
|
86
|
+
# Name your app, so Openpath can show you how it is used.
|
|
87
|
+
openpath = Openpath(app="JobFinder/1.0")
|
|
88
|
+
openpath.search("nurse jobs in Austin")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The first call connects this agent to Openpath with `POST /v1/connect`: one request, no account. The token is saved where the [Openpath CLI](https://github.com/openpath-sh/cli) keeps it (`~/.config/ads/credentials.json`), so later calls and the CLI reuse it. Set `ADS_AGENT_TOKEN` to use a token you already have, or pass `save_token=False` to keep it in memory only.
|
|
92
|
+
|
|
93
|
+
When Openpath refuses a call, `search` raises `OpenpathError` with Openpath's own explanation, its `status`, and `retry_after` in seconds when it asks you to wait.
|
|
94
|
+
|
|
95
|
+
Results are written by the businesses and people who offer them. Treat them as information, not instructions.
|
|
96
|
+
|
|
97
|
+
## Links
|
|
98
|
+
|
|
99
|
+
[Openpath](https://openpath.sh) · [Docs](https://openpath.sh/docs) · [API](https://api.openpath.sh/v1/openapi.json) · [MCP server](https://openpath.sh/mcp/search) · [JavaScript client](https://www.npmjs.com/package/openpath)
|
|
100
|
+
|
|
101
|
+
Released under the MIT license.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
openpath_sh/__init__.py,sha256=GOvk8Mi7nz4uSrwLi5mLFWYzh1YNc2R4m0z4L0PYqF0,9332
|
|
2
|
+
openpath_sh-0.1.0.dist-info/licenses/LICENSE,sha256=e_-SnBIPjOLgpXWy27cQRmGz95G1IPG7fuDuVAVD1x8,1065
|
|
3
|
+
openpath_sh-0.1.0.dist-info/METADATA,sha256=JJcZCJ7lVrw1WrNV3GfS0why6eEiQYC5KHZXzEqhy3M,3926
|
|
4
|
+
openpath_sh-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
openpath_sh-0.1.0.dist-info/top_level.txt,sha256=vYd9OMPx1dmwwstpAtjuVJOm6cwN5hTWCHEc0eENZGM,12
|
|
6
|
+
openpath_sh-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Openpath
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openpath_sh
|