Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
457b84c6da | ||
|
|
5937dc2725 | ||
|
|
0126093601 | ||
|
|
70b2b3c02f | ||
|
|
9b3a134721 | ||
|
|
6d5aa89cd8 |
@@ -0,0 +1,2 @@
|
||||
GMAIL_ADDRESS=
|
||||
GMAIL_TOKEN=
|
||||
@@ -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 }}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
# Secrets: personal use only, never committed
|
||||
.env
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Test and tool caches
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Generated documentation
|
||||
docs/doxygen/
|
||||
|
||||
# Editors and OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# AI prompt log (the repository is public)
|
||||
/docs/ai-prompts/
|
||||
@@ -0,0 +1,23 @@
|
||||
# Doxygen configuration for the birthday wisher (Python).
|
||||
# Run from the repository root: doxygen Doxyfile
|
||||
# Output goes to docs/doxygen/ (git-ignored).
|
||||
|
||||
PROJECT_NAME = "Birthday Wisher"
|
||||
PROJECT_BRIEF = "Sends a random birthday letter by e-mail (Udemy 100 Days of Code, day 32)"
|
||||
OUTPUT_DIRECTORY = docs/doxygen
|
||||
INPUT = src README.md
|
||||
FILE_PATTERNS = *.py *.md
|
||||
RECURSIVE = YES
|
||||
USE_MDFILE_AS_MAINPAGE = README.md
|
||||
|
||||
OPTIMIZE_OUTPUT_JAVA = YES
|
||||
EXTRACT_ALL = YES
|
||||
EXTRACT_PRIVATE = NO
|
||||
JAVADOC_AUTOBRIEF = YES
|
||||
AUTOLINK_SUPPORT = YES
|
||||
SOURCE_BROWSER = YES
|
||||
|
||||
GENERATE_HTML = YES
|
||||
GENERATE_LATEX = NO
|
||||
QUIET = YES
|
||||
WARN_IF_UNDOCUMENTED = YES
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
| 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]"
|
||||
```
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
## 📄 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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
name,email,year,month,day
|
||||
Alice Example,alice@example.com,1990,3,14
|
||||
Bob Example,bob@example.com,1985,10,17
|
||||
Carol Example,carol@example.com,2001,10,17
|
||||
Dave Example,dave@example.com,1978,12,24
|
||||
|
@@ -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 |
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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*.
|
||||
|
||||

|
||||
|
||||
| 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).
|
||||
@@ -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*.
|
||||
|
||||

|
||||
|
||||
| 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).
|
||||
@@ -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*.
|
||||
|
||||

|
||||
|
||||
| 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).
|
||||
@@ -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]"
|
||||
```
|
||||
|
||||

|
||||
|
||||
Tilvalget `dev` installerer pytest, ruff og mypy. Selve programmet kræver intet.
|
||||
@@ -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]"
|
||||
```
|
||||
|
||||

|
||||
|
||||
Tambahan `dev` memasang pytest, ruff dan mypy. Program itu sendiri tidak memerlukan apa-apa.
|
||||
@@ -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]"
|
||||
```
|
||||
|
||||

|
||||
|
||||
The `dev` extra installs pytest, ruff and mypy. Nothing is needed to run the program itself.
|
||||
@@ -0,0 +1,18 @@
|
||||
# Test
|
||||
|
||||
🌐 [English](Testing) · **Dansk** · [Bahasa Melayu](Testing-ms)
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||

|
||||
|
||||
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/`).
|
||||
@@ -0,0 +1,18 @@
|
||||
# Ujian
|
||||
|
||||
🌐 [English](Testing) · [Dansk](Testing-da) · **Bahasa Melayu**
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||

|
||||
|
||||
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/`).
|
||||
@@ -0,0 +1,18 @@
|
||||
# Testing
|
||||
|
||||
🌐 **English** · [Dansk](Testing-da) · [Bahasa Melayu](Testing-ms)
|
||||
|
||||
```bash
|
||||
python -m pytest
|
||||
```
|
||||
|
||||

