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 ADDED
@@ -0,0 +1,3 @@
1
+ """Youpdated, a simple update tracker."""
2
+
3
+ __version__ = "0.1.0"
youpdated/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
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
+ """