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.
- youpdated-0.1.0/ADDING_SOURCES.md +333 -0
- youpdated-0.1.0/CHANGELOG.md +36 -0
- youpdated-0.1.0/CONTRIBUTING.md +71 -0
- youpdated-0.1.0/LICENSE +21 -0
- youpdated-0.1.0/MANIFEST.in +11 -0
- youpdated-0.1.0/PKG-INFO +452 -0
- youpdated-0.1.0/README.md +410 -0
- youpdated-0.1.0/SECURITY.md +38 -0
- youpdated-0.1.0/pyproject.toml +72 -0
- youpdated-0.1.0/setup.cfg +4 -0
- youpdated-0.1.0/youpdated/__init__.py +3 -0
- youpdated-0.1.0/youpdated/__main__.py +6 -0
- youpdated-0.1.0/youpdated/cleanup.py +117 -0
- youpdated-0.1.0/youpdated/cli.py +251 -0
- youpdated-0.1.0/youpdated/config.py +214 -0
- youpdated-0.1.0/youpdated/http.py +216 -0
- youpdated-0.1.0/youpdated/models.py +63 -0
- youpdated-0.1.0/youpdated/py.typed +0 -0
- youpdated-0.1.0/youpdated/registry.py +49 -0
- youpdated-0.1.0/youpdated/render/__init__.py +5 -0
- youpdated-0.1.0/youpdated/render/json_out.py +29 -0
- youpdated-0.1.0/youpdated/render/rss_out.py +58 -0
- youpdated-0.1.0/youpdated/render/terminal.py +142 -0
- youpdated-0.1.0/youpdated/runner.py +134 -0
- youpdated-0.1.0/youpdated/sources/__init__.py +5 -0
- youpdated-0.1.0/youpdated/sources/base.py +44 -0
- youpdated-0.1.0/youpdated/sources/browser.py +397 -0
- youpdated-0.1.0/youpdated/sources/feed.py +111 -0
- youpdated-0.1.0/youpdated/sources/generic.py +76 -0
- youpdated-0.1.0/youpdated/sources/github.py +158 -0
- youpdated-0.1.0/youpdated/sources/itch.py +210 -0
- youpdated-0.1.0/youpdated/sources/npm.py +88 -0
- youpdated-0.1.0/youpdated/sources/steam.py +88 -0
- youpdated-0.1.0/youpdated/sources/youtube.py +335 -0
- youpdated-0.1.0/youpdated/state.py +154 -0
- youpdated-0.1.0/youpdated.egg-info/PKG-INFO +452 -0
- youpdated-0.1.0/youpdated.egg-info/SOURCES.txt +39 -0
- youpdated-0.1.0/youpdated.egg-info/dependency_links.txt +1 -0
- youpdated-0.1.0/youpdated.egg-info/entry_points.txt +2 -0
- youpdated-0.1.0/youpdated.egg-info/requires.txt +13 -0
- 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!
|
youpdated-0.1.0/LICENSE
ADDED
|
@@ -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.
|