From 3dbe34f6784313a65bbaaa04776641f58e2c6143 Mon Sep 17 00:00:00 2001 From: Nemo Date: Fri, 10 Apr 2026 17:18:52 +0200 Subject: [PATCH 1/4] Adds MacOS and improved history support - Automatic browser history detection is much better - Improve tests - MacOS menu bar support, looks like hackerbar --- .github/workflows/test.yml | 7 +- .gitmodules | 3 - .python-version | 1 + CHANGELOG.md | 9 +- README.md | 36 +--- hackertray/__init__.py | 342 +++++++------------------------ hackertray/chrome.py | 33 --- hackertray/firefox.py | 75 ------- hackertray/hackernews.py | 5 +- hackertray/history.py | 326 ++++++++++++++++++++++++++++++ hackertray/linux.py | 245 ++++++++++++++++++++++ hackertray/macos.py | 401 +++++++++++++++++++++++++++++++++++++ hackertray/version.py | 7 +- pyproject.toml | 11 +- shared-modules | 1 - test/chrome_test.py | 15 -- test/conftest.py | 20 ++ test/firefox_test.py | 50 ----- test/history_test.py | 300 +++++++++++++++++++++++++++ test/hn_test.py | 33 ++- test/macos_ui_test.py | 378 ++++++++++++++++++++++++++++++++++ test/news_fixture.json | 1 + test/safari/History.db | Bin 0 -> 12288 bytes test/version_test.py | 24 ++- uv.lock | 288 +++++++++++++++++++++++++- 25 files changed, 2113 insertions(+), 498 deletions(-) delete mode 100644 .gitmodules create mode 100644 .python-version delete mode 100644 hackertray/chrome.py delete mode 100644 hackertray/firefox.py create mode 100644 hackertray/history.py create mode 100644 hackertray/linux.py create mode 100644 hackertray/macos.py delete mode 160000 shared-modules delete mode 100644 test/chrome_test.py create mode 100644 test/conftest.py delete mode 100644 test/firefox_test.py create mode 100644 test/history_test.py create mode 100644 test/macos_ui_test.py create mode 100644 test/news_fixture.json create mode 100644 test/safari/History.db diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b91ede2..594f3b0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,11 +8,14 @@ on: jobs: test: - runs-on: ubuntu-latest strategy: matrix: + os: [ubuntu-latest, macos-latest] python-version: ["3.11", "3.12", "3.13", "3.14"] + runs-on: ${{ matrix.os }} + env: + CI: "true" steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v8 - run: uv run --python ${{ matrix.python-version }} --with pytest python -m pytest diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 1e7a990..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "shared-modules"] - path = shared-modules - url = https://github.com/flathub/shared-modules.git diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1a4d0..8f32553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,11 @@ This file will only list released and supported versions, usually skipping over 5.0.0 ===== -* Migrated from setup.py to pyproject.toml +* Upgrades for modern python (3.11+), using uv and pyproject.toml. * Switched from requests to urllib (zero external dependencies) -* Switched from custom appindicator shim to native AppIndicator3 via GObject introspection -* Migrated icon loading to importlib.resources -* Modernized version detection using importlib.metadata -* Requires Python 3.11+ +* Switched to AppIndicator3 on Linux +* Adds MacOS support +* Improved history scanning, works for dozens of browsers 4.0.2 ===== diff --git a/README.md b/README.md index bba9dde..2ee88aa 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ [![Coverage Status](https://coveralls.io/repos/github/captn3m0/hackertray/badge.svg?branch=master)](https://coveralls.io/github/captn3m0/hackertray?branch=master) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/captn3m0/hackertray/test.yml) -HackerTray is a simple [Hacker News](https://news.ycombinator.com/) Linux application -that lets you view top HN stories in your System Tray. It uses appindicator where available, -but provides a Gtk StatusIcon fallback in case AppIndicator is not available. +HackerTray is a simple [Hacker News](https://news.ycombinator.com/) application +that lets you view top HN stories in your System Tray. On Linux it uses appindicator where available +(with a Gtk StatusIcon fallback). On macOS it uses a native status bar menu via pyobjc. The inspiration for this came from [Hacker Bar](https://web.archive.org/web/20131126173924/http://hackerbarapp.com/) (now dead), which was Mac-only. @@ -47,34 +47,19 @@ HackerTray will automatically check the latest version on startup, and inform yo HackerTray accepts its various options via the command line. Run `hackertray -h` to see all options. Currently the following switches are supported: 1. `-c`: Enables comments support. Clicking on links will also open the comments page on HN. Can be switched off via the UI, but the setting is not remembered. -2. `--chrome PROFILE-PATH`: Specifying a profile path to a chrome directory will make HackerTray read the Chrome History file to mark links as read. Links are checked once every 5 minutes, which is when the History file is copied (to override the lock in case Chrome is open), searched using sqlite and deleted. This feature is still experimental. -3. `--firefox PROFILE-PATH`: Specify path to a firefox profile directory. HackerTray will read your firefox history from this profile, and use it to mark links as read. Pass `auto` as PROFILE-PATH to automatically read the default profile and use that. -4. `--reverse` (or `-r`). Switches the order for the elements in the menu, so Quit is at top. Use this if your system bar is at the bottom of the screen. +2. `--reverse` (or `-r`): Switches the order for the elements in the menu, so Quit is at top. Use this if your system bar is at the bottom of the screen. +3. `--verbose`: Enable debug logging. -Note that the `--chrome` and `--firefox` options are independent, and can be used together. However, they cannot be specified multiple times (so reading from 2 chrome profiles is not possible). +Browser history is automatically discovered from all installed browsers (Chrome, Firefox, Safari, Brave, Edge, Arc, and many more). All profiles are searched. -### Google Chrome Profile Path - -Where your Profile is stored depends on [which version of chrome you are using](https://chromium.googlesource.com/chromium/src.git/+/62.0.3202.58/docs/user_data_dir.md#linux): - -- [Chrome Stable] `~/.config/google-chrome/Default` -- [Chrome Beta] `~/.config/google-chrome-beta/Default` -- [Chrome Dev] `~/.config/google-chrome-unstable/Default` -- [Chromium] `~/.config/chromium/Default` - -Replace `Default` with `Profile 1`, `Profile 2` or so on if you use multiple profiles on Chrome. Note that the `--chrome` option accepts a `PROFILE-PATH`, not the History file itself. Also note that sometimes `~` might not be set, so you might need to use the complete path (such as `/home/nemo/.config/google-chrome/Default/`). - -### Firefox Profile Path - -The default firefox profile path is `~/.mozilla/firefox/*.default`, where `*` denotes a random 8 digit string. You can also read `~/.mozilla/firefox/profiles.ini` to get a list of profiles. Alternatively, just pass `auto` and HackerTray will pick the default profile automatically. +Options can also be set in `~/.config/hackertray/hackertray.ini` (or `~/.config/hackertray.ini`): ## Features 1. Minimalist Approach to HN 2. Opens links in your default browser -3. Remembers which links you opened, even if you opened them outside of HackerTray -4. Shows Points/Comment count in a simple format -5. Reads your Google Chrome/Firefox History file to determine which links you've already read (even if you may not have opened them via HackerTray) +3. Shows Points/Comment count in a simple format +4. Reads your browser history to mark which links you've already visited ### Troubleshooting @@ -101,7 +86,8 @@ On every launch, a request is made to `https://pypi.python.org/pypi/hackertray/j ## Credits -- Mark Rickert for [Hacker Bar](http://hackerbarapp.com/) (No longer active) +- Mark Rickert for [Hacker Bar](https://github.com/MohawkApps/Hacker-Bar) (No longer active, MIT License). The macOS port references Hacker Bar's original design. +- [browser-history](https://github.com/browser-history/browser-history) (Apache 2.0) — browser history discovery patterns were informed by this project. - [Giridaran Manivannan](https://github.com/ace03uec) for troubleshooting instructions. - [@cheeaun](https://github.com/cheeaun) for the [Unofficial Hacker News API](https://github.com/cheeaun/node-hnapi/) diff --git a/hackertray/__init__.py b/hackertray/__init__.py index 4296291..565c1bb 100644 --- a/hackertray/__init__.py +++ b/hackertray/__init__.py @@ -1,290 +1,94 @@ #!/usr/bin/env python import os -import urllib.error -import subprocess -import importlib -import importlib.resources - -if(os.environ.get('CI') != 'true'): - import gi - gi.require_version('Gtk', '3.0') - from gi.repository import Gtk,GLib - import webbrowser - gi.require_version('AppIndicator3', '0.1') - from gi.repository import AppIndicator3 as AppIndicator - -import json +import sys import argparse -from os.path import expanduser -import signal +import configparser +import logging +from pathlib import Path from .hackernews import HackerNews -from .chrome import Chrome -from .firefox import Firefox from .version import Version +_IS_MACOS = sys.platform == "darwin" -class HackerNewsApp: - HN_URL_PREFIX = "https://news.ycombinator.com/item?id=" - UPDATE_URL = "https://github.com/captn3m0/hackertray#upgrade" - ABOUT_URL = "https://github.com/captn3m0/hackertray" - - def __init__(self, args): - # Load the database - home = expanduser("~") - with open(home + '/.hackertray.json', 'a+') as content_file: - content_file.seek(0) - content = content_file.read() - try: - self.db = set(json.loads(content)) - except ValueError: - self.db = set() - - # create an indicator applet - self.ind = AppIndicator.Indicator.new("Hacker Tray", "hacker-tray", AppIndicator.IndicatorCategory.APPLICATION_STATUS) - self.ind.set_status(AppIndicator.IndicatorStatus.ACTIVE) - self.ind.set_icon_theme_path(self._icon_theme_path()) - icon_name = "hacker-tray-light" if self._is_light_theme() else "hacker-tray" - self.ind.set_icon(icon_name) - - # create a menu - self.menu = Gtk.Menu() - - self.commentState = args.comments - self.reverse = args.reverse - self.chrome_data_directory = args.chrome - - # Resolve firefox: None = not requested, "auto" = detect, else = specific path - self.firefox_explicit = args.firefox is not None and args.firefox != "auto" - if args.firefox == "auto": - self.firefox_data_directory = Firefox.default_firefox_profile_path() - else: - self.firefox_data_directory = args.firefox - - # create items for the menu - separator, settings, about, refresh, quit - menuSeparator = Gtk.SeparatorMenuItem() - menuSeparator.show() - self.add(menuSeparator) - - # Settings submenu - settingsItem = Gtk.MenuItem.new_with_label("Settings") - settingsMenu = Gtk.Menu() - settingsItem.set_submenu(settingsMenu) - - btnComments = Gtk.CheckMenuItem.new_with_label("Open Comments") - btnComments.set_active(args.comments) - btnComments.connect("toggled", self.toggleComments) - settingsMenu.append(btnComments) - btnComments.show() - - btnReverse = Gtk.CheckMenuItem.new_with_label("Reverse Ordering") - btnReverse.set_active(args.reverse) - btnReverse.connect("toggled", self.toggleReverse) - settingsMenu.append(btnReverse) - btnReverse.show() - - # Only show Firefox toggle if --firefox was unset or "auto" - if not self.firefox_explicit: - btnFirefox = Gtk.CheckMenuItem.new_with_label("Detect Firefox read items") - btnFirefox.set_active(self.firefox_data_directory is not None) - btnFirefox.connect("toggled", self.toggleFirefox) - settingsMenu.append(btnFirefox) - btnFirefox.show() - - self.add(settingsItem) - settingsItem.show() - - btnAbout = Gtk.MenuItem.new_with_label("About") - btnAbout.show() - btnAbout.connect("activate", self.showAbout) - self.add(btnAbout) - - btnRefresh = Gtk.MenuItem.new_with_label("Refresh") - btnRefresh.show() - btnRefresh.connect("activate", self.refresh, True) - self.add(btnRefresh) - - if Version.new_available(): - btnUpdate = Gtk.MenuItem.new_with_label("New Update Available") - btnUpdate.show() - btnUpdate.connect('activate', self.showUpdate) - self.add(btnUpdate) - - btnQuit = Gtk.MenuItem.new_with_label("Quit") - btnQuit.show() - btnQuit.connect("activate", self.quit) - self.add(btnQuit) - self.menu.show() - self.ind.set_menu(self.menu) - - self.refresh() - - def add(self, item): - if self.reverse: - self.menu.prepend(item) - else: - self.menu.append(item) - - def toggleComments(self, widget): - """Whether comments page is opened or not""" - self.commentState = widget.get_active() - def toggleReverse(self, widget): - self.reverse = widget.get_active() +def _load_config(): + """Load config from ~/.config/hackertray/hackertray.ini or ~/.config/hackertray.ini.""" + config_dir = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + candidates = [ + config_dir / "hackertray" / "hackertray.ini", + config_dir / "hackertray.ini", + ] - def toggleFirefox(self, widget): - if widget.get_active(): - try: - self.firefox_data_directory = Firefox.default_firefox_profile_path() - except RuntimeError: - print("[+] Could not find Firefox profile") - widget.set_active(False) - return - else: - self.firefox_data_directory = None + cp = configparser.ConfigParser() + for path in candidates: + if path.is_file(): + cp.read(path) + break - def showUpdate(self, widget): - """Handle the update button""" - webbrowser.open(HackerNewsApp.UPDATE_URL) - # Remove the update button once clicked - self.menu.remove(widget) + if "hackertray" not in cp: + return {} - def showAbout(self, widget): - """Handle the about btn""" - webbrowser.open(HackerNewsApp.ABOUT_URL) + section = cp["hackertray"] + defaults = {} - # ToDo: Handle keyboard interrupt properly - def quit(self, widget, data=None): - """ Handler for the quit button""" - l = list(self.db)[-200:] - home = expanduser("~") + # Boolean options + for key in ("comments", "reverse", "verbose"): + if key in section: + defaults[key] = section.getboolean(key) + if "macos-icon-color" in section: + defaults["macos_icon_color"] = section["macos-icon-color"] - with open(home + '/.hackertray.json', 'w+') as file: - file.write(json.dumps(l)) + return defaults - Gtk.main_quit() - def run(self): - signal.signal(signal.SIGINT, self.quit) - Gtk.main() - return 0 - - def open(self, widget, **args): - """Opens the link in the web browser""" - # We disconnect and reconnect the event in case we have - # to set it to active and we don't want the signal to be processed - if not widget.get_active(): - widget.disconnect(widget.signal_id) - widget.set_active(True) - widget.signal_id = widget.connect('activate', self.open) - - self.db.add(widget.item_id) - webbrowser.open(widget.url) - - # TODO: Add support for Shift+Click or Right Click - # to do the opposite of the current commentState setting - if self.commentState: - webbrowser.open(self.HN_URL_PREFIX + str(widget.hn_id)) - - def addItem(self, item): - """Adds an item to the menu""" - # This is in the case of YC Job Postings, which we skip - if item['points'] == 0 or item['points'] is None: - return - - points = str(item['points']).zfill(3) + "/" + str(item['comments_count']).zfill(3) - - i = Gtk.CheckMenuItem.new_with_label(label="(" + points + ")"+item['title']) - label = i.get_child() - label.set_markup("" + points + " "+item['title']+"".format(points=points, title=item['title'])) - label.set_selectable(False) - - visited = item['history'] or item['id'] in self.db - print(f"[ui] {'visited' if visited else 'unvisited'}: {item['url']}") - - i.url = item['url'] - tooltip = "{url}\nPosted by {user} {timeago}".format(url=item['url'], user=item['user'], timeago=item['time_ago']) - i.set_tooltip_text(tooltip) - i.hn_id = item['id'] - i.item_id = item['id'] - i.set_active(visited) - i.signal_id = i.connect('activate', self.open) - if self.reverse: - self.menu.append(i) - else: - self.menu.prepend(i) - i.show() - - def refresh(self, widget=None, no_timer=False): - """Refreshes the menu """ - try: - # Create an array of 20 false to denote matches in History - searchResults = [False]*20 - data = list(reversed(HackerNews.getHomePage()[0:20])) - urls = [item['url'] for item in data] - if self.chrome_data_directory: - searchResults = self.mergeBoolArray(searchResults, Chrome.search(urls, self.chrome_data_directory)) - - if self.firefox_data_directory: - searchResults = self.mergeBoolArray(searchResults, Firefox.search(urls, self.firefox_data_directory)) - - # Remove all the current stories - for i in self.menu.get_children(): - if hasattr(i, 'url'): - self.menu.remove(i) - - # Add back all the refreshed news - for index, item in enumerate(data): - item['history'] = searchResults[index] - if item['url'].startswith('item?id='): - item['url'] = "https://news.ycombinator.com/" + item['url'] - - self.addItem(item) - # Catch network errors - except urllib.error.URLError as e: - print("[+] There was an error in fetching news items") - finally: - # Call every 10 minutes - if not no_timer: - GLib.timeout_add(10 * 30 * 1000, self.refresh) - - # Merges two boolean arrays, using OR operation against each pair - def mergeBoolArray(self, original, patch): - for index, var in enumerate(original): - original[index] = original[index] or patch[index] - return original - - @staticmethod - def _icon_theme_path(): - """Return the icon data dir as a host-accessible path. +def main(): + parser = argparse.ArgumentParser(description="Hacker News in your System Tray") + parser.add_argument("-v", "--version", action="version", version=Version.current()) + parser.add_argument( + "-c", + "--comments", + dest="comments", + default=False, + action="store_true", + help="Load the HN comments link for the article as well", + ) + parser.add_argument( + "-r", + "--reverse", + dest="reverse", + default=False, + action="store_true", + help="Reverse the order of items. Use if your status bar is at the bottom of the screen", + ) + parser.add_argument( + "--macos-icon-color", + dest="macos_icon_color", + default="orange", + choices=["black", "white", "none", "orange", "green"], + help="Status bar icon background color (macOS only)", + ) + parser.add_argument( + "--verbose", + dest="verbose", + default=False, + action="store_true", + help="Enable debug logging", + ) + + # Load config file defaults, CLI flags take precedence + config_defaults = _load_config() + parser.set_defaults(**config_defaults) - AppIndicator sends this path over D-Bus to the tray host, which runs - outside the Flatpak sandbox. Inside a Flatpak, /app/ paths are not - accessible from the host, so we translate via /.flatpak-info.""" - data_dir = str(importlib.resources.files('hackertray.data')) - if os.path.exists("/.flatpak-info"): - import configparser - info = configparser.ConfigParser() - info.read("/.flatpak-info") - app_path = info.get("Instance", "app-path") - data_dir = app_path + data_dir.removeprefix("/app") - return data_dir + args = parser.parse_args() - @staticmethod - def _is_light_theme(): - settings = Gtk.Settings.get_default() - if settings and settings.get_property("gtk-application-prefer-dark-theme"): - return False + if _IS_MACOS: + from .macos import main_macos + main_macos(args) + else: + from .linux import HackerNewsApp -def main(): - parser = argparse.ArgumentParser(description='Hacker News in your System Tray') - parser.add_argument('-v', '--version', action='version', version=Version.current()) - parser.add_argument('-c', '--comments', dest='comments', default=False, action='store_true', help="Load the HN comments link for the article as well") - parser.add_argument('--chrome', dest='chrome', help="Specify a Google Chrome Profile directory to use for matching chrome history") - parser.add_argument('--firefox', dest='firefox', help="Specify a Firefox Profile directory to use for matching firefox history. Pass auto to automatically pick the default profile") - parser.add_argument('-r', '--reverse', dest='reverse', default=False, action='store_true', help="Reverse the order of items. Use if your status bar is at the bottom of the screen") - args = parser.parse_args() - indicator = HackerNewsApp(args) - indicator.run() + indicator = HackerNewsApp(args) + indicator.run() diff --git a/hackertray/chrome.py b/hackertray/chrome.py deleted file mode 100644 index 4335ec9..0000000 --- a/hackertray/chrome.py +++ /dev/null @@ -1,33 +0,0 @@ - -import sqlite3 -import shutil -import tempfile -import os -import sys - - -class Chrome: - - @staticmethod - def search(urls, config_folder_path): - file_name = os.path.abspath(config_folder_path + '/History') - if not os.path.isfile(file_name): - print("ERROR: Could not find Chrome history file", file=sys.stderr) - sys.exit(1) - fd, tmp_path = tempfile.mkstemp(prefix='hackertray_chrome_') - try: - os.close(fd) - shutil.copyfile(file_name, tmp_path) - src = sqlite3.connect(tmp_path) - conn = sqlite3.connect(":memory:") - src.backup(conn) - src.close() - finally: - os.unlink(tmp_path) - db = conn.cursor() - result = [] - for url in urls: - db.execute('SELECT url from urls WHERE url=:url', {"url": url}) - result.append(db.fetchone() is not None) - conn.close() - return result diff --git a/hackertray/firefox.py b/hackertray/firefox.py deleted file mode 100644 index c9f153e..0000000 --- a/hackertray/firefox.py +++ /dev/null @@ -1,75 +0,0 @@ -import sqlite3 -import shutil -import tempfile -import os -import sys -from pathlib import Path -import configparser - -class Firefox: - HISTORY_FILE_NAME = '/places.sqlite' - - @staticmethod - def default_firefox_profile_path(): - config_home = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")) - candidates = [ - Path(config_home) / "mozilla" / "firefox", - Path.home() / ".config" / "mozilla" / "firefox", - Path.home() / ".mozilla" / "firefox", - Path.home() / ".var" / "app" / "org.mozilla.firefox" / ".mozilla" / "firefox", - ] - firefox_dir = None - for candidate in candidates: - if (candidate / "profiles.ini").exists(): - firefox_dir = candidate - break - if firefox_dir is None: - raise RuntimeError("Couldn't find Firefox profiles.ini") - - parser = configparser.ConfigParser() - parser.read(firefox_dir / "profiles.ini") - - # Prefer the active install's locked profile (modern Firefox) - for section in parser.sections(): - if section.startswith("Install") and parser.has_option(section, "Default"): - rel_path = parser[section]["Default"] - profile_path = firefox_dir / rel_path - if profile_path.is_dir(): - return str(profile_path) - - # Fall back to the section marked Default=1 - for section in parser.sections(): - if parser.has_option(section, "Default") and parser[section]["Default"] == "1": - if parser.has_option(section, "IsRelative") and parser[section]["IsRelative"] == "1": - profile_path = firefox_dir / parser[section]["Path"] - else: - profile_path = Path(parser[section]["Path"]) - if profile_path.is_dir(): - return str(profile_path) - - raise RuntimeError("Couldn't find default Firefox profile") - - @staticmethod - def search(urls, config_folder_path): - file_name = os.path.abspath(config_folder_path + Firefox.HISTORY_FILE_NAME) - if not os.path.isfile(file_name): - print("ERROR: Could not find Firefox history file, using %s" % file_name) - sys.exit(1) - fd, tmp_path = tempfile.mkstemp(prefix='hackertray_firefox_') - try: - os.close(fd) - shutil.copyfile(file_name, tmp_path) - src = sqlite3.connect(tmp_path) - conn = sqlite3.connect(":memory:") - src.backup(conn) - src.close() - finally: - os.unlink(tmp_path) - db = conn.cursor() - - result = [] - for url in urls: - db.execute('SELECT url from moz_places WHERE url=:url', {"url": url}) - result.append(db.fetchone() is not None) - conn.close() - return result diff --git a/hackertray/hackernews.py b/hackertray/hackernews.py index 20cb3b1..c851f02 100644 --- a/hackertray/hackernews.py +++ b/hackertray/hackernews.py @@ -2,13 +2,10 @@ import json import urllib.request -urls = [ - 'https://node-hnapi.herokuapp.com/' -] +urls = ["https://node-hnapi.herokuapp.com/"] class HackerNews: - @staticmethod def getHomePage(): random.shuffle(urls) diff --git a/hackertray/history.py b/hackertray/history.py new file mode 100644 index 0000000..9311339 --- /dev/null +++ b/hackertray/history.py @@ -0,0 +1,326 @@ +"""Browser history discovery and search. + +Discovers all browser history databases on the system using glob patterns, +then searches them for visited URLs. Supports Chromium-based, Firefox-based, +and Safari browsers. +""" + +from __future__ import annotations + +import enum +import logging +import os +import re +import shutil +import sqlite3 +import sys +import tempfile +from dataclasses import dataclass +from pathlib import Path + +logger = logging.getLogger(__name__) + +_IS_MACOS = sys.platform == "darwin" +_IS_LINUX = sys.platform.startswith("linux") + +_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +# ── Schema types ────────────────────────────────────────────────────── + + +class HistorySchema(enum.Enum): + """Database schema for a browser family.""" + + CHROMIUM = ("History", "urls", "url") + FIREFOX = ("places.sqlite", "moz_places", "url") + SAFARI = ("History.db", "history_items", "url") + + def __init__(self, db_file: str, table: str, column: str): + self.db_file = db_file + self.table = table + self.column = column + + +# ── Glob-based discovery ────────────────────────────────────────────── + +# Each entry: (schema, label, {platform: [glob patterns relative to ~]}) +# Globs use * to match any profile directory. + +_BROWSERS: list[tuple[HistorySchema, str, dict[str, list[str]]]] = [ + # Chromium-based + ( + HistorySchema.CHROMIUM, + "Chrome", + { + "macos": ["Library/Application Support/Google/Chrome/*/History"], + "linux": [".config/google-chrome/*/History"], + }, + ), + ( + HistorySchema.CHROMIUM, + "Chromium", + { + "macos": ["Library/Application Support/Chromium/*/History"], + "linux": [".config/chromium/*/History"], + }, + ), + ( + HistorySchema.CHROMIUM, + "Brave", + { + "macos": [ + "Library/Application Support/BraveSoftware/Brave-Browser/*/History" + ], + "linux": [".config/BraveSoftware/Brave-Browser/*/History"], + }, + ), + ( + HistorySchema.CHROMIUM, + "Edge", + { + "macos": ["Library/Application Support/Microsoft Edge/*/History"], + "linux": [ + ".config/microsoft-edge/*/History", + ".config/microsoft-edge-dev/*/History", + ], + }, + ), + ( + HistorySchema.CHROMIUM, + "Vivaldi", + { + "macos": ["Library/Application Support/Vivaldi/*/History"], + "linux": [".config/vivaldi/*/History"], + }, + ), + ( + HistorySchema.CHROMIUM, + "Arc", + { + "macos": ["Library/Application Support/Arc/User Data/*/History"], + }, + ), + ( + HistorySchema.CHROMIUM, + "Opera", + { + "macos": ["Library/Application Support/com.operasoftware.Opera/History"], + "linux": [".config/opera/History"], + }, + ), + ( + HistorySchema.CHROMIUM, + "Opera GX", + { + "macos": ["Library/Application Support/com.operasoftware.OperaGX/History"], + }, + ), + ( + HistorySchema.CHROMIUM, + "Yandex", + { + "macos": ["Library/Application Support/Yandex/YandexBrowser/*/History"], + "linux": [".config/yandex-browser/*/History"], + }, + ), + ( + HistorySchema.CHROMIUM, + "Sidekick", + { + "macos": ["Library/Application Support/Sidekick/*/History"], + "linux": [".config/sidekick/*/History"], + }, + ), + ( + HistorySchema.CHROMIUM, + "Thorium", + { + "macos": ["Library/Application Support/Thorium/*/History"], + "linux": [".config/thorium/*/History"], + }, + ), + ( + HistorySchema.CHROMIUM, + "Epic", + { + "macos": ["Library/Application Support/HiddenReflex/Epic/*/History"], + }, + ), + # Firefox-based + ( + HistorySchema.FIREFOX, + "Firefox", + { + "macos": [ + "Library/Application Support/Firefox/Profiles/*/places.sqlite", + "Library/Application Support/Firefox/*/places.sqlite", + ], + "linux": [ + ".mozilla/firefox/*/places.sqlite", + ".var/app/org.mozilla.firefox/.mozilla/firefox/*/places.sqlite", + ], + }, + ), + ( + HistorySchema.FIREFOX, + "LibreWolf", + { + "macos": ["Library/Application Support/LibreWolf/*/places.sqlite"], + "linux": [ + ".librewolf/*/places.sqlite", + ".var/app/io.gitlab.librewolf-community/.librewolf/*/places.sqlite", + ], + }, + ), + ( + HistorySchema.FIREFOX, + "Waterfox", + { + "macos": ["Library/Application Support/Waterfox/*/places.sqlite"], + "linux": [".waterfox/*/places.sqlite"], + }, + ), + ( + HistorySchema.FIREFOX, + "Zen", + { + "macos": [ + "Library/Application Support/zen/Profiles/*/places.sqlite", + "Library/Application Support/zen/*/places.sqlite", + ], + "linux": [".zen/*/places.sqlite"], + }, + ), + ( + HistorySchema.FIREFOX, + "Floorp", + { + "macos": ["Library/Application Support/Floorp/*/places.sqlite"], + "linux": [".floorp/*/places.sqlite"], + }, + ), + # Safari + ( + HistorySchema.SAFARI, + "Safari", + { + "macos": ["Library/Safari/History.db"], + }, + ), +] + +_PLATFORM_KEY = "macos" if _IS_MACOS else "linux" if _IS_LINUX else None + + +# ── Database access ─────────────────────────────────────────────────── + + +class DatabaseError(Exception): + """Raised when a history database cannot be read.""" + + +@dataclass(frozen=True) +class HistoryDB: + """A discovered browser history database.""" + + label: str + schema: HistorySchema + path: Path + + def search(self, urls: list[str]) -> set[str]: + """Return the subset of urls found in this history database.""" + if not urls: + return set() + return _query_urls(self.path, self.schema.table, self.schema.column, urls) + + +def _open_readonly(db_path: Path) -> sqlite3.Connection: + """Copy a database to memory to avoid lock contention.""" + fd, tmp_path = tempfile.mkstemp(prefix="hackertray_") + try: + os.close(fd) + shutil.copyfile(db_path, tmp_path) + src = sqlite3.connect(tmp_path) + conn = sqlite3.connect(":memory:") + src.backup(conn) + src.close() + except (OSError, sqlite3.Error) as e: + raise DatabaseError(f"Failed to read {db_path}: {e}") from e + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + return conn + + +def _query_urls(db_path: Path, table: str, column: str, urls: list[str]) -> set[str]: + """Check which URLs exist in a browser history SQLite database.""" + if not _IDENTIFIER_RE.match(table): + raise ValueError(f"Invalid table name: {table!r}") + if not _IDENTIFIER_RE.match(column): + raise ValueError(f"Invalid column name: {column!r}") + + if not db_path.is_file(): + raise DatabaseError(f"History database not found: {db_path}") + + conn = _open_readonly(db_path) + try: + cursor = conn.cursor() + cursor.execute("CREATE TEMP TABLE _ht_lookup (url TEXT PRIMARY KEY)") + cursor.executemany( + "INSERT OR IGNORE INTO _ht_lookup (url) VALUES (?)", [(u,) for u in urls] + ) + query = f'SELECT l.url FROM _ht_lookup l INNER JOIN "{table}" h ON l.url = h."{column}"' + return {row[0] for row in cursor.execute(query)} + finally: + conn.close() + + +# ── Public API ──────────────────────────────────────────────────────── + + +def discover(home: Path | None = None) -> list[HistoryDB]: + """Discover all browser history databases on this system. + + Returns a list of HistoryDB instances, one per database file found. + """ + if home is None: + home = Path.home() + + results: list[HistoryDB] = [] + for schema, label, platform_globs in _BROWSERS: + patterns = platform_globs.get(_PLATFORM_KEY, []) if _PLATFORM_KEY else [] + for pattern in patterns: + for db_path in sorted(home.glob(pattern)): + if db_path.is_file(): + logger.debug("Found %s history: %s", label, db_path) + results.append(HistoryDB(label=label, schema=schema, path=db_path)) + return results + + +def search(urls: list[str], databases: list[HistoryDB] | None = None) -> set[str]: + """Search browser history for the given URLs. + + Args: + urls: URLs to look up. + databases: Databases to search. If None, discovers all databases. + + Returns: + Set of URLs found in any browser's history. + """ + if not urls: + return set() + + if databases is None: + databases = discover() + + found: set[str] = set() + for db in databases: + try: + found |= db.search(urls) + except (DatabaseError, Exception) as e: + logger.debug("Error searching %s (%s): %s", db.label, db.path, e) + continue + return found diff --git a/hackertray/linux.py b/hackertray/linux.py new file mode 100644 index 0000000..edd3a4b --- /dev/null +++ b/hackertray/linux.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python +"""Linux GTK system tray app for HackerTray.""" + +import logging +import signal +import urllib.error +import webbrowser + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk, GLib + +gi.require_version("AppIndicator3", "0.1") +from gi.repository import AppIndicator3 as AppIndicator + +import importlib +import importlib.resources +import os +import configparser + +from .hackernews import HackerNews +from .version import Version + +logger = logging.getLogger(__name__) + + +class HackerNewsApp: + HN_URL_PREFIX = "https://news.ycombinator.com/item?id=" + UPDATE_URL = "https://github.com/captn3m0/hackertray#upgrade" + ABOUT_URL = "https://github.com/captn3m0/hackertray" + + def __init__(self, args): + # create an indicator applet + self.ind = AppIndicator.Indicator.new( + "Hacker Tray", + "hacker-tray", + AppIndicator.IndicatorCategory.APPLICATION_STATUS, + ) + self.ind.set_status(AppIndicator.IndicatorStatus.ACTIVE) + self.ind.set_icon_theme_path(self._icon_theme_path()) + icon_name = "hacker-tray-light" if self._is_light_theme() else "hacker-tray" + self.ind.set_icon(icon_name) + + # create a menu + self.menu = Gtk.Menu() + + self.commentState = args.comments + self.reverse = args.reverse + + # Discover browser history databases + from .history import discover + + self._history_dbs = discover() + + # create items for the menu - separator, settings, about, refresh, quit + menuSeparator = Gtk.SeparatorMenuItem() + menuSeparator.show() + self.add(menuSeparator) + + # Settings submenu + settingsItem = Gtk.MenuItem.new_with_label("Settings") + settingsMenu = Gtk.Menu() + settingsItem.set_submenu(settingsMenu) + + btnComments = Gtk.CheckMenuItem.new_with_label("Open Comments") + btnComments.set_active(args.comments) + btnComments.connect("toggled", self.toggleComments) + settingsMenu.append(btnComments) + btnComments.show() + + btnReverse = Gtk.CheckMenuItem.new_with_label("Reverse Ordering") + btnReverse.set_active(args.reverse) + btnReverse.connect("toggled", self.toggleReverse) + settingsMenu.append(btnReverse) + btnReverse.show() + + self.add(settingsItem) + settingsItem.show() + + btnAbout = Gtk.MenuItem.new_with_label("About") + btnAbout.show() + btnAbout.connect("activate", self.showAbout) + self.add(btnAbout) + + btnRefresh = Gtk.MenuItem.new_with_label("Refresh") + btnRefresh.show() + btnRefresh.connect("activate", self.refresh, True) + self.add(btnRefresh) + + if Version.new_available(): + btnUpdate = Gtk.MenuItem.new_with_label("New Update Available") + btnUpdate.show() + btnUpdate.connect("activate", self.showUpdate) + self.add(btnUpdate) + + btnQuit = Gtk.MenuItem.new_with_label("Quit") + btnQuit.show() + btnQuit.connect("activate", self.quit) + self.add(btnQuit) + self.menu.show() + self.ind.set_menu(self.menu) + + self.refresh() + + def add(self, item): + if self.reverse: + self.menu.prepend(item) + else: + self.menu.append(item) + + def toggleComments(self, widget): + """Whether comments page is opened or not""" + self.commentState = widget.get_active() + + def toggleReverse(self, widget): + self.reverse = widget.get_active() + + def showUpdate(self, widget): + """Handle the update button""" + webbrowser.open(HackerNewsApp.UPDATE_URL) + # Remove the update button once clicked + self.menu.remove(widget) + + def showAbout(self, widget): + """Handle the about btn""" + webbrowser.open(HackerNewsApp.ABOUT_URL) + + def quit(self, widget, data=None): + """Handler for the quit button""" + Gtk.main_quit() + + def run(self): + signal.signal(signal.SIGINT, self.quit) + Gtk.main() + return 0 + + def open(self, widget, **args): + """Opens the link in the web browser""" + # We disconnect and reconnect the event in case we have + # to set it to active and we don't want the signal to be processed + if not widget.get_active(): + widget.disconnect(widget.signal_id) + widget.set_active(True) + widget.signal_id = widget.connect("activate", self.open) + + webbrowser.open(widget.url) + + # TODO: Add support for Shift+Click or Right Click + # to do the opposite of the current commentState setting + if self.commentState: + webbrowser.open(self.HN_URL_PREFIX + str(widget.hn_id)) + + def addItem(self, item): + """Adds an item to the menu""" + # This is in the case of YC Job Postings, which we skip + if item["points"] == 0 or item["points"] is None: + return + + points = ( + str(item["points"]).zfill(3) + "/" + str(item["comments_count"]).zfill(3) + ) + + i = Gtk.CheckMenuItem.new_with_label(label="(" + points + ")" + item["title"]) + label = i.get_child() + label.set_markup( + "" + + points + + " " + + item["title"] + + "".format(points=points, title=item["title"]) + ) + label.set_selectable(False) + + visited = item["history"] + logger.debug("%s: %s", "visited" if visited else "unvisited", item["url"]) + + i.url = item["url"] + tooltip = "{url}\nPosted by {user} {timeago}".format( + url=item["url"], user=item["user"], timeago=item["time_ago"] + ) + i.set_tooltip_text(tooltip) + i.hn_id = item["id"] + i.item_id = item["id"] + i.set_active(visited) + i.signal_id = i.connect("activate", self.open) + if self.reverse: + self.menu.append(i) + else: + self.menu.prepend(i) + i.show() + + def refresh(self, widget=None, no_timer=False): + """Refreshes the menu""" + try: + data = list(reversed(HackerNews.getHomePage()[0:20])) + urls = [item["url"] for item in data] + + # Search browser history + from .history import search as history_search + + visited_urls = history_search(urls, self._history_dbs) + + # Remove all the current stories + for i in self.menu.get_children(): + if hasattr(i, "url"): + self.menu.remove(i) + + # Add back all the refreshed news + for item in data: + item["history"] = item["url"] in visited_urls + if item["url"].startswith("item?id="): + item["url"] = "https://news.ycombinator.com/" + item["url"] + + self.addItem(item) + # Catch network errors + except urllib.error.URLError as e: + print("[+] There was an error in fetching news items") + finally: + # Call every 10 minutes + if not no_timer: + GLib.timeout_add(10 * 30 * 1000, self.refresh) + + @staticmethod + def _icon_theme_path(): + """Return the icon data dir as a host-accessible path. + + AppIndicator sends this path over D-Bus to the tray host, which runs + outside the Flatpak sandbox. Inside a Flatpak, /app/ paths are not + accessible from the host, so we translate via /.flatpak-info.""" + data_dir = str(importlib.resources.files("hackertray.data")) + if os.path.exists("/.flatpak-info"): + import configparser + + info = configparser.ConfigParser() + info.read("/.flatpak-info") + app_path = info.get("Instance", "app-path") + data_dir = app_path + data_dir.removeprefix("/app") + return data_dir + + @staticmethod + def _is_light_theme(): + settings = Gtk.Settings.get_default() + if settings and settings.get_property("gtk-application-prefer-dark-theme"): + return False diff --git a/hackertray/macos.py b/hackertray/macos.py new file mode 100644 index 0000000..0248924 --- /dev/null +++ b/hackertray/macos.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python +"""Native macOS status bar app for HackerTray using pyobjc.""" + +import logging +import webbrowser +import signal +import urllib.error +from threading import Thread + +logger = logging.getLogger(__name__) + +import objc +from AppKit import ( + NSApplication, + NSStatusBar, + NSMenu, + NSMenuItem, + NSFont, + NSFontAttributeName, + NSForegroundColorAttributeName, + NSBackgroundColorAttributeName, + NSColor, + NSImage, + NSMutableAttributedString, + NSBezierPath, + NSTimer, + NSVariableStatusItemLength, + NSApp, + NSOnState, + NSOffState, + NSApplicationActivationPolicyAccessory, +) +from Foundation import ( + NSObject, + NSMakeRange, + NSMakeRect, + NSMakeSize, + NSMakePoint, + NSDictionary, +) +from PyObjCTools import AppHelper + +from .hackernews import HackerNews +from .version import Version + + +HN_URL_PREFIX = "https://news.ycombinator.com/item?id=" +UPDATE_URL = "https://github.com/captn3m0/hackertray#upgrade" +ABOUT_URL = "https://github.com/captn3m0/hackertray" + + +class HackerTrayDelegate(NSObject): + def init(self): + self = objc.super(HackerTrayDelegate, self).init() + if self is None: + return None + self._comment_state = False + self._reverse = False + self._history_dbs = [] + self._pending_data = None + self._last_data = None # last fetched data for live redraws + return self + + @objc.python_method + def configure(self, args): + from . import history + + self._comment_state = args.comments + self._reverse = args.reverse + self._icon_color = getattr(args, "macos_icon_color", "orange") or "orange" + + # Discover all browser history databases + self._history_dbs = history.discover() + + def applicationDidFinishLaunching_(self, notification): + # Status bar item + self._status_item = NSStatusBar.systemStatusBar().statusItemWithLength_( + NSVariableStatusItemLength + ) + if self._icon_color == "none": + self._status_item.setTitle_("Y") + self._status_item.button().setFont_(NSFont.boldSystemFontOfSize_(14.0)) + else: + self._status_item.button().setImage_(self._make_yc_icon()) + logger.debug("Status bar item created (icon_color=%s)", self._icon_color) + + # Menu + self._menu = NSMenu.alloc().init() + self._menu.setAutoenablesItems_(False) + + # Add a loading item so the menu isn't empty + loading = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( + "Loading...", None, "" + ) + loading.setEnabled_(False) + self._menu.addItem_(loading) + + self._status_item.setMenu_(self._menu) + + # Kick off first refresh + self.refresh_(None) + + # Refresh every 5 minutes + NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( + 300.0, self, b"refresh:", None, True + ) + + # Timer to allow Python signal handling (Ctrl+C) + NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( + 1.0, self, b"signalCheck:", None, True + ) + + def signalCheck_(self, timer): + """No-op timer that gives Python a chance to handle signals.""" + pass + + def refresh_(self, timer): + """Refresh news items in a background thread.""" + logger.debug("refresh_ called, spawning background fetch thread") + Thread(target=self._fetch_and_update, daemon=True).start() + + @objc.python_method + def _fetch_and_update(self): + try: + logger.debug("Fetching HN homepage...") + data = list(reversed(HackerNews.getHomePage()[0:20])) + logger.debug("Got %d items from HN", len(data)) + urls = [item["url"] for item in data] + + # Search browser history + from . import history + + visited_urls = history.search(urls, self._history_dbs) + logger.debug("Found %d visited URLs", len(visited_urls)) + + for item in data: + item["history"] = item["url"] in visited_urls + if item["url"].startswith("item?id="): + item["url"] = "https://news.ycombinator.com/" + item["url"] + + # Stash data and poke the main thread + self._pending_data = data + logger.debug("Posting applyPendingData: to main thread") + self.performSelectorOnMainThread_withObject_waitUntilDone_( + b"applyPendingData:", None, False + ) + except urllib.error.URLError as e: + logger.error("URL error fetching news: %s", e) + except Exception as e: + logger.error("Error during refresh: %s", e, exc_info=True) + + def applyPendingData_(self, ignored): + """Called on the main thread to rebuild the menu from _pending_data.""" + logger.debug("applyPendingData_ called on main thread") + data = self._pending_data + if data is None: + logger.debug("No pending data, skipping") + return + self._pending_data = None + self._last_data = data + self._rebuild_menu(data) + + @objc.python_method + def _rebuild_menu(self, data): + logger.debug("_rebuild_menu: rebuilding with %d items", len(data)) + self._menu.removeAllItems() + + items_to_add = list(data) + if self._reverse: + items_to_add = list(reversed(items_to_add)) + + for item in items_to_add: + if item["points"] == 0 or item["points"] is None: + continue + mi = self._make_news_item(item) + self._menu.addItem_(mi) + + # -- Separator -- + self._menu.addItem_(NSMenuItem.separatorItem()) + + # Refresh + refresh_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( + "Refresh", b"refresh:", "" + ) + refresh_item.setTarget_(self) + self._menu.addItem_(refresh_item) + + # Settings submenu + settings_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( + "Settings", None, "" + ) + settings_menu = NSMenu.alloc().init() + settings_menu.setAutoenablesItems_(False) + + comments_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( + "Open Comments", b"toggleComments:", "" + ) + comments_item.setTarget_(self) + comments_item.setState_(NSOnState if self._comment_state else NSOffState) + settings_menu.addItem_(comments_item) + + reverse_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( + "Reverse Ordering", b"toggleReverse:", "" + ) + reverse_item.setTarget_(self) + reverse_item.setState_(NSOnState if self._reverse else NSOffState) + settings_menu.addItem_(reverse_item) + + settings_item.setSubmenu_(settings_menu) + self._menu.addItem_(settings_item) + + # About + about_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( + "About HackerTray", b"showAbout:", "" + ) + about_item.setTarget_(self) + self._menu.addItem_(about_item) + + # -- Separator + Quit -- + self._menu.addItem_(NSMenuItem.separatorItem()) + quit_item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( + "Quit", b"quit:", "" + ) + quit_item.setTarget_(self) + self._menu.addItem_(quit_item) + logger.debug( + "_rebuild_menu: done, menu has %d items", self._menu.numberOfItems() + ) + + @objc.python_method + def _make_news_item(self, item): + """Create a styled menu item mimicking Hacker Bar's layout.""" + visited = item["history"] + logger.debug("%s: %s", "visited" if visited else "unvisited", item["url"]) + + points = str(item["points"]).zfill(3) + comments = str(item["comments_count"]).zfill(3) + title = item["title"] + + # Build attributed string: "123/456 Title" + mono = NSFont.monospacedDigitSystemFontOfSize_weight_(13.0, 0.0) + regular = NSFont.systemFontOfSize_(13.0) + + score_str = f"{points}/{comments} " + full_str = score_str + title + + attr_str = NSMutableAttributedString.alloc().initWithString_(full_str) + attr_str.addAttribute_value_range_( + NSFontAttributeName, mono, NSMakeRange(0, len(score_str)) + ) + attr_str.addAttribute_value_range_( + NSForegroundColorAttributeName, + NSColor.secondaryLabelColor(), + NSMakeRange(0, len(score_str)), + ) + # Gray background on comment count + comment_start = len(points) + 1 # after "123/" + attr_str.addAttribute_value_range_( + NSBackgroundColorAttributeName, + NSColor.colorWithSRGBRed_green_blue_alpha_(0.5, 0.5, 0.5, 0.15), + NSMakeRange(comment_start, len(comments)), + ) + + title_start = len(score_str) + attr_str.addAttribute_value_range_( + NSFontAttributeName, regular, NSMakeRange(title_start, len(title)) + ) + + # Orange background on "Show HN" tag + show_hn_tag = "Show HN" + if title.startswith("Show HN:"): + attr_str.addAttribute_value_range_( + NSBackgroundColorAttributeName, + NSColor.colorWithSRGBRed_green_blue_alpha_(1.0, 0.6, 0.0, 0.15), + NSMakeRange(title_start, len(show_hn_tag)), + ) + + mi = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( + full_str, b"openLink:", "" + ) + mi.setAttributedTitle_(attr_str) + mi.setTarget_(self) + + tooltip = "{url}\nPosted by {user} {timeago}".format( + url=item["url"], user=item.get("user", ""), timeago=item.get("time_ago", "") + ) + mi.setToolTip_(tooltip) + + mi.setRepresentedObject_( + { + "url": item["url"], + "hn_id": item["id"], + "item_id": item["id"], + } + ) + + if visited: + mi.setState_(NSOnState) + + return mi + + def openLink_(self, sender): + info = sender.representedObject() + if info is None: + return + url = info["url"] + item_id = info["item_id"] + hn_id = info["hn_id"] + + webbrowser.open(url) + + if self._comment_state: + webbrowser.open(HN_URL_PREFIX + str(hn_id)) + + # Redraw to show visited checkmark + if self._last_data is not None: + self._rebuild_menu(self._last_data) + + def toggleComments_(self, sender): + self._comment_state = not self._comment_state + sender.setState_(NSOnState if self._comment_state else NSOffState) + + def toggleReverse_(self, sender): + self._reverse = not self._reverse + if self._last_data is not None: + self._rebuild_menu(self._last_data) + + def showAbout_(self, sender): + webbrowser.open(ABOUT_URL) + + def showUpdate_(self, sender): + webbrowser.open(UPDATE_URL) + + def quit_(self, sender): + AppHelper.stopEventLoop() + + @objc.python_method + def _make_yc_icon(self): + """Create a YC-style icon: colored rounded-rect with contrasting Y.""" + bg_colors = { + "orange": (1.0, 0.4, 0.0), # #FF6600 + "black": (0.0, 0.0, 0.0), + "white": (1.0, 1.0, 1.0), + "green": (0.0, 0.5, 0.0), + } + fg_colors = { + "orange": NSColor.whiteColor(), + "black": NSColor.whiteColor(), + "white": NSColor.blackColor(), + "green": NSColor.whiteColor(), + } + r, g, b = bg_colors[self._icon_color] + bg = NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, 1.0) + fg = fg_colors[self._icon_color] + + size = 18.0 + img = NSImage.alloc().initWithSize_(NSMakeSize(size, size)) + img.lockFocus() + + bg.setFill() + NSBezierPath.bezierPathWithRoundedRect_xRadius_yRadius_( + NSMakeRect(0, 0, size, size), 3.0, 3.0 + ).fill() + + attrs = NSDictionary.dictionaryWithObjects_forKeys_( + [NSFont.monospacedSystemFontOfSize_weight_(13.0, 0.0), fg], + [NSFontAttributeName, NSForegroundColorAttributeName], + ) + y_str = NSMutableAttributedString.alloc().initWithString_attributes_("Y", attrs) + str_size = y_str.size() + y_str.drawAtPoint_( + NSMakePoint( + (size - str_size.width) / 2.0, + (size - str_size.height) / 2.0, + ) + ) + + img.unlockFocus() + img.setTemplate_(False) + return img + + +def main_macos(args): + logging.basicConfig( + level=logging.DEBUG if args.verbose else logging.WARNING, + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + ) + app = NSApplication.sharedApplication() + app.setActivationPolicy_(NSApplicationActivationPolicyAccessory) + + delegate = HackerTrayDelegate.alloc().init() + delegate.configure(args) + app.setDelegate_(delegate) + + # Ctrl+C handler + def handle_sigint(*_): + AppHelper.stopEventLoop() + + signal.signal(signal.SIGINT, handle_sigint) + + AppHelper.runEventLoop() diff --git a/hackertray/version.py b/hackertray/version.py index ab5767d..00e8326 100644 --- a/hackertray/version.py +++ b/hackertray/version.py @@ -3,13 +3,14 @@ import urllib.error from importlib.metadata import version, PackageNotFoundError + class Version: PYPI_URL = "https://pypi.python.org/pypi/hackertray/json" @staticmethod def latest(): with urllib.request.urlopen(Version.PYPI_URL) as r: - return json.loads(r.read())['info']['version'] + return json.loads(r.read())["info"]["version"] @staticmethod def current(): @@ -24,7 +25,9 @@ def new_available(): latest = Version.latest() current = Version.current() try: - if pkg_resources.parse_version(latest) > pkg_resources.parse_version(current): + if pkg_resources.parse_version(latest) > pkg_resources.parse_version( + current + ): print("[+] New version " + latest + " is available") return True else: diff --git a/pyproject.toml b/pyproject.toml index 3cc9e24..aab9353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,7 +13,10 @@ authors = [ ] keywords = ["hacker news", "hn", "tray", "system tray", "icon", "hackertray"] requires-python = ">=3.11" -dependencies = [] +dependencies = [ + "pyobjc-core>=10.0; sys_platform == 'darwin'", + "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'", +] [project.urls] Homepage = "https://captnemo.in/hackertray" @@ -30,3 +33,9 @@ exclude = ["test*"] [tool.setuptools.package-data] "hackertray.data" = ["hacker-tray.png", "hacker-tray-light.png"] + +[dependency-groups] +dev = [ + "pytest>=9.0.3", + "pytest-cov>=7.1.0", +] diff --git a/shared-modules b/shared-modules deleted file mode 160000 index 2f1fb18..0000000 --- a/shared-modules +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2f1fb187252a619f4775e3126395584041b071fb diff --git a/test/chrome_test.py b/test/chrome_test.py deleted file mode 100644 index 1b30683..0000000 --- a/test/chrome_test.py +++ /dev/null @@ -1,15 +0,0 @@ -import unittest -import os - -from hackertray import Chrome - -class ChromeTest(unittest.TestCase): - def runTest(self): - config_folder_path = os.getcwd()+'/test/' - data = Chrome.search([ - "https://github.com/", - "https://news.ycombinator.com/", - "https://github.com/captn3m0/hackertray", - "http://invalid_url/"], - config_folder_path) - self.assertTrue(data == [True,True,True,False]) \ No newline at end of file diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..979fb5e --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,20 @@ +"""Block all network access during tests. + +Any urllib.request.urlopen call that isn't mocked will raise immediately +instead of hitting the network. +""" + +import urllib.request + +import pytest + + +def _blocked_urlopen(*args, **kwargs): + raise RuntimeError( + f"Network access not allowed in tests (urlopen called with {args!r})" + ) + + +@pytest.fixture(autouse=True) +def _no_network(monkeypatch): + monkeypatch.setattr(urllib.request, "urlopen", _blocked_urlopen) diff --git a/test/firefox_test.py b/test/firefox_test.py deleted file mode 100644 index b111d18..0000000 --- a/test/firefox_test.py +++ /dev/null @@ -1,50 +0,0 @@ -import unittest -import os -import tempfile -import shutil -from pathlib import Path - -from hackertray import Firefox - -class FirefoxTest(unittest.TestCase): - def test_history(self): - config_folder_path = os.getcwd()+'/test/' - data = Firefox.search([ - "http://www.hckrnews.com/", - "http://www.google.com/", - "http://wiki.ubuntu.com/", - "http://invalid_url/"], - config_folder_path) - self.assertTrue(data == [True,True,True,False]) - - def test_default(self): - with tempfile.TemporaryDirectory() as tmpdir: - firefox_dir = Path(tmpdir) / "mozilla" / "firefox" - profile_dir = firefox_dir / "x0ran0o9.default" - profile_dir.mkdir(parents=True) - - # Copy test places.sqlite into the fake profile - shutil.copy( - Path(__file__).parent / "places.sqlite", - profile_dir / "places.sqlite", - ) - - profiles_ini = firefox_dir / "profiles.ini" - profiles_ini.write_text( - "[Profile1]\n" - "Name=default\n" - "IsRelative=1\n" - "Path=x0ran0o9.default\n" - "Default=1\n" - ) - - old_val = os.environ.get("XDG_CONFIG_HOME") - try: - os.environ["XDG_CONFIG_HOME"] = tmpdir - result = Firefox.default_firefox_profile_path() - self.assertEqual(result, str(profile_dir)) - finally: - if old_val is None: - os.environ.pop("XDG_CONFIG_HOME", None) - else: - os.environ["XDG_CONFIG_HOME"] = old_val diff --git a/test/history_test.py b/test/history_test.py new file mode 100644 index 0000000..77cb8c3 --- /dev/null +++ b/test/history_test.py @@ -0,0 +1,300 @@ +import unittest +import tempfile +import shutil +from pathlib import Path + +from hackertray.history import ( + HistoryDB, + HistorySchema, + DatabaseError, + discover, + search, + _query_urls, +) + +FIXTURES = Path(__file__).parent + + +class TestChromiumSearch(unittest.TestCase): + def setUp(self): + self.db = HistoryDB( + label="Chrome", schema=HistorySchema.CHROMIUM, path=FIXTURES / "History" + ) + + def test_search_matches(self): + found = self.db.search( + [ + "https://github.com/", + "https://news.ycombinator.com/", + "https://github.com/captn3m0/hackertray", + "http://invalid_url/", + ] + ) + self.assertEqual( + found, + { + "https://github.com/", + "https://news.ycombinator.com/", + "https://github.com/captn3m0/hackertray", + }, + ) + + def test_empty_urls(self): + self.assertEqual(self.db.search([]), set()) + + def test_no_matches(self): + found = self.db.search( + [ + "https://nonexistent.example.com/", + "https://also-not-there.example.org/page", + ] + ) + self.assertEqual(found, set()) + + def test_missing_db_raises(self): + db = HistoryDB( + label="Chrome", + schema=HistorySchema.CHROMIUM, + path=Path("/nonexistent/History"), + ) + with self.assertRaises(DatabaseError): + db.search(["https://example.com/"]) + + def test_copied_fixture(self): + with tempfile.TemporaryDirectory() as tmpdir: + shutil.copy(FIXTURES / "History", Path(tmpdir) / "History") + db = HistoryDB( + label="Chrome", + schema=HistorySchema.CHROMIUM, + path=Path(tmpdir) / "History", + ) + found = db.search(["https://github.com/", "http://invalid_url/"]) + self.assertEqual(found, {"https://github.com/"}) + + +class TestFirefoxSearch(unittest.TestCase): + def setUp(self): + self.db = HistoryDB( + label="Firefox", + schema=HistorySchema.FIREFOX, + path=FIXTURES / "places.sqlite", + ) + + def test_search_matches(self): + found = self.db.search( + [ + "http://www.hckrnews.com/", + "http://www.google.com/", + "http://wiki.ubuntu.com/", + "http://invalid_url/", + ] + ) + self.assertEqual( + found, + { + "http://www.hckrnews.com/", + "http://www.google.com/", + "http://wiki.ubuntu.com/", + }, + ) + + def test_copied_fixture(self): + with tempfile.TemporaryDirectory() as tmpdir: + shutil.copy(FIXTURES / "places.sqlite", Path(tmpdir) / "places.sqlite") + db = HistoryDB( + label="Firefox", + schema=HistorySchema.FIREFOX, + path=Path(tmpdir) / "places.sqlite", + ) + found = db.search(["http://www.hckrnews.com/", "http://invalid_url/"]) + self.assertEqual(found, {"http://www.hckrnews.com/"}) + + +class TestSafariSearch(unittest.TestCase): + def setUp(self): + self.db = HistoryDB( + label="Safari", + schema=HistorySchema.SAFARI, + path=FIXTURES / "safari" / "History.db", + ) + + def test_search_matches(self): + found = self.db.search( + [ + "https://github.com/", + "https://news.ycombinator.com/", + "https://example.com/test", + "https://nonexistent.example.com/", + ] + ) + self.assertEqual( + found, + { + "https://github.com/", + "https://news.ycombinator.com/", + "https://example.com/test", + }, + ) + + def test_empty_urls(self): + self.assertEqual(self.db.search([]), set()) + + def test_no_matches(self): + found = self.db.search(["https://nothing.example.com/"]) + self.assertEqual(found, set()) + + def test_missing_db_raises(self): + db = HistoryDB( + label="Safari", + schema=HistorySchema.SAFARI, + path=Path("/nonexistent/History.db"), + ) + with self.assertRaises(DatabaseError): + db.search(["https://example.com/"]) + + +class TestDiscover(unittest.TestCase): + def test_discover_finds_firefox(self): + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / "home" + profile_dir = ( + home + / "Library" + / "Application Support" + / "Firefox" + / "abc12345.default-release" + ) + profile_dir.mkdir(parents=True) + shutil.copy(FIXTURES / "places.sqlite", profile_dir / "places.sqlite") + dbs = discover(home=home) + firefox_dbs = [db for db in dbs if db.label == "Firefox"] + self.assertTrue(len(firefox_dbs) >= 1) + self.assertEqual(firefox_dbs[0].schema, HistorySchema.FIREFOX) + + def test_discover_finds_chrome(self): + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / "home" + chrome_dir = ( + home + / "Library" + / "Application Support" + / "Google" + / "Chrome" + / "Default" + ) + chrome_dir.mkdir(parents=True) + shutil.copy(FIXTURES / "History", chrome_dir / "History") + dbs = discover(home=home) + chrome_dbs = [db for db in dbs if db.label == "Chrome"] + self.assertTrue(len(chrome_dbs) >= 1) + self.assertEqual(chrome_dbs[0].schema, HistorySchema.CHROMIUM) + + def test_discover_finds_safari(self): + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / "home" + safari_dir = home / "Library" / "Safari" + safari_dir.mkdir(parents=True) + shutil.copy(FIXTURES / "safari" / "History.db", safari_dir / "History.db") + dbs = discover(home=home) + safari_dbs = [db for db in dbs if db.label == "Safari"] + self.assertTrue(len(safari_dbs) >= 1) + self.assertEqual(safari_dbs[0].schema, HistorySchema.SAFARI) + + def test_discover_empty_home(self): + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / "emptyhome" + home.mkdir() + dbs = discover(home=home) + self.assertEqual(dbs, []) + + def test_discover_multiple_chrome_profiles(self): + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / "home" + for profile in ("Default", "Profile 1"): + d = ( + home + / "Library" + / "Application Support" + / "Google" + / "Chrome" + / profile + ) + d.mkdir(parents=True) + shutil.copy(FIXTURES / "History", d / "History") + dbs = discover(home=home) + chrome_dbs = [db for db in dbs if db.label == "Chrome"] + self.assertEqual(len(chrome_dbs), 2) + + +class TestSearch(unittest.TestCase): + def test_search_empty_urls(self): + self.assertEqual(search([]), set()) + + def test_search_with_databases(self): + db = HistoryDB( + label="Chrome", schema=HistorySchema.CHROMIUM, path=FIXTURES / "History" + ) + found = search(["https://github.com/", "http://nope.example.com/"], [db]) + self.assertEqual(found, {"https://github.com/"}) + + def test_search_auto_discover(self): + # With no databases installed in a fake home, returns empty + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / "empty" + home.mkdir() + found = search(["https://github.com/"], discover(home=home)) + self.assertEqual(found, set()) + + def test_search_skips_broken_db(self): + with tempfile.TemporaryDirectory() as tmpdir: + bad_db = Path(tmpdir) / "History" + bad_db.write_bytes(b"not a sqlite database") + broken = HistoryDB( + label="Broken", schema=HistorySchema.CHROMIUM, path=bad_db + ) + good = HistoryDB( + label="Chrome", schema=HistorySchema.CHROMIUM, path=FIXTURES / "History" + ) + found = search(["https://github.com/"], [broken, good]) + self.assertEqual(found, {"https://github.com/"}) + + def test_search_skips_missing_db(self): + missing = HistoryDB( + label="Gone", + schema=HistorySchema.CHROMIUM, + path=Path("/nonexistent/History"), + ) + found = search(["https://github.com/"], [missing]) + self.assertEqual(found, set()) + + +class TestQueryValidation(unittest.TestCase): + def test_invalid_table_name(self): + with self.assertRaises(ValueError, msg="Invalid table name"): + _query_urls(FIXTURES / "History", "urls; DROP TABLE--", "url", ["x"]) + + def test_invalid_column_name(self): + with self.assertRaises(ValueError, msg="Invalid column name"): + _query_urls(FIXTURES / "History", "urls", "url; DROP--", ["x"]) + + def test_corrupt_file_raises(self): + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: + f.write(b"not a sqlite database") + f.flush() + with self.assertRaises(DatabaseError): + _query_urls(Path(f.name), "urls", "url", ["https://x.com/"]) + + +class TestHistorySchema(unittest.TestCase): + def test_chromium_schema(self): + self.assertEqual(HistorySchema.CHROMIUM.db_file, "History") + self.assertEqual(HistorySchema.CHROMIUM.table, "urls") + self.assertEqual(HistorySchema.CHROMIUM.column, "url") + + def test_firefox_schema(self): + self.assertEqual(HistorySchema.FIREFOX.db_file, "places.sqlite") + self.assertEqual(HistorySchema.FIREFOX.table, "moz_places") + + def test_safari_schema(self): + self.assertEqual(HistorySchema.SAFARI.db_file, "History.db") + self.assertEqual(HistorySchema.SAFARI.table, "history_items") diff --git a/test/hn_test.py b/test/hn_test.py index 07bec2d..2405a30 100644 --- a/test/hn_test.py +++ b/test/hn_test.py @@ -1,11 +1,34 @@ +import json import unittest +from pathlib import Path +from unittest import mock + from hackertray import HackerNews +FIXTURE = json.loads((Path(__file__).parent / "news_fixture.json").read_text()) + + class HNTest(unittest.TestCase): - def runTest(self): - data = HackerNews.getHomePage() - self.assertTrue(len(data) > 0) + def test_parses_stories(self): + with mock.patch("hackertray.hackernews.urllib.request.urlopen") as m: + m.return_value.__enter__ = lambda s: s + m.return_value.__exit__ = mock.Mock(return_value=False) + m.return_value.read.return_value = json.dumps(FIXTURE).encode() + + data = HackerNews.getHomePage() + + self.assertEqual(len(data), len(FIXTURE)) + self.assertEqual(data[0]["id"], FIXTURE[0]["id"]) + self.assertEqual(data[0]["title"], FIXTURE[0]["title"]) + + def test_items_have_required_fields(self): + for item in FIXTURE: + self.assertIn("id", item) + self.assertIn("title", item) + self.assertIn("url", item) + self.assertIn("points", item) + self.assertIn("comments_count", item) -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/test/macos_ui_test.py b/test/macos_ui_test.py new file mode 100644 index 0000000..5174ea9 --- /dev/null +++ b/test/macos_ui_test.py @@ -0,0 +1,378 @@ +import sys +import unittest +from unittest import mock + +import pytest + +pytestmark = pytest.mark.skipif(sys.platform != "darwin", reason="macOS-only tests") + +if sys.platform == "darwin": + from AppKit import ( + NSApplication, + NSApplicationActivationPolicyAccessory, + NSMenu, + NSOnState, + NSOffState, + ) + from hackertray.macos import HackerTrayDelegate + + # Need an NSApplication instance for NSMenu to work + _app = NSApplication.sharedApplication() + _app.setActivationPolicy_(NSApplicationActivationPolicyAccessory) + + +def _make_item( + id=1, + title="Test Story", + url="https://example.com", + points=42, + comments_count=10, + history=False, + user="testuser", + time_ago="1 hour ago", +): + return { + "id": id, + "title": title, + "url": url, + "points": points, + "comments_count": comments_count, + "history": history, + "user": user, + "time_ago": time_ago, + } + + +def _make_delegate(): + d = HackerTrayDelegate.alloc().init() + d._menu = NSMenu.alloc().init() + d._menu.setAutoenablesItems_(False) + return d + + +def _news_items(menu): + """Return menu items that represent news stories (have representedObject).""" + return [ + menu.itemAtIndex_(i) + for i in range(menu.numberOfItems()) + if menu.itemAtIndex_(i).representedObject() is not None + ] + + +def _item_titled(menu, title): + """Find a menu item by exact title.""" + for i in range(menu.numberOfItems()): + mi = menu.itemAtIndex_(i) + if mi.title() == title: + return mi + return None + + +class TestMenuStructure(unittest.TestCase): + def setUp(self): + self.d = _make_delegate() + self.data = [ + _make_item(id=i, title=f"Story {i}", points=i * 10 + 10) + for i in range(1, 4) + ] + self.d._rebuild_menu(self.data) + + def test_news_items_count(self): + self.assertEqual(len(_news_items(self.d._menu)), 3) + + def test_has_refresh(self): + self.assertIsNotNone(_item_titled(self.d._menu, "Refresh")) + + def test_has_settings(self): + self.assertIsNotNone(_item_titled(self.d._menu, "Settings")) + + def test_has_about(self): + self.assertIsNotNone(_item_titled(self.d._menu, "About HackerTray")) + + def test_has_quit(self): + self.assertIsNotNone(_item_titled(self.d._menu, "Quit")) + + def test_separators_present(self): + seps = [ + self.d._menu.itemAtIndex_(i) + for i in range(self.d._menu.numberOfItems()) + if self.d._menu.itemAtIndex_(i).isSeparatorItem() + ] + self.assertEqual(len(seps), 2) + + def test_no_visit_hn_item(self): + self.assertIsNone(_item_titled(self.d._menu, "Visit news.ycombinator.com")) + + +class TestNewsItemFormat(unittest.TestCase): + def test_title_contains_points_and_title(self): + d = _make_delegate() + d._rebuild_menu([_make_item(points=7, comments_count=3, title="Hello World")]) + mi = _news_items(d._menu)[0] + self.assertIn("007/003", mi.title()) + self.assertIn("Hello World", mi.title()) + + def test_attributed_title_set(self): + d = _make_delegate() + d._rebuild_menu([_make_item()]) + mi = _news_items(d._menu)[0] + self.assertIsNotNone(mi.attributedTitle()) + + def test_tooltip_contains_url_and_user(self): + d = _make_delegate() + d._rebuild_menu([_make_item(url="https://ex.com", user="alice")]) + mi = _news_items(d._menu)[0] + self.assertIn("https://ex.com", mi.toolTip()) + self.assertIn("alice", mi.toolTip()) + + def test_represented_object(self): + d = _make_delegate() + d._rebuild_menu([_make_item(id=99, url="https://ex.com")]) + info = _news_items(d._menu)[0].representedObject() + self.assertEqual(info["url"], "https://ex.com") + self.assertEqual(info["hn_id"], 99) + self.assertEqual(info["item_id"], 99) + + def test_zero_points_skipped(self): + d = _make_delegate() + d._rebuild_menu([_make_item(points=0)]) + self.assertEqual(len(_news_items(d._menu)), 0) + + def test_none_points_skipped(self): + d = _make_delegate() + d._rebuild_menu([_make_item(points=None)]) + self.assertEqual(len(_news_items(d._menu)), 0) + + +class TestVisitedState(unittest.TestCase): + def test_history_true_sets_checkmark(self): + d = _make_delegate() + d._rebuild_menu([_make_item(history=True)]) + mi = _news_items(d._menu)[0] + self.assertEqual(mi.state(), NSOnState) + + def test_history_false_no_checkmark(self): + d = _make_delegate() + d._rebuild_menu([_make_item(history=False)]) + mi = _news_items(d._menu)[0] + self.assertEqual(mi.state(), NSOffState) + + def test_open_link_opens_browser(self): + d = _make_delegate() + data = [_make_item(id=7, url="https://ex.com")] + d._rebuild_menu(data) + d._last_data = data + mi = _news_items(d._menu)[0] + with mock.patch("webbrowser.open") as m: + d.openLink_(mi) + m.assert_called_with("https://ex.com") + + def test_open_link_with_comments(self): + d = _make_delegate() + d._comment_state = True + data = [_make_item(id=5, url="https://ex.com")] + d._rebuild_menu(data) + d._last_data = data + mi = _news_items(d._menu)[0] + with mock.patch("webbrowser.open") as m: + d.openLink_(mi) + calls = [c[0][0] for c in m.call_args_list] + self.assertIn("https://ex.com", calls) + self.assertIn("https://news.ycombinator.com/item?id=5", calls) + + +class TestReverseOrdering(unittest.TestCase): + def _titles(self, menu): + return [mi.representedObject()["hn_id"] for mi in _news_items(menu)] + + def test_default_order(self): + d = _make_delegate() + data = [_make_item(id=i, points=10) for i in [1, 2, 3]] + d._rebuild_menu(data) + self.assertEqual(self._titles(d._menu), [1, 2, 3]) + + def test_reverse_order(self): + d = _make_delegate() + d._reverse = True + data = [_make_item(id=i, points=10) for i in [1, 2, 3]] + d._rebuild_menu(data) + self.assertEqual(self._titles(d._menu), [3, 2, 1]) + + def test_toggle_reverse_redraws(self): + d = _make_delegate() + data = [_make_item(id=i, points=10) for i in [1, 2, 3]] + d._rebuild_menu(data) + d._last_data = data + self.assertEqual(self._titles(d._menu), [1, 2, 3]) + + # Simulate toggle + fake_sender = _item_titled(d._menu, "Reverse Ordering") + d.toggleReverse_(fake_sender) + self.assertTrue(d._reverse) + self.assertEqual(self._titles(d._menu), [3, 2, 1]) + + +class TestToggleComments(unittest.TestCase): + def test_toggle_on(self): + d = _make_delegate() + self.assertFalse(d._comment_state) + d._rebuild_menu([_make_item()]) + item = _item_titled(d._menu, "Open Comments") + # Submenu item - get from settings submenu + settings = _item_titled(d._menu, "Settings") + sub = settings.submenu() + comments = sub.itemAtIndex_(0) + d.toggleComments_(comments) + self.assertTrue(d._comment_state) + self.assertEqual(comments.state(), NSOnState) + + def test_toggle_off(self): + d = _make_delegate() + d._comment_state = True + d._rebuild_menu([_make_item()]) + settings = _item_titled(d._menu, "Settings") + comments = settings.submenu().itemAtIndex_(0) + d.toggleComments_(comments) + self.assertFalse(d._comment_state) + self.assertEqual(comments.state(), NSOffState) + + +class TestSettingsSubmenu(unittest.TestCase): + def test_comments_initial_state_off(self): + d = _make_delegate() + d._rebuild_menu([_make_item()]) + settings = _item_titled(d._menu, "Settings") + comments = settings.submenu().itemAtIndex_(0) + self.assertEqual(comments.title(), "Open Comments") + self.assertEqual(comments.state(), NSOffState) + + def test_comments_initial_state_on(self): + d = _make_delegate() + d._comment_state = True + d._rebuild_menu([_make_item()]) + settings = _item_titled(d._menu, "Settings") + comments = settings.submenu().itemAtIndex_(0) + self.assertEqual(comments.state(), NSOnState) + + def test_reverse_initial_state(self): + d = _make_delegate() + d._reverse = True + d._rebuild_menu([_make_item()]) + settings = _item_titled(d._menu, "Settings") + reverse = settings.submenu().itemAtIndex_(1) + self.assertEqual(reverse.title(), "Reverse Ordering") + self.assertEqual(reverse.state(), NSOnState) + + +class TestConfigure(unittest.TestCase): + def test_configure_basic(self): + d = _make_delegate() + args = mock.Mock(comments=True, reverse=True, macos_icon_color="orange") + with mock.patch("hackertray.history.discover", return_value=[]): + d.configure(args) + self.assertTrue(d._comment_state) + self.assertTrue(d._reverse) + + def test_configure_discovers_databases(self): + d = _make_delegate() + args = mock.Mock(comments=False, reverse=False, macos_icon_color="orange") + fake_db = mock.Mock() + with mock.patch("hackertray.history.discover", return_value=[fake_db]): + d.configure(args) + self.assertEqual(d._history_dbs, [fake_db]) + + +import json +from pathlib import Path + +_FIXTURE = json.loads((Path(__file__).parent / "news_fixture.json").read_text()) + + +def _launch_delegate(args): + """Create a delegate, configure it, and run applicationDidFinishLaunching_. + + Calls applicationDidFinishLaunching_ directly (bypassing AppHelper.runEventLoop) + to exercise status bar item creation, icon setup, and timer scheduling. + The background refresh thread is suppressed — we populate the menu + synchronously with fixture data afterwards. + """ + fixture_data = [dict(item, history=False) for item in _FIXTURE] + + delegate = HackerTrayDelegate.alloc().init() + with mock.patch("hackertray.history.discover", return_value=[]): + delegate.configure(args) + + # Suppress the background thread spawned by refresh_ + with mock.patch("hackertray.macos.Thread"): + delegate.applicationDidFinishLaunching_(None) + + # Synchronously populate the menu with fixture data + delegate._rebuild_menu(fixture_data) + delegate._last_data = fixture_data + + return delegate + + +class TestAppLaunchIntegration(unittest.TestCase): + """Launch the real macOS app via main_macos, let the run loop tick, then stop.""" + + def test_app_launches_and_populates_menu(self): + args = mock.Mock( + comments=False, + reverse=False, + macos_icon_color="orange", + verbose=False, + ) + delegate = _launch_delegate(args) + + self.assertIsInstance(delegate, HackerTrayDelegate) + + # Status bar item was created with an icon + self.assertIsNotNone(delegate._status_item) + self.assertIsNotNone(delegate._status_item.button().image()) + + # Menu was populated with news items (fixture has 29 with non-zero points) + menu = delegate._menu + news = _news_items(menu) + self.assertEqual(len(news), 29) + + # Verify real fixture titles made it through + titles = [mi.title() for mi in news] + self.assertTrue(any("Intel 486" in t for t in titles)) + self.assertTrue(any("Show HN" in t for t in titles)) + + # Standard menu items present + self.assertIsNotNone(_item_titled(menu, "Refresh")) + self.assertIsNotNone(_item_titled(menu, "Quit")) + + def test_app_launches_with_reverse(self): + args = mock.Mock( + comments=False, + reverse=True, + macos_icon_color="orange", + verbose=False, + ) + delegate = _launch_delegate(args) + + menu = delegate._menu + news = _news_items(menu) + + # With reverse=True, items should be in reversed order + ids = [mi.representedObject()["hn_id"] for mi in news] + # The fixture data is reversed by _rebuild_menu when _reverse is True + fixture_ids = [ + item["id"] for item in _FIXTURE if item["points"] and item["points"] != 0 + ] + self.assertEqual(ids, list(reversed(fixture_ids))) + + def test_app_launches_with_text_icon(self): + args = mock.Mock( + comments=False, + reverse=False, + macos_icon_color="none", + verbose=False, + ) + delegate = _launch_delegate(args) + + self.assertEqual(delegate._status_item.button().title(), "Y") + self.assertIsNone(delegate._status_item.button().image()) diff --git a/test/news_fixture.json b/test/news_fixture.json new file mode 100644 index 0000000..851a1db --- /dev/null +++ b/test/news_fixture.json @@ -0,0 +1 @@ +[{"id":47718812,"title":"Why do we tell ourselves scary stories about AI?","points":18,"user":"lschueller","time":1775831735,"time_ago":"32 minutes ago","comments_count":25,"type":"link","url":"https://www.quantamagazine.org/why-do-we-tell-ourselves-scary-stories-about-ai-20260410/","domain":"quantamagazine.org"},{"id":47676174,"title":"Mysteries of Dropbox: Property-Based Testing of a Distributed Sync Service [pdf]","points":40,"user":"JackeJR","time":1775572868,"time_ago":"3 days ago","comments_count":5,"type":"link","url":"https://www.cis.upenn.edu/~bcpierce/papers/mysteriesofdropbox.pdf","domain":"cis.upenn.edu"},{"id":47718470,"title":"Code is run more than read (2023)","points":11,"user":"facundo_olano","time":1775830368,"time_ago":"an hour ago","comments_count":0,"type":"link","url":"https://olano.dev/blog/code-is-run-more-than-read/","domain":"olano.dev"},{"id":47718830,"title":"CPU-Z and HWMonitor Compromised","points":36,"user":"Wingy","time":1775831849,"time_ago":"30 minutes ago","comments_count":5,"type":"link","url":"https://old.reddit.com/r/pcmasterrace/comments/1sh4e5l/warning_hwmonitor_163_download_on_the_official/","domain":"old.reddit.com"},{"id":47716490,"title":"FBI used iPhone notification data to retrieve deleted Signal messages","points":274,"user":"01-_-","time":1775820544,"time_ago":"4 hours ago","comments_count":121,"type":"link","url":"https://9to5mac.com/2026/04/09/fbi-used-iphone-notification-data-to-retrieve-deleted-signal-messages/","domain":"9to5mac.com"},{"id":47716809,"title":"Intel 486 CPU announced April 10, 1989","points":103,"user":"jnord","time":1775822614,"time_ago":"3 hours ago","comments_count":80,"type":"link","url":"https://dfarq.homeip.net/intel-486-cpu-announced-april-10-1989/","domain":"dfarq.homeip.net"},{"id":47704804,"title":"How NASA built Artemis II’s fault-tolerant computer","points":509,"user":"speckx","time":1775747560,"time_ago":"a day ago","comments_count":191,"type":"link","url":"https://cacm.acm.org/news/how-nasa-built-artemis-iis-fault-tolerant-computer/","domain":"cacm.acm.org"},{"id":47713495,"title":"A new trick brings stability to quantum operations","points":190,"user":"joko42","time":1775793897,"time_ago":"11 hours ago","comments_count":45,"type":"link","url":"https://ethz.ch/en/news-and-events/eth-news/news/2026/04/a-new-trick-brings-stability-to-quantum-operations.html","domain":"ethz.ch"},{"id":47712718,"title":"I still prefer MCP over skills","points":324,"user":"gmays","time":1775786508,"time_ago":"13 hours ago","comments_count":273,"type":"link","url":"https://david.coffee/i-still-prefer-mcp-over-skills/","domain":"david.coffee"},{"id":47717973,"title":"The effects of caffeine consumption do not decay with a ~5 hour half-life","points":52,"user":"swah","time":1775828319,"time_ago":"an hour ago","comments_count":37,"type":"link","url":"https://www.lesswrong.com/posts/vefsxkGWkEMmDcZ7v/the-effects-of-caffeine-consumption-do-not-decay-with-a-5","domain":"lesswrong.com"},{"id":47674894,"title":"Model-Based Testing for Dungeons & Dragons","points":79,"user":"Firfi","time":1775567741,"time_ago":"3 days ago","comments_count":30,"type":"link","url":"https://www.loskutoff.com/blog/model-based-testing-dnd/","domain":"loskutoff.com"},{"id":47717587,"title":"OpenAI backs Illinois bill that would limit when AI labs can be held liable","points":320,"user":"smurda","time":1775826523,"time_ago":"2 hours ago","comments_count":219,"type":"link","url":"https://www.wired.com/story/openai-backs-bill-exempt-ai-firms-model-harm-lawsuits/","domain":"wired.com"},{"id":47714273,"title":"Penguin 'Toxicologists' Find PFAS Chemicals in Remote Patagonia","points":73,"user":"giuliomagnifico","time":1775801746,"time_ago":"9 hours ago","comments_count":27,"type":"link","url":"https://www.ucdavis.edu/health/news/penguin-toxicologists-find-pfas-chemicals-remote-patagonia","domain":"ucdavis.edu"},{"id":47690609,"title":"Deterministic Primality Testing for Limited Bit Width","points":5,"user":"ibobev","time":1775657979,"time_ago":"2 days ago","comments_count":0,"type":"link","url":"https://www.jeremykun.com/2026/04/07/deterministic-miller-rabin/","domain":"jeremykun.com"},{"id":47708818,"title":"Native Instant Space Switching on macOS","points":579,"user":"PaulHoule","time":1775764106,"time_ago":"19 hours ago","comments_count":282,"type":"link","url":"https://arhan.sh/blog/native-instant-space-switching-on-macos/","domain":"arhan.sh"},{"id":47678844,"title":"Show HN: Marimo pair – Reactive Python notebooks as environments for agents","points":62,"user":"manzt","time":1775584066,"time_ago":"3 days ago","comments_count":12,"type":"link","url":"https://github.com/marimo-team/marimo-pair","domain":"github.com"},{"id":47714573,"title":"Artemis II and the invisible hazard on the way to the Moon","points":59,"user":"zeristor","time":1775804657,"time_ago":"8 hours ago","comments_count":46,"type":"link","url":"https://www.ansto.gov.au/news/artemis-ii-and-invisible-hazard-on-way-to-moon-part-1","domain":"ansto.gov.au"},{"id":47712656,"title":"We've raised $17M to build what comes after Git","points":229,"user":"ellieh","time":1775785978,"time_ago":"13 hours ago","comments_count":492,"type":"link","url":"https://blog.gitbutler.com/series-a","domain":"blog.gitbutler.com"},{"id":47708697,"title":"Show HN: QVAC SDK, a universal JavaScript SDK for building local AI applications","points":20,"user":"qvac","time":1775763488,"time_ago":"19 hours ago","comments_count":4,"type":"link","url":"item?id=47708697"},{"id":47718458,"title":"Tech job relocation market is recovering. The competition is growing faster","points":16,"user":"andrewstetsenko","time":1775830319,"time_ago":"an hour ago","comments_count":0,"type":"link","url":"https://relocateme.substack.com/p/the-tech-relocation-job-market-is","domain":"relocateme.substack.com"},{"id":47715339,"title":"Show HN: Keeper – embedded secret store for Go (help me break it)","points":48,"user":"babawere","time":1775811213,"time_ago":"6 hours ago","comments_count":26,"type":"link","url":"https://github.com/agberohq/keeper","domain":"github.com"},{"id":47675906,"title":"Generative art over the years","points":202,"user":"evakhoury","time":1775571923,"time_ago":"3 days ago","comments_count":50,"type":"link","url":"https://blog.veitheller.de/Generative_art_over_the_years.html","domain":"blog.veitheller.de"},{"id":47713744,"title":"CollectWise (YC F24) Is Hiring","points":null,"user":null,"time":1775796185,"time_ago":"10 hours ago","comments_count":0,"type":"job","url":"https://www.ycombinator.com/companies/collectwise/jobs/Ktc6m6o-ai-agent-engineer","domain":"ycombinator.com"},{"id":47686046,"title":"The Art of Risk Management (2017)","points":38,"user":"walterbell","time":1775628944,"time_ago":"2 days ago","comments_count":11,"type":"link","url":"https://www.bcg.com/publications/2017/finance-function-excellence-corporate-development-art-risk-management","domain":"bcg.com"},{"id":47709158,"title":"Charcuterie – Visual similarity Unicode explorer","points":278,"user":"rickcarlino","time":1775765527,"time_ago":"19 hours ago","comments_count":64,"type":"link","url":"https://charcuterie.elastiq.ch/","domain":"charcuterie.elastiq.ch"},{"id":47680005,"title":"RAM Has a Design Flaw from 1966. I Bypassed It [video]","points":326,"user":"surprisetalk","time":1775589272,"time_ago":"3 days ago","comments_count":114,"type":"link","url":"https://www.youtube.com/watch?v=KKbgulTp3FE","domain":"youtube.com"},{"id":47707477,"title":"Old laptops in a colo as low cost servers","points":354,"user":"argentum47","time":1775758959,"time_ago":"21 hours ago","comments_count":207,"type":"link","url":"https://colaptop.pages.dev/","domain":"colaptop.pages.dev"},{"id":47706140,"title":"Unfolder for Mac – A 3D model unfolding tool for creating papercraft","points":285,"user":"codazoda","time":1775753926,"time_ago":"a day ago","comments_count":55,"type":"link","url":"https://www.unfolder.app/","domain":"unfolder.app"},{"id":47718114,"title":"US summons bank bosses over cyber risks from Anthropic's latest AI model","points":10,"user":"ascold","time":1775828837,"time_ago":"an hour ago","comments_count":1,"type":"link","url":"https://www.theguardian.com/technology/2026/apr/10/us-summoned-bank-bosses-to-discuss-cyber-risks-posed-by-anthropic-latest-ai-model","domain":"theguardian.com"},{"id":47707632,"title":"Instant 1.0, a backend for AI-coded apps","points":187,"user":"stopachka","time":1775759459,"time_ago":"21 hours ago","comments_count":97,"type":"link","url":"https://www.instantdb.com/essays/architecture","domain":"instantdb.com"}] \ No newline at end of file diff --git a/test/safari/History.db b/test/safari/History.db new file mode 100644 index 0000000000000000000000000000000000000000..784829980bf56a12824d48ddfa1c8a8290b32d35 GIT binary patch literal 12288 zcmeI&%}T>S5C`zxMCu1<^i;gaf`=+fD|izGqpnhnRci|NASJD<4K#^%H-238>>GIS zQG6MX`T|~@Rw}lF9tHUi*=!~|JBQzGLkZzJ7g6Vs1=OgeRg{^5wQHi~VwO*DY7Yj;so|;_nqpw0l>U{Kal%+HMJ79{HlQ zN2B6-AFnS%9hzDrNn`WbsaGL3<(6CVe6jScwx%0NTm8C2o4}-++MoPXJ2u}ro7>JK zbDo_inh+3x00bZa0SG_<0uX=z1Rwwb2>gM-jGbYtqa!0tW6t$Tp`fmV=)9%!jU*}< zrOm9BVcTEUV|AtTH?&$0hAn?7ItxuRt#_9?=YQ_JFuD*BfB*y_009U< Z00Izz00bZa0SNpDfhlh1c;C+q_B-Z`pBn%G literal 0 HcmV?d00001 diff --git a/test/version_test.py b/test/version_test.py index 0ff4292..1d4ffa5 100644 --- a/test/version_test.py +++ b/test/version_test.py @@ -1,10 +1,24 @@ +import json import unittest +from unittest import mock + from hackertray import Version + class VersionTest(unittest.TestCase): - def runTest(self): - version = Version.latest() - assert version + def test_latest(self): + fake_response = json.dumps({"info": {"version": "9.9.9"}}).encode() + with mock.patch("hackertray.version.urllib.request.urlopen") as m: + m.return_value.__enter__ = lambda s: s + m.return_value.__exit__ = mock.Mock(return_value=False) + m.return_value.read.return_value = fake_response + version = Version.latest() + self.assertEqual(version, "9.9.9") + + def test_current(self): + version = Version.current() + self.assertIsInstance(version, str) + -if __name__ == '__main__': - unittest.main() \ No newline at end of file +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index d2c77fd..7f854ff 100644 --- a/uv.lock +++ b/uv.lock @@ -2,11 +2,293 @@ version = 1 revision = 3 requires-python = ">=3.11" -[options] -exclude-newer = "2026-03-23T16:36:16.327460101Z" -exclude-newer-span = "P7D" +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] [[package]] name = "hackertray" version = "5.0.0" source = { editable = "." } +dependencies = [ + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyobjc-core", marker = "sys_platform == 'darwin'", specifier = ">=10.0" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'", specifier = ">=10.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest-cov", specifier = ">=7.1.0" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyobjc-core" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/b6/d5612eb40be4fd5ef88c259339e6313f46ba67577a95d86c3470b951fce0/pyobjc_core-12.1.tar.gz", hash = "sha256:2bb3903f5387f72422145e1466b3ac3f7f0ef2e9960afa9bcd8961c5cbf8bd21", size = 1000532, upload-time = "2025-11-14T10:08:28.292Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/df/d2b290708e9da86d6e7a9a2a2022b91915cf2e712a5a82e306cb6ee99792/pyobjc_core-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c918ebca280925e7fcb14c5c43ce12dcb9574a33cccb889be7c8c17f3bcce8b6", size = 671263, upload-time = "2025-11-14T09:31:35.231Z" }, + { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d2/29e5e536adc07bc3d33dd09f3f7cf844bf7b4981820dc2a91dd810f3c782/pyobjc_core-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:01c0cf500596f03e21c23aef9b5f326b9fb1f8f118cf0d8b66749b6cf4cbb37a", size = 677370, upload-time = "2025-11-14T09:33:05.273Z" }, + { url = "https://files.pythonhosted.org/packages/1b/f0/4b4ed8924cd04e425f2a07269943018d43949afad1c348c3ed4d9d032787/pyobjc_core-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:177aaca84bb369a483e4961186704f64b2697708046745f8167e818d968c88fc", size = 719586, upload-time = "2025-11-14T09:33:53.302Z" }, + { url = "https://files.pythonhosted.org/packages/25/98/9f4ed07162de69603144ff480be35cd021808faa7f730d082b92f7ebf2b5/pyobjc_core-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:844515f5d86395b979d02152576e7dee9cc679acc0b32dc626ef5bda315eaa43", size = 670164, upload-time = "2025-11-14T09:34:37.458Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/dc076965c96c7f0de25c0a32b7f8aa98133ed244deaeeacfc758783f1f30/pyobjc_core-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:453b191df1a4b80e756445b935491b974714456ae2cbae816840bd96f86db882", size = 712204, upload-time = "2025-11-14T09:35:24.148Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/07/5760735c0fffc65107e648eaf7e0991f46da442ac4493501be5380e6d9d4/pyobjc_framework_cocoa-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f52228bcf38da64b77328787967d464e28b981492b33a7675585141e1b0a01e6", size = 383812, upload-time = "2025-11-14T09:40:53.169Z" }, + { url = "https://files.pythonhosted.org/packages/95/bf/ee4f27ec3920d5c6fc63c63e797c5b2cc4e20fe439217085d01ea5b63856/pyobjc_framework_cocoa-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:547c182837214b7ec4796dac5aee3aa25abc665757b75d7f44f83c994bcb0858", size = 384590, upload-time = "2025-11-14T09:41:17.336Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/0c2e734165abb46215797bd830c4bdcb780b699854b15f2b6240515edcc6/pyobjc_framework_cocoa-12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5a3dcd491cacc2f5a197142b3c556d8aafa3963011110102a093349017705118", size = 384689, upload-time = "2025-11-14T09:41:41.478Z" }, + { url = "https://files.pythonhosted.org/packages/23/3b/b9f61be7b9f9b4e0a6db18b3c35c4c4d589f2d04e963e2174d38c6555a92/pyobjc_framework_cocoa-12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:914b74328c22d8ca261d78c23ef2befc29776e0b85555973927b338c5734ca44", size = 388843, upload-time = "2025-11-14T09:42:05.719Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/f777cc9e775fc7dae77b569254570fe46eb842516b3e4fe383ab49eab598/pyobjc_framework_cocoa-12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:03342a60fc0015bcdf9b93ac0b4f457d3938e9ef761b28df9564c91a14f0129a", size = 384932, upload-time = "2025-11-14T09:42:29.771Z" }, + { url = "https://files.pythonhosted.org/packages/58/27/b457b7b37089cad692c8aada90119162dfb4c4a16f513b79a8b2b022b33b/pyobjc_framework_cocoa-12.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6ba1dc1bfa4da42d04e93d2363491275fb2e2be5c20790e561c8a9e09b8cf2cc", size = 388970, upload-time = "2025-11-14T09:42:53.964Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] From e515c91ac3992ec7a2deb3a80096ad48ba87c4fe Mon Sep 17 00:00:00 2001 From: Nemo Date: Fri, 10 Apr 2026 17:42:20 +0200 Subject: [PATCH 2/4] don't fail fast on tests --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 594f3b0..a16a038 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,7 @@ on: jobs: test: strategy: + fail-fast: false matrix: os: [ubuntu-latest, macos-latest] python-version: ["3.11", "3.12", "3.13", "3.14"] @@ -17,5 +18,5 @@ jobs: CI: "true" steps: - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v8 + - uses: astral-sh/setup-uv@v8.0.0 - run: uv run --python ${{ matrix.python-version }} --with pytest python -m pytest From fdf476b4255aae57dec1e17b4dd19fe41ead40d6 Mon Sep 17 00:00:00 2001 From: Nemo Date: Fri, 10 Apr 2026 18:00:51 +0200 Subject: [PATCH 3/4] Improve tests for history --- .github/workflows/release.yml | 2 + .github/workflows/test.yml | 2 + README.md | 2 +- hackertray/history.py | 5 +- pyproject.toml | 2 +- test/history_test.py | 114 ++++++++++++++++++---------------- uv.lock | 12 ++-- 7 files changed, 78 insertions(+), 61 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fcf50c..419bf55 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,6 +10,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-python@v6 with: python-version: "3.11" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a16a038..dd90377 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,5 +18,7 @@ jobs: CI: "true" steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: astral-sh/setup-uv@v8.0.0 - run: uv run --python ${{ matrix.python-version }} --with pytest python -m pytest diff --git a/README.md b/README.md index 2ee88aa..1ec3953 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Coverage Status](https://coveralls.io/repos/github/captn3m0/hackertray/badge.svg?branch=master)](https://coveralls.io/github/captn3m0/hackertray?branch=master) ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/captn3m0/hackertray/test.yml) -HackerTray is a simple [Hacker News](https://news.ycombinator.com/) application +HackerTray is a cross-platform [Hacker News](https://news.ycombinator.com/) application that lets you view top HN stories in your System Tray. On Linux it uses appindicator where available (with a Gtk StatusIcon fallback). On macOS it uses a native status bar menu via pyobjc. diff --git a/hackertray/history.py b/hackertray/history.py index 9311339..14fc2c4 100644 --- a/hackertray/history.py +++ b/hackertray/history.py @@ -281,7 +281,7 @@ def _query_urls(db_path: Path, table: str, column: str, urls: list[str]) -> set[ # ── Public API ──────────────────────────────────────────────────────── -def discover(home: Path | None = None) -> list[HistoryDB]: +def discover(home: Path | None = None, platform: str | None = None) -> list[HistoryDB]: """Discover all browser history databases on this system. Returns a list of HistoryDB instances, one per database file found. @@ -289,9 +289,10 @@ def discover(home: Path | None = None) -> list[HistoryDB]: if home is None: home = Path.home() + key = platform or _PLATFORM_KEY results: list[HistoryDB] = [] for schema, label, platform_globs in _BROWSERS: - patterns = platform_globs.get(_PLATFORM_KEY, []) if _PLATFORM_KEY else [] + patterns = platform_globs.get(key, []) if key else [] for pattern in patterns: for db_path in sorted(home.glob(pattern)): if db_path.is_file(): diff --git a/pyproject.toml b/pyproject.toml index aab9353..c76f7fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,6 @@ exclude = ["test*"] [dependency-groups] dev = [ - "pytest>=9.0.3", + "pytest>=9.0.2", "pytest-cov>=7.1.0", ] diff --git a/test/history_test.py b/test/history_test.py index 77cb8c3..f709b35 100644 --- a/test/history_test.py +++ b/test/history_test.py @@ -154,74 +154,82 @@ def test_missing_db_raises(self): class TestDiscover(unittest.TestCase): - def test_discover_finds_firefox(self): + def _setup_and_discover(self, rel_path, fixture, platform): + """Create a fake browser profile and run discover.""" with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) / "home" - profile_dir = ( - home - / "Library" - / "Application Support" - / "Firefox" - / "abc12345.default-release" - ) - profile_dir.mkdir(parents=True) - shutil.copy(FIXTURES / "places.sqlite", profile_dir / "places.sqlite") - dbs = discover(home=home) - firefox_dbs = [db for db in dbs if db.label == "Firefox"] - self.assertTrue(len(firefox_dbs) >= 1) - self.assertEqual(firefox_dbs[0].schema, HistorySchema.FIREFOX) - - def test_discover_finds_chrome(self): - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) / "home" - chrome_dir = ( - home - / "Library" - / "Application Support" - / "Google" - / "Chrome" - / "Default" - ) - chrome_dir.mkdir(parents=True) - shutil.copy(FIXTURES / "History", chrome_dir / "History") - dbs = discover(home=home) - chrome_dbs = [db for db in dbs if db.label == "Chrome"] - self.assertTrue(len(chrome_dbs) >= 1) - self.assertEqual(chrome_dbs[0].schema, HistorySchema.CHROMIUM) + dest = home / rel_path + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(FIXTURES / fixture, dest) + return discover(home=home, platform=platform) + + def test_discover_finds_firefox_macos(self): + dbs = self._setup_and_discover( + "Library/Application Support/Firefox/abc12345.default-release/places.sqlite", + "places.sqlite", "macos") + firefox_dbs = [db for db in dbs if db.label == "Firefox"] + self.assertEqual(len(firefox_dbs), 1) + self.assertEqual(firefox_dbs[0].schema, HistorySchema.FIREFOX) + + def test_discover_finds_firefox_linux(self): + dbs = self._setup_and_discover( + ".mozilla/firefox/abc12345.default-release/places.sqlite", + "places.sqlite", "linux") + firefox_dbs = [db for db in dbs if db.label == "Firefox"] + self.assertEqual(len(firefox_dbs), 1) + self.assertEqual(firefox_dbs[0].schema, HistorySchema.FIREFOX) + + def test_discover_finds_chrome_macos(self): + dbs = self._setup_and_discover( + "Library/Application Support/Google/Chrome/Default/History", + "History", "macos") + chrome_dbs = [db for db in dbs if db.label == "Chrome"] + self.assertEqual(len(chrome_dbs), 1) + self.assertEqual(chrome_dbs[0].schema, HistorySchema.CHROMIUM) + + def test_discover_finds_chrome_linux(self): + dbs = self._setup_and_discover( + ".config/google-chrome/Default/History", + "History", "linux") + chrome_dbs = [db for db in dbs if db.label == "Chrome"] + self.assertEqual(len(chrome_dbs), 1) + self.assertEqual(chrome_dbs[0].schema, HistorySchema.CHROMIUM) def test_discover_finds_safari(self): - with tempfile.TemporaryDirectory() as tmpdir: - home = Path(tmpdir) / "home" - safari_dir = home / "Library" / "Safari" - safari_dir.mkdir(parents=True) - shutil.copy(FIXTURES / "safari" / "History.db", safari_dir / "History.db") - dbs = discover(home=home) - safari_dbs = [db for db in dbs if db.label == "Safari"] - self.assertTrue(len(safari_dbs) >= 1) - self.assertEqual(safari_dbs[0].schema, HistorySchema.SAFARI) + dbs = self._setup_and_discover( + "Library/Safari/History.db", + "safari/History.db", "macos") + safari_dbs = [db for db in dbs if db.label == "Safari"] + self.assertEqual(len(safari_dbs), 1) + self.assertEqual(safari_dbs[0].schema, HistorySchema.SAFARI) def test_discover_empty_home(self): with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) / "emptyhome" home.mkdir() - dbs = discover(home=home) - self.assertEqual(dbs, []) + for platform in ("macos", "linux"): + dbs = discover(home=home, platform=platform) + self.assertEqual(dbs, []) + + def test_discover_multiple_chrome_profiles_macos(self): + with tempfile.TemporaryDirectory() as tmpdir: + home = Path(tmpdir) / "home" + for profile in ("Default", "Profile 1"): + d = home / "Library" / "Application Support" / "Google" / "Chrome" / profile + d.mkdir(parents=True) + shutil.copy(FIXTURES / "History", d / "History") + dbs = discover(home=home, platform="macos") + chrome_dbs = [db for db in dbs if db.label == "Chrome"] + self.assertEqual(len(chrome_dbs), 2) - def test_discover_multiple_chrome_profiles(self): + def test_discover_multiple_chrome_profiles_linux(self): with tempfile.TemporaryDirectory() as tmpdir: home = Path(tmpdir) / "home" for profile in ("Default", "Profile 1"): - d = ( - home - / "Library" - / "Application Support" - / "Google" - / "Chrome" - / profile - ) + d = home / ".config" / "google-chrome" / profile d.mkdir(parents=True) shutil.copy(FIXTURES / "History", d / "History") - dbs = discover(home=home) + dbs = discover(home=home, platform="linux") chrome_dbs = [db for db in dbs if db.label == "Chrome"] self.assertEqual(len(chrome_dbs), 2) diff --git a/uv.lock b/uv.lock index 7f854ff..7adf1f5 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,10 @@ version = 1 revision = 3 requires-python = ">=3.11" +[options] +exclude-newer = "2026-04-03T15:52:14.933796379Z" +exclude-newer-span = "P7D" + [[package]] name = "colorama" version = "0.4.6" @@ -138,7 +142,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "pytest", specifier = ">=9.0.3" }, + { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, ] @@ -211,7 +215,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -220,9 +224,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] [[package]] From 4fc022aa9dea48483d4998f82e07e7fe2095e1d2 Mon Sep 17 00:00:00 2001 From: Nemo Date: Sat, 11 Apr 2026 00:51:19 +0200 Subject: [PATCH 4/4] Run coveralls --- .github/workflows/test.yml | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dd90377..1b4cb76 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,4 +21,30 @@ jobs: with: persist-credentials: false - uses: astral-sh/setup-uv@v8.0.0 - - run: uv run --python ${{ matrix.python-version }} --with pytest python -m pytest + - name: Run tests + run: > + uv run --python ${{ matrix.python-version }} + --with pytest --with pytest-cov + python -m pytest + --cov=hackertray + --cov-report=lcov:coverage.lcov + --cov-report=markdown-append:$GITHUB_STEP_SUMMARY + - name: Upload to Coveralls + if: matrix.python-version == '3.14' + uses: coverallsapp/github-action@v2 + with: + file: coverage.lcov + format: lcov + flag-name: run-${{ matrix.os }}-${{ matrix.python-version }} + parallel: true + + coveralls-finish: + needs: test + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Coveralls finished + uses: coverallsapp/github-action@v2 + with: + parallel-finished: true + carryforward: "run-ubuntu-latest-3.14,run-macos-latest-3.14"