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({})