hiringcafe-cli 0.1.4__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.
@@ -0,0 +1,152 @@
1
+ """HiringCafe CLI package interface."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from hiringcafe_cli import (
7
+ auth,
8
+ count,
9
+ job,
10
+ probe,
11
+ saved_jobs_list,
12
+ saved_jobs_remove,
13
+ saved_jobs_save,
14
+ saved_jobs_stage,
15
+ search,
16
+ )
17
+ from hiringcafe_cli.cache import CacheStore, active_cache_root
18
+ from hiringcafe_cli.firebase import FirebaseAuthError, FirebaseTransportError
19
+ from hiringcafe_cli.http_client import HireCafeHTTPError, HireCafeTransportError
20
+ from hiringcafe_cli.saved_jobs import VALID_STAGES
21
+ from hiringcafe_cli.saved_jobs_list import FirestoreError
22
+ from hiringcafe_cli.search_state import InvalidSearchState
23
+
24
+ __version__ = "0.1.0"
25
+
26
+ EXIT_CONTRACT = """\
27
+ Exit codes:
28
+ 0 success
29
+ 1 transport, HTTP, parsing, or API error
30
+ 2 invalid arguments or search state
31
+ 3 rate limited
32
+ 4 no stored credential
33
+ 5 stored credential rejected
34
+ 6 write conflict or prerequisite missing
35
+ """
36
+
37
+
38
+ def build_parser() -> argparse.ArgumentParser:
39
+ """Build the root CLI parser.
40
+
41
+ Returns:
42
+ An argparse parser implementing the PR 0.1 bootstrap contract.
43
+ """
44
+ parser = argparse.ArgumentParser(
45
+ prog="hiringcafe",
46
+ description="Hiring Cafe command-line interface",
47
+ epilog=EXIT_CONTRACT,
48
+ formatter_class=argparse.RawDescriptionHelpFormatter,
49
+ )
50
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
51
+ cache_options = argparse.ArgumentParser(add_help=False)
52
+ cache_options.add_argument("--no-cache", action="store_true")
53
+ cache_options.add_argument("--cache-ttl", type=int)
54
+ subparsers = parser.add_subparsers(dest="command")
55
+ probe_parser = subparsers.add_parser("probe", help="run the opt-in live endpoint probe")
56
+ probe_parser.set_defaults(handler=probe.main)
57
+ search_parser = subparsers.add_parser("search", help="search jobs", parents=[cache_options])
58
+ search_parser.add_argument("query", nargs="?", default="")
59
+ search_parser.add_argument("--page", type=int, default=0)
60
+ search_parser.add_argument("--pages", type=int, default=1)
61
+ search_parser.add_argument("--raw-search-state")
62
+ search_parser.add_argument("--json", action="store_true")
63
+ search_parser.add_argument("--jsonl", action="store_true")
64
+ search_parser.set_defaults(handler=search.run)
65
+ count_parser = subparsers.add_parser("count", help="print total result count", parents=[cache_options])
66
+ count_parser.add_argument("query", nargs="?", default="")
67
+ count_parser.add_argument("--raw-search-state")
68
+ count_parser.add_argument("--json", action="store_true")
69
+ count_parser.set_defaults(handler=count.run)
70
+ job_parser = subparsers.add_parser("job", help="inspect job details")
71
+ job_subparsers = job_parser.add_subparsers(dest="job_command", required=True)
72
+ job_show_parser = job_subparsers.add_parser("show", help="show a job description", parents=[cache_options])
73
+ job_show_parser.add_argument("object_id")
74
+ job_show_parser.add_argument("--json", action="store_true")
75
+ job_show_parser.set_defaults(handler=job.run)
76
+ auth_parser = subparsers.add_parser("auth", help="manage Firebase authentication")
77
+ auth_subparsers = auth_parser.add_subparsers(dest="auth_command", required=True)
78
+ auth_login_parser = auth_subparsers.add_parser("login", help="log in to Firebase")
79
+ auth_login_parser.add_argument("--email", required=True)
80
+ auth_login_parser.set_defaults(handler=auth.login_email)
81
+ auth_import_parser = auth_subparsers.add_parser("import", help="import a refresh token")
82
+ auth_import_parser.set_defaults(handler=auth.import_token)
83
+ auth_status_parser = auth_subparsers.add_parser("status", help="show credential status")
84
+ auth_status_parser.add_argument("--json", action="store_true")
85
+ auth_status_parser.set_defaults(handler=auth.status)
86
+ auth_logout_parser = auth_subparsers.add_parser("logout", help="remove the local session")
87
+ auth_logout_parser.set_defaults(handler=auth.logout)
88
+ saved_parser = subparsers.add_parser("saved-jobs", help="manage saved jobs")
89
+ saved_subparsers = saved_parser.add_subparsers(dest="saved_command", required=True)
90
+ saved_list_parser = saved_subparsers.add_parser("list", help="list saved jobs")
91
+ saved_list_parser.add_argument("--stage", choices=sorted(VALID_STAGES))
92
+ saved_list_parser.add_argument("--json", action="store_true")
93
+ saved_list_parser.set_defaults(handler=saved_jobs_list.run)
94
+ saved_save_parser = saved_subparsers.add_parser("save", help="save a job")
95
+ saved_save_parser.add_argument("object_id")
96
+ saved_save_parser.add_argument("--stage", default="saved")
97
+ saved_save_parser.set_defaults(handler=saved_jobs_save.run)
98
+ saved_stage_parser = saved_subparsers.add_parser("stage", help="update a job stage")
99
+ saved_stage_parser.add_argument("object_id")
100
+ saved_stage_parser.add_argument("--stage", required=True)
101
+ saved_stage_parser.set_defaults(handler=saved_jobs_stage.run)
102
+ saved_remove_parser = saved_subparsers.add_parser("remove", help="remove a saved job")
103
+ saved_remove_parser.add_argument("object_id")
104
+ saved_remove_parser.add_argument("--yes", action="store_true")
105
+ saved_remove_parser.set_defaults(handler=saved_jobs_remove.run)
106
+ cache_parser = subparsers.add_parser("cache", help="manage anonymous read cache")
107
+ cache_subparsers = cache_parser.add_subparsers(dest="cache_command", required=True)
108
+ cache_purge_parser = cache_subparsers.add_parser("purge", help="remove cached anonymous reads")
109
+ cache_purge_parser.set_defaults(handler=cache_purge)
110
+ return parser
111
+
112
+
113
+ def cache_purge(argv=None, json_output: bool = False) -> int:
114
+ """Purge all anonymous cache files."""
115
+ CacheStore(active_cache_root()).purge()
116
+ print("Cache purged")
117
+ return 0
118
+
119
+
120
+ def main(argv: list[str] | None = None) -> None:
121
+ """Run the command-line interface.
122
+
123
+ Args:
124
+ argv: Optional argv list; defaults to ``sys.argv[1:]``.
125
+ """
126
+ arguments = build_parser().parse_args(argv)
127
+ handler = getattr(arguments, "handler", None)
128
+ if handler is not None:
129
+ json_output = getattr(arguments, "json", False)
130
+ try:
131
+ raise SystemExit(handler(arguments, json_output))
132
+ except InvalidSearchState as error:
133
+ print(f"hiringcafe: {error}", file=sys.stderr)
134
+ raise SystemExit(2) from error
135
+ except ValueError as error:
136
+ print(f"hiringcafe: {error}", file=sys.stderr)
137
+ raise SystemExit(1) from error
138
+ except FirebaseAuthError as error:
139
+ print(f"hiringcafe: {error}", file=sys.stderr)
140
+ raise SystemExit(5) from error
141
+ except FirebaseTransportError as error:
142
+ print(f"hiringcafe: {error}", file=sys.stderr)
143
+ raise SystemExit(1) from error
144
+ except FirestoreError as error:
145
+ print(f"hiringcafe: {error}", file=sys.stderr)
146
+ raise SystemExit(1) from error
147
+ except HireCafeHTTPError as error:
148
+ print(f"hiringcafe: {error}", file=sys.stderr)
149
+ raise SystemExit(3 if error.status == 429 else 1) from error
150
+ except HireCafeTransportError as error:
151
+ print(f"hiringcafe: {error}", file=sys.stderr)
152
+ raise SystemExit(1) from error
hiringcafe_cli/auth.py ADDED
@@ -0,0 +1,95 @@
1
+ """Firebase authentication commands."""
2
+
3
+ import getpass
4
+ import json
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from hiringcafe_cli.firebase import FirebaseClient
10
+ from hiringcafe_cli.session import SessionStore
11
+
12
+
13
+ def default_session_path() -> Path:
14
+ """Return the platform user config session path."""
15
+ config_home = os.environ.get("HIRINGCAFE_CONFIG_HOME")
16
+ if config_home:
17
+ return Path(config_home) / "session.json"
18
+ return Path.home() / ".config" / "hiringcafe-cli" / "session.json"
19
+
20
+
21
+ def store() -> SessionStore:
22
+ """Create the session store using the selected configuration root."""
23
+ return SessionStore(default_session_path())
24
+
25
+
26
+ def import_token(argv: object, json_output: bool = False) -> int:
27
+ """Import a refresh token through stdin or the nonprinting environment."""
28
+ token = sys.stdin.readline().strip() or os.environ.get("HIRINGCAFE_REFRESH_TOKEN", "")
29
+ if not token:
30
+ print("hiringcafe: no refresh token provided on stdin or HIRINGCAFE_REFRESH_TOKEN", file=sys.stderr)
31
+ return 2
32
+ credentials = FirebaseClient().refresh(token)
33
+ store().save(credentials["refreshToken"])
34
+ print("Authenticated Firebase session saved")
35
+ return 0
36
+
37
+
38
+ def login_email(argv: object, json_output: bool = False) -> int:
39
+ """Exchange Firebase email/password credentials once and save only the refresh token."""
40
+ password = os.environ.get("HIRINGCAFE_PASSWORD") or getpass.getpass("Firebase password: ")
41
+ credentials = FirebaseClient().login_with_password(argv.email, password)
42
+ del password
43
+ store().save(credentials["refreshToken"])
44
+ print("Authenticated Firebase session saved")
45
+ return 0
46
+
47
+
48
+ def status(argv: object, json_output: bool = False) -> int:
49
+ """Report whether the stored Firebase credential can obtain an ID token.
50
+
51
+ Args:
52
+ argv: Unused CLI namespace.
53
+ json_output: Whether stdout must be machine-readable.
54
+
55
+ Returns:
56
+ Exit code 0 when usable, 4 when no session exists.
57
+ """
58
+ session = store().load()
59
+ if session is None:
60
+ if json_output:
61
+ print(json.dumps({"version": 1, "authenticated": False}, separators=(",", ":")))
62
+ else:
63
+ print("No stored credential", file=sys.stderr)
64
+ return 4
65
+ FirebaseClient().refresh(session.refresh_token)
66
+ if json_output:
67
+ payload = json.dumps(
68
+ {"version": 1, "authenticated": True},
69
+ separators=(",", ":"),
70
+ )
71
+ print(payload)
72
+ else:
73
+ print("Authenticated")
74
+ return 0
75
+
76
+
77
+ def logout(argv: object, json_output: bool = False) -> int:
78
+ """Delete the local Firebase session.
79
+
80
+ Args:
81
+ argv: Unused CLI namespace.
82
+ json_output: Unused; logout output remains intentionally human-only.
83
+
84
+ Returns:
85
+ Exit code 0 after local credential removal.
86
+ """
87
+ session_store = store()
88
+ had_session = session_store.load() is not None
89
+ try:
90
+ session_store.delete()
91
+ except OSError as error:
92
+ print(f"hiringcafe: cannot remove session: {error}", file=sys.stderr)
93
+ return 1
94
+ print("Local Firebase session removed" if had_session else "No local Firebase session")
95
+ return 0
@@ -0,0 +1,98 @@
1
+ """Owner-only anonymous-read TTL cache."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ import time
9
+ from pathlib import Path
10
+
11
+ VERSION = 1
12
+
13
+
14
+ def default_cache_root(base: Path) -> Path:
15
+ """Return the versioned cache root under a configuration base."""
16
+ return base / "cache" / "v1"
17
+
18
+
19
+ def active_cache_root() -> Path:
20
+ """Return the configured or user-default cache root."""
21
+ configured = os.environ.get("HIRINGCAFE_CACHE_HOME")
22
+ if configured:
23
+ return default_cache_root(Path(configured))
24
+ return default_cache_root(Path.home() / ".cache" / "hiringcafe-cli")
25
+
26
+
27
+ class CacheStore:
28
+ def __init__(self, root: Path) -> None:
29
+ self.root = root
30
+
31
+ def path(self, key: tuple) -> Path:
32
+ raw = json.dumps(key, separators=(",", ":"), sort_keys=True)
33
+ name = hashlib.sha256(raw.encode()).hexdigest()
34
+ return self.root / f"{name}.json"
35
+
36
+ def read(self, key: tuple, ttl: int) -> dict | None:
37
+ path = self.path(key)
38
+ if not path.exists():
39
+ print("cache miss", file=sys.stderr)
40
+ return None
41
+ payload = None
42
+ try:
43
+ payload = json.loads(path.read_text(encoding="utf-8"))
44
+ except (OSError, json.JSONDecodeError):
45
+ payload = None
46
+ if not (
47
+ isinstance(payload, dict)
48
+ and payload.get("version") == VERSION
49
+ and isinstance(payload.get("expiresAt"), int)
50
+ and isinstance(payload.get("value"), dict)
51
+ ):
52
+ path.unlink(missing_ok=True)
53
+ print("cache miss", file=sys.stderr)
54
+ return None
55
+ if time.time() >= payload["expiresAt"]:
56
+ path.unlink(missing_ok=True)
57
+ print("cache miss", file=sys.stderr)
58
+ return None
59
+ print("cache hit", file=sys.stderr)
60
+ return payload["value"]
61
+
62
+ def write(self, key: tuple, value: dict, ttl: int) -> None:
63
+ """Atomically write an owner-only cache entry."""
64
+ self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
65
+ if self.root.exists():
66
+ os.chmod(self.root, 0o700)
67
+ destination = self.path(key)
68
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f"{destination.name}.", suffix=".tmp", dir=self.root)
69
+ try:
70
+ with os.fdopen(descriptor, "w", encoding="utf-8") as output:
71
+ os.chmod(temporary_name, 0o600)
72
+ json.dump(
73
+ {"version": VERSION, "expiresAt": int(time.time() + ttl), "value": value},
74
+ output,
75
+ separators=(",", ":"),
76
+ )
77
+ output.flush()
78
+ os.fsync(output.fileno())
79
+ os.replace(temporary_name, destination)
80
+ except BaseException:
81
+ Path(temporary_name).unlink(missing_ok=True)
82
+ raise
83
+
84
+ def purge(self) -> None:
85
+ if not self.root.exists():
86
+ return
87
+ for entry in self.root.iterdir():
88
+ if entry.is_file():
89
+ entry.unlink()
90
+
91
+
92
+ def cache_read(key: tuple, ttl: int, root: Path, enabled: bool = True) -> dict | None:
93
+ return CacheStore(root).read(key, ttl) if enabled else None
94
+
95
+
96
+ def cache_write(key: tuple, value: dict, ttl: int, root: Path, enabled: bool = True) -> None:
97
+ if enabled:
98
+ CacheStore(root).write(key, value, ttl)
@@ -0,0 +1,37 @@
1
+ """Anonymous result-count command."""
2
+
3
+ import json
4
+
5
+ from hiringcafe_cli.search import search
6
+
7
+
8
+ def count(
9
+ query: str, raw_search_state: str | None = None, no_cache: bool = False, cache_ttl: int | None = None
10
+ ) -> dict:
11
+ """Count results by requesting page zero.
12
+
13
+ Args:
14
+ query: Query-text mode.
15
+ raw_search_state: Raw JSON object mode.
16
+
17
+ Returns:
18
+ Stable count envelope.
19
+ """
20
+ envelope = search(query, 0, raw_search_state, no_cache=no_cache, cache_ttl=cache_ttl)
21
+ total_count = envelope["result"]["totalCount"]
22
+ return {"version": 1, "query": envelope["query"], "count": total_count}
23
+
24
+
25
+ def run(argv: object, json_output: bool) -> int:
26
+ """Execute count with CLI arguments."""
27
+ envelope = count(
28
+ argv.query,
29
+ argv.raw_search_state,
30
+ no_cache=getattr(argv, "no_cache", False),
31
+ cache_ttl=getattr(argv, "cache_ttl", None),
32
+ )
33
+ if json_output:
34
+ print(json.dumps(envelope, separators=(",", ":")))
35
+ else:
36
+ print(envelope["count"])
37
+ return 0
@@ -0,0 +1,93 @@
1
+ """Firebase Identity Toolkit REST adapter."""
2
+
3
+ import json
4
+ import urllib.error
5
+ import urllib.parse
6
+ import urllib.request
7
+ from urllib.request import urlopen
8
+
9
+ from hiringcafe_cli.io_limits import read_limited
10
+
11
+ FIREBASE_WEB_API_KEY = "AIzaSyBggiJ0ZSnwXoh1JILiairK8I3_BzI1V1E"
12
+ PROJECT_ID = "hiringcafe-ec4d6"
13
+
14
+
15
+ class FirebaseAuthError(RuntimeError):
16
+ """Firebase rejected the credential."""
17
+
18
+
19
+ class FirebaseTransportError(RuntimeError):
20
+ """Firebase could not be reached."""
21
+
22
+
23
+ class FirebaseClient:
24
+ """Client for the public Firebase Identity Toolkit endpoints."""
25
+
26
+ post = staticmethod(urlopen)
27
+
28
+ def refresh(self, refresh_token: str) -> dict:
29
+ """Exchange a refresh token for fresh credentials.
30
+
31
+ Args:
32
+ refresh_token: Stored Firebase refresh token.
33
+
34
+ Returns:
35
+ Firebase token response containing an ephemeral ID token.
36
+ """
37
+ query = urllib.parse.urlencode({"key": FIREBASE_WEB_API_KEY})
38
+ body = urllib.parse.urlencode({"grant_type": "refresh_token", "refresh_token": refresh_token}).encode()
39
+ payload = self._post(
40
+ f"https://securetoken.googleapis.com/v1/token?{query}",
41
+ body,
42
+ )
43
+ if "id_token" not in payload or "refresh_token" not in payload:
44
+ raise ValueError("Firebase token response is missing required fields")
45
+ return {
46
+ "idToken": payload["id_token"],
47
+ "refreshToken": payload["refresh_token"],
48
+ "userId": payload.get("user_id"),
49
+ }
50
+
51
+ def login_with_password(self, email: str, password: str) -> dict:
52
+ """Exchange Firebase email/password credentials once.
53
+
54
+ Args:
55
+ email: User's Firebase account email.
56
+ password: Password used in memory for exactly this request.
57
+
58
+ Returns:
59
+ Firebase sign-in response containing the durable refresh token.
60
+ """
61
+ query = urllib.parse.urlencode({"key": FIREBASE_WEB_API_KEY})
62
+ body = json.dumps({"email": email, "password": password, "returnSecureToken": True}).encode()
63
+ payload = self._post(
64
+ f"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?{query}",
65
+ body,
66
+ {"Content-Type": "application/json"},
67
+ )
68
+ if "refreshToken" not in payload:
69
+ raise ValueError("Firebase sign-in response has no refresh token")
70
+ return payload
71
+
72
+ def _post(self, url: str, body: bytes, extra_headers: dict | None = None, timeout: float = 30) -> dict:
73
+ """POST JSON or form data and parse a JSON object response."""
74
+ headers = {"Accept": "application/json"}
75
+ if extra_headers:
76
+ headers.update(extra_headers)
77
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
78
+ try:
79
+ with self.post(request, timeout=timeout) as response:
80
+ text = read_limited(response, "Firebase").decode("utf-8")
81
+ except urllib.error.HTTPError as error:
82
+ if error.code == 400:
83
+ raise FirebaseAuthError("Firebase rejected the credential") from error
84
+ raise FirebaseTransportError(f"Firebase returned HTTP {error.code}") from error
85
+ except (urllib.error.URLError, TimeoutError, OSError) as error:
86
+ raise FirebaseTransportError("Firebase transport failed") from error
87
+ try:
88
+ value = json.loads(text)
89
+ except json.JSONDecodeError as error:
90
+ raise ValueError("invalid Firebase JSON response") from error
91
+ if not isinstance(value, dict):
92
+ raise ValueError("Firebase response is not an object")
93
+ return value