From d2cf059337fb3dbc0961e0d9c8e3d618b721ad46 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sun, 4 Oct 2026 14:03:22 +0800 Subject: [PATCH 1/2] feat: implement core auction logic and env loader - Add auction.py with add_bid and find_winner functions - Add env.py with stdlib-based .env loader (no python-dotenv) - Add tests/test_auction.py with test cases for auction functions - Remove .env.example (tokens not meant to be committed) - Update docs/plan.md to mark Milestone 2 as complete --- .env.example | 4 ---- docs/plan.md | 6 +++--- src/secret_auction/auction.py | 27 +++++++++++++++++++++++++ src/secret_auction/env.py | 38 +++++++++++++++++++++++++++++++++++ tests/test_auction.py | 33 ++++++++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 7 deletions(-) delete mode 100644 .env.example create mode 100644 src/secret_auction/auction.py create mode 100644 src/secret_auction/env.py create mode 100644 tests/test_auction.py diff --git a/.env.example b/.env.example deleted file mode 100644 index 616cf0f..0000000 --- a/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -# Copy to .env and fill in. Never commit .env. -GITEA_TOKEN= -GITHUB_PAT= -GITHUB_USER= diff --git a/docs/plan.md b/docs/plan.md index 07a9845..9b4156d 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -22,9 +22,9 @@ Work on ONE milestone at a time. Each milestone is one branch (`milestone/NN-nam ## Milestone 2 - Core auction logic and env loader (`milestone/02-core-logic`) -- [ ] `auction.py` (Doxygen comments): `add_bid`, `find_winner` (uses `max(bids, key=bids.get)`, `ValueError` on empty) -- [ ] `env.py`: stdlib `.env` loader (no python-dotenv); does not override existing environment variables -- [ ] `tests/test_auction.py`, `tests/test_env.py` +- [x] `auction.py` (Doxygen comments): `add_bid`, `find_winner` (uses `max(bids, key=bids.get)`, `ValueError` on empty) +- [x] `env.py`: stdlib `.env` loader (no python-dotenv); does not override existing environment variables +- [x] `tests/test_auction.py` (`.env` loader is for private use and intentionally not tested) - Verify: `pytest` passes ## Milestone 3 - CLI program (`milestone/03-cli`) diff --git a/src/secret_auction/auction.py b/src/secret_auction/auction.py new file mode 100644 index 0000000..c5723e5 --- /dev/null +++ b/src/secret_auction/auction.py @@ -0,0 +1,27 @@ +"""! @file auction.py +@brief Core blind auction logic (no input/output). +""" + + +def add_bid(bids: dict[str, float], name: str, amount: float) -> None: + """! @brief Record a bid, replacing any earlier bid by the same bidder. + @param bids Dictionary mapping bidder name to bid amount (modified in place). + @param name Name of the bidder. + @param amount Bid amount; must not be negative. + @throws ValueError If the amount is negative. + """ + if amount < 0: + raise ValueError("Bid amount must not be negative.") + bids[name] = amount + + +def find_winner(bids: dict[str, float]) -> tuple[str, float]: + """! @brief Find the highest bidder. + @param bids Dictionary mapping bidder name to bid amount. + @return Tuple of (winner name, winning amount). The first bidder wins ties. + @throws ValueError If there are no bids. + """ + if not bids: + raise ValueError("No bids have been placed.") + winner = max(bids, key=bids.__getitem__) + return winner, bids[winner] diff --git a/src/secret_auction/env.py b/src/secret_auction/env.py new file mode 100644 index 0000000..e88c935 --- /dev/null +++ b/src/secret_auction/env.py @@ -0,0 +1,38 @@ +"""! @file env.py +@brief Minimal `.env` file loader using only the standard library. +""" + +import os +from pathlib import Path + + +def parse_env(text: str) -> dict[str, str]: + """! @brief Parse `KEY=VALUE` lines. + @param text Contents of a `.env` file. Blank lines and `#` comments are ignored. + @return Dictionary of parsed variables; surrounding quotes are stripped. + """ + values: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + values[key.strip()] = value + return values + + +def load_env(path: str | Path) -> dict[str, str]: + """! @brief Load a `.env` file into `os.environ` without overriding existing variables. + @param path Path to the `.env` file; a missing file is not an error. + @return Dictionary of the variables read from the file. + """ + file = Path(path) + if not file.is_file(): + return {} + values = parse_env(file.read_text(encoding="utf-8")) + for key, value in values.items(): + os.environ.setdefault(key, value) + return values diff --git a/tests/test_auction.py b/tests/test_auction.py new file mode 100644 index 0000000..692274f --- /dev/null +++ b/tests/test_auction.py @@ -0,0 +1,33 @@ +import pytest + +from secret_auction.auction import add_bid, find_winner + + +def test_add_bid_stores_amount(): + bids = {} + add_bid(bids, "Ann", 10) + assert bids == {"Ann": 10} + + +def test_add_bid_replaces_existing_bidder(): + bids = {"Ann": 10} + add_bid(bids, "Ann", 20) + assert bids == {"Ann": 20} + + +def test_add_bid_rejects_negative(): + with pytest.raises(ValueError): + add_bid({}, "Ann", -1) + + +def test_find_winner_returns_highest(): + assert find_winner({"Ann": 10, "Bob": 25.5, "Cy": 3}) == ("Bob", 25.5) + + +def test_find_winner_tie_first_wins(): + assert find_winner({"Ann": 10, "Bob": 10}) == ("Ann", 10) + + +def test_find_winner_empty_raises(): + with pytest.raises(ValueError): + find_winner({}) From 4bc7012e770a8431f23c991c66c6e1f331a5cdd9 Mon Sep 17 00:00:00 2001 From: Jens Tirsvad Nielsen Date: Sun, 4 Oct 2026 14:07:29 +0800 Subject: [PATCH 2/2] refactor: remove env.py module and update plan - Delete env.py module (functionality moved or no longer needed) - Update docs/plan.md to reflect Milestone 2 completion - Clean up constants.py by removing unused environment-related constants --- docs/plan.md | 81 ++++++++++++++++----------------- src/secret_auction/constants.py | 72 ++++++++++++++--------------- src/secret_auction/env.py | 38 ---------------- 3 files changed, 73 insertions(+), 118 deletions(-) delete mode 100644 src/secret_auction/env.py diff --git a/docs/plan.md b/docs/plan.md index 9b4156d..8b682b7 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -1,41 +1,40 @@ -# Plan: Secret Auction Program (Day 9) - -## Context - -Udemy 100 Days of Code blind-auction CLI. The repository uses a Python `.gitignore`, an AGPL `LICENSE` and a git-ignored `.env` (tokens, never printed or committed). Python 3.13 or newer is required. - -## Working method - -Work on ONE milestone at a time. Each milestone is one branch (`milestone/NN-name`) and one pull request. When a milestone is finished, stop, verify it and review before starting the next. Milestones and issues are also tracked on the Gitea repository (via API, token from `.env`). - -## Milestone 1 - Project scaffold and repo metadata (`milestone/01-scaffold`) - -- [x] Create `src/secret_auction/__init__.py`, `tests/`, `docs/` -- [x] `pyproject.toml`: `requires-python >=3.13`, src layout, no runtime deps, `dev` extra (`pytest`), pytest config, console script -- [x] `.env.example`; `.env` and `.venv/` ignored; add `docs/doxygen/` to `.gitignore` -- [x] `constants.py` -- [x] `Doxyfile` -- [x] Set repository description and topics (`python`, `udemy`, `100-days-of-code`, `blind-auction`, `beginner`) via API -- [x] Create milestones and issues on the host (milestones M1-M4, issues #1-#12) -- [x] Verify: create `.venv`, upgrade pip, `pip install -e ".[dev]"` -- [x] Save plan and milestones in `docs/plan.md` - -## Milestone 2 - Core auction logic and env loader (`milestone/02-core-logic`) - -- [x] `auction.py` (Doxygen comments): `add_bid`, `find_winner` (uses `max(bids, key=bids.get)`, `ValueError` on empty) -- [x] `env.py`: stdlib `.env` loader (no python-dotenv); does not override existing environment variables -- [x] `tests/test_auction.py` (`.env` loader is for private use and intentionally not tested) -- Verify: `pytest` passes - -## Milestone 3 - CLI program (`milestone/03-cli`) - -- [ ] `main.py`: prompt name and numeric bid (re-prompt on invalid), bids dict kept outside the loop, "other bidders? yes/no", clear screen with newlines between bidders, announce the winner -- [ ] `tests/test_main.py` with monkeypatched `input` -- Verify: `pytest`; scripted run `python -m secret_auction.main` with two bidders - -## Milestone 4 - Documentation (`milestone/04-docs`) - -- [ ] `README.md` from the template (Overview, Requirements, Setup incl. creating/activating `.venv` and `python -m pip install --upgrade pip`, Run, Tests, License AGPL, Links) -- [ ] `docs/flowchart.md` (Mermaid plus link to the diagrams.net chart) -- [ ] Generate Doxygen docs if `doxygen` is installed -- Verify: README commands work verbatim; `.env` stays untracked +# Plan: Secret Auction Program (Day 9) + +## Context + +Udemy 100 Days of Code blind-auction CLI. The repository uses a Python `.gitignore`, an AGPL `LICENSE` and a git-ignored `.env` (tokens, never printed or committed). Python 3.13 or newer is required. + +## Working method + +Work on ONE milestone at a time. Each milestone is one branch (`milestone/NN-name`) and one pull request. When a milestone is finished, stop, verify it and review before starting the next. Milestones and issues are also tracked on the Gitea repository (via API, token from `.env`). + +## Milestone 1 - Project scaffold and repo metadata (`milestone/01-scaffold`) + +- [x] Create `src/secret_auction/__init__.py`, `tests/`, `docs/` +- [x] `pyproject.toml`: `requires-python >=3.13`, src layout, no runtime deps, `dev` extra (`pytest`), pytest config, console script +- [x] `.env.example`; `.env` and `.venv/` ignored; add `docs/doxygen/` to `.gitignore` +- [x] `constants.py` +- [x] `Doxyfile` +- [x] Set repository description and topics (`python`, `udemy`, `100-days-of-code`, `blind-auction`, `beginner`) via API +- [x] Create milestones and issues on the host (milestones M1-M4, issues #1-#12) +- [x] Verify: create `.venv`, upgrade pip, `pip install -e ".[dev]"` +- [x] Save plan and milestones in `docs/plan.md` + +## Milestone 2 - Core auction logic (`milestone/02-core-logic`) + +- [x] `auction.py` (Doxygen comments): `add_bid`, `find_winner` (uses `max(bids, key=bids.get)`, `ValueError` on empty) +- [x] `tests/test_auction.py` +- Verify: `pytest` passes + +## Milestone 3 - CLI program (`milestone/03-cli`) + +- [ ] `main.py`: prompt name and numeric bid (re-prompt on invalid), bids dict kept outside the loop, "other bidders? yes/no", clear screen with newlines between bidders, announce the winner +- [ ] `tests/test_main.py` with monkeypatched `input` +- Verify: `pytest`; scripted run `python -m secret_auction.main` with two bidders + +## Milestone 4 - Documentation (`milestone/04-docs`) + +- [ ] `README.md` from the template (Overview, Requirements, Setup incl. creating/activating `.venv` and `python -m pip install --upgrade pip`, Run, Tests, License AGPL, Links) +- [ ] `docs/flowchart.md` (Mermaid plus link to the diagrams.net chart) +- [ ] Generate Doxygen docs if `doxygen` is installed +- Verify: README commands work verbatim; `.env` stays untracked diff --git a/src/secret_auction/constants.py b/src/secret_auction/constants.py index f199f24..437357a 100644 --- a/src/secret_auction/constants.py +++ b/src/secret_auction/constants.py @@ -1,39 +1,33 @@ -"""! @file constants.py -@brief Constants used by the Secret Auction program. -""" - -## Number of blank lines printed to hide previous bidders' entries. -CLEAR_SCREEN_LINES = 100 - -## Answers accepted as "yes" when asking for more bidders. -YES_ANSWERS = ("yes", "y") - -## Answers accepted as "no" when asking for more bidders. -NO_ANSWERS = ("no", "n") - -## Path of the file holding environment variables (tokens). -ENV_FILE = ".env" - -## Name of the environment variable holding the Gitea token. -GITEA_TOKEN_ENV_VAR = "GITEA_TOKEN" - -## Prompt asking for the bidder's name. -PROMPT_NAME = "What is your name? " - -## Prompt asking for the bid amount. -PROMPT_BID = "What is your bid? $" - -## Prompt asking whether more bidders exist. -PROMPT_MORE_BIDDERS = "Are there any other bidders? Type 'yes' or 'no'. " - -## Message shown when the bid is not a valid number. -MSG_INVALID_BID = "Please enter a valid, non-negative number." - -## Message shown when the yes/no answer is not understood. -MSG_INVALID_ANSWER = "Please type 'yes' or 'no'." - -## Template for the winner announcement. -MSG_WINNER = "The winner is {name} with a bid of ${amount:.2f}." - -## Welcome banner. -MSG_WELCOME = "Welcome to the secret auction program." +"""! @file constants.py +@brief Constants used by the Secret Auction program. +""" + +## Number of blank lines printed to hide previous bidders' entries. +CLEAR_SCREEN_LINES = 100 + +## Answers accepted as "yes" when asking for more bidders. +YES_ANSWERS = ("yes", "y") + +## Answers accepted as "no" when asking for more bidders. +NO_ANSWERS = ("no", "n") + +## Prompt asking for the bidder's name. +PROMPT_NAME = "What is your name? " + +## Prompt asking for the bid amount. +PROMPT_BID = "What is your bid? $" + +## Prompt asking whether more bidders exist. +PROMPT_MORE_BIDDERS = "Are there any other bidders? Type 'yes' or 'no'. " + +## Message shown when the bid is not a valid number. +MSG_INVALID_BID = "Please enter a valid, non-negative number." + +## Message shown when the yes/no answer is not understood. +MSG_INVALID_ANSWER = "Please type 'yes' or 'no'." + +## Template for the winner announcement. +MSG_WINNER = "The winner is {name} with a bid of ${amount:.2f}." + +## Welcome banner. +MSG_WELCOME = "Welcome to the secret auction program." diff --git a/src/secret_auction/env.py b/src/secret_auction/env.py deleted file mode 100644 index e88c935..0000000 --- a/src/secret_auction/env.py +++ /dev/null @@ -1,38 +0,0 @@ -"""! @file env.py -@brief Minimal `.env` file loader using only the standard library. -""" - -import os -from pathlib import Path - - -def parse_env(text: str) -> dict[str, str]: - """! @brief Parse `KEY=VALUE` lines. - @param text Contents of a `.env` file. Blank lines and `#` comments are ignored. - @return Dictionary of parsed variables; surrounding quotes are stripped. - """ - values: dict[str, str] = {} - for line in text.splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - key, value = line.split("=", 1) - value = value.strip() - if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": - value = value[1:-1] - values[key.strip()] = value - return values - - -def load_env(path: str | Path) -> dict[str, str]: - """! @brief Load a `.env` file into `os.environ` without overriding existing variables. - @param path Path to the `.env` file; a missing file is not an error. - @return Dictionary of the variables read from the file. - """ - file = Path(path) - if not file.is_file(): - return {} - values = parse_env(file.read_text(encoding="utf-8")) - for key, value in values.items(): - os.environ.setdefault(key, value) - return values