|
||||
|
||||
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/`).
|
||||
@@ -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
|
||||
```
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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
|
||||
```
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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
|
||||
```
|
||||
|
||||

|
||||
|
||||
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.
|
||||
@@ -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)
|
||||
@@ -0,0 +1,7 @@
|
||||
Dear [NAME],
|
||||
|
||||
Happy birthday!
|
||||
|
||||
All the best for the year!
|
||||
|
||||
Angela
|
||||
@@ -0,0 +1,7 @@
|
||||
Hey [NAME],
|
||||
|
||||
Happy birthday! Have a wonderful time today and eat lots of cake!
|
||||
|
||||
Lots of love,
|
||||
|
||||
Angela
|
||||
@@ -0,0 +1,7 @@
|
||||
Dear [NAME],
|
||||
|
||||
It's your birthday! Have a great day!
|
||||
|
||||
All my love,
|
||||
|
||||
Angela
|
||||
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "birthday-wisher"
|
||||
version = "0.1.0"
|
||||
description = "Sends a random birthday letter by e-mail to everyone whose birthday is today (Udemy 100 Days of Code, day 32)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
|
||||
[project.scripts]
|
||||
birthday-wisher = "birthday_wisher.presentation.cli:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
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"
|
||||
@@ -0,0 +1,4 @@
|
||||
"""!
|
||||
@file __init__.py
|
||||
@brief Birthday wisher: sends a random birthday letter on a person's birthday.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -0,0 +1,37 @@
|
||||
"""!
|
||||
@file constants.py
|
||||
@brief Constants of the birthday wisher: file names, placeholder, SMTP settings.
|
||||
"""
|
||||
|
||||
## Name of the CSV file with the people and their birth dates.
|
||||
BIRTHDAYS_FILE: str = "birthdays.csv"
|
||||
|
||||
## Folder with the letter templates.
|
||||
LETTER_TEMPLATES_DIR: str = "letter_templates"
|
||||
|
||||
## File name pattern of a letter template (the number is 1 to LETTER_COUNT).
|
||||
LETTER_FILE_PATTERN: str = "letter_{number}.txt"
|
||||
|
||||
## Number of letter templates the program chooses from.
|
||||
LETTER_COUNT: int = 3
|
||||
|
||||
## Placeholder in a letter that is replaced by the person's name.
|
||||
NAME_PLACEHOLDER: str = "[NAME]"
|
||||
|
||||
## Columns of the CSV file.
|
||||
CSV_COLUMNS: tuple[str, ...] = ("name", "email", "year", "month", "day")
|
||||
|
||||
## SMTP server of Gmail.
|
||||
SMTP_HOST: str = "smtp.gmail.com"
|
||||
|
||||
## SMTP port that expects STARTTLS.
|
||||
SMTP_PORT: int = 587
|
||||
|
||||
## Subject of the e-mail.
|
||||
EMAIL_SUBJECT: str = "Happy Birthday"
|
||||
|
||||
## Environment variable with the sender's e-mail address.
|
||||
ENV_SENDER_ADDRESS: str = "GMAIL_ADDRESS"
|
||||
|
||||
## Environment variable with the sender's app password (token).
|
||||
ENV_SENDER_TOKEN: str = "GMAIL_TOKEN"
|
||||
@@ -0,0 +1,4 @@
|
||||
"""!
|
||||
@file __init__.py
|
||||
@brief Domain layer: the business rules, independent of files, mail and the console.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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))
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Smoke test: the package imports and the constants are consistent."""
|
||||
|
||||
from birthday_wisher import constants
|
||||
|
||||
|
||||
def test_letter_file_pattern_has_number_placeholder() -> None:
|
||||
assert "{number}" in constants.LETTER_FILE_PATTERN
|
||||
|
||||
|
||||
def test_csv_columns_start_with_name_and_email() -> None:
|
||||
assert constants.CSV_COLUMNS[:2] == ("name", "email")
|
||||
|
||||
|
||||
def test_smtp_port_expects_starttls() -> None:
|
||||
assert constants.SMTP_PORT == 587
|
||||
Reference in New Issue
Block a user