Author SHA1 Message Date
Tirsvad b18d0eb8af Merge pull request 'MIL-003: README, screenshots, tri-lingual wiki' (#21) from mil-003-documentation-and-publication into main
Publish wiki / publish-wiki (push) Successful in 20s
Publish wiki / wiki (push) Successful in 21s
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-17 09:41:42 +02:00
Tirsvad 457b84c6da Add README, screenshots, tri-lingual wiki and wiki workflow (MIL-003)
README from the framework template, three terminal screenshots rendered by
script, wiki pages in English, Danish and Bahasa Malaysia, the publish-wiki
workflow, and the milestone links in the Project Plan.

Refs #13
Refs #14
Refs #15
Refs #16
Refs #17
Refs #18
2026-10-17 15:41:15 +08:00
Tirsvad 5937dc2725 Merge pull request 'MIL-002: birthday wisher core' (#20) from mil-002-birthday-wisher-core into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 7s
2026-10-17 09:37:35 +02:00
Tirsvad 0126093601 Implement the birthday wisher core (MIL-002)
Layered package (domain, application, infrastructure, presentation), CLI with
--dry-run and --today, SMTP with STARTTLS behind a port, 56 offline tests and
the Python code review RC-006.

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
Refs #7
Refs #8
Refs #9
Refs #10
Refs #11
Refs #12
2026-10-17 15:37:16 +08:00
Tirsvad 70b2b3c02f Merge pull request 'MIL-001: project setup' (#19) from mil-001-project-setup into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-17 09:31:35 +02:00
Tirsvad 9b3a134721 Merge pull request 'Plan baseline: Business Case, Stakeholder Analysis, Project Plan, milestones' (#1) from plan-baseline into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Successful in 6s
2026-10-17 09:31:09 +02:00
52 changed files with 1752 additions and 28 deletions
+38
View File
@@ -0,0 +1,38 @@
# Publish the project's wiki pages to the git host's wiki.
#
# On a push to main that changes docs/wiki/, this workflow calls the reusable
# workflow of TirSystem/github-action, which copies the Markdown pages of
# docs/wiki/ to the wiki of this repository (framework/process/wiki.md says
# what the folder holds: English and the PO language as a minimum).
#
# INSTALLING THIS FILE IS THE OPT-IN. There is no marker file. A project that
# does not copy it publishes nothing.
#
# Copy it to .gitea/workflows/publish-wiki.yml. Then:
# 1. Create the secret TOKEN_FOR_GITEA_WIKI in the organization of the
# repository: a Gitea token with scope write:repository for a user that
# can write this repository (see guides/credentials.md of github-action).
# 2. Enable the wiki in the repository settings.
# 3. Keep @v1, or pin the exact release tag together with tools-ref.
# A runner must be registered for the repository (or its organization).
#
# prune: "true" deletes wiki pages that are not in docs/wiki/, so the wiki
# always equals the folder. Leave it "false" to keep hand-written pages.
name: Publish wiki
on:
push:
branches: [ main ]
paths:
- "docs/wiki/**"
workflow_dispatch:
jobs:
wiki:
uses: TirSystem/github-action/.gitea/workflows/publish-wiki-pages.yml@v1
with:
wiki_dir: docs/wiki
prune: "true"
secrets:
TOKEN_FOR_GITEA_WIKI: ${{ secrets.TOKEN_FOR_GITEA_WIKI }}
+3
View File
@@ -27,3 +27,6 @@ docs/doxygen/
.vscode/
.DS_Store
Thumbs.db
# AI prompt log (the repository is public)
/docs/ai-prompts/
+123 -1
View File
@@ -1,2 +1,124 @@
# 032-birthday-wisher
# 🚀 Birthday Wisher
Sends a random birthday letter by e-mail to everyone in `birthdays.csv` whose birthday is today. It is the "normal" level of day 32 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*, written for course participants and for anyone who browses the repository for ideas.
## 📚 Table of Contents
- [Overview](#-overview)
- [Requirements](#-requirements)
- [Setup](#-setup)
- [Run](#-run)
- [Tests](#-tests)
- [License](#-license)
- [Links](#-links)
- [Appendix: Project documents](#-appendix-project-documents)
## 🧭 Overview
The program reads `birthdays.csv`, compares today's month and day with each row, picks one of `letter_templates/letter_1.txt` to `letter_3.txt` at random, replaces `[NAME]` with the person's name and sends the letter through SMTP with STARTTLS (Gmail by default). It uses only the Python standard library, so it has no runtime dependencies.
![A dry run: the letters that would be sent](docs/images/dry-run.png)
| Path | What it is |
| --- | --- |
| `src/birthday_wisher/` | The program: `domain`, `application`, `infrastructure` and `presentation` layers, constants in `constants.py` |
| `tests/` | pytest tests: `unit`, `integration`, `e2e`; all run offline |
| `birthdays.csv` | The people: `name,email,year,month,day` (dummy data) |
| `letter_templates/` | Three letters with the `[NAME]` placeholder |
| `Doxyfile` | Doxygen configuration; the source carries Doxygen comments |
| `docs/` | Project documents, images and the wiki in English, Danish and Bahasa Malaysia |
## 📋 Requirements
- Python 3.13 or newer, to run the program and the tests.
- A Gmail address and an app password, only to send real e-mail. A dry run and the tests need neither.
- Doxygen, only to build the API documentation (optional).
## 🛠️ Setup
Create and use a local virtual environment, then upgrade pip and install the project with its test tools:
```bash
python -m venv .venv
```
Activate it. On Windows (PowerShell): `.venv\Scripts\Activate.ps1`. On Windows (cmd): `.venv\Scripts\activate`. On Linux and macOS: `source .venv/bin/activate`.
```bash
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
![Creating the .venv](docs/images/venv-setup.png)
To send real e-mail, set two environment variables (see `.env.example`; never commit a real `.env`):
```bash
export GMAIL_ADDRESS="you@gmail.com"
export GMAIL_TOKEN="your-app-password"
```
On Windows PowerShell use `$env:GMAIL_ADDRESS = "you@gmail.com"` and `$env:GMAIL_TOKEN = "your-app-password"`.
## ▶️ Run
Print the letters instead of sending them (pretend today is 17 October 2000):
```bash
python -m birthday_wisher --dry-run --today 2000-10-17
```
Send the letters for today's birthdays:
```bash
python -m birthday_wisher
```
Options: `--dry-run`, `--today YYYY-MM-DD`, `--csv FILE`, `--letters FOLDER`. The program exits with status 1 when setup is wrong or a letter could not be sent. Generate the API documentation with `doxygen Doxyfile` (output in `docs/doxygen/`).
## 🧪 Tests
```bash
python -m pytest
```
The tests use a fake SMTP connection and temporary files. They need no network, no `.env` and no credentials.
![The test run](docs/images/tests.png)
## 📄 License
GNU Affero General Public License v3.0, see [LICENSE](LICENSE).
## 🔗 Links
- [Repository][repository]
- [Documentation][documentation]
- [Issue tracker][issues]
## 📎 Appendix: Project documents
| Document | What it tells you |
| --- | --- |
| [AGENTS.md][agents] | The rules AI agents follow in this repository |
| [Business Case][BC-001] | Why the project exists, its scope and success criteria |
| [Stakeholder Analysis][SA-001] | Who is involved; the stakeholder IDs used for owners and reviewers |
| [Project Plan][PP-001] | The gateways and their schedule |
| [Milestones][milestones] | One document per gateway: Go/No-Go criteria and tasks |
| [Artifact registry][registry] | Where each artifact type lives, the next version, the PO language and domain |
| [Framework README][framework-readme] | The framework this project uses |
| [Framework changelog][framework-changelog] | What changed in each framework release |
---
[repository]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher
[documentation]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/wiki
[issues]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/issues
[agents]: ./AGENTS.md
[BC-001]: ./docs/business-case.md
[SA-001]: ./docs/stakeholder-analysis.md
[PP-001]: ./docs/project-plan.md
[milestones]: ./docs/milestones/
[registry]: ./docs/artifact-registry.md
[framework-readme]: ./framework/README.md
[framework-changelog]: ./framework/CHANGELOG.md
+1 -1
View File
@@ -15,7 +15,7 @@ document of a type. `Primary File` may contain a glob (e.g.
| SA | Stakeholder Analysis | docs/stakeholder-analysis.md | 002 |
| PP | Project Plan | docs/project-plan.md | 002 |
| MIL | Milestone / Gateway | docs/milestones/*.md | 004 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 006 |
| RC | SQA Review Record | docs/sqa/reviews/rc-*.md | 007 |
## Languages
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+6 -3
View File
@@ -29,9 +29,9 @@ This plan schedules the three phases that deliver the birthday wisher of [BC-001
| Gateway | Document | Window | Decision date | Owner | Stories | Main deliverable | Milestone |
| --- | --- | --- | --- | --- | --- | --- | --- |
| G1 Project setup | [MIL-001] | 2026-10-17 to 2026-10-18 | 2026-10-18 | S01 | none (plain tasks) | Skeleton, tooling, repository metadata | |
| G2 Core | [MIL-002] | 2026-10-18 to 2026-10-19 | 2026-10-19 | S01 | none (plain tasks) | Working, tested birthday wisher | |
| G3 Documentation | [MIL-003] | 2026-10-19 to 2026-10-20 | 2026-10-20 | S01 | none (plain tasks) | README, tri-lingual wiki, screenshots | |
| G1 Project setup | [MIL-001] | 2026-10-17 to 2026-10-18 | 2026-10-18 | S01 | none (plain tasks) | Skeleton, tooling, repository metadata | [M-188] |
| G2 Core | [MIL-002] | 2026-10-18 to 2026-10-19 | 2026-10-19 | S01 | none (plain tasks) | Working, tested birthday wisher | [M-189] |
| G3 Documentation | [MIL-003] | 2026-10-19 to 2026-10-20 | 2026-10-20 | S01 | none (plain tasks) | README, tri-lingual wiki, screenshots | [M-190] |
```plantuml
@startgantt
@@ -85,3 +85,6 @@ A No-Go at a gate moves every later date by the time needed to fix and re-review
[MIL-002]: ./milestones/mil-002-birthday-wisher-core.md
[MIL-003]: ./milestones/mil-003-documentation-and-publication.md
[052008f]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/commit/052008f21364120a7c4d5ebbff26b8c16e05a21c
[M-188]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/milestones/188
[M-189]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/milestones/189
[M-190]: https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/milestones/190
@@ -0,0 +1,60 @@
# SQA Review Record
## Metadata
| Key | Value |
| --- | --- |
| ID | RC-006 |
| CrossReference | [MIL-002], [QC-PY-001] |
## Version History
| Date | Status | Author | Reviewer | Change | Commit |
| --- | --- | --- | --- | --- | --- |
| 2026-10-17 | Proposed | Jens Tirsvad Nielsen | S01 | Initial version | pending |
---
## Artifact Under Review
- Instance reviewed: [MIL-002]
- Checklist used: [QC-PY-001]
- Scope: full review of `src/` and `tests/` (MIL-002 task 6)
- Language and domain: n/a (technical type)
- Language reviewer: none (the reviewer is the stand-in for S01, who reads English and knows the IT domain)
- Mode: autonomous, not independent (reviewer: 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 | Modules, functions and variables are snake_case, classes PascalCase, constants UPPER_SNAKE; ruff (E, F, I, B, UP) agrees. |
| 2 | Names state purpose in the domain's language; no unexplained abbreviations, no single-letter names outside tiny scopes | Pass | Names state purpose (`find_birthday_people`, `send_birthday_wishes`, `read_credentials`); no cryptic abbreviations. |
| 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; config is in `pyproject.toml`; no suppressions. |
| 4 | Every function and method signature is type-annotated, including `-> None` | Pass | `mypy --strict src tests` reports no issues; every signature is annotated. |
| 5 | No bare `except:`, no swallowed exceptions; specific exceptions are raised and the cause is kept (`raise ... from`) | Pass | No bare `except`; `MailError`, `BirthdayDataError`, `LetterTemplateError` are raised `from` their cause. |
| 6 | No mutable default arguments and no shadowed builtins | Pass | No mutable default arguments (dataclass fields use `default_factory`); no builtin shadowed. |
| 7 | Files, locks and connections are managed with context managers | Pass | CSV file and SMTP connection are used in `with` blocks. |
| 8 | Public modules, classes and functions have docstrings that say what, not how | Pass | Every public module, class and function has a Doxygen docstring (`@brief`, `@param`, `@return`). |
| 9 | Logging uses `logging`, not `print`; no secrets or personal data in log output | Pass | `print` is used only in `presentation/cli.py` for the program output the user asked for (dry-run mail, status lines, errors to stderr); it is not diagnostic logging. The token is never printed (tested in `test_cli.py`). |
| 10 | Classes and operations trace to the Design Class Diagram they implement; deviations are recorded | N-A | No Design Class Diagram exists: the plan has only plain technical tasks and no use case (recorded in the Project Plan, Open Issues). The structure follows `clean-architecture`. Stand-in decision, open to challenge by S01. |
| 11 | Tests exist for new behaviour, are named for the behaviour, and do not depend on order or the network | Pass | 56 tests pass offline in 0.2 s; they use fakes and `tmp_path`, are named for behaviour and do not read `.env`. |
| 12 | Type checker runs in strict mode without errors; `Any` is justified in a comment | Pass | Strict mode passes. `Any` appears twice for the SMTP factory type, each with a comment. |
| 13 | Dependencies are declared and pinned in the project's dependency file, none unused | Pass | `dependencies = []`; dev extras are pytest, ruff and mypy with lower bounds. Exact pins are not set because the project has no runtime dependencies. |
| 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 placeholder, SMTP settings, subject and file names are defined once in `constants.py`. |
| 15 | Imports point inward: business-rule modules import no framework, database, UI or delivery-mechanism code, and there are no import cycles | Pass | `domain` imports nothing from other layers; `application` imports `domain`; `infrastructure` and `presentation` import inward. No cycles. |
| 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 | One reason to change per module; ports are narrow protocols; the use case receives its collaborators. |
| 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 | `domain`, `application`, `infrastructure`, `presentation` under `src/birthday_wisher/`; `unit`, `integration`, `e2e` under `tests/`. |
| 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 | All names are English, lower-case snake_case, with no spaces. |
## Overall Verdict
Go - All applicable criteria pass; criterion 10 is N-A because no Design Class Diagram exists (stand-in decision).
## Action Items
| Action | Owner | Due |
| --- | --- | --- |
| Decide whether a Design Class Diagram is wanted for this small project | S01 | 2026-10-20 |
---
[MIL-002]: ../../milestones/mil-002-birthday-wisher-core.md
[QC-PY-001]: ../../../framework/qc/code/qc-programming-python.md
+14
View File
@@ -0,0 +1,14 @@
# Arkitektur
🌐 [English](Architecture) · **Dansk** · [Bahasa Melayu](Architecture-ms)
Koden følger en lille clean architecture. Imports peger indad.
| Lag | Mappe | Indeholder |
| --- | --- | --- |
| Domæne | `src/birthday_wisher/domain/` | `Person` og fejlene |
| Applikation | `src/birthday_wisher/application/` | Use casen `send_birthday_wishes` og portene (`BirthdayRepository`, `LetterSource`, `MailSender`) |
| Infrastruktur | `src/birthday_wisher/infrastructure/` | CSV-læser, brevfiler, SMTP-afsender, indstillinger |
| Præsentation | `src/birthday_wisher/presentation/` | Kommandolinjen og composition root |
Testene afspejler lagene: `tests/unit`, `tests/integration`, `tests/e2e`. Kursets hint om en ordbog med `(month, day)` som nøgle er `birthdays_by_day`.
+14
View File
@@ -0,0 +1,14 @@
# Seni Bina
🌐 [English](Architecture) · [Dansk](Architecture-da) · **Bahasa Melayu**
Kod mengikut seni bina bersih yang kecil. Import menuju ke dalam.
| Lapisan | Folder | Mengandungi |
| --- | --- | --- |
| Domain | `src/birthday_wisher/domain/` | `Person` dan ralat |
| Aplikasi | `src/birthday_wisher/application/` | Kes guna `send_birthday_wishes` dan port (`BirthdayRepository`, `LetterSource`, `MailSender`) |
| Infrastruktur | `src/birthday_wisher/infrastructure/` | Pembaca CSV, fail surat, penghantar SMTP, tetapan |
| Pembentangan | `src/birthday_wisher/presentation/` | Baris arahan dan composition root |
Ujian mencerminkan lapisan: `tests/unit`, `tests/integration`, `tests/e2e`. Petunjuk kursus tentang kamus berkunci `(month, day)` ialah `birthdays_by_day`.
+14
View File
@@ -0,0 +1,14 @@
# Architecture
🌐 **English** · [Dansk](Architecture-da) · [Bahasa Melayu](Architecture-ms)
The code follows a small clean architecture. Imports point inward.
| Layer | Folder | Holds |
| --- | --- | --- |
| Domain | `src/birthday_wisher/domain/` | `Person` and the errors |
| Application | `src/birthday_wisher/application/` | The use case `send_birthday_wishes` and the ports (`BirthdayRepository`, `LetterSource`, `MailSender`) |
| Infrastructure | `src/birthday_wisher/infrastructure/` | CSV reader, letter files, SMTP mailer, settings |
| Presentation | `src/birthday_wisher/presentation/` | The command line and the composition root |
The tests mirror the layers: `tests/unit`, `tests/integration`, `tests/e2e`. The course hint of a dictionary keyed by `(month, day)` is `birthdays_by_day`.
+18
View File
@@ -0,0 +1,18 @@
# Konfiguration
🌐 [English](Configuration) · **Dansk** · [Bahasa Melayu](Configuration-ms)
**Personer.** `birthdays.csv` har overskriften `name,email,year,month,day`. Filen i repositoryet indeholder opdigtede data. Erstat den med dine egne, og hold rigtige adresser ude af git.
**Breve.** `letter_templates/letter_1.txt` til `letter_3.txt`. Teksten `[NAME]` erstattes af personens navn. Ét brev vælges tilfældigt.
**Afsender.** To miljøvariabler:
| Variabel | Betydning |
| --- | --- |
| `GMAIL_ADDRESS` | Adressen, e-mailen sendes fra |
| `GMAIL_TOKEN` | Gmail-app-adgangskode |
`.env.example` viser navnene. Læg aldrig en rigtig `.env` i git; den ignoreres af git. Programmet læser kun miljøet og udskriver aldrig tokenet.
**Konstanter.** Server, port, emne og filnavne står i `src/birthday_wisher/constants.py`.
+18
View File
@@ -0,0 +1,18 @@
# Konfigurasi
🌐 [English](Configuration) · [Dansk](Configuration-da) · **Bahasa Melayu**
**Orang.** `birthdays.csv` mempunyai pengepala `name,email,year,month,day`. Fail dalam repositori mengandungi data rekaan. Gantikan dengan data anda sendiri, dan jauhkan alamat sebenar daripada git.
**Surat.** `letter_templates/letter_1.txt` hingga `letter_3.txt`. Teks `[NAME]` diganti dengan nama orang itu. Satu surat dipilih secara rawak.
**Penghantar.** Dua pemboleh ubah persekitaran:
| Pemboleh ubah | Maksud |
| --- | --- |
| `GMAIL_ADDRESS` | Alamat e-mel dihantar daripadanya |
| `GMAIL_TOKEN` | Kata laluan aplikasi Gmail |
`.env.example` menunjukkan namanya. Jangan sekali-kali komit `.env` sebenar; git mengabaikannya. Program hanya membaca persekitaran dan tidak pernah mencetak token.
**Pemalar.** Pelayan, port, subjek dan nama fail ada dalam `src/birthday_wisher/constants.py`.
+18
View File
@@ -0,0 +1,18 @@
# Configuration
🌐 **English** · [Dansk](Configuration-da) · [Bahasa Melayu](Configuration-ms)
**People.** `birthdays.csv` has the header `name,email,year,month,day`. The file in the repository holds dummy data. Replace it with your own, and keep real addresses out of git.
**Letters.** `letter_templates/letter_1.txt` to `letter_3.txt`. The text `[NAME]` is replaced by the person's name. One letter is chosen at random.
**Sender.** Two environment variables:
| Variable | Meaning |
| --- | --- |
| `GMAIL_ADDRESS` | Address the e-mail is sent from |
| `GMAIL_TOKEN` | Gmail app password |
`.env.example` shows the names. Never commit a real `.env`; it is ignored by git. The program reads the environment only and never prints the token.
**Constants.** Server, port, subject and file names are in `src/birthday_wisher/constants.py`.
+17
View File
@@ -0,0 +1,17 @@
# Fødselsdagshilsner
🌐 [English](Home) · **Dansk** · [Bahasa Melayu](Home-ms)
Sender et tilfældigt fødselsdagsbrev på e-mail til alle i `birthdays.csv`, der har fødselsdag i dag. Det er "normal"-niveauet på dag 32 af Udemys *100 Days of Code: The Complete Python Pro Bootcamp*.
![En prøvekørsel](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/dry-run.png)
| Side | Læs den, når du vil |
| --- | --- |
| [Setup](Setup-da) | Oprette et lokalt `.venv` og installere projektet |
| [Usage](Usage-da) | Køre programmet og læse dets output |
| [Configuration](Configuration-da) | Ændre personerne, brevene og afsenderen |
| [Testing](Testing-da) | Køre testene |
| [Architecture](Architecture-da) | Se, hvordan koden er opbygget |
Programmet bruger kun Pythons standardbibliotek: der er ingen runtime-afhængigheder. Kildekode: [repository](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher).
+17
View File
@@ -0,0 +1,17 @@
# Pengucap Hari Lahir
🌐 [English](Home) · [Dansk](Home-da) · **Bahasa Melayu**
Menghantar surat hari jadi rawak melalui e-mel kepada semua orang dalam `birthdays.csv` yang berhari jadi hari ini. Ini ialah tahap "normal" hari ke-32 kursus Udemy *100 Days of Code: The Complete Python Pro Bootcamp*.
![Larian percubaan](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/dry-run.png)
| Halaman | Baca apabila anda ingin |
| --- | --- |
| [Setup](Setup-ms) | Mencipta `.venv` setempat dan memasang projek |
| [Usage](Usage-ms) | Menjalankan program dan membaca outputnya |
| [Configuration](Configuration-ms) | Menukar senarai orang, surat dan penghantar |
| [Testing](Testing-ms) | Menjalankan ujian |
| [Architecture](Architecture-ms) | Melihat bagaimana kod disusun |
Program ini hanya menggunakan pustaka standard Python: tiada kebergantungan masa larian. Kod sumber: [repositori](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher).
+17
View File
@@ -0,0 +1,17 @@
# Birthday Wisher
🌐 **English** · [Dansk](Home-da) · [Bahasa Melayu](Home-ms)
Sends a random birthday letter by e-mail to everyone in `birthdays.csv` whose birthday is today. It is the "normal" level of day 32 of Udemy's *100 Days of Code: The Complete Python Pro Bootcamp*.
![A dry run](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/dry-run.png)
| Page | Read it when you want to |
| --- | --- |
| [Setup](Setup) | Create a local `.venv` and install the project |
| [Usage](Usage) | Run the program and read its output |
| [Configuration](Configuration) | Change the people, the letters and the sender |
| [Testing](Testing) | Run the tests |
| [Architecture](Architecture) | See how the code is organised |
The program uses only the Python standard library: there are no runtime dependencies. Source: [repository](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher).
+20
View File
@@ -0,0 +1,20 @@
# Opsætning
🌐 [English](Setup) · **Dansk** · [Bahasa Melayu](Setup-ms)
Du skal bruge Python 3.13 eller nyere.
```bash
python -m venv .venv
```
Aktivér miljøet. Windows PowerShell: `.venv\Scripts\Activate.ps1`. Windows cmd: `.venv\Scripts\activate`. Linux og macOS: `source .venv/bin/activate`.
```bash
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
![Oprettelse af .venv](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/venv-setup.png)
Tilvalget `dev` installerer pytest, ruff og mypy. Selve programmet kræver intet.
+20
View File
@@ -0,0 +1,20 @@
# Persediaan
🌐 [English](Setup) · [Dansk](Setup-da) · **Bahasa Melayu**
Anda memerlukan Python 3.13 atau lebih baharu.
```bash
python -m venv .venv
```
Aktifkan persekitaran. Windows PowerShell: `.venv\Scripts\Activate.ps1`. Windows cmd: `.venv\Scripts\activate`. Linux dan macOS: `source .venv/bin/activate`.
```bash
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
![Mencipta .venv](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/venv-setup.png)
Tambahan `dev` memasang pytest, ruff dan mypy. Program itu sendiri tidak memerlukan apa-apa.
+20
View File
@@ -0,0 +1,20 @@
# Setup
🌐 **English** · [Dansk](Setup-da) · [Bahasa Melayu](Setup-ms)
You need Python 3.13 or newer.
```bash
python -m venv .venv
```
Activate the environment. Windows PowerShell: `.venv\Scripts\Activate.ps1`. Windows cmd: `.venv\Scripts\activate`. Linux and macOS: `source .venv/bin/activate`.
```bash
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"
```
![Creating the .venv](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/venv-setup.png)
The `dev` extra installs pytest, ruff and mypy. Nothing is needed to run the program itself.
+18
View File
@@ -0,0 +1,18 @@
# Test
🌐 [English](Testing) · **Dansk** · [Bahasa Melayu](Testing-ms)
```bash
python -m pytest
```
![Testkørslen](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/tests.png)
Testene bruger en falsk SMTP-forbindelse og midlertidige filer. De kræver intet netværk, ingen `.env` og ingen adgangskoder. Valgfri kontrol:
```bash
python -m ruff check src tests
python -m mypy src tests
```
API-dokumentation fra Doxygen-kommentarerne: `doxygen Doxyfile` (output i `docs/doxygen/`).
+18
View File
@@ -0,0 +1,18 @@
# Ujian
🌐 [English](Testing) · [Dansk](Testing-da) · **Bahasa Melayu**
```bash
python -m pytest
```
![Larian ujian](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/tests.png)
Ujian menggunakan sambungan SMTP palsu dan fail sementara. Ia tidak memerlukan rangkaian, `.env` atau kelayakan. Semakan pilihan:
```bash
python -m ruff check src tests
python -m mypy src tests
```
Dokumentasi API daripada komen Doxygen: `doxygen Doxyfile` (output dalam `docs/doxygen/`).
+18
View File
@@ -0,0 +1,18 @@
# Testing
🌐 **English** · [Dansk](Testing-da) · [Bahasa Melayu](Testing-ms)
```bash
python -m pytest
```
![The test run](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/tests.png)
The tests use a fake SMTP connection and temporary files. They need no network, no `.env` and no credentials. Optional checks:
```bash
python -m ruff check src tests
python -m mypy src tests
```
API documentation from the Doxygen comments: `doxygen Doxyfile` (output in `docs/doxygen/`).
+26
View File
@@ -0,0 +1,26 @@
# Brug
🌐 [English](Usage) · **Dansk** · [Bahasa Melayu](Usage-ms)
Udskriv brevene i stedet for at sende dem. `--today` lader som om, det er en anden dag:
```bash
python -m birthday_wisher --dry-run --today 2000-10-17
```
![En prøvekørsel](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/dry-run.png)
Send brevene til dagens fødselsdage (kræver [afsenderindstillingerne](Configuration-da)):
```bash
python -m birthday_wisher
```
| Valgmulighed | Betydning |
| --- | --- |
| `--dry-run` | Udskriv e-mailene i stedet for at sende dem |
| `--today YYYY-MM-DD` | Lad som om, det er denne dato |
| `--csv FILE` | Brug en anden fødselsdagsfil |
| `--letters FOLDER` | Brug en anden mappe med brevskabeloner |
Slutstatus 0 betyder succes. Status 1 betyder, at opsætningen er forkert (manglende fil eller indstilling), eller at et brev ikke kunne sendes. En fødselsdag den 29. februar fejres den 28. februar i år, der ikke er skudår.
+26
View File
@@ -0,0 +1,26 @@
# Penggunaan
🌐 [English](Usage) · [Dansk](Usage-da) · **Bahasa Melayu**
Cetak surat tanpa menghantarnya. `--today` berpura-pura hari ini ialah tarikh lain:
```bash
python -m birthday_wisher --dry-run --today 2000-10-17
```
![Larian percubaan](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/dry-run.png)
Hantar surat untuk hari jadi hari ini (memerlukan [tetapan penghantar](Configuration-ms)):
```bash
python -m birthday_wisher
```
| Pilihan | Maksud |
| --- | --- |
| `--dry-run` | Cetak e-mel tanpa menghantarnya |
| `--today YYYY-MM-DD` | Berpura-pura hari ini ialah tarikh ini |
| `--csv FILE` | Guna fail hari jadi lain |
| `--letters FOLDER` | Guna folder templat surat lain |
Status keluar 0 bermaksud berjaya. Status 1 bermaksud persediaan salah (fail atau tetapan tiada) atau satu surat gagal dihantar. Hari jadi pada 29 Februari disambut pada 28 Februari dalam tahun yang bukan tahun lompat.
+26
View File
@@ -0,0 +1,26 @@
# Usage
🌐 **English** · [Dansk](Usage-da) · [Bahasa Melayu](Usage-ms)
Print the letters instead of sending them. `--today` pretends it is another day:
```bash
python -m birthday_wisher --dry-run --today 2000-10-17
```
![A dry run](https://git.tirsystem.com/Tirsvad-Udemy-100-days-of-code/032-birthday-wisher/raw/branch/main/docs/images/dry-run.png)
Send the letters for today's birthdays (needs the [sender settings](Configuration)):
```bash
python -m birthday_wisher
```
| Option | Meaning |
| --- | --- |
| `--dry-run` | Print the e-mails instead of sending them |
| `--today YYYY-MM-DD` | Pretend today is this date |
| `--csv FILE` | Use another birthdays file |
| `--letters FOLDER` | Use another folder of letter templates |
Exit status 0 means success. Status 1 means the setup is wrong (missing file or setting) or a letter could not be sent. A birthday on 29 February is celebrated on 28 February in years that are not leap years.
+14
View File
@@ -0,0 +1,14 @@
**Birthday Wisher**
- [Home](Home)
- [Setup](Setup)
- [Usage](Usage)
- [Configuration](Configuration)
- [Testing](Testing)
- [Architecture](Architecture)
**Languages**
- English: [Home](Home)
- Dansk: [Home](Home-da)
- Bahasa Melayu: [Home](Home-ms)
+15 -1
View File
@@ -11,8 +11,11 @@ requires-python = ">=3.13"
license = { file = "LICENSE" }
dependencies = []
[project.scripts]
birthday-wisher = "birthday_wisher.presentation.cli:main"
[project.optional-dependencies]
dev = ["pytest>=8"]
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11"]
[tool.setuptools.packages.find]
where = ["src"]
@@ -20,3 +23,14 @@ where = ["src"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
[tool.ruff]
line-length = 88
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP"]
[tool.mypy]
strict = true
mypy_path = "src"
+1 -1
View File
@@ -1,4 +1,4 @@
"""!
@file __init__.py
@brief Birthday wisher: sends a random birthday letter to everyone whose birthday is today.
@brief Birthday wisher: sends a random birthday letter on a person's birthday.
"""
+10
View File
@@ -0,0 +1,10 @@
"""!
@file __main__.py
@brief Allows `python -m birthday_wisher`.
"""
import sys
from birthday_wisher.presentation.cli import main
sys.exit(main())
@@ -0,0 +1,4 @@
"""!
@file __init__.py
@brief Application layer: the use case and the ports it needs.
"""
+51
View File
@@ -0,0 +1,51 @@
"""!
@file ports.py
@brief Ports: what the use case needs from the outside world.
"""
from typing import Protocol
from birthday_wisher.domain.person import Person
class BirthdayRepository(Protocol):
"""!
@brief Source of the people and their birth dates.
"""
def load_all(self) -> list[Person]:
"""!
@brief Read every person.
@return All people, in file order.
"""
...
class LetterSource(Protocol):
"""!
@brief Source of the letter templates.
"""
def read_letter(self, number: int) -> str:
"""!
@brief Read one template.
@param number Template number, 1 to LETTER_COUNT.
@return The template text with its placeholder.
"""
...
class MailSender(Protocol):
"""!
@brief Sends one e-mail.
"""
def send(self, recipient: str, subject: str, body: str) -> None:
"""!
@brief Send a plain-text e-mail.
@param recipient Address to send to.
@param subject Subject line.
@param body Message text.
@throws MailError when the mail cannot be sent.
"""
...
@@ -0,0 +1,4 @@
"""!
@file __init__.py
@brief Use cases of the birthday wisher.
"""
@@ -0,0 +1,91 @@
"""!
@file send_birthday_wishes.py
@brief Use case: send a random birthday letter to everyone whose birthday is today.
"""
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import date
from birthday_wisher import constants
from birthday_wisher.application.ports import (
BirthdayRepository,
LetterSource,
MailSender,
)
from birthday_wisher.domain.errors import MailError
from birthday_wisher.domain.person import Person
@dataclass
class WishReport:
"""!
@brief Outcome of one run.
@param sent Names of the people who were sent a letter.
@param failed Names of the people whose letter could not be sent.
"""
sent: list[str] = field(default_factory=list)
failed: list[str] = field(default_factory=list)
def birthdays_by_day(people: list[Person]) -> dict[tuple[int, int], list[Person]]:
"""!
@brief Build the dictionary the course hints describe.
@details The key is (month, day). The value is a list, because two people
can share a birthday.
@param people All people.
@return Mapping from (month, day) to the people born on that day.
"""
result: dict[tuple[int, int], list[Person]] = {}
for person in people:
result.setdefault(person.birthday, []).append(person)
return result
def find_birthday_people(people: list[Person], today: date) -> list[Person]:
"""!
@brief Select the people whose birthday is on the given day.
@param people All people.
@param today The day to check.
@return The people to wish a happy birthday, in file order.
"""
return [person for person in people if person.has_birthday_on(today)]
def build_letter(template: str, name: str) -> str:
"""!
@brief Put the person's name into a letter template.
@param template Template text containing the name placeholder.
@param name Name of the recipient.
@return The finished letter.
"""
return template.replace(constants.NAME_PLACEHOLDER, name)
def send_birthday_wishes(
repository: BirthdayRepository,
letters: LetterSource,
mailer: MailSender,
today: date,
choose_letter: Callable[[], int],
) -> WishReport:
"""!
@brief Send one random letter to everyone whose birthday is today.
@param repository Source of the people.
@param letters Source of the letter templates.
@param mailer Sends the e-mail.
@param today The day to check.
@param choose_letter Returns the number of the template to use (1 to LETTER_COUNT).
@return Who was sent a letter and for whom sending failed.
"""
report = WishReport()
for person in find_birthday_people(repository.load_all(), today):
body = build_letter(letters.read_letter(choose_letter()), person.name)
try:
mailer.send(person.email, constants.EMAIL_SUBJECT, body)
except MailError:
report.failed.append(person.name)
else:
report.sent.append(person.name)
return report
+4
View File
@@ -0,0 +1,4 @@
"""!
@file __init__.py
@brief Domain layer: the business rules, independent of files, mail and the console.
"""
+34
View File
@@ -0,0 +1,34 @@
"""!
@file errors.py
@brief Errors the birthday wisher raises.
"""
class BirthdayWisherError(Exception):
"""!
@brief Base class of every error raised by this program.
"""
class BirthdayDataError(BirthdayWisherError):
"""!
@brief The birthdays file is missing or a row in it is invalid.
"""
class LetterTemplateError(BirthdayWisherError):
"""!
@brief A letter template is missing or unreadable.
"""
class ConfigurationError(BirthdayWisherError):
"""!
@brief A required setting (sender address or token) is missing.
"""
class MailError(BirthdayWisherError):
"""!
@brief A letter could not be sent.
"""
+46
View File
@@ -0,0 +1,46 @@
"""!
@file person.py
@brief The Person entity: a name, an e-mail address and a birth date.
"""
import calendar
from dataclasses import dataclass
from datetime import date
@dataclass(frozen=True)
class Person:
"""!
@brief A person who should receive a birthday wish.
@param name Name that replaces the placeholder in the letter.
@param email Address the letter is sent to.
@param year Year of birth.
@param month Month of birth (1 to 12).
@param day Day of birth (1 to 31).
"""
name: str
email: str
year: int
month: int
day: int
@property
def birthday(self) -> tuple[int, int]:
"""!
@brief The (month, day) pair the course hints use as dictionary key.
@return Tuple of month and day of birth.
"""
return (self.month, self.day)
def has_birthday_on(self, today: date) -> bool:
"""!
@brief Tell whether the given day is this person's birthday.
@details A birthday on 29 February is celebrated on 28 February in
years that are not leap years.
@param today The day to check.
@return True when month and day match.
"""
if self.birthday == (2, 29) and not calendar.isleap(today.year):
return (today.month, today.day) == (2, 28)
return (today.month, today.day) == self.birthday
@@ -0,0 +1,4 @@
"""!
@file __init__.py
@brief Infrastructure layer: files, SMTP and environment settings.
"""
@@ -0,0 +1,54 @@
"""!
@file csv_birthday_repository.py
@brief Reads the people from the birthdays CSV file.
"""
import csv
from pathlib import Path
from birthday_wisher import constants
from birthday_wisher.domain.errors import BirthdayDataError
from birthday_wisher.domain.person import Person
class CsvBirthdayRepository:
"""!
@brief BirthdayRepository backed by a CSV file with the columns of CSV_COLUMNS.
@param path Path of the CSV file.
"""
def __init__(self, path: Path) -> None:
self._path = path
def load_all(self) -> list[Person]:
"""!
@brief Read every row of the file.
@return All people, in file order.
@throws BirthdayDataError when the file is missing or a row is invalid.
"""
try:
with self._path.open(newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle)
missing = set(constants.CSV_COLUMNS) - set(reader.fieldnames or [])
if missing:
raise BirthdayDataError(
f"{self._path}: missing column(s) {sorted(missing)}"
)
return [
self._to_person(row, number)
for number, row in enumerate(reader, start=2)
]
except OSError as err:
raise BirthdayDataError(f"cannot read {self._path}: {err}") from err
def _to_person(self, row: dict[str, str], line: int) -> Person:
try:
return Person(
name=row["name"].strip(),
email=row["email"].strip(),
year=int(row["year"]),
month=int(row["month"]),
day=int(row["day"]),
)
except (TypeError, ValueError, AttributeError) as err:
raise BirthdayDataError(f"{self._path} line {line}: invalid row") from err
@@ -0,0 +1,34 @@
"""!
@file letter_templates.py
@brief Reads the letter templates from a folder.
"""
from pathlib import Path
from birthday_wisher import constants
from birthday_wisher.domain.errors import LetterTemplateError
class FolderLetterSource:
"""!
@brief LetterSource backed by the files letter_1.txt, letter_2.txt, and so on.
@param folder Folder that holds the templates.
"""
def __init__(self, folder: Path) -> None:
self._folder = folder
def read_letter(self, number: int) -> str:
"""!
@brief Read one template.
@param number Template number, 1 to LETTER_COUNT.
@return The template text.
@throws LetterTemplateError when the file cannot be read.
"""
path = self._folder / constants.LETTER_FILE_PATTERN.format(number=number)
try:
return path.read_text(encoding="utf-8")
except OSError as err:
raise LetterTemplateError(
f"cannot read letter template {path}: {err}"
) from err
@@ -0,0 +1,42 @@
"""!
@file settings.py
@brief Reads the sender's address and token from the environment.
"""
from collections.abc import Mapping
from dataclasses import dataclass
from birthday_wisher import constants
from birthday_wisher.domain.errors import ConfigurationError
@dataclass(frozen=True)
class Credentials:
"""!
@brief The sender's login.
@param address Sender e-mail address.
@param token Password or app password; hidden from repr.
"""
address: str
token: str
def __repr__(self) -> str:
return f"Credentials(address={self.address!r}, token='***')"
def read_credentials(environ: Mapping[str, str]) -> Credentials:
"""!
@brief Read the credentials from an environment mapping.
@details Only the names of the missing variables are reported, never a value.
@param environ Environment variables, normally os.environ.
@return The credentials.
@throws ConfigurationError when a variable is missing or empty.
"""
names = (constants.ENV_SENDER_ADDRESS, constants.ENV_SENDER_TOKEN)
missing = [name for name in names if not environ.get(name)]
if missing:
raise ConfigurationError(
f"missing environment variable(s): {', '.join(missing)}"
)
return Credentials(environ[names[0]], environ[names[1]])
@@ -0,0 +1,59 @@
"""!
@file smtp_mailer.py
@brief Sends e-mail through an SMTP server with STARTTLS.
"""
import smtplib
from collections.abc import Callable
from email.message import EmailMessage
from typing import Any # Any: smtplib.SMTP and test fakes share no common type
from birthday_wisher.domain.errors import MailError
class SmtpMailer:
"""!
@brief MailSender that talks SMTP with STARTTLS.
@param host SMTP server name.
@param port SMTP port that expects STARTTLS.
@param address Sender address, also used to log in.
@param token Password or app password. Never logged or shown.
@param smtp_factory Creates the SMTP connection; tests replace it with a fake.
"""
def __init__(
self,
host: str,
port: int,
address: str,
token: str,
smtp_factory: Callable[[str, int], Any] = smtplib.SMTP,
) -> None:
self._host = host
self._port = port
self._address = address
self._token = token
self._smtp_factory = smtp_factory
def send(self, recipient: str, subject: str, body: str) -> None:
"""!
@brief Connect, start TLS, log in and send one plain-text e-mail.
@param recipient Address to send to.
@param subject Subject line.
@param body Message text.
@throws MailError when the connection, login or sending fails.
"""
message = EmailMessage()
message["From"] = self._address
message["To"] = recipient
message["Subject"] = subject
message.set_content(body)
try:
with self._smtp_factory(self._host, self._port) as connection:
connection.starttls()
connection.login(self._address, self._token)
connection.send_message(message)
except (smtplib.SMTPException, OSError) as err:
raise MailError(
f"could not send to {recipient}: {type(err).__name__}"
) from err
@@ -0,0 +1,4 @@
"""!
@file __init__.py
@brief Presentation layer: the command line and the composition root.
"""
+127
View File
@@ -0,0 +1,127 @@
"""!
@file cli.py
@brief Command line entry point and composition root.
"""
import argparse
import os
import random
import sys
from collections.abc import Callable, Mapping, Sequence
from datetime import date
from pathlib import Path
from typing import Any # Any: smtplib.SMTP and test fakes share no common type
from birthday_wisher import constants
from birthday_wisher.application.ports import MailSender
from birthday_wisher.application.use_cases.send_birthday_wishes import (
send_birthday_wishes,
)
from birthday_wisher.domain.errors import BirthdayWisherError
from birthday_wisher.infrastructure.csv_birthday_repository import (
CsvBirthdayRepository,
)
from birthday_wisher.infrastructure.letter_templates import FolderLetterSource
from birthday_wisher.infrastructure.settings import read_credentials
from birthday_wisher.infrastructure.smtp_mailer import SmtpMailer
class _DryRunMailer:
"""!
@brief MailSender that prints the e-mail instead of sending it.
"""
def send(self, recipient: str, subject: str, body: str) -> None:
"""!
@brief Print the e-mail.
@param recipient Address the e-mail would go to.
@param subject Subject line.
@param body Message text.
"""
print(f"[dry run] To: {recipient}\nSubject: {subject}\n\n{body}\n")
def build_parser() -> argparse.ArgumentParser:
"""!
@brief Create the argument parser.
@return The parser with --dry-run, --today, --csv and --letters.
"""
parser = argparse.ArgumentParser(
prog="birthday-wisher",
description="Send a random birthday letter to everyone born on today's date.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="print the e-mails instead of sending them",
)
parser.add_argument(
"--today",
type=date.fromisoformat,
default=None,
help="pretend today is YYYY-MM-DD",
)
parser.add_argument(
"--csv",
type=Path,
default=Path(constants.BIRTHDAYS_FILE),
help="birthdays CSV file",
)
parser.add_argument(
"--letters",
type=Path,
default=Path(constants.LETTER_TEMPLATES_DIR),
help="folder with the letter templates",
)
return parser
def _choose_letter() -> int:
return random.randint(1, constants.LETTER_COUNT)
def main(
argv: Sequence[str] | None = None,
environ: Mapping[str, str] | None = None,
smtp_factory: Callable[[str, int], Any] | None = None,
) -> int:
"""!
@brief Run the program.
@param argv Command line arguments; sys.argv is used when None.
@param environ Environment variables; os.environ is used when None.
@param smtp_factory Creates the SMTP connection; tests pass a fake.
@return Exit status: 0 on success, 1 when a letter failed or setup is wrong.
"""
args = build_parser().parse_args(argv)
env = os.environ if environ is None else environ
today = args.today or date.today()
try:
mailer: MailSender
if args.dry_run:
mailer = _DryRunMailer()
else:
credentials = read_credentials(env)
mailer = SmtpMailer(
constants.SMTP_HOST,
constants.SMTP_PORT,
credentials.address,
credentials.token,
**({} if smtp_factory is None else {"smtp_factory": smtp_factory}),
)
report = send_birthday_wishes(
CsvBirthdayRepository(args.csv),
FolderLetterSource(args.letters),
mailer,
today,
_choose_letter,
)
except BirthdayWisherError as err:
print(f"error: {err}", file=sys.stderr)
return 1
if not report.sent and not report.failed:
print(f"No birthdays today ({today.isoformat()}).")
for name in report.sent:
print(f"Sent a birthday letter to {name}.")
for name in report.failed:
print(f"error: could not send a letter to {name}.", file=sys.stderr)
return 1 if report.failed else 0
+158
View File
@@ -0,0 +1,158 @@
"""End-to-end tests of the command line: real files, fake SMTP, no network."""
import smtplib
from email.message import EmailMessage
from pathlib import Path
from types import TracebackType
from typing import ClassVar, Self
import pytest
from birthday_wisher import constants
from birthday_wisher.presentation.cli import main
ROOT = Path(__file__).resolve().parents[3]
ENV = {
constants.ENV_SENDER_ADDRESS: "me@example.com",
constants.ENV_SENDER_TOKEN: "tok-123",
}
class FakeSmtp:
"""Records the messages sent through every instance."""
messages: ClassVar[list[EmailMessage]] = []
fail = False
def __init__(self, host: str, port: int) -> None:
pass
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
return None
def starttls(self) -> None:
return None
def login(self, user: str, password: str) -> None:
if FakeSmtp.fail:
raise smtplib.SMTPAuthenticationError(535, b"no")
def send_message(self, message: EmailMessage) -> None:
FakeSmtp.messages.append(message)
@pytest.fixture(autouse=True)
def _reset() -> None:
FakeSmtp.messages = []
FakeSmtp.fail = False
@pytest.fixture
def data(tmp_path: Path) -> list[str]:
"""Arguments for a temporary CSV (Bob and Carol on 10-17) and real letters."""
csv_path = tmp_path / "b.csv"
csv_path.write_text(
"name,email,year,month,day\n"
"Alice,alice@example.com,1990,3,14\n"
"Bob,bob@example.com,1985,10,17\n"
"Carol,carol@example.com,2001,10,17\n",
encoding="utf-8",
)
return ["--csv", str(csv_path), "--letters", str(ROOT / "letter_templates")]
def test_dry_run_prints_to_and_subject_without_env(
data: list[str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Dry run needs no environment and prints finished letters."""
code = main([*data, "--today", "2026-10-17", "--dry-run"], environ={})
out = capsys.readouterr().out
assert code == 0
assert "To: bob@example.com" in out
assert "To: carol@example.com" in out
assert "Subject: Happy Birthday" in out
assert "[NAME]" not in out
assert "alice@example.com" not in out
def test_real_mode_sends_through_fake_smtp(
data: list[str], capsys: pytest.CaptureFixture[str]
) -> None:
"""Real mode sends one mail per birthday person through the factory."""
code = main([*data, "--today", "2026-10-17"], environ=ENV, smtp_factory=FakeSmtp)
assert code == 0
assert sorted(str(m["To"]) for m in FakeSmtp.messages) == [
"bob@example.com",
"carol@example.com",
]
assert all(m["Subject"] == "Happy Birthday" for m in FakeSmtp.messages)
assert "Sent a birthday letter to Bob." in capsys.readouterr().out
def test_missing_env_returns_1_naming_variable_not_value(
data: list[str], capsys: pytest.CaptureFixture[str]
) -> None:
"""A missing variable is named, its sibling's value is not shown."""
env = {constants.ENV_SENDER_ADDRESS: "me@example.com"}
code = main([*data, "--today", "2026-10-17"], environ=env, smtp_factory=FakeSmtp)
err = capsys.readouterr().err
assert code == 1
assert constants.ENV_SENDER_TOKEN in err
assert "me@example.com" not in err
assert FakeSmtp.messages == []
def test_no_birthdays_today_prints_message(
data: list[str], capsys: pytest.CaptureFixture[str]
) -> None:
"""A day without birthdays prints a message and succeeds."""
code = main([*data, "--today", "2026-01-01", "--dry-run"], environ={})
assert code == 0
assert "No birthdays today" in capsys.readouterr().out
def test_missing_csv_returns_1(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""A missing CSV file returns 1 with an error message."""
code = main(["--csv", str(tmp_path / "none.csv"), "--dry-run"], environ={})
assert code == 1
assert "error:" in capsys.readouterr().err
def test_smtp_failure_returns_1_without_token(
data: list[str], capsys: pytest.CaptureFixture[str]
) -> None:
"""A failing login returns 1 and never prints the token."""
FakeSmtp.fail = True
code = main([*data, "--today", "2026-10-17"], environ=ENV, smtp_factory=FakeSmtp)
err = capsys.readouterr().err
assert code == 1
assert "tok-123" not in err
def test_shipped_birthdays_csv_has_bob_and_carol_on_oct_17(
capsys: pytest.CaptureFixture[str],
) -> None:
"""The shipped sample data works with the shipped letters."""
args = [
"--csv",
str(ROOT / "birthdays.csv"),
"--letters",
str(ROOT / "letter_templates"),
"--today",
"2026-10-17",
"--dry-run",
]
assert main(args, environ={}) == 0
out = capsys.readouterr().out
assert "bob@example.com" in out
assert "carol@example.com" in out
@@ -0,0 +1,63 @@
"""Integration tests for CsvBirthdayRepository against real files."""
from pathlib import Path
import pytest
from birthday_wisher.domain.errors import BirthdayDataError
from birthday_wisher.domain.person import Person
from birthday_wisher.infrastructure.csv_birthday_repository import (
CsvBirthdayRepository,
)
HEADER = "name,email,year,month,day\n"
def _write(tmp_path: Path, text: str) -> Path:
path = tmp_path / "birthdays.csv"
path.write_text(text, encoding="utf-8")
return path
def test_load_all_reads_people_in_file_order(tmp_path: Path) -> None:
"""Rows become Person objects in file order."""
path = _write(
tmp_path,
HEADER + "Ann,ann@example.com,1990,3,14\nBob,bob@example.com,1985,10,17\n",
)
assert CsvBirthdayRepository(path).load_all() == [
Person("Ann", "ann@example.com", 1990, 3, 14),
Person("Bob", "bob@example.com", 1985, 10, 17),
]
def test_load_all_header_only_gives_empty_list(tmp_path: Path) -> None:
"""A file with only a header holds nobody."""
assert CsvBirthdayRepository(_write(tmp_path, HEADER)).load_all() == []
def test_load_all_missing_file_raises(tmp_path: Path) -> None:
"""A missing file raises BirthdayDataError."""
with pytest.raises(BirthdayDataError):
CsvBirthdayRepository(tmp_path / "nope.csv").load_all()
def test_load_all_missing_column_raises(tmp_path: Path) -> None:
"""A missing column is named in the error."""
path = _write(tmp_path, "name,email,year,month\nAnn,a@example.com,1990,3\n")
with pytest.raises(BirthdayDataError, match="day"):
CsvBirthdayRepository(path).load_all()
def test_load_all_non_numeric_value_raises(tmp_path: Path) -> None:
"""A non-numeric month raises and names the line."""
path = _write(tmp_path, HEADER + "Ann,a@example.com,1990,March,14\n")
with pytest.raises(BirthdayDataError, match="line 2"):
CsvBirthdayRepository(path).load_all()
def test_load_all_short_row_raises(tmp_path: Path) -> None:
"""A row with too few fields raises BirthdayDataError."""
path = _write(tmp_path, HEADER + "Ann,a@example.com,1990\n")
with pytest.raises(BirthdayDataError):
CsvBirthdayRepository(path).load_all()
@@ -0,0 +1,34 @@
"""Integration tests for FolderLetterSource against real files."""
from pathlib import Path
import pytest
from birthday_wisher import constants
from birthday_wisher.application.use_cases.send_birthday_wishes import build_letter
from birthday_wisher.domain.errors import LetterTemplateError
from birthday_wisher.infrastructure.letter_templates import FolderLetterSource
LETTERS_DIR = Path(__file__).resolve().parents[3] / constants.LETTER_TEMPLATES_DIR
@pytest.mark.parametrize("number", range(1, constants.LETTER_COUNT + 1))
def test_every_real_template_can_be_chosen_and_built(number: int) -> None:
"""Each shipped template holds the placeholder and is fully replaced."""
template = FolderLetterSource(LETTERS_DIR).read_letter(number)
assert constants.NAME_PLACEHOLDER in template
letter = build_letter(template, "Zelda")
assert "Zelda" in letter
assert constants.NAME_PLACEHOLDER not in letter
def test_read_letter_reads_file_from_folder(tmp_path: Path) -> None:
"""A template file is returned as written."""
(tmp_path / "letter_1.txt").write_text("Hi [NAME]", encoding="utf-8")
assert FolderLetterSource(tmp_path).read_letter(1) == "Hi [NAME]"
def test_read_letter_missing_file_raises_template_error(tmp_path: Path) -> None:
"""A missing template raises LetterTemplateError."""
with pytest.raises(LetterTemplateError):
FolderLetterSource(tmp_path).read_letter(1)
@@ -0,0 +1,46 @@
"""Tests for reading the sender credentials from an environment mapping."""
import pytest
from birthday_wisher import constants
from birthday_wisher.domain.errors import ConfigurationError
from birthday_wisher.infrastructure.settings import Credentials, read_credentials
ADDRESS = constants.ENV_SENDER_ADDRESS
TOKEN = constants.ENV_SENDER_TOKEN
def test_read_credentials_returns_address_and_token() -> None:
"""Both variables present give the credentials."""
result = read_credentials({ADDRESS: "me@example.com", TOKEN: "tok"})
assert result == Credentials("me@example.com", "tok")
def test_read_credentials_reports_missing_names_not_values() -> None:
"""The error names the missing variable and shows no value."""
with pytest.raises(ConfigurationError) as info:
read_credentials({ADDRESS: "me@example.com"})
text = str(info.value)
assert TOKEN in text
assert "me@example.com" not in text
def test_read_credentials_reports_both_when_none_set() -> None:
"""Both missing names are listed."""
with pytest.raises(ConfigurationError) as info:
read_credentials({})
assert ADDRESS in str(info.value)
assert TOKEN in str(info.value)
def test_read_credentials_treats_empty_value_as_missing() -> None:
"""An empty value counts as missing."""
with pytest.raises(ConfigurationError, match=TOKEN):
read_credentials({ADDRESS: "me@example.com", TOKEN: ""})
def test_credentials_repr_hides_token() -> None:
"""Neither repr nor str shows the token."""
credentials = Credentials("me@example.com", "super-secret")
assert "super-secret" not in repr(credentials)
assert "super-secret" not in str(credentials)
@@ -0,0 +1,113 @@
"""Tests for SmtpMailer with a fake SMTP connection (no network)."""
import smtplib
from email.message import EmailMessage
from types import TracebackType
from typing import ClassVar, Self
import pytest
from birthday_wisher import constants
from birthday_wisher.domain.errors import MailError
from birthday_wisher.infrastructure.smtp_mailer import SmtpMailer
TOKEN = "s3cret-token"
class FakeSmtp:
"""Records calls in order; can raise on a chosen step."""
calls: ClassVar[list[str]] = []
messages: ClassVar[list[EmailMessage]] = []
fail_on: ClassVar[dict[str, Exception]] = {}
created_with: ClassVar[list[tuple[str, int]]] = []
def __init__(self, host: str, port: int) -> None:
FakeSmtp.created_with.append((host, port))
def __enter__(self) -> Self:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
FakeSmtp.calls.append("quit")
def _step(self, name: str) -> None:
FakeSmtp.calls.append(name)
if name in FakeSmtp.fail_on:
raise FakeSmtp.fail_on[name]
def starttls(self) -> None:
self._step("starttls")
def login(self, user: str, password: str) -> None:
self._step("login")
assert (user, password) == ("me@example.com", TOKEN)
def send_message(self, message: EmailMessage) -> None:
self._step("send_message")
FakeSmtp.messages.append(message)
@pytest.fixture(autouse=True)
def _reset_fake() -> None:
FakeSmtp.calls = []
FakeSmtp.messages = []
FakeSmtp.fail_on = {}
FakeSmtp.created_with = []
def _mailer() -> SmtpMailer:
return SmtpMailer("smtp.test", 587, "me@example.com", TOKEN, FakeSmtp)
def test_send_calls_starttls_then_login_then_send_message() -> None:
"""The SMTP steps run in the required order."""
_mailer().send("bob@example.com", constants.EMAIL_SUBJECT, "Hi Bob")
assert FakeSmtp.calls == ["starttls", "login", "send_message", "quit"]
assert FakeSmtp.created_with == [("smtp.test", 587)]
def test_send_builds_message_with_subject_recipient_and_body() -> None:
"""Subject, recipient, sender and body reach the server."""
_mailer().send("bob@example.com", constants.EMAIL_SUBJECT, "Hi Bob")
message = FakeSmtp.messages[0]
assert message["Subject"] == "Happy Birthday"
assert message["To"] == "bob@example.com"
assert message["From"] == "me@example.com"
assert message.get_content().strip() == "Hi Bob"
@pytest.mark.parametrize("step", ["starttls", "login", "send_message"])
@pytest.mark.parametrize(
"error",
[
smtplib.SMTPAuthenticationError(535, b"bad " + TOKEN.encode()),
smtplib.SMTPException("x"),
OSError("net down " + TOKEN),
],
)
def test_send_failure_becomes_mail_error_without_token(
step: str, error: Exception
) -> None:
"""smtplib and OS errors become MailError and never carry the token."""
FakeSmtp.fail_on = {step: error}
with pytest.raises(MailError) as info:
_mailer().send("bob@example.com", "Happy Birthday", "Hi")
assert TOKEN not in str(info.value)
assert "bob@example.com" in str(info.value)
def test_send_connection_failure_becomes_mail_error() -> None:
"""A refused connection becomes MailError."""
def refuse(host: str, port: int) -> FakeSmtp:
raise ConnectionRefusedError("refused")
mailer = SmtpMailer("h", 587, "me@example.com", TOKEN, refuse)
with pytest.raises(MailError):
mailer.send("bob@example.com", "Happy Birthday", "Hi")
@@ -0,0 +1,104 @@
"""Unit tests for the send-birthday-wishes use case, using fakes of the ports."""
from datetime import date
from birthday_wisher import constants
from birthday_wisher.application.use_cases.send_birthday_wishes import (
birthdays_by_day,
build_letter,
find_birthday_people,
send_birthday_wishes,
)
from birthday_wisher.domain.errors import MailError
from birthday_wisher.domain.person import Person
TODAY = date(2026, 10, 17)
ALICE = Person("Alice", "alice@example.com", 1990, 3, 14)
BOB = Person("Bob", "bob@example.com", 1985, 10, 17)
CAROL = Person("Carol", "carol@example.com", 2001, 10, 17)
class FakeRepository:
"""In-memory BirthdayRepository."""
def __init__(self, people: list[Person]) -> None:
self._people = people
def load_all(self) -> list[Person]:
return list(self._people)
class FakeLetters:
"""In-memory LetterSource."""
def read_letter(self, number: int) -> str:
return f"Letter {number} for [NAME]"
class FakeMailer:
"""Records mails; fails for the addresses in `fail_for`."""
def __init__(self, fail_for: set[str] | None = None) -> None:
self.sent: list[tuple[str, str, str]] = []
self._fail_for = fail_for or set()
def send(self, recipient: str, subject: str, body: str) -> None:
if recipient in self._fail_for:
raise MailError("boom")
self.sent.append((recipient, subject, body))
def test_find_birthday_people_selects_only_today() -> None:
assert find_birthday_people([ALICE, BOB], TODAY) == [BOB]
def test_find_birthday_people_returns_both_sharing_a_day() -> None:
assert find_birthday_people([BOB, ALICE, CAROL], TODAY) == [BOB, CAROL]
def test_find_birthday_people_empty_when_nobody() -> None:
assert find_birthday_people([ALICE], TODAY) == []
def test_birthdays_by_day_groups_people_sharing_a_day() -> None:
result = birthdays_by_day([ALICE, BOB, CAROL])
assert result == {(3, 14): [ALICE], (10, 17): [BOB, CAROL]}
def test_build_letter_replaces_name() -> None:
assert build_letter("Dear [NAME],", "Bob") == "Dear Bob,"
def test_build_letter_replaces_every_placeholder() -> None:
assert build_letter("[NAME] and [NAME]", "Bo") == "Bo and Bo"
def test_send_wishes_sends_one_letter_to_todays_person_only() -> None:
mailer = FakeMailer()
report = send_birthday_wishes(
FakeRepository([ALICE, BOB]), FakeLetters(), mailer, TODAY, lambda: 2
)
assert mailer.sent == [
("bob@example.com", constants.EMAIL_SUBJECT, "Letter 2 for Bob")
]
assert report.sent == ["Bob"]
assert report.failed == []
def test_send_wishes_with_no_birthday_sends_nothing() -> None:
mailer = FakeMailer()
report = send_birthday_wishes(
FakeRepository([ALICE]), FakeLetters(), mailer, TODAY, lambda: 1
)
assert mailer.sent == []
assert report.sent == [] and report.failed == []
def test_send_wishes_one_failure_does_not_stop_the_others() -> None:
mailer = FakeMailer(fail_for={"bob@example.com"})
report = send_birthday_wishes(
FakeRepository([BOB, CAROL]), FakeLetters(), mailer, TODAY, lambda: 1
)
assert report.failed == ["Bob"]
assert report.sent == ["Carol"]
assert [m[0] for m in mailer.sent] == ["carol@example.com"]
+45
View File
@@ -0,0 +1,45 @@
"""Unit tests for the Person entity."""
from datetime import date
from birthday_wisher.domain.person import Person
def _person(month: int, day: int) -> Person:
return Person("Ann", "ann@example.com", 1990, month, day)
def test_has_birthday_on_same_month_and_day() -> None:
assert _person(10, 17).has_birthday_on(date(2026, 10, 17))
def test_has_birthday_on_other_day_is_false() -> None:
assert not _person(10, 17).has_birthday_on(date(2026, 10, 18))
def test_has_birthday_on_same_day_other_month_is_false() -> None:
assert not _person(10, 17).has_birthday_on(date(2026, 11, 17))
def test_has_birthday_on_ignores_year() -> None:
assert _person(10, 17).has_birthday_on(date(1999, 10, 17))
def test_leap_day_birthday_is_29_feb_in_leap_year() -> None:
assert _person(2, 29).has_birthday_on(date(2024, 2, 29))
def test_leap_day_birthday_is_not_28_feb_in_leap_year() -> None:
assert not _person(2, 29).has_birthday_on(date(2024, 2, 28))
def test_leap_day_birthday_is_28_feb_in_non_leap_year() -> None:
assert _person(2, 29).has_birthday_on(date(2026, 2, 28))
def test_leap_day_birthday_is_not_1_mar_in_non_leap_year() -> None:
assert not _person(2, 29).has_birthday_on(date(2026, 3, 1))
def test_28_feb_birthday_is_28_feb_in_non_leap_year() -> None:
assert _person(2, 28).has_birthday_on(date(2026, 2, 28))