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 ADDED
@@ -0,0 +1,3 @@
1
+ """pdatum: job postings and the employers behind them, for scripts and agents."""
2
+
3
+ __version__ = "0.1.0"
pdatum/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from pdatum.cli import main
2
+
3
+ main()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pdatum = pdatum.cli:main
@@ -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