pdatum 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.
- pdatum/__init__.py +3 -0
- pdatum/__main__.py +3 -0
- pdatum/cli.py +521 -0
- pdatum/client.py +113 -0
- pdatum/config.py +107 -0
- pdatum/output.py +79 -0
- pdatum/timespec.py +48 -0
- pdatum-0.1.0.dist-info/METADATA +89 -0
- pdatum-0.1.0.dist-info/RECORD +13 -0
- pdatum-0.1.0.dist-info/WHEEL +5 -0
- pdatum-0.1.0.dist-info/entry_points.txt +2 -0
- pdatum-0.1.0.dist-info/licenses/LICENSE +21 -0
- pdatum-0.1.0.dist-info/top_level.txt +1 -0
pdatum/__init__.py
ADDED
pdatum/__main__.py
ADDED
pdatum/cli.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The `pdatum` command.
|
|
3
|
+
|
|
4
|
+
Built on pkanban's model (typer, rich, `--json` everywhere, errors on stderr),
|
|
5
|
+
with the fixes pkanban needed: Click's Windows expansion of `~` and wildcards
|
|
6
|
+
is off, `--json` and `--api-key` are only taken from where they can be ours
|
|
7
|
+
(never from another option's value, never after `--`), and long text never
|
|
8
|
+
has to pass through a shell.
|
|
9
|
+
|
|
10
|
+
Streams -- `jobs pull`, `jobs changes`, `employers pull` -- write JSON lines to
|
|
11
|
+
stdout and progress to stderr, in either mode, so they can be piped straight
|
|
12
|
+
into a file or another tool.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import errno
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
from rich.table import Table
|
|
22
|
+
|
|
23
|
+
from pdatum import __version__, config, timespec
|
|
24
|
+
from pdatum.client import Client, PdatumError
|
|
25
|
+
from pdatum.output import (
|
|
26
|
+
configure_streams,
|
|
27
|
+
console,
|
|
28
|
+
emit,
|
|
29
|
+
emit_error,
|
|
30
|
+
line,
|
|
31
|
+
progress,
|
|
32
|
+
set_json_output,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
app = typer.Typer(
|
|
36
|
+
help="Job postings and the employers behind them, for scripts and agents. "
|
|
37
|
+
"Start with: pdatum skill",
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
add_completion=False,
|
|
40
|
+
)
|
|
41
|
+
jobs_app = typer.Typer(help="Job postings.", no_args_is_help=True)
|
|
42
|
+
employers_app = typer.Typer(help="Employer records, with facts and their sources.", no_args_is_help=True)
|
|
43
|
+
key_app = typer.Typer(help="Store or forget your API key.", no_args_is_help=True)
|
|
44
|
+
app.add_typer(jobs_app, name="jobs")
|
|
45
|
+
app.add_typer(employers_app, name="employers")
|
|
46
|
+
app.add_typer(key_app, name="key")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _version(value: bool):
|
|
50
|
+
if value:
|
|
51
|
+
emit({"version": __version__}, lambda: console.print(f"pdatum {__version__}"))
|
|
52
|
+
raise typer.Exit()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.callback()
|
|
56
|
+
def root(
|
|
57
|
+
json_out: bool = typer.Option(
|
|
58
|
+
False, "--json", help="Print results as JSON. Or set PDATUM_OUTPUT=json."
|
|
59
|
+
),
|
|
60
|
+
api_key: Optional[str] = typer.Option(
|
|
61
|
+
None, "--api-key", "-k", help="Use this key for this command only."
|
|
62
|
+
),
|
|
63
|
+
version: bool = typer.Option(
|
|
64
|
+
False, "--version", "-V", callback=_version, is_eager=True, help="Show the version."
|
|
65
|
+
),
|
|
66
|
+
):
|
|
67
|
+
"""pdatum"""
|
|
68
|
+
# main() usually takes these off argv before typer sees them, so they work
|
|
69
|
+
# after the subcommand too; declaring them here puts them in --help.
|
|
70
|
+
if json_out:
|
|
71
|
+
set_json_output(True)
|
|
72
|
+
if api_key:
|
|
73
|
+
config.set_runtime_key(api_key)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def client(need_key=True):
|
|
77
|
+
key, _ = config.api_key()
|
|
78
|
+
if need_key and not key:
|
|
79
|
+
raise PdatumError(
|
|
80
|
+
"No API key. Set PDATUM_API_KEY, pass --api-key, or run: pdatum key save <key>",
|
|
81
|
+
code="missing_key",
|
|
82
|
+
)
|
|
83
|
+
return Client(config.base_url(), key)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def when(value, name):
|
|
87
|
+
"""A --posted-since style option, parsed, or a usage error that says why."""
|
|
88
|
+
if value is None:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
return timespec.parse(value)
|
|
92
|
+
except ValueError as e:
|
|
93
|
+
raise typer.BadParameter(str(e), param_hint=name)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# -- shared options -------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
BRAND = typer.Option(None, "--brand", help="jobwolverine or rxraven. Both if omitted.")
|
|
99
|
+
EMPLOYER = typer.Option(None, "--employer", help="An employer slug (see: pdatum employers list).")
|
|
100
|
+
QUERY = typer.Option(None, "--q", "-q", help="Text in the position or company.")
|
|
101
|
+
LOCATION = typer.Option(None, "--location", help="Text in the location.")
|
|
102
|
+
REMOTE = typer.Option(None, "--remote/--not-remote", help="Only remote jobs, or only not.")
|
|
103
|
+
SINCE = typer.Option(None, "--posted-since", help="2026-09-01, 7d, or epoch seconds.")
|
|
104
|
+
BEFORE = typer.Option(None, "--posted-before", help="2026-09-01, 7d, or epoch seconds.")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def job_filters(brand, employer, q, location, remote, posted_since, posted_before):
|
|
108
|
+
return dict(
|
|
109
|
+
brand=brand,
|
|
110
|
+
employer=employer,
|
|
111
|
+
q=q,
|
|
112
|
+
location=location,
|
|
113
|
+
remote=remote,
|
|
114
|
+
posted_since=when(posted_since, "--posted-since"),
|
|
115
|
+
posted_before=when(posted_before, "--posted-before"),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def fraction(shown, total):
|
|
120
|
+
"""Coverage first, the way #430 asks: '20 of 1,234'."""
|
|
121
|
+
return f"{shown:,} of {total:,}" if total is not None else f"{shown:,}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# -- jobs -----------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@jobs_app.command("count")
|
|
128
|
+
def jobs_count(
|
|
129
|
+
brand: Optional[str] = BRAND, employer: Optional[str] = EMPLOYER,
|
|
130
|
+
q: Optional[str] = QUERY, location: Optional[str] = LOCATION,
|
|
131
|
+
remote: Optional[bool] = REMOTE, posted_since: Optional[str] = SINCE,
|
|
132
|
+
posted_before: Optional[str] = BEFORE,
|
|
133
|
+
):
|
|
134
|
+
"""How many open jobs match. Cheap: size a query before pulling it."""
|
|
135
|
+
body = client().get(
|
|
136
|
+
"/jobs/count", **job_filters(brand, employer, q, location, remote, posted_since, posted_before)
|
|
137
|
+
)
|
|
138
|
+
emit(body["data"], lambda: console.print(f"{body['data']['count']:,}"))
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def job_table(jobs):
|
|
142
|
+
table = Table(show_edge=False, pad_edge=False)
|
|
143
|
+
for column in ("id", "posted", "company", "position", "location"):
|
|
144
|
+
table.add_column(column, overflow="fold")
|
|
145
|
+
for j in jobs:
|
|
146
|
+
table.add_row(
|
|
147
|
+
str(j["id"]),
|
|
148
|
+
timespec.show(j["posted_at"] or j["first_seen_at"])[:10],
|
|
149
|
+
j["company"] or "", j["position"] or "",
|
|
150
|
+
(j["location"] or "") + (" (remote)" if j["remote"] else ""),
|
|
151
|
+
)
|
|
152
|
+
return table
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@jobs_app.command("search")
|
|
156
|
+
def jobs_search(
|
|
157
|
+
brand: Optional[str] = BRAND, employer: Optional[str] = EMPLOYER,
|
|
158
|
+
q: Optional[str] = QUERY, location: Optional[str] = LOCATION,
|
|
159
|
+
remote: Optional[bool] = REMOTE, posted_since: Optional[str] = SINCE,
|
|
160
|
+
posted_before: Optional[str] = BEFORE,
|
|
161
|
+
limit: int = typer.Option(20, "--limit", "-n", min=1, max=500, help="How many to show."),
|
|
162
|
+
):
|
|
163
|
+
"""The newest open jobs that match: one page. For all of them, use pull."""
|
|
164
|
+
body = client().get(
|
|
165
|
+
"/jobs", limit=limit,
|
|
166
|
+
**job_filters(brand, employer, q, location, remote, posted_since, posted_before),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def render():
|
|
170
|
+
console.print(job_table(body["data"]))
|
|
171
|
+
more = " -- pdatum jobs pull for all of them" if body["next_cursor"] else ""
|
|
172
|
+
console.print(f"[dim]{fraction(len(body['data']), body['total'])}{more}[/dim]")
|
|
173
|
+
|
|
174
|
+
emit(body, render)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@jobs_app.command("get")
|
|
178
|
+
def jobs_get(job_id: int = typer.Argument(..., help="The job's id.")):
|
|
179
|
+
"""One job, open or closed, with its full description."""
|
|
180
|
+
job = client().get(f"/jobs/{job_id}")["data"]
|
|
181
|
+
|
|
182
|
+
def render():
|
|
183
|
+
state = "open" if job["open"] else "[red]closed[/red]"
|
|
184
|
+
console.print(f"[bold]{job['position']}[/bold] -- {job['company'] or '?'} ({state})")
|
|
185
|
+
console.print(
|
|
186
|
+
f"{job['location'] or 'no location'}{' - remote' if job['remote'] else ''} | "
|
|
187
|
+
f"posted {timespec.show(job['posted_at'])} | "
|
|
188
|
+
f"first seen {timespec.show(job['first_seen_at'])}"
|
|
189
|
+
)
|
|
190
|
+
if job["salary_min"] or job["salary_max"]:
|
|
191
|
+
console.print(f"salary {job['salary_min'] or '?'}-{job['salary_max'] or '?'} "
|
|
192
|
+
f"{job['salary_currency']}")
|
|
193
|
+
console.print(f"apply: {job['application_url']}")
|
|
194
|
+
console.print()
|
|
195
|
+
console.print(job["description"], markup=False)
|
|
196
|
+
|
|
197
|
+
emit(job, render)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def pull_progress(noun):
|
|
201
|
+
def report(received, total):
|
|
202
|
+
progress(f"pulled {fraction(received, total)} {noun}")
|
|
203
|
+
return report
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@jobs_app.command("pull")
|
|
207
|
+
def jobs_pull(
|
|
208
|
+
brand: Optional[str] = BRAND, employer: Optional[str] = EMPLOYER,
|
|
209
|
+
q: Optional[str] = QUERY, location: Optional[str] = LOCATION,
|
|
210
|
+
remote: Optional[bool] = REMOTE, posted_since: Optional[str] = SINCE,
|
|
211
|
+
posted_before: Optional[str] = BEFORE,
|
|
212
|
+
full: bool = typer.Option(False, "--full", help="Whole descriptions, not excerpts."),
|
|
213
|
+
max_records: Optional[int] = typer.Option(None, "--max", min=1, help="Stop after this many."),
|
|
214
|
+
):
|
|
215
|
+
"""Every open job that matches, as JSON lines on stdout."""
|
|
216
|
+
filters = job_filters(brand, employer, q, location, remote, posted_since, posted_before)
|
|
217
|
+
written = 0
|
|
218
|
+
for job in client().pages(
|
|
219
|
+
"/jobs", on_page=pull_progress("jobs"),
|
|
220
|
+
detail="full" if full else "summary", limit=100 if full else 500, **filters,
|
|
221
|
+
):
|
|
222
|
+
line(job)
|
|
223
|
+
written += 1
|
|
224
|
+
if max_records and written >= max_records:
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@jobs_app.command("changes")
|
|
229
|
+
def jobs_changes(
|
|
230
|
+
since: str = typer.Option(..., "--since", help="2026-09-01, 7d, or epoch seconds."),
|
|
231
|
+
brand: Optional[str] = BRAND,
|
|
232
|
+
full: bool = typer.Option(False, "--full", help="Whole descriptions, not excerpts."),
|
|
233
|
+
):
|
|
234
|
+
"""
|
|
235
|
+
Every job added, closed or reopened since then, as JSON lines. Upsert by
|
|
236
|
+
id; closed jobs arrive with "open": false. The last line on stderr says
|
|
237
|
+
what --since to use next time.
|
|
238
|
+
"""
|
|
239
|
+
start = when(since, "--since")
|
|
240
|
+
latest = None
|
|
241
|
+
for job in client().pages(
|
|
242
|
+
"/jobs/changes", on_page=pull_progress("changes"),
|
|
243
|
+
since=start, brand=brand, detail="full" if full else "summary",
|
|
244
|
+
limit=100 if full else 500,
|
|
245
|
+
):
|
|
246
|
+
line(job)
|
|
247
|
+
latest = job["changed_at"]
|
|
248
|
+
# Minus one: times are whole seconds, so re-reading the last second is
|
|
249
|
+
# what guarantees nothing that landed in it is missed. See the API docs.
|
|
250
|
+
resume = (latest - 1) if latest is not None else start
|
|
251
|
+
progress(f"next time: pdatum jobs changes --since {resume}")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# -- employers --------------------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
EMPLOYER_Q = typer.Option(None, "--q", "-q", help="Text in the name.")
|
|
258
|
+
DOMAIN = typer.Option(None, "--domain", help="An exact domain, e.g. pfizer.com.")
|
|
259
|
+
EMPLOYER_BRAND = typer.Option(None, "--brand", help="Only employers with open jobs there.")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def hiring_word(value):
|
|
263
|
+
return {True: "yes", False: "no", None: "unknown"}[value]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@employers_app.command("list")
|
|
267
|
+
def employers_list(
|
|
268
|
+
q: Optional[str] = EMPLOYER_Q, domain: Optional[str] = DOMAIN,
|
|
269
|
+
brand: Optional[str] = EMPLOYER_BRAND,
|
|
270
|
+
limit: int = typer.Option(50, "--limit", "-n", min=1, max=500),
|
|
271
|
+
):
|
|
272
|
+
"""Employer records: one page. For all of them, use pull."""
|
|
273
|
+
body = client().get("/employers", q=q, domain=domain, brand=brand, limit=limit)
|
|
274
|
+
|
|
275
|
+
def render():
|
|
276
|
+
table = Table(show_edge=False, pad_edge=False)
|
|
277
|
+
for column in ("slug", "name", "domain", "open jobs", "hiring"):
|
|
278
|
+
table.add_column(column, overflow="fold")
|
|
279
|
+
for e in body["data"]:
|
|
280
|
+
table.add_row(
|
|
281
|
+
e["slug"], e["name"], e["domain"] or "",
|
|
282
|
+
str(sum(e["open_postings"].values())), hiring_word(e["hiring"]),
|
|
283
|
+
)
|
|
284
|
+
console.print(table)
|
|
285
|
+
console.print(f"[dim]{fraction(len(body['data']), body['total'])}[/dim]")
|
|
286
|
+
|
|
287
|
+
emit(body, render)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@employers_app.command("pull")
|
|
291
|
+
def employers_pull(
|
|
292
|
+
q: Optional[str] = EMPLOYER_Q, domain: Optional[str] = DOMAIN,
|
|
293
|
+
brand: Optional[str] = EMPLOYER_BRAND,
|
|
294
|
+
):
|
|
295
|
+
"""Every employer record that matches, as JSON lines on stdout."""
|
|
296
|
+
for employer in client().pages(
|
|
297
|
+
"/employers", on_page=pull_progress("employers"),
|
|
298
|
+
q=q, domain=domain, brand=brand, limit=500,
|
|
299
|
+
):
|
|
300
|
+
line(employer)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@employers_app.command("get")
|
|
304
|
+
def employers_get(slug: str = typer.Argument(..., help="The employer's slug.")):
|
|
305
|
+
"""One employer: open jobs per brand, every fact with its source, and history."""
|
|
306
|
+
e = client().get(f"/employers/{slug}")["data"]
|
|
307
|
+
|
|
308
|
+
def render():
|
|
309
|
+
console.print(f"[bold]{e['name']}[/bold] ({e['slug']}) {e['domain'] or ''}")
|
|
310
|
+
postings = ", ".join(f"{b} {n}" for b, n in e["open_postings"].items())
|
|
311
|
+
console.print(f"open jobs: {postings} | hiring: {hiring_word(e['hiring'])}")
|
|
312
|
+
if e["facts"]:
|
|
313
|
+
table = Table(show_edge=False, pad_edge=False, title="facts")
|
|
314
|
+
for column in ("key", "value", "source", "observed"):
|
|
315
|
+
table.add_column(column, overflow="fold")
|
|
316
|
+
for f in e["facts"]:
|
|
317
|
+
table.add_row(f["key"], str(f["value"]), f["source"],
|
|
318
|
+
timespec.show(f["observed_at"]))
|
|
319
|
+
console.print(table)
|
|
320
|
+
else:
|
|
321
|
+
console.print("[dim]no facts recorded[/dim]")
|
|
322
|
+
|
|
323
|
+
emit(e, render)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# -- the key, the server, and you ------------------------------------------------
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@app.command("me")
|
|
330
|
+
def me():
|
|
331
|
+
"""The key you are using, its scopes, and what it has used."""
|
|
332
|
+
data = client().get("/me")["data"]
|
|
333
|
+
|
|
334
|
+
def render():
|
|
335
|
+
_, source = config.api_key()
|
|
336
|
+
console.print(f"[bold]{data['name']}[/bold] -- {data['holder']}")
|
|
337
|
+
console.print(f"key {data['prefix']}... from {source}; scopes: {' '.join(data['scopes'])}")
|
|
338
|
+
console.print(f"expires {timespec.show(data['expires_at'])}")
|
|
339
|
+
for period, used in data["usage"].items():
|
|
340
|
+
console.print(f"{period.replace('_', ' ')}: {used['requests']:,} requests, "
|
|
341
|
+
f"{used['records']:,} records")
|
|
342
|
+
|
|
343
|
+
emit(data, render)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@key_app.command("save")
|
|
347
|
+
def key_save(key: str = typer.Argument(..., help="The key you were issued.")):
|
|
348
|
+
"""Check a key against the server, then save it to the config file."""
|
|
349
|
+
data = Client(config.base_url(), key).get("/me")["data"]
|
|
350
|
+
config.save_key(key)
|
|
351
|
+
emit(
|
|
352
|
+
{"saved": str(config.config_path()), "key": data},
|
|
353
|
+
lambda: console.print(
|
|
354
|
+
f"Saved {data['prefix']}... ({data['name']}) to {config.config_path()}"
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@key_app.command("clear")
|
|
360
|
+
def key_clear():
|
|
361
|
+
"""Forget the saved key. It still works until it is revoked."""
|
|
362
|
+
had = config.clear_key()
|
|
363
|
+
emit({"cleared": had}, lambda: console.print(
|
|
364
|
+
f"Cleared the key from {config.config_path()}" if had else "No key was saved."
|
|
365
|
+
))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@app.command("config")
|
|
369
|
+
def cmd_config(
|
|
370
|
+
url: Optional[str] = typer.Option(None, "--url", help="Point at another server."),
|
|
371
|
+
):
|
|
372
|
+
"""Show, or set, which server this talks to and where the key comes from."""
|
|
373
|
+
if url:
|
|
374
|
+
config.set_base_url(url)
|
|
375
|
+
key, source = config.api_key()
|
|
376
|
+
data = {
|
|
377
|
+
"url": config.base_url(),
|
|
378
|
+
"config": str(config.config_path()),
|
|
379
|
+
"key": config.display_key(key),
|
|
380
|
+
"key_from": source,
|
|
381
|
+
}
|
|
382
|
+
emit(data, lambda: [console.print(f"{k}: {v}") for k, v in data.items()])
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
SKILL = """\
|
|
386
|
+
pdatum -- job postings and the employers behind them, for agents.
|
|
387
|
+
|
|
388
|
+
Auth: the key comes from PDATUM_API_KEY. Do not write it into files.
|
|
389
|
+
Check it: pdatum me
|
|
390
|
+
|
|
391
|
+
Size a query before pulling it (cheap, returns only a number):
|
|
392
|
+
pdatum jobs count --q "data engineer" --brand jobwolverine --posted-since 30d
|
|
393
|
+
|
|
394
|
+
Look at a page:
|
|
395
|
+
pdatum jobs search --q nurse --remote --json
|
|
396
|
+
|
|
397
|
+
Pull everything that matches, as JSON lines (progress goes to stderr):
|
|
398
|
+
pdatum jobs pull --q nurse --brand rxraven > jobs.jsonl
|
|
399
|
+
|
|
400
|
+
One job in full: pdatum jobs get 48213 --json
|
|
401
|
+
Employers and their facts: pdatum employers list --q pfizer --json
|
|
402
|
+
pdatum employers get pfizer --json
|
|
403
|
+
Every fact names its source. hiring is true, false, or null (unknown).
|
|
404
|
+
|
|
405
|
+
Stay in sync: pdatum jobs changes --since 2026-09-01 > delta.jsonl
|
|
406
|
+
Upsert each line by id; "open": false means the job closed.
|
|
407
|
+
The last stderr line gives the --since to use next time.
|
|
408
|
+
|
|
409
|
+
Times take 2026-09-01, 7d / 12h / 30m, or epoch seconds.
|
|
410
|
+
Every command takes --json; errors go to stderr, exit code non-zero.
|
|
411
|
+
The full API reference: pdatum guide
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@app.command("skill")
|
|
416
|
+
def skill():
|
|
417
|
+
"""Short instructions for an AI agent. Save them where your agent reads them."""
|
|
418
|
+
emit({"skill": SKILL}, lambda: print(SKILL, end=""))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@app.command("guide")
|
|
422
|
+
def guide():
|
|
423
|
+
"""The full API reference, fetched from the server so it is never stale."""
|
|
424
|
+
text = client(need_key=False).text("/docs")
|
|
425
|
+
emit({"guide": text}, lambda: print(text))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# -- entry point ----------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
# Options that take no value, so a flag right after one is still ours.
|
|
431
|
+
VALUELESS = frozenset({
|
|
432
|
+
"--json", "--version", "-V", "--help", "--full", "--remote", "--not-remote",
|
|
433
|
+
})
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _is_ours(argv, i):
|
|
437
|
+
previous = argv[i - 1]
|
|
438
|
+
return not (previous.startswith("-") and previous not in VALUELESS)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _options_end(argv):
|
|
442
|
+
return argv.index("--") if "--" in argv else len(argv)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def extract_json_flag(argv):
|
|
446
|
+
"""
|
|
447
|
+
Take --json off argv wherever it can be ours, so it works after the
|
|
448
|
+
subcommand too. Not when it is another option's value (`--q --json`),
|
|
449
|
+
and nothing after `--`.
|
|
450
|
+
"""
|
|
451
|
+
found = False
|
|
452
|
+
for i in range(_options_end(argv) - 1, 0, -1):
|
|
453
|
+
if argv[i] == "--json" and _is_ours(argv, i):
|
|
454
|
+
argv.pop(i)
|
|
455
|
+
found = True
|
|
456
|
+
return found
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def extract_api_key(argv):
|
|
460
|
+
"""Take --api-key KEY / -k KEY / --api-key=KEY off argv, by the same rules."""
|
|
461
|
+
for i in range(1, _options_end(argv)):
|
|
462
|
+
token = argv[i]
|
|
463
|
+
if token.startswith("--api-key=") and _is_ours(argv, i):
|
|
464
|
+
argv.pop(i)
|
|
465
|
+
return token.split("=", 1)[1]
|
|
466
|
+
if token in ("--api-key", "-k") and _is_ours(argv, i):
|
|
467
|
+
if i + 1 >= len(argv):
|
|
468
|
+
emit_error(f"{token} needs a value.")
|
|
469
|
+
raise SystemExit(2)
|
|
470
|
+
argv.pop(i)
|
|
471
|
+
return argv.pop(i)
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def main(argv=None):
|
|
476
|
+
configure_streams()
|
|
477
|
+
argv = sys.argv if argv is None else argv
|
|
478
|
+
|
|
479
|
+
if extract_json_flag(argv):
|
|
480
|
+
set_json_output(True)
|
|
481
|
+
key = extract_api_key(argv)
|
|
482
|
+
if key is not None:
|
|
483
|
+
config.set_runtime_key(key)
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
# Click expands ~ and wildcards in every argument on Windows, standing
|
|
487
|
+
# in for a shell that does not: "~4,100" becomes a home directory and
|
|
488
|
+
# "*.py" a list of files. Nothing here is a path pattern.
|
|
489
|
+
app(args=argv[1:], prog_name="pdatum", windows_expand_args=False)
|
|
490
|
+
except PdatumError as e:
|
|
491
|
+
extra = {k: v for k, v in (("code", e.code), ("status", e.status)) if v is not None}
|
|
492
|
+
emit_error(e.message, **extra)
|
|
493
|
+
raise SystemExit(1)
|
|
494
|
+
except KeyboardInterrupt:
|
|
495
|
+
raise SystemExit(130)
|
|
496
|
+
except OSError as e:
|
|
497
|
+
if not _reader_left(e):
|
|
498
|
+
raise
|
|
499
|
+
# `pdatum jobs pull | head`: the reader left, which is not an error.
|
|
500
|
+
# Point stdout at nothing, or Python's own flush at exit raises the
|
|
501
|
+
# same error again and prints it.
|
|
502
|
+
try:
|
|
503
|
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
|
504
|
+
os.dup2(devnull, sys.stdout.fileno())
|
|
505
|
+
except (OSError, ValueError):
|
|
506
|
+
pass
|
|
507
|
+
raise SystemExit(0)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _reader_left(error):
|
|
511
|
+
"""
|
|
512
|
+
Whether an OSError means whoever was reading stdout has gone. POSIX says
|
|
513
|
+
so with EPIPE; Windows says EINVAL when the pipe's far end is closed.
|
|
514
|
+
"""
|
|
515
|
+
if isinstance(error, BrokenPipeError):
|
|
516
|
+
return True
|
|
517
|
+
return os.name == "nt" and error.errno == errno.EINVAL
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
if __name__ == "__main__":
|
|
521
|
+
main()
|
pdatum/client.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The HTTP client. Knows the API's envelope, its errors and its paging, and
|
|
3
|
+
nothing about how results are printed.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from pdatum import __version__
|
|
11
|
+
|
|
12
|
+
TIMEOUT = 60
|
|
13
|
+
|
|
14
|
+
# How many times one request waits out a 429 before giving up. A long pull
|
|
15
|
+
# hits the per-key limit on purpose; waiting is the right answer, forever is
|
|
16
|
+
# not.
|
|
17
|
+
MAX_RETRIES = 8
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PdatumError(Exception):
|
|
21
|
+
"""A failure the user can act on, reported without a traceback."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, message, code=None, status=None):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.message = message
|
|
26
|
+
self.code = code
|
|
27
|
+
self.status = status
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _param(value):
|
|
31
|
+
if isinstance(value, bool):
|
|
32
|
+
return "true" if value else "false"
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Client:
|
|
37
|
+
|
|
38
|
+
def __init__(self, url, key=None, session=None, sleep=time.sleep):
|
|
39
|
+
self.url = url.rstrip("/")
|
|
40
|
+
self.key = key
|
|
41
|
+
self.session = session or requests.Session()
|
|
42
|
+
self.session.headers["User-Agent"] = f"pdatum-cli/{__version__}"
|
|
43
|
+
if key:
|
|
44
|
+
self.session.headers["Authorization"] = f"Bearer {key}"
|
|
45
|
+
self._sleep = sleep
|
|
46
|
+
|
|
47
|
+
def _send(self, path, params=None):
|
|
48
|
+
url = f"{self.url}/api/v1{path}"
|
|
49
|
+
params = {k: _param(v) for k, v in (params or {}).items() if v is not None}
|
|
50
|
+
|
|
51
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
52
|
+
try:
|
|
53
|
+
response = self.session.get(url, params=params, timeout=TIMEOUT)
|
|
54
|
+
except requests.exceptions.Timeout:
|
|
55
|
+
raise PdatumError(f"{self.url} took more than {TIMEOUT}s to answer. Try again.")
|
|
56
|
+
except (requests.exceptions.MissingSchema, requests.exceptions.InvalidURL,
|
|
57
|
+
requests.exceptions.InvalidSchema):
|
|
58
|
+
raise PdatumError(
|
|
59
|
+
f"{self.url!r} is not a URL. Set one with: pdatum config --url https://..."
|
|
60
|
+
)
|
|
61
|
+
except requests.exceptions.ConnectionError:
|
|
62
|
+
raise PdatumError(f"Could not reach {self.url}. Check the URL (pdatum config).")
|
|
63
|
+
|
|
64
|
+
if response.status_code == 429 and attempt < MAX_RETRIES:
|
|
65
|
+
try:
|
|
66
|
+
wait = float(response.headers.get("Retry-After", "1"))
|
|
67
|
+
except ValueError:
|
|
68
|
+
wait = 1.0
|
|
69
|
+
self._sleep(max(wait, 0.5))
|
|
70
|
+
continue
|
|
71
|
+
break
|
|
72
|
+
|
|
73
|
+
if response.status_code >= 400:
|
|
74
|
+
raise self._error(response)
|
|
75
|
+
return response
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def _error(response):
|
|
79
|
+
code, message = None, None
|
|
80
|
+
try:
|
|
81
|
+
error = response.json().get("error") or {}
|
|
82
|
+
code, message = error.get("code"), error.get("message")
|
|
83
|
+
except (ValueError, AttributeError):
|
|
84
|
+
pass
|
|
85
|
+
if not message:
|
|
86
|
+
message = f"The server answered {response.status_code}."
|
|
87
|
+
if response.status_code == 401 and code == "missing_key":
|
|
88
|
+
message += " Set PDATUM_API_KEY, or run: pdatum key save <key>"
|
|
89
|
+
return PdatumError(message, code=code, status=response.status_code)
|
|
90
|
+
|
|
91
|
+
def get(self, path, **params):
|
|
92
|
+
"""One request; the parsed JSON body."""
|
|
93
|
+
return self._send(path, params).json()
|
|
94
|
+
|
|
95
|
+
def text(self, path):
|
|
96
|
+
return self._send(path).text
|
|
97
|
+
|
|
98
|
+
def pages(self, path, on_page=None, **params):
|
|
99
|
+
"""
|
|
100
|
+
Every record a list endpoint returns, following next_cursor to the
|
|
101
|
+
end. on_page(received so far, total) is called after each page.
|
|
102
|
+
"""
|
|
103
|
+
cursor, received = None, 0
|
|
104
|
+
while True:
|
|
105
|
+
body = self.get(path, cursor=cursor, **params)
|
|
106
|
+
for record in body["data"]:
|
|
107
|
+
yield record
|
|
108
|
+
received += len(body["data"])
|
|
109
|
+
if on_page is not None:
|
|
110
|
+
on_page(received, body.get("total"))
|
|
111
|
+
cursor = body.get("next_cursor")
|
|
112
|
+
if not cursor:
|
|
113
|
+
return
|
pdatum/config.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Where the CLI finds its server and its key.
|
|
3
|
+
|
|
4
|
+
The key is looked for in this order, and the first one found is used:
|
|
5
|
+
|
|
6
|
+
1. --api-key on the command line, for one invocation;
|
|
7
|
+
2. PDATUM_API_KEY in the environment -- the way to give an agent a key,
|
|
8
|
+
since it needs no file and nothing is written anywhere;
|
|
9
|
+
3. the config file, written by `pdatum key save`.
|
|
10
|
+
|
|
11
|
+
The config file is ~/.pdatum.json unless PDATUM_CONFIG_PATH says otherwise.
|
|
12
|
+
Both are resolved on every call rather than at import, so a test (or a
|
|
13
|
+
script) that sets the variable after importing still gets the file it asked
|
|
14
|
+
for -- pkanban once wrote to developers' real credentials for want of that.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
DEFAULT_URL = "https://pdatum.pearachute.com"
|
|
22
|
+
|
|
23
|
+
_runtime_key = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def config_path():
|
|
27
|
+
raw = os.environ.get("PDATUM_CONFIG_PATH")
|
|
28
|
+
if raw:
|
|
29
|
+
# Expanded here because nothing else will: a quoted "~/x.json" would
|
|
30
|
+
# otherwise create a directory literally named "~".
|
|
31
|
+
return Path(raw).expanduser()
|
|
32
|
+
return Path.home() / ".pdatum.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load():
|
|
36
|
+
path = config_path()
|
|
37
|
+
if not path.exists():
|
|
38
|
+
return {}
|
|
39
|
+
with open(path, encoding="utf-8") as f:
|
|
40
|
+
try:
|
|
41
|
+
data = json.load(f)
|
|
42
|
+
except ValueError:
|
|
43
|
+
return {}
|
|
44
|
+
return data if isinstance(data, dict) else {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save(data):
|
|
48
|
+
path = config_path()
|
|
49
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
51
|
+
json.dump(data, f, indent=2)
|
|
52
|
+
try:
|
|
53
|
+
# The file holds a key. Readable by its owner only, where the
|
|
54
|
+
# platform has such a thing.
|
|
55
|
+
os.chmod(path, 0o600)
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_runtime_key(key):
|
|
61
|
+
"""--api-key: this invocation only, never written anywhere."""
|
|
62
|
+
global _runtime_key
|
|
63
|
+
_runtime_key = key
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def api_key():
|
|
67
|
+
"""(key, where it came from), or (None, None)."""
|
|
68
|
+
if _runtime_key:
|
|
69
|
+
return _runtime_key, "--api-key"
|
|
70
|
+
env = os.environ.get("PDATUM_API_KEY", "").strip()
|
|
71
|
+
if env:
|
|
72
|
+
return env, "PDATUM_API_KEY"
|
|
73
|
+
saved = load().get("api_key")
|
|
74
|
+
if saved:
|
|
75
|
+
return saved, str(config_path())
|
|
76
|
+
return None, None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def save_key(key):
|
|
80
|
+
data = load()
|
|
81
|
+
data["api_key"] = key
|
|
82
|
+
save(data)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def clear_key():
|
|
86
|
+
data = load()
|
|
87
|
+
had = data.pop("api_key", None) is not None
|
|
88
|
+
save(data)
|
|
89
|
+
return had
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def base_url():
|
|
93
|
+
env = os.environ.get("PDATUM_URL", "").strip()
|
|
94
|
+
if env:
|
|
95
|
+
return env.rstrip("/")
|
|
96
|
+
return (load().get("url") or DEFAULT_URL).rstrip("/")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def set_base_url(url):
|
|
100
|
+
data = load()
|
|
101
|
+
data["url"] = url.rstrip("/")
|
|
102
|
+
save(data)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def display_key(key):
|
|
106
|
+
"""Enough of a key to recognise it, never enough to use it."""
|
|
107
|
+
return key[:15] + "..." if key else None
|
pdatum/output.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
How a command reports: formatted for a person, or JSON for a script.
|
|
3
|
+
|
|
4
|
+
Copied in shape from pkanban. A command hands emit() the API's payload and a
|
|
5
|
+
callable that renders it for a person; the mode decides which is printed. In
|
|
6
|
+
JSON mode stdout carries only the result and errors go to stderr as JSON, so a
|
|
7
|
+
script can parse stdout without first working out whether it holds an error.
|
|
8
|
+
|
|
9
|
+
Streams (`jobs pull`, `jobs changes`, `employers pull`) are JSON lines in
|
|
10
|
+
either mode: one record per line on stdout, progress on stderr.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
|
|
19
|
+
_json_output = None
|
|
20
|
+
|
|
21
|
+
console = Console(highlight=False)
|
|
22
|
+
errors = Console(stderr=True, highlight=False)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def configure_streams():
|
|
26
|
+
"""
|
|
27
|
+
UTF-8 on stdout and stderr. On Windows they default to the console code
|
|
28
|
+
page, and one company name with an accent in it would otherwise end a
|
|
29
|
+
listing with a UnicodeEncodeError. An explicit PYTHONIOENCODING is left
|
|
30
|
+
alone.
|
|
31
|
+
"""
|
|
32
|
+
chosen = bool(os.environ.get("PYTHONIOENCODING", "").strip())
|
|
33
|
+
for stream in (sys.stdout, sys.stderr):
|
|
34
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
35
|
+
if reconfigure is None:
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
reconfigure(errors="replace") if chosen else reconfigure(
|
|
39
|
+
encoding="utf-8", errors="replace"
|
|
40
|
+
)
|
|
41
|
+
except (ValueError, OSError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def set_json_output(enabled):
|
|
46
|
+
global _json_output
|
|
47
|
+
_json_output = bool(enabled)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def json_output():
|
|
51
|
+
if _json_output is not None:
|
|
52
|
+
return _json_output
|
|
53
|
+
return os.environ.get("PDATUM_OUTPUT", "").strip().lower() == "json"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def emit(payload, render):
|
|
57
|
+
if json_output():
|
|
58
|
+
# Plain print: rich reflows long lines and reads [brackets] as markup,
|
|
59
|
+
# and either would corrupt JSON.
|
|
60
|
+
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
61
|
+
else:
|
|
62
|
+
render()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def emit_error(message, **extra):
|
|
66
|
+
if json_output():
|
|
67
|
+
print(json.dumps({"error": message, **extra}), file=sys.stderr)
|
|
68
|
+
else:
|
|
69
|
+
errors.print(f"[red]error:[/red] {message}", markup=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def line(record):
|
|
73
|
+
"""One JSON-lines record on stdout."""
|
|
74
|
+
sys.stdout.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def progress(message):
|
|
78
|
+
"""A status line on stderr, where it never mixes with the data."""
|
|
79
|
+
print(message, file=sys.stderr, flush=True)
|
pdatum/timespec.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Times, as a person or an agent would write them, turned into the epoch
|
|
3
|
+
seconds the API takes.
|
|
4
|
+
|
|
5
|
+
1727000000 epoch seconds, as-is
|
|
6
|
+
2026-09-01 midnight UTC that day
|
|
7
|
+
2026-09-01T12:30:00Z an ISO timestamp; no offset means UTC
|
|
8
|
+
7d, 12h, 30m that long ago
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
_RELATIVE = re.compile(r"^(\d+)\s*([dhm])$")
|
|
16
|
+
_UNITS = {"d": 86400, "h": 3600, "m": 60}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse(value, now=None):
|
|
20
|
+
text = str(value).strip()
|
|
21
|
+
if not text:
|
|
22
|
+
raise ValueError("a time is required")
|
|
23
|
+
|
|
24
|
+
if text.isdigit():
|
|
25
|
+
return int(text)
|
|
26
|
+
|
|
27
|
+
match = _RELATIVE.match(text.lower())
|
|
28
|
+
if match:
|
|
29
|
+
now = time.time() if now is None else now
|
|
30
|
+
return int(now - int(match.group(1)) * _UNITS[match.group(2)])
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
34
|
+
except ValueError:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"{value!r} is not a time. Use epoch seconds, a date (2026-09-01), "
|
|
37
|
+
f"an ISO timestamp, or an age like 7d, 12h, 30m."
|
|
38
|
+
)
|
|
39
|
+
if parsed.tzinfo is None:
|
|
40
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
41
|
+
return int(parsed.timestamp())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def show(epoch):
|
|
45
|
+
"""An epoch as a UTC date and time, for people; '-' for none."""
|
|
46
|
+
if epoch is None:
|
|
47
|
+
return "-"
|
|
48
|
+
return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdatum
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Job postings and the employers behind them, from the command line -- for scripts and AI agents.
|
|
5
|
+
Author-email: Pearachute <pdatum@pearachute.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://pdatum.pearachute.com
|
|
8
|
+
Project-URL: Documentation, https://pdatum.pearachute.com/api/v1/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/japherwocky/pdatum
|
|
10
|
+
Project-URL: Issues, https://github.com/japherwocky/pdatum/issues
|
|
11
|
+
Keywords: jobs,job-postings,employers,data,api,cli,ai-agents
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: requests>=2.31.0
|
|
19
|
+
Requires-Dist: typer>=0.9.0
|
|
20
|
+
Requires-Dist: rich>=13.0.0
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# pdatum
|
|
24
|
+
|
|
25
|
+
Job postings, and the employers behind them, from the command line. Built for
|
|
26
|
+
scripts and AI agents: everything prints JSON on request, errors go to
|
|
27
|
+
stderr, and large pulls stream JSON lines.
|
|
28
|
+
|
|
29
|
+
pdatum is the data jobwolverine.com and rxraven.com are built on.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pip install pdatum
|
|
33
|
+
export PDATUM_API_KEY=pdatum_... # keys are issued by hand for now
|
|
34
|
+
|
|
35
|
+
pdatum jobs count --q "data engineer" --posted-since 30d
|
|
36
|
+
pdatum jobs search --q nurse --remote
|
|
37
|
+
pdatum jobs pull --brand rxraven > jobs.jsonl
|
|
38
|
+
pdatum employers get pfizer --json
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
| Command | |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `pdatum jobs count [filters]` | Returns how many open jobs match, and nothing else. It's cheap, so run it first. |
|
|
46
|
+
| `pdatum jobs search [filters] [-n N]` | Shows the newest matches: one page. |
|
|
47
|
+
| `pdatum jobs pull [filters] [--full] [--max N]` | Streams every match to stdout as JSON lines. Progress goes to stderr. |
|
|
48
|
+
| `pdatum jobs get ID` | Returns one job, open or closed, with its full text. |
|
|
49
|
+
| `pdatum jobs changes --since T` | Streams every job added, closed or reopened since `T`. |
|
|
50
|
+
| `pdatum employers list / pull / get SLUG` | Employer records. Each fact names its source. |
|
|
51
|
+
| `pdatum me` | Shows your key, its scopes, and what it has used. |
|
|
52
|
+
| `pdatum skill` | Prints short instructions for an AI agent to save. |
|
|
53
|
+
| `pdatum guide` | Prints the full API reference, from the server. |
|
|
54
|
+
|
|
55
|
+
The job filters are `--brand`, `--employer`, `-q`, `--location`,
|
|
56
|
+
`--remote/--not-remote`, `--posted-since` and `--posted-before`. A time can be
|
|
57
|
+
`2026-09-01`, an age like `7d`, `12h` or `30m`, or epoch seconds.
|
|
58
|
+
|
|
59
|
+
## Keeping a copy in sync
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
pdatum jobs pull > jobs.jsonl # once
|
|
63
|
+
pdatum jobs changes --since 2026-09-01 > delta.jsonl
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Upsert each line of `delta.jsonl` by `id`. A line with `"open": false` means
|
|
67
|
+
that job has closed. The last line on stderr gives the `--since` to use next
|
|
68
|
+
time. A change can arrive twice, but never not at all.
|
|
69
|
+
|
|
70
|
+
## Keys and configuration
|
|
71
|
+
|
|
72
|
+
The key is taken from `--api-key`, then `PDATUM_API_KEY`, then the config
|
|
73
|
+
file, which `pdatum key save <key>` writes after checking the key with the
|
|
74
|
+
server. The config file is `~/.pdatum.json`; `PDATUM_CONFIG_PATH` moves it.
|
|
75
|
+
`PDATUM_URL` or `pdatum config --url` points the CLI at another server.
|
|
76
|
+
|
|
77
|
+
Every command takes `--json`, or set `PDATUM_OUTPUT=json`. Errors go to
|
|
78
|
+
stderr, and the exit code is non-zero: 1 for a failure, 2 for a usage error.
|
|
79
|
+
|
|
80
|
+
## Development
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install -e .
|
|
84
|
+
python -m unittest discover -s tests
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The tests never reach a server. The API this talks to is documented at
|
|
88
|
+
https://pdatum.pearachute.com/api/v1/docs, and `pdatum guide` prints the same
|
|
89
|
+
reference from whichever server you point it at. MIT licensed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
pdatum/__init__.py,sha256=mF3WU4a-Tno-D6LUOLt07GY4G2nafzBaVXm63cTGEXs,105
|
|
2
|
+
pdatum/__main__.py,sha256=x3QFSkX8cA4ibcutpuOootV9LvDGaUQO9xqvcUaEIeo,36
|
|
3
|
+
pdatum/cli.py,sha256=3OURKjtDWBYHwMlR2XJQ-2enEbJBAYKwkPl5c5_nQmI,18681
|
|
4
|
+
pdatum/client.py,sha256=ecTsX7KZxGys4Hq1wXNfVmpRCZ1EuP03GjvNJItDZDo,3895
|
|
5
|
+
pdatum/config.py,sha256=mohu-YWLOP1PxYcLgeTB3pgjteYQ2KnorKB01jiLpPQ,2791
|
|
6
|
+
pdatum/output.py,sha256=NXTSlJ1Stm476MIbk4RS08_j9NRzEBOtrIU1Q8YR5gU,2392
|
|
7
|
+
pdatum/timespec.py,sha256=-H8Eu41hDPPxeQ8d8aRoDUq9Stmr3UjifuTbecemxyo,1425
|
|
8
|
+
pdatum-0.1.0.dist-info/licenses/LICENSE,sha256=lt1XcN23IlsPy5HIzaKHOMpdiebSCiKlacN0cjkFZYg,1067
|
|
9
|
+
pdatum-0.1.0.dist-info/METADATA,sha256=DaoVXUlA8Z1LeCHUQODOF1wAS_KtqQMvnfYcIOUOCJc,3591
|
|
10
|
+
pdatum-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
pdatum-0.1.0.dist-info/entry_points.txt,sha256=ibZxHAkSaRSI5yMmbxiFdepMiOxBaYFRBfmbKtGKaUM,43
|
|
12
|
+
pdatum-0.1.0.dist-info/top_level.txt,sha256=kTN3iJxJs4wojc0LOAFWGl5Sa9Fa7KmV96edTpdhlZQ,7
|
|
13
|
+
pdatum-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pearachute
|
|
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
|
+
pdatum
|