cited-core 0.3.2__tar.gz

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,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .venv/
10
+ venv/
11
+ env/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .pytest_cache/
15
+ *.so
16
+ .coverage
17
+ htmlcov/
18
+ .env
19
+ ~/.cited/
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: cited-core
3
+ Version: 0.3.2
4
+ Summary: Core library for the Cited GEO platform
5
+ Author-email: Cited <support@youcited.com>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: keyring>=25.0.0
10
+ Requires-Dist: pydantic>=2.0.0
11
+ Requires-Dist: tomli-w>=1.0.0
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cited-core"
7
+ version = "0.3.2"
8
+ description = "Core library for the Cited GEO platform"
9
+ license = "LicenseRef-Proprietary"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "Cited", email = "support@youcited.com" }]
12
+ dependencies = [
13
+ "httpx>=0.27.0",
14
+ "keyring>=25.0.0",
15
+ "tomli-w>=1.0.0",
16
+ "pydantic>=2.0.0",
17
+ ]
18
+
19
+ [tool.hatch.build.targets.wheel]
20
+ packages = ["src/cited_core"]
@@ -0,0 +1 @@
1
+ __version__ = "0.3.2"
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from cited_core.errors import CitedAPIError
8
+
9
+ DEFAULT_TIMEOUT = 30.0
10
+ LONG_TIMEOUT = 120.0
11
+
12
+
13
+ class CitedClient:
14
+ def __init__(
15
+ self,
16
+ base_url: str,
17
+ token: str | None = None,
18
+ agent_api_key: str | None = None,
19
+ timeout: float = DEFAULT_TIMEOUT,
20
+ ) -> None:
21
+ self.base_url = base_url.rstrip("/")
22
+ self.token = token
23
+ self.agent_api_key = agent_api_key
24
+ headers: dict[str, str] = {
25
+ "Accept": "application/json",
26
+ }
27
+ cookies: dict[str, str] = {}
28
+ if token:
29
+ cookies["advgeo_session"] = token
30
+ if agent_api_key:
31
+ headers["X-Agent-API-Key"] = agent_api_key
32
+ self._client = httpx.Client(
33
+ base_url=self.base_url,
34
+ headers=headers,
35
+ cookies=cookies,
36
+ timeout=timeout,
37
+ follow_redirects=True,
38
+ )
39
+
40
+ def close(self) -> None:
41
+ self._client.close()
42
+
43
+ def __enter__(self) -> CitedClient:
44
+ return self
45
+
46
+ def __exit__(self, *args: Any) -> None:
47
+ self.close()
48
+
49
+ def _handle_response(self, response: httpx.Response) -> Any:
50
+ if response.is_success:
51
+ if response.headers.get("content-type", "").startswith("application/json"):
52
+ return response.json()
53
+ return response.text
54
+ # Try to extract error message from JSON body
55
+ message = f"HTTP {response.status_code}"
56
+ error_code = None
57
+ try:
58
+ body = response.json()
59
+ if isinstance(body, dict):
60
+ message = str(body.get("detail", body.get("message", message)))
61
+ error_code = body.get("error_code")
62
+ except Exception:
63
+ message = response.text or message
64
+ raise CitedAPIError(response.status_code, str(message), error_code)
65
+
66
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
67
+ response = self._client.get(path, params=params)
68
+ return self._handle_response(response)
69
+
70
+ def post(
71
+ self,
72
+ path: str,
73
+ json: dict[str, Any] | None = None,
74
+ data: dict[str, Any] | None = None,
75
+ timeout: float | None = None,
76
+ ) -> Any:
77
+ kwargs: dict[str, Any] = {}
78
+ if json is not None:
79
+ kwargs["json"] = json
80
+ if data is not None:
81
+ kwargs["data"] = data
82
+ if timeout is not None:
83
+ kwargs["timeout"] = timeout
84
+ response = self._client.post(path, **kwargs)
85
+ return self._handle_response(response)
86
+
87
+ def put(self, path: str, json: dict[str, Any] | None = None) -> Any:
88
+ response = self._client.put(path, json=json or {})
89
+ return self._handle_response(response)
90
+
91
+ def patch(self, path: str, json: dict[str, Any] | None = None) -> Any:
92
+ response = self._client.patch(path, json=json or {})
93
+ return self._handle_response(response)
94
+
95
+ def delete(self, path: str) -> Any:
96
+ response = self._client.delete(path)
97
+ return self._handle_response(response)
98
+
99
+ def post_raw(
100
+ self,
101
+ path: str,
102
+ json: dict[str, Any] | None = None,
103
+ timeout: float | None = None,
104
+ ) -> httpx.Response:
105
+ """POST that returns the raw httpx.Response (for cookie extraction, etc.)."""
106
+ kwargs: dict[str, Any] = {}
107
+ if json is not None:
108
+ kwargs["json"] = json
109
+ if timeout is not None:
110
+ kwargs["timeout"] = timeout
111
+ return self._client.post(path, **kwargs)
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ # Auth
4
+ LOGIN = "/auth/login"
5
+ CLI_LOGIN = "/auth/cli-login"
6
+ CLI_REGISTER = "/auth/cli-register"
7
+ CLI_VERIFY_EMAIL = "/auth/cli-verify-email"
8
+ CLI_OAUTH_START = "/auth/cli-oauth-start"
9
+ LOGOUT = "/auth/logout"
10
+ ME = "/auth/me"
11
+
12
+ # System / Health
13
+ ROOT = "/"
14
+ HEALTH = "/health"
15
+ HEALTH_READY = "/health/ready"
16
+ HEALTH_LIVE = "/health/live"
17
+ HEALTH_APIS = "/health/apis"
18
+
19
+ # Business
20
+ BUSINESSES = "/businesses"
21
+ BUSINESS = "/businesses/{business_id}"
22
+ BUSINESS_LOGO = "/businesses/{business_id}/logo"
23
+ HEALTH_SCORES = "/businesses/{business_id}/health-scores"
24
+ HEALTH_SCORES_BREAKDOWN = "/businesses/{business_id}/health-scores/breakdown"
25
+
26
+ # Business Crawl
27
+ CRAWL_DATA = "/businesses/{business_id}/crawl-data"
28
+ CRAWL_START = "/businesses/{business_id}/crawl"
29
+ CRAWL_CANCEL = "/businesses/{business_id}/crawl/cancel"
30
+
31
+ # Named Audits (templates)
32
+ NAMED_AUDITS = "/named-audits"
33
+ NAMED_AUDIT = "/named-audits/{named_audit_id}"
34
+
35
+ # Audit
36
+ AUDIT_START = "/audit/start"
37
+ AUDIT_STATUS = "/audit/{job_id}/status"
38
+ AUDIT_RESULT = "/audit/{job_id}/result"
39
+ AUDIT_DETAILS = "/audit/{job_id}/details"
40
+ AUDIT_EXPORT_PDF = "/audit/{job_id}/export/pdf"
41
+ AUDIT_HISTORY = "/audit/history"
42
+ AUDIT_CANCEL = "/audit/{job_id}/cancel"
43
+ AUDIT_DELETE = "/audit/{job_id}"
44
+ AUDIT_PROVIDERS = "/audit/{job_id}/providers"
45
+
46
+ # Recommendations
47
+ RECOMMEND_START = "/recommendations/start"
48
+ RECOMMEND_STATUS = "/recommendations/{job_id}/status"
49
+ RECOMMEND_RESULT = "/recommendations/{job_id}/result"
50
+ RECOMMEND_HISTORY = "/recommendations/audit/{audit_job_id}/history"
51
+ RECOMMEND_EXPORT_PDF = "/recommendations/{job_id}/export/pdf"
52
+ RECOMMEND_CANCEL = "/recommendations/{job_id}/cancel"
53
+ RECOMMEND_DELETE = "/recommendations/{job_id}"
54
+
55
+ # Solutions
56
+ SOLUTION_REQUEST = "/solutions/request"
57
+ SOLUTION_CREATE = "/solutions/create"
58
+ SOLUTION_PRIORITY_ACTION = "/solutions/priority-action"
59
+ SOLUTION_STATUS = "/solutions/{job_id}/status"
60
+ SOLUTION_RESULT = "/solutions/{job_id}/result"
61
+ SOLUTION_HISTORY = "/solutions/history"
62
+ SOLUTION_CANCEL = "/solutions/{job_id}/cancel"
63
+ SOLUTION_DELETE = "/solutions/{job_id}"
64
+
65
+ # Business HQ
66
+ HQ = "/businesses/{business_id}/hq"
67
+ HQ_HEAVY = "/businesses/{business_id}/hq/heavy"
68
+ HQ_PRIORITY = "/businesses/{business_id}/hq/priority"
69
+ HQ_EXPORT_PDF = "/businesses/{business_id}/hq/export/pdf"
70
+ HQ_RECOMPUTE = "/businesses/{business_id}/hq/recompute"
71
+ PERSONAS = "/businesses/{business_id}/personas"
72
+ PRODUCTS = "/businesses/{business_id}/products"
73
+ BUYER_INTENTS = "/businesses/{business_id}/buyer-intents"
74
+ AGENTIC_READINESS = "/businesses/{business_id}/agentic-readiness"
75
+ TRUST_SIGNALS = "/businesses/{business_id}/trust-signals"
76
+
77
+ # Analytics
78
+ ANALYTICS_COMPARISON = "/analytics/audits/{audit_id}/comparison"
79
+ ANALYTICS_SUMMARY = "/analytics/businesses/{business_id}/summary"
80
+ ANALYTICS_TRENDS = "/analytics/businesses/{business_id}/trends"
81
+ ANALYTICS_ADVANCED = "/analytics/businesses/{business_id}/advanced-scoring"
82
+
83
+ # Agent Query API (v1)
84
+ AGENT_ROOT = "/agent/v1"
85
+ AGENT_BUSINESS = "/agent/v1/business/{business_id}"
86
+ AGENT_FACTS = "/agent/v1/business/{business_id}/facts"
87
+ AGENT_CLAIMS = "/agent/v1/business/{business_id}/claims"
88
+ AGENT_COMPARISON = "/agent/v1/business/{business_id}/comparison"
89
+ AGENT_SEMANTIC_HEALTH = "/agent/v1/business/{business_id}/semantic-health"
90
+ AGENT_BUYER_FIT = "/agent/v1/query/buyer-fit"
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import socket
5
+ import threading
6
+ from functools import partial
7
+ from http.server import BaseHTTPRequestHandler, HTTPServer
8
+ from urllib.parse import parse_qs, urlparse
9
+
10
+ _SUCCESS_HTML_TEMPLATE = """\
11
+ <!DOCTYPE html>
12
+ <html>
13
+ <head><meta charset="utf-8"><title>Cited CLI</title>
14
+ <style>body{{font-family:system-ui,sans-serif;display:flex;justify-content:center;
15
+ align-items:center;height:100vh;margin:0;background:#f8f9fa;}}
16
+ .card{{text-align:center;padding:2rem;max-width:600px;}}
17
+ .token-section{{margin-top:1.5rem;text-align:left;}}
18
+ .token-box{{background:#f1f5f9;border:1px solid #cbd5e1;border-radius:6px;
19
+ padding:0.75rem;font-family:monospace;font-size:0.8rem;word-break:break-all;
20
+ max-height:4rem;overflow-y:auto;}}
21
+ .copy-btn{{margin-top:0.5rem;padding:0.4rem 1rem;background:#3b82f6;color:#fff;
22
+ border:none;border-radius:4px;cursor:pointer;font-size:0.85rem;}}
23
+ .copy-btn:hover{{background:#2563eb;}}</style></head>
24
+ <body><div class="card">
25
+ <h1 style="color:#22c55e;">&#10003; Authentication successful</h1>
26
+ <p>You can close this tab and return to your terminal.</p>
27
+ <details class="token-section">
28
+ <summary>Terminal not responding? Copy your token manually</summary>
29
+ <p style="font-size:0.85rem;color:#475569;">
30
+ If the CLI didn't detect the login, copy this token and paste it in your terminal.</p>
31
+ <div class="token-box" id="token-text">{token}</div>
32
+ <button class="copy-btn" onclick="
33
+ var t=document.getElementById('token-text').textContent;
34
+ navigator.clipboard.writeText(t).then(function(){{this.textContent='Copied!'}}.bind(this))
35
+ ">Copy token</button>
36
+ </details>
37
+ </div></body></html>
38
+ """
39
+
40
+ _ERROR_HTML = """\
41
+ <!DOCTYPE html>
42
+ <html>
43
+ <head><meta charset="utf-8"><title>Cited CLI</title>
44
+ <style>body{font-family:system-ui,sans-serif;display:flex;justify-content:center;
45
+ align-items:center;height:100vh;margin:0;background:#f8f9fa;}
46
+ .card{text-align:center;padding:2rem;}</style></head>
47
+ <body><div class="card">
48
+ <h1 style="color:#ef4444;">Authentication failed</h1>
49
+ <p>No token was received. Please try again.</p>
50
+ </div></body></html>
51
+ """
52
+
53
+
54
+ def _find_free_port() -> int:
55
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
56
+ s.bind(("127.0.0.1", 0))
57
+ port: int = s.getsockname()[1]
58
+ return port
59
+
60
+
61
+ class _CallbackHandler(BaseHTTPRequestHandler):
62
+ def __init__( # type: ignore[no-untyped-def]
63
+ self, token_event: threading.Event, server_ref: OAuthCallbackServer, *args, **kwargs,
64
+ ):
65
+ self._token_event = token_event
66
+ self._server_ref = server_ref
67
+ super().__init__(*args, **kwargs)
68
+
69
+ def do_GET(self) -> None: # noqa: N802
70
+ parsed = urlparse(self.path)
71
+ if parsed.path != "/callback":
72
+ self.send_error(404)
73
+ return
74
+
75
+ params = parse_qs(parsed.query)
76
+ token = params.get("token", [None])[0]
77
+
78
+ if token:
79
+ self._server_ref.token = token
80
+ success_html = _SUCCESS_HTML_TEMPLATE.format(token=html.escape(token))
81
+ self._send_html(200, success_html)
82
+ self._token_event.set()
83
+ else:
84
+ self._send_html(400, _ERROR_HTML)
85
+
86
+ def _send_html(self, code: int, html: str) -> None:
87
+ self.send_response(code)
88
+ self.send_header("Content-Type", "text/html; charset=utf-8")
89
+ self.end_headers()
90
+ self.wfile.write(html.encode())
91
+
92
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002
93
+ pass # Suppress request logging
94
+
95
+
96
+ class OAuthCallbackServer:
97
+ """Temporary localhost server to receive OAuth callback with token."""
98
+
99
+ def __init__(self, timeout: float = 120.0) -> None:
100
+ self.token: str | None = None
101
+ self.port: int = _find_free_port()
102
+ self.redirect_uri = f"http://localhost:{self.port}/callback"
103
+ self._timeout = timeout
104
+ self._token_event = threading.Event()
105
+ self._server: HTTPServer | None = None
106
+ self._thread: threading.Thread | None = None
107
+
108
+ def start(self) -> None:
109
+ handler = partial(_CallbackHandler, self._token_event, self)
110
+ self._server = HTTPServer(("127.0.0.1", self.port), handler)
111
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
112
+ self._thread.start()
113
+
114
+ def wait_for_token(self) -> str | None:
115
+ self._token_event.wait(timeout=self._timeout)
116
+ return self.token
117
+
118
+ def shutdown(self) -> None:
119
+ if self._server:
120
+ self._server.shutdown()
121
+ if self._thread:
122
+ self._thread.join(timeout=5)
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import json
5
+ import os
6
+ import stat
7
+
8
+ from cited_core.config.constants import CONFIG_DIR, CREDENTIALS_FILE, KEYRING_SERVICE
9
+
10
+
11
+ class TokenStore:
12
+ def __init__(self) -> None:
13
+ self._keyring_available: bool | None = None
14
+
15
+ def _has_keyring(self) -> bool:
16
+ if self._keyring_available is None:
17
+ try:
18
+ import keyring
19
+ from keyring.backends.fail import Keyring as FailKeyring
20
+
21
+ backend = keyring.get_keyring()
22
+ self._keyring_available = not isinstance(backend, FailKeyring)
23
+ except Exception:
24
+ self._keyring_available = False
25
+ return self._keyring_available
26
+
27
+ def save_token(self, env: str, token: str) -> None:
28
+ if self._has_keyring():
29
+ import keyring
30
+
31
+ keyring.set_password(KEYRING_SERVICE, f"token:{env}", token)
32
+ else:
33
+ self._save_file(env, token)
34
+
35
+ def get_token(self, env: str) -> str | None:
36
+ if self._has_keyring():
37
+ import keyring
38
+
39
+ return keyring.get_password(KEYRING_SERVICE, f"token:{env}")
40
+ return self._load_file(env)
41
+
42
+ def delete_token(self, env: str) -> None:
43
+ if self._has_keyring():
44
+ import keyring
45
+
46
+ with contextlib.suppress(keyring.errors.PasswordDeleteError):
47
+ keyring.delete_password(KEYRING_SERVICE, f"token:{env}")
48
+ else:
49
+ self._delete_file(env)
50
+
51
+ def has_token(self, env: str) -> bool:
52
+ return self.get_token(env) is not None
53
+
54
+ # --- file fallback ---
55
+
56
+ def _ensure_creds_file(self) -> dict[str, str]:
57
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
58
+ if CREDENTIALS_FILE.exists():
59
+ with open(CREDENTIALS_FILE) as f:
60
+ data: dict[str, str] = json.load(f)
61
+ return data
62
+ return {}
63
+
64
+ def _write_creds(self, data: dict[str, str]) -> None:
65
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
66
+ with open(CREDENTIALS_FILE, "w") as f:
67
+ json.dump(data, f, indent=2)
68
+ os.chmod(CREDENTIALS_FILE, stat.S_IRUSR | stat.S_IWUSR)
69
+
70
+ def _save_file(self, env: str, token: str) -> None:
71
+ data = self._ensure_creds_file()
72
+ data[env] = token
73
+ self._write_creds(data)
74
+
75
+ def _load_file(self, env: str) -> str | None:
76
+ data = self._ensure_creds_file()
77
+ return data.get(env)
78
+
79
+ def _delete_file(self, env: str) -> None:
80
+ data = self._ensure_creds_file()
81
+ if env in data:
82
+ del data[env]
83
+ self._write_creds(data)
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ ENVIRONMENTS: dict[str, str] = {
6
+ "prod": "https://api.youcited.com",
7
+ "dev": "https://dev.youcited.com",
8
+ "local": "http://localhost:8000",
9
+ }
10
+
11
+ FRONTEND_URLS: dict[str, str] = {
12
+ "prod": "https://app.youcited.com",
13
+ "dev": "https://dev.youcited.com",
14
+ "local": "http://localhost:3000",
15
+ }
16
+
17
+ DEFAULT_ENV = "prod"
18
+ CONFIG_DIR = Path.home() / ".cited"
19
+ CONFIG_FILE = CONFIG_DIR / "config.toml"
20
+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
21
+ KEYRING_SERVICE = "cited-cli"
22
+
23
+ VALID_INDUSTRIES = [
24
+ "automotive",
25
+ "beauty",
26
+ "consulting",
27
+ "education",
28
+ "entertainment",
29
+ "finance",
30
+ "fitness",
31
+ "government",
32
+ "healthcare",
33
+ "home_services",
34
+ "hospitality",
35
+ "legal",
36
+ "manufacturing",
37
+ "non_profit",
38
+ "real_estate",
39
+ "restaurant",
40
+ "retail",
41
+ "technology",
42
+ "other",
43
+ ]
44
+
45
+ VALID_SOURCE_TYPES = [
46
+ "question_insight",
47
+ "head_to_head",
48
+ "strengthening_tip",
49
+ "priority_action",
50
+ ]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ import tomllib
4
+ from typing import Any
5
+
6
+ import tomli_w
7
+
8
+ from cited_core.config.constants import CONFIG_DIR, CONFIG_FILE, DEFAULT_ENV, ENVIRONMENTS
9
+
10
+ VALID_KEYS = {
11
+ "environment",
12
+ "default_business_id",
13
+ "agent_api_key",
14
+ "output",
15
+ }
16
+
17
+ VALID_OUTPUT_VALUES = {"json", "text"}
18
+
19
+
20
+ class ConfigManager:
21
+ def __init__(self) -> None:
22
+ self._ensure_config_dir()
23
+ self._data: dict[str, Any] = self._load()
24
+
25
+ def _ensure_config_dir(self) -> None:
26
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
27
+
28
+ def _load(self) -> dict[str, Any]:
29
+ if not CONFIG_FILE.exists():
30
+ return {"default": {}}
31
+ with open(CONFIG_FILE, "rb") as f:
32
+ return tomllib.load(f)
33
+
34
+ def _save(self) -> None:
35
+ with open(CONFIG_FILE, "wb") as f:
36
+ tomli_w.dump(self._data, f)
37
+
38
+ def get(self, key: str, profile: str = "default") -> Any:
39
+ section = self._data.get(profile, self._data.get("default", {}))
40
+ return section.get(key)
41
+
42
+ def set(self, key: str, value: str, profile: str = "default") -> None:
43
+ if profile not in self._data:
44
+ self._data[profile] = {}
45
+ self._data[profile][key] = value
46
+ self._save()
47
+
48
+ def delete(self, key: str, profile: str = "default") -> bool:
49
+ section = self._data.get(profile, {})
50
+ if key in section:
51
+ del section[key]
52
+ self._save()
53
+ return True
54
+ return False
55
+
56
+ def get_all(self, profile: str = "default") -> dict[str, Any]:
57
+ return dict(self._data.get(profile, {}))
58
+
59
+ def get_environment(self, profile: str = "default", override: str | None = None) -> str:
60
+ if override:
61
+ return override
62
+ return self.get("environment", profile) or DEFAULT_ENV
63
+
64
+ def get_api_url(self, profile: str = "default", env_override: str | None = None) -> str:
65
+ env = self.get_environment(profile, env_override)
66
+ return ENVIRONMENTS.get(env, ENVIRONMENTS[DEFAULT_ENV])
67
+
68
+ def get_profiles(self) -> list[str]:
69
+ return [k for k in self._data if k != "default" and isinstance(self._data[k], dict)]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import IntEnum
4
+
5
+
6
+ class ExitCode(IntEnum):
7
+ SUCCESS = 0
8
+ ERROR = 1
9
+ AUTH_ERROR = 2
10
+ NOT_FOUND = 3
11
+ VALIDATION_ERROR = 4
12
+ RATE_LIMITED = 5
13
+
14
+
15
+ class CitedAPIError(Exception):
16
+ def __init__(self, status_code: int, message: str, error_code: str | None = None):
17
+ self.status_code = status_code
18
+ self.message = message
19
+ self.error_code = error_code
20
+ super().__init__(message)
21
+
22
+
23
+ def exit_code_for_status(status_code: int) -> ExitCode:
24
+ if status_code == 401 or status_code == 403:
25
+ return ExitCode.AUTH_ERROR
26
+ if status_code == 404:
27
+ return ExitCode.NOT_FOUND
28
+ if status_code == 422:
29
+ return ExitCode.VALIDATION_ERROR
30
+ if status_code == 429:
31
+ return ExitCode.RATE_LIMITED
32
+ return ExitCode.ERROR