diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..277a514 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +.PHONY: check lint format fix test typecheck clean + +lint: + uv run ruff check . + +format: + uv run ruff format . + +fix: + uv run ruff check . --fix + uv run ruff format . + +typecheck: + uv run mypy . + +test: + uv run pytest -v + +check: + uv run ruff check . + uv run ruff format --check . + uv run mypy . + uv run pytest -v + +clean: + find . -type d -name "__pycache__" -exec rm -rf {} + + find . -type d -name ".pytest_cache" -exec rm -rf {} + + find . -type d -name ".ruff_cache" -exec rm -rf {} + + find . -type d -name ".mypy_cache" -exec rm -rf {} + + find . -name "*.pyc" -delete diff --git a/pyproject.toml b/pyproject.toml index 029f156..b7d9ae2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ + "email-validator>=2.3.0", "pydantic>=2.13.4", "pytest>=9.1.1", ] diff --git a/python_fundamentals/exercises/day13_email_validation.py b/python_fundamentals/exercises/day13_email_validation.py new file mode 100644 index 0000000..eb8f139 --- /dev/null +++ b/python_fundamentals/exercises/day13_email_validation.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3.12 +# file_name: day13_email_validation.py +# branch: day13_pydantic_advance + +from pydantic import ( + BaseModel, + EmailStr, + Field, + ValidationError, + field_validator, +) + + +class Employee(BaseModel): + """Represents an employee within the organization.""" + + name: str = Field( + min_length=3, + max_length=20, + ) + email: EmailStr + salary: float = Field(gt=0) + + @field_validator("name") + @classmethod + def normalize_and_validate_name(cls, value: str) -> str: + """ + Normalize and validate an employee name. + + Rules: + - Remove leading/trailing whitespace. + - Convert to Title Case. + - Name must contain at least 3 characters. + - Name must contain alphabetic characters only. + """ + + value = value.strip().title() + + if len(value) < 3: + raise ValueError("Name must contain at least 3 characters.") + + if not value.replace(" ", "").isalpha(): + raise ValueError("Name must contain alphabetic characters only.") + + return value + + +def print_separator() -> None: + print("-" * 60) + + +if __name__ == "__main__": + # --------------------------------------------------------- + # Valid Employee + # --------------------------------------------------------- + + try: + valid_employee = Employee( + name=" tejas dixit ", + email="tejasdixit17@zohomail.in", + salary=25_000.99, + ) + + print("Valid Employee") + print(valid_employee.model_dump()) + + except ValidationError as error: + print(error) + + print_separator() + + # --------------------------------------------------------- + # Invalid Email + # --------------------------------------------------------- + + try: + invalid_employee_1: Employee = Employee( + name="Vignesh Gawali", + email="invalid-email", + salary=30_000, + ) + + except ValidationError as error: + print("Invalid Email") + print(error) + + print_separator() + + # --------------------------------------------------------- + # Invalid Name (Contains Digits) + # --------------------------------------------------------- + + try: + invalid_employee_2: Employee = Employee( + name="Tejas123", + email="tejas@example.com", + salary=30_000, + ) + + except ValidationError as error: + print("Invalid Name") + print(error) + + print_separator() + + # --------------------------------------------------------- + # Invalid Salary + # --------------------------------------------------------- + + try: + invalid_employee_3: Employee = Employee( + name="Rahul Sharma", + email="rahul@example.com", + salary=-5_000, + ) + + except ValidationError as error: + print("Invalid Salary") + print(error) diff --git a/python_fundamentals/exercises/inventory.py b/python_fundamentals/exercises/inventory.py index cf2fafe..23a01f6 100644 --- a/python_fundamentals/exercises/inventory.py +++ b/python_fundamentals/exercises/inventory.py @@ -58,7 +58,7 @@ def validate_product(product: Product) -> None: if not product.name.strip(): raise ValueError("Product name is required.") - if not isinstance(product.price, (int, float)): + if not isinstance(product.price, (float)): raise TypeError("Price must be numeric.") if product.price <= 0: diff --git a/python_fundamentals/notes/day13-email-str-and-field-validator.md b/python_fundamentals/notes/day13-email-str-and-field-validator.md new file mode 100644 index 0000000..2cdbd14 --- /dev/null +++ b/python_fundamentals/notes/day13-email-str-and-field-validator.md @@ -0,0 +1,291 @@ + +# Day 13 — Advanced Pydantic: EmailStr & `field_validator` + +## Learning Objectives + +By the end of this lesson I learned: + +* How to use specialized Pydantic types. +* Why `EmailStr` is better than `str` for email fields. +* How to write custom validation using `@field_validator`. +* How to normalize input before validation. +* How to keep business validation inside the model. +* How to test Pydantic validation using `pytest`. + +--- + +# Why Pydantic? + +Python type hints provide static information: + +```python +email: str +``` + +This only tells Python that the field is a string. + +Pydantic adds **runtime validation**. + +```python +email: EmailStr +``` + +Now the field must contain a valid email address. + +--- + +# Specialized Types + +Instead of using primitive types everywhere, Pydantic provides semantic types. + +Example: + +```python +from pydantic import EmailStr + +email: EmailStr +``` + +Benefits: + +* Validates email addresses. +* Produces meaningful validation errors. +* Makes the model self-documenting. + +--- + +# `Field()` + +`Field()` is used to declare validation rules. + +Example: + +```python +name: str = Field( + min_length=3, + max_length=20, +) + +salary: float = Field(gt=0) +``` + +Supported validations include: + +* `min_length` +* `max_length` +* `gt` +* `ge` +* `lt` +* `le` +* `default` +* `description` + +--- + +# `field_validator` + +`field_validator` allows custom validation that cannot be expressed using `Field()`. + +Example: + +```python +@field_validator("name") +@classmethod +def normalize_and_validate_name( + cls, + value: str, +) -> str: + ... +``` + +A validator can: + +* Validate values. +* Normalize values. +* Transform values. + +--- + +# Validation Pipeline + +When creating a model, validation occurs in this order: + +```text +Input + │ + ▼ +Type Conversion + │ + ▼ +Field() + │ + ▼ +field_validator() + │ + ▼ +Model Created +``` + +Understanding this order helps explain why validators receive values that have already been converted to the declared type. + +--- + +# Normalization Before Validation + +A common pattern is: + +```python +value = value.strip() +value = value.title() +``` + +Then validate: + +```python +if len(value) < 3: + raise ValueError(...) +``` + +Finally: + +```python +return value +``` + +Recommended order: + +```text +Normalize + ↓ +Validate + ↓ +Return +``` + +--- + +# Returning the Value + +Every validator **must** return the final value. + +Example: + +```python +return value +``` + +If the value is not returned, the model has nothing to store. + +--- + +# Raising Errors + +Inside a validator: + +```python +raise ValueError(...) +``` + +Outside the model: + +```python +ValidationError +``` + +Pydantic automatically converts `ValueError` into `ValidationError`. + +--- + +# Keeping Validation Inside the Model + +Instead of: + +```python +employee = Employee(...) + +if employee.salary < 0: + ... +``` + +Prefer: + +```python +class Employee(BaseModel): + ... +``` + +The model should enforce its own business rules. + +--- + +# Testing Validation + +Validation should be tested using `pytest`. + +Example: + +```python +def test_invalid_email() -> None: + with pytest.raises(ValidationError): + Employee( + name="Tejas Dixit", + email="invalid-email", + salary=25_000, + ) +``` + +Testing validation rules is more reliable than checking printed output. + +--- + +# Best Practices + +✔ Use semantic types (`EmailStr`) instead of plain strings. + +✔ Keep validation close to the data. + +✔ Normalize input before validating. + +✔ Return the validated value from every validator. + +✔ Raise `ValueError` inside validators. + +✔ Test validation using `pytest`. + +--- + +# Concepts Learned + +* `BaseModel` +* `EmailStr` +* `Field` +* `field_validator` +* `ValidationError` +* `model_dump()` + +--- + +# Key Takeaways + +* Type hints describe the expected type. +* Pydantic validates data at runtime. +* `Field()` handles common validation rules. +* `field_validator()` handles custom business rules. +* Models should protect their own invariants. +* Validation belongs inside the model, not throughout the application. + +--- + +# Summary + +Today I learned how to build self-validating models using Pydantic. + +I can now: + +* Create runtime-validated models. +* Use semantic types like `EmailStr`. +* Normalize incoming data. +* Implement custom validation with `field_validator`. +* Test validation logic using `pytest`. + +This completes the foundation required before learning **`model_validator`**, where validation involves relationships between multiple fields. diff --git a/python_fundamentals/tests/test_day13_email_validation.py b/python_fundamentals/tests/test_day13_email_validation.py new file mode 100644 index 0000000..502db54 --- /dev/null +++ b/python_fundamentals/tests/test_day13_email_validation.py @@ -0,0 +1,13 @@ +import pytest +from pydantic import ValidationError + +from python_fundamentals.exercises.day13_email_validation import Employee + + +def test_invalid_email() -> None: + with pytest.raises(ValidationError): + _: Employee = Employee( + name="tejas Dixit", + email="invalid-email", + salary=25_000, + ) diff --git a/python_fundamentals/tests/test_inventory.py b/python_fundamentals/tests/test_inventory.py index f3a6637..1d869f4 100644 --- a/python_fundamentals/tests/test_inventory.py +++ b/python_fundamentals/tests/test_inventory.py @@ -4,7 +4,7 @@ def test_invalid_product() -> None: - with pytest.raises(ValueError): + with pytest.raises(TypeError): validate_product( Product( name="laptop", diff --git a/uv.lock b/uv.lock index 1b911c0..a0b26e8 100644 --- a/uv.lock +++ b/uv.lock @@ -126,6 +126,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "filelock" version = "3.29.4" @@ -144,6 +166,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -158,6 +189,7 @@ name = "learning-lab" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "email-validator" }, { name = "pydantic" }, { name = "pytest" }, ] @@ -172,6 +204,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "email-validator", specifier = ">=2.3.0" }, { name = "pydantic", specifier = ">=2.13.4" }, { name = "pytest", specifier = ">=9.1.1" }, ]