word-stack 0.2.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 @@
1
+ WORD_STACK_ENV=development
@@ -0,0 +1,17 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv/
11
+
12
+ # IDE
13
+ .idea/
14
+
15
+ # Development environments
16
+ .env
17
+ .dev_data/
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kemal Soylu
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,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: word-stack
3
+ Version: 0.2.0
4
+ Summary: A powerful, terminal-based vocabulary builder and daily study tool.
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: pytest>=9.0.2
8
+ Requires-Dist: requests>=2.32.5
9
+ Requires-Dist: rich-argparse>=1.7.2
10
+ Requires-Dist: rich>=14.3.3
@@ -0,0 +1,109 @@
1
+ # Word-Stack
2
+
3
+ A sleek, fast, terminal-based vocabulary builder. Add words from your command line, automatically fetch their definitions and pronunciations, and build a daily study habit without ever leaving your terminal.
4
+
5
+ ## Features
6
+ * **Lightning Fast:** Built with modern Python and `uv`.
7
+ * **Zero-Friction Adding:** Just type `word-stack add <word>`. The Dictionary API automatically fetches definitions, examples, and phonetics.
8
+ * **Bulk Import:** Quickly add multiple words at once with beautiful progress tracking.
9
+ * **Daily Study Mode:** Uses a built-in SQLite database to track your learning and serves you a daily quiz of unstudied words.
10
+ * **Beautiful UI:** Terminal output is fully styled with `rich` for gorgeous tables, panels, and loading animations.
11
+
12
+ ## Prerequisites
13
+ Before installing, make sure you have the [uv package manager](https://docs.astral.sh/uv/) installed on your system.
14
+
15
+ ## Installation
16
+
17
+ Word-Stack is easily installable globally:
18
+
19
+ ```bash
20
+ git clone https://github.com/kemalbsoylu/word-stack.git
21
+ cd word-stack
22
+ uv tool install .
23
+ ```
24
+
25
+ *Note: The application creates a local SQLite database safely tucked away in `~/.word-stack/words.db` to keep your system clean.*
26
+
27
+ ### Setting up your Alias
28
+ To make the tool lightning fast to use, set up a permanent alias in your shell configuration (like `~/.zshrc` or `~/.bashrc`):
29
+ ```bash
30
+ alias ws="word-stack"
31
+ ```
32
+
33
+ ### Updating your Installation
34
+ When you pull new code from this repository, you can force `uv` to update your global installation with the latest features:
35
+ ```bash
36
+ uv tool install --force .
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ Once installed, you can use the `ws` alias from anywhere on your system.
42
+
43
+ **Check your installed version:**
44
+ ```bash
45
+ ws --version
46
+ ```
47
+
48
+ **Add a word (Auto-fetches definition):**
49
+ ```bash
50
+ ws add "persevere"
51
+ ws add "equilibrium" "denge"
52
+ ```
53
+
54
+ **Add multiple words at once:**
55
+ ```bash
56
+ ws bulk "apple" "banana" "ephemeral"
57
+ ```
58
+
59
+ **List your latest saved words:**
60
+ ```bash
61
+ ws list
62
+ ws list --limit 20
63
+ ```
64
+
65
+ **View word details:**
66
+ ```bash
67
+ ws show "persevere"
68
+ ```
69
+
70
+ **Start your daily study session:**
71
+ ```bash
72
+ ws study
73
+ ```
74
+
75
+ **Delete a word:**
76
+ ```bash
77
+ ws delete "persevere"
78
+ ```
79
+
80
+ ## Development Setup
81
+
82
+ Word-Stack uses `uv` for lightning-fast development. You **do not** need to manually create virtual environments or run `pip install`. `uv` handles everything for you automatically.
83
+
84
+ If you want to contribute or test features without altering your personal vocabulary database, set up an isolated development environment:
85
+
86
+ **1. Set up your environment variables:**
87
+ ```bash
88
+ cp .env.example .env
89
+ ```
90
+
91
+ **2. Set up a temporary development alias in your terminal:**
92
+ ```bash
93
+ alias dev="uv run --env-file .env -m word_stack.main"
94
+ ```
95
+
96
+ Now you can run commands like `dev list` or `dev bulk test`. The application will detect the development environment and safely route all data to a local `.dev_data/words.db` file instead of your global database!
97
+
98
+ ### Running Tests
99
+ To run the test suite, simply use:
100
+ ```bash
101
+ uv run pytest
102
+ ```
103
+
104
+ ---
105
+
106
+ ### 🎓 For Python Beginners
107
+ Are you learning Python and want to see exactly how this tool was built?
108
+
109
+ This repository is the production version of the tool. If you want to see a step-by-step, 3-phase educational history of how to build a CLI app from scratch, please visit the [Word-Stack-CLI Educational Repository](https://github.com/kemalbsoylu/word-stack-cli). It is designed specifically for beginners to explore and submit their first Open Source contributions!
@@ -0,0 +1,28 @@
1
+ feat: deletion of app, update of app
2
+
3
+ styling
4
+
5
+ add suggestions with difflib and rich
6
+ feat: if a word not found, suggest?
7
+
8
+ spaced-repetition algorithms?
9
+ CSV or an Anki deck?
10
+
11
+ ---
12
+
13
+ later:
14
+ testing db: testing add_word, list_words, or delete_word. advanced database mock tests
15
+ keep logs, logging
16
+ check --verbose, cyclopts
17
+
18
+ question:
19
+ https://audreyfeldroy.github.io/cookiecutter-pypackage/tutorial/
20
+ https://github.com/cookiecutter/cookiecutter
21
+
22
+ ---
23
+
24
+ done:
25
+ fix: if a word not found, do not add it,
26
+ feat: add multiple words at once
27
+ feat: -v --version
28
+ fix: make list show latest words and total number
@@ -0,0 +1,18 @@
1
+ [project]
2
+ name = "word-stack"
3
+ version = "0.2.0"
4
+ description = "A powerful, terminal-based vocabulary builder and daily study tool."
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "pytest>=9.0.2",
8
+ "requests>=2.32.5",
9
+ "rich>=14.3.3",
10
+ "rich-argparse>=1.7.2",
11
+ ]
12
+
13
+ [project.scripts]
14
+ word-stack = "word_stack.main:main"
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
@@ -0,0 +1,55 @@
1
+ import pytest
2
+ import requests
3
+
4
+ from unittest.mock import patch
5
+ from word_stack.api import get_word_info
6
+
7
+
8
+ # The @patch decorator intercepts 'requests.get' inside our api.py file
9
+ @patch("word_stack.api.requests.get")
10
+ def test_get_word_info_success(mock_get):
11
+ """Test fetching a word successfully using a fake API response."""
12
+
13
+ # 1. Setup the fake response (This is what the API would normally return)
14
+ mock_get.return_value.status_code = 200
15
+ mock_get.return_value.json.return_value = [{
16
+ "phonetic": "/tɛst/",
17
+ "meanings": [{
18
+ "definitions": [{
19
+ "definition": "A procedure intended to establish the quality of something.",
20
+ "example": "Both tests were successful."
21
+ }]
22
+ }]
23
+ }]
24
+
25
+ # 2. Call function. It thinks it's hitting the internet, but it gets fake data!
26
+ result = get_word_info("test")
27
+
28
+ # 3. Assert it parsed fake JSON correctly
29
+ assert result is not None
30
+ assert result["phonetic"] == "/tɛst/"
31
+ assert result["definition"] == "A procedure intended to establish the quality of something."
32
+ assert result["example"] == "Both tests were successful."
33
+
34
+
35
+ @patch("word_stack.api.requests.get")
36
+ def test_get_word_info_not_found(mock_get):
37
+ """Test how the app handles a 404 Not Found error."""
38
+
39
+ # Simulate the API returning a 404 error
40
+ mock_get.return_value.status_code = 404
41
+
42
+ # Tell pytest that we EXPECT a ValueError to be raised here
43
+ with pytest.raises(ValueError, match="not_found"):
44
+ get_word_info("notarealword123")
45
+
46
+
47
+ @patch("word_stack.api.requests.get")
48
+ def test_get_word_info_connection_error(mock_get):
49
+ """Test how the app handles a complete network failure."""
50
+
51
+ # .side_effect is used in mocks to simulate an exception being thrown
52
+ mock_get.side_effect = requests.exceptions.ConnectionError("No internet")
53
+
54
+ with pytest.raises(ConnectionError):
55
+ get_word_info("wifi_is_down_word")
@@ -0,0 +1,27 @@
1
+ from word_stack.storage import format_date
2
+
3
+
4
+ def test_format_date_valid_iso():
5
+ """Test that a valid ISO timestamp formats correctly."""
6
+ # This is a fixed date: March 9, 2026 at 12:30 PM
7
+ iso_string = "2026-03-09T12:30:00"
8
+
9
+ # Call our function
10
+ result = format_date(iso_string)
11
+
12
+ # Assert (check) that the result matches what we expect
13
+ assert result == "Mar 09, 2026 at 12:30 PM"
14
+
15
+
16
+ def test_format_date_empty_or_na():
17
+ """Test that empty strings or 'N/A' return the default 'Never studied' message."""
18
+ assert format_date(None) == "Never studied"
19
+ assert format_date("N/A") == "Never studied"
20
+
21
+
22
+ def test_format_date_invalid_string():
23
+ """Test that an invalid date string just returns the string itself."""
24
+ # If the database accidentally got corrupted with bad data,
25
+ # the function should safely return the bad data instead of crashing.
26
+ bad_data = "Not a real date"
27
+ assert format_date(bad_data) == "Not a real date"
@@ -0,0 +1,228 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "certifi"
7
+ version = "2026.2.25"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "charset-normalizer"
16
+ version = "3.4.5"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" }
19
+ wheels = [
20
+ { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" },
21
+ { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" },
22
+ { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" },
23
+ { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" },
24
+ { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" },
25
+ { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" },
26
+ { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" },
27
+ { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" },
28
+ { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" },
29
+ { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" },
30
+ { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" },
31
+ { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" },
32
+ { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" },
33
+ { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" },
34
+ { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" },
35
+ { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" },
36
+ { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" },
37
+ { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" },
38
+ { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" },
39
+ { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" },
40
+ { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" },
41
+ { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" },
42
+ { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" },
43
+ { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" },
44
+ { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" },
45
+ { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" },
46
+ { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" },
47
+ { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" },
48
+ { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" },
49
+ { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" },
50
+ { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" },
51
+ { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" },
52
+ { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" },
53
+ { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" },
54
+ { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" },
55
+ { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" },
56
+ { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" },
57
+ { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" },
58
+ { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" },
59
+ { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" },
60
+ { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" },
61
+ { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" },
62
+ { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" },
63
+ { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" },
64
+ { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" },
65
+ { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" },
66
+ { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" },
67
+ { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" },
68
+ { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" },
69
+ ]
70
+
71
+ [[package]]
72
+ name = "colorama"
73
+ version = "0.4.6"
74
+ source = { registry = "https://pypi.org/simple" }
75
+ 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" }
76
+ wheels = [
77
+ { 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" },
78
+ ]
79
+
80
+ [[package]]
81
+ name = "idna"
82
+ version = "3.11"
83
+ source = { registry = "https://pypi.org/simple" }
84
+ sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
85
+ wheels = [
86
+ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
87
+ ]
88
+
89
+ [[package]]
90
+ name = "iniconfig"
91
+ version = "2.3.0"
92
+ source = { registry = "https://pypi.org/simple" }
93
+ 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" }
94
+ wheels = [
95
+ { 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" },
96
+ ]
97
+
98
+ [[package]]
99
+ name = "markdown-it-py"
100
+ version = "4.0.0"
101
+ source = { registry = "https://pypi.org/simple" }
102
+ dependencies = [
103
+ { name = "mdurl" },
104
+ ]
105
+ sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
106
+ wheels = [
107
+ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
108
+ ]
109
+
110
+ [[package]]
111
+ name = "mdurl"
112
+ version = "0.1.2"
113
+ source = { registry = "https://pypi.org/simple" }
114
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
115
+ wheels = [
116
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
117
+ ]
118
+
119
+ [[package]]
120
+ name = "packaging"
121
+ version = "26.0"
122
+ source = { registry = "https://pypi.org/simple" }
123
+ 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" }
124
+ wheels = [
125
+ { 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" },
126
+ ]
127
+
128
+ [[package]]
129
+ name = "pluggy"
130
+ version = "1.6.0"
131
+ source = { registry = "https://pypi.org/simple" }
132
+ 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" }
133
+ wheels = [
134
+ { 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" },
135
+ ]
136
+
137
+ [[package]]
138
+ name = "pygments"
139
+ version = "2.19.2"
140
+ source = { registry = "https://pypi.org/simple" }
141
+ sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
142
+ wheels = [
143
+ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
144
+ ]
145
+
146
+ [[package]]
147
+ name = "pytest"
148
+ version = "9.0.2"
149
+ source = { registry = "https://pypi.org/simple" }
150
+ dependencies = [
151
+ { name = "colorama", marker = "sys_platform == 'win32'" },
152
+ { name = "iniconfig" },
153
+ { name = "packaging" },
154
+ { name = "pluggy" },
155
+ { name = "pygments" },
156
+ ]
157
+ 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" }
158
+ wheels = [
159
+ { 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" },
160
+ ]
161
+
162
+ [[package]]
163
+ name = "requests"
164
+ version = "2.32.5"
165
+ source = { registry = "https://pypi.org/simple" }
166
+ dependencies = [
167
+ { name = "certifi" },
168
+ { name = "charset-normalizer" },
169
+ { name = "idna" },
170
+ { name = "urllib3" },
171
+ ]
172
+ sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
173
+ wheels = [
174
+ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
175
+ ]
176
+
177
+ [[package]]
178
+ name = "rich"
179
+ version = "14.3.3"
180
+ source = { registry = "https://pypi.org/simple" }
181
+ dependencies = [
182
+ { name = "markdown-it-py" },
183
+ { name = "pygments" },
184
+ ]
185
+ sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
186
+ wheels = [
187
+ { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
188
+ ]
189
+
190
+ [[package]]
191
+ name = "rich-argparse"
192
+ version = "1.7.2"
193
+ source = { registry = "https://pypi.org/simple" }
194
+ dependencies = [
195
+ { name = "rich" },
196
+ ]
197
+ sdist = { url = "https://files.pythonhosted.org/packages/4c/f7/1c65e0245d4c7009a87ac92908294a66e7e7635eccf76a68550f40c6df80/rich_argparse-1.7.2.tar.gz", hash = "sha256:64fd2e948fc96e8a1a06e0e72c111c2ce7f3af74126d75c0f5f63926e7289cd1", size = 38500, upload-time = "2025-11-01T10:35:44.232Z" }
198
+ wheels = [
199
+ { url = "https://files.pythonhosted.org/packages/04/80/97b6f357ac458d9ad9872cc3183ca09ef7439ac89e030ea43053ba1294b6/rich_argparse-1.7.2-py3-none-any.whl", hash = "sha256:0559b1f47a19bbeb82bf15f95a057f99bcbbc98385532f57937f9fc57acc501a", size = 25476, upload-time = "2025-11-01T10:35:42.681Z" },
200
+ ]
201
+
202
+ [[package]]
203
+ name = "urllib3"
204
+ version = "2.6.3"
205
+ source = { registry = "https://pypi.org/simple" }
206
+ sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
207
+ wheels = [
208
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
209
+ ]
210
+
211
+ [[package]]
212
+ name = "word-stack"
213
+ version = "0.2.0"
214
+ source = { editable = "." }
215
+ dependencies = [
216
+ { name = "pytest" },
217
+ { name = "requests" },
218
+ { name = "rich" },
219
+ { name = "rich-argparse" },
220
+ ]
221
+
222
+ [package.metadata]
223
+ requires-dist = [
224
+ { name = "pytest", specifier = ">=9.0.2" },
225
+ { name = "requests", specifier = ">=2.32.5" },
226
+ { name = "rich", specifier = ">=14.3.3" },
227
+ { name = "rich-argparse", specifier = ">=1.7.2" },
228
+ ]
File without changes
@@ -0,0 +1,34 @@
1
+ import requests
2
+
3
+
4
+ def get_word_info(word):
5
+ """Fetch word details from the Free Dictionary API."""
6
+ url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
7
+
8
+ try:
9
+ response = requests.get(url)
10
+
11
+ if response.status_code == 404:
12
+ raise ValueError("not_found")
13
+
14
+ response.raise_for_status()
15
+ data = response.json()[0]
16
+
17
+ phonetic = data.get("phonetic", "N/A")
18
+ definition = "No definition found"
19
+ example = "No example found"
20
+
21
+ if data.get("meanings"):
22
+ first_meaning = data["meanings"][0]
23
+ if first_meaning.get("definitions"):
24
+ definition = first_meaning["definitions"][0].get("definition", definition)
25
+ example = first_meaning["definitions"][0].get("example", example)
26
+
27
+ return {
28
+ "phonetic": phonetic,
29
+ "definition": definition,
30
+ "example": example
31
+ }
32
+
33
+ except requests.exceptions.RequestException as e:
34
+ raise ConnectionError(str(e))
@@ -0,0 +1,66 @@
1
+ import argparse
2
+ import importlib.metadata
3
+
4
+ from rich_argparse import RawDescriptionRichHelpFormatter
5
+ from word_stack.storage import add_word, list_words, show_word, delete_word, study_words, has_studied_today, \
6
+ add_multiple_words
7
+
8
+
9
+ def get_version():
10
+ """Fetch the package version from pyproject.toml."""
11
+ try:
12
+ return importlib.metadata.version("word-stack")
13
+ except importlib.metadata.PackageNotFoundError:
14
+ return "unknown (not installed as a package)"
15
+
16
+
17
+ def main():
18
+ status_msg = "✅ You have studied today!" if has_studied_today() else "❌ You haven't studied today yet."
19
+
20
+ parser = argparse.ArgumentParser(
21
+ description=f"Word-Stack: Your personal vocabulary builder.\n\nDaily Status: {status_msg}",
22
+ formatter_class=RawDescriptionRichHelpFormatter
23
+ )
24
+
25
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {get_version()}")
26
+
27
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
28
+
29
+ add_parser = subparsers.add_parser("add", help="Add a new word")
30
+ add_parser.add_argument("word", type=str, help="The English word")
31
+ add_parser.add_argument("translation", type=str, nargs="?", default="N/A", help="Optional translation")
32
+
33
+ bulk_parser = subparsers.add_parser("bulk", help="Add multiple words at once (e.g., word-stack bulk apple banana)")
34
+ bulk_parser.add_argument("words", type=str, nargs="+", help="List of English words separated by spaces")
35
+
36
+ list_parser = subparsers.add_parser("list", help="List latest saved words")
37
+ list_parser.add_argument("-l", "--limit", type=int, default=10, help="Number of latest words to show (default: 10)")
38
+
39
+ show_parser = subparsers.add_parser("show", help="Show details for a specific word")
40
+ show_parser.add_argument("word", type=str, help="The English word to inspect")
41
+
42
+ delete_parser = subparsers.add_parser("delete", help="Delete a saved word")
43
+ delete_parser.add_argument("word", type=str, help="The English word to delete")
44
+
45
+ study_parser = subparsers.add_parser("study", help="Start a daily study session (10 words)")
46
+
47
+ args = parser.parse_args()
48
+
49
+ if args.command == "add":
50
+ add_word(args.word, args.translation)
51
+ elif args.command == "bulk":
52
+ add_multiple_words(args.words)
53
+ elif args.command == "list":
54
+ list_words(args.limit)
55
+ elif args.command == "show":
56
+ show_word(args.word)
57
+ elif args.command == "delete":
58
+ delete_word(args.word)
59
+ elif args.command == "study":
60
+ study_words()
61
+ else:
62
+ parser.print_help()
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
@@ -0,0 +1,367 @@
1
+ import os
2
+ import sqlite3
3
+
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from word_stack.api import get_word_info
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+ from rich.panel import Panel
10
+ from rich.progress import Progress
11
+
12
+ console = Console()
13
+
14
+ if os.getenv("WORD_STACK_ENV") == "development":
15
+ APP_DIR = Path.cwd() / ".dev_data"
16
+ else:
17
+ APP_DIR = Path.home() / ".word-stack"
18
+
19
+ APP_DIR.mkdir(parents=True, exist_ok=True)
20
+ DB_FILE = APP_DIR / "words.db"
21
+
22
+
23
+ def get_connection():
24
+ """Create and return a database connection."""
25
+ conn = sqlite3.connect(DB_FILE)
26
+ conn.row_factory = sqlite3.Row
27
+ return conn
28
+
29
+
30
+ def init_db():
31
+ """Initialize the database and create the table if it doesn't exist."""
32
+ conn = get_connection()
33
+ cursor = conn.cursor()
34
+
35
+ cursor.execute('''
36
+ CREATE TABLE IF NOT EXISTS words (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ word TEXT UNIQUE NOT NULL,
39
+ translation TEXT,
40
+ phonetic TEXT,
41
+ definition TEXT,
42
+ example TEXT,
43
+ last_studied TEXT
44
+ )
45
+ ''')
46
+ conn.commit()
47
+ conn.close()
48
+
49
+
50
+ init_db()
51
+
52
+
53
+ def format_date(iso_string):
54
+ """Convert an ISO timestamp into human-readable date."""
55
+ if not iso_string or iso_string == "N/A":
56
+ return "Never studied"
57
+ try:
58
+ dt = datetime.fromisoformat(iso_string)
59
+ # Format: Mar 09, 2026 at 12:00 PM
60
+ return dt.strftime("%b %d, %Y at %I:%M %p")
61
+ except ValueError:
62
+ return iso_string
63
+
64
+
65
+ def has_studied_today():
66
+ """Check the database to see if any word was studied today."""
67
+ conn = get_connection()
68
+ cursor = conn.cursor()
69
+
70
+ cursor.execute("SELECT MAX(last_studied) FROM words")
71
+ row = cursor.fetchone()
72
+ conn.close()
73
+
74
+ if row and row[0]:
75
+ last_studied_iso = row[0]
76
+ if last_studied_iso != "N/A":
77
+ last_date = last_studied_iso.split("T")[0]
78
+ today_date = datetime.now().date().isoformat()
79
+ return last_date == today_date
80
+
81
+ return False
82
+
83
+
84
+ def add_word(word, translation="N/A"):
85
+ """Add a new word and its translation to the database."""
86
+ conn = get_connection()
87
+ cursor = conn.cursor()
88
+
89
+ cursor.execute("SELECT word FROM words WHERE LOWER(word) = LOWER(?)", (word,))
90
+ if cursor.fetchone():
91
+ console.print(f"[bold yellow]The word '{word}' is already in your list![/bold yellow]")
92
+ conn.close()
93
+ return
94
+
95
+ try:
96
+ with console.status(f"[bold cyan]🔍 Fetching info for '{word}' from the internet...[/bold cyan]",
97
+ spinner="dots"):
98
+ api_info = get_word_info(word)
99
+
100
+ except ValueError:
101
+ console.print(f"[bold yellow]⚠️ '{word}' was not saved. It could not be found in the dictionary.[/bold yellow]")
102
+ conn.close()
103
+ return
104
+
105
+ except ConnectionError as e:
106
+ console.print(f"[bold red]❌ '{word}' was not saved. Network error![/bold red]")
107
+ conn.close()
108
+ return
109
+
110
+ cursor.execute('''
111
+ INSERT INTO words (word, translation, phonetic, definition, example, last_studied)
112
+ VALUES (?, ?, ?, ?, ?, ?)
113
+ ''', (
114
+ word,
115
+ translation,
116
+ api_info["phonetic"],
117
+ api_info["definition"],
118
+ api_info["example"],
119
+ None
120
+ ))
121
+
122
+ conn.commit()
123
+ conn.close()
124
+ console.print(f"[bold green]✅ Successfully added '{word}'.[/bold green]")
125
+
126
+
127
+ def add_multiple_words(words):
128
+ """Add multiple words at once with a progress bar and summary."""
129
+ conn = get_connection()
130
+ cursor = conn.cursor()
131
+
132
+ added = []
133
+ skipped = []
134
+ not_found = []
135
+ errors = []
136
+
137
+ unique_words = list(dict.fromkeys(words))
138
+
139
+ console.print()
140
+
141
+ with Progress(console=console) as progress:
142
+ task = progress.add_task("[cyan]Processing words...", total=len(unique_words))
143
+
144
+ for word in unique_words:
145
+ progress.update(task, description=f"[cyan]Fetching '{word}'...")
146
+
147
+ cursor.execute("SELECT word FROM words WHERE LOWER(word) = LOWER(?)", (word,))
148
+ if cursor.fetchone():
149
+ skipped.append(word)
150
+ progress.advance(task)
151
+ continue
152
+
153
+ try:
154
+ api_info = get_word_info(word)
155
+
156
+ cursor.execute('''
157
+ INSERT INTO words (word, translation, phonetic, definition, example, last_studied)
158
+ VALUES (?, ?, ?, ?, ?, ?)
159
+ ''', (
160
+ word,
161
+ "N/A",
162
+ api_info["phonetic"],
163
+ api_info["definition"],
164
+ api_info["example"],
165
+ None
166
+ ))
167
+ conn.commit()
168
+ added.append(word)
169
+
170
+ except ValueError:
171
+ not_found.append(word)
172
+ except ConnectionError:
173
+ errors.append(word)
174
+
175
+ progress.advance(task)
176
+
177
+ conn.close()
178
+
179
+ console.print("\n[bold magenta]Summary[/bold magenta]")
180
+
181
+ if added:
182
+ console.print(f"[bold green]✅ Added ({len(added)}):[/bold green] {', '.join(added)}")
183
+ if skipped:
184
+ console.print(f"[bold yellow]⏭️ Skipped - already saved ({len(skipped)}):[/bold yellow] {', '.join(skipped)}")
185
+ if not_found:
186
+ console.print(f"[bold red]❌ Not Found ({len(not_found)}):[/bold red] {', '.join(not_found)}")
187
+ if errors:
188
+ console.print(f"[bold red]⚠️ Network Errors ({len(errors)}):[/bold red] {', '.join(errors)}")
189
+
190
+ console.print()
191
+
192
+
193
+ def list_words(count=10):
194
+ """Display latest saved words, newest first."""
195
+ conn = get_connection()
196
+ cursor = conn.cursor()
197
+
198
+ cursor.execute("SELECT COUNT(*) FROM words")
199
+ total_words = cursor.fetchone()[0]
200
+
201
+ if total_words == 0:
202
+ console.print("[yellow]Your word list is empty. Add some words first![/yellow]")
203
+ conn.close()
204
+ return
205
+
206
+ cursor.execute("SELECT word, translation, definition FROM words ORDER BY id DESC LIMIT ?", (count,))
207
+ rows = cursor.fetchall()
208
+
209
+ display_count = len(rows)
210
+
211
+ table = Table(title=f"📚 Your Latest {display_count} Words (Total: {total_words})", show_header=True, header_style="bold magenta")
212
+
213
+ table.add_column("Word", style="cyan", width=15)
214
+ table.add_column("Translation", style="green", width=15)
215
+ table.add_column("Definition", style="white")
216
+
217
+ for row in rows:
218
+ table.add_row(row['word'], row['translation'], row['definition'])
219
+
220
+ console.print()
221
+ console.print(table)
222
+
223
+ remaining_words = total_words - display_count
224
+
225
+ if remaining_words == 1:
226
+ console.print(f"[dim]...and {remaining_words} more word hidden.[/dim]")
227
+ elif remaining_words > 1:
228
+ console.print(f"[dim]...and {remaining_words} more words hidden.[/dim]")
229
+
230
+ if has_studied_today():
231
+ console.print("\n[bold green]✅ Daily Goal: You have studied today![/bold green]")
232
+ else:
233
+ console.print("\n[bold yellow]⚠️ Daily Goal: You haven't studied today yet. Run 'study'![/bold yellow]")
234
+
235
+ console.print()
236
+ conn.close()
237
+
238
+
239
+ def show_word(word):
240
+ """Show all details for a specific word."""
241
+ conn = get_connection()
242
+ cursor = conn.cursor()
243
+
244
+ cursor.execute("SELECT * FROM words WHERE LOWER(word) = LOWER(?)", (word,))
245
+ row = cursor.fetchone()
246
+
247
+ if row:
248
+ content = (
249
+ f"[bold cyan]Translation :[/bold cyan] {row['translation']}\n"
250
+ f"[bold cyan]Phonetic :[/bold cyan] {row['phonetic']}\n"
251
+ f"[bold cyan]Definition :[/bold cyan] {row['definition']}\n"
252
+ f"[bold cyan]Example :[/bold cyan] {row['example']}\n"
253
+ f"[bold cyan]Last Studied:[/bold cyan] {format_date(row['last_studied'])}"
254
+ )
255
+
256
+ card = Panel(
257
+ content,
258
+ title=f"📖 [bold magenta]{row['word'].upper()}[/bold magenta]",
259
+ border_style="blue",
260
+ expand=False
261
+ )
262
+
263
+ console.print()
264
+ console.print(card)
265
+ console.print()
266
+ conn.close()
267
+ else:
268
+ conn.close()
269
+ console.print(f"\n[bold yellow]⚠️ The word '{word}' was not found in your list.[/bold yellow]")
270
+
271
+ choice = console.input(f"[dim]Would you like to search the dictionary and add '{word}' now? (y/n): [/dim]")
272
+ if choice.lower() == 'y':
273
+ console.print()
274
+ add_word(word, "N/A")
275
+
276
+
277
+ def delete_word(word):
278
+ """Delete a word from the saved list."""
279
+ conn = get_connection()
280
+ cursor = conn.cursor()
281
+
282
+ cursor.execute("SELECT id FROM words WHERE LOWER(word) = LOWER(?)", (word,))
283
+ if not cursor.fetchone():
284
+ console.print(f"[bold yellow]⚠️ The word '{word}' was not found in your list.[/bold yellow]")
285
+ conn.close()
286
+ return
287
+
288
+ cursor.execute("DELETE FROM words WHERE LOWER(word) = LOWER(?)", (word,))
289
+ conn.commit()
290
+ conn.close()
291
+ console.print(f"[bold green]✅ Successfully deleted '{word}'.[/bold green]")
292
+
293
+
294
+ def study_words():
295
+ """Start an interactive study session with words."""
296
+ conn = get_connection()
297
+ cursor = conn.cursor()
298
+
299
+ cursor.execute('''
300
+ SELECT * FROM words
301
+ ORDER BY last_studied ASC NULLS FIRST
302
+ LIMIT 10
303
+ ''')
304
+ study_list = cursor.fetchall()
305
+
306
+ if not study_list:
307
+ console.print("[bold yellow]Your word list is empty. Add some words first![/bold yellow]")
308
+ conn.close()
309
+ return
310
+
311
+ os.system('cls' if os.name == 'nt' else 'clear')
312
+ console.print(f"\n[bold magenta]🎓 Starting Study Session ({len(study_list)} words)[/bold magenta]")
313
+
314
+ if has_studied_today():
315
+ console.print("[bold cyan]🌟 You already studied today, but extra practice is always great![/bold cyan]\n")
316
+
317
+ console.print("Try to remember the translation and meaning.")
318
+ console.input("\n[dim]Press Enter to begin...[/dim]")
319
+
320
+ for i, row in enumerate(study_list):
321
+ os.system('cls' if os.name == 'nt' else 'clear')
322
+
323
+ front = Panel(
324
+ f"[bold white]Word {i + 1} of {len(study_list)}[/bold white]",
325
+ title=f"🤔 [bold cyan]{row['word'].upper()}[/bold cyan]",
326
+ border_style="cyan",
327
+ expand=False
328
+ )
329
+ console.print(front)
330
+
331
+ user_input = console.input("\n[dim]Press Enter to reveal answer (or 'q' to quit)...[/dim] ")
332
+ if user_input.lower() == 'q':
333
+ console.print("\n[bold yellow]Ending study session early. Great job today![/bold yellow]")
334
+ break
335
+
336
+ back_content = (
337
+ f"[bold green]Translation :[/bold green] {row['translation']}\n"
338
+ f"[bold green]Phonetic :[/bold green] {row['phonetic']}\n"
339
+ f"[bold green]Definition :[/bold green] {row['definition']}\n"
340
+ f"[bold green]Example :[/bold green] {row['example']}\n\n"
341
+ f"[dim]Previously Studied: {format_date(row['last_studied'])}[/dim]"
342
+ )
343
+
344
+ back = Panel(
345
+ back_content,
346
+ title=f"💡 [bold green]Answer[/bold green]",
347
+ border_style="green",
348
+ expand=False
349
+ )
350
+ console.print(back)
351
+
352
+ now = datetime.now().isoformat()
353
+ cursor.execute('''
354
+ UPDATE words
355
+ SET last_studied = ?
356
+ WHERE id = ?
357
+ ''', (now, row['id']))
358
+
359
+ if i < len(study_list) - 1:
360
+ next_action = console.input("\n[dim]Press Enter for the next word (or 'q' to quit)...[/dim] ")
361
+ if next_action.lower() == 'q':
362
+ console.print("\n[bold yellow]Ending study session early. Great job today![/bold yellow]")
363
+ break
364
+
365
+ conn.commit()
366
+ conn.close()
367
+ console.print("\n[bold green]✅ Study session complete! Progress saved.[/bold green]")