From a490b12a4ee4df2bcd59845ce501d3d3f7fa8283 Mon Sep 17 00:00:00 2001 From: mac Date: Wed, 6 May 2026 04:19:57 +0800 Subject: [PATCH 1/3] added docstrings --- src/paperscout/__init__.py | 2 ++ src/paperscout/__main__.py | 11 +++------ src/paperscout/config.py | 3 +++ src/paperscout/health.py | 2 ++ src/paperscout/models.py | 7 ++++++ src/paperscout/monitor.py | 23 +++++++++--------- src/paperscout/scout.py | 24 ++++++++++++++----- src/paperscout/sources.py | 49 +++++++++++++------------------------- src/paperscout/storage.py | 32 +++++-------------------- 9 files changed, 68 insertions(+), 85 deletions(-) diff --git a/src/paperscout/__init__.py b/src/paperscout/__init__.py index 014fd27..8b39cf2 100644 --- a/src/paperscout/__init__.py +++ b/src/paperscout/__init__.py @@ -1,3 +1,5 @@ +"""WG21 paper scout: Slack bot, index polling, and isocpp.org probing.""" + from importlib.metadata import PackageNotFoundError, version try: diff --git a/src/paperscout/__main__.py b/src/paperscout/__main__.py index d94a5c6..3279876 100644 --- a/src/paperscout/__main__.py +++ b/src/paperscout/__main__.py @@ -22,14 +22,7 @@ def _setup_logging(data_dir: Path, console_level: str = "INFO", retention_days: int = 7) -> None: - """Configure root logger with: - - • Console (stderr) — at *console_level*, for interactive monitoring. - • Rotating file (data_dir/paperscout.log) — at *console_level*, rotated - midnight each day, keeping *retention_days* days of history. - - Noisy third-party libraries are silenced to WARNING regardless. - """ + """Console + daily rotating file logging; third-party loggers capped at WARNING.""" data_dir.mkdir(parents=True, exist_ok=True) fmt = logging.Formatter( @@ -61,6 +54,7 @@ def _setup_logging(data_dir: Path, console_level: str = "INFO", retention_days: async def _async_main() -> None: + """Start DB, Slack app, health server, and the polling scheduler.""" data_dir = settings.data_dir data_dir.mkdir(parents=True, exist_ok=True) @@ -135,6 +129,7 @@ def _on_poll_result(result): def main() -> None: + """CLI entry: run ``_async_main`` until interrupt.""" try: asyncio.run(_async_main()) except KeyboardInterrupt: diff --git a/src/paperscout/config.py b/src/paperscout/config.py index 23c0dd3..df2ebd2 100644 --- a/src/paperscout/config.py +++ b/src/paperscout/config.py @@ -1,3 +1,5 @@ +"""Environment-backed runtime configuration (see ``settings`` singleton).""" + from __future__ import annotations from pathlib import Path @@ -7,6 +9,7 @@ class Settings(BaseSettings): + """Application settings loaded from environment and optional ``.env``.""" model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/src/paperscout/health.py b/src/paperscout/health.py index 2dc2485..79aa5da 100644 --- a/src/paperscout/health.py +++ b/src/paperscout/health.py @@ -15,6 +15,8 @@ class _HealthHandler(BaseHTTPRequestHandler): + """Serves JSON ``GET /health`` with version, uptime, index and probe stats.""" + launch_time: datetime paper_count_fn: Callable[[], int] state: object # ProbeState — kept generic to avoid circular import diff --git a/src/paperscout/models.py b/src/paperscout/models.py index 018888f..6e9c978 100644 --- a/src/paperscout/models.py +++ b/src/paperscout/models.py @@ -1,3 +1,5 @@ +"""Domain types for WG21 papers parsed from the wg21.link index.""" + from __future__ import annotations import re @@ -6,6 +8,7 @@ class PaperPrefix(str, Enum): + """Paper ID prefix letters (P/D/N, subgroup codes, etc.).""" D = "D" P = "P" N = "N" @@ -19,6 +22,7 @@ class PaperPrefix(str, Enum): class PaperType(str, Enum): + """Classification from the wg21.link index ``type`` field.""" PAPER = "paper" ISSUE = "issue" EDITORIAL = "editorial" @@ -27,6 +31,7 @@ class PaperType(str, Enum): class FileExt(str, Enum): + """Published file extension for a paper artifact.""" PDF = ".pdf" HTML = ".html" @@ -38,6 +43,7 @@ class FileExt(str, Enum): @dataclass(slots=True) class Paper: + """One indexed paper: id, metadata, and derived number/prefix/revision.""" id: str title: str = "" author: str = "" @@ -82,6 +88,7 @@ def revision(self) -> int | None: @staticmethod def from_index_entry(key: str, entry: dict) -> Paper: + """Build a ``Paper`` from a wg21.link index key and value dict.""" return Paper( id=key, title=entry.get("title", ""), diff --git a/src/paperscout/monitor.py b/src/paperscout/monitor.py index 7f49458..f1b522b 100644 --- a/src/paperscout/monitor.py +++ b/src/paperscout/monitor.py @@ -1,3 +1,5 @@ +"""Polling scheduler: diff index snapshots, run probes, dispatch notifications.""" + from __future__ import annotations import asyncio @@ -19,6 +21,8 @@ @dataclass(slots=True) class DiffResult: + """New and updated papers between two index snapshots.""" + new_papers: list[Paper] updated_papers: list[Paper] @@ -27,6 +31,7 @@ def diff_snapshots( previous: dict[str, Paper], current: dict[str, Paper], ) -> DiffResult: + """Compare two id→paper maps; detect additions and metadata changes.""" new_papers: list[Paper] = [] updated_papers: list[Paper] = [] prev_keys = set(previous.keys()) @@ -53,11 +58,7 @@ def diff_snapshots( @dataclass class PerUserMatches: - """Watchlist matches for a single Slack user in one poll cycle. - - Each entry in *papers* and *probe_hits* is a ``(item, match_reason)`` - tuple where ``match_reason`` is ``'author'`` or ``'paper'``. - """ + """One user's watchlist hits: ``(paper|hit, 'author'|'paper')`` tuples.""" papers: list[tuple[Paper, str]] = field(default_factory=list) probe_hits: list[tuple[ProbeHit, str]] = field(default_factory=list) @@ -68,13 +69,7 @@ class PerUserMatches: @dataclass(slots=True) class DPTransition: - """A D-paper draft that has been formally published as its P counterpart. - - *paper* -- the new P-paper entry from the wg21.link index - *draft_url* -- the D-paper URL we originally probed - *last_modified -- server Last-Modified of the draft (Unix timestamp), or None - *discovered_at* -- our wall-clock time when we first found the draft - """ + """Index P entry that corresponds to a draft URL we previously probed on isocpp.""" paper: Paper draft_url: str @@ -83,6 +78,8 @@ class DPTransition: class PollResult: + """Outcome of one poll: index diff, probe hits, D→P transitions, per-user matches.""" + def __init__( self, diff: DiffResult, @@ -147,6 +144,7 @@ async def seed(self) -> None: ) async def poll_once(self) -> PollResult: + """Refresh index (if enabled), diff, probe isocpp, compute matches, notify.""" self._poll_count += 1 t0 = time.monotonic() log.info("POLL-START poll=%d", self._poll_count) @@ -272,6 +270,7 @@ async def poll_once(self) -> PollResult: return result async def run_forever(self) -> None: + """Run ``poll_once`` on an interval, with overrun cooldown between cycles.""" interval = self.cfg.poll_interval_minutes * 60 cooldown = self.cfg.poll_overrun_cooldown_seconds log.info( diff --git a/src/paperscout/scout.py b/src/paperscout/scout.py index 8da8ce5..3df4544 100644 --- a/src/paperscout/scout.py +++ b/src/paperscout/scout.py @@ -1,3 +1,5 @@ +"""Slack Bolt app: outbound notifications, commands, and message queue.""" + from __future__ import annotations import logging @@ -19,6 +21,7 @@ def create_app() -> App: + """Construct a Slack Bolt ``App`` using configured bot token and signing secret.""" return App( token=settings.slack_bot_token, signing_secret=settings.slack_signing_secret, @@ -32,12 +35,7 @@ def create_app() -> App: class MessageQueue: - """Thread-safe, rate-limited Slack ``chat.postMessage`` queue. - - Maintains a 1-message-per-second-per-channel limit and honours the - ``Retry-After`` header on HTTP 429 responses. All channel and DM posts - go through this queue so the polling loop is never blocked by Slack I/O. - """ + """Background queue for Slack posts: per-channel throttle and 429 retry-after.""" def __init__(self, app: App): self._app = app @@ -48,11 +46,13 @@ def __init__(self, app: App): self._thread: threading.Thread | None = None def start(self) -> None: + """Start the background sender thread.""" self._thread = threading.Thread(target=self._run, daemon=True, name="mq-sender") self._thread.start() log.info("MessageQueue started") def enqueue(self, channel: str, text: str, **kwargs) -> None: + """Queue a ``chat.postMessage`` for *channel* (or user id for DMs).""" self._q.put((channel, text, kwargs)) def _run(self) -> None: @@ -110,6 +110,7 @@ def _send_with_retry(self, channel: str, text: str, kwargs: dict) -> None: def _paper_link(paper: Paper) -> str: + """Slack mrkdwn ```` for *paper* (wg21.link fallback if no URL).""" url = paper.url or paper.long_link if not url: url = f"https://wg21.link/{paper.id}" @@ -117,11 +118,13 @@ def _paper_link(paper: Paper) -> str: def _hit_label(hit_url: str, prefix: str, number: int, revision: int, ext: str) -> str: + """Slack mrkdwn link for an isocpp probe hit filename.""" name = f"{prefix}{number:04d}R{revision}{ext}" return f"<{hit_url}|{name}>" def _fmt_lm(lm: datetime | None) -> str: + """Short human-readable age string from a Last-Modified time.""" if lm is None: return "modified: unknown" now = datetime.now(timezone.utc) @@ -244,6 +247,7 @@ def notify_users(app: App, result: PollResult, mq: MessageQueue) -> None: def _batch_lines(lines: list[str], max_len: int) -> list[str]: + """Split *lines* into Slack-sized chunks under *max_len* characters.""" batches: list[str] = [] current: list[str] = [] current_len = 0 @@ -270,6 +274,7 @@ def register_handlers( paper_count_fn, launch_time: datetime | None = None, ) -> None: + """Wire Slack events for mentions, DMs, watchlist, status, version, uptime.""" def _dispatch(text: str, user_id: str, channel_type: str, say, reply_opts: dict) -> None: words = [w for w in text.split() if w] if not words: @@ -362,6 +367,7 @@ def handle_message(event, context, say): def _reply_opts(event: dict) -> dict: + """kwargs for ``say`` including ``thread_ts`` when replying in a thread.""" opts: dict = {"unfurl_links": False, "unfurl_media": False} thread_ts = event.get("thread_ts") if thread_ts: @@ -376,6 +382,7 @@ def _handle_watchlist( say, reply_opts: dict, ) -> None: + """Parse ``watchlist`` subcommand: add, remove, list, or usage.""" if not args: _show_watchlist(user_id, user_watchlist, say, reply_opts) return @@ -408,6 +415,7 @@ def _show_watchlist( say, reply_opts: dict, ) -> None: + """Post the user’s watchlist entries or an empty-state hint.""" entries = user_watchlist.list_entries(user_id) if entries: lines = [f"• {entry} ({etype})" for entry, etype in entries] @@ -426,6 +434,7 @@ def _show_watchlist( def _handle_status(state: ProbeState, paper_count_fn, say, reply_opts: dict) -> None: + """Post loaded paper count, last poll, probe settings.""" from datetime import datetime as _dt from datetime import timezone as _tz @@ -449,12 +458,14 @@ def _handle_status(state: ProbeState, paper_count_fn, say, reply_opts: dict) -> def _handle_version(say, reply_opts: dict) -> None: + """Post package version string.""" from . import __version__ say(text=f"Paperscout v{__version__}", **reply_opts) def _format_uptime(delta) -> str: + """Compact ``Nd Nh Nm`` string for a timedelta.""" total_seconds = int(delta.total_seconds()) days, remainder = divmod(total_seconds, 86400) hours, remainder = divmod(remainder, 3600) @@ -469,6 +480,7 @@ def _format_uptime(delta) -> str: def _handle_uptime(launch_time: datetime | None, say, reply_opts: dict) -> None: + """Post time since process start (from *launch_time*).""" if launch_time is None: say(text="Uptime information is not available.", **reply_opts) return diff --git a/src/paperscout/sources.py b/src/paperscout/sources.py index 823137a..ee03ff9 100644 --- a/src/paperscout/sources.py +++ b/src/paperscout/sources.py @@ -1,3 +1,5 @@ +"""WG21 index fetch/cache and async HTTP probing of isocpp.org drafts.""" + from __future__ import annotations import asyncio @@ -37,6 +39,7 @@ def __init__(self, pool): self._sorted_p_nums: list[int] = [] # sorted unique P-numbers, for gap analysis async def refresh(self) -> dict[str, Paper]: + """Load index from cache or network; populate ``self.papers``.""" cached = self._cache.read_if_fresh() if cached is not None: log.info("Loaded %d entries from cache", len(cached)) @@ -106,17 +109,7 @@ def effective_frontier( gap_threshold: int = 50, extra_p_numbers: Iterable[int] | None = None, ) -> int: - """Highest P-number in the main cluster of active papers. - - Walks backward from the absolute highest P-number and stops at the - first number whose gap to its predecessor is within *gap_threshold*. - This filters out isolated high-numbered outliers (e.g. a pre-assigned - planning document at P5000 when active work is around P4030) that - would otherwise push the frontier window far above actual activity. - - *extra_p_numbers* merges draft paper numbers (e.g. from discovered - isocpp.org URLs) into the same gap walk as wg21 index P-numbers. - """ + """Largest P-number in the “main cluster”, ignoring huge gaps/outliers.""" nums_set = set(self._max_rev.keys()) if extra_p_numbers: nums_set |= set(extra_p_numbers) @@ -129,6 +122,7 @@ def effective_frontier( return nums[0] def latest_revision(self, number: int) -> int | None: + """Highest revision R*n* seen in the index for P-number *number*.""" rev = self._max_rev.get(number) return rev if rev is not None and rev >= 0 else None @@ -145,6 +139,7 @@ def known_p_numbers(self) -> set[int]: class Tier(str, Enum): + """Probe priority bucket for isocpp HEAD requests.""" WATCHLIST = "watchlist" FRONTIER = "frontier" RECENT = "recent" @@ -153,6 +148,8 @@ class Tier(str, Enum): @dataclass(slots=True) class ProbeHit: + """Successful HEAD to an unpublished draft URL plus optional excerpt text.""" + url: str prefix: str number: int @@ -171,7 +168,7 @@ class ProbeHit: async def _fetch_pdf_text(client: httpx.AsyncClient, pdf_url: str) -> str: - """Download a PDF and extract the first ~1 000 words as plain text via PyMuPDF.""" + """First ~1000 words from a PDF via PyMuPDF, or empty if unavailable.""" try: import fitz # PyMuPDF except ImportError: @@ -209,11 +206,7 @@ async def _fetch_front_text( number: int, revision: int, ) -> str: - """Return the first ~1 000 words of a paper as plain text. - - Tries the HTML version first; falls back to PDF text extraction when - only a PDF is available (e.g. brand-new frontier papers). - """ + """Opening text from HTML if present, else from PDF (for watchlist author match).""" html_url = f"{ISO_BASE}{prefix}{number:04d}R{revision}.html" try: resp = await client.get(html_url, timeout=15.0) @@ -236,22 +229,7 @@ async def _fetch_front_text( class ISOProber: - """Two-frequency async HEAD prober for isocpp.org/files/papers/. - - Hot list (every cycle, ~30 min): - • Watchlist papers - • Frontier window around the effective-frontier P-number - • Papers active within hot_lookback_months - - Cold list (distributed across cold_cycle_divisor cycles ≈ 24 h): - • Every other known P-number (probe for the next unpublished draft) - • Every gap number in 1..frontier (may be untracked new assignments) - - Alerting is driven by the HTTP Last-Modified response header rather than - our own discovery state. A hit is flagged is_recent=True when the server - reports the file was modified within alert_modified_hours of now, ensuring - we only notify about genuinely new or updated drafts. - """ + """Async HEAD probe of isocpp draft URLs: hot every cycle, cold in rotating slices.""" # Keys that _stats is reset to at the start of every run_cycle(). _STATS_TEMPLATE: dict[str, int] = { @@ -281,6 +259,7 @@ def __init__( # ── Public API ─────────────────────────────────────────────────────────── async def run_cycle(self) -> list[ProbeHit]: + """HEAD all scheduled URLs; return recent hits and persist discovery state.""" self._cycle += 1 self._stats = dict(self._STATS_TEMPLATE) t0 = time.monotonic() @@ -576,6 +555,8 @@ async def _probe_one( @dataclass(slots=True) class OpenStdEntry: + """One row scraped from open-std.org WG21 paper listings.""" + paper_id: str title: str author: str @@ -584,6 +565,7 @@ class OpenStdEntry: async def scrape_open_std(year: int | None = None) -> list[OpenStdEntry]: + """Fetch and parse the open-std.org WG21 papers index page for *year*.""" year = year or date.today().year url = OPEN_STD_URL.format(year=year) try: @@ -600,6 +582,7 @@ async def scrape_open_std(year: int | None = None) -> list[OpenStdEntry]: def _parse_open_std_html(html: str) -> list[OpenStdEntry]: + """Parse WG21 paper listing rows from an open-std.org HTML page.""" entries: list[OpenStdEntry] = [] rows = re.findall(r"]*>(.*?)", html, re.DOTALL | re.IGNORECASE) for row in rows: diff --git a/src/paperscout/storage.py b/src/paperscout/storage.py index 4c2cc31..b8b27cb 100644 --- a/src/paperscout/storage.py +++ b/src/paperscout/storage.py @@ -35,6 +35,7 @@ def iso_paper_number_from_discovered_url(url: str) -> int | None: @contextmanager def _conn(pool: ThreadedConnectionPool) -> Generator: + """Borrow a connection from *pool*, commit on success, rollback on error.""" conn = pool.getconn() try: yield conn @@ -52,12 +53,7 @@ def _conn(pool: ThreadedConnectionPool) -> Generator: class PaperCache: - """TTL-based cache for the wg21.link JSON index stored in PostgreSQL. - - Provides the same ``is_fresh`` / ``read`` / ``read_if_fresh`` / ``write`` - interface as the old ``JsonCache`` so that ``WG21Index`` needs no further - changes. - """ + """TTL-backed JSON cache for wg21.link index (same API as legacy JsonCache).""" def __init__(self, pool: ThreadedConnectionPool, ttl_hours: float = 1.0): self._pool = pool @@ -120,10 +116,7 @@ def write(self, data: dict) -> None: class ProbeState: - """PostgreSQL-backed probe state: discovered URLs, miss counters, last-poll. - - All existing methods are preserved with identical signatures. - """ + """Persisted probe bookkeeping: discovered URLs, miss backoff, poll time.""" def __init__(self, pool: ThreadedConnectionPool): self._pool = pool @@ -140,11 +133,7 @@ def _ensure_poll_row(self) -> None: # ── discovered ─────────────────────────────────────────────────────────── def get_all_discovered(self) -> dict[str, dict]: - """Return full discovered map as a dict (for status display / iteration). - - Performs a full table scan -- prefer ``is_discovered()`` for single-URL - lookups and call this only when the complete map is actually needed. - """ + """All discovered URLs (full table scan; prefer ``is_discovered`` for one URL).""" with _conn(self._pool) as conn: with conn.cursor() as cur: cur.execute("SELECT url, last_modified, discovered_at FROM discovered_urls") @@ -276,12 +265,7 @@ def save(self) -> None: class UserWatchlist: - """Per-user watchlist stored in the ``user_watchlist`` table. - - Each entry is either an author name substring (``entry_type='author'``) - or a paper number string (``entry_type='paper'``). The type is - auto-detected: pure digit strings → paper, anything else → author. - """ + """Slack users’ watchlists: author substring or numeric paper id per row.""" def __init__(self, pool: ThreadedConnectionPool): self._pool = pool @@ -356,11 +340,7 @@ def matches_for_users( new_papers: list, # list[Paper] probe_hits: list, # list[ProbeHit] ) -> dict[str, PerUserMatches]: - """Compute per-user matched papers and probe hits. - - Returns a dict keyed by ``slack_user_id``. Only users with at least - one match appear in the result. - """ + """Users with at least one author or paper-number match in this poll.""" from .monitor import PerUserMatches # local import to avoid circular all_entries = self._get_all_entries() From 7fc7675094e514a01cb585b3ce1f7be3dd9ff9fc Mon Sep 17 00:00:00 2001 From: mac Date: Wed, 6 May 2026 04:22:19 +0800 Subject: [PATCH 2/3] fixed lint errors --- src/paperscout/config.py | 1 + src/paperscout/models.py | 4 ++++ src/paperscout/scout.py | 1 + src/paperscout/sources.py | 1 + 4 files changed, 7 insertions(+) diff --git a/src/paperscout/config.py b/src/paperscout/config.py index df2ebd2..15dff3d 100644 --- a/src/paperscout/config.py +++ b/src/paperscout/config.py @@ -10,6 +10,7 @@ class Settings(BaseSettings): """Application settings loaded from environment and optional ``.env``.""" + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/src/paperscout/models.py b/src/paperscout/models.py index 6e9c978..395e946 100644 --- a/src/paperscout/models.py +++ b/src/paperscout/models.py @@ -9,6 +9,7 @@ class PaperPrefix(str, Enum): """Paper ID prefix letters (P/D/N, subgroup codes, etc.).""" + D = "D" P = "P" N = "N" @@ -23,6 +24,7 @@ class PaperPrefix(str, Enum): class PaperType(str, Enum): """Classification from the wg21.link index ``type`` field.""" + PAPER = "paper" ISSUE = "issue" EDITORIAL = "editorial" @@ -32,6 +34,7 @@ class PaperType(str, Enum): class FileExt(str, Enum): """Published file extension for a paper artifact.""" + PDF = ".pdf" HTML = ".html" @@ -44,6 +47,7 @@ class FileExt(str, Enum): @dataclass(slots=True) class Paper: """One indexed paper: id, metadata, and derived number/prefix/revision.""" + id: str title: str = "" author: str = "" diff --git a/src/paperscout/scout.py b/src/paperscout/scout.py index 3df4544..45b757b 100644 --- a/src/paperscout/scout.py +++ b/src/paperscout/scout.py @@ -275,6 +275,7 @@ def register_handlers( launch_time: datetime | None = None, ) -> None: """Wire Slack events for mentions, DMs, watchlist, status, version, uptime.""" + def _dispatch(text: str, user_id: str, channel_type: str, say, reply_opts: dict) -> None: words = [w for w in text.split() if w] if not words: diff --git a/src/paperscout/sources.py b/src/paperscout/sources.py index ee03ff9..e3c2af6 100644 --- a/src/paperscout/sources.py +++ b/src/paperscout/sources.py @@ -140,6 +140,7 @@ def known_p_numbers(self) -> set[int]: class Tier(str, Enum): """Probe priority bucket for isocpp HEAD requests.""" + WATCHLIST = "watchlist" FRONTIER = "frontier" RECENT = "recent" From dc17b448a48c85b46d783b8aa5129b24509f05ed Mon Sep 17 00:00:00 2001 From: mac Date: Wed, 6 May 2026 04:26:43 +0800 Subject: [PATCH 3/3] addressed coderabbitai reviews --- src/paperscout/scout.py | 10 ++++++++-- src/paperscout/storage.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/paperscout/scout.py b/src/paperscout/scout.py index 45b757b..be0614f 100644 --- a/src/paperscout/scout.py +++ b/src/paperscout/scout.py @@ -359,7 +359,13 @@ def handle_message(event, context, say): if bot_id and f"<@{bot_id}>" in text: text = text.split(f"<@{bot_id}>", 1)[-1].strip() if text: - _dispatch(text, user_id, channel_type, say=say, reply_opts=_reply_opts(event)) + _dispatch( + text, + user_id, + channel_type, + say=say, + reply_opts=_reply_opts(event), + ) else: # Public/private channels: handled by app_mention; skip plain messages @@ -416,7 +422,7 @@ def _show_watchlist( say, reply_opts: dict, ) -> None: - """Post the user’s watchlist entries or an empty-state hint.""" + """Post the user's watchlist entries or an empty-state hint.""" entries = user_watchlist.list_entries(user_id) if entries: lines = [f"• {entry} ({etype})" for entry, etype in entries] diff --git a/src/paperscout/storage.py b/src/paperscout/storage.py index b8b27cb..5481752 100644 --- a/src/paperscout/storage.py +++ b/src/paperscout/storage.py @@ -265,7 +265,7 @@ def save(self) -> None: class UserWatchlist: - """Slack users’ watchlists: author substring or numeric paper id per row.""" + """Slack users' watchlists: author substring or numeric paper id per row.""" def __init__(self, pool: ThreadedConnectionPool): self._pool = pool