akta-pro-cli 0.3.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.
- akta_pro_cli/__init__.py +8 -0
- akta_pro_cli/__main__.py +25 -0
- akta_pro_cli/app.py +94 -0
- akta_pro_cli/client.py +102 -0
- akta_pro_cli/commands/__init__.py +1 -0
- akta_pro_cli/commands/account.py +35 -0
- akta_pro_cli/commands/alternative.py +109 -0
- akta_pro_cli/commands/auth.py +123 -0
- akta_pro_cli/commands/company.py +184 -0
- akta_pro_cli/commands/config.py +54 -0
- akta_pro_cli/commands/industry.py +46 -0
- akta_pro_cli/commands/news.py +232 -0
- akta_pro_cli/commands/update.py +64 -0
- akta_pro_cli/config.py +71 -0
- akta_pro_cli/console.py +10 -0
- akta_pro_cli/news_tags.py +102 -0
- akta_pro_cli/options.py +21 -0
- akta_pro_cli/runtime.py +157 -0
- akta_pro_cli/update.py +79 -0
- akta_pro_cli-0.3.0.dist-info/METADATA +134 -0
- akta_pro_cli-0.3.0.dist-info/RECORD +25 -0
- akta_pro_cli-0.3.0.dist-info/WHEEL +5 -0
- akta_pro_cli-0.3.0.dist-info/entry_points.txt +2 -0
- akta_pro_cli-0.3.0.dist-info/licenses/LICENSE +21 -0
- akta_pro_cli-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""`akta-pro config` — view and change stored settings without re-entering the key."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from akta_pro_cli.client import DEFAULT_BASE_URL
|
|
10
|
+
from akta_pro_cli.config import credentials_path, load_credentials, save_credentials
|
|
11
|
+
from akta_pro_cli.console import err, out
|
|
12
|
+
from akta_pro_cli.runtime import EXIT_BAD_INPUT
|
|
13
|
+
|
|
14
|
+
config_app = typer.Typer(no_args_is_help=True, help="View or change stored settings (offline).")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _mask(key: str | None) -> str:
|
|
18
|
+
if not key:
|
|
19
|
+
return "(not set)"
|
|
20
|
+
return f"{key[:5]}…{key[-4:]}" if len(key) > 12 else "…"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@config_app.command("show")
|
|
24
|
+
def show() -> None:
|
|
25
|
+
"""Show the stored credentials/config (offline; API key masked)."""
|
|
26
|
+
creds = load_credentials()
|
|
27
|
+
base = creds.get("base_url")
|
|
28
|
+
out.print(f"config file : {credentials_path()}")
|
|
29
|
+
out.print(f"api key : {_mask(creds.get('api_key'))}")
|
|
30
|
+
out.print(f"base url : {base or f'(not set → default {DEFAULT_BASE_URL})'}")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@config_app.command("base-url")
|
|
34
|
+
def base_url(
|
|
35
|
+
url: Annotated[str | None, typer.Argument(help="New base URL. Omit and pass --reset to restore the default.")] = None,
|
|
36
|
+
reset: Annotated[bool, typer.Option("--reset", help="Reset to the default (api.akta.pro).")] = False,
|
|
37
|
+
) -> None:
|
|
38
|
+
"""Change or reset the stored base URL, keeping the stored API key."""
|
|
39
|
+
creds = load_credentials()
|
|
40
|
+
if reset:
|
|
41
|
+
creds.pop("base_url", None)
|
|
42
|
+
save_credentials(creds)
|
|
43
|
+
out.print(f"[green]✓[/] base url reset → default ({DEFAULT_BASE_URL})")
|
|
44
|
+
return
|
|
45
|
+
if not url:
|
|
46
|
+
err.print("[red]Provide a URL, or pass --reset.[/]")
|
|
47
|
+
raise typer.Exit(code=EXIT_BAD_INPUT)
|
|
48
|
+
creds["base_url"] = url
|
|
49
|
+
save_credentials(creds)
|
|
50
|
+
out.print(f"[green]✓[/] base url set → {url}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def register(app: typer.Typer) -> None:
|
|
54
|
+
app.add_typer(config_app, name="config")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""`akta-pro industry` — resolve free-text industries to akta.pro codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from akta_pro_cli.options import JsonOpt, OutOpt
|
|
11
|
+
from akta_pro_cli.runtime import emit, fetch
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(no_args_is_help=True, help="Industry search.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _industry_table(result: object) -> Table | None:
|
|
17
|
+
rows = result.get("data") if isinstance(result, dict) else None
|
|
18
|
+
if not rows:
|
|
19
|
+
return None
|
|
20
|
+
table = Table(title="Industry matches")
|
|
21
|
+
table.add_column("Code")
|
|
22
|
+
table.add_column("Industry", overflow="fold")
|
|
23
|
+
table.add_column("Similarity", justify="right")
|
|
24
|
+
for row in rows:
|
|
25
|
+
sim = row.get("similarity")
|
|
26
|
+
table.add_row(
|
|
27
|
+
str(row.get("code", "")),
|
|
28
|
+
str(row.get("industry_name", "")),
|
|
29
|
+
f"{sim:.3f}" if isinstance(sim, (int, float)) else str(sim or ""),
|
|
30
|
+
)
|
|
31
|
+
return table
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("search")
|
|
35
|
+
def search(
|
|
36
|
+
ctx: typer.Context,
|
|
37
|
+
query: Annotated[str, typer.Argument(help="Free-text industry or topic, e.g. 'warehouse automation'.")],
|
|
38
|
+
json_out: JsonOpt = False,
|
|
39
|
+
output: OutOpt = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Resolve a free-text industry to akta.pro industry codes (free).
|
|
42
|
+
|
|
43
|
+
Use the returned `code` values as `--industry` in `akta-pro news`.
|
|
44
|
+
"""
|
|
45
|
+
result = fetch(ctx.obj, "/industry/search", {"query": query})
|
|
46
|
+
emit(ctx.obj, result, json_out=json_out, output=output, renderer=_industry_table)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""`akta-pro news` — signals (list), detail (full bodies), and the type taxonomy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Group
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from akta_pro_cli.console import err
|
|
15
|
+
from akta_pro_cli.news_tags import NEWS_CATEGORIES, NEWS_TAGS
|
|
16
|
+
from akta_pro_cli.options import JsonOpt, OutOpt
|
|
17
|
+
from akta_pro_cli.runtime import EXIT_BAD_INPUT, emit, fetch
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(no_args_is_help=True, help="News signals, article detail, and the type taxonomy.")
|
|
20
|
+
|
|
21
|
+
# Compact list fields (mirrors the MCP's news_signals shaping); `id` feeds `detail`.
|
|
22
|
+
_STRIPPED_FIELDS = ("id", "title", "ai_summary", "url", "published_date", "publisher")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Sentiment(str, Enum):
|
|
26
|
+
positive = "positive"
|
|
27
|
+
negative = "negative"
|
|
28
|
+
neutral = "neutral"
|
|
29
|
+
all = "all"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NewsScore(str, Enum):
|
|
33
|
+
high = "High"
|
|
34
|
+
medium = "Medium"
|
|
35
|
+
low = "Low"
|
|
36
|
+
all = "all"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _shape(article: dict, *, full: bool) -> dict:
|
|
40
|
+
if full:
|
|
41
|
+
return {k: v for k, v in article.items() if k != "full_text"}
|
|
42
|
+
return {k: article.get(k) for k in _STRIPPED_FIELDS}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _csv(values: list[str] | None) -> str | None:
|
|
46
|
+
"""Join repeatable-option values into the comma-separated string the API
|
|
47
|
+
expects (or None when nothing was passed)."""
|
|
48
|
+
if not values:
|
|
49
|
+
return None
|
|
50
|
+
return ",".join(v.strip() for v in values if v.strip()) or None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _signals_table(result: object) -> Table | None:
|
|
54
|
+
rows = result.get("data") if isinstance(result, dict) else None
|
|
55
|
+
if not rows:
|
|
56
|
+
return None
|
|
57
|
+
table = Table(title=f"News ({result.get('count', '?')} of {result.get('total', '?')})")
|
|
58
|
+
for col in ("Id", "Date", "Publisher", "Title", "AI summary", "URL"):
|
|
59
|
+
table.add_column(col, overflow="fold")
|
|
60
|
+
for row in rows:
|
|
61
|
+
table.add_row(
|
|
62
|
+
str(row.get("id", "") or ""),
|
|
63
|
+
str(row.get("published_date", "") or ""),
|
|
64
|
+
str(row.get("publisher", "") or ""),
|
|
65
|
+
str(row.get("title", "") or ""),
|
|
66
|
+
str(row.get("ai_summary", "") or ""),
|
|
67
|
+
str(row.get("url", "") or ""),
|
|
68
|
+
)
|
|
69
|
+
return table
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.command("signals")
|
|
73
|
+
def signals(
|
|
74
|
+
ctx: typer.Context,
|
|
75
|
+
company: Annotated[str | None, typer.Option("--company", help="Company website or akta.pro UUID.")] = None,
|
|
76
|
+
industry: Annotated[str | None, typer.Option("--industry", help="Comma-separated industry codes (from `akta-pro industry search`) — preferred for sector/market topics.")] = None,
|
|
77
|
+
query: Annotated[str | None, typer.Option("--query", help="Open-ended topic, e.g. 'crude oil prices' (last resort; prefer --company/--industry).")] = None,
|
|
78
|
+
title: Annotated[str | None, typer.Option("--title", help="Search by text in the article title.")] = None,
|
|
79
|
+
start_date: Annotated[str | None, typer.Option("--start-date", help="Start of range, YYYY-MM-DD. Non-enterprise limited to ~6 months back.")] = None,
|
|
80
|
+
end_date: Annotated[str | None, typer.Option("--end-date", help="End of range, YYYY-MM-DD. Default today.")] = None,
|
|
81
|
+
type_codes: Annotated[list[str] | None, typer.Option("--type-code", "-t", help="News-type tag code(s) from `akta-pro news types`, e.g. SD01, CM03 (repeatable).")] = None,
|
|
82
|
+
sentiment: Annotated[Sentiment, typer.Option("--sentiment", help="Filter by article sentiment.")] = Sentiment.all,
|
|
83
|
+
news_score: Annotated[NewsScore, typer.Option("--news-score", help="Filter by relevance/quality tier.")] = NewsScore.all,
|
|
84
|
+
countries: Annotated[list[str] | None, typer.Option("--country", help="Filter by the event's country — ISO codes, e.g. USA, GBR (repeatable).")] = None,
|
|
85
|
+
blacklisted: Annotated[list[str] | None, typer.Option("--blacklist", help="Publisher domain(s) to exclude, e.g. example.com (repeatable).")] = None,
|
|
86
|
+
entity_person: Annotated[list[str] | None, typer.Option("--entity-person", help="Only articles mentioning these people, by name (repeatable).")] = None,
|
|
87
|
+
entity_location: Annotated[list[str] | None, typer.Option("--entity-location", help="Only articles mentioning these locations (repeatable).")] = None,
|
|
88
|
+
entity_product: Annotated[list[str] | None, typer.Option("--entity-product", help="Only articles mentioning these product names (repeatable).")] = None,
|
|
89
|
+
entity_event: Annotated[list[str] | None, typer.Option("--entity-event", help="Only articles mentioning these event names (repeatable).")] = None,
|
|
90
|
+
naics_codes: Annotated[list[str] | None, typer.Option("--naics", help="Filter by NAICS industry classification code(s) (repeatable).")] = None,
|
|
91
|
+
sic_codes: Annotated[list[str] | None, typer.Option("--sic", help="Filter by SIC industry classification code(s) (repeatable).")] = None,
|
|
92
|
+
iptc_codes: Annotated[list[str] | None, typer.Option("--iptc", help="Filter by IPTC media topic code(s) (repeatable).")] = None,
|
|
93
|
+
iab_codes: Annotated[list[str] | None, typer.Option("--iab", help="Filter by IAB content taxonomy code(s) (repeatable).")] = None,
|
|
94
|
+
group_articles: Annotated[bool, typer.Option("--group-articles", help="Group near-duplicate articles from the same event.")] = False,
|
|
95
|
+
limit: Annotated[int, typer.Option("-n", "--limit", min=1, max=1000, help="Max articles (max 1000).")] = 10,
|
|
96
|
+
offset: Annotated[int, typer.Option("--offset", min=0, help="Pagination offset.")] = 0,
|
|
97
|
+
full: Annotated[bool, typer.Option("--full", help="Return all per-article fields (industries, types, entities, mentions) except body text.")] = False,
|
|
98
|
+
json_out: JsonOpt = False,
|
|
99
|
+
output: OutOpt = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
"""List news signals. Typically anchor the search with one of
|
|
102
|
+
--company/--industry/--query/--title (all optional).
|
|
103
|
+
|
|
104
|
+
The list is compact and never includes article bodies; each result carries an
|
|
105
|
+
`id` — pass those to `akta-pro news detail` for full text. Cost: 0.1/call +
|
|
106
|
+
0.01/article. Filter by type with `-t` codes from `akta-pro news types`.
|
|
107
|
+
"""
|
|
108
|
+
params = {
|
|
109
|
+
"company": company,
|
|
110
|
+
"industry": industry,
|
|
111
|
+
"query": query,
|
|
112
|
+
"title": title,
|
|
113
|
+
"start_date": start_date,
|
|
114
|
+
"end_date": end_date,
|
|
115
|
+
"sentiment_list": None if sentiment == Sentiment.all else sentiment.value,
|
|
116
|
+
"news_score_list": None if news_score == NewsScore.all else news_score.value,
|
|
117
|
+
"type_list": _csv(type_codes),
|
|
118
|
+
"countries": _csv(countries),
|
|
119
|
+
"blacklisted": _csv(blacklisted),
|
|
120
|
+
"entity_person_list": _csv(entity_person),
|
|
121
|
+
"entity_location_list": _csv(entity_location),
|
|
122
|
+
"entity_product_list": _csv(entity_product),
|
|
123
|
+
"entity_event_list": _csv(entity_event),
|
|
124
|
+
"naics_code_list": _csv(naics_codes),
|
|
125
|
+
"sic_code_list": _csv(sic_codes),
|
|
126
|
+
"iptc_code_list": _csv(iptc_codes),
|
|
127
|
+
"iab_code_list": _csv(iab_codes),
|
|
128
|
+
"group_articles": True if group_articles else None,
|
|
129
|
+
"limit": limit,
|
|
130
|
+
"offset": offset,
|
|
131
|
+
"full_text": False, # list stays compact; bodies via `news detail`
|
|
132
|
+
}
|
|
133
|
+
resp = fetch(ctx.obj, "/news", params)
|
|
134
|
+
articles = (resp.get("data") if isinstance(resp, dict) else None) or []
|
|
135
|
+
shaped = [_shape(a, full=full) for a in articles]
|
|
136
|
+
result = {
|
|
137
|
+
"total": resp.get("total") if isinstance(resp, dict) else None,
|
|
138
|
+
"count": len(shaped),
|
|
139
|
+
"credits_consumed": resp.get("credits_consumed") if isinstance(resp, dict) else None,
|
|
140
|
+
"data": shaped,
|
|
141
|
+
}
|
|
142
|
+
emit(ctx.obj, result, json_out=json_out, output=output, renderer=_signals_table)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _detail_view(result: object) -> Group | None:
|
|
146
|
+
"""Readable reader view for `news detail`: title + meta + article body.
|
|
147
|
+
|
|
148
|
+
Puts `full_text` front and centre (the whole point of `detail`); when the
|
|
149
|
+
body is empty, falls back to the AI summary and points at the source URL so
|
|
150
|
+
the output is still useful. Use --json for the full enrichment payload.
|
|
151
|
+
"""
|
|
152
|
+
rows = result.get("data") if isinstance(result, dict) else None
|
|
153
|
+
if not rows:
|
|
154
|
+
return None
|
|
155
|
+
panels = []
|
|
156
|
+
for a in rows:
|
|
157
|
+
meta_bits = [str(a.get(k)) for k in ("publisher", "published_date") if a.get(k)]
|
|
158
|
+
meta = " · ".join(meta_bits)
|
|
159
|
+
url = a.get("url") or ""
|
|
160
|
+
body = (a.get("full_text") or "").strip()
|
|
161
|
+
if body:
|
|
162
|
+
content = Text(body)
|
|
163
|
+
else:
|
|
164
|
+
summary = (a.get("ai_summary") or "").strip()
|
|
165
|
+
content = Text()
|
|
166
|
+
content.append("Full article text is unavailable for this article.\n", style="yellow")
|
|
167
|
+
if summary:
|
|
168
|
+
content.append("\nAI summary:\n", style="bold")
|
|
169
|
+
content.append(summary + "\n")
|
|
170
|
+
if url:
|
|
171
|
+
content.append("\nRead the source: ", style="dim")
|
|
172
|
+
content.append(url, style="cyan underline")
|
|
173
|
+
header = Text(str(a.get("title") or f"#{a.get('id')}"), style="bold")
|
|
174
|
+
if meta:
|
|
175
|
+
header.append(f"\n{meta}", style="dim")
|
|
176
|
+
if body and url:
|
|
177
|
+
header.append(f"\n{url}", style="cyan")
|
|
178
|
+
panels.append(Panel(content, title=header, title_align="left",
|
|
179
|
+
subtitle=f"id {a.get('id')}", subtitle_align="right"))
|
|
180
|
+
return Group(*panels)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.command("detail")
|
|
184
|
+
def detail(
|
|
185
|
+
ctx: typer.Context,
|
|
186
|
+
news_ids: Annotated[list[int], typer.Argument(help="News id(s) from `akta-pro news signals` (max 10).")],
|
|
187
|
+
json_out: JsonOpt = False,
|
|
188
|
+
output: OutOpt = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Fetch full article bodies for specific news id(s). Cost: 0.1/call + 0.01/article.
|
|
191
|
+
|
|
192
|
+
In a terminal this renders a readable reader view — the article `full_text`
|
|
193
|
+
up top, falling back to the AI summary + source URL when a body isn't
|
|
194
|
+
available. Pass --json for the complete enrichment payload.
|
|
195
|
+
"""
|
|
196
|
+
if not news_ids:
|
|
197
|
+
err.print("[red]Provide at least one news id[/] (from `akta-pro news signals`).")
|
|
198
|
+
raise typer.Exit(code=EXIT_BAD_INPUT)
|
|
199
|
+
ids_csv = ",".join(str(i) for i in news_ids[:10])
|
|
200
|
+
result = fetch(ctx.obj, "/news/by-id/", {"news_ids": ids_csv})
|
|
201
|
+
emit(ctx.obj, result, json_out=json_out, output=output, renderer=_detail_view)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _types_table(_: object) -> Table:
|
|
205
|
+
table = Table(title=f"News types ({len(NEWS_TAGS)} tags)")
|
|
206
|
+
table.add_column("Code", style="bold cyan")
|
|
207
|
+
table.add_column("Category")
|
|
208
|
+
table.add_column("Type", overflow="fold")
|
|
209
|
+
for code, name in NEWS_TAGS:
|
|
210
|
+
table.add_row(code, NEWS_CATEGORIES.get(code[:2], ""), name)
|
|
211
|
+
return table
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@app.command("types")
|
|
215
|
+
def types(
|
|
216
|
+
ctx: typer.Context,
|
|
217
|
+
json_out: JsonOpt = False,
|
|
218
|
+
output: OutOpt = None,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""List the news-type tag codes for `--type-code` filtering (free, offline)."""
|
|
221
|
+
categories = []
|
|
222
|
+
for prefix, cat_name in NEWS_CATEGORIES.items():
|
|
223
|
+
tags = [{"code": c, "name": n} for c, n in NEWS_TAGS if c.startswith(prefix)]
|
|
224
|
+
if tags:
|
|
225
|
+
categories.append({"category": cat_name, "codes": tags})
|
|
226
|
+
result = {
|
|
227
|
+
"count": len(NEWS_TAGS),
|
|
228
|
+
"usage": "Pass matching code(s) to `akta-pro news signals --type-code CODE`.",
|
|
229
|
+
"categories": categories,
|
|
230
|
+
}
|
|
231
|
+
# No network/credits — flat table in a TTY, structured JSON otherwise.
|
|
232
|
+
emit(ctx.obj, result, json_out=json_out, output=output, renderer=_types_table)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""`akta-pro update` — check for a newer release and update the CLI in place."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from akta_pro_cli import __version__
|
|
12
|
+
from akta_pro_cli import update as _u
|
|
13
|
+
from akta_pro_cli.console import err, out
|
|
14
|
+
from akta_pro_cli.runtime import EXIT_API
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def update(
|
|
18
|
+
check: Annotated[bool, typer.Option("--check", help="Only report whether an update exists; don't install.")] = False,
|
|
19
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt and update.")] = False,
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Check for a newer release and, if found, reinstall it via pipx.
|
|
22
|
+
|
|
23
|
+
Discovers the latest version from PyPI and upgrades the installed package.
|
|
24
|
+
"""
|
|
25
|
+
current = __version__
|
|
26
|
+
latest = _u.cached_latest(timeout=8.0, force=True)
|
|
27
|
+
if latest is None:
|
|
28
|
+
err.print(
|
|
29
|
+
f"[yellow]Couldn't reach PyPI to check for updates[/] (network?). "
|
|
30
|
+
f"You're on v{current}."
|
|
31
|
+
)
|
|
32
|
+
err.print(f"See {_u.PROJECT_URL}, or upgrade manually.")
|
|
33
|
+
raise typer.Exit(code=EXIT_API)
|
|
34
|
+
|
|
35
|
+
if not _u.is_newer(latest, current):
|
|
36
|
+
out.print(f"[green]✓ up to date[/] — v{current} is the latest.")
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
cmd = ["pipx", "upgrade", _u.PACKAGE]
|
|
40
|
+
out.print(f"Update available: [bold]v{current}[/] → [bold green]v{latest}[/]")
|
|
41
|
+
|
|
42
|
+
if check:
|
|
43
|
+
out.print("Run to update: " + " ".join(cmd))
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if shutil.which("pipx") is None:
|
|
47
|
+
err.print("[yellow]pipx not found on PATH.[/] Update manually: " + " ".join(cmd))
|
|
48
|
+
raise typer.Exit(code=EXIT_API)
|
|
49
|
+
|
|
50
|
+
if not yes and not typer.confirm(f"Update to v{latest} now?", default=True):
|
|
51
|
+
out.print("Skipped. Run when ready: " + " ".join(cmd))
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
err.print(f"Updating to v{latest} via pipx…")
|
|
55
|
+
rc = subprocess.run(cmd).returncode
|
|
56
|
+
if rc == 0:
|
|
57
|
+
out.print(f"[green]✓ updated to v{latest}[/]")
|
|
58
|
+
else:
|
|
59
|
+
err.print(f"[red]pipx exited {rc}.[/] Try manually: " + " ".join(cmd))
|
|
60
|
+
raise typer.Exit(code=EXIT_API)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def register(app: typer.Typer) -> None:
|
|
64
|
+
app.command("update")(update)
|
akta_pro_cli/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Local credential + config storage for the akta.pro CLI.
|
|
2
|
+
|
|
3
|
+
Credentials live in a JSON file under the user's config dir
|
|
4
|
+
(`$XDG_CONFIG_HOME/akta-pro` or `~/.config/akta-pro` on Unix, `%APPDATA%\\akta-pro`
|
|
5
|
+
on Windows), written 0600. Only the API key path is used in v1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import stat
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
APP_NAME = "akta-pro"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def config_dir() -> Path:
|
|
19
|
+
if os.name == "nt":
|
|
20
|
+
base = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
|
|
21
|
+
return Path(base) / APP_NAME
|
|
22
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
23
|
+
return Path(base) / APP_NAME
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def credentials_path() -> Path:
|
|
27
|
+
return config_dir() / "credentials.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_credentials() -> dict:
|
|
31
|
+
path = credentials_path()
|
|
32
|
+
if not path.exists():
|
|
33
|
+
return {}
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(path.read_text())
|
|
36
|
+
return data if isinstance(data, dict) else {}
|
|
37
|
+
except (ValueError, OSError):
|
|
38
|
+
return {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_credentials(data: dict) -> Path:
|
|
42
|
+
directory = config_dir()
|
|
43
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
try:
|
|
45
|
+
os.chmod(directory, 0o700)
|
|
46
|
+
except OSError:
|
|
47
|
+
pass # best-effort (e.g. Windows)
|
|
48
|
+
path = credentials_path()
|
|
49
|
+
path.write_text(json.dumps(data, indent=2))
|
|
50
|
+
try:
|
|
51
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
|
52
|
+
except OSError:
|
|
53
|
+
pass
|
|
54
|
+
return path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def clear_credentials() -> bool:
|
|
58
|
+
"""Delete the stored credentials file. Returns True if a file was removed."""
|
|
59
|
+
path = credentials_path()
|
|
60
|
+
if path.exists():
|
|
61
|
+
path.unlink()
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def stored_api_key() -> str | None:
|
|
67
|
+
return load_credentials().get("api_key")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def stored_base_url() -> str | None:
|
|
71
|
+
return load_credentials().get("base_url")
|
akta_pro_cli/console.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Shared Rich consoles.
|
|
2
|
+
|
|
3
|
+
`out` writes machine/primary output to stdout; `err` writes human status,
|
|
4
|
+
credit lines, and errors to stderr — so `akta-pro ... --json | jq` stays clean.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
|
|
9
|
+
out = Console()
|
|
10
|
+
err = Console(stderr=True)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""akta.pro news-type taxonomy — a static snapshot of the published `news-tags.csv`.
|
|
2
|
+
|
|
3
|
+
Kept in the CLI so the package is fully independent of the MCP. The MCP has its
|
|
4
|
+
own copy (`akta_mcp/tools/news_tags.py`); keep both in sync if the CSV changes:
|
|
5
|
+
https://delightful-beach-02437b20f.7.azurestaticapps.net/news-tags.csv
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Leaf-tag category prefixes → human category names.
|
|
9
|
+
NEWS_CATEGORIES = {
|
|
10
|
+
"OT": "Others",
|
|
11
|
+
"SD": "Strategic Developments",
|
|
12
|
+
"CM": "Capital Markets & Transactions",
|
|
13
|
+
"LG": "Leadership & Governance",
|
|
14
|
+
"WT": "Workforce & Talent",
|
|
15
|
+
"FR": "Financial Results & Performance",
|
|
16
|
+
"RC": "Regulatory & Compliance",
|
|
17
|
+
"MM": "Macro & Market Environment",
|
|
18
|
+
"MD": "Market Dynamics",
|
|
19
|
+
"II": "Industry Disruption & Innovation",
|
|
20
|
+
"RK": "Risk & Crisis Events",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# (code, name) leaf tags. `news signals --type-code …` sends these as `type_list`.
|
|
24
|
+
NEWS_TAGS = [
|
|
25
|
+
("OT01", "Others"),
|
|
26
|
+
("SD01", "Product Launches & Enhancements"),
|
|
27
|
+
("SD02", "Partnerships & Alliances"),
|
|
28
|
+
("SD03", "Geographic Expansion"),
|
|
29
|
+
("SD04", "Operational Milestones"),
|
|
30
|
+
("SD05", "Market Share & Positioning"),
|
|
31
|
+
("SD06", "Digital Transformation"),
|
|
32
|
+
("SD07", "Brand & Marketing Initiatives"),
|
|
33
|
+
("SD08", "Research, Development & Innovation"),
|
|
34
|
+
("SD09", "Sustainability & ESG"),
|
|
35
|
+
("SD10", "Pricing & Commercial Strategy"),
|
|
36
|
+
("SD11", "Awards, Rankings & Recognitions"),
|
|
37
|
+
("CM01", "Equity Fund-Raising"),
|
|
38
|
+
("CM02", "Debt Financing"),
|
|
39
|
+
("CM03", "Mergers & Acquisitions"),
|
|
40
|
+
("CM04", "Divestitures & Asset Sales"),
|
|
41
|
+
("CM05", "IPOs & Follow-on Offerings"),
|
|
42
|
+
("CM06", "SPAC Deals & De-SPACs"),
|
|
43
|
+
("CM07", "Secondary Transactions"),
|
|
44
|
+
("CM08", "Shareholder Returns (Buybacks & Dividends)"),
|
|
45
|
+
("CM09", "Valuation Events"),
|
|
46
|
+
("CM10", "Deal Flow & Volume Statistics"),
|
|
47
|
+
("CM11", "Public-to-Private/Take-Private"),
|
|
48
|
+
("CM12", "Daily Stock Market News"),
|
|
49
|
+
("LG01", "C-Suite Appointments & Departures"),
|
|
50
|
+
("LG02", "Board Composition & Changes"),
|
|
51
|
+
("LG03", "Succession Planning"),
|
|
52
|
+
("LG04", "Organisational Restructuring"),
|
|
53
|
+
("LG05", "Executive Commentary & Interviews"),
|
|
54
|
+
("LG06", "Conflicts of Interest & Related-Party Transactions"),
|
|
55
|
+
("WT01", "Strategic Hiring"),
|
|
56
|
+
("WT02", "Workforce Reductions"),
|
|
57
|
+
("WT03", "Compensation & Benefits"),
|
|
58
|
+
("WT04", "Culture & Workplace Practices"),
|
|
59
|
+
("WT05", "Talent-Market Dynamics"),
|
|
60
|
+
("FR01", "Earnings Releases"),
|
|
61
|
+
("FR02", "Revenue & Growth Metrics"),
|
|
62
|
+
("FR03", "Profitability & Margins"),
|
|
63
|
+
("FR04", "Cash Flow & Capital Expenditure"),
|
|
64
|
+
("FR05", "Guidance & Forecast Revisions"),
|
|
65
|
+
("FR06", "Credit-Rating Actions"),
|
|
66
|
+
("FR07", "Sector Performance Dashboards"),
|
|
67
|
+
("RC01", "Regulatory Approvals & Certifications"),
|
|
68
|
+
("RC02", "Policy & Legislative Changes"),
|
|
69
|
+
("RC03", "Legal Proceedings & Litigation"),
|
|
70
|
+
("RC04", "Compliance & Reporting Updates"),
|
|
71
|
+
("RC05", "Intellectual Property"),
|
|
72
|
+
("RC06", "Antitrust & Competition"),
|
|
73
|
+
("RC07", "Data Privacy & Cybersecurity Regulation"),
|
|
74
|
+
("RC08", "Tax & Accounting Rules"),
|
|
75
|
+
("MM01", "Economic Indicators"),
|
|
76
|
+
("MM02", "Monetary Policy"),
|
|
77
|
+
("MM03", "Fiscal Policy"),
|
|
78
|
+
("MM04", "Trade, Tariffs & Sanctions"),
|
|
79
|
+
("MM05", "Consumer Behaviour & Spending"),
|
|
80
|
+
("MM06", "Labor-Market Statistics"),
|
|
81
|
+
("MM07", "Sector Outlook & Forecasts"),
|
|
82
|
+
("MD01", "Market Indices Performance"),
|
|
83
|
+
("MD02", "Institutional Flows & Positioning"),
|
|
84
|
+
("MD03", "Sector Rotation"),
|
|
85
|
+
("MD04", "Volatility & Risk Appetite"),
|
|
86
|
+
("MD05", "FX & Commodity Pricing"),
|
|
87
|
+
("MD06", "Liquidity Conditions"),
|
|
88
|
+
("MD07", "Investor Sentiment"),
|
|
89
|
+
("II01", "Emerging Technologies"),
|
|
90
|
+
("II02", "Technology Adoption & Disruption"),
|
|
91
|
+
("II03", "Business-Model Evolution"),
|
|
92
|
+
("II04", "Competitive Threats"),
|
|
93
|
+
("II05", "Market-Structure Changes"),
|
|
94
|
+
("RK01", "Geopolitical Risk"),
|
|
95
|
+
("RK02", "Public-Health Crises"),
|
|
96
|
+
("RK03", "Natural Disasters"),
|
|
97
|
+
("RK04", "Operational Disruptions"),
|
|
98
|
+
("RK05", "Cybersecurity Incidents"),
|
|
99
|
+
("RK06", "Reputational Damage"),
|
|
100
|
+
("RK07", "Financial Distress & Restructuring"),
|
|
101
|
+
("RK08", "Supply-Chain Disruptions"),
|
|
102
|
+
]
|
akta_pro_cli/options.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Reusable Typer option/argument annotations shared across command modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
CompanyArg = Annotated[str, typer.Argument(help="Company website (e.g. 'canva.com') or akta.pro UUID.")]
|
|
11
|
+
|
|
12
|
+
JsonOpt = Annotated[bool, typer.Option("--json", help="Emit raw JSON to stdout (clean for pipes).")]
|
|
13
|
+
|
|
14
|
+
OutOpt = Annotated[
|
|
15
|
+
Path | None,
|
|
16
|
+
typer.Option("-o", "--output", help="Write the raw JSON/text payload to a file.", show_default=False),
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
LimitOpt = Annotated[int, typer.Option("-n", "--limit", help="Max items to return.")]
|
|
20
|
+
|
|
21
|
+
OffsetOpt = Annotated[int, typer.Option("--offset", help="Pagination offset.")]
|