youpdated 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.
- youpdated/__init__.py +3 -0
- youpdated/__main__.py +6 -0
- youpdated/cleanup.py +117 -0
- youpdated/cli.py +251 -0
- youpdated/config.py +214 -0
- youpdated/http.py +216 -0
- youpdated/models.py +63 -0
- youpdated/py.typed +0 -0
- youpdated/registry.py +49 -0
- youpdated/render/__init__.py +5 -0
- youpdated/render/json_out.py +29 -0
- youpdated/render/rss_out.py +58 -0
- youpdated/render/terminal.py +142 -0
- youpdated/runner.py +134 -0
- youpdated/sources/__init__.py +5 -0
- youpdated/sources/base.py +44 -0
- youpdated/sources/browser.py +397 -0
- youpdated/sources/feed.py +111 -0
- youpdated/sources/generic.py +76 -0
- youpdated/sources/github.py +158 -0
- youpdated/sources/itch.py +210 -0
- youpdated/sources/npm.py +88 -0
- youpdated/sources/steam.py +88 -0
- youpdated/sources/youtube.py +335 -0
- youpdated/state.py +154 -0
- youpdated-0.1.0.dist-info/METADATA +452 -0
- youpdated-0.1.0.dist-info/RECORD +31 -0
- youpdated-0.1.0.dist-info/WHEEL +5 -0
- youpdated-0.1.0.dist-info/entry_points.txt +2 -0
- youpdated-0.1.0.dist-info/licenses/LICENSE +21 -0
- youpdated-0.1.0.dist-info/top_level.txt +1 -0
youpdated/__init__.py
ADDED
youpdated/__main__.py
ADDED
youpdated/cleanup.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Finds and removes everything from Youpdated."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .config import (
|
|
10
|
+
DEFAULT_CONFIG_NAME,
|
|
11
|
+
config_dir,
|
|
12
|
+
data_dir,
|
|
13
|
+
default_config_path,
|
|
14
|
+
default_state_path,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
APP_DIR_NAME = "youpdated"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Trace:
|
|
22
|
+
"""Something Youpdated-based."""
|
|
23
|
+
|
|
24
|
+
path: Path
|
|
25
|
+
kind: str # "config" | "state" | "directory"
|
|
26
|
+
note: str
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def exists(self) -> bool:
|
|
30
|
+
return self.path.exists()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def find_traces(
|
|
34
|
+
config_override: str | Path | None = None,
|
|
35
|
+
state_override: str | Path | None = None,
|
|
36
|
+
*,
|
|
37
|
+
keep_config: bool = False,
|
|
38
|
+
) -> list[Trace]:
|
|
39
|
+
"""Every file and directory this tool may have created, existing now."""
|
|
40
|
+
traces: list[Trace] = []
|
|
41
|
+
seen: set[Path] = set()
|
|
42
|
+
|
|
43
|
+
def add(path: Path | None, kind: str, note: str) -> None:
|
|
44
|
+
if path is None:
|
|
45
|
+
return
|
|
46
|
+
resolved = Path(path).expanduser()
|
|
47
|
+
if resolved in seen or not resolved.exists():
|
|
48
|
+
return
|
|
49
|
+
seen.add(resolved)
|
|
50
|
+
traces.append(Trace(resolved, kind, note))
|
|
51
|
+
|
|
52
|
+
if not keep_config:
|
|
53
|
+
add(config_override, "config", "config file (--config)")
|
|
54
|
+
add(Path.cwd() / "youpdated.yaml", "config", "project config file")
|
|
55
|
+
add(Path.cwd() / "youpdated.yml", "config", "project config file")
|
|
56
|
+
add(default_config_path(), "config", "config file")
|
|
57
|
+
|
|
58
|
+
add(state_override, "state", "history database (--state)")
|
|
59
|
+
add(default_state_path(), "state", "history database")
|
|
60
|
+
|
|
61
|
+
# Dirs last
|
|
62
|
+
for directory in (config_dir(), data_dir()):
|
|
63
|
+
add(directory, "directory", "application directory (only if empty)")
|
|
64
|
+
|
|
65
|
+
return traces
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def remove_traces(traces: list[Trace]) -> tuple[list[Trace], list[tuple[Trace, str]]]:
|
|
69
|
+
"""Delete the given traces. Returns ``(removed, failed)``.
|
|
70
|
+
|
|
71
|
+
Files first, then directories, any additions to dirs that are unexpected donn't let them delete
|
|
72
|
+
"""
|
|
73
|
+
removed: list[Trace] = []
|
|
74
|
+
failed: list[tuple[Trace, str]] = []
|
|
75
|
+
|
|
76
|
+
files = [t for t in traces if t.kind != "directory"]
|
|
77
|
+
directories = [t for t in traces if t.kind == "directory"]
|
|
78
|
+
|
|
79
|
+
for trace in files:
|
|
80
|
+
try:
|
|
81
|
+
if trace.path.is_file():
|
|
82
|
+
trace.path.unlink()
|
|
83
|
+
removed.append(trace)
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
failed.append((trace, str(exc)))
|
|
86
|
+
|
|
87
|
+
for trace in directories:
|
|
88
|
+
path = trace.path
|
|
89
|
+
# it must be a youpdated directory and empty.
|
|
90
|
+
if path.name != APP_DIR_NAME:
|
|
91
|
+
failed.append((trace, "not a youpdated directory, aborted"))
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
if not path.is_dir():
|
|
95
|
+
continue
|
|
96
|
+
leftovers = list(path.iterdir())
|
|
97
|
+
if leftovers:
|
|
98
|
+
failed.append(
|
|
99
|
+
(trace, f"not empty ({len(leftovers)} other file(s)), aborted")
|
|
100
|
+
)
|
|
101
|
+
continue
|
|
102
|
+
path.rmdir()
|
|
103
|
+
removed.append(trace)
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
failed.append((trace, str(exc)))
|
|
106
|
+
|
|
107
|
+
return removed, failed
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def package_removal_command() -> str:
|
|
111
|
+
"""Command to remove the package."""
|
|
112
|
+
|
|
113
|
+
return f"{sys.executable} -m pip uninstall youpdated"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def config_filename() -> str:
|
|
117
|
+
return DEFAULT_CONFIG_NAME
|
youpdated/cli.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.markup import escape
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .cleanup import find_traces, package_removal_command, remove_traces
|
|
12
|
+
from .config import (
|
|
13
|
+
EXAMPLE_CONFIG,
|
|
14
|
+
Config,
|
|
15
|
+
ConfigError,
|
|
16
|
+
default_config_path,
|
|
17
|
+
default_state_path,
|
|
18
|
+
load_config,
|
|
19
|
+
)
|
|
20
|
+
from .http import Client
|
|
21
|
+
from .registry import all_sources
|
|
22
|
+
from .render import json_out, rss_out, terminal
|
|
23
|
+
from .runner import parse_since, run
|
|
24
|
+
from .state import State
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="youpdated",
|
|
30
|
+
description="Collect updates for the games, apps, and packages you follow.",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument("--version", action="version", version=f"youpdated {__version__}")
|
|
33
|
+
sub = parser.add_subparsers(dest="command")
|
|
34
|
+
|
|
35
|
+
check = sub.add_parser("check", help="fetch sources and report what's new (default)")
|
|
36
|
+
check.add_argument("-c", "--config", help="path to a config file")
|
|
37
|
+
check.add_argument(
|
|
38
|
+
"-s", "--source", action="append", dest="sources",
|
|
39
|
+
help="only check this source (repeatable)",
|
|
40
|
+
)
|
|
41
|
+
check.add_argument("--all", action="store_true", help="report everything, not just new items")
|
|
42
|
+
check.add_argument("--since", help="only items newer than a window. (Eg: 7d, 12h)")
|
|
43
|
+
check.add_argument("--json", action="store_true", help="emit JSON to stdout")
|
|
44
|
+
check.add_argument("--rss", metavar="FILE", help="write an aggregated Atom feed")
|
|
45
|
+
check.add_argument(
|
|
46
|
+
"--no-save", action="store_true",
|
|
47
|
+
help="do not record anything as seen (state untouched)",
|
|
48
|
+
)
|
|
49
|
+
check.add_argument(
|
|
50
|
+
"--test", action="store_true",
|
|
51
|
+
help="show what would have been requested",
|
|
52
|
+
)
|
|
53
|
+
check.add_argument("--state", help="path to the state database")
|
|
54
|
+
check.add_argument("-v", "--verbose", action="store_true", help="show request detail and bodies")
|
|
55
|
+
check.add_argument(
|
|
56
|
+
"--fail-on-error", action="store_true",
|
|
57
|
+
help="exit non-zero if any source failed",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
sub.add_parser("sources", help="list available sources")
|
|
61
|
+
|
|
62
|
+
init = sub.add_parser("init", help="write a starter config file")
|
|
63
|
+
init.add_argument("-c", "--config", help="where to write it")
|
|
64
|
+
init.add_argument("-f", "--force", action="store_true", help="overwrite an existing file")
|
|
65
|
+
|
|
66
|
+
uninstall = sub.add_parser(
|
|
67
|
+
"uninstall",
|
|
68
|
+
help="remove all Youpdated files",
|
|
69
|
+
)
|
|
70
|
+
uninstall.add_argument("-c", "--config", help="also remove this config file")
|
|
71
|
+
uninstall.add_argument("--state", help="also remove this state database")
|
|
72
|
+
uninstall.add_argument(
|
|
73
|
+
"--keep-config", action="store_true", help="delete history but keep config files"
|
|
74
|
+
)
|
|
75
|
+
uninstall.add_argument(
|
|
76
|
+
"--test", action="store_true", help="list removals without deleting"
|
|
77
|
+
)
|
|
78
|
+
uninstall.add_argument(
|
|
79
|
+
"-y", "--yes", action="store_true", help="skip confirmation"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return parser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_sources(console: Console) -> int:
|
|
86
|
+
console.print("[bold]Available sources[/]\n")
|
|
87
|
+
for name, source in sorted(all_sources().items()):
|
|
88
|
+
console.print(f" [cyan]{name:<10}[/] {getattr(source, 'summary', '')}")
|
|
89
|
+
console.print("\n[dim]Add entries under `sources:` in your config.[/]")
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def cmd_init(args: argparse.Namespace, console: Console) -> int:
|
|
94
|
+
path = Path(args.config).expanduser() if args.config else default_config_path()
|
|
95
|
+
if path.exists() and not args.force:
|
|
96
|
+
console.print(f"[yellow]{path} already exists.[/] Use --force to overwrite.")
|
|
97
|
+
return 1
|
|
98
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
path.write_text(EXAMPLE_CONFIG, encoding="utf-8")
|
|
100
|
+
console.print(f"Wrote starter config to [bold]{path}[/]")
|
|
101
|
+
console.print("[dim]Edit, then run `youpdated check`.[/]")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_uninstall(args: argparse.Namespace, console: Console) -> int:
|
|
106
|
+
traces = find_traces(args.config, args.state, keep_config=args.keep_config)
|
|
107
|
+
|
|
108
|
+
if not traces:
|
|
109
|
+
console.print("[dim]No Youpdated files found.[/]")
|
|
110
|
+
console.print(f"\nTo remove the package itself:\n [bold]{package_removal_command()}[/]")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
console.print("[bold]This will permanently delete:[/]\n")
|
|
114
|
+
for trace in traces:
|
|
115
|
+
console.print(f" [yellow]{escape(str(trace.path))}[/]")
|
|
116
|
+
console.print(f" [dim]{trace.note}[/]")
|
|
117
|
+
console.print()
|
|
118
|
+
|
|
119
|
+
if args.test:
|
|
120
|
+
console.print("[dim]--test: nothing was deleted.[/]")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
if not args.yes:
|
|
124
|
+
if not sys.stdin.isatty():
|
|
125
|
+
console.print(
|
|
126
|
+
"[red]Refusing to delete without confirmation.[/] "
|
|
127
|
+
"Re-run with [bold]--yes[/] (or [bold]--test[/] to preview)."
|
|
128
|
+
)
|
|
129
|
+
return 1
|
|
130
|
+
try:
|
|
131
|
+
answer = input("Delete these? Type 'yes' to confirm: ").strip().lower()
|
|
132
|
+
except (EOFError, KeyboardInterrupt):
|
|
133
|
+
answer = ""
|
|
134
|
+
if answer not in ("yes", "y"):
|
|
135
|
+
console.print("[dim]Cancelled. Nothing deleted.[/]")
|
|
136
|
+
return 1
|
|
137
|
+
|
|
138
|
+
removed, failed = remove_traces(traces)
|
|
139
|
+
|
|
140
|
+
for trace in removed:
|
|
141
|
+
console.print(f"[green]removed[/] {escape(str(trace.path))}")
|
|
142
|
+
for trace, reason in failed:
|
|
143
|
+
console.print(f"[yellow]kept[/] {escape(str(trace.path))} — {escape(reason)}")
|
|
144
|
+
|
|
145
|
+
console.print(
|
|
146
|
+
f"\n[bold]{len(removed)}[/] item(s) removed"
|
|
147
|
+
+ (f", [bold]{len(failed)}[/] left in place." if failed else ".")
|
|
148
|
+
)
|
|
149
|
+
console.print(
|
|
150
|
+
"\nRemoved all written files. To remove the package itself:\n"
|
|
151
|
+
f" [bold]{package_removal_command()}[/]"
|
|
152
|
+
)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def cmd_check(args: argparse.Namespace, console: Console) -> int:
|
|
157
|
+
# JSON goes to stdout, status chatter to stderr.
|
|
158
|
+
status = Console(stderr=True) if args.json else console
|
|
159
|
+
|
|
160
|
+
config: Config = load_config(args.config)
|
|
161
|
+
|
|
162
|
+
since = None
|
|
163
|
+
if args.since:
|
|
164
|
+
try:
|
|
165
|
+
since = parse_since(args.since)
|
|
166
|
+
except ValueError as exc:
|
|
167
|
+
raise ConfigError(str(exc)) from exc
|
|
168
|
+
|
|
169
|
+
unknown = set(args.sources or []) - set(all_sources())
|
|
170
|
+
if unknown:
|
|
171
|
+
raise ConfigError(
|
|
172
|
+
f"unknown source(s): {', '.join(sorted(unknown))}. "
|
|
173
|
+
"Run `youpdated sources` to see the list."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
state_path = Path(args.state).expanduser() if args.state else default_state_path()
|
|
177
|
+
|
|
178
|
+
with State(state_path) as state:
|
|
179
|
+
with Client(
|
|
180
|
+
config.privacy,
|
|
181
|
+
state,
|
|
182
|
+
test=args.test,
|
|
183
|
+
verbose=args.verbose or args.test,
|
|
184
|
+
use_conditional=not args.all,
|
|
185
|
+
logger=lambda msg: status.print(escape(msg), style="dim", highlight=False),
|
|
186
|
+
) as client:
|
|
187
|
+
if args.verbose or args.test:
|
|
188
|
+
status.print(f"[dim]config: {config.path}[/]")
|
|
189
|
+
status.print(f"[dim]state: {state_path}[/]")
|
|
190
|
+
status.print(f"[dim]client: {client.describe()}[/]")
|
|
191
|
+
|
|
192
|
+
result = run(
|
|
193
|
+
config,
|
|
194
|
+
state,
|
|
195
|
+
client,
|
|
196
|
+
only_sources=args.sources,
|
|
197
|
+
show_all=args.all,
|
|
198
|
+
since=since,
|
|
199
|
+
save=not (args.no_save or args.test),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if args.test:
|
|
203
|
+
status.print(
|
|
204
|
+
f"\n[dim]{len(client.requested_urls)} request(s) planned; "
|
|
205
|
+
"none sent.[/]"
|
|
206
|
+
)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
if args.json:
|
|
210
|
+
print(json_out.render(result))
|
|
211
|
+
else:
|
|
212
|
+
terminal.render(result, console=console, verbose=args.verbose)
|
|
213
|
+
|
|
214
|
+
if args.rss:
|
|
215
|
+
rss_path = Path(args.rss).expanduser()
|
|
216
|
+
rss_path.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
rss_path.write_text(rss_out.render(result), encoding="utf-8")
|
|
218
|
+
status.print(f"[dim]Wrote {len(result.updates)} entries to {rss_path}[/]")
|
|
219
|
+
|
|
220
|
+
if args.fail_on_error and result.errors:
|
|
221
|
+
return 2
|
|
222
|
+
return 0
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def main(argv: list[str] | None = None) -> int:
|
|
226
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
227
|
+
parser = build_parser()
|
|
228
|
+
|
|
229
|
+
# `youpdated` and `youpdated --json` = `check`.
|
|
230
|
+
if not argv or argv[0].startswith("-") and argv[0] not in ("-h", "--help", "--version"):
|
|
231
|
+
argv.insert(0, "check")
|
|
232
|
+
|
|
233
|
+
args = parser.parse_args(argv)
|
|
234
|
+
if args.command is None:
|
|
235
|
+
args = parser.parse_args(["check"])
|
|
236
|
+
|
|
237
|
+
console = Console()
|
|
238
|
+
try:
|
|
239
|
+
if args.command == "sources":
|
|
240
|
+
return cmd_sources(console)
|
|
241
|
+
if args.command == "init":
|
|
242
|
+
return cmd_init(args, console)
|
|
243
|
+
if args.command == "uninstall":
|
|
244
|
+
return cmd_uninstall(args, console)
|
|
245
|
+
return cmd_check(args, console)
|
|
246
|
+
except ConfigError as exc:
|
|
247
|
+
console.print(f"[red]Config error:[/] {exc}")
|
|
248
|
+
return 1
|
|
249
|
+
except KeyboardInterrupt:
|
|
250
|
+
console.print("\n[dim]interrupted[/]")
|
|
251
|
+
return 130
|
youpdated/config.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Config load validation.
|
|
2
|
+
|
|
3
|
+
A source entry either bare scalar or a mapping
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from platformdirs import PlatformDirs
|
|
14
|
+
|
|
15
|
+
# appauthor=False for Windows: left unset, platformdirs falls back to app name as the author and nests everything under
|
|
16
|
+
# AppData\Local\youpdated\youpdated. macOS and Linux are unaffected.
|
|
17
|
+
_DIRS = PlatformDirs("youpdated", appauthor=False)
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG_NAME = "config.yaml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(Exception):
|
|
23
|
+
"""Raised with a message meant to be shown to the user."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PrivacyConfig:
|
|
28
|
+
proxy: str | None = None
|
|
29
|
+
user_agent: str = "rotate"
|
|
30
|
+
jitter: tuple[float, float] = (0.5, 3.0)
|
|
31
|
+
concurrency: int = 4
|
|
32
|
+
timeout: float = 20.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Config:
|
|
37
|
+
privacy: PrivacyConfig = field(default_factory=PrivacyConfig)
|
|
38
|
+
sources: dict[str, list[Any]] = field(default_factory=dict)
|
|
39
|
+
path: Path | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def config_dir() -> Path:
|
|
43
|
+
return Path(_DIRS.user_config_dir)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def data_dir() -> Path:
|
|
47
|
+
return Path(_DIRS.user_data_dir)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def default_config_path() -> Path:
|
|
51
|
+
return config_dir() / DEFAULT_CONFIG_NAME
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def default_state_path() -> Path:
|
|
55
|
+
return data_dir() / "state.sqlite3"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_config(explicit: str | Path | None = None) -> Path | None:
|
|
59
|
+
"""Resolve the config file to use: --config, then ./youpdated.yaml, then XDG."""
|
|
60
|
+
if explicit:
|
|
61
|
+
path = Path(explicit).expanduser()
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
raise ConfigError(f"config file not found: {path}")
|
|
64
|
+
return path
|
|
65
|
+
for candidate in (
|
|
66
|
+
Path.cwd() / "youpdated.yaml",
|
|
67
|
+
Path.cwd() / "youpdated.yml",
|
|
68
|
+
default_config_path(),
|
|
69
|
+
):
|
|
70
|
+
if candidate.is_file():
|
|
71
|
+
return candidate
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_config(explicit: str | Path | None = None) -> Config:
|
|
76
|
+
path = find_config(explicit)
|
|
77
|
+
if path is None:
|
|
78
|
+
raise ConfigError(
|
|
79
|
+
"no config file found. Run `youpdated init` to create one at "
|
|
80
|
+
f"{default_config_path()}"
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
84
|
+
except yaml.YAMLError as exc:
|
|
85
|
+
raise ConfigError(f"{path}: invalid YAML: {exc}") from exc
|
|
86
|
+
return parse_config(raw, path=path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_config(raw: Any, path: Path | None = None) -> Config:
|
|
90
|
+
where = f"{path}: " if path else ""
|
|
91
|
+
if not isinstance(raw, dict):
|
|
92
|
+
raise ConfigError(f"{where}top level must be a mapping")
|
|
93
|
+
|
|
94
|
+
privacy = _parse_privacy(raw.get("privacy") or {}, where)
|
|
95
|
+
|
|
96
|
+
sources_raw = raw.get("sources")
|
|
97
|
+
if sources_raw is None:
|
|
98
|
+
raise ConfigError(f"{where}missing required `sources:` section")
|
|
99
|
+
if not isinstance(sources_raw, dict):
|
|
100
|
+
raise ConfigError(f"{where}`sources` must be a mapping of source name -> list")
|
|
101
|
+
|
|
102
|
+
sources: dict[str, list[Any]] = {}
|
|
103
|
+
for name, entries in sources_raw.items():
|
|
104
|
+
if entries is None:
|
|
105
|
+
continue
|
|
106
|
+
if not isinstance(entries, list):
|
|
107
|
+
raise ConfigError(f"{where}`sources.{name}` must be a list")
|
|
108
|
+
if entries:
|
|
109
|
+
sources[str(name)] = entries
|
|
110
|
+
|
|
111
|
+
if not sources:
|
|
112
|
+
raise ConfigError(f"{where}`sources` has no entries — nothing to check")
|
|
113
|
+
|
|
114
|
+
return Config(privacy=privacy, sources=sources, path=path)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _parse_privacy(raw: Any, where: str) -> PrivacyConfig:
|
|
118
|
+
if not isinstance(raw, dict):
|
|
119
|
+
raise ConfigError(f"{where}`privacy` must be a mapping")
|
|
120
|
+
|
|
121
|
+
privacy = PrivacyConfig()
|
|
122
|
+
|
|
123
|
+
proxy = raw.get("proxy")
|
|
124
|
+
if proxy is not None:
|
|
125
|
+
if not isinstance(proxy, str) or "://" not in proxy:
|
|
126
|
+
raise ConfigError(
|
|
127
|
+
f"{where}`privacy.proxy` must be a URL like socks5://127.0.0.1:9050"
|
|
128
|
+
# tor as example across project btw
|
|
129
|
+
)
|
|
130
|
+
privacy.proxy = proxy
|
|
131
|
+
|
|
132
|
+
ua = raw.get("user_agent")
|
|
133
|
+
if ua is not None:
|
|
134
|
+
if not isinstance(ua, str) or not ua.strip():
|
|
135
|
+
raise ConfigError(f"{where}`privacy.user_agent` must be 'rotate' or a UA string")
|
|
136
|
+
privacy.user_agent = ua
|
|
137
|
+
|
|
138
|
+
jitter = raw.get("jitter")
|
|
139
|
+
if jitter is not None:
|
|
140
|
+
if (
|
|
141
|
+
not isinstance(jitter, (list, tuple))
|
|
142
|
+
or len(jitter) != 2
|
|
143
|
+
or not all(isinstance(v, (int, float)) and v >= 0 for v in jitter)
|
|
144
|
+
):
|
|
145
|
+
raise ConfigError(f"{where}`privacy.jitter` must be [min, max] non-negative seconds")
|
|
146
|
+
low, high = float(jitter[0]), float(jitter[1])
|
|
147
|
+
if low > high:
|
|
148
|
+
raise ConfigError(f"{where}`privacy.jitter` min must not exceed max")
|
|
149
|
+
privacy.jitter = (low, high)
|
|
150
|
+
|
|
151
|
+
for name, cast, check in (
|
|
152
|
+
("concurrency", int, lambda v: v >= 1),
|
|
153
|
+
("timeout", float, lambda v: v > 0),
|
|
154
|
+
):
|
|
155
|
+
value = raw.get(name)
|
|
156
|
+
if value is None:
|
|
157
|
+
continue
|
|
158
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or not check(cast(value)):
|
|
159
|
+
raise ConfigError(f"{where}`privacy.{name}` must be a positive number")
|
|
160
|
+
setattr(privacy, name, cast(value))
|
|
161
|
+
|
|
162
|
+
return privacy
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
EXAMPLE_CONFIG = """\
|
|
166
|
+
# youpdated config: every source entry is a bare value or a mapping.
|
|
167
|
+
|
|
168
|
+
privacy:
|
|
169
|
+
# Route every request through a proxy. Works with Tor's default SOCKS port.
|
|
170
|
+
# proxy: socks5://127.0.0.1:9050
|
|
171
|
+
user_agent: rotate # 'rotate' or a fixed UA string
|
|
172
|
+
jitter: [0.5, 3.0] # random delay, in seconds, between hits on one host
|
|
173
|
+
concurrency: 4
|
|
174
|
+
timeout: 20
|
|
175
|
+
|
|
176
|
+
sources:
|
|
177
|
+
github:
|
|
178
|
+
- python/cpython
|
|
179
|
+
- repo: astral-sh/uv
|
|
180
|
+
watch: [releases, commits]
|
|
181
|
+
|
|
182
|
+
npm:
|
|
183
|
+
- express
|
|
184
|
+
- "@types/node"
|
|
185
|
+
|
|
186
|
+
steam:
|
|
187
|
+
- 440
|
|
188
|
+
- appid: 730
|
|
189
|
+
name: Counter-Strike 2
|
|
190
|
+
|
|
191
|
+
itch:
|
|
192
|
+
# Watches devlog posts and new builds; most games have one.
|
|
193
|
+
- https://hempuli.itch.io/baba-is-you
|
|
194
|
+
- url: https://aak581.itch.io/engineering-marvels-from-hell
|
|
195
|
+
watch: [devlog, releases]
|
|
196
|
+
|
|
197
|
+
youtube:
|
|
198
|
+
- "@NASA"
|
|
199
|
+
# - playlist: PLxxxxxxxxxxxxxxxx
|
|
200
|
+
|
|
201
|
+
browser:
|
|
202
|
+
- chrome
|
|
203
|
+
- brave
|
|
204
|
+
- firefox
|
|
205
|
+
- browser: edge
|
|
206
|
+
platform: windows
|
|
207
|
+
channel: beta
|
|
208
|
+
|
|
209
|
+
# Anything else with a RSS/Atom feed
|
|
210
|
+
feed:
|
|
211
|
+
- https://blog.rust-lang.org/feed.xml
|
|
212
|
+
- url: https://github.com/obsidianmd/obsidian-releases/releases.atom
|
|
213
|
+
name: Obsidian
|
|
214
|
+
"""
|