shop-notify 1.0.1__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,5 @@
1
+ """Watch an Etsy shop for new listings and send notifications."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("shop-notify")
@@ -0,0 +1,371 @@
1
+ import argparse
2
+ import logging
3
+ import signal
4
+ import sys
5
+ import threading
6
+ import time
7
+ from collections.abc import Sequence
8
+ from datetime import datetime
9
+ from types import FrameType
10
+ from typing import TypedDict
11
+
12
+ from shop_notify import __version__
13
+ from shop_notify.config import NotifySettings, Settings
14
+ from shop_notify.db import ListingStore
15
+ from shop_notify.etsy import EtsyClient, EtsyError
16
+ from shop_notify.models import Listing, Money
17
+ from shop_notify.notifications import (
18
+ AppriseNotifier,
19
+ ConsoleNotifier,
20
+ Message,
21
+ MessageRenderer,
22
+ MultiNotifier,
23
+ NotificationError,
24
+ Notifier,
25
+ Sender,
26
+ )
27
+ from shop_notify.systemd import SystemdNotifier
28
+
29
+ log = logging.getLogger("shop_notify")
30
+
31
+
32
+ class ImageOptions(TypedDict):
33
+ images: bool
34
+ images_max: int
35
+ images_in_summary: bool
36
+
37
+
38
+ def setup_logging(settings: Settings) -> None:
39
+ handlers: list[logging.Handler] = [logging.StreamHandler()]
40
+ if settings.log_file:
41
+ handlers.append(logging.FileHandler(settings.log_file, encoding="utf-8"))
42
+ logging.basicConfig(
43
+ level=settings.log_level.upper(),
44
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
45
+ handlers=handlers,
46
+ force=True,
47
+ )
48
+ # Apprise logs full target URLs (including credentials) at debug level
49
+ logging.getLogger("apprise").setLevel(logging.WARNING)
50
+ # httpx logs every request at INFO; only show that when debugging
51
+ if logging.getLogger().getEffectiveLevel() > logging.DEBUG:
52
+ logging.getLogger("httpx").setLevel(logging.WARNING)
53
+
54
+
55
+ def image_options(settings: NotifySettings) -> ImageOptions:
56
+ return {
57
+ "images": settings.notify_images,
58
+ "images_max": settings.notify_images_max,
59
+ "images_in_summary": settings.notify_images_in_summary,
60
+ }
61
+
62
+
63
+ def build_notifier(settings: Settings) -> Notifier:
64
+ notifiers: list[Notifier] = []
65
+ try:
66
+ renderer = MessageRenderer(
67
+ settings.notify_template_dir,
68
+ shop_name=settings.etsy_shop_name,
69
+ language=settings.notify_language,
70
+ )
71
+ if settings.notify_config is not None or settings.notify_urls is not None:
72
+ urls = settings.notify_urls.get_secret_value() if settings.notify_urls else None
73
+ notifiers.append(
74
+ AppriseNotifier(
75
+ urls=urls,
76
+ config=settings.notify_config,
77
+ renderer=renderer,
78
+ **image_options(settings),
79
+ )
80
+ )
81
+ except ValueError as e:
82
+ raise SystemExit(f"Invalid notification configuration: {e}") from e
83
+
84
+ console = settings.notify_console
85
+ if console is True or (console == "auto" and not notifiers):
86
+ notifiers.insert(0, ConsoleNotifier(renderer))
87
+ elif console == "auto" and not settings.notify_existing_on_first_run:
88
+ notifiers.insert(0, ConsoleNotifier(renderer, first_run_only=True))
89
+
90
+ notifier = notifiers[0] if len(notifiers) == 1 else MultiNotifier(notifiers)
91
+ log.info("Notification targets: %s", ", ".join(notifier.targets) or "none (log only)")
92
+ return notifier
93
+
94
+
95
+ def resolve_shop_id(client: EtsyClient, settings: Settings) -> int:
96
+ if settings.etsy_shop_id is not None:
97
+ return settings.etsy_shop_id
98
+ assert settings.etsy_shop_name
99
+ try:
100
+ shop_id = client.find_shop_id(settings.etsy_shop_name)
101
+ except EtsyError as e:
102
+ raise SystemExit(f"Could not resolve shop {settings.etsy_shop_name!r}: {e}") from e
103
+ log.info("Watching shop %s (ID %d)", settings.etsy_shop_name, shop_id)
104
+ return shop_id
105
+
106
+
107
+ def add_images(client: EtsyClient, listings: Sequence[Listing]) -> list[Listing]:
108
+ """Attach main image URLs; notifications are still sent if this fails."""
109
+ try:
110
+ images = client.listing_images([item.listing_id for item in listings])
111
+ except EtsyError as e:
112
+ log.warning("Could not load listing images: %s", e)
113
+ return list(listings)
114
+ return [item.model_copy(update={"image_url": images.get(item.listing_id)}) for item in listings]
115
+
116
+
117
+ QUOTA_WARNING_THRESHOLD = 0.2
118
+
119
+
120
+ def check_quota(client: EtsyClient) -> None:
121
+ """Warn early when the daily Etsy request quota is running low."""
122
+ quota = client.quota
123
+ if quota is None:
124
+ return
125
+ if quota.fraction_left < QUOTA_WARNING_THRESHOLD:
126
+ log.warning(
127
+ "Only %d of %d Etsy API requests left today - consider a larger POLL_INTERVAL",
128
+ quota.remaining,
129
+ quota.limit,
130
+ )
131
+ else:
132
+ log.debug("Etsy API requests left today: %d of %d", quota.remaining, quota.limit)
133
+
134
+
135
+ def run_once(
136
+ client: EtsyClient,
137
+ store: ListingStore,
138
+ notifier: Notifier,
139
+ shop_id: int,
140
+ *,
141
+ notify_existing: bool = False,
142
+ ) -> int:
143
+ """Poll once and notify new items; returns the number of notified items.
144
+
145
+ On the first run (empty database) the existing items are only recorded unless
146
+ ``notify_existing`` is set; notifiers still receive them flagged as ``initial``.
147
+ """
148
+ listings = list(client.active_listings(shop_id))
149
+ log.debug("Fetched %d active listings from shop %s", len(listings), shop_id)
150
+ check_quota(client)
151
+
152
+ first_run = store.is_empty()
153
+ new_items = store.sync(listings)
154
+ if new_items and first_run and not notify_existing:
155
+ notifier.notify(new_items, initial=True)
156
+ store.mark_as_notified(new_items)
157
+ log.info(
158
+ "First run: %d existing item(s) recorded, notifications start with the next new item",
159
+ len(new_items),
160
+ )
161
+ return 0
162
+ if new_items:
163
+ notifier.notify(add_images(client, new_items))
164
+ store.mark_as_notified(new_items)
165
+ for item in new_items:
166
+ log.info("New item: %s (%s)", item.title, item.url)
167
+ log.info("Notified %d new item(s)", len(new_items))
168
+ else:
169
+ log.info("No new items this time")
170
+ return len(new_items)
171
+
172
+
173
+ def healthcheck(settings: Settings, now: float | None = None) -> int:
174
+ """Return 0 if the last successful poll is recent enough, 1 otherwise."""
175
+ path = settings.heartbeat_path
176
+ try:
177
+ age = (time.time() if now is None else now) - path.stat().st_mtime
178
+ except FileNotFoundError:
179
+ print(f"unhealthy: no heartbeat yet ({path})")
180
+ return 1
181
+ if age > settings.heartbeat_max_age:
182
+ print(f"unhealthy: last successful poll {age / 60:.0f} min ago")
183
+ return 1
184
+ print(f"healthy: last successful poll {age:.0f} s ago")
185
+ return 0
186
+
187
+
188
+ def _now() -> str:
189
+ return f"{datetime.now().astimezone():%H:%M}"
190
+
191
+
192
+ def _quota_status(client: EtsyClient) -> str:
193
+ quota = client.quota
194
+ return f", {quota.remaining}/{quota.limit} API requests left today" if quota else ""
195
+
196
+
197
+ def wait_for_next_poll(stop: threading.Event, seconds: float, systemd: SystemdNotifier) -> None:
198
+ """Sleep until the next poll or a stop request, pinging the systemd watchdog meanwhile."""
199
+ remaining = float(seconds)
200
+ while remaining > 0 and not stop.is_set():
201
+ chunk = min(remaining, systemd.watchdog_interval or remaining)
202
+ stop.wait(chunk)
203
+ remaining -= chunk
204
+ systemd.watchdog()
205
+
206
+
207
+ def run(settings: Settings) -> None:
208
+ setup_logging(settings)
209
+ log.info("Shop Notify %s starting", __version__)
210
+ notifier = build_notifier(settings)
211
+ systemd = SystemdNotifier()
212
+
213
+ stop = threading.Event()
214
+
215
+ def _handle_signal(signum: int, _frame: FrameType | None) -> None:
216
+ log.info("Received %s, shutting down", signal.Signals(signum).name)
217
+ stop.set()
218
+
219
+ signal.signal(signal.SIGINT, _handle_signal)
220
+ signal.signal(signal.SIGTERM, _handle_signal)
221
+
222
+ store = ListingStore(settings.db_path)
223
+ with EtsyClient(
224
+ api_key=settings.etsy_api_key.get_secret_value(),
225
+ shared_secret=settings.etsy_shared_secret.get_secret_value(),
226
+ base_url=settings.etsy_api_endpoint,
227
+ ) as client:
228
+ try:
229
+ shop_id = resolve_shop_id(client, settings)
230
+ systemd.ready(f"Watching shop {settings.etsy_shop_name or shop_id}")
231
+ while not stop.is_set():
232
+ try:
233
+ new = run_once(
234
+ client,
235
+ store,
236
+ notifier,
237
+ shop_id,
238
+ notify_existing=settings.notify_existing_on_first_run,
239
+ )
240
+ settings.heartbeat_path.touch()
241
+ systemd.status(f"Last poll {_now()}: {new} new item(s){_quota_status(client)}")
242
+ except (EtsyError, NotificationError) as e:
243
+ log.error("%s", e)
244
+ systemd.status(f"Last poll {_now()} failed: {str(e)[:200]}")
245
+ except Exception:
246
+ log.exception("Unexpected error during poll")
247
+ systemd.status(f"Last poll {_now()} failed with an unexpected error")
248
+ wait_for_next_poll(stop, settings.poll_interval, systemd)
249
+ finally:
250
+ systemd.stopping()
251
+ store.close()
252
+
253
+
254
+ SAMPLE_IMAGE_URL = (
255
+ "https://raw.githubusercontent.com/spielkind/shop-notify/main/docs/sample-item.png"
256
+ )
257
+ PROJECT_URL = "https://github.com/spielkind/shop-notify"
258
+
259
+
260
+ SAMPLE_TITLES = {
261
+ "en": ('Sample: hand-thrown mug "Morning dew"', "Sample: small vase in sage green (no image)"),
262
+ "de": (
263
+ "Muster: handgedrehte Tasse „Morgentau“",
264
+ "Muster: kleine Vase in Salbeigrün (ohne Bild)",
265
+ ),
266
+ }
267
+
268
+
269
+ def sample_listings(language: str = "en") -> list[Listing]:
270
+ now = int(time.time())
271
+ first, second = SAMPLE_TITLES.get(language, SAMPLE_TITLES["en"])
272
+ return [
273
+ Listing(
274
+ listing_id=1,
275
+ title=first,
276
+ url=PROJECT_URL,
277
+ last_modified_timestamp=now,
278
+ price=Money(amount=2450, divisor=100, currency_code="EUR"),
279
+ image_url=SAMPLE_IMAGE_URL,
280
+ ),
281
+ Listing(
282
+ listing_id=2,
283
+ title=second,
284
+ url=PROJECT_URL,
285
+ last_modified_timestamp=now,
286
+ price=Money(amount=1800, divisor=100, currency_code="EUR"),
287
+ ),
288
+ ]
289
+
290
+
291
+ def build_test_senders(settings: NotifySettings, url: str | None) -> tuple[list[Sender], Message]:
292
+ """Targets of a test notification: ``url`` only, or all configured ones."""
293
+ renderer = MessageRenderer(
294
+ settings.notify_template_dir,
295
+ shop_name=settings.etsy_shop_name,
296
+ language=settings.notify_language,
297
+ )
298
+ senders: list[Sender] = []
299
+ if url:
300
+ senders.append(AppriseNotifier(urls=url, renderer=renderer, **image_options(settings)))
301
+ else:
302
+ if settings.has_notify_targets:
303
+ urls = settings.notify_urls.get_secret_value() if settings.notify_urls else None
304
+ senders.append(
305
+ AppriseNotifier(
306
+ urls=urls,
307
+ config=settings.notify_config,
308
+ renderer=renderer,
309
+ **image_options(settings),
310
+ )
311
+ )
312
+ console = settings.notify_console
313
+ if console is True or (console == "auto" and not senders):
314
+ senders.insert(0, ConsoleNotifier(renderer))
315
+ if not senders:
316
+ raise ValueError("no notification targets configured (NOTIFY_CONSOLE=false)")
317
+ return senders, renderer.render(sample_listings(renderer.language), test=True)
318
+
319
+
320
+ def test_notify(settings: NotifySettings, url: str | None = None) -> int:
321
+ """Send a sample notification; 0 = all targets ok, 1 = some failed, 2 = config error."""
322
+ logging.basicConfig(level=logging.WARNING, format="%(levelname)s %(name)s: %(message)s")
323
+ logging.getLogger("apprise").setLevel(logging.WARNING)
324
+ try:
325
+ senders, message = build_test_senders(settings, url)
326
+ except ValueError as e:
327
+ print(f"Invalid notification configuration: {e}", file=sys.stderr)
328
+ return 2
329
+
330
+ print(f"Sending a test notification with {len(sample_listings())} sample items …\n")
331
+ deliveries = [delivery for sender in senders for delivery in sender.send(message)]
332
+ print()
333
+ for delivery in deliveries:
334
+ print(f" {'✓' if delivery.ok else '✗'} {delivery.target}")
335
+ succeeded = sum(delivery.ok for delivery in deliveries)
336
+ print(f"\n{succeeded} of {len(deliveries)} target(s) succeeded.")
337
+ return 0 if succeeded == len(deliveries) else 1
338
+
339
+
340
+ def main(argv: Sequence[str] | None = None) -> None:
341
+ parser = argparse.ArgumentParser(
342
+ prog="shop-notify", description="Watch a shop for new listings and send notifications."
343
+ )
344
+ parser.add_argument(
345
+ "command",
346
+ nargs="?",
347
+ choices=["run", "healthcheck", "test-notify"],
348
+ default="run",
349
+ help=(
350
+ "run: poll the shop (default); healthcheck: exit 0 if the last poll was recent; "
351
+ "test-notify: send a sample notification to all targets"
352
+ ),
353
+ )
354
+ parser.add_argument(
355
+ "--url", help="test-notify only: send to this Apprise URL instead of the configured targets"
356
+ )
357
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
358
+ args = parser.parse_args(argv)
359
+ if args.url and args.command != "test-notify":
360
+ parser.error("--url can only be used with test-notify")
361
+
362
+ if args.command == "test-notify":
363
+ raise SystemExit(test_notify(NotifySettings(), args.url))
364
+ settings = Settings()
365
+ if args.command == "healthcheck":
366
+ raise SystemExit(healthcheck(settings))
367
+ run(settings)
368
+
369
+
370
+ if __name__ == "__main__":
371
+ main()
shop_notify/config.py ADDED
@@ -0,0 +1,81 @@
1
+ from pathlib import Path
2
+ from typing import Literal, Self
3
+
4
+ from pydantic import Field, SecretStr, field_validator, model_validator
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+
8
+ class NotifySettings(BaseSettings):
9
+ """Settings needed to send notifications; no Etsy credentials required."""
10
+
11
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
12
+
13
+ etsy_shop_name: str | None = None
14
+
15
+ log_file: Path | None = Path("shop-notify.log")
16
+ log_level: str = "INFO"
17
+
18
+ # Apprise notification targets; without any, notifications go to the console only
19
+ notify_config: Path | None = None
20
+ notify_urls: SecretStr | None = None
21
+ # Print notifications to stdout: "auto" = only if no other targets are configured
22
+ notify_console: Literal["auto"] | bool = "auto"
23
+ # On the first run (empty database) existing items are only recorded, not notified
24
+ notify_existing_on_first_run: bool = False
25
+ # Directory with custom subject.txt.j2 / body.txt.j2 / body.html.j2 templates
26
+ notify_template_dir: Path | None = None
27
+ # Language of the built-in templates
28
+ notify_language: Literal["en", "de"] = "en"
29
+ # Push/chat services: one message per item with its image, up to this many items
30
+ notify_images: bool = True
31
+ notify_images_max: int = Field(default=5, ge=1)
32
+ # Above notify_images_max: attach the images to the summary message (Telegram album)
33
+ notify_images_in_summary: bool = False
34
+
35
+ @field_validator("log_file", "notify_config", "notify_template_dir", mode="before")
36
+ @classmethod
37
+ def _empty_path_is_none(cls, value: object) -> object:
38
+ return None if value == "" else value
39
+
40
+ @field_validator("notify_urls", mode="before")
41
+ @classmethod
42
+ def _empty_urls_is_none(cls, value: object) -> object:
43
+ return None if isinstance(value, str) and not value.strip() else value
44
+
45
+ @field_validator("notify_console", mode="before")
46
+ @classmethod
47
+ def _normalize_console(cls, value: object) -> object:
48
+ return value.strip().lower() if isinstance(value, str) else value
49
+
50
+ @property
51
+ def has_notify_targets(self) -> bool:
52
+ return self.notify_config is not None or self.notify_urls is not None
53
+
54
+
55
+ class Settings(NotifySettings):
56
+ """All settings for watching a shop."""
57
+
58
+ etsy_api_key: SecretStr
59
+ etsy_shared_secret: SecretStr
60
+ etsy_api_endpoint: str = "https://openapi.etsy.com/v3/application/"
61
+ etsy_shop_id: int | None = None
62
+
63
+ poll_interval: int = Field(default=300, ge=10)
64
+ db_path: Path = Path("shop-notify.db")
65
+ heartbeat_file: Path | None = None
66
+
67
+ @model_validator(mode="after")
68
+ def _require_shop(self) -> Self:
69
+ if self.etsy_shop_id is None and not self.etsy_shop_name:
70
+ raise ValueError("either ETSY_SHOP_ID or ETSY_SHOP_NAME must be set")
71
+ return self
72
+
73
+ @property
74
+ def heartbeat_path(self) -> Path:
75
+ """Heartbeat file, by default next to the database."""
76
+ return self.heartbeat_file or self.db_path.with_suffix(".heartbeat")
77
+
78
+ @property
79
+ def heartbeat_max_age(self) -> float:
80
+ """Seconds after which a missing heartbeat update counts as unhealthy."""
81
+ return 2 * self.poll_interval + 60
shop_notify/db.py ADDED
@@ -0,0 +1,72 @@
1
+ import sqlite3
2
+ import time
3
+ from collections.abc import Iterable
4
+ from pathlib import Path
5
+
6
+ from shop_notify.models import Listing
7
+
8
+ _SCHEMA = """
9
+ CREATE TABLE IF NOT EXISTS listings (
10
+ listing_id INTEGER PRIMARY KEY,
11
+ request_date INTEGER NOT NULL,
12
+ notified_date INTEGER,
13
+ result TEXT
14
+ );
15
+ """
16
+
17
+
18
+ class ListingStore:
19
+ """Persists seen listings and tracks which ones have already been notified."""
20
+
21
+ def __init__(self, path: Path | str) -> None:
22
+ self._db = sqlite3.connect(path)
23
+ self._db.executescript(_SCHEMA)
24
+
25
+ def close(self) -> None:
26
+ self._db.close()
27
+
28
+ def is_empty(self) -> bool:
29
+ return self._db.execute("SELECT 1 FROM listings LIMIT 1").fetchone() is None
30
+
31
+ def sync(self, listings: Iterable[Listing]) -> list[Listing]:
32
+ """Store the current listings, drop vanished notified ones, return un-notified ones.
33
+
34
+ Listings that disappear and later re-appear are therefore notified again.
35
+ Un-notified listings are returned in the order given (Etsy: newest first),
36
+ followed by pending ones that are no longer listed.
37
+ """
38
+ now = int(time.time())
39
+ listings = list(listings)
40
+ position = {item.listing_id: index for index, item in enumerate(listings)}
41
+ rows = [(item.listing_id, now, item.model_dump_json()) for item in listings]
42
+
43
+ with self._db:
44
+ self._db.executemany(
45
+ """
46
+ INSERT INTO listings (listing_id, request_date, result) VALUES (?, ?, ?)
47
+ ON CONFLICT (listing_id) DO UPDATE
48
+ SET request_date = excluded.request_date, result = excluded.result
49
+ """,
50
+ rows,
51
+ )
52
+ self._db.execute(
53
+ "DELETE FROM listings WHERE request_date < ? AND notified_date IS NOT NULL",
54
+ (now,),
55
+ )
56
+
57
+ cursor = self._db.execute(
58
+ "SELECT result FROM listings WHERE notified_date IS NULL ORDER BY listing_id"
59
+ )
60
+ pending = [Listing.model_validate_json(row[0]) for row in cursor]
61
+ return sorted(pending, key=lambda item: position.get(item.listing_id, len(position)))
62
+
63
+ def mark_as_notified(self, listings: Iterable[Listing]) -> None:
64
+ now = int(time.time())
65
+ with self._db:
66
+ self._db.executemany(
67
+ """
68
+ UPDATE listings SET notified_date = ?
69
+ WHERE listing_id = ? AND notified_date IS NULL
70
+ """,
71
+ [(now, item.listing_id) for item in listings],
72
+ )