youpdated 0.1.0__tar.gz

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.
Files changed (41) hide show
  1. youpdated-0.1.0/ADDING_SOURCES.md +333 -0
  2. youpdated-0.1.0/CHANGELOG.md +36 -0
  3. youpdated-0.1.0/CONTRIBUTING.md +71 -0
  4. youpdated-0.1.0/LICENSE +21 -0
  5. youpdated-0.1.0/MANIFEST.in +11 -0
  6. youpdated-0.1.0/PKG-INFO +452 -0
  7. youpdated-0.1.0/README.md +410 -0
  8. youpdated-0.1.0/SECURITY.md +38 -0
  9. youpdated-0.1.0/pyproject.toml +72 -0
  10. youpdated-0.1.0/setup.cfg +4 -0
  11. youpdated-0.1.0/youpdated/__init__.py +3 -0
  12. youpdated-0.1.0/youpdated/__main__.py +6 -0
  13. youpdated-0.1.0/youpdated/cleanup.py +117 -0
  14. youpdated-0.1.0/youpdated/cli.py +251 -0
  15. youpdated-0.1.0/youpdated/config.py +214 -0
  16. youpdated-0.1.0/youpdated/http.py +216 -0
  17. youpdated-0.1.0/youpdated/models.py +63 -0
  18. youpdated-0.1.0/youpdated/py.typed +0 -0
  19. youpdated-0.1.0/youpdated/registry.py +49 -0
  20. youpdated-0.1.0/youpdated/render/__init__.py +5 -0
  21. youpdated-0.1.0/youpdated/render/json_out.py +29 -0
  22. youpdated-0.1.0/youpdated/render/rss_out.py +58 -0
  23. youpdated-0.1.0/youpdated/render/terminal.py +142 -0
  24. youpdated-0.1.0/youpdated/runner.py +134 -0
  25. youpdated-0.1.0/youpdated/sources/__init__.py +5 -0
  26. youpdated-0.1.0/youpdated/sources/base.py +44 -0
  27. youpdated-0.1.0/youpdated/sources/browser.py +397 -0
  28. youpdated-0.1.0/youpdated/sources/feed.py +111 -0
  29. youpdated-0.1.0/youpdated/sources/generic.py +76 -0
  30. youpdated-0.1.0/youpdated/sources/github.py +158 -0
  31. youpdated-0.1.0/youpdated/sources/itch.py +210 -0
  32. youpdated-0.1.0/youpdated/sources/npm.py +88 -0
  33. youpdated-0.1.0/youpdated/sources/steam.py +88 -0
  34. youpdated-0.1.0/youpdated/sources/youtube.py +335 -0
  35. youpdated-0.1.0/youpdated/state.py +154 -0
  36. youpdated-0.1.0/youpdated.egg-info/PKG-INFO +452 -0
  37. youpdated-0.1.0/youpdated.egg-info/SOURCES.txt +39 -0
  38. youpdated-0.1.0/youpdated.egg-info/dependency_links.txt +1 -0
  39. youpdated-0.1.0/youpdated.egg-info/entry_points.txt +2 -0
  40. youpdated-0.1.0/youpdated.egg-info/requires.txt +13 -0
  41. youpdated-0.1.0/youpdated.egg-info/top_level.txt +1 -0
