matrixcrypto 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 bigsk1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: matrixcrypto
3
+ Version: 0.1.0
4
+ Summary: A Matrix-style terminal display for cryptocurrency tickers and prices
5
+ Author: bigsk1
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bigsk1/matrix-crypto
8
+ Project-URL: Issues, https://github.com/bigsk1/matrix-crypto/issues
9
+ Keywords: cryptocurrency,terminal,curses,matrix
10
+ Classifier: Environment :: Console :: Curses
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Operating System :: Microsoft :: Windows
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Terminals
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE.md
22
+ Requires-Dist: requests<3,>=2.31
23
+ Requires-Dist: windows-curses<3,>=2.4.2; platform_system == "Windows"
24
+ Dynamic: license-file
25
+
26
+ # Matrix Crypto
27
+
28
+ A Matrix-style cryptocurrency ticker for your terminal. It displays live USD prices from CoinGecko's public API, or tickers only in offline mode. No API key or `.env` file is needed.
29
+
30
+ ![Matrix Crypto terminal display](https://raw.githubusercontent.com/bigsk1/matrix-crypto/main/img/matrix.png)
31
+
32
+ ## Requirements
33
+
34
+ - Python 3.12 or newer
35
+ - A terminal with color support. On Windows, `windows-curses` is installed automatically.
36
+ - Network access for live prices. Offline mode does not make API requests.
37
+
38
+ ## Install
39
+
40
+ After the first PyPI release, install into a virtual environment:
41
+
42
+ ```bash
43
+ python3.12 -m venv .venv
44
+ source .venv/bin/activate
45
+ python -m pip install matrixcrypto
46
+ matrixcrypto
47
+ ```
48
+
49
+ On Windows, create a virtual environment with `py -3.12 -m venv .venv`, activate it with `.venv\Scripts\activate`, then run `python -m pip install matrixcrypto` and `matrixcrypto`.
50
+
51
+ With uv, create a virtual environment and install the same package:
52
+
53
+ ```bash
54
+ uv venv --python 3.12
55
+ uv pip install matrixcrypto
56
+ source .venv/bin/activate
57
+ matrixcrypto
58
+ ```
59
+
60
+ The installed commands are `matrixcrypto`, `matrixcrypto-offline`, and `matrixcrypto-update-list`. Press any key, including Ctrl+C, to exit the display.
61
+
62
+ ## Run from a source checkout
63
+
64
+ ```bash
65
+ python3.12 -m venv .venv
66
+ source .venv/bin/activate
67
+ python -m pip install -e .
68
+ matrixcrypto
69
+ ```
70
+
71
+ The included `run_linux.sh` and `run_windows.bat` scripts also create a local virtual environment and install the checkout. The `install.sh` script remains available for Linux users who prefer the repository installer and its `matrixc` command.
72
+
73
+ ## Options
74
+
75
+ ```bash
76
+ matrixcrypto --bg-color red --crypto-color yellow --eth
77
+ matrixcrypto --solana
78
+ matrixcrypto --offline
79
+ matrixcrypto-offline
80
+ matrixcrypto --config ./my-cryptos.json
81
+ ```
82
+
83
+ `--bg-color` and `--crypto-color` accept `red`, `green`, `blue`, `yellow`, `cyan`, `magenta`, or `white`. The `--solana` and `--eth` options select bundled ecosystem lists. The display requests fresh prices about every 75 seconds; a coin without a current price shows `N/A`.
84
+
85
+ CoinGecko may rate-limit public requests. If a price request fails, the display shows `N/A` until a later request succeeds.
86
+
87
+ The offline default is a curated set of 12 active tickers from the refreshed overall list. Older entries from the previous offline snapshot, including MATIC, have been removed. You can use `--config` to show any other coins.
88
+
89
+ For a custom list, save a JSON file like this and pass its path with `--config`:
90
+
91
+ ```json
92
+ {
93
+ "cryptos": [
94
+ {"id": "bitcoin", "ticker": "BTC", "name": "Bitcoin"},
95
+ {"id": "ethereum", "ticker": "ETH", "name": "Ethereum"}
96
+ ]
97
+ }
98
+ ```
99
+
100
+ The `id` values are CoinGecko coin IDs. Run `matrixcrypto --help` for all display options.
101
+
102
+ ## Refresh a list
103
+
104
+ The bundled lists are release snapshots. To generate a current list in the working directory, run:
105
+
106
+ ```bash
107
+ matrixcrypto-update-list --ecosystem all --output crypto_list.json
108
+ matrixcrypto-update-list --ecosystem solana --output solana.json
109
+ matrixcrypto-update-list --ecosystem ethereum --output ethereum.json
110
+ matrixcrypto --config ./crypto_list.json
111
+ ```
112
+
113
+ The list updater only writes a file after CoinGecko returns a valid, nonempty result. Use `--limit` to change the default of 20 coins. To update a future release's bundled defaults, write the new files under `src/matrixcrypto/data/` in a source checkout and rebuild the package.
114
+
115
+ ## Publishing
116
+
117
+ See [RELEASING.md](https://github.com/bigsk1/matrix-crypto/blob/main/RELEASING.md) for the first release checklist and token-safe upload commands.
118
+
119
+ Licensed under MIT. See [LICENSE.md](https://github.com/bigsk1/matrix-crypto/blob/main/LICENSE.md).
@@ -0,0 +1,94 @@
1
+ # Matrix Crypto
2
+
3
+ A Matrix-style cryptocurrency ticker for your terminal. It displays live USD prices from CoinGecko's public API, or tickers only in offline mode. No API key or `.env` file is needed.
4
+
5
+ ![Matrix Crypto terminal display](https://raw.githubusercontent.com/bigsk1/matrix-crypto/main/img/matrix.png)
6
+
7
+ ## Requirements
8
+
9
+ - Python 3.12 or newer
10
+ - A terminal with color support. On Windows, `windows-curses` is installed automatically.
11
+ - Network access for live prices. Offline mode does not make API requests.
12
+
13
+ ## Install
14
+
15
+ After the first PyPI release, install into a virtual environment:
16
+
17
+ ```bash
18
+ python3.12 -m venv .venv
19
+ source .venv/bin/activate
20
+ python -m pip install matrixcrypto
21
+ matrixcrypto
22
+ ```
23
+
24
+ On Windows, create a virtual environment with `py -3.12 -m venv .venv`, activate it with `.venv\Scripts\activate`, then run `python -m pip install matrixcrypto` and `matrixcrypto`.
25
+
26
+ With uv, create a virtual environment and install the same package:
27
+
28
+ ```bash
29
+ uv venv --python 3.12
30
+ uv pip install matrixcrypto
31
+ source .venv/bin/activate
32
+ matrixcrypto
33
+ ```
34
+
35
+ The installed commands are `matrixcrypto`, `matrixcrypto-offline`, and `matrixcrypto-update-list`. Press any key, including Ctrl+C, to exit the display.
36
+
37
+ ## Run from a source checkout
38
+
39
+ ```bash
40
+ python3.12 -m venv .venv
41
+ source .venv/bin/activate
42
+ python -m pip install -e .
43
+ matrixcrypto
44
+ ```
45
+
46
+ The included `run_linux.sh` and `run_windows.bat` scripts also create a local virtual environment and install the checkout. The `install.sh` script remains available for Linux users who prefer the repository installer and its `matrixc` command.
47
+
48
+ ## Options
49
+
50
+ ```bash
51
+ matrixcrypto --bg-color red --crypto-color yellow --eth
52
+ matrixcrypto --solana
53
+ matrixcrypto --offline
54
+ matrixcrypto-offline
55
+ matrixcrypto --config ./my-cryptos.json
56
+ ```
57
+
58
+ `--bg-color` and `--crypto-color` accept `red`, `green`, `blue`, `yellow`, `cyan`, `magenta`, or `white`. The `--solana` and `--eth` options select bundled ecosystem lists. The display requests fresh prices about every 75 seconds; a coin without a current price shows `N/A`.
59
+
60
+ CoinGecko may rate-limit public requests. If a price request fails, the display shows `N/A` until a later request succeeds.
61
+
62
+ The offline default is a curated set of 12 active tickers from the refreshed overall list. Older entries from the previous offline snapshot, including MATIC, have been removed. You can use `--config` to show any other coins.
63
+
64
+ For a custom list, save a JSON file like this and pass its path with `--config`:
65
+
66
+ ```json
67
+ {
68
+ "cryptos": [
69
+ {"id": "bitcoin", "ticker": "BTC", "name": "Bitcoin"},
70
+ {"id": "ethereum", "ticker": "ETH", "name": "Ethereum"}
71
+ ]
72
+ }
73
+ ```
74
+
75
+ The `id` values are CoinGecko coin IDs. Run `matrixcrypto --help` for all display options.
76
+
77
+ ## Refresh a list
78
+
79
+ The bundled lists are release snapshots. To generate a current list in the working directory, run:
80
+
81
+ ```bash
82
+ matrixcrypto-update-list --ecosystem all --output crypto_list.json
83
+ matrixcrypto-update-list --ecosystem solana --output solana.json
84
+ matrixcrypto-update-list --ecosystem ethereum --output ethereum.json
85
+ matrixcrypto --config ./crypto_list.json
86
+ ```
87
+
88
+ The list updater only writes a file after CoinGecko returns a valid, nonempty result. Use `--limit` to change the default of 20 coins. To update a future release's bundled defaults, write the new files under `src/matrixcrypto/data/` in a source checkout and rebuild the package.
89
+
90
+ ## Publishing
91
+
92
+ See [RELEASING.md](https://github.com/bigsk1/matrix-crypto/blob/main/RELEASING.md) for the first release checklist and token-safe upload commands.
93
+
94
+ Licensed under MIT. See [LICENSE.md](https://github.com/bigsk1/matrix-crypto/blob/main/LICENSE.md).
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "matrixcrypto"
7
+ version = "0.1.0"
8
+ description = "A Matrix-style terminal display for cryptocurrency tickers and prices"
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ license-files = ["LICENSE.md"]
13
+ authors = [{name = "bigsk1"}]
14
+ keywords = ["cryptocurrency", "terminal", "curses", "matrix"]
15
+ classifiers = [
16
+ "Environment :: Console :: Curses",
17
+ "Intended Audience :: End Users/Desktop",
18
+ "Operating System :: Microsoft :: Windows",
19
+ "Operating System :: POSIX :: Linux",
20
+ "Operating System :: MacOS",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Topic :: Terminals",
25
+ ]
26
+ dependencies = [
27
+ "requests>=2.31,<3",
28
+ "windows-curses>=2.4.2,<3; platform_system == 'Windows'",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/bigsk1/matrix-crypto"
33
+ Issues = "https://github.com/bigsk1/matrix-crypto/issues"
34
+
35
+ [project.scripts]
36
+ matrixcrypto = "matrixcrypto.app:main"
37
+ matrixcrypto-offline = "matrixcrypto.app:offline_main"
38
+ matrixcrypto-update-list = "matrixcrypto.lists:main"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
42
+
43
+ [tool.setuptools.package-data]
44
+ matrixcrypto = ["data/*.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Matrix-style cryptocurrency terminal display."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .app import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,329 @@
1
+ """Terminal animation and optional CoinGecko price updates."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import curses
7
+ import json
8
+ import math
9
+ import random
10
+ import sys
11
+ import threading
12
+ import time
13
+ from importlib import resources
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import requests
18
+
19
+ SETTINGS = {
20
+ "ANIMATION_SPEED": 0.01,
21
+ "BACKGROUND_PATTERN": (1, 2, 3, 1),
22
+ "BACKGROUND_CHARS": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+-=[]{}|;:,.<>?/",
23
+ "CRYPTO_DISPLAY_COUNT": 3,
24
+ "CRYPTO_DISPLAY_CHANCE": 0.13,
25
+ "BACKGROUND_COLUMN_LENGTH_RANGE": (0.3, 0.6),
26
+ "CRYPTO_FALL_SPEED_RANGE": (0.12, 0.16),
27
+ "BACKGROUND_CHANGE_CHANCE": 0.5,
28
+ "BACKGROUND_FALL_SPEED_RANGE": (0.06, 0.1),
29
+ }
30
+
31
+ COLOR_MAP = {
32
+ "red": curses.COLOR_RED,
33
+ "green": curses.COLOR_GREEN,
34
+ "blue": curses.COLOR_BLUE,
35
+ "yellow": curses.COLOR_YELLOW,
36
+ "cyan": curses.COLOR_CYAN,
37
+ "magenta": curses.COLOR_MAGENTA,
38
+ "white": curses.COLOR_WHITE,
39
+ }
40
+
41
+ PRICE_URL = "https://api.coingecko.com/api/v3/simple/price"
42
+ PRICE_UPDATE_SECONDS = 75
43
+
44
+
45
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
46
+ parser = argparse.ArgumentParser(
47
+ description="Matrix-style cryptocurrency terminal display"
48
+ )
49
+ parser.add_argument("--bg-color", choices=COLOR_MAP, default="green")
50
+ parser.add_argument("--crypto-color", choices=COLOR_MAP, default="white")
51
+ parser.add_argument(
52
+ "--config", type=Path, help="Path to a custom cryptocurrency JSON list"
53
+ )
54
+ parser.add_argument(
55
+ "--offline", action="store_true", help="Show tickers without fetching prices"
56
+ )
57
+ ecosystem = parser.add_mutually_exclusive_group()
58
+ ecosystem.add_argument(
59
+ "--solana", action="store_true", help="Use the Solana ecosystem list"
60
+ )
61
+ ecosystem.add_argument(
62
+ "--eth", action="store_true", help="Use the Ethereum ecosystem list"
63
+ )
64
+ return parser.parse_args(argv)
65
+
66
+
67
+ def default_config_name(args: argparse.Namespace, offline: bool) -> str:
68
+ if args.solana:
69
+ return "solana_ecosystem_crypto_list.json"
70
+ if args.eth:
71
+ return "ethereum_ecosystem_crypto_list.json"
72
+ if offline:
73
+ return "offline_crypto_list.json"
74
+ return "crypto_list.json"
75
+
76
+
77
+ def load_cryptos(config: Path | str) -> list[dict[str, Any]]:
78
+ if isinstance(config, Path):
79
+ with config.open(encoding="utf-8") as file:
80
+ data = json.load(file)
81
+ else:
82
+ resource = resources.files("matrixcrypto").joinpath("data", config)
83
+ with resource.open("r", encoding="utf-8") as file:
84
+ data = json.load(file)
85
+
86
+ if not isinstance(data, dict) or not isinstance(data.get("cryptos"), list):
87
+ raise TypeError("Configuration must contain a 'cryptos' list")
88
+ cryptos = data["cryptos"]
89
+ if not cryptos or any(
90
+ not isinstance(crypto, dict)
91
+ or not isinstance(crypto.get("id"), str)
92
+ or not isinstance(crypto.get("ticker"), str)
93
+ for crypto in cryptos
94
+ ):
95
+ raise ValueError(
96
+ "Configuration needs at least one crypto with string 'id' and 'ticker'"
97
+ )
98
+ return cryptos
99
+
100
+
101
+ def fetch_current_prices(cryptos: list[dict[str, Any]]) -> bool:
102
+ ids = ",".join(crypto["id"] for crypto in cryptos)
103
+ for crypto in cryptos:
104
+ crypto.pop("price", None)
105
+ try:
106
+ response = requests.get(
107
+ PRICE_URL,
108
+ params={"ids": ids, "vs_currencies": "usd"},
109
+ timeout=20,
110
+ )
111
+ response.raise_for_status()
112
+ prices = response.json()
113
+ if not isinstance(prices, dict):
114
+ return False
115
+ for crypto in cryptos:
116
+ price_data = prices.get(crypto["id"])
117
+ if not isinstance(price_data, dict):
118
+ continue
119
+ price = price_data.get("usd")
120
+ if (
121
+ isinstance(price, (int, float))
122
+ and not isinstance(price, bool)
123
+ and math.isfinite(price)
124
+ ):
125
+ crypto["price"] = f"{price:.2f}" if price < 1000 else f"{price:,.0f}"
126
+ return True
127
+ except (requests.RequestException, ValueError):
128
+ return False
129
+
130
+
131
+ def update_prices_periodically(
132
+ cryptos: list[dict[str, Any]], stop_event: threading.Event
133
+ ) -> None:
134
+ while not stop_event.is_set():
135
+ fetch_current_prices(cryptos)
136
+ stop_event.wait(PRICE_UPDATE_SECONDS)
137
+
138
+
139
+ def init_color_pairs(bg_color: str, crypto_color: str) -> None:
140
+ curses.start_color()
141
+ try:
142
+ curses.use_default_colors()
143
+ background = -1
144
+ except curses.error:
145
+ background = curses.COLOR_BLACK
146
+ curses.init_pair(1, COLOR_MAP[bg_color], background)
147
+ curses.init_pair(2, curses.COLOR_WHITE, background)
148
+ curses.init_pair(3, COLOR_MAP[crypto_color], background)
149
+
150
+
151
+ class MatrixColumn:
152
+ def __init__(self, height: int) -> None:
153
+ self.height = height
154
+ self.length = max(
155
+ 1, int(random.uniform(*SETTINGS["BACKGROUND_COLUMN_LENGTH_RANGE"]) * height)
156
+ )
157
+ self.chars = [
158
+ random.choice(SETTINGS["BACKGROUND_CHARS"]) for _ in range(self.length)
159
+ ]
160
+ self.speed = random.uniform(*SETTINGS["BACKGROUND_FALL_SPEED_RANGE"])
161
+ self.counter = 0.0
162
+ self.top = -self.length
163
+
164
+ def update(self, dt: float) -> None:
165
+ self.counter += dt
166
+ if self.counter < self.speed:
167
+ return
168
+ self.counter = 0.0
169
+ self.top += 1
170
+ if self.top >= self.height:
171
+ self.top = -self.length
172
+ if random.random() < SETTINGS["BACKGROUND_CHANGE_CHANCE"]:
173
+ self.chars[random.randrange(self.length)] = random.choice(
174
+ SETTINGS["BACKGROUND_CHARS"]
175
+ )
176
+
177
+ def draw(self, stdscr: Any, x: int) -> None:
178
+ for index, char in enumerate(self.chars):
179
+ y = self.top + index
180
+ if 0 <= y < self.height:
181
+ attr = (
182
+ curses.color_pair(2) | curses.A_BOLD
183
+ if index == self.length - 1
184
+ else curses.color_pair(1) | curses.A_DIM
185
+ )
186
+ safe_addstr(stdscr, y, x, char, attr)
187
+
188
+
189
+ class CryptoDisplay:
190
+ def __init__(
191
+ self, crypto: dict[str, Any], x: int, max_y: int, offline: bool
192
+ ) -> None:
193
+ self.crypto = crypto
194
+ self.x = x
195
+ self.y = 0
196
+ self.max_y = max_y
197
+ self.offline = offline
198
+ self.speed = random.uniform(*SETTINGS["CRYPTO_FALL_SPEED_RANGE"])
199
+ self.counter = 0.0
200
+
201
+ def update(self, dt: float) -> None:
202
+ self.counter += dt
203
+ if self.counter >= self.speed:
204
+ self.counter = 0.0
205
+ self.y += 1
206
+
207
+ def get_display_text(self) -> str:
208
+ ticker = self.crypto["ticker"].upper()
209
+ return (
210
+ ticker if self.offline else f"{ticker} $ {self.crypto.get('price', 'N/A')}"
211
+ )
212
+
213
+ def is_offscreen(self) -> bool:
214
+ return self.y >= self.max_y
215
+
216
+
217
+ def safe_addstr(stdscr: Any, y: int, x: int, text: str, attr: int) -> None:
218
+ try:
219
+ stdscr.addstr(y, x, text, attr)
220
+ except curses.error:
221
+ pass
222
+
223
+
224
+ def build_columns(height: int, width: int) -> list[tuple[int, MatrixColumn]]:
225
+ columns: list[tuple[int, MatrixColumn]] = []
226
+ x = 0
227
+ for index in range(width):
228
+ if x >= width:
229
+ break
230
+ columns.append((x, MatrixColumn(height)))
231
+ x += (
232
+ 1
233
+ + SETTINGS["BACKGROUND_PATTERN"][
234
+ index % len(SETTINGS["BACKGROUND_PATTERN"])
235
+ ]
236
+ )
237
+ return columns
238
+
239
+
240
+ def run_screen(
241
+ stdscr: Any, cryptos: list[dict[str, Any]], args: argparse.Namespace, offline: bool
242
+ ) -> None:
243
+ init_color_pairs(args.bg_color, args.crypto_color)
244
+ try:
245
+ curses.curs_set(0)
246
+ except curses.error:
247
+ pass
248
+ stdscr.nodelay(True)
249
+
250
+ stop_event = threading.Event()
251
+ if not offline:
252
+ threading.Thread(
253
+ target=update_prices_periodically, args=(cryptos, stop_event), daemon=True
254
+ ).start()
255
+
256
+ height, width = stdscr.getmaxyx()
257
+ columns = build_columns(height, width)
258
+ displays: list[CryptoDisplay] = []
259
+ last_time = time.monotonic()
260
+
261
+ try:
262
+ while True:
263
+ now = time.monotonic()
264
+ dt = now - last_time
265
+ last_time = now
266
+
267
+ current_size = stdscr.getmaxyx()
268
+ if current_size != (height, width):
269
+ height, width = current_size
270
+ columns = build_columns(height, width)
271
+ displays.clear()
272
+
273
+ stdscr.erase()
274
+ for x, column in columns:
275
+ column.update(dt)
276
+ column.draw(stdscr, x)
277
+
278
+ for display in displays[:]:
279
+ display.update(dt)
280
+ if display.is_offscreen():
281
+ displays.remove(display)
282
+ continue
283
+ for index, char in enumerate(display.get_display_text()):
284
+ y = display.y + index
285
+ if y < height:
286
+ safe_addstr(
287
+ stdscr,
288
+ y,
289
+ display.x,
290
+ char,
291
+ curses.color_pair(3) | curses.A_BOLD,
292
+ )
293
+
294
+ if (
295
+ width
296
+ and len(displays) < SETTINGS["CRYPTO_DISPLAY_COUNT"]
297
+ and random.random() < SETTINGS["CRYPTO_DISPLAY_CHANCE"]
298
+ ):
299
+ displays.append(
300
+ CryptoDisplay(
301
+ random.choice(cryptos), random.randrange(width), height, offline
302
+ )
303
+ )
304
+
305
+ stdscr.refresh()
306
+ if stdscr.getch() != -1:
307
+ break
308
+ time.sleep(SETTINGS["ANIMATION_SPEED"])
309
+ finally:
310
+ stop_event.set()
311
+
312
+
313
+ def main(argv: list[str] | None = None, *, offline: bool = False) -> int:
314
+ args = parse_args(argv)
315
+ offline = offline or args.offline
316
+ try:
317
+ cryptos = load_cryptos(args.config or default_config_name(args, offline))
318
+ except (OSError, TypeError, ValueError) as exc:
319
+ print(f"matrixcrypto: cannot load cryptocurrency list: {exc}", file=sys.stderr)
320
+ return 1
321
+ try:
322
+ curses.wrapper(run_screen, cryptos, args, offline)
323
+ except KeyboardInterrupt:
324
+ pass
325
+ return 0
326
+
327
+
328
+ def offline_main() -> int:
329
+ return main(offline=True)
@@ -0,0 +1,104 @@
1
+ {
2
+ "cryptos": [
3
+ {
4
+ "id": "bitcoin",
5
+ "ticker": "BTC",
6
+ "name": "Bitcoin"
7
+ },
8
+ {
9
+ "id": "ethereum",
10
+ "ticker": "ETH",
11
+ "name": "Ethereum"
12
+ },
13
+ {
14
+ "id": "tether",
15
+ "ticker": "USDT",
16
+ "name": "Tether"
17
+ },
18
+ {
19
+ "id": "binancecoin",
20
+ "ticker": "BNB",
21
+ "name": "BNB"
22
+ },
23
+ {
24
+ "id": "ripple",
25
+ "ticker": "XRP",
26
+ "name": "XRP"
27
+ },
28
+ {
29
+ "id": "usd-coin",
30
+ "ticker": "USDC",
31
+ "name": "USDC"
32
+ },
33
+ {
34
+ "id": "solana",
35
+ "ticker": "SOL",
36
+ "name": "Solana"
37
+ },
38
+ {
39
+ "id": "tron",
40
+ "ticker": "TRX",
41
+ "name": "TRON"
42
+ },
43
+ {
44
+ "id": "zcash",
45
+ "ticker": "ZEC",
46
+ "name": "Zcash"
47
+ },
48
+ {
49
+ "id": "figure-heloc",
50
+ "ticker": "FIGR_HELOC",
51
+ "name": "Figure Heloc"
52
+ },
53
+ {
54
+ "id": "hyperliquid",
55
+ "ticker": "HYPE",
56
+ "name": "Hyperliquid"
57
+ },
58
+ {
59
+ "id": "dogecoin",
60
+ "ticker": "DOGE",
61
+ "name": "Dogecoin"
62
+ },
63
+ {
64
+ "id": "monero",
65
+ "ticker": "XMR",
66
+ "name": "Monero"
67
+ },
68
+ {
69
+ "id": "rain",
70
+ "ticker": "RAIN",
71
+ "name": "Rain"
72
+ },
73
+ {
74
+ "id": "whitebit",
75
+ "ticker": "WBT",
76
+ "name": "WhiteBIT Coin"
77
+ },
78
+ {
79
+ "id": "usds",
80
+ "ticker": "USDS",
81
+ "name": "USDS"
82
+ },
83
+ {
84
+ "id": "chainlink",
85
+ "ticker": "LINK",
86
+ "name": "Chainlink"
87
+ },
88
+ {
89
+ "id": "cardano",
90
+ "ticker": "ADA",
91
+ "name": "Cardano"
92
+ },
93
+ {
94
+ "id": "leo-token",
95
+ "ticker": "LEO",
96
+ "name": "LEO Token"
97
+ },
98
+ {
99
+ "id": "stellar",
100
+ "ticker": "XLM",
101
+ "name": "Stellar"
102
+ }
103
+ ]
104
+ }
@@ -0,0 +1,104 @@
1
+ {
2
+ "cryptos": [
3
+ {
4
+ "id": "ethereum",
5
+ "ticker": "ETH",
6
+ "name": "Ethereum"
7
+ },
8
+ {
9
+ "id": "tether",
10
+ "ticker": "USDT",
11
+ "name": "Tether"
12
+ },
13
+ {
14
+ "id": "binancecoin",
15
+ "ticker": "BNB",
16
+ "name": "BNB"
17
+ },
18
+ {
19
+ "id": "usd-coin",
20
+ "ticker": "USDC",
21
+ "name": "USDC"
22
+ },
23
+ {
24
+ "id": "staked-ether",
25
+ "ticker": "STETH",
26
+ "name": "Lido Staked Ether"
27
+ },
28
+ {
29
+ "id": "wrapped-steth",
30
+ "ticker": "WSTETH",
31
+ "name": "Wrapped stETH"
32
+ },
33
+ {
34
+ "id": "whitebit",
35
+ "ticker": "WBT",
36
+ "name": "WhiteBIT Coin"
37
+ },
38
+ {
39
+ "id": "wrapped-beacon-eth",
40
+ "ticker": "WBETH",
41
+ "name": "Wrapped Beacon ETH"
42
+ },
43
+ {
44
+ "id": "usds",
45
+ "ticker": "USDS",
46
+ "name": "USDS"
47
+ },
48
+ {
49
+ "id": "wrapped-bitcoin",
50
+ "ticker": "WBTC",
51
+ "name": "Wrapped Bitcoin"
52
+ },
53
+ {
54
+ "id": "chainlink",
55
+ "ticker": "LINK",
56
+ "name": "Chainlink"
57
+ },
58
+ {
59
+ "id": "leo-token",
60
+ "ticker": "LEO",
61
+ "name": "LEO Token"
62
+ },
63
+ {
64
+ "id": "coinbase-wrapped-btc",
65
+ "ticker": "CBBTC",
66
+ "name": "Coinbase Wrapped BTC"
67
+ },
68
+ {
69
+ "id": "aave-v3-weth",
70
+ "ticker": "AWETH",
71
+ "name": "Aave v3 WETH"
72
+ },
73
+ {
74
+ "id": "wrapped-eeth",
75
+ "ticker": "WEETH",
76
+ "name": "Wrapped eETH"
77
+ },
78
+ {
79
+ "id": "weth",
80
+ "ticker": "WETH",
81
+ "name": "WETH"
82
+ },
83
+ {
84
+ "id": "uniswap",
85
+ "ticker": "UNI",
86
+ "name": "Uniswap"
87
+ },
88
+ {
89
+ "id": "ethena-usde",
90
+ "ticker": "USDE",
91
+ "name": "Ethena USDe"
92
+ },
93
+ {
94
+ "id": "dai",
95
+ "ticker": "DAI",
96
+ "name": "Dai"
97
+ },
98
+ {
99
+ "id": "susds",
100
+ "ticker": "SUSDS",
101
+ "name": "sUSDS"
102
+ }
103
+ ]
104
+ }
@@ -0,0 +1,64 @@
1
+ {
2
+ "cryptos": [
3
+ {
4
+ "id": "bitcoin",
5
+ "ticker": "BTC",
6
+ "name": "Bitcoin"
7
+ },
8
+ {
9
+ "id": "ethereum",
10
+ "ticker": "ETH",
11
+ "name": "Ethereum"
12
+ },
13
+ {
14
+ "id": "binancecoin",
15
+ "ticker": "BNB",
16
+ "name": "BNB"
17
+ },
18
+ {
19
+ "id": "ripple",
20
+ "ticker": "XRP",
21
+ "name": "XRP"
22
+ },
23
+ {
24
+ "id": "solana",
25
+ "ticker": "SOL",
26
+ "name": "Solana"
27
+ },
28
+ {
29
+ "id": "tron",
30
+ "ticker": "TRX",
31
+ "name": "TRON"
32
+ },
33
+ {
34
+ "id": "zcash",
35
+ "ticker": "ZEC",
36
+ "name": "Zcash"
37
+ },
38
+ {
39
+ "id": "hyperliquid",
40
+ "ticker": "HYPE",
41
+ "name": "Hyperliquid"
42
+ },
43
+ {
44
+ "id": "dogecoin",
45
+ "ticker": "DOGE",
46
+ "name": "Dogecoin"
47
+ },
48
+ {
49
+ "id": "monero",
50
+ "ticker": "XMR",
51
+ "name": "Monero"
52
+ },
53
+ {
54
+ "id": "chainlink",
55
+ "ticker": "LINK",
56
+ "name": "Chainlink"
57
+ },
58
+ {
59
+ "id": "cardano",
60
+ "ticker": "ADA",
61
+ "name": "Cardano"
62
+ }
63
+ ]
64
+ }
@@ -0,0 +1,104 @@
1
+ {
2
+ "cryptos": [
3
+ {
4
+ "id": "tether",
5
+ "ticker": "USDT",
6
+ "name": "Tether"
7
+ },
8
+ {
9
+ "id": "usd-coin",
10
+ "ticker": "USDC",
11
+ "name": "USDC"
12
+ },
13
+ {
14
+ "id": "solana",
15
+ "ticker": "SOL",
16
+ "name": "Solana"
17
+ },
18
+ {
19
+ "id": "usds",
20
+ "ticker": "USDS",
21
+ "name": "USDS"
22
+ },
23
+ {
24
+ "id": "wrapped-bitcoin",
25
+ "ticker": "WBTC",
26
+ "name": "Wrapped Bitcoin"
27
+ },
28
+ {
29
+ "id": "chainlink",
30
+ "ticker": "LINK",
31
+ "name": "Chainlink"
32
+ },
33
+ {
34
+ "id": "coinbase-wrapped-btc",
35
+ "ticker": "CBBTC",
36
+ "name": "Coinbase Wrapped BTC"
37
+ },
38
+ {
39
+ "id": "ethena-usde",
40
+ "ticker": "USDE",
41
+ "name": "Ethena USDe"
42
+ },
43
+ {
44
+ "id": "usd1-wlfi",
45
+ "ticker": "USD1",
46
+ "name": "USD1"
47
+ },
48
+ {
49
+ "id": "global-dollar",
50
+ "ticker": "USDG",
51
+ "name": "Global Dollar"
52
+ },
53
+ {
54
+ "id": "paypal-usd",
55
+ "ticker": "PYUSD",
56
+ "name": "PayPal USD"
57
+ },
58
+ {
59
+ "id": "hashnote-usyc",
60
+ "ticker": "USYC",
61
+ "name": "Circle USYC"
62
+ },
63
+ {
64
+ "id": "blackrock-usd-institutional-digital-liquidity-fund",
65
+ "ticker": "BUIDL",
66
+ "name": "BlackRock USD Institutional Digital Liquidity Fund"
67
+ },
68
+ {
69
+ "id": "ondo-us-dollar-yield",
70
+ "ticker": "USDY",
71
+ "name": "Ondo US Dollar Yield"
72
+ },
73
+ {
74
+ "id": "ethena",
75
+ "ticker": "ENA",
76
+ "name": "Ethena"
77
+ },
78
+ {
79
+ "id": "aave",
80
+ "ticker": "AAVE",
81
+ "name": "Aave"
82
+ },
83
+ {
84
+ "id": "pump-fun",
85
+ "ticker": "PUMP",
86
+ "name": "Pump.fun"
87
+ },
88
+ {
89
+ "id": "world-liberty-financial",
90
+ "ticker": "WLFI",
91
+ "name": "World Liberty Financial"
92
+ },
93
+ {
94
+ "id": "wrapped-solana",
95
+ "ticker": "WSOL",
96
+ "name": "Wrapped SOL"
97
+ },
98
+ {
99
+ "id": "usdgo",
100
+ "ticker": "USDGO",
101
+ "name": "USDGO"
102
+ }
103
+ ]
104
+ }
@@ -0,0 +1,62 @@
1
+ """Refresh cryptocurrency lists from the public CoinGecko API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import requests
12
+
13
+ MARKETS_URL = "https://api.coingecko.com/api/v3/coins/markets"
14
+ DEFAULT_FILES = {
15
+ "all": "crypto_list.json",
16
+ "solana": "solana_ecosystem_crypto_list.json",
17
+ "ethereum": "ethereum_ecosystem_crypto_list.json",
18
+ }
19
+
20
+
21
+ def fetch_list(ecosystem: str, limit: int) -> list[dict[str, str]]:
22
+ params: dict[str, Any] = {
23
+ "vs_currency": "usd",
24
+ "order": "market_cap_desc",
25
+ "per_page": limit,
26
+ "page": 1,
27
+ "sparkline": "false",
28
+ }
29
+ if ecosystem != "all":
30
+ params["category"] = f"{ecosystem}-ecosystem"
31
+ response = requests.get(MARKETS_URL, params=params, timeout=20)
32
+ response.raise_for_status()
33
+ data = response.json()
34
+ if not isinstance(data, list) or not data:
35
+ raise ValueError("CoinGecko returned no coins")
36
+ return [
37
+ {"id": coin["id"], "ticker": coin["symbol"].upper(), "name": coin["name"]}
38
+ for coin in data
39
+ ]
40
+
41
+
42
+ def main(argv: list[str] | None = None) -> int:
43
+ parser = argparse.ArgumentParser(
44
+ description="Fetch a cryptocurrency list from CoinGecko"
45
+ )
46
+ parser.add_argument("--ecosystem", choices=DEFAULT_FILES, default="all")
47
+ parser.add_argument("--limit", type=int, default=20)
48
+ parser.add_argument("--output", type=Path, help="Destination JSON file")
49
+ args = parser.parse_args(argv)
50
+ if not 1 <= args.limit <= 250:
51
+ parser.error("--limit must be between 1 and 250")
52
+ output = args.output or Path(DEFAULT_FILES[args.ecosystem])
53
+ try:
54
+ cryptos = fetch_list(args.ecosystem, args.limit)
55
+ output.write_text(
56
+ json.dumps({"cryptos": cryptos}, indent=2) + "\n", encoding="utf-8"
57
+ )
58
+ except (requests.RequestException, OSError, ValueError, KeyError, TypeError) as exc:
59
+ print(f"matrixcrypto-update-list: {exc}", file=sys.stderr)
60
+ return 1
61
+ print(f"Saved {len(cryptos)} cryptocurrencies to {output}")
62
+ return 0
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: matrixcrypto
3
+ Version: 0.1.0
4
+ Summary: A Matrix-style terminal display for cryptocurrency tickers and prices
5
+ Author: bigsk1
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/bigsk1/matrix-crypto
8
+ Project-URL: Issues, https://github.com/bigsk1/matrix-crypto/issues
9
+ Keywords: cryptocurrency,terminal,curses,matrix
10
+ Classifier: Environment :: Console :: Curses
11
+ Classifier: Intended Audience :: End Users/Desktop
12
+ Classifier: Operating System :: Microsoft :: Windows
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Terminals
19
+ Requires-Python: >=3.12
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE.md
22
+ Requires-Dist: requests<3,>=2.31
23
+ Requires-Dist: windows-curses<3,>=2.4.2; platform_system == "Windows"
24
+ Dynamic: license-file
25
+
26
+ # Matrix Crypto
27
+
28
+ A Matrix-style cryptocurrency ticker for your terminal. It displays live USD prices from CoinGecko's public API, or tickers only in offline mode. No API key or `.env` file is needed.
29
+
30
+ ![Matrix Crypto terminal display](https://raw.githubusercontent.com/bigsk1/matrix-crypto/main/img/matrix.png)
31
+
32
+ ## Requirements
33
+
34
+ - Python 3.12 or newer
35
+ - A terminal with color support. On Windows, `windows-curses` is installed automatically.
36
+ - Network access for live prices. Offline mode does not make API requests.
37
+
38
+ ## Install
39
+
40
+ After the first PyPI release, install into a virtual environment:
41
+
42
+ ```bash
43
+ python3.12 -m venv .venv
44
+ source .venv/bin/activate
45
+ python -m pip install matrixcrypto
46
+ matrixcrypto
47
+ ```
48
+
49
+ On Windows, create a virtual environment with `py -3.12 -m venv .venv`, activate it with `.venv\Scripts\activate`, then run `python -m pip install matrixcrypto` and `matrixcrypto`.
50
+
51
+ With uv, create a virtual environment and install the same package:
52
+
53
+ ```bash
54
+ uv venv --python 3.12
55
+ uv pip install matrixcrypto
56
+ source .venv/bin/activate
57
+ matrixcrypto
58
+ ```
59
+
60
+ The installed commands are `matrixcrypto`, `matrixcrypto-offline`, and `matrixcrypto-update-list`. Press any key, including Ctrl+C, to exit the display.
61
+
62
+ ## Run from a source checkout
63
+
64
+ ```bash
65
+ python3.12 -m venv .venv
66
+ source .venv/bin/activate
67
+ python -m pip install -e .
68
+ matrixcrypto
69
+ ```
70
+
71
+ The included `run_linux.sh` and `run_windows.bat` scripts also create a local virtual environment and install the checkout. The `install.sh` script remains available for Linux users who prefer the repository installer and its `matrixc` command.
72
+
73
+ ## Options
74
+
75
+ ```bash
76
+ matrixcrypto --bg-color red --crypto-color yellow --eth
77
+ matrixcrypto --solana
78
+ matrixcrypto --offline
79
+ matrixcrypto-offline
80
+ matrixcrypto --config ./my-cryptos.json
81
+ ```
82
+
83
+ `--bg-color` and `--crypto-color` accept `red`, `green`, `blue`, `yellow`, `cyan`, `magenta`, or `white`. The `--solana` and `--eth` options select bundled ecosystem lists. The display requests fresh prices about every 75 seconds; a coin without a current price shows `N/A`.
84
+
85
+ CoinGecko may rate-limit public requests. If a price request fails, the display shows `N/A` until a later request succeeds.
86
+
87
+ The offline default is a curated set of 12 active tickers from the refreshed overall list. Older entries from the previous offline snapshot, including MATIC, have been removed. You can use `--config` to show any other coins.
88
+
89
+ For a custom list, save a JSON file like this and pass its path with `--config`:
90
+
91
+ ```json
92
+ {
93
+ "cryptos": [
94
+ {"id": "bitcoin", "ticker": "BTC", "name": "Bitcoin"},
95
+ {"id": "ethereum", "ticker": "ETH", "name": "Ethereum"}
96
+ ]
97
+ }
98
+ ```
99
+
100
+ The `id` values are CoinGecko coin IDs. Run `matrixcrypto --help` for all display options.
101
+
102
+ ## Refresh a list
103
+
104
+ The bundled lists are release snapshots. To generate a current list in the working directory, run:
105
+
106
+ ```bash
107
+ matrixcrypto-update-list --ecosystem all --output crypto_list.json
108
+ matrixcrypto-update-list --ecosystem solana --output solana.json
109
+ matrixcrypto-update-list --ecosystem ethereum --output ethereum.json
110
+ matrixcrypto --config ./crypto_list.json
111
+ ```
112
+
113
+ The list updater only writes a file after CoinGecko returns a valid, nonempty result. Use `--limit` to change the default of 20 coins. To update a future release's bundled defaults, write the new files under `src/matrixcrypto/data/` in a source checkout and rebuild the package.
114
+
115
+ ## Publishing
116
+
117
+ See [RELEASING.md](https://github.com/bigsk1/matrix-crypto/blob/main/RELEASING.md) for the first release checklist and token-safe upload commands.
118
+
119
+ Licensed under MIT. See [LICENSE.md](https://github.com/bigsk1/matrix-crypto/blob/main/LICENSE.md).
@@ -0,0 +1,18 @@
1
+ LICENSE.md
2
+ README.md
3
+ pyproject.toml
4
+ src/matrixcrypto/__init__.py
5
+ src/matrixcrypto/__main__.py
6
+ src/matrixcrypto/app.py
7
+ src/matrixcrypto/lists.py
8
+ src/matrixcrypto.egg-info/PKG-INFO
9
+ src/matrixcrypto.egg-info/SOURCES.txt
10
+ src/matrixcrypto.egg-info/dependency_links.txt
11
+ src/matrixcrypto.egg-info/entry_points.txt
12
+ src/matrixcrypto.egg-info/requires.txt
13
+ src/matrixcrypto.egg-info/top_level.txt
14
+ src/matrixcrypto/data/crypto_list.json
15
+ src/matrixcrypto/data/ethereum_ecosystem_crypto_list.json
16
+ src/matrixcrypto/data/offline_crypto_list.json
17
+ src/matrixcrypto/data/solana_ecosystem_crypto_list.json
18
+ tests/test_app.py
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ matrixcrypto = matrixcrypto.app:main
3
+ matrixcrypto-offline = matrixcrypto.app:offline_main
4
+ matrixcrypto-update-list = matrixcrypto.lists:main
@@ -0,0 +1,4 @@
1
+ requests<3,>=2.31
2
+
3
+ [:platform_system == "Windows"]
4
+ windows-curses<3,>=2.4.2
@@ -0,0 +1 @@
1
+ matrixcrypto
@@ -0,0 +1,99 @@
1
+ import contextlib
2
+ import json
3
+ import tempfile
4
+ import unittest
5
+ from pathlib import Path
6
+ from unittest.mock import Mock, patch
7
+
8
+ import requests
9
+
10
+ from matrixcrypto import app, lists
11
+
12
+
13
+ class ConfigTests(unittest.TestCase):
14
+ def test_all_bundled_lists_are_available(self):
15
+ for filename in (
16
+ "crypto_list.json",
17
+ "solana_ecosystem_crypto_list.json",
18
+ "ethereum_ecosystem_crypto_list.json",
19
+ "offline_crypto_list.json",
20
+ ):
21
+ with self.subTest(filename=filename):
22
+ self.assertTrue(app.load_cryptos(filename))
23
+
24
+ def test_custom_list_rejects_empty_list(self):
25
+ with tempfile.TemporaryDirectory() as directory:
26
+ path = Path(directory) / "empty.json"
27
+ path.write_text('{"cryptos": []}', encoding="utf-8")
28
+ with self.assertRaisesRegex(ValueError, "at least one crypto"):
29
+ app.load_cryptos(path)
30
+
31
+ def test_main_uses_packaged_list_from_any_directory(self):
32
+ with tempfile.TemporaryDirectory() as directory:
33
+ with (
34
+ patch("matrixcrypto.app.curses.wrapper") as wrapper,
35
+ contextlib.chdir(directory),
36
+ ):
37
+ self.assertEqual(app.main(["--offline"]), 0)
38
+ self.assertTrue(wrapper.called)
39
+ self.assertTrue(wrapper.call_args.args[1])
40
+ self.assertTrue(wrapper.call_args.args[3])
41
+
42
+
43
+ class PriceTests(unittest.TestCase):
44
+ def test_prices_are_formatted_and_missing_ids_remain_unavailable(self):
45
+ cryptos = [
46
+ {"id": "bitcoin", "ticker": "BTC"},
47
+ {"id": "ethereum", "ticker": "ETH"},
48
+ {"id": "missing", "ticker": "MISSING"},
49
+ ]
50
+ response = Mock()
51
+ response.json.return_value = {
52
+ "bitcoin": {"usd": 1234.56},
53
+ "ethereum": {"usd": 12.345},
54
+ }
55
+ with patch("matrixcrypto.app.requests.get", return_value=response) as get:
56
+ self.assertTrue(app.fetch_current_prices(cryptos))
57
+ self.assertEqual(get.call_args.kwargs["timeout"], 20)
58
+ self.assertEqual(cryptos[0]["price"], "1,235")
59
+ self.assertEqual(cryptos[1]["price"], "12.35")
60
+ self.assertNotIn("price", cryptos[2])
61
+
62
+ def test_api_error_clears_stale_price(self):
63
+ cryptos = [{"id": "bitcoin", "ticker": "BTC", "price": "10.00"}]
64
+ with patch("matrixcrypto.app.requests.get", side_effect=requests.Timeout):
65
+ self.assertFalse(app.fetch_current_prices(cryptos))
66
+ self.assertNotIn("price", cryptos[0])
67
+
68
+
69
+ class ListUpdaterTests(unittest.TestCase):
70
+ def test_failed_request_does_not_overwrite_list(self):
71
+ with tempfile.TemporaryDirectory() as directory:
72
+ output = Path(directory) / "coins.json"
73
+ output.write_text("existing", encoding="utf-8")
74
+ with patch("matrixcrypto.lists.requests.get", side_effect=requests.Timeout):
75
+ self.assertEqual(lists.main(["--output", str(output)]), 1)
76
+ self.assertEqual(output.read_text(encoding="utf-8"), "existing")
77
+
78
+ def test_successful_request_writes_selected_ecosystem(self):
79
+ response = Mock()
80
+ response.json.return_value = [
81
+ {"id": "solana", "symbol": "sol", "name": "Solana"}
82
+ ]
83
+ with tempfile.TemporaryDirectory() as directory:
84
+ output = Path(directory) / "coins.json"
85
+ with patch("matrixcrypto.lists.requests.get", return_value=response) as get:
86
+ self.assertEqual(
87
+ lists.main(["--ecosystem", "solana", "--output", str(output)]),
88
+ 0,
89
+ )
90
+ self.assertEqual(
91
+ get.call_args.kwargs["params"]["category"], "solana-ecosystem"
92
+ )
93
+ self.assertEqual(
94
+ json.loads(output.read_text())["cryptos"][0]["ticker"], "SOL"
95
+ )
96
+
97
+
98
+ if __name__ == "__main__":
99
+ unittest.main()