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.
@@ -0,0 +1,158 @@
1
+ """GitHub releases, tags, and commits.
2
+
3
+ ``.atom`` endpoints don't need credentials and no rate-limit (default here)
4
+ ``GITHUB_TOKEN`` in the environment switches to the REST API, which has better release info
5
+
6
+ COMMONLY RATE LIMITED!
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ from datetime import datetime, timezone
14
+ from typing import Any, ClassVar, Iterable
15
+
16
+ from ..http import Client
17
+ from ..models import Target, Update
18
+ from ..registry import register
19
+ from .base import ConfigEntryError, entry_fields, require
20
+ from .feed import html_to_text, parse_feed
21
+
22
+ REPO_RE = re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$")
23
+ VALID_WATCH = ("releases", "tags", "commits")
24
+
25
+
26
+ def _normalize_repo(value: Any, source: str) -> str:
27
+ text = str(value).strip().rstrip("/")
28
+ # Accept a full URL as well as owner/repo.
29
+ match = re.search(r"github\.com/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)", text)
30
+ if match:
31
+ text = match.group(1)
32
+ if not REPO_RE.match(text):
33
+ raise ConfigEntryError(
34
+ f"sources.{source}: `{value}` is not an owner/repo or a github.com URL"
35
+ )
36
+ return text
37
+
38
+
39
+ @register
40
+ class GitHubSource:
41
+ name: ClassVar[str] = "github"
42
+ summary: ClassVar[str] = "Releases, tags, and commits for a repository"
43
+
44
+ def targets(self, entries: list[Any]) -> list[Target]:
45
+ targets = []
46
+ for entry in entries:
47
+ fields = entry_fields(entry, "repo", self.name)
48
+ repo = _normalize_repo(require(fields, "repo", self.name), self.name)
49
+
50
+ watch = fields.get("watch") or ["releases"]
51
+ if isinstance(watch, str):
52
+ watch = [watch]
53
+ unknown = [w for w in watch if w not in VALID_WATCH]
54
+ if unknown:
55
+ raise ConfigEntryError(
56
+ f"sources.{self.name}: unknown watch {unknown} for {repo}; "
57
+ f"valid options are {list(VALID_WATCH)}"
58
+ )
59
+
60
+ targets.append(
61
+ Target(
62
+ source=self.name,
63
+ key=repo,
64
+ label=fields.get("name"),
65
+ params={"watch": list(watch), "branch": fields.get("branch")},
66
+ )
67
+ )
68
+ return targets
69
+
70
+ def fetch(self, target: Target, client: Client) -> Iterable[Update]:
71
+ watch = target.params.get("watch") or ["releases"]
72
+ token = os.environ.get("GITHUB_TOKEN")
73
+ updates: list[Update] = []
74
+
75
+ for kind in watch:
76
+ if kind == "releases" and token:
77
+ updates.extend(self._releases_via_api(target, client, token))
78
+ elif kind == "releases":
79
+ updates.extend(self._atom(target, client, "releases.atom", "release"))
80
+ elif kind == "tags":
81
+ updates.extend(self._atom(target, client, "tags.atom", "tag"))
82
+ elif kind == "commits":
83
+ branch = target.params.get("branch")
84
+ path = f"commits/{branch}.atom" if branch else "commits.atom"
85
+ updates.extend(self._atom(target, client, path, "commit"))
86
+
87
+ return updates
88
+
89
+ # anon path
90
+
91
+ def _atom(self, target: Target, client: Client, path: str, tag: str) -> list[Update]:
92
+ url = f"https://github.com/{target.key}/{path}"
93
+ fetched = client.get(url, conditional=True)
94
+ if fetched is None: # 304 Not Modified, or test run
95
+ return []
96
+ return parse_feed(
97
+ fetched.content,
98
+ source=self.name,
99
+ target=target.key,
100
+ version_of=_version_from_entry if tag != "commit" else None,
101
+ tags=(tag,),
102
+ )
103
+
104
+ # authed path
105
+
106
+ def _releases_via_api(self, target: Target, client: Client, token: str) -> list[Update]:
107
+ url = f"https://api.github.com/repos/{target.key}/releases?per_page=20"
108
+ fetched = client.get(
109
+ url,
110
+ conditional=True,
111
+ headers={
112
+ "Authorization": f"Bearer {token}",
113
+ "Accept": "application/vnd.github+json",
114
+ "X-GitHub-Api-Version": "2022-11-28",
115
+ },
116
+ )
117
+ if fetched is None:
118
+ return []
119
+
120
+ updates = []
121
+ for release in fetched.json():
122
+ if release.get("draft"):
123
+ continue
124
+ published = release.get("published_at") or release.get("created_at")
125
+ updates.append(
126
+ Update(
127
+ source=self.name,
128
+ target=target.key,
129
+ uid=f"release:{release['id']}",
130
+ title=(release.get("name") or release.get("tag_name") or "").strip()
131
+ or "(untitled)",
132
+ url=release.get("html_url", ""),
133
+ published=_parse_iso(published),
134
+ version=release.get("tag_name"),
135
+ body=html_to_text(release.get("body")),
136
+ tags=("release",) + (("prerelease",) if release.get("prerelease") else ()),
137
+ )
138
+ )
139
+ return updates
140
+
141
+
142
+ def _version_from_entry(entry: Any) -> str | None:
143
+ """Recover the tag from an atom entry id like ``tag:github.com,...:Repository/1/v3.13.0``."""
144
+ for candidate in (entry.get("id", ""), entry.get("link", "")):
145
+ match = re.search(r"[/:]([^/:]*\d[^/:]*)$", str(candidate))
146
+ if match:
147
+ return match.group(1)
148
+ return None
149
+
150
+
151
+ def _parse_iso(value: str | None) -> datetime | None:
152
+ if not value:
153
+ return None
154
+ try:
155
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
156
+ except ValueError:
157
+ return None
158
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
@@ -0,0 +1,210 @@
1
+ """itch.io devlogs and builds.
2
+
3
+ Two watched per game:
4
+ - devlogs (``<game-url>/devlog.rss``): itch answers 404 when a game doesn't have one, which is normal and not an error
5
+ - releases: many games ship new builds without a devlog, so 404 above moves here. The game page has "Updated" timestamps
6
+ and the list of downloadable files; they fingerprint the current build, and a change means a new release.
7
+
8
+ Both are on by default. ``watch: [devlog]`` or ``watch: [releases]`` to choose
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ import re
15
+ from datetime import datetime, timezone
16
+ from typing import Any, ClassVar, Iterable
17
+
18
+ from ..http import Client
19
+ from ..models import Target, Update
20
+ from ..registry import register
21
+ from .base import ConfigEntryError, entry_fields, require
22
+ from .feed import parse_feed
23
+
24
+ GAME_URL_RE = re.compile(
25
+ r"^(?:https?://)?(?P<user>[A-Za-z0-9-]+)\.itch\.io/(?P<game>[A-Za-z0-9._-]+)/?$"
26
+ )
27
+
28
+ # info panel: <tr><td>Updated</td><td><abbr title="17 August 2026 @ 10:34 UTC">…
29
+ INFO_ROW_RE = re.compile(r"<td>([^<]{1,30})</td><td>(.{0,400}?)</td>", re.S)
30
+ ABBR_TITLE_RE = re.compile(r'<abbr title="([^"]+)"')
31
+ UPLOAD_NAME_RE = re.compile(r'<strong title="([^"]*)" class="name">')
32
+ UPLOAD_SIZE_RE = re.compile(r'class="file_size"><span>([^<]*)</span>')
33
+ ITCH_DATE_RE = re.compile(
34
+ r"(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})(?:\s*@\s*(\d{1,2}):(\d{2}))?", re.I
35
+ )
36
+
37
+ MONTHS = {
38
+ m.lower(): i
39
+ for i, m in enumerate(
40
+ [
41
+ "January", "February", "March", "April", "May", "June",
42
+ "July", "August", "September", "October", "November", "December",
43
+ ],
44
+ start=1,
45
+ )
46
+ }
47
+
48
+ VALID_WATCH = ("devlog", "releases")
49
+
50
+
51
+ @register
52
+ class ItchSource:
53
+ name: ClassVar[str] = "itch"
54
+ summary: ClassVar[str] = "Devlog posts and new builds for an itch.io game"
55
+
56
+ def targets(self, entries: list[Any]) -> list[Target]:
57
+ targets = []
58
+ for entry in entries:
59
+ fields = entry_fields(entry, "url", self.name)
60
+
61
+ if fields.get("user") and fields.get("game"):
62
+ user, game = str(fields["user"]).strip(), str(fields["game"]).strip()
63
+ else:
64
+ raw = str(require(fields, "url", self.name)).strip()
65
+ match = GAME_URL_RE.match(raw)
66
+ if not match:
67
+ raise ConfigEntryError(
68
+ f"sources.{self.name}: `{raw}` is not an itch.io game URL "
69
+ "(expected https://user.itch.io/game)"
70
+ )
71
+ user, game = match.group("user"), match.group("game")
72
+
73
+ watch = fields.get("watch") or list(VALID_WATCH)
74
+ if isinstance(watch, str):
75
+ watch = [watch]
76
+ unknown = [w for w in watch if w not in VALID_WATCH]
77
+ if unknown:
78
+ raise ConfigEntryError(
79
+ f"sources.{self.name}: unknown watch {unknown} for {user}/{game}; "
80
+ f"valid options are {list(VALID_WATCH)}"
81
+ )
82
+
83
+ targets.append(
84
+ Target(
85
+ source=self.name,
86
+ key=f"{user}/{game}",
87
+ label=fields.get("name") or game.replace("-", " "),
88
+ params={
89
+ "page": f"https://{user}.itch.io/{game}",
90
+ "watch": list(watch),
91
+ },
92
+ )
93
+ )
94
+ return targets
95
+
96
+ def fetch(self, target: Target, client: Client) -> Iterable[Update]:
97
+ watch = target.params.get("watch") or list(VALID_WATCH)
98
+ updates: list[Update] = []
99
+ if "devlog" in watch:
100
+ updates.extend(self._devlog(target, client))
101
+ if "releases" in watch:
102
+ updates.extend(self._release(target, client))
103
+ return updates
104
+
105
+ # devlog feed
106
+
107
+ def _devlog(self, target: Target, client: Client) -> list[Update]:
108
+ url = f"{target.params['page']}/devlog.rss"
109
+ fetched = client.get(url, conditional=True, soft_statuses=(404,))
110
+ if fetched is None:
111
+ return []
112
+ if fetched.status == 404:
113
+ # No devlog has ever been posted for this game
114
+ return []
115
+
116
+ return parse_feed(
117
+ fetched.content,
118
+ source=self.name,
119
+ target=target.key,
120
+ tags=("devlog",),
121
+ )
122
+
123
+ # build fingerprint from the game page
124
+
125
+ def _release(self, target: Target, client: Client) -> list[Update]:
126
+ page = target.params["page"]
127
+ fetched = client.get(page, conditional=True, soft_statuses=(404,))
128
+ if fetched is None or fetched.status == 404:
129
+ return []
130
+
131
+ html = fetched.text
132
+ info = self._info_rows(html)
133
+ uploads = self._uploads(html)
134
+ updated = _parse_itch_date(info.get("Updated") or info.get("Published"))
135
+ version = info.get("Version")
136
+
137
+ # Nothing observable on page
138
+ if not uploads and updated is None and not version:
139
+ return []
140
+
141
+ # Any change to the build produces a different uid
142
+ fingerprint = "|".join(
143
+ [
144
+ updated.isoformat() if updated else "",
145
+ version or "",
146
+ *(f"{name}:{size}" for name, size in uploads),
147
+ ]
148
+ )
149
+ digest = hashlib.sha256(fingerprint.encode("utf-8")).hexdigest()[:16]
150
+
151
+ if version:
152
+ title = f"New build — {version}"
153
+ elif uploads:
154
+ title = f"New build — {len(uploads)} file(s)"
155
+ else:
156
+ title = "Game page updated"
157
+
158
+ body = ", ".join(f"{name} ({size})" for name, size in uploads) or None
159
+
160
+ return [
161
+ Update(
162
+ source=self.name,
163
+ target=target.key,
164
+ uid=f"build:{digest}",
165
+ title=title,
166
+ url=page,
167
+ published=updated,
168
+ version=version,
169
+ body=body,
170
+ tags=("release",),
171
+ )
172
+ ]
173
+
174
+ def _info_rows(self, html: str) -> dict[str, str]:
175
+ """Pull "More information" table into a plain dict.
176
+
177
+ Values keep their timestamp when supplied. (visible text is relative ("20 hours ago") fyi)
178
+ """
179
+ rows: dict[str, str] = {}
180
+ for key, cell in INFO_ROW_RE.findall(html):
181
+ key = key.strip()
182
+ if key in rows:
183
+ continue
184
+ abbr = ABBR_TITLE_RE.search(cell)
185
+ value = abbr.group(1) if abbr else re.sub(r"<[^>]+>", "", cell).strip()
186
+ if value:
187
+ rows[key] = value
188
+ return rows
189
+
190
+ def _uploads(self, html: str) -> list[tuple[str, str]]:
191
+ names = UPLOAD_NAME_RE.findall(html)
192
+ sizes = UPLOAD_SIZE_RE.findall(html)
193
+ # Sizes can be missing for some entries
194
+ return [(n, sizes[i] if i < len(sizes) else "") for i, n in enumerate(names)]
195
+
196
+
197
+ def _parse_itch_date(value: str | None) -> datetime | None:
198
+ """Parse itch's "17 August 2026 @ 10:34 UTC" (time optional)."""
199
+ if not value:
200
+ return None
201
+ match = ITCH_DATE_RE.search(value)
202
+ if not match:
203
+ return None
204
+ day, month_name, year, hour, minute = match.groups()
205
+ month = MONTHS.get(month_name.lower())
206
+ if month is None:
207
+ return None
208
+ return datetime(
209
+ int(year), month, int(day), int(hour or 0), int(minute or 0), tzinfo=timezone.utc
210
+ )
@@ -0,0 +1,88 @@
1
+ """npm package releases from the public registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any, ClassVar, Iterable
7
+ from urllib.parse import quote
8
+
9
+ from ..http import Client
10
+ from ..models import Target, Update
11
+ from ..registry import register
12
+ from .base import ConfigEntryError, entry_fields, require
13
+
14
+ # keys alongside real versions in the registry `time` map
15
+ _NON_VERSION_KEYS = {"created", "modified"}
16
+ MAX_VERSIONS = 10
17
+
18
+
19
+ @register
20
+ class NpmSource:
21
+ name: ClassVar[str] = "npm"
22
+ summary: ClassVar[str] = "New published versions of a package"
23
+
24
+ def targets(self, entries: list[Any]) -> list[Target]:
25
+ targets = []
26
+ for entry in entries:
27
+ fields = entry_fields(entry, "package", self.name)
28
+ package = str(require(fields, "package", self.name)).strip()
29
+ if not package or package.startswith("."):
30
+ raise ConfigEntryError(f"sources.{self.name}: `{package}` is not a package name")
31
+ targets.append(
32
+ Target(
33
+ source=self.name,
34
+ key=package,
35
+ label=fields.get("name"),
36
+ params={"tag": fields.get("tag")},
37
+ )
38
+ )
39
+ return targets
40
+
41
+ def fetch(self, target: Target, client: Client) -> Iterable[Update]:
42
+ # name keeps @ but slash escaped
43
+ url = f"https://registry.npmjs.org/{quote(target.key, safe='@')}"
44
+ fetched = client.get(url, conditional=True)
45
+ if fetched is None:
46
+ return []
47
+
48
+ doc = fetched.json()
49
+ latest = (doc.get("dist-tags") or {}).get("latest")
50
+ only_tag = target.params.get("tag")
51
+ if only_tag:
52
+ wanted = (doc.get("dist-tags") or {}).get(only_tag)
53
+ versions = [wanted] if wanted else []
54
+ else:
55
+ versions = [v for v in (doc.get("time") or {}) if v not in _NON_VERSION_KEYS]
56
+
57
+ times = doc.get("time") or {}
58
+ dated = [(v, _parse_iso(times.get(v))) for v in versions if v]
59
+ dated.sort(key=lambda pair: pair[1] or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
60
+
61
+ updates = []
62
+ for version, published in dated[:MAX_VERSIONS]:
63
+ meta = (doc.get("versions") or {}).get(version) or {}
64
+ tags = ("release",) if version == latest else ()
65
+ updates.append(
66
+ Update(
67
+ source=self.name,
68
+ target=target.key,
69
+ uid=f"version:{version}",
70
+ title=f"{target.key} {version}",
71
+ url=f"https://www.npmjs.com/package/{target.key}/v/{version}",
72
+ published=published,
73
+ version=version,
74
+ body=meta.get("description") or doc.get("description"),
75
+ tags=tags + (("latest",) if version == latest else ()),
76
+ )
77
+ )
78
+ return updates
79
+
80
+
81
+ def _parse_iso(value: str | None) -> datetime | None:
82
+ if not value:
83
+ return None
84
+ try:
85
+ parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
86
+ except ValueError:
87
+ return None
88
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
@@ -0,0 +1,88 @@
1
+ """Steam patch notes and news for a game.
2
+
3
+ Config can be an appid; the store name is resolved and cached locally so reports stay readable
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ from typing import Any, ClassVar, Iterable
10
+
11
+ from ..http import Client, FetchError
12
+ from ..models import Target, Update
13
+ from ..registry import register
14
+ from .base import ConfigEntryError, entry_fields, require
15
+ from .feed import parse_feed
16
+
17
+ NAME_CACHE = "steam_app_names"
18
+
19
+
20
+ @register
21
+ class SteamSource:
22
+ name: ClassVar[str] = "steam"
23
+ summary: ClassVar[str] = "Patch notes and news for Steam games"
24
+
25
+ def targets(self, entries: list[Any]) -> list[Target]:
26
+ targets = []
27
+ for entry in entries:
28
+ fields = entry_fields(entry, "appid", self.name)
29
+ raw = str(require(fields, "appid", self.name)).strip()
30
+ # Accept store URL and id
31
+ match = re.search(r"(?:app/)?(\d+)", raw)
32
+ if not match:
33
+ raise ConfigEntryError(
34
+ f"sources.{self.name}: `{raw}` is not an appid or a store URL"
35
+ )
36
+ appid = match.group(1)
37
+ targets.append(
38
+ Target(
39
+ source=self.name,
40
+ key=appid,
41
+ label=fields.get("name"),
42
+ params={},
43
+ )
44
+ )
45
+ return targets
46
+
47
+ def fetch(self, target: Target, client: Client) -> Iterable[Update]:
48
+ if not target.label:
49
+ target.label = self._app_name(target.key, client)
50
+
51
+ url = (
52
+ f"https://store.steampowered.com/feeds/news/app/{target.key}/"
53
+ "?cc=US&l=english"
54
+ )
55
+ fetched = client.get(url, conditional=True)
56
+ if fetched is None:
57
+ return []
58
+
59
+ return parse_feed(
60
+ fetched.content,
61
+ source=self.name,
62
+ target=target.key,
63
+ tags=("news",),
64
+ )
65
+
66
+ def _app_name(self, appid: str, client: Client) -> str | None:
67
+ """Resolve store name once then cache"""
68
+ state = client.state
69
+ if state is not None:
70
+ cached = state.cache_get(NAME_CACHE, appid)
71
+ if cached:
72
+ return cached
73
+ try:
74
+ fetched = client.get(
75
+ f"https://store.steampowered.com/api/appdetails?appids={appid}&l=english"
76
+ )
77
+ except FetchError:
78
+ return None
79
+ if fetched is None:
80
+ return None
81
+ try:
82
+ payload = fetched.json().get(appid) or {}
83
+ name = (payload.get("data") or {}).get("name")
84
+ except (ValueError, AttributeError):
85
+ return None
86
+ if name and state is not None:
87
+ state.cache_set(NAME_CACHE, appid, name)
88
+ return name