selltostate-cli 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.
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: selltostate-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: selltostate: official CLI for the Sell to State government-contracts data API (REST/MCP parity).
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://www.selltostate.com/cli/
|
|
7
|
+
Project-URL: Source, https://github.com/getbeton/selltostate-cli
|
|
8
|
+
Keywords: selltostate,government,tenders,procurement,cli
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# `selltostate-cli` — `selltostate`
|
|
16
|
+
|
|
17
|
+
Official command-line client for the **Sell to State** government-contracts data API
|
|
18
|
+
(`app.selltostate.com`). Python port of the npm package
|
|
19
|
+
[`@selltostate/cli`](https://www.npmjs.com/package/@selltostate/cli): the same commands,
|
|
20
|
+
flags, output and exit codes. Standard library only, Python 3.9+.
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pipx install selltostate-cli # or: pip install selltostate-cli / uv tool install selltostate-cli
|
|
24
|
+
selltostate auth login # prompts for the key, checks it with /whoami, saves it
|
|
25
|
+
selltostate search "IT consulting" --country singapore --year-min 2026 --format json
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Get a key at `https://app.selltostate.com/dashboard` (Settings -> API keys).
|
|
29
|
+
|
|
30
|
+
## Auth and configuration
|
|
31
|
+
|
|
32
|
+
The CLI sends a dashboard API key as the `x-api-key` header. Resolution order:
|
|
33
|
+
|
|
34
|
+
1. `--api-key` / `--api-url` flags
|
|
35
|
+
2. `STS_API_KEY` / `STS_API_URL` environment variables
|
|
36
|
+
3. `~/.config/selltostate/config.json` (written by `auth login`, mode `0600`; honours `XDG_CONFIG_HOME`)
|
|
37
|
+
4. Default API URL `https://app.selltostate.com`
|
|
38
|
+
|
|
39
|
+
The npm and the Python CLI share this config file.
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
| Command | Endpoint |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| `whoami` | `GET /api/v1/whoami` |
|
|
46
|
+
| `search <q> [filters]` | `GET /api/v1/search` |
|
|
47
|
+
| `awards [filters]` | `GET /api/v1/awards` |
|
|
48
|
+
| `get buyer\|supplier\|tender\|sector <id>` | `GET /api/v1/{buyers\|suppliers\|tenders\|sectors}/<id>` (tender: `award_id`) |
|
|
49
|
+
| `sectors`, `suppliers [--limit]`, `buyers [--limit]` | `GET /api/v1/{sectors\|suppliers\|buyers}` |
|
|
50
|
+
| `lists ls\|create\|show\|add\|rm\|rename\|delete` | `/api/v1/lists*` |
|
|
51
|
+
| `saved-searches ls\|save\|show\|rename\|delete` | `/api/v1/saved-searches*` |
|
|
52
|
+
| `export [filters] [-o file]` | `GET /api/v1/awards.csv` |
|
|
53
|
+
| `price` | `GET /api/v1/price` (no auth) |
|
|
54
|
+
| `pay [--token --customer]` | `POST /api/v1/pay` |
|
|
55
|
+
|
|
56
|
+
`--format table|json|csv` (default `table`; `--json` = `--format json`). Run
|
|
57
|
+
`selltostate <command> --help` for the flags of one command.
|
|
58
|
+
|
|
59
|
+
## Exit codes
|
|
60
|
+
|
|
61
|
+
`0` success · `1` error · `2` unauthorized (401) · `3` payment required (402) · `4` rate limited (429) · `64` usage error
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
This package lives in the `data-atlas` monorepo next to `packages/cli`. Keep both in lockstep.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python -m unittest discover -s tests # no network; compares --help with packages/cli/dist when it is built
|
|
69
|
+
uv build # dist/selltostate_cli-<version>-py3-none-any.whl
|
|
70
|
+
```
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
selltostate_cli.py,sha256=X2O5VxQx2WbvnvC3g6fUVUpwYpcNCCB5i3Y2ED923kM,41344
|
|
2
|
+
selltostate_cli-0.1.0.dist-info/METADATA,sha256=3nUwAPMVkTmcQCeXQD_BF8RRd3j4IMxxNEi3znfPd0Y,2994
|
|
3
|
+
selltostate_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
4
|
+
selltostate_cli-0.1.0.dist-info/entry_points.txt,sha256=BOJDddCUFfm1Sq8A0Qgcfj9ZXFXYps95qqqEwyy873I,53
|
|
5
|
+
selltostate_cli-0.1.0.dist-info/top_level.txt,sha256=8DjHuPhMtnBhTay8BnDtfHBICUxZmcvC9PXZbafJsFw,16
|
|
6
|
+
selltostate_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
selltostate_cli
|
selltostate_cli.py
ADDED
|
@@ -0,0 +1,1088 @@
|
|
|
1
|
+
"""selltostate: official CLI for the Sell to State government-contracts data API.
|
|
2
|
+
|
|
3
|
+
Python port of packages/cli (npm @selltostate/cli). Same commands, flags, output
|
|
4
|
+
and exit codes; stdlib only. Every data command maps 1:1 to a REST /api/v1
|
|
5
|
+
endpoint and sends the dashboard key as x-api-key. A 402 prints the agent-native
|
|
6
|
+
payment_link; a 401 points at `auth login`. Keep in lockstep with packages/cli.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import getpass
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import urllib.request
|
|
20
|
+
|
|
21
|
+
VERSION = "0.1.0"
|
|
22
|
+
BIN = "selltostate"
|
|
23
|
+
DEFAULT_API_URL = "https://app.selltostate.com"
|
|
24
|
+
|
|
25
|
+
# Exit codes (same as the npm CLI).
|
|
26
|
+
EXIT_ERROR, EXIT_UNAUTHORIZED, EXIT_PAYMENT, EXIT_RATE_LIMIT, EXIT_USAGE = 1, 2, 3, 4, 64
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Exit(Exception):
|
|
30
|
+
"""Raised to end the command with an exit code (main() turns it into sys.exit)."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, code: int):
|
|
33
|
+
super().__init__(code)
|
|
34
|
+
self.code = code
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── Output ───────────────────────────────────────────────────────────────────
|
|
38
|
+
def out(data) -> None:
|
|
39
|
+
s = data if isinstance(data, str) else dumps(data)
|
|
40
|
+
sys.stdout.write(s if s.endswith("\n") else s + "\n")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def err(message: str) -> None:
|
|
44
|
+
sys.stderr.write(f"{BIN}: {message}\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dumps(data) -> str:
|
|
48
|
+
"""JSON.stringify(data, null, 2)."""
|
|
49
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Config (flag -> env -> ~/.config/selltostate/config.json -> default) ─────
|
|
53
|
+
def config_dir() -> str:
|
|
54
|
+
xdg = (os.environ.get("XDG_CONFIG_HOME") or "").strip()
|
|
55
|
+
return os.path.join(xdg or os.path.join(os.path.expanduser("~"), ".config"), "selltostate")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def config_path() -> str:
|
|
59
|
+
return os.path.join(config_dir(), "config.json")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_stored_config() -> dict:
|
|
63
|
+
try:
|
|
64
|
+
with open(config_path(), encoding="utf-8") as f:
|
|
65
|
+
parsed = json.load(f)
|
|
66
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
67
|
+
except (OSError, ValueError):
|
|
68
|
+
# A missing or corrupt config file must never crash the CLI.
|
|
69
|
+
return {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_stored_config(patch: dict) -> dict:
|
|
73
|
+
"""Merge patch into the config file. It holds the API key: file 0600, dir 0700."""
|
|
74
|
+
d = config_dir()
|
|
75
|
+
os.makedirs(d, exist_ok=True)
|
|
76
|
+
try:
|
|
77
|
+
os.chmod(d, 0o700)
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
merged = {**read_stored_config(), **patch}
|
|
81
|
+
merged = {k: v for k, v in merged.items() if v}
|
|
82
|
+
path = config_path()
|
|
83
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
84
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
85
|
+
f.write(json.dumps(merged, indent=2) + "\n")
|
|
86
|
+
try:
|
|
87
|
+
os.chmod(path, 0o600)
|
|
88
|
+
except OSError:
|
|
89
|
+
pass
|
|
90
|
+
return merged
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_config(api_key: str | None = None, api_url: str | None = None) -> dict:
|
|
94
|
+
stored = read_stored_config()
|
|
95
|
+
key, key_src = None, "none"
|
|
96
|
+
for value, src in ((api_key, "flag"), (os.environ.get("STS_API_KEY"), "env"), (stored.get("apiKey"), "file")):
|
|
97
|
+
if value:
|
|
98
|
+
key, key_src = value, src
|
|
99
|
+
break
|
|
100
|
+
url, url_src = DEFAULT_API_URL, "default"
|
|
101
|
+
for value, src in ((api_url, "flag"), (os.environ.get("STS_API_URL"), "env"), (stored.get("apiUrl"), "file")):
|
|
102
|
+
if value:
|
|
103
|
+
url, url_src = value, src
|
|
104
|
+
break
|
|
105
|
+
return {
|
|
106
|
+
"apiUrl": url.strip().rstrip("/"),
|
|
107
|
+
"apiKey": (key or "").strip() or None,
|
|
108
|
+
"source": {"apiKey": key_src, "apiUrl": url_src},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── REST client ──────────────────────────────────────────────────────────────
|
|
113
|
+
class ApiError(Exception):
|
|
114
|
+
"""A non-2xx response, with the parsed body when JSON."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, status: int, message: str, body=None):
|
|
117
|
+
super().__init__(message)
|
|
118
|
+
self.status = status
|
|
119
|
+
self.body = body
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _query_value(v) -> str:
|
|
123
|
+
if isinstance(v, bool):
|
|
124
|
+
return "true" if v else "false"
|
|
125
|
+
if isinstance(v, float) and v.is_integer():
|
|
126
|
+
return str(int(v))
|
|
127
|
+
return str(v)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_query(query: dict | None) -> str:
|
|
131
|
+
pairs = []
|
|
132
|
+
for key, value in (query or {}).items():
|
|
133
|
+
for v in value if isinstance(value, list) else [value]:
|
|
134
|
+
if v is None or v == "":
|
|
135
|
+
continue
|
|
136
|
+
pairs.append((key, _query_value(v)))
|
|
137
|
+
return "?" + urllib.parse.urlencode(pairs) if pairs else ""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
141
|
+
def redirect_request(self, *args, **kwargs): # noqa: D401 - urllib hook
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _default_open(req: urllib.request.Request, follow: bool):
|
|
146
|
+
opener = urllib.request.build_opener() if follow else urllib.request.build_opener(_NoRedirect)
|
|
147
|
+
return opener.open(req, timeout=60)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _quote(segment: str) -> str:
|
|
151
|
+
"""encodeURIComponent."""
|
|
152
|
+
return urllib.parse.quote(str(segment), safe="!'()*-._~")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class ApiClient:
|
|
156
|
+
"""One method per documented machine endpoint. `opener(req, follow)` is injectable for tests."""
|
|
157
|
+
|
|
158
|
+
def __init__(self, api_url: str, api_key: str | None, user_agent: str = "selltostate-cli", opener=None):
|
|
159
|
+
self.api_url = (api_url or DEFAULT_API_URL).rstrip("/")
|
|
160
|
+
self.api_key = api_key
|
|
161
|
+
self.user_agent = user_agent
|
|
162
|
+
self.opener = opener or _default_open
|
|
163
|
+
|
|
164
|
+
def request(self, method: str, path: str, query=None, body=None, raw_text=False, no_follow=False):
|
|
165
|
+
url = f"{self.api_url}{path}{build_query(query)}"
|
|
166
|
+
headers = {"accept": "application/json", "user-agent": self.user_agent}
|
|
167
|
+
if self.api_key:
|
|
168
|
+
headers["x-api-key"] = self.api_key
|
|
169
|
+
data = None
|
|
170
|
+
if body is not None:
|
|
171
|
+
headers["content-type"] = "application/json"
|
|
172
|
+
data = json.dumps(body).encode()
|
|
173
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
res = self.opener(req, not no_follow)
|
|
177
|
+
try:
|
|
178
|
+
status, res_headers, payload = res.status, res.headers, res.read()
|
|
179
|
+
finally:
|
|
180
|
+
getattr(res, "close", lambda: None)()
|
|
181
|
+
except urllib.error.HTTPError as e:
|
|
182
|
+
with e:
|
|
183
|
+
status, res_headers, payload = e.code, e.headers, e.read()
|
|
184
|
+
except (urllib.error.URLError, OSError) as e:
|
|
185
|
+
raise RuntimeError(f"network error talking to {url}: {getattr(e, 'reason', e)}") from e
|
|
186
|
+
|
|
187
|
+
# The awards.csv route redirects to /checkout (no subscription) or /auth
|
|
188
|
+
# (bad key). Map it to the same 402/401 contract the JSON endpoints use,
|
|
189
|
+
# so the CSV file never gets an HTML login page.
|
|
190
|
+
if no_follow and 300 <= status < 400:
|
|
191
|
+
location = res_headers.get("location") or ""
|
|
192
|
+
code = 401 if re.search(r"/auth\b", location) else 402
|
|
193
|
+
message = (
|
|
194
|
+
"export requires a valid API key (redirected to /auth)"
|
|
195
|
+
if code == 401
|
|
196
|
+
else "export requires an active subscription (redirected to /checkout)"
|
|
197
|
+
)
|
|
198
|
+
raise ApiError(code, message, {"error": "unauthorized" if code == 401 else "payment_required", "redirect": location})
|
|
199
|
+
|
|
200
|
+
text = payload.decode("utf-8", errors="replace")
|
|
201
|
+
is_json = "application/json" in (res_headers.get("content-type") or "")
|
|
202
|
+
|
|
203
|
+
if not 200 <= status < 300:
|
|
204
|
+
parsed = text
|
|
205
|
+
if is_json:
|
|
206
|
+
try:
|
|
207
|
+
parsed = json.loads(text)
|
|
208
|
+
except ValueError:
|
|
209
|
+
pass
|
|
210
|
+
message = parsed.get("error") if isinstance(parsed, dict) and parsed.get("error") else f"{method} {path} -> {status}"
|
|
211
|
+
raise ApiError(status, str(message), parsed)
|
|
212
|
+
|
|
213
|
+
if raw_text:
|
|
214
|
+
return text
|
|
215
|
+
if status == 204:
|
|
216
|
+
return None
|
|
217
|
+
return json.loads(text) if is_json and text else text
|
|
218
|
+
|
|
219
|
+
# Identity, pricing, payment
|
|
220
|
+
def whoami(self):
|
|
221
|
+
return self.request("GET", "/api/v1/whoami")
|
|
222
|
+
|
|
223
|
+
def price(self):
|
|
224
|
+
return self.request("GET", "/api/v1/price")
|
|
225
|
+
|
|
226
|
+
def pay(self, body=None):
|
|
227
|
+
return self.request("POST", "/api/v1/pay", body=body or {})
|
|
228
|
+
|
|
229
|
+
# Search and awards
|
|
230
|
+
def search(self, query):
|
|
231
|
+
return self.request("GET", "/api/v1/search", query=query)
|
|
232
|
+
|
|
233
|
+
def awards(self, query):
|
|
234
|
+
return self.request("GET", "/api/v1/awards", query=query)
|
|
235
|
+
|
|
236
|
+
def awards_csv(self, query):
|
|
237
|
+
return self.request("GET", "/api/v1/awards.csv", query=query, raw_text=True, no_follow=True)
|
|
238
|
+
|
|
239
|
+
# Reference lists
|
|
240
|
+
def sectors(self):
|
|
241
|
+
return self.request("GET", "/api/v1/sectors")
|
|
242
|
+
|
|
243
|
+
def suppliers(self, query=None):
|
|
244
|
+
return self.request("GET", "/api/v1/suppliers", query=query)
|
|
245
|
+
|
|
246
|
+
def buyers(self, query=None):
|
|
247
|
+
return self.request("GET", "/api/v1/buyers", query=query)
|
|
248
|
+
|
|
249
|
+
# Entity detail
|
|
250
|
+
def get_buyer(self, slug, query=None):
|
|
251
|
+
return self.request("GET", f"/api/v1/buyers/{_quote(slug)}", query=query)
|
|
252
|
+
|
|
253
|
+
def get_supplier(self, slug, query=None):
|
|
254
|
+
return self.request("GET", f"/api/v1/suppliers/{_quote(slug)}", query=query)
|
|
255
|
+
|
|
256
|
+
def get_tender(self, tender_id):
|
|
257
|
+
return self.request("GET", f"/api/v1/tenders/{_quote(tender_id)}")
|
|
258
|
+
|
|
259
|
+
def get_sector(self, slug, query=None):
|
|
260
|
+
return self.request("GET", f"/api/v1/sectors/{_quote(slug)}", query=query)
|
|
261
|
+
|
|
262
|
+
# Lists (owner-scoped, x-api-key only)
|
|
263
|
+
def list_lists(self):
|
|
264
|
+
return self.request("GET", "/api/v1/lists")
|
|
265
|
+
|
|
266
|
+
def get_list(self, list_id, query=None):
|
|
267
|
+
return self.request("GET", f"/api/v1/lists/{_quote(list_id)}", query=query)
|
|
268
|
+
|
|
269
|
+
def create_list(self, name):
|
|
270
|
+
return self.request("POST", "/api/v1/lists", body={"name": name})
|
|
271
|
+
|
|
272
|
+
def rename_list(self, list_id, name):
|
|
273
|
+
return self.request("PATCH", f"/api/v1/lists/{_quote(list_id)}", body={"name": name})
|
|
274
|
+
|
|
275
|
+
def delete_list(self, list_id):
|
|
276
|
+
return self.request("DELETE", f"/api/v1/lists/{_quote(list_id)}")
|
|
277
|
+
|
|
278
|
+
def add_list_item(self, list_id, item):
|
|
279
|
+
return self.request("POST", f"/api/v1/lists/{_quote(list_id)}/items", body=item)
|
|
280
|
+
|
|
281
|
+
def remove_list_item(self, list_id, tender_no):
|
|
282
|
+
return self.request("DELETE", f"/api/v1/lists/{_quote(list_id)}/items", body={"tender_no": tender_no})
|
|
283
|
+
|
|
284
|
+
# Saved searches
|
|
285
|
+
def list_saved_searches(self):
|
|
286
|
+
return self.request("GET", "/api/v1/saved-searches")
|
|
287
|
+
|
|
288
|
+
def get_saved_search(self, search_id):
|
|
289
|
+
return self.request("GET", f"/api/v1/saved-searches/{_quote(search_id)}")
|
|
290
|
+
|
|
291
|
+
def create_saved_search(self, body):
|
|
292
|
+
return self.request("POST", "/api/v1/saved-searches", body=body)
|
|
293
|
+
|
|
294
|
+
def rename_saved_search(self, search_id, name):
|
|
295
|
+
return self.request("PATCH", f"/api/v1/saved-searches/{_quote(search_id)}", body={"name": name})
|
|
296
|
+
|
|
297
|
+
def delete_saved_search(self, search_id):
|
|
298
|
+
return self.request("DELETE", f"/api/v1/saved-searches/{_quote(search_id)}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# ── Format (table | json | csv) ──────────────────────────────────────────────
|
|
302
|
+
def parse_format(raw: str | None, json_flag: bool) -> str:
|
|
303
|
+
if json_flag:
|
|
304
|
+
return "json"
|
|
305
|
+
if raw is None:
|
|
306
|
+
return "table"
|
|
307
|
+
v = raw.strip().lower()
|
|
308
|
+
if v in ("json", "table", "csv"):
|
|
309
|
+
return v
|
|
310
|
+
raise ValueError(f'invalid --format "{raw}" (expected: table | json | csv)')
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def col(key, label=None, render=None):
|
|
314
|
+
return (key, label or key, render)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _js_string(v) -> str:
|
|
318
|
+
if isinstance(v, bool):
|
|
319
|
+
return "true" if v else "false"
|
|
320
|
+
if isinstance(v, float) and v.is_integer():
|
|
321
|
+
return str(int(v))
|
|
322
|
+
return str(v)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _cell(row: dict, column) -> str:
|
|
326
|
+
key, _, render = column
|
|
327
|
+
raw = row.get(key)
|
|
328
|
+
if render:
|
|
329
|
+
return render(raw)
|
|
330
|
+
if raw is None:
|
|
331
|
+
return ""
|
|
332
|
+
if isinstance(raw, (dict, list)):
|
|
333
|
+
return json.dumps(raw, separators=(",", ":"), ensure_ascii=False)
|
|
334
|
+
return _js_string(raw)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def num(value) -> str:
|
|
338
|
+
"""toLocaleString("en-US"): thousands separators, up to 3 decimals."""
|
|
339
|
+
if value is None or value == "":
|
|
340
|
+
return ""
|
|
341
|
+
try:
|
|
342
|
+
n = float(value)
|
|
343
|
+
except (TypeError, ValueError):
|
|
344
|
+
return str(value)
|
|
345
|
+
if not math.isfinite(n):
|
|
346
|
+
return str(value)
|
|
347
|
+
if n.is_integer():
|
|
348
|
+
return f"{int(n):,}"
|
|
349
|
+
return f"{n:,.3f}".rstrip("0").rstrip(".")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def truncate(value, max_len: int) -> str:
|
|
353
|
+
s = "" if value is None else _js_string(value)
|
|
354
|
+
return s[: max_len - 1] + "…" if len(s) > max_len else s
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def render_table(rows: list, columns: list) -> str:
|
|
358
|
+
if not rows:
|
|
359
|
+
return "(no rows)"
|
|
360
|
+
headers = [c[1] for c in columns]
|
|
361
|
+
matrix = [[_cell(r, c) for c in columns] for r in rows]
|
|
362
|
+
widths = [max([len(headers[i])] + [len(m[i]) for m in matrix]) for i in range(len(columns))]
|
|
363
|
+
lines = [" | ".join(h.ljust(widths[i]) for i, h in enumerate(headers)), "-+-".join("-" * w for w in widths)]
|
|
364
|
+
lines += [" | ".join(c.ljust(widths[i]) for i, c in enumerate(m)) for m in matrix]
|
|
365
|
+
return "\n".join(lines)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _csv_escape(value: str) -> str:
|
|
369
|
+
return '"' + value.replace('"', '""') + '"' if re.search(r'[",\r\n]', value) else value
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def render_csv(rows: list, columns: list) -> str:
|
|
373
|
+
header = ",".join(_csv_escape(c[1]) for c in columns)
|
|
374
|
+
body = "\n".join(",".join(_csv_escape(_cell(r, c)) for c in columns) for r in rows)
|
|
375
|
+
return f"{header}\n{body}\n" if rows else f"{header}\n"
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def render_tabular(fmt: str, rows: list, columns: list, raw) -> str:
|
|
379
|
+
if fmt == "json":
|
|
380
|
+
return dumps(raw)
|
|
381
|
+
if fmt == "csv":
|
|
382
|
+
return render_csv(rows, columns)
|
|
383
|
+
return render_table(rows, columns)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# ── Args ─────────────────────────────────────────────────────────────────────
|
|
387
|
+
BOOLEAN_FLAGS = {"help", "version", "json", "yes", "quiet"}
|
|
388
|
+
SHORT_VALUE_FLAGS = {"o": "output"}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def parse_args(argv: list) -> tuple:
|
|
392
|
+
"""Positionals + flags: `--flag value`, `--flag=value`, boolean `--flag`, `-o value`, `-h`, `--`."""
|
|
393
|
+
positionals, flags = [], {}
|
|
394
|
+
i = 0
|
|
395
|
+
while i < len(argv):
|
|
396
|
+
tok = argv[i]
|
|
397
|
+
nxt = argv[i + 1] if i + 1 < len(argv) else None
|
|
398
|
+
if tok == "--":
|
|
399
|
+
positionals += argv[i + 1 :]
|
|
400
|
+
break
|
|
401
|
+
if tok.startswith("--"):
|
|
402
|
+
if "=" in tok:
|
|
403
|
+
name, value = tok[2:].split("=", 1)
|
|
404
|
+
flags[name] = value
|
|
405
|
+
elif tok[2:] in BOOLEAN_FLAGS or nxt is None or nxt.startswith("-"):
|
|
406
|
+
flags[tok[2:]] = True
|
|
407
|
+
else:
|
|
408
|
+
flags[tok[2:]] = nxt
|
|
409
|
+
i += 1
|
|
410
|
+
elif tok.startswith("-") and len(tok) > 1:
|
|
411
|
+
short = tok[1:]
|
|
412
|
+
if short == "h":
|
|
413
|
+
flags["help"] = True
|
|
414
|
+
elif short == "v":
|
|
415
|
+
flags["version"] = True
|
|
416
|
+
elif short in SHORT_VALUE_FLAGS:
|
|
417
|
+
if nxt is not None and not nxt.startswith("-"):
|
|
418
|
+
flags[SHORT_VALUE_FLAGS[short]] = nxt
|
|
419
|
+
i += 1
|
|
420
|
+
else:
|
|
421
|
+
flags[SHORT_VALUE_FLAGS[short]] = True
|
|
422
|
+
else:
|
|
423
|
+
flags[short] = True
|
|
424
|
+
else:
|
|
425
|
+
positionals.append(tok)
|
|
426
|
+
i += 1
|
|
427
|
+
return positionals, flags
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def flag_str(flags: dict, *names):
|
|
431
|
+
for n in names:
|
|
432
|
+
if isinstance(flags.get(n), str):
|
|
433
|
+
return flags[n]
|
|
434
|
+
return None
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def flag_bool(flags: dict, *names) -> bool:
|
|
438
|
+
return any(flags.get(n) is True or flags.get(n) == "true" for n in names)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def flag_num(flags: dict, *names):
|
|
442
|
+
s = flag_str(flags, *names)
|
|
443
|
+
if s is None:
|
|
444
|
+
return None
|
|
445
|
+
try:
|
|
446
|
+
n = float(s.strip() or "0")
|
|
447
|
+
except ValueError:
|
|
448
|
+
return None
|
|
449
|
+
return n if math.isfinite(n) else None
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def flag_list(flags: dict, *names) -> list:
|
|
453
|
+
v = flag_str(flags, *names)
|
|
454
|
+
return [s.strip() for s in v.split(",") if s.strip()] if v else []
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# ── Shared command plumbing ──────────────────────────────────────────────────
|
|
458
|
+
def client_for(cfg: dict) -> ApiClient:
|
|
459
|
+
return ApiClient(cfg["apiUrl"], cfg["apiKey"], user_agent=f"{BIN}/{VERSION}")
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def require_credential(cfg: dict) -> None:
|
|
463
|
+
if not cfg["apiKey"]:
|
|
464
|
+
err(f"no API key configured. Run `{BIN} auth login`, set STS_API_KEY, or pass --api-key.")
|
|
465
|
+
raise Exit(EXIT_UNAUTHORIZED)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def explain_api_error(e: Exception):
|
|
469
|
+
"""Map an ApiError to a hint + exit code. Always raises Exit."""
|
|
470
|
+
if isinstance(e, ApiError):
|
|
471
|
+
if e.status == 401:
|
|
472
|
+
err(f"unauthorized — run `{BIN} auth login` to set a valid API key.")
|
|
473
|
+
raise Exit(EXIT_UNAUTHORIZED)
|
|
474
|
+
if e.status == 402:
|
|
475
|
+
body = e.body if isinstance(e.body, dict) else {}
|
|
476
|
+
err("payment required — no active subscription.")
|
|
477
|
+
if body.get("price"):
|
|
478
|
+
err(f"price: {body['price']}" + (f" {body['currency']}" if body.get("currency") else ""))
|
|
479
|
+
if body.get("payment_link"):
|
|
480
|
+
err(f"pay (human): {body['payment_link']}")
|
|
481
|
+
endpoint = (body.get("machine_payment") or {}).get("endpoint")
|
|
482
|
+
if endpoint:
|
|
483
|
+
err(f"pay (agent): POST {endpoint}")
|
|
484
|
+
err(f"or run `{BIN} pay` to activate.")
|
|
485
|
+
raise Exit(EXIT_PAYMENT)
|
|
486
|
+
if e.status == 429:
|
|
487
|
+
err("rate limited (429) — slow down and retry.")
|
|
488
|
+
raise Exit(EXIT_RATE_LIMIT)
|
|
489
|
+
err(f"API error {e.status}: {e}")
|
|
490
|
+
raise Exit(EXIT_ERROR)
|
|
491
|
+
err(str(e))
|
|
492
|
+
raise Exit(EXIT_ERROR)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def call(fn, *args):
|
|
496
|
+
try:
|
|
497
|
+
return fn(*args)
|
|
498
|
+
except (ApiError, RuntimeError) as e:
|
|
499
|
+
explain_api_error(e)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def fmt_of(flags: dict, default: str | None = None) -> str:
|
|
503
|
+
return parse_format(flag_str(flags, "format") or default, flag_bool(flags, "json"))
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def usage_error(group: str, form: str):
|
|
507
|
+
err(f"usage: {BIN} {group} {form}")
|
|
508
|
+
raise Exit(EXIT_USAGE)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def pick(obj: dict, keys: list) -> str:
|
|
512
|
+
for k in keys:
|
|
513
|
+
v = obj.get(k)
|
|
514
|
+
if v is not None and v != "":
|
|
515
|
+
return json.dumps(v, separators=(",", ":")) if isinstance(v, (dict, list)) else _js_string(v)
|
|
516
|
+
return ""
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
PARTNER_COLUMNS = [
|
|
520
|
+
col("name", "name", lambda v: truncate(v, 40)),
|
|
521
|
+
col("slug"),
|
|
522
|
+
col("total", "total", num),
|
|
523
|
+
col("count", "count", num),
|
|
524
|
+
]
|
|
525
|
+
|
|
526
|
+
AWARD_COLUMNS = [
|
|
527
|
+
col("tender_no"),
|
|
528
|
+
col("supplier", "supplier", lambda v: truncate(v, 30)),
|
|
529
|
+
col("agency", "agency", lambda v: truncate(v, 30)),
|
|
530
|
+
col("category", "category", lambda v: truncate(v, 20)),
|
|
531
|
+
col("awarded_amt", "awarded_amt", num),
|
|
532
|
+
col("published_at"),
|
|
533
|
+
col("awarded_at"),
|
|
534
|
+
]
|
|
535
|
+
|
|
536
|
+
LISTS_COLUMNS = [
|
|
537
|
+
col("id"),
|
|
538
|
+
col("name", "name", lambda v: truncate(v, 40)),
|
|
539
|
+
col("itemCount", "tenders", num),
|
|
540
|
+
col("buyerCount", "buyers", num),
|
|
541
|
+
col("supplierCount", "suppliers", num),
|
|
542
|
+
]
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# ── Commands ─────────────────────────────────────────────────────────────────
|
|
546
|
+
WHOAMI_USAGE = """selltostate whoami — show the authenticated subject, plan, scopes and export cap
|
|
547
|
+
|
|
548
|
+
Usage: selltostate whoami [--format table|json|csv]
|
|
549
|
+
|
|
550
|
+
Calls GET /api/v1/whoami. A valid-but-unentitled key returns active:false
|
|
551
|
+
(pay to unlock) rather than an error; a missing/invalid key is a 401."""
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def cmd_whoami(pos, flags, cfg):
|
|
555
|
+
require_credential(cfg)
|
|
556
|
+
fmt = fmt_of(flags)
|
|
557
|
+
me = call(client_for(cfg).whoami)
|
|
558
|
+
if not (flag_bool(flags, "json") or flag_str(flags, "format") is not None):
|
|
559
|
+
state = "active" if me.get("active") else f"inactive — run `{BIN} pay` to unlock"
|
|
560
|
+
out(
|
|
561
|
+
"\n".join(
|
|
562
|
+
[
|
|
563
|
+
f"subject: {me.get('subject')}",
|
|
564
|
+
f"type: {me.get('type')}",
|
|
565
|
+
f"plan: {me.get('plan') or '—'}",
|
|
566
|
+
f"status: {state}",
|
|
567
|
+
f"scopes: {', '.join(me.get('scopes') or []) or '—'}",
|
|
568
|
+
f"export cap: {_js_string(me.get('export_cap'))}",
|
|
569
|
+
]
|
|
570
|
+
)
|
|
571
|
+
)
|
|
572
|
+
return
|
|
573
|
+
row = {
|
|
574
|
+
"subject": me.get("subject"),
|
|
575
|
+
"type": me.get("type"),
|
|
576
|
+
"active": me.get("active"),
|
|
577
|
+
"plan": me.get("plan") or "",
|
|
578
|
+
"scopes": " ".join(me.get("scopes") or []),
|
|
579
|
+
"export_cap": me.get("export_cap"),
|
|
580
|
+
}
|
|
581
|
+
out(render_tabular(fmt, [row], [col(k) for k in row], me))
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
SEARCH_USAGE = """selltostate search <query> [options] — faceted search across tenders/awards
|
|
585
|
+
|
|
586
|
+
Usage: selltostate search <query> [options]
|
|
587
|
+
|
|
588
|
+
Options:
|
|
589
|
+
--scope <name> Collection: tenders (default) | awards | …
|
|
590
|
+
--sector <s> Sector slug (comma-separated for multiple)
|
|
591
|
+
--country <c> Country filter
|
|
592
|
+
--type <t> Procurement type
|
|
593
|
+
--status <s> Status filter
|
|
594
|
+
--year-min <n> Lower bound on year
|
|
595
|
+
--year-max <n> Upper bound on year
|
|
596
|
+
--amount-min <n> Lower bound on amount
|
|
597
|
+
--amount-max <n> Upper bound on amount
|
|
598
|
+
--page <n> Page number (default 1)
|
|
599
|
+
--per-page <n> Results per page (1–50, default 25)
|
|
600
|
+
--format <fmt> table (default) | json | csv
|
|
601
|
+
|
|
602
|
+
Calls GET /api/v1/search."""
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def cmd_search(pos, flags, cfg):
|
|
606
|
+
require_credential(cfg)
|
|
607
|
+
fmt = fmt_of(flags)
|
|
608
|
+
query = {
|
|
609
|
+
"q": pos[0] if pos else flag_str(flags, "q") or "",
|
|
610
|
+
"scope": flag_str(flags, "scope"),
|
|
611
|
+
"sector": flag_list(flags, "sector"),
|
|
612
|
+
"country": flag_str(flags, "country"),
|
|
613
|
+
"type": flag_str(flags, "type"),
|
|
614
|
+
"status": flag_str(flags, "status"),
|
|
615
|
+
"year_min": flag_num(flags, "year-min"),
|
|
616
|
+
"year_max": flag_num(flags, "year-max"),
|
|
617
|
+
"amount_min": flag_num(flags, "amount-min"),
|
|
618
|
+
"amount_max": flag_num(flags, "amount-max"),
|
|
619
|
+
"page": flag_num(flags, "page"),
|
|
620
|
+
"per_page": flag_num(flags, "per-page"),
|
|
621
|
+
}
|
|
622
|
+
res = call(client_for(cfg).search, query)
|
|
623
|
+
if fmt == "json":
|
|
624
|
+
return out(dumps(res))
|
|
625
|
+
body = res if isinstance(res, dict) else {}
|
|
626
|
+
hits = body.get("hits") if isinstance(body.get("hits"), list) else []
|
|
627
|
+
rows = [
|
|
628
|
+
{
|
|
629
|
+
"id": pick(h, ["tender_no", "id", "award_id", "slug"]),
|
|
630
|
+
"title": truncate(pick(h, ["title", "name", "description"]), 60),
|
|
631
|
+
"buyer": pick(h, ["buyer", "agency", "buyer_name"]),
|
|
632
|
+
"supplier": pick(h, ["supplier", "supplier_name"]),
|
|
633
|
+
"amount": num(pick(h, ["amount", "awarded_amt", "value"])),
|
|
634
|
+
"year": pick(h, ["year"]),
|
|
635
|
+
}
|
|
636
|
+
for h in hits
|
|
637
|
+
]
|
|
638
|
+
out(render_tabular(fmt, rows, [col(k) for k in ("id", "title", "buyer", "supplier", "amount", "year")], res))
|
|
639
|
+
if fmt != "csv":
|
|
640
|
+
found = body.get("found", len(hits))
|
|
641
|
+
out(f"\nfound {_js_string(found)}, page {_js_string(body.get('page') or 1)} × {_js_string(body.get('per_page') or len(hits))}")
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
AWARDS_USAGE = """selltostate awards [options] — list awarded tenders (the explorer dataset)
|
|
645
|
+
|
|
646
|
+
Usage: selltostate awards [options]
|
|
647
|
+
|
|
648
|
+
Options:
|
|
649
|
+
--q <text> Free-text filter
|
|
650
|
+
--agency <name> Buyer / agency filter
|
|
651
|
+
--supplier <name> Supplier filter
|
|
652
|
+
--category <name> Category filter
|
|
653
|
+
--country <slug> Country slug (default singapore)
|
|
654
|
+
--year <n> Award year
|
|
655
|
+
--amount-min <n> Minimum awarded amount
|
|
656
|
+
--amount-max <n> Maximum awarded amount
|
|
657
|
+
--page <n> Page number (default 1)
|
|
658
|
+
--per-page <n> Rows per page (server-capped by your plan)
|
|
659
|
+
--format <fmt> table (default) | json | csv
|
|
660
|
+
|
|
661
|
+
Calls GET /api/v1/awards. For a bulk CSV export use `selltostate export`."""
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def awards_query(flags: dict) -> dict:
|
|
665
|
+
return {
|
|
666
|
+
"q": flag_str(flags, "q"),
|
|
667
|
+
"agency": flag_str(flags, "agency"),
|
|
668
|
+
"supplier": flag_str(flags, "supplier"),
|
|
669
|
+
"category": flag_str(flags, "category"),
|
|
670
|
+
"country": flag_str(flags, "country"),
|
|
671
|
+
"year": flag_num(flags, "year"),
|
|
672
|
+
"amount_min": flag_num(flags, "amount-min"),
|
|
673
|
+
"amount_max": flag_num(flags, "amount-max"),
|
|
674
|
+
"page": flag_num(flags, "page"),
|
|
675
|
+
"page_size": flag_num(flags, "per-page"),
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def cmd_awards(pos, flags, cfg):
|
|
680
|
+
require_credential(cfg)
|
|
681
|
+
fmt = fmt_of(flags)
|
|
682
|
+
res = call(client_for(cfg).awards, awards_query(flags))
|
|
683
|
+
if fmt == "json":
|
|
684
|
+
return out(dumps(res))
|
|
685
|
+
body = res if isinstance(res, dict) else {}
|
|
686
|
+
rows = body.get("rows") if isinstance(body.get("rows"), list) else []
|
|
687
|
+
out(render_tabular(fmt, rows, AWARD_COLUMNS, res))
|
|
688
|
+
if fmt != "csv":
|
|
689
|
+
total = body.get("total", len(rows))
|
|
690
|
+
cap = body.get("export_cap")
|
|
691
|
+
out(f"\n{len(rows)} rows (page {_js_string(body.get('page') or 1)}, {_js_string(total)} total, export cap {'—' if cap is None else _js_string(cap)})")
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
GET_USAGE = """selltostate get <kind> <slug-or-id> [options] — fetch one entity in detail
|
|
695
|
+
|
|
696
|
+
Usage: selltostate get buyer|supplier|tender|sector <slug-or-id> [options]
|
|
697
|
+
|
|
698
|
+
Options:
|
|
699
|
+
--page <n> Partner-table page (buyer/supplier/sector)
|
|
700
|
+
--kind <buyers|suppliers> Sector partner table to return (sector only)
|
|
701
|
+
--format <fmt> json (default) | table | csv (table/csv = partner table)
|
|
702
|
+
|
|
703
|
+
Calls GET /api/v1/{buyers|suppliers|tenders|sectors}/<id>. A missing entity is a 404."""
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def cmd_get(pos, flags, cfg):
|
|
707
|
+
require_credential(cfg)
|
|
708
|
+
kind = pos[0] if pos else None
|
|
709
|
+
if kind not in ("buyer", "supplier", "tender", "sector"):
|
|
710
|
+
err(f"unknown entity kind: {kind or '(none)'}. Expected: buyer | supplier | tender | sector")
|
|
711
|
+
raise Exit(EXIT_USAGE)
|
|
712
|
+
if len(pos) < 2:
|
|
713
|
+
err(f"missing <slug-or-id>. Usage: selltostate get {kind} <slug-or-id>")
|
|
714
|
+
raise Exit(EXIT_USAGE)
|
|
715
|
+
ident = pos[1]
|
|
716
|
+
fmt = fmt_of(flags, "json")
|
|
717
|
+
page = flag_num(flags, "page")
|
|
718
|
+
client = client_for(cfg)
|
|
719
|
+
if kind == "buyer":
|
|
720
|
+
res, partner_key = call(client.get_buyer, ident, {"sup_page": page} if page else None), "suppliers"
|
|
721
|
+
elif kind == "supplier":
|
|
722
|
+
res, partner_key = call(client.get_supplier, ident, {"buyer_page": page} if page else None), "buyers"
|
|
723
|
+
elif kind == "sector":
|
|
724
|
+
sector_kind = "suppliers" if flag_str(flags, "kind") == "suppliers" else "buyers"
|
|
725
|
+
res, partner_key = call(client.get_sector, ident, {"kind": sector_kind, "page": page}), "partners"
|
|
726
|
+
else:
|
|
727
|
+
res, partner_key = call(client.get_tender, ident), ""
|
|
728
|
+
if fmt == "json":
|
|
729
|
+
return out(dumps(res))
|
|
730
|
+
if not partner_key:
|
|
731
|
+
err("note: tender detail has no flat partner table; emitting JSON.")
|
|
732
|
+
return out(dumps(res))
|
|
733
|
+
block = res.get(partner_key) if isinstance(res, dict) else None
|
|
734
|
+
rows = block.get("rows") if isinstance(block, dict) and isinstance(block.get("rows"), list) else []
|
|
735
|
+
out(render_tabular(fmt, rows, PARTNER_COLUMNS, res))
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
LISTS_USAGE = """selltostate lists <subcommand> — manage saved lists
|
|
739
|
+
|
|
740
|
+
Subcommands:
|
|
741
|
+
ls List every list you own (tender, buyer and supplier counts)
|
|
742
|
+
create <name> Create a new list
|
|
743
|
+
show <id> Show a list + its items (--page, --per-page)
|
|
744
|
+
add <id> <tender_no> Add an award (or --award-id <id>) to a list
|
|
745
|
+
rm <id> <tender_no> Remove an item from a list
|
|
746
|
+
rename <id> <name> Rename a list
|
|
747
|
+
delete <id> Delete a list
|
|
748
|
+
|
|
749
|
+
Options: --format table|json|csv
|
|
750
|
+
|
|
751
|
+
Calls /api/v1/lists*. Requires a dashboard API key (x-api-key)."""
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def format_lists(res, fmt: str) -> str:
|
|
755
|
+
if fmt == "json":
|
|
756
|
+
return dumps(res)
|
|
757
|
+
lists = (res.get("lists") or []) if isinstance(res, dict) else []
|
|
758
|
+
return render_tabular(fmt, lists, LISTS_COLUMNS, res)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def cmd_lists(pos, flags, cfg):
|
|
762
|
+
require_credential(cfg)
|
|
763
|
+
sub, rest = (pos[0] if pos else "ls"), pos[1:]
|
|
764
|
+
fmt = fmt_of(flags)
|
|
765
|
+
client = client_for(cfg)
|
|
766
|
+
if sub == "ls":
|
|
767
|
+
return out(format_lists(call(client.list_lists), fmt))
|
|
768
|
+
if sub == "create":
|
|
769
|
+
name = " ".join(rest).strip()
|
|
770
|
+
if not name:
|
|
771
|
+
usage_error("lists", "create <name>")
|
|
772
|
+
return out(dumps(call(client.create_list, name)))
|
|
773
|
+
if sub == "show":
|
|
774
|
+
if not rest:
|
|
775
|
+
usage_error("lists", "show <id>")
|
|
776
|
+
res = call(client.get_list, rest[0], {"page": flag_num(flags, "page"), "page_size": flag_num(flags, "per-page")})
|
|
777
|
+
if fmt == "json":
|
|
778
|
+
return out(dumps(res))
|
|
779
|
+
body = res if isinstance(res, dict) else {}
|
|
780
|
+
items = body.get("items") if isinstance(body.get("items"), list) else []
|
|
781
|
+
columns = [
|
|
782
|
+
col("tender_no"),
|
|
783
|
+
col("supplier", "supplier", lambda v: truncate(v, 30)),
|
|
784
|
+
col("agency", "agency", lambda v: truncate(v, 30)),
|
|
785
|
+
col("awarded_amt", "awarded_amt", num),
|
|
786
|
+
]
|
|
787
|
+
table = render_tabular(fmt, items, columns, res)
|
|
788
|
+
if fmt == "csv":
|
|
789
|
+
return out(table)
|
|
790
|
+
out(f"# {(body.get('list') or {}).get('name') or '(list)'}")
|
|
791
|
+
out(table)
|
|
792
|
+
return out(f"\n{len(items)} items (page {_js_string(body.get('page') or 1)}, {_js_string(body.get('total', len(items)))} total)")
|
|
793
|
+
if sub == "add":
|
|
794
|
+
if not rest:
|
|
795
|
+
usage_error("lists", "add <id> <tender_no|--award-id ID>")
|
|
796
|
+
award_id = flag_str(flags, "award-id")
|
|
797
|
+
tender_no = rest[1] if len(rest) > 1 else None
|
|
798
|
+
if not tender_no and not award_id:
|
|
799
|
+
usage_error("lists", "add <id> <tender_no> (or --award-id ID)")
|
|
800
|
+
item = {"tender_no": tender_no} if tender_no else {"award_id": award_id}
|
|
801
|
+
return out(dumps(call(client.add_list_item, rest[0], item)))
|
|
802
|
+
if sub == "rm":
|
|
803
|
+
if len(rest) < 2:
|
|
804
|
+
usage_error("lists", "rm <id> <tender_no>")
|
|
805
|
+
return out(dumps(call(client.remove_list_item, rest[0], rest[1])))
|
|
806
|
+
if sub == "rename":
|
|
807
|
+
name = " ".join(rest[1:]).strip()
|
|
808
|
+
if not rest or not name:
|
|
809
|
+
usage_error("lists", "rename <id> <name>")
|
|
810
|
+
return out(dumps(call(client.rename_list, rest[0], name)))
|
|
811
|
+
if sub == "delete":
|
|
812
|
+
if not rest:
|
|
813
|
+
usage_error("lists", "delete <id>")
|
|
814
|
+
return out(dumps(call(client.delete_list, rest[0])))
|
|
815
|
+
err(f"unknown lists subcommand: {sub}. Try: ls | create | show | add | rm | rename | delete")
|
|
816
|
+
raise Exit(EXIT_USAGE)
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
SAVED_SEARCHES_USAGE = """selltostate saved-searches <subcommand> — manage saved searches
|
|
820
|
+
|
|
821
|
+
Subcommands:
|
|
822
|
+
ls List every saved search you can see
|
|
823
|
+
save <name> [--query JSON] [--kind atlas] Save a search
|
|
824
|
+
show <id> Show one saved search
|
|
825
|
+
rename <id> <name> Rename a saved search
|
|
826
|
+
delete <id> Delete a saved search
|
|
827
|
+
|
|
828
|
+
Options: --format table|json|csv
|
|
829
|
+
|
|
830
|
+
Calls /api/v1/saved-searches*. Requires a dashboard API key (x-api-key)."""
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def cmd_saved_searches(pos, flags, cfg):
|
|
834
|
+
require_credential(cfg)
|
|
835
|
+
sub, rest = (pos[0] if pos else "ls"), pos[1:]
|
|
836
|
+
fmt = fmt_of(flags)
|
|
837
|
+
client = client_for(cfg)
|
|
838
|
+
if sub == "ls":
|
|
839
|
+
res = call(client.list_saved_searches)
|
|
840
|
+
if fmt == "json":
|
|
841
|
+
return out(dumps(res))
|
|
842
|
+
searches = (res.get("searches") or []) if isinstance(res, dict) else []
|
|
843
|
+
return out(render_tabular(fmt, searches, [col("id"), col("name", "name", lambda v: truncate(v, 50))], res))
|
|
844
|
+
if sub == "save":
|
|
845
|
+
name = " ".join(rest).strip()
|
|
846
|
+
if not name:
|
|
847
|
+
usage_error("saved-searches", "save <name> [--query JSON] [--kind atlas]")
|
|
848
|
+
query_raw = flag_str(flags, "query")
|
|
849
|
+
query = {}
|
|
850
|
+
if query_raw:
|
|
851
|
+
try:
|
|
852
|
+
query = json.loads(query_raw)
|
|
853
|
+
except ValueError:
|
|
854
|
+
err(f"--query must be valid JSON (got: {query_raw})")
|
|
855
|
+
raise Exit(EXIT_USAGE)
|
|
856
|
+
body = {"name": name, "query": query}
|
|
857
|
+
if flag_str(flags, "kind"):
|
|
858
|
+
body["kind"] = flag_str(flags, "kind")
|
|
859
|
+
return out(dumps(call(client.create_saved_search, body)))
|
|
860
|
+
if sub == "show":
|
|
861
|
+
if not rest:
|
|
862
|
+
usage_error("saved-searches", "show <id>")
|
|
863
|
+
return out(dumps(call(client.get_saved_search, rest[0])))
|
|
864
|
+
if sub == "rename":
|
|
865
|
+
name = " ".join(rest[1:]).strip()
|
|
866
|
+
if not rest or not name:
|
|
867
|
+
usage_error("saved-searches", "rename <id> <name>")
|
|
868
|
+
return out(dumps(call(client.rename_saved_search, rest[0], name)))
|
|
869
|
+
if sub == "delete":
|
|
870
|
+
if not rest:
|
|
871
|
+
usage_error("saved-searches", "delete <id>")
|
|
872
|
+
return out(dumps(call(client.delete_saved_search, rest[0])))
|
|
873
|
+
err(f"unknown saved-searches subcommand: {sub}. Try: ls | save | show | rename | delete")
|
|
874
|
+
raise Exit(EXIT_USAGE)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
EXPORT_USAGE = """selltostate export [filters] [-o file.csv] — export awards as CSV
|
|
878
|
+
|
|
879
|
+
Usage: selltostate export [options] [-o|--output FILE]
|
|
880
|
+
|
|
881
|
+
Filters (same as `awards`):
|
|
882
|
+
--q <text> Free-text filter
|
|
883
|
+
--agency <name> Buyer / agency filter
|
|
884
|
+
--supplier <name> Supplier filter
|
|
885
|
+
--category <name> Category filter
|
|
886
|
+
--country <slug> Country slug (default singapore)
|
|
887
|
+
--year <n> Award year
|
|
888
|
+
--amount-min <n> Minimum awarded amount
|
|
889
|
+
--amount-max <n> Maximum awarded amount
|
|
890
|
+
|
|
891
|
+
Output:
|
|
892
|
+
-o, --output <file> Write CSV to <file> (default: stdout)
|
|
893
|
+
|
|
894
|
+
Calls GET /api/v1/awards.csv. Row count is capped by your plan (paid 5000 / trial 20)."""
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def cmd_export(pos, flags, cfg):
|
|
898
|
+
require_credential(cfg)
|
|
899
|
+
output = flag_str(flags, "output", "o")
|
|
900
|
+
csv_text = call(client_for(cfg).awards_csv, awards_query(flags))
|
|
901
|
+
if not output:
|
|
902
|
+
sys.stdout.write(csv_text)
|
|
903
|
+
return
|
|
904
|
+
try:
|
|
905
|
+
with open(output, "w", encoding="utf-8", newline="") as f:
|
|
906
|
+
f.write(csv_text)
|
|
907
|
+
except OSError as e:
|
|
908
|
+
err(f"could not write {output}: {e}")
|
|
909
|
+
raise Exit(EXIT_ERROR)
|
|
910
|
+
trimmed = csv_text.rstrip("\n")
|
|
911
|
+
err(f"wrote {max(0, len(trimmed.split(chr(10))) - 1) if trimmed else 0} rows to {output}")
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _reference(method: str, columns=None):
|
|
915
|
+
def handler(pos, flags, cfg):
|
|
916
|
+
require_credential(cfg)
|
|
917
|
+
fmt = fmt_of(flags)
|
|
918
|
+
limit = flag_num(flags, "limit")
|
|
919
|
+
fn = getattr(client_for(cfg), method)
|
|
920
|
+
res = call(fn) if method == "sectors" else call(fn, {"limit": limit} if limit else None)
|
|
921
|
+
if fmt == "json":
|
|
922
|
+
return out(dumps(res))
|
|
923
|
+
rows = (res.get("rows") or []) if isinstance(res, dict) else []
|
|
924
|
+
out(render_tabular(fmt, rows, columns or PARTNER_COLUMNS, res))
|
|
925
|
+
|
|
926
|
+
return handler
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def cmd_price(pos, flags, cfg):
|
|
930
|
+
# price is public: no credential check.
|
|
931
|
+
out(dumps(call(client_for(cfg).price)))
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def cmd_pay(pos, flags, cfg):
|
|
935
|
+
require_credential(cfg)
|
|
936
|
+
body = {}
|
|
937
|
+
token = flag_str(flags, "token", "payment-method-token")
|
|
938
|
+
if token:
|
|
939
|
+
body["payment_method_token"] = token
|
|
940
|
+
if flag_str(flags, "customer"):
|
|
941
|
+
body["customer"] = flag_str(flags, "customer")
|
|
942
|
+
out(dumps(call(client_for(cfg).pay, body)))
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
# ── auth ─────────────────────────────────────────────────────────────────────
|
|
946
|
+
AUTH_USAGE = """selltostate auth <subcommand> — manage credentials
|
|
947
|
+
|
|
948
|
+
Subcommands:
|
|
949
|
+
login [--api-key KEY] [--api-url URL] Save a key to {path}
|
|
950
|
+
status Show the resolved credential + source
|
|
951
|
+
logout Remove the stored API key"""
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
def cmd_auth(pos, flags, cfg):
|
|
955
|
+
sub = pos[0] if pos else None
|
|
956
|
+
if sub == "login":
|
|
957
|
+
api_key = flag_str(flags, "api-key")
|
|
958
|
+
api_url = flag_str(flags, "api-url")
|
|
959
|
+
if not api_key:
|
|
960
|
+
out(f"Get a dashboard API key at {cfg['apiUrl']}/dashboard (Settings → API keys).")
|
|
961
|
+
api_key = getpass.getpass("API key: ").strip()
|
|
962
|
+
if not api_key:
|
|
963
|
+
err("no API key entered.")
|
|
964
|
+
raise Exit(EXIT_UNAUTHORIZED)
|
|
965
|
+
stored = write_stored_config({"apiKey": api_key, "apiUrl": api_url if api_url and api_url != DEFAULT_API_URL else None})
|
|
966
|
+
out(f"Saved API key to {config_path()}")
|
|
967
|
+
try:
|
|
968
|
+
me = client_for(resolve_config(stored["apiKey"], api_url)).whoami()
|
|
969
|
+
except ApiError as e:
|
|
970
|
+
if e.status == 401:
|
|
971
|
+
err("warning: the key was saved but /whoami rejected it (401). Double-check the key.")
|
|
972
|
+
else:
|
|
973
|
+
err(f"warning: saved the key but could not verify it: {e}")
|
|
974
|
+
return
|
|
975
|
+
except RuntimeError as e:
|
|
976
|
+
err(f"warning: saved the key but could not verify it: {e}")
|
|
977
|
+
return
|
|
978
|
+
if me.get("active"):
|
|
979
|
+
out(f"Authenticated as {me.get('subject')} (plan: {me.get('plan') or '—'}, active).")
|
|
980
|
+
else:
|
|
981
|
+
out(f"Authenticated as {me.get('subject')} (inactive — run `{BIN} pay` to unlock data).")
|
|
982
|
+
return
|
|
983
|
+
if sub == "status":
|
|
984
|
+
key = cfg["apiKey"]
|
|
985
|
+
out(
|
|
986
|
+
{
|
|
987
|
+
"api_url": cfg["apiUrl"],
|
|
988
|
+
"api_url_source": cfg["source"]["apiUrl"],
|
|
989
|
+
"api_key": f"{key[:4]}…{key[-4:]}" if key else "(none)",
|
|
990
|
+
"api_key_source": cfg["source"]["apiKey"],
|
|
991
|
+
"config_file": config_path(),
|
|
992
|
+
}
|
|
993
|
+
)
|
|
994
|
+
return
|
|
995
|
+
if sub == "logout":
|
|
996
|
+
write_stored_config({"apiKey": None})
|
|
997
|
+
out(f"Removed the stored API key from {config_path()}.")
|
|
998
|
+
return
|
|
999
|
+
err(f"unknown auth subcommand: {sub or '(none)'}. Try: login | status | logout")
|
|
1000
|
+
raise Exit(EXIT_USAGE)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
# ── Registry + entry point ───────────────────────────────────────────────────
|
|
1004
|
+
COMMANDS = {
|
|
1005
|
+
"auth": ("Manage credentials (login | status | logout)", cmd_auth, AUTH_USAGE),
|
|
1006
|
+
"whoami": ("Show the authenticated subject, plan, scopes and export cap", cmd_whoami, WHOAMI_USAGE),
|
|
1007
|
+
"search": ("Faceted search across tenders/awards (GET /api/v1/search)", cmd_search, SEARCH_USAGE),
|
|
1008
|
+
"awards": ("List awarded tenders (GET /api/v1/awards)", cmd_awards, AWARDS_USAGE),
|
|
1009
|
+
"get": ("Fetch one buyer|supplier|tender|sector in detail (R1)", cmd_get, GET_USAGE),
|
|
1010
|
+
"sectors": ("List the curated sector taxonomy (GET /api/v1/sectors)", _reference("sectors", [col("slug"), col("name")]), None),
|
|
1011
|
+
"suppliers": ("Top suppliers by awarded total (GET /api/v1/suppliers)", _reference("suppliers"), None),
|
|
1012
|
+
"buyers": ("Top buyers by spend (GET /api/v1/buyers)", _reference("buyers"), None),
|
|
1013
|
+
"lists": ("Manage saved lists (ls|create|show|add|rm|rename|delete)", cmd_lists, LISTS_USAGE),
|
|
1014
|
+
"saved-searches": ("Manage saved searches (ls|save|show|rename|delete)", cmd_saved_searches, SAVED_SEARCHES_USAGE),
|
|
1015
|
+
"export": ("Export awards as CSV to stdout or -o file (GET /api/v1/awards.csv)", cmd_export, EXPORT_USAGE),
|
|
1016
|
+
"price": ("Show the live subscription price (GET /api/v1/price)", cmd_price, None),
|
|
1017
|
+
"pay": ("Activate a subscription autonomously (POST /api/v1/pay)", cmd_pay, None),
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
def help_text() -> str:
|
|
1022
|
+
width = max(len(k) for k in COMMANDS)
|
|
1023
|
+
lines = [
|
|
1024
|
+
f"{BIN} v{VERSION} — CLI for the Sell to State government-contracts data API",
|
|
1025
|
+
"",
|
|
1026
|
+
f"Usage: {BIN} <command> [subcommand] [options]",
|
|
1027
|
+
"",
|
|
1028
|
+
"Commands:",
|
|
1029
|
+
]
|
|
1030
|
+
lines += [f" {name.ljust(width)} {summary}" for name, (summary, _, _) in COMMANDS.items()]
|
|
1031
|
+
lines += [
|
|
1032
|
+
"",
|
|
1033
|
+
"Global options:",
|
|
1034
|
+
" --api-key <key> API key (else $STS_API_KEY, else stored config)",
|
|
1035
|
+
f" --api-url <url> API base URL (default $STS_API_URL or {DEFAULT_API_URL})",
|
|
1036
|
+
" --format <fmt> Output format: table | json | csv (where supported)",
|
|
1037
|
+
" --json Shorthand for --format json",
|
|
1038
|
+
f" -h, --help Show help (per-command: `{BIN} <command> --help`)",
|
|
1039
|
+
" -v, --version Show version",
|
|
1040
|
+
"",
|
|
1041
|
+
"Auth:",
|
|
1042
|
+
f" {BIN} auth login Save an API key to {config_path()}",
|
|
1043
|
+
f" {BIN} auth status Show the resolved credential + its source",
|
|
1044
|
+
"",
|
|
1045
|
+
"Environment: STS_API_KEY, STS_API_URL",
|
|
1046
|
+
]
|
|
1047
|
+
return "\n".join(lines)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def run(argv: list) -> int:
|
|
1051
|
+
pos, flags = parse_args(argv)
|
|
1052
|
+
if flags.get("version"):
|
|
1053
|
+
out(VERSION)
|
|
1054
|
+
return 0
|
|
1055
|
+
if not pos:
|
|
1056
|
+
out(help_text())
|
|
1057
|
+
return 0
|
|
1058
|
+
entry = COMMANDS.get(pos[0])
|
|
1059
|
+
if not entry:
|
|
1060
|
+
err(f"unknown command: {pos[0]}")
|
|
1061
|
+
out(help_text())
|
|
1062
|
+
return EXIT_USAGE
|
|
1063
|
+
_, handler, usage = entry
|
|
1064
|
+
if flags.get("help"):
|
|
1065
|
+
out(usage.replace("{path}", config_path()) if usage else help_text())
|
|
1066
|
+
return 0
|
|
1067
|
+
cfg = resolve_config(flag_str(flags, "api-key"), flag_str(flags, "api-url"))
|
|
1068
|
+
try:
|
|
1069
|
+
handler(pos[1:], flags, cfg)
|
|
1070
|
+
except Exit as e:
|
|
1071
|
+
return e.code
|
|
1072
|
+
except (ValueError, RuntimeError) as e:
|
|
1073
|
+
err(str(e))
|
|
1074
|
+
return EXIT_ERROR
|
|
1075
|
+
return 0
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def main() -> None:
|
|
1079
|
+
try:
|
|
1080
|
+
sys.exit(run(sys.argv[1:]))
|
|
1081
|
+
except KeyboardInterrupt:
|
|
1082
|
+
sys.exit(130)
|
|
1083
|
+
except BrokenPipeError:
|
|
1084
|
+
sys.exit(0)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
if __name__ == "__main__":
|
|
1088
|
+
main()
|