Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff4f2655c4 | ||
|
|
92894268c5 | ||
|
|
b2cc709031 | ||
|
|
bc055e3c05 | ||
|
|
b3c5116110 | ||
|
|
5900fda90a |
@@ -22,11 +22,34 @@ The program shows a Malay word on a card. After three seconds the card turns ove
|
||||
| `src/flash_card/` | The program, in four layers: `domain`, `application`, `infrastructure`, `presentation` |
|
||||
| `tests/` | `pytest` tests: `unit`, `integration`, `e2e` |
|
||||
| `data/` | The word list `ms-da.csv` and, after the first known word, your own `words_to_learn.csv` |
|
||||
| `docs/` | Project documents (business case, plan, design, review records) |
|
||||
| `docs/` | Project documents (business case, plan, design, review records), `docs/wiki/` and `docs/images/` |
|
||||
| `tools/` | Helper scripts: `generate_images.py` and `capture_screenshots.py` |
|
||||
| `Doxyfile` | Doxygen configuration for the source code documentation |
|
||||
|
||||
The core program uses only the Python standard library. Spoken Malay words are an optional extra.
|
||||
|
||||
### Screenshots
|
||||
|
||||
| Front: the Malay word | Back: the Danish translation after 3 seconds |
|
||||
| --- | --- |
|
||||
| ![The front of a card][screenshot-front] | ![The back of a card][screenshot-back] |
|
||||
|
||||
### Where is each assignment step?
|
||||
|
||||
| Assignment step | Code |
|
||||
| --- | --- |
|
||||
| 1. User interface | `FlashCardView.__init__` in `src/flash_card/presentation/features/flash_card/flash_card_view.py`; images made by `tools/generate_images.py` |
|
||||
| 2. New flash cards | `CsvVocabularyRepository` (the standard library `csv` instead of `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
|
||||
| 3. Flip the cards | `FlashCardView.flip_card` and `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
|
||||
| 4. Save your progress | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository` |
|
||||
| Extra: spoken word | `GttsSpeech`, `NullSpeech`, `build_speech` in `presentation/app.py` |
|
||||
|
||||
The wiki ([English][wiki-en], [Dansk][wiki-da], [Bahasa Malaysia][wiki-ms]) explains the same in three languages.
|
||||
|
||||
### Data
|
||||
|
||||
`data/ms-da.csv` holds 1000 frequent Malay words with a Danish machine translation; see [data/README.md][data-readme] for the format and origin.
|
||||
|
||||
## 📋 Requirements
|
||||
|
||||
| Need | Why |
|
||||
@@ -69,7 +92,14 @@ python -m pip install -e ".[dev]"
|
||||
python -m flash_card
|
||||
```
|
||||
|
||||
The program is run from the activated `.venv`. The window opens with the first card; the program is built step by step and the pull requests and milestones show each step.
|
||||
The program is run from the activated `.venv`. The window opens with a card. Press the green tick for a word you know and the red cross for a word you do not. Options:
|
||||
|
||||
| Option | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir <folder>` | Folder with `ms-da.csv` and `words_to_learn.csv` |
|
||||
| `--no-speech` | Do not speak the Malay word |
|
||||
|
||||
To start again, delete `data/words_to_learn.csv`.
|
||||
|
||||
## 🧪 Tests
|
||||
|
||||
@@ -87,6 +117,8 @@ lint-imports
|
||||
|
||||
The window test in `tests/e2e/` is skipped when no display is available.
|
||||
|
||||
Build the source code documentation (Doxygen comments) with `doxygen Doxyfile`; the HTML is written to `docs/doxygen/html/`. The screenshots are made with `python tools/capture_screenshots.py` after `python -m pip install -e ".[tools]"` (Pillow, tool only).
|
||||
|
||||
## 📄 License
|
||||
|
||||
GNU Affero General Public License v3.0 or later. See [LICENSE][license].
|
||||
@@ -117,6 +149,12 @@ The documents a newcomer needs, in reading order.
|
||||
---
|
||||
|
||||
[license]: ./LICENSE
|
||||
[screenshot-front]: ./docs/images/screenshot-front.png
|
||||
[screenshot-back]: ./docs/images/screenshot-back.png
|
||||
[wiki-en]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home
|
||||
[wiki-da]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home-da
|
||||
[wiki-ms]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki/Home-ms
|
||||
[data-readme]: ./data/README.md
|
||||
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card
|
||||
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/wiki
|
||||
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/issues
|
||||
|
||||
@@ -25,7 +25,7 @@ document of a type. `Primary File` may contain a glob (e.g.
|
||||
| DCD | Design Class Diagram | docs/dcd.md | 002 |
|
||||
| DICT | Domain Dictionary | docs/dictionary.md | 002 |
|
||||
| ADR | Architecture Decision Record | docs/adr/adr-NNNN-*.md | 0004 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 028 |
|
||||
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 030 |
|
||||
| TM | Traceability Matrix | docs/sqa/traceability-matrix.md | 002 |
|
||||
|
||||
## Languages
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
+14
-7
@@ -31,13 +31,13 @@ This plan schedules the seven phases (gateways) that deliver the flash card prog
|
||||
|
||||
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| Project foundation | [MIL-001] | 2026-10-14 | 2026-10-14 | S01 | none | pyproject, gitignore, skeleton, Doxyfile, first README, word list | |
|
||||
| Step 1 - User interface | [MIL-002] | 2026-10-15 | 2026-10-15 | S01 | US-001.01 | The window with card and buttons | |
|
||||
| Step 2 - New flash cards | [MIL-003] | 2026-10-16 | 2026-10-16 | S01 | US-001.02 | Random cards from the word list | |
|
||||
| Step 3 - Flip the cards | [MIL-004] | 2026-10-17 | 2026-10-17 | S01 | US-001.03 | Flip after 3000 ms | |
|
||||
| Step 4 - Save progress | [MIL-005] | 2026-10-18 | 2026-10-18 | S01 | US-001.04 | words_to_learn.csv | |
|
||||
| Spoken Malay word | [MIL-006] | 2026-10-19 | 2026-10-19 | S01 | US-001.05 | Text to speech as optional extra | |
|
||||
| Documentation and publication | [MIL-007] | 2026-10-19 to 2026-10-21 | 2026-10-21 | S01 | none | README, screenshots, wiki in 3 languages, description and topics | |
|
||||
| Project foundation | [MIL-001] | 2026-10-14 | 2026-10-14 | S01 | none | pyproject, gitignore, skeleton, Doxyfile, first README, word list | [Milestone MIL-001][MS-001] |
|
||||
| Step 1 - User interface | [MIL-002] | 2026-10-15 | 2026-10-15 | S01 | US-001.01 | The window with card and buttons | [Milestone MIL-002][MS-002] |
|
||||
| Step 2 - New flash cards | [MIL-003] | 2026-10-16 | 2026-10-16 | S01 | US-001.02 | Random cards from the word list | [Milestone MIL-003][MS-003] |
|
||||
| Step 3 - Flip the cards | [MIL-004] | 2026-10-17 | 2026-10-17 | S01 | US-001.03 | Flip after 3000 ms | [Milestone MIL-004][MS-004] |
|
||||
| Step 4 - Save progress | [MIL-005] | 2026-10-18 | 2026-10-18 | S01 | US-001.04 | words_to_learn.csv | [Milestone MIL-005][MS-005] |
|
||||
| Spoken Malay word | [MIL-006] | 2026-10-19 | 2026-10-19 | S01 | US-001.05 | Text to speech as optional extra | [Milestone MIL-006][MS-006] |
|
||||
| Documentation and publication | [MIL-007] | 2026-10-19 to 2026-10-21 | 2026-10-21 | S01 | none | README, screenshots, wiki in 3 languages, description and topics | [Milestone MIL-007][MS-007] |
|
||||
|
||||
```plantuml
|
||||
@startgantt
|
||||
@@ -106,3 +106,10 @@ A No-Go on a phase moves every later date by the days the rework takes; the buff
|
||||
[MIL-006]: ./milestones/mil-006-malay-speech.md
|
||||
[MIL-007]: ./milestones/mil-007-documentation-and-publication.md
|
||||
[c3fbcbc]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/c3fbcbcfaf799e0ae34aed7e14d3bc8a62f03db3
|
||||
[MS-001]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/165
|
||||
[MS-002]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/166
|
||||
[MS-003]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/167
|
||||
[MS-004]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/168
|
||||
[MS-005]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/169
|
||||
[MS-006]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/170
|
||||
[MS-007]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/milestone/171
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# RC-028: Review of PY-MIL-006
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-028 |
|
||||
| CrossReference | [PY-MIL-006], [QC-PY-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [b3c5116] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [PY-MIL-006]
|
||||
- Checklist used: [QC-PY-001]
|
||||
- Scope: full review
|
||||
- Language and domain: n/a (technical type)
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing holds; ruff rule set N passes. |
|
||||
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names are the DCD-001 IT terms (`SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech`). |
|
||||
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass on 55 files with no suppressions. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on 38 source files; third-party modules are imported with `importlib.import_module`, so no `type: ignore` is needed. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | The one broad `except Exception` in `GttsSpeech` is at the thread boundary, is commented, and logs with `exc_info`; it is not silent. No bare `except`. |
|
||||
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. |
|
||||
| 7 | Files, locks and connections are managed with context managers | Pass | Audio is written to a `tempfile.TemporaryDirectory` used as a context manager, so the file is always removed. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | New members have Doxygen docstrings; `doxygen Doxyfile` ends with 0 warnings. |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | Failures are logged with `logging`, not printed; only the spoken text (a word) is in the log context, no personal data. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `open_study_session(vocabulary_repository, progress_repository, speech, choose)` and `build_speech` match DCD-001 and SD-001 (the `speak` message of `startStudySession` and `markUnknown`). |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 124 tests (3 consecutive runs green) with fakes for the synthesiser and player; no test uses the network or plays a sound. |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue; no `Any`. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` is still empty; the extra `speech` pins `playsound==1.2.2` and `gTTS>=2.5`, and no module imports them outside `GttsSpeech`. |
|
||||
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The speak rule is only in `StudySession.next_card`; the unused `SPEECH_LANGUAGE_CODE` constant was removed. |
|
||||
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; the port is in `application`, the adapters in `infrastructure`, the choice in the composition root. |
|
||||
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | `StudySession` depends on the `SpeechPort` Protocol and receives the adapter; `NullSpeech` honours the same contract (Null Object), so no `if speech` test exists. |
|
||||
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | `application/ports/` and `infrastructure/speech/` follow the folder structure of the clean-architecture skill. |
|
||||
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule. |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||
|
||||
Milestone MIL-006 Go criteria: 1 `speak(text, front_code)` once per drawn card and never when empty (unit tests); 2 `gtts` and `playsound` imported only when speaking, temporary file removed (unit tests with fakes); 3 service or player failure is logged and not raised (unit tests with caplog); 4 `build_speech` returns `NullSpeech` when the extra is missing or `--no-speech` is given (unit tests); 5 `dependencies` empty, extra pins `playsound==1.2.2` (pyproject.toml); 6 `speak` returns before a blocked player is released (unit test with an Event); 7 this record, `pytest` 124 passed. A manual check with the extra installed synthesised an mp3 of `kata` in Malay (7872 bytes) and `build_speech(True)` returned `GttsSpeech`; the audio was not played on this machine. All Go.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| none | - | - |
|
||||
|
||||
---
|
||||
|
||||
[PY-MIL-006]: ../../../src/flash_card
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[b3c5116]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/b3c51161107b4cdcbc4c5334e28608423a299638
|
||||
@@ -0,0 +1,64 @@
|
||||
# RC-029: Review of PY-MIL-007
|
||||
|
||||
## Metadata
|
||||
| Key | Value |
|
||||
| --- | --- |
|
||||
| ID | RC-029 |
|
||||
| CrossReference | [PY-MIL-007], [QC-PY-001] |
|
||||
|
||||
## Version History
|
||||
| Date | Status | Author | Reviewer | Change | Commit |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-10-14 | Accepted | Jens Tirsvad Nielsen | AI as stand-in for S01 | Initial version | [9289426] |
|
||||
|
||||
---
|
||||
|
||||
## Artifact Under Review
|
||||
|
||||
- Instance reviewed: [PY-MIL-007]
|
||||
- Checklist used: [QC-PY-001]
|
||||
- Scope: full review
|
||||
- Language and domain: n/a (technical type)
|
||||
- Language reviewer: none
|
||||
- Mode: autonomous, not independent (reviewer is `AI as stand-in for S01`)
|
||||
|
||||
## Checklist Results
|
||||
|
||||
| # | Criterion | Status | Evidence/Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Packages, modules, functions, variables, classes and constants follow PEP 8 casing (`snake_case`, `PascalCase`, `UPPER_SNAKE`) | Pass | PEP 8 casing holds in `tools/capture_screenshots.py` and the changed view; ruff rule set N passes. |
|
||||
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`capture`, `_make_dpi_aware`, `SCREENSHOT_SEED`). |
|
||||
| 3 | Code is produced by the project's formatter and passes its linter with no unexplained suppressions | Pass | `ruff check` and `ruff format --check` pass on 56 files; the one `type: ignore` is for a Windows-only attribute and says which error it silences. |
|
||||
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | Every signature is annotated; `mypy --strict` passes on `src` (38 files) and on both tool scripts. |
|
||||
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No exception handling was added. |
|
||||
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable defaults, no builtin shadowed. |
|
||||
| 7 | Files, locks and connections are managed with context managers | Pass | The data copy uses `tempfile.TemporaryDirectory` as a context manager; the Tk root is destroyed. |
|
||||
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Module, functions and constants have Doxygen comments; `doxygen Doxyfile` ends with 0 warnings. |
|
||||
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | The tool writes the two output paths with `sys.stdout.write`; no `print`, no secret. |
|
||||
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | Pass | The only source change is the button background in `FlashCardView`, which follows `wireframe.md`; the tool is outside the design classes and is not part of the program. |
|
||||
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 125 tests, 2 consecutive full runs green; one e2e assertion was added for the button background; the tool is checked by running it and reading both images. |
|
||||
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | `mypy --strict` reports no issue on the touched files; no `Any`. |
|
||||
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies` stays empty; Pillow is the optional extra `tools` and is imported only by the tool. |
|
||||
| 14 | Each piece of knowledge (a business rule, constant, format, validation or query) is defined in one place; code is merged only where it expresses the same knowledge, not where it merely looks alike | Pass | The seed, output folder and delay come from one definition each; the delay is `FLIP_DELAY_MS`. |
|
||||
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `lint-imports` reports `Layers point inward` kept; the tool imports the composition root only. |
|
||||
| 16 | SOLID holds: a class or module has one reason to change; new behaviour is added by extension, not by editing a type test in several places; subclasses honour the contract of their base; protocols are narrow; high-level code depends on protocols or abstract types and receives its implementations | Pass | The tool has one purpose (capture); the view change adds no rule. |
|
||||
| 17 | Source is laid out in the layers and folders of the clean architecture folder structure (`domain`, `application`, `infrastructure`, `presentation` packages under `src/`, and `unit`, `integration` and `e2e` under `tests/`), in `snake_case`, and imports between the layers point inward | Pass | `tools/` holds helper scripts outside `src/`; the layers are unchanged. |
|
||||
| 18 | File and folder names are English, contain no space and no character outside letters, digits, `-`, `_` and `.`, and follow the casing of the language for files | Pass | File and folder names follow the rule (`docs/images`, `docs/wiki` pages are exceptions for wiki names). |
|
||||
|
||||
## Overall Verdict
|
||||
|
||||
Go — every Mandatory criterion passes. Reviewed by AI as stand-in for S01; not independent of the author.
|
||||
|
||||
Milestone MIL-007 Go criteria: 1 `check-readme.sh` exit 0 and both screenshots linked from the README; 2 `docs/wiki/` holds Home, Install, Usage and Architecture as `<Page>.md`, `<Page>-da.md`, `<Page>-ms.md` (12 pages) and `_Sidebar.md` listing the three languages; 3 the screenshots are captured from the running program by `tools/capture_screenshots.py` and were inspected; 4 the README Setup and Run need 5 commands (create `.venv`, activate, upgrade pip, install, run); 5 the repository has a description and 10 topics (read back from the API); 6 `doxygen Doxyfile` 0 warnings; 7 `docs/sqa/traceability-matrix.md` has a row for every created artifact with its latest `RC-*`, and the plan links each milestone; 8 `check-languages.sh` exit 0 (`check-plan.sh` is a commit-message hook and was not run). The wiki is pushed to the host's wiki after this merge. The Danish and Malay wiki pages were written by the AI and have not been read by a native speaker; this is stated in the end-of-run summary. All Go.
|
||||
|
||||
## Action Items
|
||||
|
||||
| Action | Owner | Due |
|
||||
| --- | --- | --- |
|
||||
| none | - | - |
|
||||
|
||||
---
|
||||
|
||||
[PY-MIL-007]: ../../../tools
|
||||
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
|
||||
[9289426]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card/commit/92894268c51ec77d56cff48d968566e6265795a2
|
||||
@@ -50,6 +50,8 @@ updated whenever an artifact instance is created or reviewed.
|
||||
| PY-MIL-003 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-025] |
|
||||
| PY-MIL-004 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-026] |
|
||||
| PY-MIL-005 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-027] |
|
||||
| PY-MIL-006 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-028] |
|
||||
| PY-MIL-007 | Python Source Code | - | - | DCD-001, ADR-0002 | - | [RC-029] |
|
||||
|
||||
## Coverage Notes
|
||||
|
||||
@@ -114,3 +116,7 @@ updated whenever an artifact instance is created or reviewed.
|
||||
[PY-MIL-004]: ../../src/flash_card
|
||||
[RC-027]: ./reviews/rc-027-py-mil-005.md
|
||||
[PY-MIL-005]: ../../src/flash_card
|
||||
[RC-028]: ./reviews/rc-028-py-mil-006.md
|
||||
[PY-MIL-006]: ../../src/flash_card
|
||||
[RC-029]: ./reviews/rc-029-py-mil-007.md
|
||||
[PY-MIL-007]: ../../src/flash_card
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Arkitektur
|
||||
|
||||
🌐 [English](Architecture) · **Dansk** · [Bahasa Malaysia](Architecture-ms)
|
||||
|
||||
Programmet er bygget i fire lag under `src/flash_card/`. Importer peger indad; `lint-imports` kontrollerer det.
|
||||
|
||||
| Lag | Indeholder | Må importere |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, fejlene og repository-grænsefladerne | ingenting |
|
||||
| `application` | `StudySession`, `open_study_session`, `CardDto` og `SpeechPort` | `domain` |
|
||||
| `infrastructure` | CSV-repositories og tale-adaptere | `application`, `domain` |
|
||||
| `presentation` | `FlashCardView`, `FlashCardViewModel` (MVVM) og kompositionsroden `app.py` | alle lag |
|
||||
|
||||
Alle konstanter står i `src/flash_card/constants.py`.
|
||||
|
||||
## Hvor er hvert opgavetrin?
|
||||
|
||||
| Opgave | Kode |
|
||||
| --- | --- |
|
||||
| Trin 1: vindue, lærred, billede, tekster, knapper | `FlashCardView.__init__` i `presentation/features/flash_card/flash_card_view.py`; billederne laves af `tools/generate_images.py` |
|
||||
| Trin 2: læs CSV, tilfældigt ord ved hvert tryk | `CsvVocabularyRepository` og `read_word_list` (erstatter `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
|
||||
| Trin 3: vend efter 3 sekunder | `FlashCardView.flip_card`, `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
|
||||
| Trin 4: kendte ord, `words_to_learn.csv` | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository`, `open_study_session` |
|
||||
| Ekstra: tale | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` i `presentation/app.py` |
|
||||
|
||||
## Hvorfor en lagdelt løsning til en lille opgave?
|
||||
|
||||
Reglerne (hvilket kort der kommer næste gang, hvad et kendt ord gør) testes uden vindue og uden filsystem. Vinduet, CSV-filerne og taletjenesten kan udskiftes uden at røre reglerne. Se ADR-0001 og ADR-0002 i `docs/adr/`.
|
||||
|
||||
## Test
|
||||
|
||||
| Mappe | Hvad den tester |
|
||||
| --- | --- |
|
||||
| `tests/unit/` | Hvert lag for sig, med falske objekter til portene |
|
||||
| `tests/integration/` | Den rigtige ordliste og fremgangsfilen i en midlertidig mappe |
|
||||
| `tests/e2e/` | Det rigtige Tkinter-vindue (springes over uden skærm) |
|
||||
|
||||
## Projektdokumenter
|
||||
|
||||
Business case, plan, design og gennemgangsrapporter ligger i `docs/`. Arbejdet blev udført i syv milepæle, én pull request hver: se `docs/milestones/`.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Seni bina
|
||||
|
||||
🌐 [English](Architecture) · [Dansk](Architecture-da) · **Bahasa Malaysia**
|
||||
|
||||
Program dibina dalam empat lapisan di bawah `src/flash_card/`. Import menuju ke dalam; `lint-imports` memeriksanya.
|
||||
|
||||
| Lapisan | Mengandungi | Boleh mengimport |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, ralat dan antara muka repositori | tiada |
|
||||
| `application` | `StudySession`, `open_study_session`, `CardDto` dan `SpeechPort` | `domain` |
|
||||
| `infrastructure` | Repositori CSV dan penyesuai suara | `application`, `domain` |
|
||||
| `presentation` | `FlashCardView`, `FlashCardViewModel` (MVVM) dan akar komposisi `app.py` | semua lapisan |
|
||||
|
||||
Semua pemalar terdapat dalam `src/flash_card/constants.py`.
|
||||
|
||||
## Di manakah setiap langkah tugasan?
|
||||
|
||||
| Tugasan | Kod |
|
||||
| --- | --- |
|
||||
| Langkah 1: tetingkap, kanvas, imej, teks, butang | `FlashCardView.__init__` dalam `presentation/features/flash_card/flash_card_view.py`; imej dibuat oleh `tools/generate_images.py` |
|
||||
| Langkah 2: baca CSV, perkataan rawak setiap tekanan | `CsvVocabularyRepository` dan `read_word_list` (menggantikan `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
|
||||
| Langkah 3: terbalik selepas 3 saat | `FlashCardView.flip_card`, `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
|
||||
| Langkah 4: perkataan diketahui, `words_to_learn.csv` | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository`, `open_study_session` |
|
||||
| Tambahan: suara | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` dalam `presentation/app.py` |
|
||||
|
||||
## Mengapa program berlapis untuk tugasan kecil?
|
||||
|
||||
Peraturan (kad mana yang seterusnya, apa yang berlaku apabila perkataan diketahui) diuji tanpa tetingkap atau sistem fail. Tetingkap, fail CSV dan perkhidmatan suara boleh diganti tanpa menyentuh peraturan. Lihat ADR-0001 dan ADR-0002 dalam `docs/adr/`.
|
||||
|
||||
## Ujian
|
||||
|
||||
| Folder | Apa yang diuji |
|
||||
| --- | --- |
|
||||
| `tests/unit/` | Setiap lapisan sendiri, dengan objek palsu untuk port |
|
||||
| `tests/integration/` | Senarai perkataan sebenar dan fail kemajuan dalam folder sementara |
|
||||
| `tests/e2e/` | Tetingkap Tkinter sebenar (dilangkau tanpa paparan) |
|
||||
|
||||
## Dokumen projek
|
||||
|
||||
Kes perniagaan, pelan, reka bentuk dan rekod semakan terdapat dalam `docs/`. Kerja dilakukan dalam tujuh pencapaian, satu pull request setiap satu: lihat `docs/milestones/`.
|
||||
@@ -0,0 +1,40 @@
|
||||
# Architecture
|
||||
|
||||
🌐 **English** · [Dansk](Architecture-da) · [Bahasa Malaysia](Architecture-ms)
|
||||
|
||||
The program is built in four layers under `src/flash_card/`. Imports point inward; `lint-imports` checks this.
|
||||
|
||||
| Layer | Holds | May import |
|
||||
| --- | --- | --- |
|
||||
| `domain` | `Card`, `LanguagePair`, `Vocabulary`, `Deck`, the errors and the repository interfaces | nothing |
|
||||
| `application` | `StudySession`, `open_study_session`, `CardDto` and `SpeechPort` | `domain` |
|
||||
| `infrastructure` | The CSV repositories and the speech adapters | `application`, `domain` |
|
||||
| `presentation` | `FlashCardView`, `FlashCardViewModel` (MVVM) and the composition root `app.py` | all layers |
|
||||
|
||||
All constants are in `src/flash_card/constants.py`.
|
||||
|
||||
## Where is each assignment step?
|
||||
|
||||
| Assignment | Code |
|
||||
| --- | --- |
|
||||
| Step 1: window, canvas, image, texts, buttons | `FlashCardView.__init__` in `presentation/features/flash_card/flash_card_view.py`; images made by `tools/generate_images.py` |
|
||||
| Step 2: read the CSV, random word on each press | `CsvVocabularyRepository` and `read_word_list` (replace `pandas`), `Deck.draw`, `StudySession.next_card`, `FlashCardView.next_card` |
|
||||
| Step 3: flip after 3 seconds | `FlashCardView.flip_card`, `FlashCardView.flip_timer` (`after`, `after_cancel`), `FlashCardViewModel.flip_card` |
|
||||
| Step 4: known words, `words_to_learn.csv` | `FlashCardView.is_known`, `StudySession.mark_known`, `CsvProgressRepository`, `open_study_session` |
|
||||
| Extra: speech | `SpeechPort`, `GttsSpeech`, `NullSpeech`, `build_speech` in `presentation/app.py` |
|
||||
|
||||
## Why a layered program for a small assignment?
|
||||
|
||||
The rules (which card comes next, what a known word does) are tested without a window or a file system. The window, the CSV files and the speech service can be replaced without touching the rules. See ADR-0001 and ADR-0002 in `docs/adr/`.
|
||||
|
||||
## Tests
|
||||
|
||||
| Folder | What it tests |
|
||||
| --- | --- |
|
||||
| `tests/unit/` | Each layer alone, with fakes for the ports |
|
||||
| `tests/integration/` | The real word list and the progress file in a temporary folder |
|
||||
| `tests/e2e/` | The real Tkinter window (skipped when no display exists) |
|
||||
|
||||
## Project documents
|
||||
|
||||
The business case, plan, design and review records are in `docs/`. The work was done in seven milestones, one pull request each: see `docs/milestones/`.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Flash Card
|
||||
|
||||
🌐 [English](Home) · **Dansk** · [Bahasa Malaysia](Home-ms)
|
||||
|
||||
Et flashkortprogram til at lære de mest almindelige malajiske ord med danske oversættelser. Det er løsningen på dag 31 i Udemys kursus *100 Days of Code: The Complete Python Pro Bootcamp*.
|
||||
|
||||

|
||||

|
||||
|
||||
| Side | Læs den, når du vil |
|
||||
| --- | --- |
|
||||
| [Installation](Install-da) | Oprette `.venv`, installere programmet og køre testene |
|
||||
| [Brug](Usage-da) | Øve kortene, forstå fremgangsfilen og bruge indstillingerne |
|
||||
| [Arkitektur](Architecture-da) | Se lagene og finde koden til hvert opgavetrin |
|
||||
|
||||
## De fire opgavetrin
|
||||
|
||||
| Trin | Hvad programmet gør | Milepæl |
|
||||
| --- | --- | --- |
|
||||
| 1. Brugerflade | Et vindue med et kort og to knapper | MIL-002 |
|
||||
| 2. Nye flashkort | Et tilfældigt ord fra ordlisten ved hvert tryk på en knap | MIL-003 |
|
||||
| 3. Vend kortene | Efter 3 sekunder viser kortet den danske oversættelse | MIL-004 |
|
||||
| 4. Gem din fremgang | Kendte ord fjernes, og resten gemmes i `words_to_learn.csv` | MIL-005 |
|
||||
| Ekstra | Det malajiske ord bliver udtalt (valgfrit) | MIL-006 |
|
||||
|
||||
## Til kursister
|
||||
|
||||
Opgavens funktionsnavne er bevaret: `next_card`, `flip_card`, `is_known` og attributterne `flip_timer` og `current_card`. Data læses med standardbibliotekets `csv`-modul i stedet for `pandas`, så kerneprogrammet ikke har nogen afhængigheder ved kørsel.
|
||||
|
||||
Repository: <https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card>
|
||||
@@ -0,0 +1,30 @@
|
||||
# Flash Card
|
||||
|
||||
🌐 [English](Home) · [Dansk](Home-da) · **Bahasa Malaysia**
|
||||
|
||||
Program kad imbas untuk mempelajari perkataan Melayu yang paling kerap digunakan beserta terjemahan Denmark. Ia ialah penyelesaian hari ke-31 kursus Udemy *100 Days of Code: The Complete Python Pro Bootcamp*.
|
||||
|
||||

|
||||

|
||||
|
||||
| Halaman | Baca apabila anda mahu |
|
||||
| --- | --- |
|
||||
| [Pemasangan](Install-ms) | Mencipta `.venv`, memasang program dan menjalankan ujian |
|
||||
| [Penggunaan](Usage-ms) | Berlatih dengan kad, memahami fail kemajuan dan menggunakan pilihan |
|
||||
| [Seni bina](Architecture-ms) | Melihat lapisan dan mencari kod bagi setiap langkah tugasan |
|
||||
|
||||
## Empat langkah tugasan
|
||||
|
||||
| Langkah | Apa yang program lakukan | Pencapaian |
|
||||
| --- | --- | --- |
|
||||
| 1. Antara muka pengguna | Tetingkap dengan satu kad dan dua butang | MIL-002 |
|
||||
| 2. Kad imbas baharu | Satu perkataan rawak daripada senarai perkataan setiap kali butang ditekan | MIL-003 |
|
||||
| 3. Terbalikkan kad | Selepas 3 saat kad menunjukkan terjemahan Denmark | MIL-004 |
|
||||
| 4. Simpan kemajuan anda | Perkataan yang diketahui dibuang dan selebihnya disimpan dalam `words_to_learn.csv` | MIL-005 |
|
||||
| Tambahan | Perkataan Melayu disebut (pilihan) | MIL-006 |
|
||||
|
||||
## Untuk peserta kursus
|
||||
|
||||
Nama fungsi tugasan dikekalkan: `next_card`, `flip_card`, `is_known`, serta atribut `flip_timer` dan `current_card`. Data dibaca dengan modul `csv` pustaka standard dan bukan `pandas`, jadi program teras tidak mempunyai kebergantungan semasa larian.
|
||||
|
||||
Repositori: <https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card>
|
||||
@@ -0,0 +1,30 @@
|
||||
# Flash Card
|
||||
|
||||
🌐 **English** · [Dansk](Home-da) · [Bahasa Malaysia](Home-ms)
|
||||
|
||||
A flash card program for learning the most frequent Malay words with their Danish translations. It is the solution of day 31 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*.
|
||||
|
||||

|
||||

|
||||
|
||||
| Page | Read it when you want to |
|
||||
| --- | --- |
|
||||
| [Install](Install) | Create the `.venv`, install the program and run the tests |
|
||||
| [Usage](Usage) | Study the cards, understand the progress file, use the options |
|
||||
| [Architecture](Architecture) | See the layers and find the code of each assignment step |
|
||||
|
||||
## The four assignment steps
|
||||
|
||||
| Step | What the program does | Milestone |
|
||||
| --- | --- | --- |
|
||||
| 1. User interface | A window with a card and two buttons | MIL-002 |
|
||||
| 2. New flash cards | A random word from the word list on every button press | MIL-003 |
|
||||
| 3. Flip the cards | After 3 seconds the card shows the Danish translation | MIL-004 |
|
||||
| 4. Save your progress | Known words are removed and the rest is saved in `words_to_learn.csv` | MIL-005 |
|
||||
| Extra | The Malay word is spoken (optional) | MIL-006 |
|
||||
|
||||
## For course participants
|
||||
|
||||
The function names of the assignment are kept: `next_card`, `flip_card`, `is_known`, and the attributes `flip_timer` and `current_card`. The data is read with the standard library `csv` module instead of `pandas`, so the core program has no runtime dependency.
|
||||
|
||||
Repository: <https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card>
|
||||
@@ -0,0 +1,65 @@
|
||||
# Installation
|
||||
|
||||
🌐 [English](Install) · **Dansk** · [Bahasa Malaysia](Install-ms)
|
||||
|
||||
## Krav
|
||||
|
||||
- Python 3.13 eller nyere med Tkinter (på Debian eller Ubuntu: `sudo apt install python3-tk`).
|
||||
- `git`.
|
||||
- Valgfrit: en internetforbindelse og tilvalget `speech`, hvis du vil høre det malajiske ord.
|
||||
|
||||
## Hent koden
|
||||
|
||||
```bash
|
||||
git clone https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card.git
|
||||
cd 031-flash-card
|
||||
```
|
||||
|
||||
## Opret og brug et lokalt virtuelt miljø
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Linux og macOS:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
- `.[dev]` tilføjer `pytest`, `ruff`, `mypy` og `import-linter`. Brug `.`, hvis du kun vil køre programmet.
|
||||
- Hvis PowerShell nægter at køre `Activate.ps1`, så kør `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` én gang.
|
||||
- Forlad miljøet med `deactivate`; slet mappen `.venv` for at starte forfra.
|
||||
|
||||
## Udtalt malajisk ord (valgfrit)
|
||||
|
||||
```bash
|
||||
python -m pip install -e ".[speech]"
|
||||
```
|
||||
|
||||
Det installerer `gTTS` og `playsound==1.2.2`. Uden tilvalget virker programmet uden lyd.
|
||||
|
||||
## Kør og test
|
||||
|
||||
```bash
|
||||
python -m flash_card
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Vinduestesten i `tests/e2e/` springes over, når der ikke er en skærm.
|
||||
|
||||
## Dokumentation af kildekoden
|
||||
|
||||
```bash
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
HTML-filerne skrives til `docs/doxygen/html/index.html`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Pemasangan
|
||||
|
||||
🌐 [English](Install) · [Dansk](Install-da) · **Bahasa Malaysia**
|
||||
|
||||
## Keperluan
|
||||
|
||||
- Python 3.13 atau lebih baharu dengan Tkinter (pada Debian atau Ubuntu: `sudo apt install python3-tk`).
|
||||
- `git`.
|
||||
- Pilihan: sambungan internet dan tambahan `speech` jika anda mahu mendengar perkataan Melayu.
|
||||
|
||||
## Dapatkan kod
|
||||
|
||||
```bash
|
||||
git clone https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card.git
|
||||
cd 031-flash-card
|
||||
```
|
||||
|
||||
## Cipta dan gunakan persekitaran maya setempat
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Linux dan macOS:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
- `.[dev]` menambah `pytest`, `ruff`, `mypy` dan `import-linter`. Gunakan `.` jika anda hanya mahu menjalankan program.
|
||||
- Jika PowerShell enggan menjalankan `Activate.ps1`, jalankan `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` sekali.
|
||||
- Keluar daripada persekitaran dengan `deactivate`; padam folder `.venv` untuk bermula semula.
|
||||
|
||||
## Perkataan Melayu bersuara (pilihan)
|
||||
|
||||
```bash
|
||||
python -m pip install -e ".[speech]"
|
||||
```
|
||||
|
||||
Ini memasang `gTTS` dan `playsound==1.2.2`. Tanpa tambahan ini program berfungsi tanpa bunyi.
|
||||
|
||||
## Jalankan dan uji
|
||||
|
||||
```bash
|
||||
python -m flash_card
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
Ujian tetingkap dalam `tests/e2e/` dilangkau apabila tiada paparan.
|
||||
|
||||
## Dokumentasi kod sumber
|
||||
|
||||
```bash
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
HTML ditulis ke `docs/doxygen/html/index.html`.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Install
|
||||
|
||||
🌐 **English** · [Dansk](Install-da) · [Bahasa Malaysia](Install-ms)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.13 or later with Tkinter (on Debian or Ubuntu: `sudo apt install python3-tk`).
|
||||
- `git`.
|
||||
- Optional: an internet connection and the `speech` extra to hear the Malay word.
|
||||
|
||||
## Get the code
|
||||
|
||||
```bash
|
||||
git clone https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/031-flash-card.git
|
||||
cd 031-flash-card
|
||||
```
|
||||
|
||||
## Create and use a local virtual environment
|
||||
|
||||
Windows (PowerShell):
|
||||
|
||||
```powershell
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\Activate.ps1
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
Linux and macOS:
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
- `.[dev]` adds `pytest`, `ruff`, `mypy` and `import-linter`. Use `.` if you only want to run the program.
|
||||
- If PowerShell refuses to run `Activate.ps1`, run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once.
|
||||
- Leave the environment with `deactivate`; delete the `.venv` folder to start over.
|
||||
|
||||
## Spoken Malay word (optional)
|
||||
|
||||
```bash
|
||||
python -m pip install -e ".[speech]"
|
||||
```
|
||||
|
||||
This installs `gTTS` and `playsound==1.2.2`. Without it the program works silently.
|
||||
|
||||
## Run and test
|
||||
|
||||
```bash
|
||||
python -m flash_card
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
The window test in `tests/e2e/` is skipped when no display is available.
|
||||
|
||||
## Documentation of the source code
|
||||
|
||||
```bash
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
The HTML is written to `docs/doxygen/html/index.html`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Brug
|
||||
|
||||
🌐 [English](Usage) · **Dansk** · [Bahasa Malaysia](Usage-ms)
|
||||
|
||||
## Øv kortene
|
||||
|
||||
1. Start programmet med `python -m flash_card`.
|
||||
2. Et kort viser et malajisk ord. Prøv at huske den danske oversættelse.
|
||||
3. Efter tre sekunder vender kortet og viser oversættelsen.
|
||||
4. Tryk på det grønne flueben, hvis du kendte ordet, eller på det røde kryds, hvis du ikke gjorde.
|
||||
5. Et nyt tilfældigt ord vises. Gentag.
|
||||
|
||||
Et ord, du har markeret som kendt, fjernes og kommer ikke igen. Når der ikke er flere ord, står der "Nothing left to learn" på kortet.
|
||||
|
||||
## Din fremgang
|
||||
|
||||
- Første gang du trykker på det grønne flueben, skriver programmet `data/words_to_learn.csv` med de ord, du endnu ikke kan.
|
||||
- Næste start bruger den fil. Findes den ikke, bruger programmet hele ordlisten `data/ms-da.csv`.
|
||||
- For at starte forfra skal du slette `data/words_to_learn.csv`.
|
||||
- Det røde kryds ændrer aldrig filen.
|
||||
|
||||
## Filformat
|
||||
|
||||
Begge filer er UTF-8-tekst med ét kort pr. linje og cellerne adskilt af semikolon. Første linje har de to sprogkoder.
|
||||
|
||||
```text
|
||||
ms;da
|
||||
aku;mig
|
||||
yang;hvilke
|
||||
```
|
||||
|
||||
## Indstillinger
|
||||
|
||||
| Indstilling | Betydning |
|
||||
| --- | --- |
|
||||
| `--data-dir <mappe>` | Mappen med `ms-da.csv` og `words_to_learn.csv` (standard: `data/` i repositoryet) |
|
||||
| `--no-speech` | Udtal ikke det malajiske ord, selv om tilvalget til tale er installeret |
|
||||
|
||||
## Brug et andet sprogpar
|
||||
|
||||
Læg en fil med samme format i datamappen og kald den `ms-da.csv`, eller ret `VOCABULARY_FILE_NAME` og `LANGUAGE_NAMES` i `src/flash_card/constants.py`. Kodene i overskriften vises som sprognavne fra `LANGUAGE_NAMES`; ukendte koder vises med store bogstaver.
|
||||
|
||||
## Fejlfinding
|
||||
|
||||
| Problem | Hvad du skal gøre |
|
||||
| --- | --- |
|
||||
| `No module named tkinter` | Installer Tkinter (`python3-tk` på Debian og Ubuntu) |
|
||||
| Ingen lyd | Installer tilvalget `speech` og tjek netværket; programmet logger en advarsel, når udtalen mislykkes |
|
||||
| Programmet stopper med en meddelelse om filen | CSV-filen er forkert opbygget; meddelelsen nævner filen og linjen. Filen bliver ikke overskrevet |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Penggunaan
|
||||
|
||||
🌐 [English](Usage) · [Dansk](Usage-da) · **Bahasa Malaysia**
|
||||
|
||||
## Berlatih dengan kad
|
||||
|
||||
1. Mulakan program dengan `python -m flash_card`.
|
||||
2. Kad menunjukkan satu perkataan Melayu. Cuba ingat terjemahan Denmarknya.
|
||||
3. Selepas tiga saat kad terbalik dan menunjukkan terjemahan.
|
||||
4. Tekan tanda semak hijau jika anda tahu perkataan itu, atau pangkah merah jika tidak.
|
||||
5. Satu perkataan rawak baharu muncul. Ulang.
|
||||
|
||||
Perkataan yang anda tandakan sebagai diketahui dibuang dan tidak muncul lagi. Apabila tiada perkataan lagi, kad menunjukkan "Nothing left to learn".
|
||||
|
||||
## Kemajuan anda
|
||||
|
||||
- Kali pertama anda menekan tanda semak hijau, program menulis `data/words_to_learn.csv` dengan perkataan yang anda belum tahu.
|
||||
- Permulaan seterusnya menggunakan fail itu. Jika ia tiada, program menggunakan senarai penuh `data/ms-da.csv`.
|
||||
- Untuk bermula semula, padam `data/words_to_learn.csv`.
|
||||
- Pangkah merah tidak pernah mengubah fail.
|
||||
|
||||
## Format fail
|
||||
|
||||
Kedua-dua fail ialah teks UTF-8 dengan satu kad setiap baris dan sel dipisahkan oleh koma bertitik. Baris pertama mengandungi dua kod bahasa.
|
||||
|
||||
```text
|
||||
ms;da
|
||||
aku;mig
|
||||
yang;hvilke
|
||||
```
|
||||
|
||||
## Pilihan
|
||||
|
||||
| Pilihan | Maksud |
|
||||
| --- | --- |
|
||||
| `--data-dir <folder>` | Folder yang mengandungi `ms-da.csv` dan `words_to_learn.csv` (lalai: `data/` dalam repositori) |
|
||||
| `--no-speech` | Jangan sebut perkataan Melayu, walaupun tambahan suara dipasang |
|
||||
|
||||
## Gunakan pasangan bahasa lain
|
||||
|
||||
Letakkan fail dengan format yang sama dalam folder data dan namakannya `ms-da.csv`, atau ubah `VOCABULARY_FILE_NAME` dan `LANGUAGE_NAMES` dalam `src/flash_card/constants.py`. Kod pada baris pertama dipaparkan sebagai nama bahasa daripada `LANGUAGE_NAMES`; kod yang tidak dikenali dipaparkan dengan huruf besar.
|
||||
|
||||
## Penyelesaian masalah
|
||||
|
||||
| Masalah | Apa yang perlu dilakukan |
|
||||
| --- | --- |
|
||||
| `No module named tkinter` | Pasang Tkinter (`python3-tk` pada Debian dan Ubuntu) |
|
||||
| Tiada bunyi | Pasang tambahan `speech` dan periksa rangkaian; program mencatat amaran apabila penyebutan gagal |
|
||||
| Program berhenti dengan mesej tentang fail | Fail CSV tidak betul; mesej menyebut fail dan barisnya. Fail tidak ditulis ganti |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Usage
|
||||
|
||||
🌐 **English** · [Dansk](Usage-da) · [Bahasa Malaysia](Usage-ms)
|
||||
|
||||
## Study the cards
|
||||
|
||||
1. Start the program with `python -m flash_card`.
|
||||
2. A card shows a Malay word. Try to remember its Danish translation.
|
||||
3. After three seconds the card turns over and shows the translation.
|
||||
4. Press the green tick when you knew the word, or the red cross when you did not.
|
||||
5. A new random word appears. Repeat.
|
||||
|
||||
A word you marked as known is removed and does not come up again. When no word is left, the card says "Nothing left to learn".
|
||||
|
||||
## Your progress
|
||||
|
||||
- The first time you press the green tick, the program writes `data/words_to_learn.csv` with the words you do not know yet.
|
||||
- The next start uses that file. If it does not exist, the program uses the full word list `data/ms-da.csv`.
|
||||
- To start again, delete `data/words_to_learn.csv`.
|
||||
- The red cross never changes the file.
|
||||
|
||||
## File format
|
||||
|
||||
Both files are UTF-8 text with one card per line and the cells separated by a semicolon. The first line holds the two language codes.
|
||||
|
||||
```text
|
||||
ms;da
|
||||
aku;mig
|
||||
yang;hvilke
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Meaning |
|
||||
| --- | --- |
|
||||
| `--data-dir <folder>` | Folder that holds `ms-da.csv` and `words_to_learn.csv` (default: `data/` of the repository) |
|
||||
| `--no-speech` | Do not speak the Malay word, even when the speech extra is installed |
|
||||
|
||||
## Use another language pair
|
||||
|
||||
Put a file with the same format in the data folder and name it `ms-da.csv`, or change `VOCABULARY_FILE_NAME` and `LANGUAGE_NAMES` in `src/flash_card/constants.py`. The header codes are shown as language names from `LANGUAGE_NAMES`; unknown codes are shown in upper case.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | What to do |
|
||||
| --- | --- |
|
||||
| `No module named tkinter` | Install Tkinter (`python3-tk` on Debian and Ubuntu) |
|
||||
| No sound | Install the `speech` extra and check the network; the program logs a warning when speaking fails |
|
||||
| The program stops with a message about the file | The CSV file is malformed; the message names the file and the line. The file is not overwritten |
|
||||
@@ -0,0 +1,12 @@
|
||||
**Flash Card**
|
||||
|
||||
- [Home](Home)
|
||||
- [Install](Install)
|
||||
- [Usage](Usage)
|
||||
- [Architecture](Architecture)
|
||||
|
||||
**Languages**
|
||||
|
||||
- English: [Home](Home) · [Install](Install) · [Usage](Usage) · [Architecture](Architecture)
|
||||
- Dansk: [Hjem](Home-da) · [Installation](Install-da) · [Brug](Usage-da) · [Arkitektur](Architecture-da)
|
||||
- Bahasa Malaysia: [Utama](Home-ms) · [Pemasangan](Install-ms) · [Penggunaan](Usage-ms) · [Seni bina](Architecture-ms)
|
||||
@@ -24,6 +24,8 @@ dependencies = []
|
||||
[project.optional-dependencies]
|
||||
# Spoken Malay word (text to speech). Optional; the program works without it.
|
||||
speech = ["gTTS>=2.5", "playsound==1.2.2"]
|
||||
# Only for tools/capture_screenshots.py; not needed to run the program.
|
||||
tools = ["pillow>=10"]
|
||||
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11", "import-linter>=2.0"]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Ports of the application layer.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the interfaces the use cases need from outside.
|
||||
"""
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Port for spoken words.
|
||||
|
||||
@file speech_port.py
|
||||
@brief The SpeechPort interface the study session uses to say a word aloud.
|
||||
"""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class SpeechPort(Protocol):
|
||||
"""Speaks a text aloud in a language."""
|
||||
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
"""Speak a text without blocking the caller for long.
|
||||
|
||||
An implementation does not raise: a failure is logged and ignored.
|
||||
|
||||
@param text The text to speak.
|
||||
@param language_code Language of the text, for example "ms".
|
||||
@return Nothing.
|
||||
"""
|
||||
...
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from flash_card.application.ports.speech_port import SpeechPort
|
||||
from flash_card.application.use_cases.study_session import StudySession
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
@@ -16,12 +17,14 @@ from flash_card.domain.repositories.vocabulary_repository import VocabularyRepos
|
||||
def open_study_session(
|
||||
vocabulary_repository: VocabularyRepository,
|
||||
progress_repository: ProgressRepository,
|
||||
speech: SpeechPort,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
) -> StudySession:
|
||||
"""Build a session over the saved words to learn, else the whole word list.
|
||||
|
||||
@param vocabulary_repository Source of the delivered word list.
|
||||
@param progress_repository Store of the words still to learn.
|
||||
@param speech Speaks the front text of each drawn card.
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@return The new session.
|
||||
@throws VocabularyError When the saved progress, or the word list when no
|
||||
@@ -30,4 +33,4 @@ def open_study_session(
|
||||
deck = progress_repository.load()
|
||||
if deck is None:
|
||||
deck = Deck.from_vocabulary(vocabulary_repository.load())
|
||||
return StudySession(deck, choose, progress_repository)
|
||||
return StudySession(deck, choose, progress_repository, speech)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
from collections.abc import Callable, Sequence
|
||||
|
||||
from flash_card.application.dto.card_dto import CardDto
|
||||
from flash_card.application.ports.speech_port import SpeechPort
|
||||
from flash_card.domain.entities.card import Card
|
||||
from flash_card.domain.entities.deck import Deck
|
||||
from flash_card.domain.repositories.progress_repository import ProgressRepository
|
||||
@@ -20,13 +21,17 @@ class StudySession:
|
||||
deck: Deck,
|
||||
choose: Callable[[Sequence[Card]], Card],
|
||||
progress: ProgressRepository,
|
||||
speech: SpeechPort,
|
||||
) -> None:
|
||||
"""Create a session.
|
||||
|
||||
@param deck The cards still to learn.
|
||||
@param choose Picks one card from a non-empty sequence.
|
||||
@param progress Store of the words still to learn.
|
||||
@param speech Speaks the front text of each drawn card.
|
||||
"""
|
||||
## @brief Speaks the front text of each drawn card.
|
||||
self._speech = speech
|
||||
## @brief Store of the words still to learn.
|
||||
self._progress = progress
|
||||
## @brief The cards still to learn.
|
||||
@@ -60,8 +65,10 @@ class StudySession:
|
||||
if self._deck.is_empty:
|
||||
self._current = None
|
||||
return None
|
||||
self._current = self._deck.draw(self._choose, exclude=self._current)
|
||||
return self._to_dto(self._current)
|
||||
card = self._deck.draw(self._choose, exclude=self._current)
|
||||
self._current = card
|
||||
self._speech.speak(card.front, self._deck.language_pair.front_code)
|
||||
return self._to_dto(card)
|
||||
|
||||
def mark_known(self) -> None:
|
||||
"""Remove the current card from the deck and save the remaining cards.
|
||||
|
||||
@@ -51,8 +51,6 @@ CSV_ENCODING: Final[str] = "utf-8-sig"
|
||||
VOCABULARY_FILE_NAME: Final[str] = "ms-da.csv"
|
||||
## @brief File name of the words still to learn.
|
||||
PROGRESS_FILE_NAME: Final[str] = "words_to_learn.csv"
|
||||
## @brief Language code used for speech.
|
||||
SPEECH_LANGUAGE_CODE: Final[str] = "ms"
|
||||
|
||||
## @brief Root folder of the project.
|
||||
PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Speech adapters.
|
||||
|
||||
@file __init__.py
|
||||
@brief Holds the implementations of the speech port.
|
||||
"""
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Speech adapter over the gTTS service.
|
||||
|
||||
@file gtts_speech.py
|
||||
@brief GttsSpeech, the SpeechPort that synthesises with gTTS and plays the file.
|
||||
The packages gtts and playsound are optional and imported only when used.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import tempfile
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
## @brief Logger of the speech adapter.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
## @brief File name of the synthesised speech inside the temporary folder.
|
||||
_SPEECH_FILE_NAME = "speech.mp3"
|
||||
|
||||
## @brief Writes an mp3 of a text and language to a path.
|
||||
Synthesize = Callable[[str, str, Path], None]
|
||||
## @brief Plays an audio file.
|
||||
Play = Callable[[Path], None]
|
||||
|
||||
|
||||
def _default_synthesize(text: str, language_code: str, path: Path) -> None:
|
||||
"""Write an mp3 of the text with gTTS.
|
||||
|
||||
@param text The text to speak.
|
||||
@param language_code Language of the text.
|
||||
@param path File to write.
|
||||
@return Nothing.
|
||||
@throws ImportError When the gtts package is not installed.
|
||||
"""
|
||||
# Imported by name so the program starts without the optional extra.
|
||||
module = importlib.import_module("gtts")
|
||||
module.gTTS(text=text, lang=language_code).save(str(path))
|
||||
|
||||
|
||||
def _default_play(path: Path) -> None:
|
||||
"""Play an audio file and wait until it has been played.
|
||||
|
||||
@param path File to play.
|
||||
@return Nothing.
|
||||
@throws ImportError When the playsound package is not installed.
|
||||
"""
|
||||
# Imported by name so the program starts without the optional extra.
|
||||
module = importlib.import_module("playsound")
|
||||
module.playsound(str(path))
|
||||
|
||||
|
||||
class GttsSpeech:
|
||||
"""Speaks a text in a background thread; never raises."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
synthesize: Synthesize | None = None,
|
||||
play: Play | None = None,
|
||||
) -> None:
|
||||
"""Create the adapter.
|
||||
|
||||
@param synthesize Writes an mp3 of a text and language to a path, or
|
||||
None for gTTS.
|
||||
@param play Plays an audio file, or None for playsound.
|
||||
"""
|
||||
## @brief Writes the audio file.
|
||||
self._synthesize = _default_synthesize if synthesize is None else synthesize
|
||||
## @brief Plays the audio file.
|
||||
self._play = _default_play if play is None else play
|
||||
## @brief The thread of the latest speak call, or None.
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
"""Start speaking and return at once.
|
||||
|
||||
@param text The text to speak.
|
||||
@param language_code Language of the text.
|
||||
@return Nothing.
|
||||
"""
|
||||
thread = threading.Thread(
|
||||
target=self._run, args=(text, language_code), daemon=True
|
||||
)
|
||||
self._thread = thread
|
||||
thread.start()
|
||||
|
||||
def wait(self) -> None:
|
||||
"""Block until the latest speech has ended.
|
||||
|
||||
@return Nothing.
|
||||
"""
|
||||
if self._thread is not None:
|
||||
self._thread.join()
|
||||
|
||||
def _run(self, text: str, language_code: str) -> None:
|
||||
"""Synthesise and play in a temporary folder, then remove the file.
|
||||
|
||||
@param text The text to speak.
|
||||
@param language_code Language of the text.
|
||||
@return Nothing.
|
||||
"""
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
path = Path(folder) / _SPEECH_FILE_NAME
|
||||
self._synthesize(text, language_code, path)
|
||||
self._play(path)
|
||||
except Exception: # thread boundary: speech must never crash the program
|
||||
_logger.warning("Speech failed for %r", text, exc_info=True)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Silent speech adapter.
|
||||
|
||||
@file null_speech.py
|
||||
@brief NullSpeech, the SpeechPort used when speech is off or unavailable.
|
||||
"""
|
||||
|
||||
|
||||
class NullSpeech:
|
||||
"""Speaks nothing."""
|
||||
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
"""Do nothing.
|
||||
|
||||
@param text The text, ignored.
|
||||
@param language_code The language, ignored.
|
||||
@return Nothing.
|
||||
"""
|
||||
return None
|
||||
@@ -5,6 +5,7 @@
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import logging
|
||||
import random
|
||||
import tkinter as tk
|
||||
@@ -12,6 +13,7 @@ from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
from flash_card.application.ports.speech_port import SpeechPort
|
||||
from flash_card.application.use_cases.open_study_session import open_study_session
|
||||
from flash_card.constants import (
|
||||
DEFAULT_DATA_DIR,
|
||||
@@ -25,6 +27,8 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
FlashCardViewModel,
|
||||
@@ -33,20 +37,40 @@ from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
## @brief Logger of the entry point.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
## @brief Optional packages the spoken words need.
|
||||
_SPEECH_PACKAGES = ("gtts", "playsound")
|
||||
|
||||
|
||||
def build_speech(enabled: bool) -> SpeechPort:
|
||||
"""Choose the speech adapter.
|
||||
|
||||
@param enabled False when the user switched speech off.
|
||||
@return GttsSpeech when enabled and gtts and playsound are installed,
|
||||
else NullSpeech.
|
||||
"""
|
||||
if enabled and all(
|
||||
importlib.util.find_spec(name) is not None for name in _SPEECH_PACKAGES
|
||||
):
|
||||
return GttsSpeech()
|
||||
return NullSpeech()
|
||||
|
||||
|
||||
def create_application(
|
||||
data_dir: Path | None = None,
|
||||
speech_enabled: bool = True,
|
||||
) -> tuple[tk.Tk, FlashCardView]:
|
||||
"""Create the root window and the flash card view.
|
||||
|
||||
@param data_dir Folder of the data files, or None for the default folder.
|
||||
@param speech_enabled False to run without spoken words.
|
||||
@return The root window and the view built on it.
|
||||
@throws VocabularyError When the word list or saved progress is unusable.
|
||||
"""
|
||||
folder = DEFAULT_DATA_DIR if data_dir is None else data_dir
|
||||
repository = CsvVocabularyRepository(folder / VOCABULARY_FILE_NAME)
|
||||
progress = CsvProgressRepository(folder / PROGRESS_FILE_NAME)
|
||||
session = open_study_session(repository, progress, random.choice)
|
||||
speech = build_speech(speech_enabled)
|
||||
session = open_study_session(repository, progress, speech, random.choice)
|
||||
view_model = FlashCardViewModel(session)
|
||||
root = tk.Tk()
|
||||
view = FlashCardView(root, view_model)
|
||||
@@ -66,9 +90,14 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
default=DEFAULT_DATA_DIR,
|
||||
help="folder of the data files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-speech",
|
||||
action="store_true",
|
||||
help="do not speak the words",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
root, _view = create_application(args.data_dir)
|
||||
root, _view = create_application(args.data_dir, not args.no_speech)
|
||||
except VocabularyError as error:
|
||||
_logger.error("%s", error)
|
||||
return 1
|
||||
|
||||
@@ -107,6 +107,9 @@ class FlashCardView:
|
||||
self.wrong_button = tk.Button(
|
||||
root,
|
||||
image=self._wrong_image,
|
||||
bg=BACKGROUND_COLOR,
|
||||
activebackground=BACKGROUND_COLOR,
|
||||
relief="flat",
|
||||
highlightthickness=0,
|
||||
bd=0,
|
||||
command=self.next_card,
|
||||
@@ -116,6 +119,9 @@ class FlashCardView:
|
||||
self.right_button = tk.Button(
|
||||
root,
|
||||
image=self._right_image,
|
||||
bg=BACKGROUND_COLOR,
|
||||
activebackground=BACKGROUND_COLOR,
|
||||
relief="flat",
|
||||
highlightthickness=0,
|
||||
bd=0,
|
||||
command=self.is_known,
|
||||
|
||||
@@ -35,6 +35,7 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation.features.flash_card import flash_card_view
|
||||
from flash_card.presentation.features.flash_card.flash_card_view import FlashCardView
|
||||
from flash_card.presentation.features.flash_card.flash_card_view_model import (
|
||||
@@ -58,9 +59,22 @@ class _MemoryProgress:
|
||||
return None
|
||||
|
||||
|
||||
class _RecordingSpeech:
|
||||
"""Speech port that records what would have been spoken."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.spoken: list[tuple[str, str]] = []
|
||||
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
self.spoken.append((text, language_code))
|
||||
|
||||
|
||||
SPEECH = _RecordingSpeech()
|
||||
|
||||
|
||||
def _build_view(root: tk.Tk) -> FlashCardView:
|
||||
deck = Deck(LanguagePair("ms", "da"), CARDS)
|
||||
session = StudySession(deck, _first, _MemoryProgress())
|
||||
session = StudySession(deck, _first, _MemoryProgress(), SPEECH)
|
||||
return FlashCardView(root, FlashCardViewModel(session))
|
||||
|
||||
|
||||
@@ -149,6 +163,14 @@ def test_buttons_sit_in_row_one_with_their_images(
|
||||
assert str(view.right_button.cget("command"))
|
||||
|
||||
|
||||
def test_buttons_sit_on_the_window_background(
|
||||
window: tuple[tk.Tk, FlashCardView],
|
||||
) -> None:
|
||||
_root, view = window
|
||||
assert view.wrong_button.cget("bg") == BACKGROUND_COLOR
|
||||
assert view.right_button.cget("bg") == BACKGROUND_COLOR
|
||||
|
||||
|
||||
def test_wrong_button_press_shows_next_card(
|
||||
window: tuple[tk.Tk, FlashCardView],
|
||||
) -> None:
|
||||
@@ -280,7 +302,10 @@ def files_view(
|
||||
words.write_text("ms;da\naku;mig\nkau;du\nini;denne\n", encoding="utf-8")
|
||||
progress = tmp_path / "words_to_learn.csv"
|
||||
session = open_study_session(
|
||||
CsvVocabularyRepository(words), CsvProgressRepository(progress), _first
|
||||
CsvVocabularyRepository(words),
|
||||
CsvProgressRepository(progress),
|
||||
NullSpeech(),
|
||||
_first,
|
||||
)
|
||||
view = FlashCardView(root, FlashCardViewModel(session))
|
||||
yield view, progress
|
||||
@@ -327,3 +352,13 @@ def test_buttons_do_nothing_visible_when_finished(
|
||||
assert _shown(view) == ("", FINISHED_MESSAGE)
|
||||
assert view.flip_timer is None
|
||||
assert _saved(progress) == HEADER_ONLY
|
||||
|
||||
|
||||
def test_button_press_speaks_the_malay_word_of_the_shown_card(
|
||||
window: tuple[tk.Tk, FlashCardView],
|
||||
) -> None:
|
||||
root, view = window
|
||||
SPEECH.spoken.clear()
|
||||
view.wrong_button.invoke()
|
||||
assert SPEECH.spoken == [(_word(view), "ms")]
|
||||
_reset(root, view)
|
||||
|
||||
@@ -12,6 +12,7 @@ from flash_card.infrastructure.persistence.csv_progress_repository import (
|
||||
from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
|
||||
WORDS = "fr;en\nchaque;each\nparlé;speak\narrivé;come\n"
|
||||
|
||||
@@ -33,6 +34,7 @@ def _open(folder: Path, choose: Choose = _first) -> StudySession:
|
||||
return open_study_session(
|
||||
CsvVocabularyRepository(folder / "words.csv"),
|
||||
CsvProgressRepository(folder / "words_to_learn.csv"),
|
||||
NullSpeech(),
|
||||
choose,
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from flash_card.infrastructure.persistence.csv_vocabulary_repository import (
|
||||
CsvVocabularyRepository,
|
||||
)
|
||||
from flash_card.infrastructure.persistence.csv_word_list import read_word_list
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
|
||||
PATH = DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME
|
||||
|
||||
@@ -32,7 +33,9 @@ def test_open_study_session_yields_card_from_real_file(tmp_path: Path) -> None:
|
||||
repository = CsvVocabularyRepository(PATH)
|
||||
cards = repository.load().cards
|
||||
progress = CsvProgressRepository(tmp_path / PROGRESS_FILE_NAME)
|
||||
session = open_study_session(repository, progress, lambda candidates: candidates[0])
|
||||
session = open_study_session(
|
||||
repository, progress, NullSpeech(), lambda candidates: candidates[0]
|
||||
)
|
||||
|
||||
card = session.next_card()
|
||||
|
||||
|
||||
@@ -33,6 +33,11 @@ class _RecordingProgress:
|
||||
self.saved.append(deck.cards)
|
||||
|
||||
|
||||
class _SilentSpeech:
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class _MissingRepository:
|
||||
def load(self) -> Vocabulary:
|
||||
raise VocabularyNotFoundError("missing")
|
||||
@@ -46,7 +51,9 @@ def test_open_study_session_builds_session_over_all_cards() -> None:
|
||||
cards = (Card("aku", "mig"), Card("kau", "du"))
|
||||
repository = _FakeRepository(Vocabulary(LanguagePair("ms", "da"), cards))
|
||||
|
||||
session = open_study_session(repository, _RecordingProgress(), _first)
|
||||
session = open_study_session(
|
||||
repository, _RecordingProgress(), _SilentSpeech(), _first
|
||||
)
|
||||
|
||||
assert session.remaining == 2
|
||||
assert session.next_card() == CardDto("aku", "mig", "ms", "da")
|
||||
@@ -54,7 +61,9 @@ def test_open_study_session_builds_session_over_all_cards() -> None:
|
||||
|
||||
def test_open_study_session_propagates_repository_error() -> None:
|
||||
with pytest.raises(VocabularyNotFoundError):
|
||||
open_study_session(_MissingRepository(), _RecordingProgress(), _first)
|
||||
open_study_session(
|
||||
_MissingRepository(), _RecordingProgress(), _SilentSpeech(), _first
|
||||
)
|
||||
|
||||
|
||||
def test_open_study_session_prefers_saved_progress() -> None:
|
||||
@@ -62,7 +71,9 @@ def test_open_study_session_prefers_saved_progress() -> None:
|
||||
vocabulary = Vocabulary(pair, (Card("aku", "mig"), Card("kau", "du")))
|
||||
progress = _RecordingProgress(Deck(pair, [Card("kau", "du")]))
|
||||
|
||||
session = open_study_session(_FakeRepository(vocabulary), progress, _first)
|
||||
session = open_study_session(
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
|
||||
)
|
||||
|
||||
assert session.remaining == 1
|
||||
assert session.next_card() == CardDto("kau", "du", "ms", "da")
|
||||
@@ -71,7 +82,9 @@ def test_open_study_session_prefers_saved_progress() -> None:
|
||||
def test_open_study_session_skips_vocabulary_when_progress_exists() -> None:
|
||||
progress = _RecordingProgress(Deck(LanguagePair("ms", "da"), []))
|
||||
|
||||
session = open_study_session(_MissingRepository(), progress, _first)
|
||||
session = open_study_session(
|
||||
_MissingRepository(), progress, _SilentSpeech(), _first
|
||||
)
|
||||
|
||||
assert session.remaining == 0
|
||||
|
||||
@@ -80,7 +93,9 @@ def test_open_study_session_never_saves_on_start() -> None:
|
||||
vocabulary = Vocabulary(LanguagePair("ms", "da"), (Card("aku", "mig"),))
|
||||
progress = _RecordingProgress()
|
||||
|
||||
session = open_study_session(_FakeRepository(vocabulary), progress, _first)
|
||||
session = open_study_session(
|
||||
_FakeRepository(vocabulary), progress, _SilentSpeech(), _first
|
||||
)
|
||||
session.next_card()
|
||||
|
||||
assert progress.saved == []
|
||||
|
||||
@@ -28,9 +28,22 @@ class _RecordingProgress:
|
||||
self.saved.append(deck.cards)
|
||||
|
||||
|
||||
def _session(*cards: Card, progress: _RecordingProgress | None = None) -> StudySession:
|
||||
class _RecordingSpeech:
|
||||
def __init__(self) -> None:
|
||||
self.spoken: list[tuple[str, str]] = []
|
||||
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
self.spoken.append((text, language_code))
|
||||
|
||||
|
||||
def _session(
|
||||
*cards: Card,
|
||||
progress: _RecordingProgress | None = None,
|
||||
speech: _RecordingSpeech | None = None,
|
||||
) -> StudySession:
|
||||
store = _RecordingProgress() if progress is None else progress
|
||||
return StudySession(Deck(PAIR, cards), _first, store)
|
||||
voice = _RecordingSpeech() if speech is None else speech
|
||||
return StudySession(Deck(PAIR, cards), _first, store, voice)
|
||||
|
||||
|
||||
def test_current_card_is_none_before_first_draw() -> None:
|
||||
@@ -105,3 +118,35 @@ def test_next_card_never_saves() -> None:
|
||||
session.next_card()
|
||||
session.next_card()
|
||||
assert progress.saved == []
|
||||
|
||||
|
||||
def test_next_card_speaks_front_text_once_in_front_language() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _session(A, B, speech=speech)
|
||||
session.next_card()
|
||||
assert speech.spoken == [("aku", "ms")]
|
||||
|
||||
|
||||
def test_next_card_does_not_speak_when_deck_is_empty() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _session(speech=speech)
|
||||
session.next_card()
|
||||
assert speech.spoken == []
|
||||
|
||||
|
||||
def test_next_card_speaks_again_on_every_draw() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _session(A, B, speech=speech)
|
||||
session.next_card()
|
||||
session.next_card()
|
||||
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
|
||||
|
||||
|
||||
def test_mark_known_does_not_speak_but_following_draw_does() -> None:
|
||||
speech = _RecordingSpeech()
|
||||
session = _session(A, B, speech=speech)
|
||||
session.next_card()
|
||||
session.mark_known()
|
||||
assert speech.spoken == [("aku", "ms")]
|
||||
session.next_card()
|
||||
assert speech.spoken == [("aku", "ms"), ("kau", "ms")]
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests of the GttsSpeech adapter with fake synthesizer and player."""
|
||||
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.infrastructure.speech import gtts_speech
|
||||
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
||||
|
||||
|
||||
class _Recorder:
|
||||
def __init__(self) -> None:
|
||||
self.synthesized: list[tuple[str, str]] = []
|
||||
self.played: list[Path] = []
|
||||
self.existed_when_played = False
|
||||
|
||||
def synthesize(self, text: str, language_code: str, path: Path) -> None:
|
||||
self.synthesized.append((text, language_code))
|
||||
path.write_bytes(b"mp3")
|
||||
|
||||
def play(self, path: Path) -> None:
|
||||
self.played.append(path)
|
||||
self.existed_when_played = path.exists()
|
||||
|
||||
|
||||
def test_speak_synthesizes_and_plays_then_removes_file() -> None:
|
||||
recorder = _Recorder()
|
||||
speech = GttsSpeech(recorder.synthesize, recorder.play)
|
||||
|
||||
speech.speak("aku", "ms")
|
||||
speech.wait()
|
||||
|
||||
assert recorder.synthesized == [("aku", "ms")]
|
||||
assert len(recorder.played) == 1
|
||||
assert recorder.existed_when_played
|
||||
assert not recorder.played[0].exists()
|
||||
|
||||
|
||||
def test_speak_returns_before_player_has_finished() -> None:
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
def blocked_play(path: Path) -> None:
|
||||
started.set()
|
||||
release.wait(timeout=5)
|
||||
|
||||
speech = GttsSpeech(lambda text, code, path: None, blocked_play)
|
||||
|
||||
speech.speak("aku", "ms")
|
||||
|
||||
assert started.wait(timeout=5)
|
||||
assert speech._thread is not None
|
||||
assert speech._thread.is_alive()
|
||||
release.set()
|
||||
speech.wait()
|
||||
|
||||
|
||||
def test_speak_logs_and_does_not_raise_when_synthesize_fails(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
played: list[Path] = []
|
||||
|
||||
def failing(text: str, language_code: str, path: Path) -> None:
|
||||
raise OSError("no network")
|
||||
|
||||
speech = GttsSpeech(failing, played.append)
|
||||
|
||||
speech.speak("aku", "ms")
|
||||
speech.wait()
|
||||
|
||||
assert played == []
|
||||
assert "Speech failed" in caplog.text
|
||||
assert "no network" in caplog.text
|
||||
|
||||
|
||||
def test_speak_logs_and_does_not_raise_when_play_fails(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
def failing(path: Path) -> None:
|
||||
raise RuntimeError("no audio device")
|
||||
|
||||
speech = GttsSpeech(lambda text, code, path: None, failing)
|
||||
|
||||
speech.speak("aku", "ms")
|
||||
speech.wait()
|
||||
|
||||
assert "no audio device" in caplog.text
|
||||
|
||||
|
||||
def test_speak_logs_missing_package_when_default_synthesizer_raises_import_error(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
def missing(name: str) -> object:
|
||||
raise ImportError(f"No module named {name!r}")
|
||||
|
||||
monkeypatch.setattr(gtts_speech.importlib, "import_module", missing)
|
||||
speech = GttsSpeech()
|
||||
|
||||
speech.speak("aku", "ms")
|
||||
speech.wait()
|
||||
|
||||
assert "No module named 'gtts'" in caplog.text
|
||||
|
||||
|
||||
def test_wait_returns_when_nothing_was_spoken() -> None:
|
||||
GttsSpeech().wait()
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Tests of the NullSpeech adapter."""
|
||||
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
|
||||
|
||||
def test_speak_does_nothing_and_returns_none() -> None:
|
||||
assert NullSpeech().speak("aku", "ms") is None
|
||||
@@ -25,7 +25,9 @@ def test_main_runs_main_loop_and_returns_zero(
|
||||
root = _FakeRoot()
|
||||
seen: list[Path | None] = []
|
||||
|
||||
def fake_create(data_dir: Path | None = None) -> tuple[_FakeRoot, object]:
|
||||
def fake_create(
|
||||
data_dir: Path | None = None, speech_enabled: bool = True
|
||||
) -> tuple[_FakeRoot, object]:
|
||||
seen.append(data_dir)
|
||||
return root, object()
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests of the speech wiring in the entry point."""
|
||||
|
||||
import importlib.util
|
||||
from importlib.machinery import ModuleSpec
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from flash_card.infrastructure.speech.gtts_speech import GttsSpeech
|
||||
from flash_card.infrastructure.speech.null_speech import NullSpeech
|
||||
from flash_card.presentation import app
|
||||
|
||||
|
||||
class _FakeRoot:
|
||||
def mainloop(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _found(name: str) -> ModuleSpec:
|
||||
return ModuleSpec(name, None)
|
||||
|
||||
|
||||
def test_build_speech_returns_null_speech_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(importlib.util, "find_spec", _found)
|
||||
assert isinstance(app.build_speech(False), NullSpeech)
|
||||
|
||||
|
||||
def test_build_speech_returns_null_speech_when_packages_are_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(importlib.util, "find_spec", lambda name: None)
|
||||
assert isinstance(app.build_speech(True), NullSpeech)
|
||||
|
||||
|
||||
def test_build_speech_returns_null_speech_when_one_package_is_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
importlib.util,
|
||||
"find_spec",
|
||||
lambda name: None if name == "playsound" else _found(name),
|
||||
)
|
||||
assert isinstance(app.build_speech(True), NullSpeech)
|
||||
|
||||
|
||||
def test_build_speech_returns_gtts_speech_when_both_packages_are_found(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(importlib.util, "find_spec", _found)
|
||||
assert isinstance(app.build_speech(True), GttsSpeech)
|
||||
|
||||
|
||||
def test_main_passes_speech_disabled_with_no_speech_option(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
seen: list[bool] = []
|
||||
|
||||
def fake_create(
|
||||
data_dir: Path | None = None, speech_enabled: bool = True
|
||||
) -> tuple[_FakeRoot, object]:
|
||||
seen.append(speech_enabled)
|
||||
return _FakeRoot(), object()
|
||||
|
||||
monkeypatch.setattr(app, "create_application", fake_create)
|
||||
|
||||
assert app.main(["--data-dir", str(tmp_path), "--no-speech"]) == 0
|
||||
assert app.main(["--data-dir", str(tmp_path)]) == 0
|
||||
assert seen == [False, True]
|
||||
@@ -31,11 +31,18 @@ class _RecordingProgress:
|
||||
self.saved.append(deck.cards)
|
||||
|
||||
|
||||
class _SilentSpeech:
|
||||
def speak(self, text: str, language_code: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _session(
|
||||
code: str, *cards: Card, progress: _RecordingProgress | None = None
|
||||
) -> StudySession:
|
||||
store = _RecordingProgress() if progress is None else progress
|
||||
return StudySession(Deck(LanguagePair(code, "da"), cards), _first, store)
|
||||
return StudySession(
|
||||
Deck(LanguagePair(code, "da"), cards), _first, store, _SilentSpeech()
|
||||
)
|
||||
|
||||
|
||||
def test_view_model_draws_first_card_on_construction() -> None:
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Capture screenshots of the running flash card window.
|
||||
|
||||
@file capture_screenshots.py
|
||||
@brief Starts the program, grabs the front of a card and, three seconds later,
|
||||
the back, and saves both as PNG files.
|
||||
|
||||
The tool needs Pillow (`python -m pip install -e ".[tools]"`), which is a tool
|
||||
dependency only and not a dependency of the program. It grabs the screen area
|
||||
of the window, so it needs a visible desktop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import random
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import ImageGrab
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
|
||||
from flash_card.constants import ( # noqa: E402
|
||||
DEFAULT_DATA_DIR,
|
||||
FLIP_DELAY_MS,
|
||||
PROJECT_ROOT,
|
||||
VOCABULARY_FILE_NAME,
|
||||
)
|
||||
from flash_card.presentation.app import create_application # noqa: E402
|
||||
|
||||
## @brief Default folder of the screenshots.
|
||||
DEFAULT_OUTPUT_DIR = PROJECT_ROOT / "docs" / "images"
|
||||
## @brief Seed that makes the first card the same on every run.
|
||||
SCREENSHOT_SEED = 7
|
||||
## @brief Extra time in seconds to let the window settle before a grab.
|
||||
SETTLE_SECONDS = 0.5
|
||||
|
||||
|
||||
def _make_dpi_aware() -> None:
|
||||
"""Make Windows report real pixel coordinates (no effect elsewhere)."""
|
||||
if sys.platform == "win32":
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(2) # type: ignore[attr-defined,unused-ignore]
|
||||
|
||||
|
||||
def _pump(root: object, seconds: float) -> None:
|
||||
"""Run the Tk event loop for a while.
|
||||
|
||||
@param root The Tk root window.
|
||||
@param seconds How long to run.
|
||||
"""
|
||||
end = time.monotonic() + seconds
|
||||
while time.monotonic() < end:
|
||||
root.update() # type: ignore[attr-defined]
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
def capture(output_dir: Path) -> list[Path]:
|
||||
"""Capture the front and the back of one card.
|
||||
|
||||
@param output_dir Folder to write `screenshot-front.png` and
|
||||
`screenshot-back.png` into.
|
||||
@return The two files written, front first.
|
||||
"""
|
||||
_make_dpi_aware()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
random.seed(SCREENSHOT_SEED)
|
||||
with tempfile.TemporaryDirectory() as data_dir:
|
||||
shutil.copy(DEFAULT_DATA_DIR / VOCABULARY_FILE_NAME, data_dir)
|
||||
root, view = create_application(Path(data_dir), speech_enabled=False)
|
||||
root.lift()
|
||||
root.attributes("-topmost", True)
|
||||
_pump(root, SETTLE_SECONDS)
|
||||
box = (
|
||||
view.canvas.winfo_rootx() - 20,
|
||||
view.canvas.winfo_rooty() - 20,
|
||||
view.canvas.winfo_rootx() + view.canvas.winfo_width() + 20,
|
||||
view.canvas.winfo_rooty()
|
||||
+ view.canvas.winfo_height()
|
||||
+ view.right_button.winfo_height()
|
||||
+ 20,
|
||||
)
|
||||
front = output_dir / "screenshot-front.png"
|
||||
back = output_dir / "screenshot-back.png"
|
||||
ImageGrab.grab(bbox=box).save(front)
|
||||
_pump(root, FLIP_DELAY_MS / 1000 + SETTLE_SECONDS)
|
||||
ImageGrab.grab(bbox=box).save(back)
|
||||
root.destroy()
|
||||
return [front, back]
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
"""Run the tool.
|
||||
|
||||
@param argv Command line arguments, or None for `sys.argv`.
|
||||
@return The exit code, 0 on success.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
|
||||
args = parser.parse_args(argv)
|
||||
for path in capture(args.output_dir):
|
||||
sys.stdout.write(f"{path}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user