diff --git a/task-1/output/clean_sales.csv b/task-1/output/clean_sales.csv new file mode 100644 index 0000000..be91924 --- /dev/null +++ b/task-1/output/clean_sales.csv @@ -0,0 +1,13 @@ +transaction_id,product_name,category,price,quantity,customer_email,date,revenue,vat +1,Laptop Pro,Electronics,999.99,2,alice@example.com,2024-03-15,1999.98,420.0 +2,Wireless Mouse,Electronics,29.99,5,bob@company.com,2024-03-15,149.95,31.49 +3,Usb Cable,Electronics,4.99,10,,2024-03-16,49.9,10.48 +4,Office Chair,Furniture,349.5,1,charlie@work.org,2024-03-16,349.5,73.39 +5,Standing Desk,Furniture,599.0,1,charlie@work.org,not_a_date,599.0,125.79 +9,Webcam Hd,Electronics,54.99,1,,2024-03-18,54.99,11.55 +10,Desk Lamp,Furniture,34.99,4,grace@university.edu,2024-03-19,139.96,29.39 +11,Noise Cancelling Headphones,Electronics,199.99,1,alice@example.com,2024-03-19,199.99,42.0 +12,Cable Management Kit,Furniture,15.99,6,henry@business.com,2024-03-20,95.94,20.15 +13,Ergonomic Mouse Pad,Furniture,24.99,3,ivan@email.com,2024-03-20,74.97,15.74 +14,Laptop Stand,Furniture,45.99,2,jenny@work.org,2024-03-21,91.98,19.32 +15,Bluetooth Speaker,Unknown,39.99,1,karl@startup.io,2024-03-21,39.99,8.4 diff --git a/task-1/src/__pycache__/__init__.cpython-311.pyc b/task-1/src/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..c725bb3 Binary files /dev/null and b/task-1/src/__pycache__/__init__.cpython-311.pyc differ diff --git a/task-1/src/__pycache__/config.cpython-311.pyc b/task-1/src/__pycache__/config.cpython-311.pyc new file mode 100644 index 0000000..95f21aa Binary files /dev/null and b/task-1/src/__pycache__/config.cpython-311.pyc differ diff --git a/task-1/src/__pycache__/models.cpython-311.pyc b/task-1/src/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..66797ff Binary files /dev/null and b/task-1/src/__pycache__/models.cpython-311.pyc differ diff --git a/task-1/src/__pycache__/pipeline.cpython-311.pyc b/task-1/src/__pycache__/pipeline.cpython-311.pyc new file mode 100644 index 0000000..7c61678 Binary files /dev/null and b/task-1/src/__pycache__/pipeline.cpython-311.pyc differ diff --git a/task-1/src/__pycache__/transforms.cpython-311.pyc b/task-1/src/__pycache__/transforms.cpython-311.pyc new file mode 100644 index 0000000..065ff15 Binary files /dev/null and b/task-1/src/__pycache__/transforms.cpython-311.pyc differ diff --git a/task-1/src/config.py b/task-1/src/config.py index e59f7c1..90e8a76 100644 --- a/task-1/src/config.py +++ b/task-1/src/config.py @@ -21,9 +21,13 @@ def _required(name: str) -> str: """Read an env var; fail loudly if missing.""" - # TODO 2: Read os.environ[name]; if not set, raise ValueError with a - # message that names the missing variable AND points at .env.example. - raise NotImplementedError("Implement _required: see TODO 2 in config.py") + try: + return os.environ[name] + except KeyError as error: + raise ValueError( + f"Missing required environment variable: {name}. " + "See .env.example for expected variable names." + ) from error # TODO 3: Replace the placeholder lines below by calling _required(...) for @@ -31,5 +35,5 @@ def _required(name: str) -> str: # module by the rest of the pipeline as a relative import # (`from .config import INPUT_PATH, ...`), since the pipeline runs as # `python -m src.pipeline`. -INPUT_PATH: str = "" # TODO: _required("INPUT_PATH") -OUTPUT_PATH: str = "" # TODO: _required("OUTPUT_PATH") +INPUT_PATH: str = _required("INPUT_PATH") +OUTPUT_PATH: str = _required("OUTPUT_PATH") diff --git a/task-1/src/models.py b/task-1/src/models.py index 865b2b1..785eeaf 100644 --- a/task-1/src/models.py +++ b/task-1/src/models.py @@ -10,24 +10,21 @@ """ from dataclasses import dataclass - -# TODO 1: Define a @dataclass called Transaction with these fields: -# transaction_id: int -# product_name: str -# category: str -# price: float -# quantity: int -# customer_email: str -# date: str -# revenue: float = 0.0 -# vat: float = 0.0 -# -# TODO 2: Add __post_init__ that raises ValueError when: -# - self.price < 0 (with a message naming the bad value) -# - not self.product_name.strip() (empty / whitespace-only product name) - - -# Replace this stub with your dataclass: @dataclass class Transaction: - transaction_id: int # TODO: replace this stub with the full field list above + transaction_id: int + product_name: str + category: str + price: float + quantity: int + customer_email: str + date: str + revenue: float = 0.0 + vat: float = 0.0 + + def __post_init__(self) -> None: + if self.price < 0: + raise ValueError(f"price must be >= 0, got {self.price}") + + if not self.product_name.strip(): + raise ValueError("product_name must not be empty") diff --git a/task-1/src/pipeline.py b/task-1/src/pipeline.py index adce136..41dd70c 100644 --- a/task-1/src/pipeline.py +++ b/task-1/src/pipeline.py @@ -31,14 +31,20 @@ def read_csv(path: str) -> list[dict]: """Read a CSV file into a list of dicts. I/O only — no business rules.""" - # TODO: implement using csv.DictReader. - raise NotImplementedError + with open(path, newline="", encoding="utf-8") as file: + reader = csv.DictReader(file) + return list(reader) def write_csv(rows: list[dict], path: str) -> None: """Write a list of dicts to CSV. I/O only — no business rules.""" - # TODO: implement using csv.DictWriter. - raise NotImplementedError + if not rows: + return + + with open(path, "w", newline="", encoding="utf-8") as file: + writer = csv.DictWriter(file, fieldnames=rows[0].keys()) + writer.writeheader() + writer.writerows(rows) def run() -> None: diff --git a/task-1/src/transforms.py b/task-1/src/transforms.py index e6bdd76..bb0368a 100644 --- a/task-1/src/transforms.py +++ b/task-1/src/transforms.py @@ -18,8 +18,12 @@ def remove_invalid(rows: list[dict]) -> list[dict]: Empty here means missing, "", or whitespace-only. """ - # TODO: implement. Return a new list, do not mutate `rows`. - raise NotImplementedError + return [ + {**row} + for row in rows + if row.get("product_name", "").strip() != "" + and float(row.get("price", 0)) >= 0 + ] def clean_fields(rows: list[dict]) -> list[dict]: @@ -31,8 +35,15 @@ def clean_fields(rows: list[dict]) -> list[dict]: Return a new list. Do not mutate the input rows. """ - # TODO: implement. - raise NotImplementedError + return [ + { + **row, + "product_name": row["product_name"].strip().title(), + "customer_email": row["customer_email"].strip().lower(), + "category": row.get("category", "").strip() or "Unknown", + } + for row in rows + ] def calculate_revenue(rows: list[dict], vat_rate: float = 0.21) -> list[dict]: @@ -40,11 +51,22 @@ def calculate_revenue(rows: list[dict], vat_rate: float = 0.21) -> list[dict]: Round both to 2 decimal places. Coerce price/quantity from string if needed. """ - # TODO: implement. - raise NotImplementedError + return [ + { + **row, + "price": float(row["price"]), + "quantity": int(row["quantity"]), + "revenue": round(float(row["price"]) * int(row["quantity"]), 2), + "vat": round(float(row["price"]) * int(row["quantity"]) * vat_rate, 2), + } + for row in rows + ] def filter_zero_quantity(rows: list[dict]) -> list[dict]: """Remove rows where quantity is 0.""" - # TODO: implement. - raise NotImplementedError + return [ + {**row} + for row in rows + if int(row["quantity"]) != 0 + ] diff --git a/task-1/tests/__pycache__/__init__.cpython-311.pyc b/task-1/tests/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..ff8c481 Binary files /dev/null and b/task-1/tests/__pycache__/__init__.cpython-311.pyc differ diff --git a/task-1/tests/__pycache__/test_transforms.cpython-311-pytest-9.0.3.pyc b/task-1/tests/__pycache__/test_transforms.cpython-311-pytest-9.0.3.pyc new file mode 100644 index 0000000..70019f5 Binary files /dev/null and b/task-1/tests/__pycache__/test_transforms.cpython-311-pytest-9.0.3.pyc differ diff --git a/task-1/tests/test_transforms.py b/task-1/tests/test_transforms.py index 8d6bd80..f7c86bc 100644 --- a/task-1/tests/test_transforms.py +++ b/task-1/tests/test_transforms.py @@ -11,30 +11,82 @@ from src.transforms import ( calculate_revenue, clean_fields, - filter_zero_quantity, remove_invalid, ) def test_remove_invalid_drops_empty_names(): - # TODO: feed in 3 rows (one valid, one empty product_name, one - # whitespace-only product_name); assert only the valid one survives. - raise NotImplementedError + rows = [ + {"product_name": "Laptop", "price": 100.0}, + {"product_name": "", "price": 50.0}, + {"product_name": " ", "price": 25.0}, + ] + result = remove_invalid(rows) -def test_clean_fields_normalizes_names(): - # TODO: feed a row with messy product_name and uppercase email; assert - # the output has stripped + title-cased name and lowercase email. - raise NotImplementedError + assert len(result) == 1 + assert result[0]["product_name"] == "Laptop" + +@pytest.mark.parametrize( + "raw_name, raw_email, expected_name, expected_email", + [ + (" laptop stand ", " USER@EXAMPLE.COM ", "Laptop Stand", "user@example.com"), + ("MOUSE", "CUSTOMER@MAIL.COM", "Mouse", "customer@mail.com"), + ], +) + +def test_clean_fields_normalizes_names( + raw_name: str, + raw_email: str, + expected_name: str, + expected_email: str, +): + rows = [ + { + "product_name": raw_name, + "customer_email": raw_email, + "category": "accessories", + } + ] + + result = clean_fields(rows) + + assert result[0]["product_name"] == expected_name + assert result[0]["customer_email"] == expected_email def test_calculate_revenue_adds_fields(): - # TODO: feed a row with price=100, quantity=3; assert output has - # revenue=300.0 and vat=63.0 (default VAT rate is 0.21). - raise NotImplementedError + rows = [ + { + "product_name": "Laptop", + "price": 100, + "quantity": 3, + } + ] + + result = calculate_revenue(rows) + + assert result[0]["revenue"] == 300.0 + assert result[0]["vat"] == 63.0 def test_no_mutation(): - # TODO: feed in a list, run any transform on it, assert the original - # list is unchanged. This is the most important test in the file. - raise NotImplementedError + rows = [ + { + "product_name": " laptop stand ", + "customer_email": " USER@EXAMPLE.COM ", + "category": "accessories", + } + ] + + original = [ + { + "product_name": " laptop stand ", + "customer_email": " USER@EXAMPLE.COM ", + "category": "accessories", + } + ] + + clean_fields(rows) + + assert rows == original diff --git a/task-2/AI_DEBUG.md b/task-2/AI_DEBUG.md index 83cad22..af21988 100644 --- a/task-2/AI_DEBUG.md +++ b/task-2/AI_DEBUG.md @@ -11,7 +11,24 @@ Aim for 100-200 words per section. Bullet points are fine. What went wrong? Paste the traceback or the wrong-output sample. Include the file and the line you were running when it broke. ``` -(paste here) +File: task-1/tests/test_transforms.py +Command I ran: +python -m pytest tests/ -v +Traceback / error output: +(.venv) pavel@Pavels-MacBook-Air task-1 % python -m pytest tests/ -v +============================================ test session starts ============================================= +platform darwin -- Python 3.11.15, pytest-9.0.3, pluggy-1.6.0 -- /Users/pavel/Desktop/data _learn/c55-data-week-2/.venv/bin/python +cachedir: .pytest_cache +rootdir: /Users/pavel/Desktop/data _learn/c55-data-week-2/task-1 +collected 0 items / 1 error +=================================================== ERRORS =================================================== +_________________________________ ERROR collecting tests/test_transforms.py __________________________________ +In test_clean_fields_normalizes_names: function uses no argument 'raw_name' +========================================== short test summary info =========================================== +ERROR tests/test_transforms.py - Failed: In test_clean_fields_normalizes_names: function uses no argument 'raw_name' +!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +============================================== 1 error in 0.06s ============================================== +I was trying to use @pytest.mark.parametrize for test_clean_fields_normalizes_names, but pytest failed before running any tests. The issue happened during test collection, not inside the transform function itself. ``` ## The Prompt @@ -19,7 +36,41 @@ What went wrong? Paste the traceback or the wrong-output sample. Include the fil What did you ask the AI? Paste the actual prompt verbatim. (Include the code or stack trace you pasted alongside it; do NOT include any real `.env` values, API keys, or PII — replace those with ``.) ``` -(paste here) +I am writing pytest tests for pure transform functions. +I get this error: +In test_clean_fields_normalizes_names: function uses no argument 'raw_name' +Here is my test code: + +import pytest + +from src.transforms import ( + calculate_revenue, + clean_fields, + remove_invalid, +) + +@pytest.mark.parametrize( + "raw_name, raw_email, expected_name, expected_email", + [ + (" laptop stand ", " USER@EXAMPLE.COM ", "Laptop Stand", "user@example.com"), + ("MOUSE", "CUSTOMER@MAIL.COM", "Mouse", "customer@mail.com"), + ], +) +def test_clean_fields_normalizes_names(): + rows = [ + { + "product_name": " laptop stand ", + "customer_email": " USER@EXAMPLE.COM ", + "category": "accessories", + } + ] + + result = clean_fields(rows) + + assert result[0]["product_name"] == "Laptop Stand" + assert result[0]["customer_email"] == "user@example.com" + +Why does pytest say that the function uses no argument 'raw_name' and how should I fix it? ``` ## The Solution @@ -27,11 +78,52 @@ What did you ask the AI? Paste the actual prompt verbatim. (Include the code or What did the AI suggest? Did it work on the first try? Did you have to follow up? Paste the final code change (a small diff is best). ``` -(paste here) +The AI explained that `@pytest.mark.parametrize` passes values into the test function as arguments. I had listed four argument names in the decorator: +```python +"raw_name, raw_email, expected_name, expected_email" + +but my test function did not accept those arguments: + +def test_clean_fields_normalizes_names(): + +So pytest could not inject the parametrized values. I decided not to remove parametrize, because this chapter specifically introduced it as a useful pytest pattern. Instead, I fixed the test by adding the same four arguments to the function signature and using them inside the test data and assertions. + + @pytest.mark.parametrize( + "raw_name, raw_email, expected_name, expected_email", + [ + (" laptop stand ", " USER@EXAMPLE.COM ", "Laptop Stand", "user@example.com"), + ("MOUSE", "CUSTOMER@MAIL.COM", "Mouse", "customer@mail.com"), + ], + ) +-def test_clean_fields_normalizes_names(): ++def test_clean_fields_normalizes_names( ++ raw_name: str, ++ raw_email: str, ++ expected_name: str, ++ expected_email: str, ++): + rows = [ + { +- "product_name": " laptop stand ", +- "customer_email": " USER@EXAMPLE.COM ", ++ "product_name": raw_name, ++ "customer_email": raw_email, + "category": "accessories", + } + ] + + result = clean_fields(rows) + +- assert result[0]["product_name"] == "Laptop Stand" +- assert result[0]["customer_email"] == "user@example.com" ++ assert result[0]["product_name"] == expected_name ++ assert result[0]["customer_email"] == expected_email + +This worked after the function signature matched the parameter names from the decorator. ``` ## Reflection Did you understand *why* the original code was broken before the AI told you? If not, what was the gap in your mental model? If you understood it before asking, why did you still ask the AI — speed, second opinion, or something else? -(write here, ~100 words) +I did not fully understand the error before asking the AI. I thought @pytest.mark.parametrize was mostly a way to attach several examples to one test, but I missed the exact mechanism: pytest calls the test function once per example and injects the values by matching the names from the decorator to the function arguments. My decorator declared raw_name, raw_email, expected_name, and expected_email, but the function accepted no arguments, so pytest failed during collection. The important lesson is that decorators are not comments; they change how the function is called. I kept parametrize because it made the test stronger and matched the chapter’s pytest pattern. diff --git a/task-3/assets/azure_blob_week2.png b/task-3/assets/azure_blob_week2.png new file mode 100644 index 0000000..6305b8e Binary files /dev/null and b/task-3/assets/azure_blob_week2.png differ diff --git a/task-3/assets/blob_url.txt b/task-3/assets/blob_url.txt new file mode 100644 index 0000000..b911719 --- /dev/null +++ b/task-3/assets/blob_url.txt @@ -0,0 +1 @@ +https://sthyfstudentsdemo.blob.core.windows.net/week2-pavel-tisner/clean_sales.csv