Skip to content

Commit 85140f5

Browse files
authored
feat(api): implement persistent caching for API assets (#202)
2 parents 7f00d99 + 714c038 commit 85140f5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+3123
-341
lines changed

AGENTS.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# AGENTS.md
2+
3+
This file provides guidance to agents when working with code in this repository.
4+
5+
- API usage is currently GitHub-only; see [`docs/developers.md`](docs/developers.md:5) for future extensibility.
6+
- Catalog entries are JSON files in [`my_unicorn/catalog/`](my_unicorn/catalog/) with metadata for each app (see [`docs/wiki.md`](docs/wiki.md:276)).
7+
- App-specific configs are stored in `~/.config/my-unicorn/apps/` as JSON (see [`docs/wiki.md`](docs/wiki.md:335)).
8+
- Backup metadata for each app is in `~/Applications/backups/<app>/metadata.json` (see [`docs/wiki.md`](docs/wiki.md:491,505)).
9+
- Batch mode and concurrency are controlled via `~/.config/my-unicorn/settings.conf` (see [`docs/wiki.md`](docs/wiki.md:218,227)).
10+
- Verification requires digest/checksum fields in config/catalog; always use the public parse method for YAML/traditional formats ([`my_unicorn/services/verification_service.py`](my_unicorn/services/verification_service.py:233,263)).
11+
- For verification, if no strong methods and size check fails, raise error ([`my_unicorn/services/verification_service.py`](my_unicorn/services/verification_service.py:569,643)).
12+
- Always log full traceback on verification failures ([`my_unicorn/update.py`](my_unicorn/update.py:1100)).
13+
- CLI commands support install via URL, catalog, update, backup, cache, config, and auth (see [`docs/wiki.md`](docs/wiki.md:41)).
14+
- Manual CLI tests are run via [`scripts/test.bash`](scripts/test.bash:1); some require config files in `$HOME/.config/my-unicorn/apps/`.
15+
- Code style: line length 95, indent 4 spaces, double quotes for strings ([`pyproject.toml`](pyproject.toml:111,141)).
16+
- Pytest uses custom addopts and import mode: `-ra -q --strict-markers --import-mode=importlib--import-mode=importlib` ([`pyproject.toml`](pyproject.toml:67)).

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# Changelog
22
All notable changes to this project will be documented in this file.
33

4+
## v1.6.0-alpha
5+
# BREAKING CHANGES
6+
This release introduces a comprehensive caching mechanism for GitHub release data, enhancing performance and reducing redundant API calls. The caching system is designed to store release information persistently, with a configurable time-to-live (TTL) to ensure data freshness. Additionally, the release includes significant improvements to the logging capabilities of the verification service, providing detailed insights into the verification process and asset handling.
7+
48
## v1.5.1-alpha
59
## v1.5.0-alpha
610
# BREAKING CHANGES

README.md

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,20 @@
1-
Türkçe Açıklama: [README.tr.md](README.tr.md)
1+
Turkish: [README.tr.md](README.tr.md)
22

33
> [!CAUTION]
4-
> This project is in a **alpha phase** due to limited testing at this time.
5-
>
6-
> **Important:** Follow the instructions in the **Releases section** when updating the script.
7-
>
8-
> **Supported OS:** Currently, only Linux is supported.
4+
> - This project is in a **alpha phase** due to limited testing at this time.
5+
> - **Important:** Follow the instructions in the **Releases section** when updating the script.
6+
> - **Supported OS:** Currently, only Linux is supported.
97
108
# **🦄 About my-unicorn**
119

1210
> [!NOTE]
13-
> I always frustrated with the manual AppImage update process and I created this project to automate the process.
14-
>
15-
> This project introduces a Python-based CLI tool that treats AppImages like packages: installable, updatable,
16-
> and manageable via a simple interface.
17-
> Detailed information: [wiki.md](docs/wiki.md)
11+
> My Unicorn is a command-line tool to manage AppImages on Linux. It allows users to install, update, and manage AppImages from GitHub repositories easily. It's designed to simplify the process of handling AppImages, making it more convenient for users to keep their applications up-to-date.
12+
> - Detailed information: [wiki.md](docs/wiki.md)
1813
1914
- **Supported Applications:**
20-
- Super-Productivity, Siyuan, Joplin, Standard-notes, Logseq, QOwnNotes, Tagspaces, Zen-Browser, weektodo, Zettlr, HeroicGamesLauncher, KDiskMark, AppFlowy, Obsidian
15+
- Super-Productivity, Siyuan, Joplin, Standard-notes, Logseq, QOwnNotes, Tagspaces, Zen-Browser, Zettlr, HeroicGamesLauncher, KDiskMark, AppFlowy, Obsidian
2116
- Applications without verification (developer doesn't provide hash):
17+
- WeekToDo
2218
- FreeTube
2319
- Related issue: https://github.com/FreeTubeApp/FreeTube/issues/4720)
2420
- More can be found in the [catalog](my_unicorn/catalog/) folder.

docs/wiki.md

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
- Global Configuration: Store global settings in a configuration file for customization such as download directories, logging levels, and more.
3333

3434
## Helper scripts
35+
3536
- my-unicorn-installer.sh: A script to install my-unicorn and its dependencies.
3637
- It sets up a virtual environment and installs the required Python packages.
3738
- venv-wrapper.bash: Wrapper script around my-unicorn using the python virtual environment
@@ -64,21 +65,37 @@ my-unicorn install appflowy qownotes
6465
my-unicorn install appflowy --no-icon --no-verify
6566
```
6667

67-
### Updates
68+
### Updates & Cache
6869

6970
```bash
70-
# Check for updates without installation
71+
# Check for updates without installation from cache (it creates cache if not exists)
7172
my-unicorn update --check-only
73+
# --refresh-cache: Bypass cache and fetch latest release data from GitHub (forces cache refresh)
74+
my-unicorn update --check-only --refresh-cache
75+
76+
# This example shows how to force refresh cache and update a specific app.
77+
py run.py update qownnotes --refresh-cache
7278

73-
# Update specific apps
79+
# Update specific apps (it creates cache if not exists)
7480
my-unicorn update appflowy,joplin
7581
my-unicorn update appflowy joplin
7682

7783
# Update all installed apps
7884
my-unicorn update
7985
```
8086

81-
### Management
87+
```bash
88+
# Remove all cache related with qownnotes
89+
my-unicorn cache clear qownnotes
90+
91+
# Remove all cache
92+
my-unicorn cache clear --all
93+
94+
# Show cache stats
95+
my-unicorn cache --stats
96+
```
97+
98+
### Catalog & Configuration
8299

83100
```bash
84101
# List installed apps
@@ -158,6 +175,38 @@ my-unicorn backup --migrate
158175
pip install aiohttp uvloop keyring orjson packaging rich
159176
```
160177

178+
### Cache Management
179+
180+
Example zen browser cache:
181+
182+
```bash
183+
{
184+
"cached_at": "2025-08-30T16:55:48.294102+00:00",
185+
"ttl_hours": 24,
186+
"release_data": {
187+
"owner": "zen-browser",
188+
"repo": "desktop",
189+
"version": "1.15.2b",
190+
"prerelease": false,
191+
"assets": [
192+
{
193+
"name": "zen-aarch64.AppImage",
194+
"digest": "sha256:d0417f900e1e3af6a13e201cab54e35c4f50200292fef2c613e867de33c0d326",
195+
"size": 96831888,
196+
"browser_download_url": "https://github.com/zen-browser/desktop/releases/download/1.15.2b/zen-aarch64.AppImage"
197+
},
198+
{
199+
"name": "zen-x86_64.AppImage",
200+
"digest": "sha256:9035c485921102f77fdfaa37536200fd7ce61ec9ae8f694c0f472911df182cbd",
201+
"size": 109846928,
202+
"browser_download_url": "https://github.com/zen-browser/desktop/releases/download/1.15.2b/zen-x86_64.AppImage"
203+
}
204+
],
205+
"original_tag_name": "1.15.2b"
206+
}
207+
}
208+
```
209+
161210
### Config Management
162211

163212
#### Global Config (settings.conf)

my_unicorn/catalog/legcord.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"digest": false,
1515
"skip": false,
1616
"checksum_file": "latest-linux.yml",
17-
"checksum_hash_type": "sha256"
17+
"checksum_hash_type": "sha512"
1818
},
1919
"icon": {
2020
"extraction": true,

my_unicorn/catalog/nuclear.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
},
99
"github": {
1010
"repo": true,
11-
"prerelease": false
11+
"prerelease": true
1212
},
1313
"verification": {
1414
"digest": true,

my_unicorn/cli/parser.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def _add_subcommands(self, parser: argparse.ArgumentParser) -> None:
8585
self._add_list_command(subparsers)
8686
self._add_remove_command(subparsers)
8787
self._add_backup_command(subparsers)
88+
self._add_cache_command(subparsers)
8889
self._add_auth_command(subparsers)
8990
self._add_config_command(subparsers)
9091

@@ -149,9 +150,10 @@ def _add_update_command(self, subparsers) -> None:
149150
help="Update installed AppImages",
150151
epilog="""
151152
Examples:
152-
%(prog)s # Update all installed apps
153-
%(prog)s appflowy joplin # Update specific apps (without comma)
154-
%(prog)s appflowy,joplin # Update specific apps (with comma)
153+
%(prog)s # Update all installed apps
154+
%(prog)s appflowy joplin # Update specific apps (without comma)
155+
%(prog)s appflowy,joplin # Update specific apps (with comma)
156+
%(prog)s --check-only --refresh-cache # Check updates bypassing cache
155157
""",
156158
formatter_class=argparse.RawDescriptionHelpFormatter,
157159
)
@@ -165,6 +167,11 @@ def _add_update_command(self, subparsers) -> None:
165167
action="store_true",
166168
help="Only check for updates without installing",
167169
)
170+
update_parser.add_argument(
171+
"--refresh-cache",
172+
action="store_true",
173+
help="Bypass cache and fetch fresh data from GitHub API (useful for automated scripts)",
174+
)
168175
update_parser.add_argument(
169176
"--verbose", action="store_true", help="Show detailed logging during update"
170177
)
@@ -221,7 +228,7 @@ def _add_remove_command(self, subparsers) -> None:
221228
""",
222229
formatter_class=argparse.RawDescriptionHelpFormatter,
223230
)
224-
(remove_parser.add_argument("apps", nargs="+", help="Application names to remove"),)
231+
remove_parser.add_argument("apps", nargs="+", help="Application names to remove")
225232
remove_parser.add_argument(
226233
"--keep-config", action="store_true", help="Keep configuration files"
227234
)
@@ -336,3 +343,36 @@ def _add_config_command(self, subparsers) -> None:
336343
config_group.add_argument(
337344
"--reset", action="store_true", help="Reset configuration to defaults"
338345
)
346+
347+
def _add_cache_command(self, subparsers) -> None:
348+
"""Add cache command parser.
349+
350+
Args:
351+
subparsers: The subparsers object to add the cache command to
352+
353+
"""
354+
cache_parser = subparsers.add_parser(
355+
"cache", help="Manage release data cache for better performance"
356+
)
357+
358+
# Create subcommands for cache operations
359+
cache_subparsers = cache_parser.add_subparsers(
360+
dest="cache_action", help="Cache management actions", required=True
361+
)
362+
363+
# Clear command - remove cache entries
364+
clear_parser = cache_subparsers.add_parser(
365+
"clear", help="Clear cache entries"
366+
)
367+
clear_group = clear_parser.add_mutually_exclusive_group(required=True)
368+
clear_group.add_argument(
369+
"app_name", nargs="?", help="App name or owner/repo to clear (e.g., 'signal' or 'signalapp/Signal-Desktop')"
370+
)
371+
clear_group.add_argument(
372+
"--all", action="store_true", help="Clear all cache entries"
373+
)
374+
375+
# Stats command - show cache statistics
376+
cache_subparsers.add_parser(
377+
"stats", help="Show cache statistics and storage info"
378+
)

my_unicorn/cli/runner.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from ..auth import auth_manager
1111
from ..commands.auth import AuthHandler
1212
from ..commands.backup import BackupHandler
13+
from ..commands.cache import CacheHandler
1314
from ..commands.config import ConfigHandler
1415
from ..commands.install import InstallHandler
1516
from ..commands.list import ListHandler
@@ -67,6 +68,9 @@ def _init_command_handlers(self) -> None:
6768
"backup": BackupHandler(
6869
self.config_manager, self.auth_manager, self.update_manager
6970
),
71+
"cache": CacheHandler(
72+
self.config_manager, self.auth_manager, self.update_manager
73+
),
7074
"auth": AuthHandler(self.config_manager, self.auth_manager, self.update_manager),
7175
"config": ConfigHandler(
7276
self.config_manager, self.auth_manager, self.update_manager

my_unicorn/commands/cache.py

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Cache command handler for my-unicorn CLI.
2+
3+
Handles cache management operations for the CLI, including clearing cache entries
4+
and displaying cache statistics.
5+
"""
6+
7+
import sys
8+
from argparse import Namespace
9+
10+
from ..logger import get_logger
11+
from ..services.cache import get_cache_manager
12+
from .base import BaseCommandHandler
13+
14+
logger = get_logger(__name__)
15+
16+
17+
class CacheHandler(BaseCommandHandler):
18+
"""Handler for cache command operations.
19+
20+
Provides cache management functionality:
21+
- Clearing cache entries
22+
- Displaying cache statistics
23+
24+
Note:
25+
Cache refresh is handled by the update command (--refresh-cache flag).
26+
"""
27+
28+
async def execute(self, args: Namespace) -> None:
29+
"""Execute the cache command based on subcommand.
30+
31+
Args:
32+
args: Parsed command-line arguments containing cache parameters.
33+
34+
Raises:
35+
SystemExit: On unknown action or error.
36+
"""
37+
try:
38+
if args.cache_action == "clear":
39+
await self._handle_clear(args)
40+
elif args.cache_action == "stats":
41+
await self._handle_stats(args)
42+
else:
43+
logger.error("Unknown cache action: %s", args.cache_action)
44+
sys.exit(1)
45+
except KeyboardInterrupt:
46+
logger.info("Cache operation interrupted by user")
47+
sys.exit(130)
48+
except Exception as e:
49+
logger.error("Cache operation failed: %s", e)
50+
sys.exit(1)
51+
52+
async def _handle_clear(self, args: Namespace) -> None:
53+
"""Clear cache entries based on arguments.
54+
55+
Args:
56+
args: Parsed command-line arguments.
57+
58+
Raises:
59+
SystemExit: If neither --all nor app name is specified.
60+
"""
61+
cache_manager = get_cache_manager()
62+
if args.all:
63+
await cache_manager.clear_cache()
64+
logger.info("✅ Cleared all cache entries")
65+
elif args.app_name:
66+
# Parse owner/repo from app name
67+
owner, repo = self._parse_app_name(args.app_name)
68+
await cache_manager.clear_cache(owner, repo)
69+
logger.info("✅ Cleared cache for %s/%s", owner, repo)
70+
else:
71+
logger.error("Please specify either --all or an app name to clear")
72+
sys.exit(1)
73+
74+
async def _handle_stats(self, args: Namespace) -> None:
75+
"""Display cache statistics.
76+
77+
Args:
78+
args: Parsed command-line arguments.
79+
80+
Raises:
81+
SystemExit: On error.
82+
"""
83+
cache_manager = get_cache_manager()
84+
try:
85+
stats = await cache_manager.get_cache_stats()
86+
logger.info("📁 Cache Directory: %s", stats["cache_directory"])
87+
logger.info("Total Entries: %d", stats["total_entries"])
88+
logger.info("TTL Hours: %d", stats["ttl_hours"])
89+
90+
total_entries = stats["total_entries"]
91+
if isinstance(total_entries, int) and total_entries > 0:
92+
print(f"✅ Fresh Entries: {stats['fresh_entries']}")
93+
print(f"⏰ Expired Entries: {stats['expired_entries']}")
94+
corrupted = stats["corrupted_entries"]
95+
if isinstance(corrupted, int) and corrupted > 0:
96+
print(f"❌ Corrupted Entries: {corrupted}")
97+
else:
98+
print("📭 No cache entries found")
99+
100+
if "error" in stats:
101+
print(f"⚠️ Error getting stats: {stats['error']}")
102+
except Exception as e:
103+
print(f"❌ Failed to get cache stats: {e}")
104+
sys.exit(1)
105+
106+
def _parse_app_name(self, app_name: str) -> tuple[str, str]:
107+
"""Parse app name to (owner, repo).
108+
109+
Args:
110+
app_name: App name, either 'owner/repo' or just 'appname'.
111+
112+
Returns:
113+
Tuple[str, str]: (owner, repo).
114+
115+
Raises:
116+
SystemExit: If app config not found.
117+
"""
118+
if "/" in app_name:
119+
owner, repo = app_name.split("/", 1)
120+
return owner, repo
121+
122+
# Lookup owner/repo from installed app config
123+
app_config = self.config_manager.load_app_config(app_name)
124+
if not app_config:
125+
logger.error("App %s not found", app_name)
126+
sys.exit(1)
127+
return app_config["owner"], app_config["repo"]

0 commit comments

Comments
 (0)