newsscore 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.
newsscore/__init__.py ADDED
@@ -0,0 +1,42 @@
1
+ """newsscore: news sentiment scoring for stocks with a pluggable scorer.
2
+
3
+ from newsscore import NewsScorer
4
+
5
+ scorer = NewsScorer() # Jev if TYPESAFE_API_KEY is set, else keyword scorer
6
+ scorer.source_add("yahoo") # keyless RSS, good for a first try
7
+ print(scorer.score("AAPL").score)
8
+
9
+ See ``newsscore.scoring.protocol`` for how to plug in your own scoring function.
10
+ """
11
+
12
+ from ._version import __version__
13
+ from .aggregate import Aggregate, AggregateFn, aggregate, make_aggregator
14
+ from .cache import ScoreCache
15
+ from .config import load_env
16
+ from .models import Article, ArticleScore, ScoredArticle, ScoreResult
17
+ from .scorer import NewsScorer
18
+ from .scoring import JevScorer, KeywordScorer, ScoreFn, per_article
19
+ from .sources import SOURCE_TYPES, NewsSource, SourceError, register
20
+
21
+ __all__ = [
22
+ "__version__",
23
+ "NewsScorer",
24
+ "Article",
25
+ "ArticleScore",
26
+ "ScoredArticle",
27
+ "ScoreResult",
28
+ "ScoreFn",
29
+ "per_article",
30
+ "JevScorer",
31
+ "KeywordScorer",
32
+ "NewsSource",
33
+ "SourceError",
34
+ "SOURCE_TYPES",
35
+ "register",
36
+ "Aggregate",
37
+ "AggregateFn",
38
+ "aggregate",
39
+ "make_aggregator",
40
+ "ScoreCache",
41
+ "load_env",
42
+ ]
newsscore/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
newsscore/aggregate.py ADDED
@@ -0,0 +1,78 @@
1
+ """Turn many per-article scores into one number.
2
+
3
+ The default scheme is deliberately simple and fully described here so it can be
4
+ reasoned about, back-tested and replaced:
5
+
6
+ weight_i = confidence_i * relevance_i * 0.5 ** (age_hours_i / half_life_hours)
7
+ score = sum(weight_i * score_i) / sum(weight_i)
8
+ confidence = 1 - exp(-sum(weight_i) / saturation)
9
+
10
+ Older articles count less (exponential decay), unsure or off-topic articles count
11
+ less (their weights), and confidence grows with the amount of weighted evidence
12
+ but never exceeds 1. With ``saturation=3`` three fully-weighted fresh articles
13
+ give confidence ~0.63; ten give ~0.96.
14
+
15
+ Plug in your own with ``NewsScorer(aggregate_fn=...)``; it receives the scored
16
+ articles and the reference time and must return an :class:`Aggregate`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import math
22
+ from collections import defaultdict
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime
25
+ from functools import partial
26
+ from typing import Callable, Sequence
27
+
28
+ from .models import ScoredArticle
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class Aggregate:
33
+ score: float
34
+ confidence: float
35
+ by_source: dict[str, float] = field(default_factory=dict)
36
+
37
+
38
+ AggregateFn = Callable[[Sequence[ScoredArticle], datetime], Aggregate]
39
+
40
+
41
+ def aggregate(
42
+ scored: Sequence[ScoredArticle],
43
+ now: datetime,
44
+ *,
45
+ half_life_hours: float = 48.0,
46
+ saturation: float = 3.0,
47
+ ) -> Aggregate:
48
+ """Confidence- and relevance-weighted mean with exponential time decay."""
49
+ if not scored:
50
+ return Aggregate(score=0.0, confidence=0.0)
51
+
52
+ total_w = 0.0
53
+ total_ws = 0.0
54
+ per_source_w: dict[str, float] = defaultdict(float)
55
+ per_source_ws: dict[str, float] = defaultdict(float)
56
+
57
+ for item in scored:
58
+ age_hours = max(0.0, (now - item.article.published).total_seconds() / 3600.0)
59
+ decay = 0.5 ** (age_hours / half_life_hours) if half_life_hours > 0 else 1.0
60
+ w = item.score.weight * decay
61
+ if w <= 0.0:
62
+ continue
63
+ total_w += w
64
+ total_ws += w * item.score.score
65
+ per_source_w[item.article.source] += w
66
+ per_source_ws[item.article.source] += w * item.score.score
67
+
68
+ if total_w == 0.0:
69
+ return Aggregate(score=0.0, confidence=0.0)
70
+
71
+ by_source = {name: per_source_ws[name] / per_source_w[name] for name in per_source_w}
72
+ confidence = 1.0 - math.exp(-total_w / saturation) if saturation > 0 else 1.0
73
+ return Aggregate(score=total_ws / total_w, confidence=confidence, by_source=by_source)
74
+
75
+
76
+ def make_aggregator(half_life_hours: float = 48.0, saturation: float = 3.0) -> AggregateFn:
77
+ """Bind parameters so the result matches the :data:`AggregateFn` signature."""
78
+ return partial(aggregate, half_life_hours=half_life_hours, saturation=saturation)
newsscore/cache.py ADDED
@@ -0,0 +1,99 @@
1
+ """SQLite cache of per-article scores.
2
+
3
+ Scoring is the expensive step (Jev calls cost money, user models cost time), so
4
+ every :class:`~newsscore.ArticleScore` is stored under
5
+ ``(scorer_name, query, article_id)``. Re-running a query only scores articles that
6
+ have not been seen before, and back-tests can replay from the cache for free.
7
+
8
+ The cache is synchronous on purpose: SQLite calls here take microseconds and a
9
+ threadpool hop would cost more than it saves.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import sqlite3
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Iterable, Mapping
20
+
21
+ from platformdirs import user_cache_dir
22
+
23
+ from .models import ArticleScore
24
+
25
+ _SCHEMA = """
26
+ CREATE TABLE IF NOT EXISTS scores (
27
+ scorer TEXT NOT NULL,
28
+ query TEXT NOT NULL,
29
+ article_id TEXT NOT NULL,
30
+ payload TEXT NOT NULL,
31
+ created REAL NOT NULL,
32
+ PRIMARY KEY (scorer, query, article_id)
33
+ )
34
+ """
35
+
36
+
37
+ def default_cache_path() -> Path:
38
+ override = os.environ.get("NEWSSCORE_CACHE")
39
+ if override:
40
+ return Path(override).expanduser()
41
+ return Path(user_cache_dir("newsscore")) / "scores.sqlite"
42
+
43
+
44
+ class ScoreCache:
45
+ def __init__(self, path: Path | str | None = None) -> None:
46
+ self.path = Path(path) if path else default_cache_path()
47
+ self.path.parent.mkdir(parents=True, exist_ok=True)
48
+ self._conn = sqlite3.connect(self.path, check_same_thread=False)
49
+ self._conn.execute("PRAGMA journal_mode=WAL")
50
+ self._conn.execute(_SCHEMA)
51
+ self._conn.commit()
52
+
53
+ def get_many(self, scorer: str, query: str, ids: Iterable[str]) -> dict[str, ArticleScore]:
54
+ ids = list(ids)
55
+ if not ids:
56
+ return {}
57
+ found: dict[str, ArticleScore] = {}
58
+ # SQLite caps bound parameters; chunk to stay well under the limit.
59
+ for start in range(0, len(ids), 500):
60
+ chunk = ids[start : start + 500]
61
+ marks = ",".join("?" * len(chunk))
62
+ rows = self._conn.execute(
63
+ f"SELECT article_id, payload FROM scores WHERE scorer=? AND query=? AND article_id IN ({marks})",
64
+ (scorer, query, *chunk),
65
+ )
66
+ for article_id, payload in rows:
67
+ found[article_id] = ArticleScore.from_dict(json.loads(payload))
68
+ return found
69
+
70
+ def put_many(self, scorer: str, query: str, items: Mapping[str, ArticleScore]) -> None:
71
+ if not items:
72
+ return
73
+ now = time.time()
74
+ self._conn.executemany(
75
+ "INSERT OR REPLACE INTO scores (scorer, query, article_id, payload, created) VALUES (?,?,?,?,?)",
76
+ [
77
+ (scorer, query, article_id, json.dumps(score.to_dict(), default=str), now)
78
+ for article_id, score in items.items()
79
+ ],
80
+ )
81
+ self._conn.commit()
82
+
83
+ def clear(self, scorer: str | None = None) -> int:
84
+ cur = (
85
+ self._conn.execute("DELETE FROM scores WHERE scorer=?", (scorer,))
86
+ if scorer
87
+ else self._conn.execute("DELETE FROM scores")
88
+ )
89
+ self._conn.commit()
90
+ return cur.rowcount
91
+
92
+ def close(self) -> None:
93
+ self._conn.close()
94
+
95
+ def __enter__(self) -> "ScoreCache":
96
+ return self
97
+
98
+ def __exit__(self, *exc: object) -> None:
99
+ self.close()
newsscore/cli.py ADDED
@@ -0,0 +1,312 @@
1
+ """``newsscore`` command line.
2
+
3
+ newsscore source add finnhub --api-key KEY
4
+ newsscore source add yahoo
5
+ newsscore source list
6
+ newsscore score AAPL --days 7
7
+ newsscore score AAPL -s finnhub -s yahoo --json
8
+ newsscore fetch AAPL --out news.json
9
+
10
+ Nothing is written unless you ask for it: ``fetch`` and ``score`` print to stdout,
11
+ and ``--out FILE`` saves the same payload as JSON instead. Article *scores* are
12
+ cached in SQLite so a repeated query only pays for new articles, and saved sources
13
+ live in a per-user JSON file. ``newsscore doctor`` prints every path in use; the
14
+ ``NEWSSCORE_CACHE``, ``NEWSSCORE_CONFIG`` and ``NEWSSCORE_ENV`` variables move them.
15
+
16
+ API keys may be placed in a ``.env`` file in the working directory; it is loaded
17
+ on every invocation without overriding real environment variables.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import importlib.util
24
+ import json
25
+ import logging
26
+ import os
27
+ from pathlib import Path
28
+ from typing import Annotated, Optional
29
+
30
+ import typer
31
+
32
+ from ._version import __version__
33
+ from .cache import ScoreCache, default_cache_path
34
+ from .config import SourceSpec, SourceStore, config_path, env_candidates, load_env
35
+ from .scorer import NewsScorer
36
+ from .scoring import SCORERS
37
+ from .sources import SOURCE_TYPES, SourceError, make_source
38
+
39
+ app = typer.Typer(
40
+ help="News sentiment scoring for stocks. Mainstream news APIs in, one score out.",
41
+ invoke_without_command=True,
42
+ add_completion=False,
43
+ )
44
+ source_app = typer.Typer(help="Manage saved news sources.", no_args_is_help=True)
45
+ app.add_typer(source_app, name="source")
46
+
47
+ SourcesOpt = Annotated[
48
+ Optional[list[str]],
49
+ typer.Option("--source", "-s", help="Saved source name; repeat for several. Omit for all."),
50
+ ]
51
+ DaysOpt = Annotated[float, typer.Option("--days", "-d", help="Look-back window in days.")]
52
+ JsonOpt = Annotated[bool, typer.Option("--json", help="Machine-readable output.")]
53
+ OutOpt = Annotated[
54
+ Optional[Path],
55
+ typer.Option("--out", metavar="FILE", help="Write the result to FILE as JSON instead of printing it."),
56
+ ]
57
+
58
+
59
+ @app.callback()
60
+ def _main(
61
+ ctx: typer.Context,
62
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show warnings from sources.")] = False,
63
+ version: Annotated[bool, typer.Option("--version", help="Print version and exit.", is_eager=True)] = False,
64
+ ) -> None:
65
+ if version:
66
+ typer.echo(__version__)
67
+ raise typer.Exit()
68
+ if ctx.invoked_subcommand is None:
69
+ typer.echo(ctx.get_help())
70
+ raise typer.Exit()
71
+ logging.basicConfig(level=logging.INFO if verbose else logging.ERROR, format="%(levelname)s %(message)s")
72
+ load_env()
73
+
74
+
75
+ # ---- source management ------------------------------------------------------------
76
+
77
+
78
+ @source_app.command("add")
79
+ def source_add(
80
+ type_: Annotated[str, typer.Argument(metavar="TYPE", help="Source type; see `newsscore source types`.")],
81
+ name: Annotated[Optional[str], typer.Option("--name", "-n", help="Name to save under (default: the type).")] = None,
82
+ api_key: Annotated[Optional[str], typer.Option("--api-key", "-k", help="Provider API key.")] = None,
83
+ option: Annotated[
84
+ Optional[list[str]], typer.Option("--option", "-o", metavar="KEY=VALUE", help="Provider option; repeatable.")
85
+ ] = None,
86
+ replace: Annotated[bool, typer.Option("--replace", help="Overwrite a source with the same name.")] = False,
87
+ ) -> None:
88
+ """Save a news source to the local config."""
89
+ if type_ not in SOURCE_TYPES:
90
+ _fail(f"unknown type {type_!r}. Known: {', '.join(sorted(SOURCE_TYPES))}")
91
+ options = _parse_options(option or [])
92
+ spec = SourceSpec(type=type_, name=name or type_, api_key=api_key, options=options)
93
+ try: # construct once to surface bad options early; a missing key is only a warning
94
+ make_source(type_, api_key=api_key, name=spec.name, **options)
95
+ except SourceError as exc:
96
+ if "API key required" not in str(exc):
97
+ _fail(str(exc))
98
+ typer.secho(f"warning: {exc}. Saved anyway; set the env var before use.", fg="yellow", err=True)
99
+ try:
100
+ SourceStore().add(spec, replace=replace)
101
+ except KeyError as exc:
102
+ _fail(f"{exc.args[0]}; pass --replace to overwrite")
103
+ typer.echo(f"saved source {spec.name!r} ({type_}) to {config_path()}")
104
+
105
+
106
+ @source_app.command("list")
107
+ def source_list() -> None:
108
+ """Show saved sources."""
109
+ specs = SourceStore().load()
110
+ if not specs:
111
+ typer.echo("no saved sources. Try: newsscore source add yahoo")
112
+ return
113
+ rows = [(s.name, s.type, _mask(s.api_key), json.dumps(s.options) if s.options else "") for s in specs.values()]
114
+ _table(("NAME", "TYPE", "API KEY", "OPTIONS"), rows)
115
+
116
+
117
+ @source_app.command("remove")
118
+ def source_remove(name: Annotated[str, typer.Argument(help="Saved source name.")]) -> None:
119
+ """Delete a saved source."""
120
+ try:
121
+ SourceStore().remove(name)
122
+ except KeyError as exc:
123
+ _fail(exc.args[0])
124
+ typer.echo(f"removed {name!r}")
125
+
126
+
127
+ @source_app.command("types")
128
+ def source_types() -> None:
129
+ """List supported source types."""
130
+ rows = [
131
+ (t, "no" if not cls.requires_key else "yes", cls.env_key or "", cls.query_kind)
132
+ for t, cls in sorted(SOURCE_TYPES.items())
133
+ ]
134
+ _table(("TYPE", "NEEDS KEY", "ENV VAR", "QUERY"), rows)
135
+
136
+
137
+ # ---- fetching and scoring ---------------------------------------------------------
138
+
139
+
140
+ @app.command()
141
+ def fetch(
142
+ query: Annotated[str, typer.Argument(help="Ticker symbol or keyword.")],
143
+ source: SourcesOpt = None,
144
+ days: DaysOpt = 7,
145
+ as_json: JsonOpt = False,
146
+ out: OutOpt = None,
147
+ ) -> None:
148
+ """List recent articles without scoring them.
149
+
150
+ Articles are printed and then forgotten; pass --out FILE to keep them as JSON.
151
+ """
152
+ scorer = NewsScorer.from_config(score_fn="keyword", cache=False)
153
+ articles = _run(scorer, scorer.afetch(query, days=days, sources=source))
154
+ payload = [a.to_dict() for a in articles]
155
+ if out:
156
+ _write_json(out, payload)
157
+ typer.echo(f"wrote {len(articles)} article(s) to {out}")
158
+ return
159
+ if as_json:
160
+ typer.echo(json.dumps(payload, indent=2))
161
+ return
162
+ if not articles:
163
+ typer.echo("no articles found")
164
+ return
165
+ for a in articles:
166
+ typer.echo(f"{a.published:%Y-%m-%d %H:%M} [{a.source}] {a.title}")
167
+ typer.echo(f"\n{len(articles)} article(s)")
168
+
169
+
170
+ @app.command()
171
+ def score(
172
+ query: Annotated[str, typer.Argument(help="Ticker symbol or keyword.")],
173
+ source: SourcesOpt = None,
174
+ days: DaysOpt = 7,
175
+ scorer_name: Annotated[
176
+ Optional[str], typer.Option("--scorer", help=f"One of: {', '.join(sorted(SCORERS))}. Default: jev if configured, else keyword.")
177
+ ] = None,
178
+ half_life: Annotated[float, typer.Option("--half-life", help="Decay half-life in hours for aggregation.")] = 48.0,
179
+ no_cache: Annotated[bool, typer.Option("--no-cache", help="Do not read or write the score cache.")] = False,
180
+ articles: Annotated[int, typer.Option("--articles", "-a", help="Show the N most recent scored articles.")] = 0,
181
+ as_json: JsonOpt = False,
182
+ out: OutOpt = None,
183
+ ) -> None:
184
+ """Fetch, score and aggregate news sentiment for QUERY.
185
+
186
+ The result is printed; pass --out FILE to keep the full JSON (every scored
187
+ article included) instead.
188
+ """
189
+ if scorer_name and scorer_name not in SCORERS:
190
+ _fail(f"unknown scorer {scorer_name!r}. Known: {', '.join(sorted(SCORERS))}")
191
+ scorer = NewsScorer.from_config(score_fn=scorer_name, cache=not no_cache, half_life_hours=half_life)
192
+ result = _run(scorer, scorer.ascore(query, days=days, sources=source))
193
+
194
+ if out:
195
+ _write_json(out, result.to_dict())
196
+ typer.echo(f"wrote {result.n_articles} scored article(s) to {out}")
197
+ return
198
+ if as_json:
199
+ typer.echo(json.dumps(result.to_dict(), indent=2, default=str))
200
+ return
201
+
202
+ typer.echo(f"query {result.query}")
203
+ typer.echo(f"window {result.since:%Y-%m-%d} .. {result.until:%Y-%m-%d} ({days:g} days)")
204
+ typer.echo(f"scorer {scorer.scorer_name or type(scorer.score_fn).__name__}")
205
+ typer.echo(f"articles {result.n_articles}")
206
+ typer.echo(f"score {result.score:+.3f} (-1 bearish .. +1 bullish)")
207
+ typer.echo(f"confidence {result.confidence:.3f}")
208
+ if result.by_source:
209
+ typer.echo("by source " + " ".join(f"{k}={v:+.2f}" for k, v in sorted(result.by_source.items())))
210
+ for message in result.errors:
211
+ typer.secho(f"warning {message}", fg="yellow", err=True)
212
+ if articles:
213
+ typer.echo("")
214
+ for item in result.articles[:articles]:
215
+ a, s = item.article, item.score
216
+ typer.echo(f"{s.score:+.2f} c={s.confidence:.2f} r={s.relevance:.2f} {a.published:%m-%d %H:%M} [{a.source}] {a.title}")
217
+
218
+
219
+ @app.command("config-path")
220
+ def show_config_path() -> None:
221
+ """Print where saved sources are stored."""
222
+ typer.echo(str(config_path()))
223
+
224
+
225
+ @app.command("cache-clear")
226
+ def cache_clear(
227
+ scorer_name: Annotated[Optional[str], typer.Option("--scorer", help="Only clear this scorer's entries.")] = None,
228
+ ) -> None:
229
+ """Delete cached article scores."""
230
+ with ScoreCache() as cache:
231
+ n = cache.clear(scorer_name)
232
+ typer.echo(f"removed {n} cached score(s) from {ScoreCache().path}")
233
+
234
+
235
+ @app.command()
236
+ def doctor() -> None:
237
+ """Check what is configured on this machine."""
238
+ env_file = next((p for p in env_candidates() if p.is_file()), None)
239
+ typer.echo(f"env file {env_file or 'none found (looked in ' + ', '.join(str(p) for p in env_candidates()) + ')'}")
240
+ typer.echo(f"config file {config_path()} ({'exists' if config_path().exists() else 'missing'})")
241
+ cache = default_cache_path()
242
+ typer.echo(f"score cache {cache} ({'exists' if cache.exists() else 'created on first score'})")
243
+ typer.echo(f"saved sources {', '.join(SourceStore().load()) or 'none'}")
244
+ missing = []
245
+ if importlib.util.find_spec("typesafe_sdk") is None:
246
+ missing.append("typesafe-sdk not installed (pip install 'newsscore[jev]' or uv sync)")
247
+ if not os.environ.get("TYPESAFE_API_KEY"):
248
+ missing.append("TYPESAFE_API_KEY not set")
249
+ typer.echo(f"jev scorer {'ready' if not missing else 'unavailable: ' + '; '.join(missing)}")
250
+
251
+
252
+ # ---- helpers ---------------------------------------------------------------------------
253
+
254
+
255
+ def _run(scorer: NewsScorer, coro): # type: ignore[no-untyped-def]
256
+ async def go(): # type: ignore[no-untyped-def]
257
+ try:
258
+ return await coro
259
+ finally:
260
+ await scorer.aclose()
261
+
262
+ return asyncio.run(go())
263
+
264
+
265
+ def _write_json(path: Path, payload: object) -> None:
266
+ """Save `payload` as UTF-8 JSON, creating parent directories as needed."""
267
+ path = path.expanduser()
268
+ if path.parent != Path(""):
269
+ path.parent.mkdir(parents=True, exist_ok=True)
270
+ try:
271
+ path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
272
+ except OSError as exc:
273
+ _fail(f"could not write {path}: {exc}")
274
+
275
+
276
+ def _parse_options(items: list[str]) -> dict[str, object]:
277
+ options: dict[str, object] = {}
278
+ for item in items:
279
+ key, sep, value = item.partition("=")
280
+ if not sep or not key:
281
+ _fail(f"bad --option {item!r}; expected KEY=VALUE")
282
+ lowered = value.lower()
283
+ options[key] = True if lowered == "true" else False if lowered == "false" else _number_or_str(value)
284
+ return options
285
+
286
+
287
+ def _number_or_str(value: str) -> object:
288
+ try:
289
+ return int(value)
290
+ except ValueError:
291
+ return value
292
+
293
+
294
+ def _mask(key: str | None) -> str:
295
+ if not key:
296
+ return "(env)"
297
+ return key if len(key) <= 6 else f"{key[:3]}...{key[-3:]}"
298
+
299
+
300
+ def _table(header: tuple[str, ...], rows: list[tuple[str, ...]]) -> None:
301
+ widths = [max(len(str(r[i])) for r in (header, *rows)) for i in range(len(header))]
302
+ for row in (header, *rows):
303
+ typer.echo(" ".join(str(cell).ljust(w) for cell, w in zip(row, widths)).rstrip())
304
+
305
+
306
+ def _fail(message: str) -> None:
307
+ typer.secho(f"error: {message}", fg="red", err=True)
308
+ raise typer.Exit(code=1)
309
+
310
+
311
+ if __name__ == "__main__": # pragma: no cover
312
+ app()
newsscore/config.py ADDED
@@ -0,0 +1,151 @@
1
+ """Per-user store of configured news sources.
2
+
3
+ Location (first match wins):
4
+ 1. ``$NEWSSCORE_CONFIG``
5
+ 2. ``platformdirs.user_config_dir("newsscore")/sources.json``
6
+ (``%APPDATA%\\newsscore`` on Windows, ``~/.config/newsscore`` on Linux,
7
+ ``~/Library/Application Support/newsscore`` on macOS)
8
+
9
+ Shape::
10
+
11
+ {
12
+ "sources": {
13
+ "finnhub": {"type": "finnhub", "api_key": "abc", "options": {}},
14
+ "yahoo": {"type": "yahoo", "api_key": null, "options": {}}
15
+ }
16
+ }
17
+
18
+ ``api_key`` may be null; the source then falls back to its environment variable.
19
+
20
+ API keys can also live in a ``.env`` file (``KEY=VALUE`` lines, ``#`` comments,
21
+ optional quotes). :func:`load_env` is called by the CLI and by
22
+ ``NewsScorer.from_config()``; it never overrides variables already set in the
23
+ environment. Lookup: ``$NEWSSCORE_ENV`` alone if set, else ``./.env``, then ``<config dir>/.env``.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ from dataclasses import asdict, dataclass, field
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from platformdirs import user_config_dir
35
+
36
+
37
+ def env_candidates() -> list[Path]:
38
+ """Where :func:`load_env` looks. ``NEWSSCORE_ENV``, when set, is the only candidate."""
39
+ override = os.environ.get("NEWSSCORE_ENV")
40
+ if override:
41
+ return [Path(override).expanduser()]
42
+ return [Path.cwd() / ".env", Path(user_config_dir("newsscore")) / ".env"]
43
+
44
+
45
+ def parse_env(text: str) -> dict[str, str]:
46
+ """Parse ``KEY=VALUE`` lines. Supports ``export KEY=...``, quotes and ``#`` comments."""
47
+ values: dict[str, str] = {}
48
+ for raw in text.splitlines():
49
+ line = raw.strip()
50
+ if not line or line.startswith("#") or "=" not in line:
51
+ continue
52
+ if line.startswith("export "):
53
+ line = line[len("export "):]
54
+ key, _, value = line.partition("=")
55
+ key, value = key.strip(), value.strip()
56
+ if not key:
57
+ continue
58
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
59
+ value = value[1:-1]
60
+ elif value.startswith("#"):
61
+ value = "" # blank value followed by a comment
62
+ else:
63
+ value = value.split(" #", 1)[0].rstrip()
64
+ values[key] = value
65
+ return values
66
+
67
+
68
+ def load_env(path: Path | str | None = None, *, override: bool = False) -> Path | None:
69
+ """Load the first ``.env`` found into ``os.environ``. Returns the path used, or ``None``.
70
+
71
+ Empty values are skipped so a template with blank keys is harmless. Existing
72
+ environment variables win unless ``override=True``.
73
+ """
74
+ candidates = [Path(path).expanduser()] if path else env_candidates()
75
+ for candidate in candidates:
76
+ if not candidate.is_file():
77
+ continue
78
+ for key, value in parse_env(candidate.read_text(encoding="utf-8")).items():
79
+ if value and (override or key not in os.environ):
80
+ os.environ[key] = value
81
+ return candidate
82
+ return None
83
+
84
+
85
+ def config_path() -> Path:
86
+ override = os.environ.get("NEWSSCORE_CONFIG")
87
+ if override:
88
+ return Path(override).expanduser()
89
+ return Path(user_config_dir("newsscore")) / "sources.json"
90
+
91
+
92
+ @dataclass(slots=True)
93
+ class SourceSpec:
94
+ type: str
95
+ name: str
96
+ api_key: str | None = None
97
+ options: dict[str, Any] = field(default_factory=dict)
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ data = asdict(self)
101
+ data.pop("name")
102
+ return data
103
+
104
+
105
+ class SourceStore:
106
+ """Read and write the JSON file. Every method reloads from disk, so the store
107
+ is always consistent with what other processes wrote."""
108
+
109
+ def __init__(self, path: Path | str | None = None) -> None:
110
+ self.path = Path(path) if path else config_path()
111
+
112
+ def load(self) -> dict[str, SourceSpec]:
113
+ if not self.path.exists():
114
+ return {}
115
+ with self.path.open("r", encoding="utf-8") as fh:
116
+ data = json.load(fh) or {}
117
+ specs: dict[str, SourceSpec] = {}
118
+ for name, item in (data.get("sources") or {}).items():
119
+ specs[name] = SourceSpec(
120
+ type=item["type"],
121
+ name=name,
122
+ api_key=item.get("api_key"),
123
+ options=dict(item.get("options") or {}),
124
+ )
125
+ return specs
126
+
127
+ def save(self, specs: dict[str, SourceSpec]) -> None:
128
+ self.path.parent.mkdir(parents=True, exist_ok=True)
129
+ payload = {"sources": {name: spec.to_dict() for name, spec in specs.items()}}
130
+ tmp = self.path.with_suffix(".tmp")
131
+ with tmp.open("w", encoding="utf-8") as fh:
132
+ json.dump(payload, fh, indent=2)
133
+ os.replace(tmp, self.path)
134
+ try: # keys live here; tighten permissions where the OS honours them
135
+ os.chmod(self.path, 0o600)
136
+ except OSError:
137
+ pass
138
+
139
+ def add(self, spec: SourceSpec, *, replace: bool = False) -> None:
140
+ specs = self.load()
141
+ if spec.name in specs and not replace:
142
+ raise KeyError(f"source {spec.name!r} already exists (use replace)")
143
+ specs[spec.name] = spec
144
+ self.save(specs)
145
+
146
+ def remove(self, name: str) -> None:
147
+ specs = self.load()
148
+ if name not in specs:
149
+ raise KeyError(f"no source named {name!r}")
150
+ del specs[name]
151
+ self.save(specs)