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.
- hiringcafe_cli/__init__.py +152 -0
- hiringcafe_cli/auth.py +95 -0
- hiringcafe_cli/cache.py +98 -0
- hiringcafe_cli/count.py +37 -0
- hiringcafe_cli/firebase.py +93 -0
- hiringcafe_cli/http_client.py +245 -0
- hiringcafe_cli/io_limits.py +23 -0
- hiringcafe_cli/job.py +63 -0
- hiringcafe_cli/probe.py +140 -0
- hiringcafe_cli/saved_jobs.py +101 -0
- hiringcafe_cli/saved_jobs_list.py +161 -0
- hiringcafe_cli/saved_jobs_remove.py +51 -0
- hiringcafe_cli/saved_jobs_save.py +59 -0
- hiringcafe_cli/saved_jobs_stage.py +64 -0
- hiringcafe_cli/search.py +140 -0
- hiringcafe_cli/search_state.py +36 -0
- hiringcafe_cli/session.py +82 -0
- hiringcafe_cli-0.1.4.dist-info/METADATA +145 -0
- hiringcafe_cli-0.1.4.dist-info/RECORD +22 -0
- hiringcafe_cli-0.1.4.dist-info/WHEEL +4 -0
- hiringcafe_cli-0.1.4.dist-info/entry_points.txt +3 -0
- hiringcafe_cli-0.1.4.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|
hiringcafe_cli/cache.py
ADDED
|
@@ -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)
|
hiringcafe_cli/count.py
ADDED
|
@@ -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
|