@@ -0,0 +1,333 @@
1
+ # Adding your own source
2
+
3
+ A source is one class with two methods. This guide builds a complete, working one: a `pypi` source that reports new releases of a Python package.
4
+
5
+ - [The contract](#the-contract)
6
+ - [Two ways to add one](#two-ways-to-add-one)
7
+ - [Step 1: normalize config into targets](#step-1-normalize-config-into-targets)
8
+ - [Step 2: fetch updates](#step-2-fetch-updates)
9
+ - [The complete source](#the-complete-source)
10
+ - [Notes](#notes)
11
+ - [Testing it](#testing-it)
12
+ - [Shipping it as a package](#shipping-it-as-a-package)
13
+ - [Checklist](#checklist)
14
+
15
+ ## The contract
16
+
17
+ From [youpdated/sources/base.py](youpdated/sources/base.py):
18
+
19
+ ```python
20
+ class Source(Protocol):
21
+ name: ClassVar[str] # the key users write under `sources:` in config
22
+ summary: ClassVar[str] # one line shown by `youpdated sources`
23
+
24
+ def targets(self, entries: list[Any]) -> list[Target]:
25
+ """Normalize raw config entries into targets."""
26
+
27
+ def fetch(self, target: Target, client: Client) -> Iterable[Update]:
28
+ """Fetch current items for one target."""
29
+ ```
30
+
31
+ The split matters: `targets()` is pure config parsing and never touches the network, so `youpdated check --test` can validate a config without sending a request. `fetch()` does the network work and is called once per target, possibly on several threads at once.
32
+
33
+ ## Two ways to add one
34
+
35
+ **In-tree** for a source you want in the project itself:
36
+
37
+ 1. Create `youpdated/sources/yourname.py`
38
+ 2. Decorate the class with `@register`
39
+ 3. Import it in [youpdated/sources/\_\_init\_\_.py](youpdated/sources/__init__.py)
40
+
41
+ **As a separate package**: for a source you want to distribute without forking. Declare a
42
+ `youpdated.sources` entry point and Youpdated finds it automatically; see
43
+ [Shipping it as a package](#shipping-it-as-a-package).
44
+
45
+ ## Step 1: normalize config into targets
46
+
47
+ Every source accepts two config shapes: a bare value and a mapping:
48
+
49
+ ```yaml
50
+ sources:
51
+ pypi:
52
+ - httpx # bare
53
+ - package: rich # mapping
54
+ name: Rich
55
+ ```
56
+
57
+ `entry_fields()` collapses both into a dict, so you never branch on the shape yourself:
58
+
59
+ ```python
60
+ from youpdated.models import Target
61
+ from youpdated.sources.base import ConfigEntryError, entry_fields, require
62
+
63
+
64
+ def targets(self, entries: list[Any]) -> list[Target]:
65
+ targets = []
66
+ for entry in entries:
67
+ # "httpx" becomes {"package": "httpx"}; a mapping passes through.
68
+ fields = entry_fields(entry, "package", self.name)
69
+ package = str(require(fields, "package", self.name)).strip()
70
+ if "/" in package or not package:
71
+ raise ConfigEntryError(f"sources.{self.name}: `{package}` is not a package name")
72
+ targets.append(
73
+ Target(source=self.name, key=package, label=fields.get("name"), params={})
74
+ )
75
+ return targets
76
+ ```
77
+
78
+ A `Target` has four fields:
79
+
80
+ | Field | Purpose |
81
+ | --- | --- |
82
+ | `source` | Your source's `name`. |
83
+ | `key` | **Stable identity.** Scopes deduplication and appears in reports. Never let it vary between runs for the same thing. |
84
+ | `label` | Optional friendly name. Falls back to `key`. May be filled in during `fetch()` once you learn it. |
85
+ | `params` | Anything `fetch()` needs (resolved URLs, watch lists, channel names) |
86
+
87
+ Raise `ConfigEntryError` for anything you can't parse. The message is shown to the user verbatim,
88
+ so name the offending value and say what was expected.
89
+
90
+ ## Step 2: fetch updates
91
+
92
+ `fetch()` gets one target and the shared `Client`. **Always use that client** never `httpx` or `requests` directly. It applies the user's proxy, rotates user agents, paces requests per host, and clears cookies.
93
+
94
+ ```python
95
+ def fetch(self, target: Target, client: Client) -> Iterable[Update]:
96
+ fetched = client.get(f"https://pypi.org/pypi/{target.key}/json", conditional=True)
97
+ if fetched is None: # 304 Not Modified, or --test mode
98
+ return []
99
+ doc = fetched.json()
100
+ ...
101
+ ```
102
+
103
+ `client.get()` returns a `Fetched` (with `.content`, `.text`, `.json()`, `.status`) or `None`.
104
+ Useful arguments:
105
+
106
+ | Argument | Use for |
107
+ | --- | --- |
108
+ | `conditional=True` | Send ETag/Last-Modified and get `None` on a 304. Use it for anything polled repeatedly. |
109
+ | `soft_statuses=(404,)` | Return the response instead of raising, when a status is expected. itch answers 404 for a game with no devlog. |
110
+ | `headers={...}` | Extra request headers. The user agent is added for you. |
111
+ | `retries=0` | Skip retries when you're probing a fallback and want to fail fast. |
112
+
113
+ Any other non-200 raises `FetchError`, which the runner catches and reports without killing.
114
+
115
+ If the feed is RSS or Atom, don't parse it yourself:
116
+
117
+ ```python
118
+ from youpdated.sources.feed import parse_feed
119
+
120
+ return parse_feed(fetched.content, source=self.name, target=target.key, tags=("release",))
121
+ ```
122
+
123
+ ## The complete source
124
+
125
+ ```python
126
+ """A example Youpdated source for PyPI package releases."""
127
+
128
+ from __future__ import annotations
129
+
130
+ from datetime import datetime, timezone
131
+ from typing import Any, ClassVar, Iterable
132
+
133
+ from youpdated.http import Client
134
+ from youpdated.models import Target, Update
135
+ from youpdated.registry import register
136
+ from youpdated.sources.base import ConfigEntryError, entry_fields, require
137
+
138
+ MAX_VERSIONS = 10
139
+
140
+
141
+ @register
142
+ class PyPISource:
143
+ name: ClassVar[str] = "pypi"
144
+ summary: ClassVar[str] = "New releases of a PyPI package"
145
+
146
+ def targets(self, entries: list[Any]) -> list[Target]:
147
+ targets = []
148
+ for entry in entries:
149
+ fields = entry_fields(entry, "package", self.name)
150
+ package = str(require(fields, "package", self.name)).strip()
151
+ if "/" in package or not package:
152
+ raise ConfigEntryError(f"sources.{self.name}: `{package}` is not a package name")
153
+ targets.append(
154
+ Target(source=self.name, key=package, label=fields.get("name"), params={})
155
+ )
156
+ return targets
157
+
158
+ def fetch(self, target: Target, client: Client) -> Iterable[Update]:
159
+ fetched = client.get(f"https://pypi.org/pypi/{target.key}/json", conditional=True)
160
+ if fetched is None:
161
+ return []
162
+
163
+ doc = fetched.json()
164
+ latest = doc["info"]["version"]
165
+
166
+ dated = []
167
+ for version, files in (doc.get("releases") or {}).items():
168
+ if not files or all(f.get("yanked") for f in files):
169
+ continue
170
+ dated.append((version, _parse_iso(files[0].get("upload_time_iso_8601"))))
171
+ dated.sort(
172
+ key=lambda pair: pair[1] or datetime.min.replace(tzinfo=timezone.utc), reverse=True
173
+ )
174
+
175
+ return [
176
+ Update(
177
+ source=self.name,
178
+ target=target.key,
179
+ uid=f"version:{version}",
180
+ title=f"{target.key} {version}",
181
+ url=f"https://pypi.org/project/{target.key}/{version}/",
182
+ published=published,
183
+ version=version,
184
+ body=doc["info"].get("summary"),
185
+ tags=("release",) + (("latest",) if version == latest else ()),
186
+ )
187
+ for version, published in dated[:MAX_VERSIONS]
188
+ ]
189
+
190
+
191
+ def _parse_iso(value: str | None) -> datetime | None:
192
+ if not value:
193
+ return None
194
+ try:
195
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
196
+ except ValueError:
197
+ return None
198
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
199
+ ```
200
+
201
+ ## Notes
202
+
203
+ ### The uid is the deduplication system
204
+
205
+ `uid` decides whether an item is "new".
206
+
207
+ - **Stable** across runs for the same item. Derive it from an upstream id, version, or permalink.
208
+ - **Don't** derive it from anything that changes on its own: a timestamp, a position in a list, a relative date, a view count, `hash()` (randomized per process).
209
+ - Unique **within a target** only. Deduplication is scoped to `(source, target, uid)`, so two packages can both use `version:1.0`.
210
+
211
+ ```python
212
+ uid=f"version:{version}" # good: stable, tied to the item
213
+ uid=f"{datetime.now()}" # broken: new every run, reports forever
214
+ uid=str(index) # broken: shifts as items are added
215
+ ```
216
+
217
+ When an upstream gives you no id at all, fingerprint the content: hash the fields that define the item. [itch.py](youpdated/sources/itch.py) does this for game builds: filenames, sizes, and the update timestamp hash into one uid.
218
+
219
+ ### Filling in a label during fetch
220
+
221
+ Sometimes the friendly name is only available from the response. Assign it to `target.label`; the renderers pick it up:
222
+
223
+ ```python
224
+ if not target.label:
225
+ target.label = doc["info"].get("name")
226
+ ```
227
+
228
+ ### Caching a resolved value
229
+
230
+ `client.state` is a small key-value store for things that are expensive to resolve and never change: the Steam source caches appid→name there, so it looks it up once ever:
231
+
232
+ ```python
233
+ cached = client.state.cache_get("my_namespace", key) if client.state else None
234
+ if cached is None:
235
+ cached = expensive_lookup()
236
+ if client.state:
237
+ client.state.cache_set("my_namespace", key, cached)
238
+ ```
239
+
240
+ `client.state` may be `None`
241
+
242
+ ### Thread
243
+
244
+ Targets are fetched concurrently. Keep everything in local variables or on the `Target`; don't mutate shared state on the source instance.
245
+
246
+ ## Testing it
247
+
248
+ The suite is offline. `respx` intercepts requests and fails on anything unmocked.
249
+
250
+ Save a real payload as a fixture:
251
+
252
+ ```sh
253
+ curl -s https://pypi.org/pypi/httpx/json -o tests/fixtures/pypi_httpx.json
254
+ ```
255
+
256
+ Then test against it. The `client` fixture from [tests/conftest.py](tests/conftest.py) gives you a zero-jitter client with in-memory state:
257
+
258
+ ```python
259
+ import httpx
260
+ import respx
261
+
262
+ from youpdated.registry import get_source
263
+ from .conftest import fixture
264
+
265
+
266
+ @respx.mock
267
+ def test_pypi_releases(client):
268
+ respx.get("https://pypi.org/pypi/httpx/json").mock(
269
+ return_value=httpx.Response(200, content=fixture("pypi_httpx.json"))
270
+ )
271
+ source = get_source("pypi")
272
+ (target,) = source.targets(["httpx"])
273
+ updates = list(source.fetch(target, client))
274
+
275
+ assert updates
276
+ assert all(u.uid == f"version:{u.version}" for u in updates)
277
+ assert sum("latest" in u.tags for u in updates) == 1
278
+ dated = [u.published for u in updates if u.published]
279
+ assert dated == sorted(dated, reverse=True) # newest first
280
+ ```
281
+
282
+ Worth covering explicitly:
283
+
284
+ - Both config shapes normalize to the same `key`
285
+ - Bad entries raise `ConfigEntryError`
286
+ - Uids are stable: fetch the same payload twice, compare uids
287
+ - Any expected-error path (a soft 404 returning `[]` rather than raising)
288
+
289
+ Run with `PYTHONPATH=$PWD .venv/bin/python -m pytest` (on Windows,
290
+ `$env:PYTHONPATH=$PWD; .venv\Scripts\python -m pytest`).
291
+
292
+ ## Shipping it as a package
293
+
294
+ Youpdated discovers sources through the `youpdated.sources` entry point group, so a plugin needs
295
+ no changes to this repo:
296
+
297
+ ```toml
298
+ # pyproject.toml of your plugin package
299
+ [project]
300
+ name = "youpdated-pypi"
301
+ version = "0.1.0"
302
+ dependencies = ["youpdated"]
303
+
304
+ [project.entry-points."youpdated.sources"]
305
+ pypi = "youpdated_pypi:PyPISource"
306
+ ```
307
+
308
+ Install it alongside Youpdated and it appears immediately:
309
+
310
+ ```console
311
+ $ pip install youpdated-pypi
312
+ $ youpdated sources
313
+ ...
314
+ pypi New releases of a PyPI package
315
+ ```
316
+
317
+ Users then configure it like any built-in source. A plugin that fails to import is skipped.
318
+
319
+ ## Checklist
320
+
321
+ - [ ] `name` and `summary` set as `ClassVar`s
322
+ - [ ] `targets()` does no network I/O
323
+ - [ ] Both config shapes accepted via `entry_fields()`
324
+ - [ ] Bad config raises `ConfigEntryError` naming the bad value
325
+ - [ ] `key` is stable across runs
326
+ - [ ] `uid` is stable and derived from the item, never from the clock or list position
327
+ - [ ] All requests go through `client.get()`
328
+ - [ ] `conditional=True` on anything polled repeatedly
329
+ - [ ] Expected non-200s handled with `soft_statuses`; real failures left to raise
330
+ - [ ] Returns `[]` rather than inventing an update
331
+ - [ ] `published` is timezone-aware UTC (or `None`)
332
+ - [ ] Tests cover parsing, both config shapes, and uid stability
333
+ - [ ] Registered — `@register` plus an import, or an entry point
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.1.0] — 2026-08-18
8
+
9
+ First release.
10
+
11
+ ### Added
12
+
13
+ - **Seven sources**, all working without accounts or API keys:
14
+ - `github` — releases, tags, and commits via `.atom` feeds
15
+ - `npm` — newly published versions from public registry
16
+ - `steam` — patch notes and news; resolves the store name from a bare appid
17
+ - `itch` — devlog posts and new builds, fingerprinted from the game page
18
+ - `youtube` — channels and playlists, with Invidious and Data API fallback
19
+ - `browser` — Chrome, Brave, Firefox, and Edge releases across platforms and channels
20
+ - `feed` — any RSS/Atom URL, for apps without a dedicated source
21
+ - **Plugin architecture**: sources register in-tree with `@register` or ship from a third-party package through a `youpdated.sources` entry point.
22
+ - **Config**: every source entry takes a bare value for the common case or a
23
+ mapping for advanced options
24
+ - **Incremental reporting**: a SQLite history so each run reports what changed. First run records a baseline.
25
+ - **Three output formats**: terminal report, `--json`, and `--rss`
26
+ - **Privacy controls**: optional SOCKS/HTTP proxy covering every request, user-agent rotation, per-host request pacing with jitter, per-request cookie clearing, and conditional GETs. `--test` prints URLs without sending
27
+ - **`youpdated uninstall`** to remove every file from the tool. Refuses directories it didn't create or have other files.
28
+
29
+ ### Known issues
30
+
31
+ - YouTube's RSS endpoint throttles occassionaly and 404s valid URLs; fallback covers, but a run can still fail all three. Retry or set `privacy.proxy`.
32
+ - Some itch games publish no "Updated" timestamp, so build updates are reported undated.
33
+ - Firefox publishes current versions, so it reports one item per channel.
34
+ - Edge exposes release notes only for the stable and beta channels. (But like, it's Edge, why do you want to know when it updates?)
35
+
36
+ [0.1.0]: https://github.com/Void1-1/youpdated/releases/tag/v0.1.0
@@ -0,0 +1,71 @@
1
+ # Contributing
2
+
3
+ Thanks for looking. This is a small project with a few constraints.
4
+
5
+ ## The constraints
6
+
7
+ These are the reasons the tool exists, so a change that breaks one needs a strong argument:
8
+
9
+ 1. **No accounts, no required API keys.** Every source works unauthenticated. Keys are read from the environment when present, only to raise rate limits, and are never written to disk.
10
+ 2. **Nothing leaves the machine** except requests to the sources the user configured. No telemetry, no crash reporting, no update checks for Youpdated itself.
11
+ 3. **One HTTP path.** Every request goes through `client.get()` in [youpdated/http.py](youpdated/http.py).
12
+ 4. **Files stay in the platform's config and data directories.** Nowhere else.
13
+
14
+ ## Setting up
15
+
16
+ ```sh
17
+ python3 -m venv .venv
18
+ .venv/bin/pip install ".[dev]"
19
+ PYTHONPATH=$PWD .venv/bin/python -m pytest
20
+ ```
21
+
22
+ `PYTHONPATH=$PWD` makes your working tree take effect without reinstalling. On Windows:
23
+ `$env:PYTHONPATH=$PWD; .venv\Scripts\python -m pytest`.
24
+
25
+ ## Sending a change
26
+
27
+ `main` requires a pull request with passing CI:
28
+
29
+ ```sh
30
+ git switch -c my-change
31
+ # work, commit
32
+ git push -u origin my-change
33
+ gh pr create --fill
34
+ ```
35
+
36
+ CI runs the suite on Linux, macOS, and Windows across Python 3.11–3.14. All 13 jobs must be green, and the branch must be up to date with `main`. No review approvals are required.
37
+
38
+ ## Tests
39
+
40
+ The suite is **fully offline**. `respx` intercepts HTTP and fails on any unmocked request, so a green run proves the parsers work rather than that the network happened to be up. Keep it that way: capture a real payload into [tests/fixtures/](tests/fixtures/) and test against that.
41
+
42
+ ```sh
43
+ curl -s https://example.com/api/thing -o tests/fixtures/thing.json
44
+ ```
45
+
46
+ Trim large fixtures to the part that matters.
47
+
48
+ ## Adding a source
49
+
50
+ Read [ADDING_SOURCES.md](ADDING_SOURCES.md), it builds a complete working source.
51
+
52
+ ## Style
53
+
54
+ Match the surrounding code rather than a style guide. Concretely:
55
+
56
+ - Type annotations throughout, with `from __future__ import annotations`
57
+ - Comments should explain why, especially where the code looks odd because an upstream service is odd.
58
+ The Brave source reads the REST API instead of the atom feed for a reason and that reason is a comment.
59
+ - Errors that reach the user name the offending value and say what was expected
60
+ - A source raises on genuine failure: the runner catches it and reports without killing the run and returns `[]` when it's working but has nothing to report
61
+
62
+ ## Reporting things
63
+
64
+ Issue templates cover bugs, feature requests, and new-source requests. For security, see
65
+ [SECURITY.md](SECURITY.md) and use private vulnerability reporting rather than a public issue.
66
+
67
+ ## Use of AI
68
+
69
+ If you want to use AI to code for you or aid in your coding when contributing here, that's fine, just actually read it's output before submitting anything. No bloat or bad code is a goal you need to try to keep to.
70
+
71
+ Also, if you could acknowledge what AI did, that would be great!
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Void1-1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,11 @@
1
+ include LICENSE
2
+ include README.md
3
+ include CHANGELOG.md
4
+ include SECURITY.md
5
+ include CONTRIBUTING.md
6
+ include ADDING_SOURCES.md
7
+ include youpdated/py.typed
8
+
9
+ prune tests
10
+ prune .venv
11
+ global-exclude __pycache__ *.py[cod] *.sqlite3