diff --git a/absolute-vs-relative-python-imports/README.md b/absolute-vs-relative-python-imports/README.md new file mode 100644 index 0000000000..99e4f4cb6c --- /dev/null +++ b/absolute-vs-relative-python-imports/README.md @@ -0,0 +1,57 @@ +# Absolute vs Relative Imports in Python + +This folder provides the code examples for the Real Python tutorial [Absolute vs Relative Imports in Python](https://realpython.com/absolute-vs-relative-python-imports/). + +The code requires Python 3.3 or later, because `package1/` has no `__init__.py` file and relies on [namespace packages](https://peps.python.org/pep-0420/). + +## Example Project + +The tutorial's `project/` tree comes in two parallel copies so you can compare both import styles: + +- [`absolute/project/`](absolute/project/) uses absolute imports. +- [`relative/project/`](relative/project/) uses explicit relative imports. + +Both copies contain the same files: + +- `package1/module1.py`: Imports `function1` from `module2` and prints its result. +- `package1/module2.py`: Defines `function1()`. +- `package2/__init__.py`: Defines `class1`. +- `package2/module3.py`: Imports `class1` and `function2` and prints them. +- `package2/module4.py`: Part of the tutorial's tree, but empty because no example uses it. +- `package2/subpackage1/module5.py`: Defines `function2()`. +- `main.py`: Entry point that runs the imports. In `absolute/project/`, it also holds the tutorial's block of absolute import examples. + +Run the examples from inside a `project/` folder so that `package1` and `package2` are importable: + +```sh +$ cd absolute/project/ +$ python main.py +Absolute imports: +Hello from function1() in package1/module2.py + +Hello from function2() in package2/subpackage1/module5.py + +module1.main(): +Hello from function1() in package1/module2.py + +module3.main(): + +Hello from function2() in package2/subpackage1/module5.py +``` + +You can also run a single module with the `-m` switch, which works for both copies: + +```sh +$ cd relative/project/ +$ python -m package2.module3 + +Hello from function2() in package2/subpackage1/module5.py +``` + +Running a module as a file path, such as `python package1/module1.py`, fails in both copies. The relative version raises `ImportError: attempted relative import with no known parent package`. The absolute version raises `ModuleNotFoundError` because `project/` isn't on the import path. + +## Import Styling + +- [`import_style.py`](import_style.py): The tutorial's example of PEP 8 import grouping and ordering. + +This file is only a style reference and isn't meant to run. It imports third-party Flask packages and the placeholder modules `local_module` and `local_package`, which don't exist. A `# ruff: noqa: F401` comment at the top stops the linter from flagging the imports as unused. diff --git a/absolute-vs-relative-python-imports/absolute/project/main.py b/absolute-vs-relative-python-imports/absolute/project/main.py new file mode 100644 index 0000000000..5c2009acd2 --- /dev/null +++ b/absolute-vs-relative-python-imports/absolute/project/main.py @@ -0,0 +1,17 @@ +from package1 import module1 +from package1.module2 import function1 +from package2 import class1 +from package2.subpackage1.module5 import function2 + +# Also import module3 to run the absolute imports inside it +from package2 import module3 + +print("Absolute imports:") +print(function1()) +print(class1()) +print(function2()) + +print("\nmodule1.main():") +module1.main() +print("\nmodule3.main():") +module3.main() diff --git a/absolute-vs-relative-python-imports/absolute/project/package1/module1.py b/absolute-vs-relative-python-imports/absolute/project/package1/module1.py new file mode 100644 index 0000000000..ba5902463f --- /dev/null +++ b/absolute-vs-relative-python-imports/absolute/project/package1/module1.py @@ -0,0 +1,11 @@ +# package1/module1.py + +from package1.module2 import function1 + + +def main(): + print(function1()) + + +if __name__ == "__main__": + main() diff --git a/absolute-vs-relative-python-imports/absolute/project/package1/module2.py b/absolute-vs-relative-python-imports/absolute/project/package1/module2.py new file mode 100644 index 0000000000..12f5310c04 --- /dev/null +++ b/absolute-vs-relative-python-imports/absolute/project/package1/module2.py @@ -0,0 +1,2 @@ +def function1(): + return "Hello from function1() in package1/module2.py" diff --git a/absolute-vs-relative-python-imports/absolute/project/package2/__init__.py b/absolute-vs-relative-python-imports/absolute/project/package2/__init__.py new file mode 100644 index 0000000000..3a11167ade --- /dev/null +++ b/absolute-vs-relative-python-imports/absolute/project/package2/__init__.py @@ -0,0 +1,3 @@ +class class1: + def __repr__(self): + return "" diff --git a/absolute-vs-relative-python-imports/absolute/project/package2/module3.py b/absolute-vs-relative-python-imports/absolute/project/package2/module3.py new file mode 100644 index 0000000000..844c1ff2b8 --- /dev/null +++ b/absolute-vs-relative-python-imports/absolute/project/package2/module3.py @@ -0,0 +1,13 @@ +# package2/module3.py + +from package2 import class1 +from package2.subpackage1.module5 import function2 + + +def main(): + print(class1()) + print(function2()) + + +if __name__ == "__main__": + main() diff --git a/absolute-vs-relative-python-imports/absolute/project/package2/module4.py b/absolute-vs-relative-python-imports/absolute/project/package2/module4.py new file mode 100644 index 0000000000..35df88f631 --- /dev/null +++ b/absolute-vs-relative-python-imports/absolute/project/package2/module4.py @@ -0,0 +1 @@ +# The tutorial's tree includes this module, but none of the examples use it. diff --git a/absolute-vs-relative-python-imports/absolute/project/package2/subpackage1/module5.py b/absolute-vs-relative-python-imports/absolute/project/package2/subpackage1/module5.py new file mode 100644 index 0000000000..1b23838676 --- /dev/null +++ b/absolute-vs-relative-python-imports/absolute/project/package2/subpackage1/module5.py @@ -0,0 +1,2 @@ +def function2(): + return "Hello from function2() in package2/subpackage1/module5.py" diff --git a/absolute-vs-relative-python-imports/import_style.py b/absolute-vs-relative-python-imports/import_style.py new file mode 100644 index 0000000000..7eba0d60da --- /dev/null +++ b/absolute-vs-relative-python-imports/import_style.py @@ -0,0 +1,19 @@ +# ruff: noqa: F401 +"""Illustration of good import statement styling. + +Note that the imports come after the docstring. + +""" + +# Standard library imports +import datetime +import os + +# Third party imports +from flask import Flask +from flask_restful import Api +from flask_sqlalchemy import SQLAlchemy + +# Local application imports +from local_module import local_class +from local_package import local_function diff --git a/absolute-vs-relative-python-imports/relative/project/main.py b/absolute-vs-relative-python-imports/relative/project/main.py new file mode 100644 index 0000000000..602f1322a3 --- /dev/null +++ b/absolute-vs-relative-python-imports/relative/project/main.py @@ -0,0 +1,10 @@ +# Relative imports only work inside packages, so this top-level script +# imports the two packages' modules with absolute imports. The relative +# imports live in package1/module1.py and package2/module3.py. +from package1 import module1 +from package2 import module3 + +print("module1.main():") +module1.main() +print("\nmodule3.main():") +module3.main() diff --git a/absolute-vs-relative-python-imports/relative/project/package1/module1.py b/absolute-vs-relative-python-imports/relative/project/package1/module1.py new file mode 100644 index 0000000000..195b0707fb --- /dev/null +++ b/absolute-vs-relative-python-imports/relative/project/package1/module1.py @@ -0,0 +1,11 @@ +# package1/module1.py + +from .module2 import function1 + + +def main(): + print(function1()) + + +if __name__ == "__main__": + main() diff --git a/absolute-vs-relative-python-imports/relative/project/package1/module2.py b/absolute-vs-relative-python-imports/relative/project/package1/module2.py new file mode 100644 index 0000000000..12f5310c04 --- /dev/null +++ b/absolute-vs-relative-python-imports/relative/project/package1/module2.py @@ -0,0 +1,2 @@ +def function1(): + return "Hello from function1() in package1/module2.py" diff --git a/absolute-vs-relative-python-imports/relative/project/package2/__init__.py b/absolute-vs-relative-python-imports/relative/project/package2/__init__.py new file mode 100644 index 0000000000..3a11167ade --- /dev/null +++ b/absolute-vs-relative-python-imports/relative/project/package2/__init__.py @@ -0,0 +1,3 @@ +class class1: + def __repr__(self): + return "" diff --git a/absolute-vs-relative-python-imports/relative/project/package2/module3.py b/absolute-vs-relative-python-imports/relative/project/package2/module3.py new file mode 100644 index 0000000000..e7dc2b4800 --- /dev/null +++ b/absolute-vs-relative-python-imports/relative/project/package2/module3.py @@ -0,0 +1,13 @@ +# package2/module3.py + +from . import class1 +from .subpackage1.module5 import function2 + + +def main(): + print(class1()) + print(function2()) + + +if __name__ == "__main__": + main() diff --git a/absolute-vs-relative-python-imports/relative/project/package2/module4.py b/absolute-vs-relative-python-imports/relative/project/package2/module4.py new file mode 100644 index 0000000000..35df88f631 --- /dev/null +++ b/absolute-vs-relative-python-imports/relative/project/package2/module4.py @@ -0,0 +1 @@ +# The tutorial's tree includes this module, but none of the examples use it. diff --git a/absolute-vs-relative-python-imports/relative/project/package2/subpackage1/module5.py b/absolute-vs-relative-python-imports/relative/project/package2/subpackage1/module5.py new file mode 100644 index 0000000000..1b23838676 --- /dev/null +++ b/absolute-vs-relative-python-imports/relative/project/package2/subpackage1/module5.py @@ -0,0 +1,2 @@ +def function2(): + return "Hello from function2() in package2/subpackage1/module5.py" diff --git a/python-application-layouts/README.md b/python-application-layouts/README.md new file mode 100644 index 0000000000..63976292f8 --- /dev/null +++ b/python-application-layouts/README.md @@ -0,0 +1,43 @@ +# Python Project Structure: How to Choose the Right Layout + +This folder provides the code examples for the Real Python tutorial [Python Project Structure: How to Choose the Right Layout](https://realpython.com/python-application-layouts/). + +Each subfolder turns one of the tutorial's example directory trees into a small, working project that you can open, install, and run: + +| Folder | Project Type | Layout | Try It | +|--------------|-----------------|--------|--------------------------------------------------------| +| `statkit/` | Library | `src/` | `python -m pytest` | +| `weatherly/` | CLI application | Flat | `weatherly lisbon` or `python -m weatherly lisbon` | +| `portfolio/` | Django project | Flat | `python manage.py runserver` | +| `bloggy/` | Flask project | Flat | `flask --app bloggy run` | +| `bookapi/` | FastAPI project | Flat | `uvicorn bookapi.main:app` | +| `notely/` | GUI application | Flat | `notely` or `python -m notely` | + +The projects are minimal skeletons. They contain just enough code to make each tree real, and each one has its own `README.md` with details. + +## Installation + +The projects don't share a top-level `requirements.txt`. Instead, each project declares its own pinned dependencies in its `pyproject.toml`, just like a standalone repository would. Create a separate virtual environment for each project, change into its folder, and install it in editable mode along with its test dependencies: + +```console +$ cd statkit/ +$ python -m venv venv/ +$ source venv/bin/activate +(venv) $ python -m pip install -e ".[dev]" +(venv) $ python -m pytest +``` + +The same steps work for `weatherly/`, `bloggy/`, `bookapi/`, and `notely/`. The Django `portfolio/` project isn't meant to be installed, so you only install Django there: + +```console +(venv) $ python -m pip install django==6.1.2 +(venv) $ python manage.py check +``` + +## Notes + +- The `weatherly` CLI doesn't call any real weather API. Its only provider returns canned data, so it works offline. +- Folders that would otherwise be empty, such as `portfolio/static/` and `notely/notely/resources/translations/`, contain a `.gitkeep` file so that Git keeps them. +- The tests in `notely/` use Qt's `offscreen` platform, so they don't open any windows. + +The code requires Python 3.10 or later, and the Django project requires Python 3.12 or later because of Django 6.1. diff --git a/python-application-layouts/bloggy/README.md b/python-application-layouts/bloggy/README.md new file mode 100644 index 0000000000..6a19f2a5e9 --- /dev/null +++ b/python-application-layouts/bloggy/README.md @@ -0,0 +1,29 @@ +# bloggy + +A minimal Flask blog in a flat layout. The package's `__init__.py` holds the +application factory, `create_app()`, which registers the `auth` and `posts` +blueprints. Each blueprint has a matching subdirectory under `templates/`. + +## Installation + +```console +$ python -m pip install -e ".[dev]" +``` + +## Usage + +```console +$ flask --app bloggy init-db +Initialized the database. +$ flask --app bloggy run --debug +``` + +Then visit `http://127.0.0.1:5000/` and `http://127.0.0.1:5000/auth/login`. +Flask stores the SQLite database in the `instance/` folder, which it creates +next to the package. + +## Running the Tests + +```console +$ python -m pytest +``` diff --git a/python-application-layouts/bloggy/bloggy/__init__.py b/python-application-layouts/bloggy/bloggy/__init__.py new file mode 100644 index 0000000000..d667a4f9ce --- /dev/null +++ b/python-application-layouts/bloggy/bloggy/__init__.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from flask import Flask + + +def create_app(test_config=None): + """Create and configure the Flask app.""" + app = Flask(__name__, instance_relative_config=True) + app.config.from_mapping( + SECRET_KEY="dev", + DATABASE=str(Path(app.instance_path) / "bloggy.sqlite"), + ) + if test_config is None: + app.config.from_pyfile("config.py", silent=True) + else: + app.config.from_mapping(test_config) + + Path(app.instance_path).mkdir(parents=True, exist_ok=True) + + from bloggy import auth, db, posts + + db.init_app(app) + app.register_blueprint(auth.bp) + app.register_blueprint(posts.bp) + + return app diff --git a/python-application-layouts/bloggy/bloggy/auth.py b/python-application-layouts/bloggy/bloggy/auth.py new file mode 100644 index 0000000000..a48e64afdd --- /dev/null +++ b/python-application-layouts/bloggy/bloggy/auth.py @@ -0,0 +1,8 @@ +from flask import Blueprint, render_template + +bp = Blueprint("auth", __name__, url_prefix="/auth") + + +@bp.route("/login") +def login(): + return render_template("auth/login.html") diff --git a/python-application-layouts/bloggy/bloggy/db.py b/python-application-layouts/bloggy/bloggy/db.py new file mode 100644 index 0000000000..ab9f7fa6a2 --- /dev/null +++ b/python-application-layouts/bloggy/bloggy/db.py @@ -0,0 +1,43 @@ +import sqlite3 + +import click +from flask import current_app, g + +SCHEMA = """ +DROP TABLE IF EXISTS post; + +CREATE TABLE post ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + body TEXT NOT NULL +); +""" + + +def get_db(): + if "db" not in g: + g.db = sqlite3.connect(current_app.config["DATABASE"]) + g.db.row_factory = sqlite3.Row + return g.db + + +def close_db(exception=None): + db = g.pop("db", None) + if db is not None: + db.close() + + +def init_db(): + get_db().executescript(SCHEMA) + + +@click.command("init-db") +def init_db_command(): + """Clear the existing data and create new tables.""" + init_db() + click.echo("Initialized the database.") + + +def init_app(app): + app.teardown_appcontext(close_db) + app.cli.add_command(init_db_command) diff --git a/python-application-layouts/bloggy/bloggy/posts.py b/python-application-layouts/bloggy/bloggy/posts.py new file mode 100644 index 0000000000..50c83c4f42 --- /dev/null +++ b/python-application-layouts/bloggy/bloggy/posts.py @@ -0,0 +1,11 @@ +from flask import Blueprint, render_template + +from bloggy.db import get_db + +bp = Blueprint("posts", __name__) + + +@bp.route("/") +def index(): + posts = get_db().execute("SELECT title, body FROM post ORDER BY id DESC") + return render_template("posts/index.html", posts=posts.fetchall()) diff --git a/python-application-layouts/bloggy/bloggy/static/style.css b/python-application-layouts/bloggy/bloggy/static/style.css new file mode 100644 index 0000000000..22b69619e2 --- /dev/null +++ b/python-application-layouts/bloggy/bloggy/static/style.css @@ -0,0 +1,5 @@ +body { + font-family: sans-serif; + max-width: 40rem; + margin: 0 auto; +} diff --git a/python-application-layouts/bloggy/bloggy/templates/auth/login.html b/python-application-layouts/bloggy/bloggy/templates/auth/login.html new file mode 100644 index 0000000000..6f77b1859a --- /dev/null +++ b/python-application-layouts/bloggy/bloggy/templates/auth/login.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Log In{% endblock %} + +{% block content %} +

Log In

+
+ + + + + +
+{% endblock %} diff --git a/python-application-layouts/bloggy/bloggy/templates/base.html b/python-application-layouts/bloggy/bloggy/templates/base.html new file mode 100644 index 0000000000..c795b9e940 --- /dev/null +++ b/python-application-layouts/bloggy/bloggy/templates/base.html @@ -0,0 +1,15 @@ + + + + + {% block title %}{% endblock %} | Bloggy + + + + +
{% block content %}{% endblock %}
+ + diff --git a/python-application-layouts/bloggy/bloggy/templates/posts/index.html b/python-application-layouts/bloggy/bloggy/templates/posts/index.html new file mode 100644 index 0000000000..1312ae671a --- /dev/null +++ b/python-application-layouts/bloggy/bloggy/templates/posts/index.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Posts{% endblock %} + +{% block content %} +

Posts

+ {% for post in posts %} +
+

{{ post["title"] }}

+

{{ post["body"] }}

+
+ {% else %} +

No posts yet.

+ {% endfor %} +{% endblock %} diff --git a/python-application-layouts/bloggy/pyproject.toml b/python-application-layouts/bloggy/pyproject.toml new file mode 100644 index 0000000000..b356626f1d --- /dev/null +++ b/python-application-layouts/bloggy/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "bloggy" +version = "1.0.0" +description = "A personal blog built with Flask." +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["flask==3.1.3"] + +[project.optional-dependencies] +dev = ["pytest==9.1.1"] + +[tool.setuptools.package-data] +bloggy = ["static/*", "templates/**/*"] diff --git a/python-application-layouts/bloggy/tests/conftest.py b/python-application-layouts/bloggy/tests/conftest.py new file mode 100644 index 0000000000..e60d9cd2b9 --- /dev/null +++ b/python-application-layouts/bloggy/tests/conftest.py @@ -0,0 +1,19 @@ +import pytest + +from bloggy import create_app +from bloggy.db import init_db + + +@pytest.fixture +def app(tmp_path): + app = create_app( + {"TESTING": True, "DATABASE": str(tmp_path / "test.sqlite")} + ) + with app.app_context(): + init_db() + return app + + +@pytest.fixture +def client(app): + return app.test_client() diff --git a/python-application-layouts/bloggy/tests/test_bloggy.py b/python-application-layouts/bloggy/tests/test_bloggy.py new file mode 100644 index 0000000000..4aa58b2535 --- /dev/null +++ b/python-application-layouts/bloggy/tests/test_bloggy.py @@ -0,0 +1,24 @@ +from bloggy.db import get_db + + +def test_index_without_posts(client): + response = client.get("/") + assert response.status_code == 200 + assert b"No posts yet." in response.data + + +def test_index_lists_posts(app, client): + with app.app_context(): + db = get_db() + db.execute( + "INSERT INTO post (title, body) VALUES (?, ?)", + ("Hello, Flask", "My first post."), + ) + db.commit() + assert b"Hello, Flask" in client.get("/").data + + +def test_login_page(client): + response = client.get("/auth/login") + assert response.status_code == 200 + assert b"

Log In

" in response.data diff --git a/python-application-layouts/bookapi/README.md b/python-application-layouts/bookapi/README.md new file mode 100644 index 0000000000..0afd060f15 --- /dev/null +++ b/python-application-layouts/bookapi/README.md @@ -0,0 +1,37 @@ +# bookapi + +A minimal book-catalog API built with FastAPI in a flat layout, following the +shape of FastAPI's bigger-applications tutorial. `main.py` builds the app and +includes the routers from `routers/` and the admin-only router from +`internal/`. The "database" in `dependencies.py` is an in-memory dictionary. + +## Installation + +```console +$ python -m pip install -e ".[dev]" +``` + +## Usage + +There's no `__main__.py`. Instead, point an ASGI server at `bookapi.main:app`: + +```console +$ uvicorn bookapi.main:app --reload +``` + +Then try the endpoints: + +```console +$ curl http://127.0.0.1:8000/books/1 +{"id":1,"title":"A Wizard of Earthsea","author_id":1} +$ curl -X POST -H "X-Token: fake-super-secret-token" http://127.0.0.1:8000/admin/ +{"message":"Catalog updated"} +``` + +You can also explore the interactive docs at `http://127.0.0.1:8000/docs`. + +## Running the Tests + +```console +$ python -m pytest +``` diff --git a/python-application-layouts/bookapi/bookapi/__init__.py b/python-application-layouts/bookapi/bookapi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/bookapi/bookapi/dependencies.py b/python-application-layouts/bookapi/bookapi/dependencies.py new file mode 100644 index 0000000000..75858dbd44 --- /dev/null +++ b/python-application-layouts/bookapi/bookapi/dependencies.py @@ -0,0 +1,26 @@ +from typing import Annotated + +from fastapi import Header, HTTPException + +# In-memory stand-in for a real database. +FAKE_DB = { + "authors": { + 1: {"id": 1, "name": "Ursula K. Le Guin"}, + 2: {"id": 2, "name": "Terry Pratchett"}, + }, + "books": { + 1: {"id": 1, "title": "A Wizard of Earthsea", "author_id": 1}, + 2: {"id": 2, "title": "Guards! Guards!", "author_id": 2}, + }, +} + + +def get_db(): + """Provide a database session to path operations.""" + yield FAKE_DB + + +async def get_token_header(x_token: Annotated[str, Header()]): + """Allow only requests that send the expected admin token.""" + if x_token != "fake-super-secret-token": + raise HTTPException(status_code=400, detail="X-Token header invalid") diff --git a/python-application-layouts/bookapi/bookapi/internal/__init__.py b/python-application-layouts/bookapi/bookapi/internal/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/bookapi/bookapi/internal/admin.py b/python-application-layouts/bookapi/bookapi/internal/admin.py new file mode 100644 index 0000000000..78a0db93c0 --- /dev/null +++ b/python-application-layouts/bookapi/bookapi/internal/admin.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +router = APIRouter() + + +@router.post("/") +async def update_catalog(): + return {"message": "Catalog updated"} diff --git a/python-application-layouts/bookapi/bookapi/main.py b/python-application-layouts/bookapi/bookapi/main.py new file mode 100644 index 0000000000..bb84bde075 --- /dev/null +++ b/python-application-layouts/bookapi/bookapi/main.py @@ -0,0 +1,21 @@ +from fastapi import Depends, FastAPI + +from .dependencies import get_token_header +from .internal import admin +from .routers import authors, books + +app = FastAPI(title="Book Catalog API") + +app.include_router(authors.router) +app.include_router(books.router) +app.include_router( + admin.router, + prefix="/admin", + tags=["admin"], + dependencies=[Depends(get_token_header)], +) + + +@app.get("/") +async def root(): + return {"message": "Welcome to the book catalog!"} diff --git a/python-application-layouts/bookapi/bookapi/routers/__init__.py b/python-application-layouts/bookapi/bookapi/routers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/bookapi/bookapi/routers/authors.py b/python-application-layouts/bookapi/bookapi/routers/authors.py new file mode 100644 index 0000000000..45eff05822 --- /dev/null +++ b/python-application-layouts/bookapi/bookapi/routers/authors.py @@ -0,0 +1,19 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from ..dependencies import get_db + +router = APIRouter(prefix="/authors", tags=["authors"]) + + +@router.get("/") +async def read_authors(db: Annotated[dict, Depends(get_db)]): + return list(db["authors"].values()) + + +@router.get("/{author_id}") +async def read_author(author_id: int, db: Annotated[dict, Depends(get_db)]): + if author_id not in db["authors"]: + raise HTTPException(status_code=404, detail="Author not found") + return db["authors"][author_id] diff --git a/python-application-layouts/bookapi/bookapi/routers/books.py b/python-application-layouts/bookapi/bookapi/routers/books.py new file mode 100644 index 0000000000..dcd7b2773c --- /dev/null +++ b/python-application-layouts/bookapi/bookapi/routers/books.py @@ -0,0 +1,19 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException + +from ..dependencies import get_db + +router = APIRouter(prefix="/books", tags=["books"]) + + +@router.get("/") +async def read_books(db: Annotated[dict, Depends(get_db)]): + return list(db["books"].values()) + + +@router.get("/{book_id}") +async def read_book(book_id: int, db: Annotated[dict, Depends(get_db)]): + if book_id not in db["books"]: + raise HTTPException(status_code=404, detail="Book not found") + return db["books"][book_id] diff --git a/python-application-layouts/bookapi/pyproject.toml b/python-application-layouts/bookapi/pyproject.toml new file mode 100644 index 0000000000..f283dfb3bc --- /dev/null +++ b/python-application-layouts/bookapi/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "bookapi" +version = "1.0.0" +description = "A book-catalog API built with FastAPI." +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["fastapi==0.149.0", "uvicorn==0.54.0"] + +[project.optional-dependencies] +dev = ["httpx2==2.14.0", "pytest==9.1.1"] diff --git a/python-application-layouts/bookapi/tests/test_main.py b/python-application-layouts/bookapi/tests/test_main.py new file mode 100644 index 0000000000..3807c3d7b7 --- /dev/null +++ b/python-application-layouts/bookapi/tests/test_main.py @@ -0,0 +1,26 @@ +from fastapi.testclient import TestClient + +from bookapi.main import app + +client = TestClient(app) + + +def test_read_books(): + response = client.get("/books/") + assert response.status_code == 200 + assert response.json()[0]["title"] == "A Wizard of Earthsea" + + +def test_read_missing_author(): + response = client.get("/authors/99") + assert response.status_code == 404 + + +def test_admin_requires_token(): + assert ( + client.post("/admin/", headers={"X-Token": "wrong"}).status_code == 400 + ) + response = client.post( + "/admin/", headers={"X-Token": "fake-super-secret-token"} + ) + assert response.status_code == 200 diff --git a/python-application-layouts/notely/README.md b/python-application-layouts/notely/README.md new file mode 100644 index 0000000000..54048df8eb --- /dev/null +++ b/python-application-layouts/notely/README.md @@ -0,0 +1,38 @@ +# notely + +A minimal desktop notes app built with PySide6 in a flat layout, split along +the Model-View-Controller (MVC) pattern: + +- `models/`: The `Note` data class and a `NoteStore` that persists notes to a + JSON file in your home folder +- `views/`: The `MainWindow`, which loads its widgets from the Qt Designer file + `views/ui/main_window.ui` +- `controllers/`: The `NoteController`, which handles input and keeps the view + and the model in sync +- `resources/`: The app icon and an empty `translations/` folder + +The app loads the `.ui` file and the icon with `importlib.resources`, and +the `[tool.setuptools.package-data]` table in `pyproject.toml` makes sure +both ship with the package. + +## Installation + +```console +$ python -m pip install -e ".[dev]" +``` + +## Usage + +```console +$ notely +``` + +You can also run `python -m notely`. + +## Running the Tests + +The tests use Qt's `offscreen` platform, so they don't open any windows: + +```console +$ python -m pytest +``` diff --git a/python-application-layouts/notely/notely/__init__.py b/python-application-layouts/notely/notely/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/notely/notely/__main__.py b/python-application-layouts/notely/notely/__main__.py new file mode 100644 index 0000000000..25a9e09f93 --- /dev/null +++ b/python-application-layouts/notely/notely/__main__.py @@ -0,0 +1,3 @@ +from notely.app import main + +main() diff --git a/python-application-layouts/notely/notely/app.py b/python-application-layouts/notely/notely/app.py new file mode 100644 index 0000000000..98616742f2 --- /dev/null +++ b/python-application-layouts/notely/notely/app.py @@ -0,0 +1,27 @@ +import sys +from pathlib import Path + +from PySide6.QtWidgets import QApplication + +from notely.controllers import NoteController +from notely.models import NoteStore +from notely.resources import app_icon +from notely.views import MainWindow + +DEFAULT_STORE_PATH = Path.home() / ".notely.json" + + +def create_main_window(store_path=DEFAULT_STORE_PATH): + """Wire the model, view, and controller together.""" + store = NoteStore(store_path) + window = MainWindow() + window.setWindowIcon(app_icon()) + window.controller = NoteController(store, window) + return window + + +def main(): + app = QApplication(sys.argv) + window = create_main_window() + window.show() + sys.exit(app.exec()) diff --git a/python-application-layouts/notely/notely/controllers/__init__.py b/python-application-layouts/notely/notely/controllers/__init__.py new file mode 100644 index 0000000000..b3c8c3f291 --- /dev/null +++ b/python-application-layouts/notely/notely/controllers/__init__.py @@ -0,0 +1,3 @@ +from notely.controllers.note_controller import NoteController + +__all__ = ["NoteController"] diff --git a/python-application-layouts/notely/notely/controllers/note_controller.py b/python-application-layouts/notely/notely/controllers/note_controller.py new file mode 100644 index 0000000000..cfd48b987d --- /dev/null +++ b/python-application-layouts/notely/notely/controllers/note_controller.py @@ -0,0 +1,33 @@ +class NoteController: + """Handle user input and keep the view and the model in sync.""" + + def __init__(self, store, window): + self.store = store + self.window = window + self.window.new_button.clicked.connect(self.new_note) + self.window.save_button.clicked.connect(self.save_notes) + self.window.notes_list.currentRowChanged.connect(self.show_note) + self.window.editor.textChanged.connect(self.update_body) + for note in self.store.notes: + self.window.notes_list.addItem(note.title) + + def current_note(self): + row = self.window.notes_list.currentRow() + return self.store.notes[row] if row >= 0 else None + + def new_note(self): + note = self.store.add(f"Note {len(self.store.notes) + 1}") + self.window.notes_list.addItem(note.title) + self.window.notes_list.setCurrentRow(len(self.store.notes) - 1) + + def show_note(self, row): + note = self.current_note() + self.window.editor.setPlainText(note.body if note else "") + + def update_body(self): + note = self.current_note() + if note is not None: + note.body = self.window.editor.toPlainText() + + def save_notes(self): + self.store.save() diff --git a/python-application-layouts/notely/notely/models/__init__.py b/python-application-layouts/notely/notely/models/__init__.py new file mode 100644 index 0000000000..35257bce78 --- /dev/null +++ b/python-application-layouts/notely/notely/models/__init__.py @@ -0,0 +1,3 @@ +from notely.models.note import Note, NoteStore + +__all__ = ["Note", "NoteStore"] diff --git a/python-application-layouts/notely/notely/models/note.py b/python-application-layouts/notely/notely/models/note.py new file mode 100644 index 0000000000..164d81d712 --- /dev/null +++ b/python-application-layouts/notely/notely/models/note.py @@ -0,0 +1,32 @@ +import json +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass +class Note: + title: str + body: str = "" + + +class NoteStore: + """Keep notes in memory and persist them to a JSON file.""" + + def __init__(self, path): + self.path = Path(path) + self.notes = self._load() + + def _load(self): + if not self.path.exists(): + return [] + data = json.loads(self.path.read_text(encoding="utf-8")) + return [Note(**item) for item in data] + + def add(self, title): + note = Note(title) + self.notes.append(note) + return note + + def save(self): + data = [asdict(note) for note in self.notes] + self.path.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/python-application-layouts/notely/notely/resources/__init__.py b/python-application-layouts/notely/notely/resources/__init__.py new file mode 100644 index 0000000000..73d23cd2f9 --- /dev/null +++ b/python-application-layouts/notely/notely/resources/__init__.py @@ -0,0 +1,9 @@ +from importlib.resources import as_file, files + +from PySide6.QtGui import QIcon + + +def app_icon(): + """Return the app icon that ships inside the package.""" + with as_file(files("notely.resources") / "icons" / "notely.svg") as path: + return QIcon(str(path)) diff --git a/python-application-layouts/notely/notely/resources/icons/notely.svg b/python-application-layouts/notely/notely/resources/icons/notely.svg new file mode 100644 index 0000000000..0f1da0100b --- /dev/null +++ b/python-application-layouts/notely/notely/resources/icons/notely.svg @@ -0,0 +1,4 @@ + + + + diff --git a/python-application-layouts/notely/notely/resources/translations/.gitkeep b/python-application-layouts/notely/notely/resources/translations/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/notely/notely/views/__init__.py b/python-application-layouts/notely/notely/views/__init__.py new file mode 100644 index 0000000000..20a864af3d --- /dev/null +++ b/python-application-layouts/notely/notely/views/__init__.py @@ -0,0 +1,3 @@ +from notely.views.main_window import MainWindow + +__all__ = ["MainWindow"] diff --git a/python-application-layouts/notely/notely/views/main_window.py b/python-application-layouts/notely/notely/views/main_window.py new file mode 100644 index 0000000000..531118d519 --- /dev/null +++ b/python-application-layouts/notely/notely/views/main_window.py @@ -0,0 +1,24 @@ +from importlib.resources import as_file, files + +from PySide6.QtUiTools import QUiLoader +from PySide6.QtWidgets import QMainWindow + + +def load_ui(name, parent=None): + """Load a Qt Designer .ui file that ships inside the package.""" + resource = files("notely.views") / "ui" / name + with as_file(resource) as path: + return QUiLoader().load(str(path), parent) + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.ui = load_ui("main_window.ui", self) + self.setCentralWidget(self.ui) + self.setWindowTitle("Notely") + self.resize(640, 400) + self.notes_list = self.ui.notesList + self.editor = self.ui.editor + self.new_button = self.ui.newButton + self.save_button = self.ui.saveButton diff --git a/python-application-layouts/notely/notely/views/ui/main_window.ui b/python-application-layouts/notely/notely/views/ui/main_window.ui new file mode 100644 index 0000000000..0b413c2d71 --- /dev/null +++ b/python-application-layouts/notely/notely/views/ui/main_window.ui @@ -0,0 +1,34 @@ + + + MainWidget + + + + + + + + + + + New Note + + + + + + + Save + + + + + + + + + + + + + diff --git a/python-application-layouts/notely/pyproject.toml b/python-application-layouts/notely/pyproject.toml new file mode 100644 index 0000000000..a86f26a2d1 --- /dev/null +++ b/python-application-layouts/notely/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "notely" +version = "1.0.0" +description = "A desktop notes app built with PySide6." +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["pyside6==6.12.0"] + +[project.optional-dependencies] +dev = ["pytest==9.1.1"] + +[project.scripts] +notely = "notely.app:main" + +[tool.setuptools.package-data] +notely = ["resources/**/*", "views/ui/*.ui"] diff --git a/python-application-layouts/notely/tests/conftest.py b/python-application-layouts/notely/tests/conftest.py new file mode 100644 index 0000000000..bdcc5e2421 --- /dev/null +++ b/python-application-layouts/notely/tests/conftest.py @@ -0,0 +1,12 @@ +import os + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + + +@pytest.fixture(scope="session") +def qapp(): + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) diff --git a/python-application-layouts/notely/tests/test_notely.py b/python-application-layouts/notely/tests/test_notely.py new file mode 100644 index 0000000000..69cac1a9d6 --- /dev/null +++ b/python-application-layouts/notely/tests/test_notely.py @@ -0,0 +1,20 @@ +from notely.app import create_main_window +from notely.models import NoteStore + + +def test_store_round_trip(tmp_path): + path = tmp_path / "notes.json" + store = NoteStore(path) + store.add("Groceries").body = "Eggs" + store.save() + assert NoteStore(path).notes[0].body == "Eggs" + + +def test_new_note_and_save(qapp, tmp_path): + path = tmp_path / "notes.json" + window = create_main_window(path) + window.new_button.click() + window.editor.setPlainText("Buy milk") + window.save_button.click() + assert window.notes_list.count() == 1 + assert NoteStore(path).notes[0].body == "Buy milk" diff --git a/python-application-layouts/portfolio/README.md b/python-application-layouts/portfolio/README.md new file mode 100644 index 0000000000..a4dfa81453 --- /dev/null +++ b/python-application-layouts/portfolio/README.md @@ -0,0 +1,32 @@ +# portfolio + +A minimal Django project for a personal portfolio site in a flat layout. The +`portfolio/` package comes from `django-admin startproject`, and the +`blog`, `contact`, and `projects` apps live under `apps/`. Because of that, +each app's `AppConfig.name` and `INSTALLED_APPS` entry use the full dotted +path, such as `"apps.blog"`. + +## Installation + +```console +$ python -m pip install django==6.1.2 +``` + +The same pinned dependency appears in `pyproject.toml`, which this project +uses for metadata only. You run a Django project through `manage.py`, so +there's no need to install the project itself. + +## Usage + +```console +$ python manage.py check +$ python manage.py runserver +``` + +Then visit `http://localhost:8000/`, `/blog/`, and `/contact/`. + +## Running the Tests + +```console +$ python manage.py test +``` diff --git a/python-application-layouts/portfolio/apps/__init__.py b/python-application-layouts/portfolio/apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/portfolio/apps/blog/__init__.py b/python-application-layouts/portfolio/apps/blog/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/portfolio/apps/blog/apps.py b/python-application-layouts/portfolio/apps/blog/apps.py new file mode 100644 index 0000000000..d0fbcb9c34 --- /dev/null +++ b/python-application-layouts/portfolio/apps/blog/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + name = "apps.blog" diff --git a/python-application-layouts/portfolio/apps/blog/urls.py b/python-application-layouts/portfolio/apps/blog/urls.py new file mode 100644 index 0000000000..db82530dee --- /dev/null +++ b/python-application-layouts/portfolio/apps/blog/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from apps.blog import views + +app_name = "blog" +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/python-application-layouts/portfolio/apps/blog/views.py b/python-application-layouts/portfolio/apps/blog/views.py new file mode 100644 index 0000000000..4828802a92 --- /dev/null +++ b/python-application-layouts/portfolio/apps/blog/views.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def index(request): + return render(request, "base.html", {"title": "Blog"}) diff --git a/python-application-layouts/portfolio/apps/contact/__init__.py b/python-application-layouts/portfolio/apps/contact/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/portfolio/apps/contact/apps.py b/python-application-layouts/portfolio/apps/contact/apps.py new file mode 100644 index 0000000000..8f1603e84a --- /dev/null +++ b/python-application-layouts/portfolio/apps/contact/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ContactConfig(AppConfig): + name = "apps.contact" diff --git a/python-application-layouts/portfolio/apps/contact/urls.py b/python-application-layouts/portfolio/apps/contact/urls.py new file mode 100644 index 0000000000..0658ce52b9 --- /dev/null +++ b/python-application-layouts/portfolio/apps/contact/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from apps.contact import views + +app_name = "contact" +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/python-application-layouts/portfolio/apps/contact/views.py b/python-application-layouts/portfolio/apps/contact/views.py new file mode 100644 index 0000000000..eceec1a3b8 --- /dev/null +++ b/python-application-layouts/portfolio/apps/contact/views.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def index(request): + return render(request, "base.html", {"title": "Contact"}) diff --git a/python-application-layouts/portfolio/apps/projects/__init__.py b/python-application-layouts/portfolio/apps/projects/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/portfolio/apps/projects/apps.py b/python-application-layouts/portfolio/apps/projects/apps.py new file mode 100644 index 0000000000..09cd01d276 --- /dev/null +++ b/python-application-layouts/portfolio/apps/projects/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ProjectsConfig(AppConfig): + name = "apps.projects" diff --git a/python-application-layouts/portfolio/apps/projects/urls.py b/python-application-layouts/portfolio/apps/projects/urls.py new file mode 100644 index 0000000000..6a1057ed4a --- /dev/null +++ b/python-application-layouts/portfolio/apps/projects/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from apps.projects import views + +app_name = "projects" +urlpatterns = [ + path("", views.index, name="index"), +] diff --git a/python-application-layouts/portfolio/apps/projects/views.py b/python-application-layouts/portfolio/apps/projects/views.py new file mode 100644 index 0000000000..d4a0130b05 --- /dev/null +++ b/python-application-layouts/portfolio/apps/projects/views.py @@ -0,0 +1,5 @@ +from django.shortcuts import render + + +def index(request): + return render(request, "base.html", {"title": "Projects"}) diff --git a/python-application-layouts/portfolio/manage.py b/python-application-layouts/portfolio/manage.py new file mode 100755 index 0000000000..f710c8a34f --- /dev/null +++ b/python-application-layouts/portfolio/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" + +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portfolio.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/python-application-layouts/portfolio/portfolio/__init__.py b/python-application-layouts/portfolio/portfolio/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/portfolio/portfolio/asgi.py b/python-application-layouts/portfolio/portfolio/asgi.py new file mode 100644 index 0000000000..8de01e804d --- /dev/null +++ b/python-application-layouts/portfolio/portfolio/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for portfolio project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.1/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portfolio.settings") + +application = get_asgi_application() diff --git a/python-application-layouts/portfolio/portfolio/settings.py b/python-application-layouts/portfolio/portfolio/settings.py new file mode 100644 index 0000000000..9aff4e018c --- /dev/null +++ b/python-application-layouts/portfolio/portfolio/settings.py @@ -0,0 +1,92 @@ +"""Django settings for the portfolio project. + +Generated by 'django-admin startproject' using Django 6.1.2, then trimmed and +adjusted for the apps/, static/, and templates/ directories at the root. +""" + +import os +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + +# Don't use the fallback key in production. +SECRET_KEY = os.environ.get( + "DJANGO_SECRET_KEY", "django-insecure-change-me-before-deploying" +) + +DEBUG = os.environ.get("DJANGO_DEBUG", "1") == "1" + +ALLOWED_HOSTS = ["localhost", "127.0.0.1"] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "apps.blog", + "apps.contact", + "apps.projects", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "portfolio.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "portfolio.wsgi.application" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_TZ = True + +STATIC_URL = "static/" +STATICFILES_DIRS = [BASE_DIR / "static"] + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/python-application-layouts/portfolio/portfolio/urls.py b/python-application-layouts/portfolio/portfolio/urls.py new file mode 100644 index 0000000000..12826e9d98 --- /dev/null +++ b/python-application-layouts/portfolio/portfolio/urls.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("", include("apps.projects.urls")), + path("blog/", include("apps.blog.urls")), + path("contact/", include("apps.contact.urls")), +] diff --git a/python-application-layouts/portfolio/portfolio/wsgi.py b/python-application-layouts/portfolio/portfolio/wsgi.py new file mode 100644 index 0000000000..0abca996f0 --- /dev/null +++ b/python-application-layouts/portfolio/portfolio/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for portfolio project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "portfolio.settings") + +application = get_wsgi_application() diff --git a/python-application-layouts/portfolio/pyproject.toml b/python-application-layouts/portfolio/pyproject.toml new file mode 100644 index 0000000000..c6f463c210 --- /dev/null +++ b/python-application-layouts/portfolio/pyproject.toml @@ -0,0 +1,7 @@ +[project] +name = "portfolio" +version = "1.0.0" +description = "A personal portfolio site built with Django." +readme = "README.md" +requires-python = ">=3.12" +dependencies = ["django==6.1.2"] diff --git a/python-application-layouts/portfolio/static/.gitkeep b/python-application-layouts/portfolio/static/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/portfolio/templates/base.html b/python-application-layouts/portfolio/templates/base.html new file mode 100644 index 0000000000..228175e858 --- /dev/null +++ b/python-application-layouts/portfolio/templates/base.html @@ -0,0 +1,16 @@ + + + + + {{ title }} | Portfolio + + + +

{{ title }}

+ {% block content %}{% endblock content %} + + diff --git a/python-application-layouts/portfolio/tests/__init__.py b/python-application-layouts/portfolio/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/portfolio/tests/test_views.py b/python-application-layouts/portfolio/tests/test_views.py new file mode 100644 index 0000000000..bca416714a --- /dev/null +++ b/python-application-layouts/portfolio/tests/test_views.py @@ -0,0 +1,14 @@ +from django.test import SimpleTestCase +from django.urls import reverse + + +class PageTests(SimpleTestCase): + def test_pages_render(self): + for name, title in [ + ("projects:index", "Projects"), + ("blog:index", "Blog"), + ("contact:index", "Contact"), + ]: + with self.subTest(name=name): + response = self.client.get(reverse(name)) + self.assertContains(response, f"

{title}

") diff --git a/python-application-layouts/statkit/LICENSE b/python-application-layouts/statkit/LICENSE new file mode 100644 index 0000000000..a68ed9665b --- /dev/null +++ b/python-application-layouts/statkit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Real Python + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/python-application-layouts/statkit/README.md b/python-application-layouts/statkit/README.md new file mode 100644 index 0000000000..f989b78f83 --- /dev/null +++ b/python-application-layouts/statkit/README.md @@ -0,0 +1,34 @@ +# statkit + +A small statistics helper library in a `src/` layout. It provides `mean()`, +`median()`, `stdev()`, `pearson()`, and `spearman()` using only the standard +library. + +## Installation + +Because of the `src/` layout, you need to install the package before you can +import it: + +```console +$ python -m pip install -e ".[dev]" +``` + +## Usage + +```pycon +>>> from statkit import mean, median, pearson, spearman +>>> mean([1, 2, 3, 4]) +2.5 +>>> median([4, 1, 3, 2]) +2.5 +>>> pearson([1, 2, 3, 4, 5], [1, 4, 9, 16, 25]) +0.9811049102515929 +>>> spearman([1, 2, 3, 4, 5], [1, 4, 9, 16, 25]) +1.0 +``` + +## Running the Tests + +```console +$ python -m pytest +``` diff --git a/python-application-layouts/statkit/docs/index.md b/python-application-layouts/statkit/docs/index.md new file mode 100644 index 0000000000..baaa4e2558 --- /dev/null +++ b/python-application-layouts/statkit/docs/index.md @@ -0,0 +1,4 @@ +# statkit + +Placeholder for the library's documentation. Anything longer than the +`README.md` belongs here. diff --git a/python-application-layouts/statkit/pyproject.toml b/python-application-layouts/statkit/pyproject.toml new file mode 100644 index 0000000000..d83f613005 --- /dev/null +++ b/python-application-layouts/statkit/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "statkit" +version = "1.0.0" +description = "A small statistics helper library." +readme = "README.md" +requires-python = ">=3.10" + +[project.optional-dependencies] +dev = ["pytest==9.1.1"] + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/python-application-layouts/statkit/src/statkit/__init__.py b/python-application-layouts/statkit/src/statkit/__init__.py new file mode 100644 index 0000000000..088a07a96c --- /dev/null +++ b/python-application-layouts/statkit/src/statkit/__init__.py @@ -0,0 +1,4 @@ +from statkit.descriptive import mean, median, stdev +from statkit.correlation import pearson, spearman + +__all__ = ["mean", "median", "stdev", "pearson", "spearman"] diff --git a/python-application-layouts/statkit/src/statkit/correlation.py b/python-application-layouts/statkit/src/statkit/correlation.py new file mode 100644 index 0000000000..27ee2022ba --- /dev/null +++ b/python-application-layouts/statkit/src/statkit/correlation.py @@ -0,0 +1,45 @@ +import math +from collections.abc import Sequence + +from statkit.descriptive import mean + + +def pearson(x: Sequence[float], y: Sequence[float]) -> float: + """Return Pearson's correlation coefficient for x and y.""" + if len(x) != len(y): + raise ValueError("x and y must have the same length") + if len(x) < 2: + raise ValueError("pearson requires at least two data points") + x_mean, y_mean = mean(x), mean(y) + dx = [xi - x_mean for xi in x] + dy = [yi - y_mean for yi in y] + covariance = math.fsum(a * b for a, b in zip(dx, dy)) + spread = math.sqrt( + math.fsum(a * a for a in dx) * math.fsum(b * b for b in dy) + ) + if spread == 0: + raise ValueError("at least one input is constant") + return covariance / spread + + +def spearman(x: Sequence[float], y: Sequence[float]) -> float: + """Return Spearman's rank correlation coefficient for x and y.""" + return pearson(_ranks(x), _ranks(y)) + + +def _ranks(data: Sequence[float]) -> list[float]: + """Rank data from 1 upward, giving ties the average of their ranks.""" + order = sorted(range(len(data)), key=lambda i: data[i]) + ranks = [0.0] * len(data) + start = 0 + while start < len(order): + end = start + while ( + end + 1 < len(order) and data[order[end + 1]] == data[order[start]] + ): + end += 1 + average_rank = (start + end) / 2 + 1 + for position in range(start, end + 1): + ranks[order[position]] = average_rank + start = end + 1 + return ranks diff --git a/python-application-layouts/statkit/src/statkit/descriptive.py b/python-application-layouts/statkit/src/statkit/descriptive.py new file mode 100644 index 0000000000..0e99033de6 --- /dev/null +++ b/python-application-layouts/statkit/src/statkit/descriptive.py @@ -0,0 +1,29 @@ +import math +from collections.abc import Sequence + + +def mean(data: Sequence[float]) -> float: + """Return the arithmetic mean of data.""" + if not data: + raise ValueError("mean requires at least one data point") + return math.fsum(data) / len(data) + + +def median(data: Sequence[float]) -> float: + """Return the middle value of data, averaging the two middle values.""" + if not data: + raise ValueError("median requires at least one data point") + values = sorted(data) + middle = len(values) // 2 + if len(values) % 2 == 1: + return values[middle] + return (values[middle - 1] + values[middle]) / 2 + + +def stdev(data: Sequence[float]) -> float: + """Return the sample standard deviation of data.""" + if len(data) < 2: + raise ValueError("stdev requires at least two data points") + center = mean(data) + squares = math.fsum((x - center) ** 2 for x in data) + return math.sqrt(squares / (len(data) - 1)) diff --git a/python-application-layouts/statkit/src/statkit/py.typed b/python-application-layouts/statkit/src/statkit/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python-application-layouts/statkit/tests/test_correlation.py b/python-application-layouts/statkit/tests/test_correlation.py new file mode 100644 index 0000000000..75cc9a9286 --- /dev/null +++ b/python-application-layouts/statkit/tests/test_correlation.py @@ -0,0 +1,31 @@ +import math + +import pytest + +from statkit import pearson, spearman + + +def test_pearson_perfect_positive(): + assert math.isclose(pearson([1, 2, 3, 4], [2, 4, 6, 8]), 1.0) + + +def test_pearson_perfect_negative(): + assert math.isclose(pearson([1, 2, 3, 4], [8, 6, 4, 2]), -1.0) + + +def test_spearman_monotonic_but_not_linear(): + x = [1, 2, 3, 4, 5] + y = [1, 4, 9, 16, 25] + assert pearson(x, y) < 1.0 + assert math.isclose(spearman(x, y), 1.0) + + +def test_spearman_with_ties(): + assert math.isclose( + spearman([1, 2, 2, 3], [1, 2, 3, 4]), 0.9486832980505138 + ) + + +def test_mismatched_lengths_raise(): + with pytest.raises(ValueError): + pearson([1, 2, 3], [1, 2]) diff --git a/python-application-layouts/statkit/tests/test_descriptive.py b/python-application-layouts/statkit/tests/test_descriptive.py new file mode 100644 index 0000000000..be770de51a --- /dev/null +++ b/python-application-layouts/statkit/tests/test_descriptive.py @@ -0,0 +1,27 @@ +import math + +import pytest + +from statkit import mean, median, stdev + + +def test_mean(): + assert mean([1, 2, 3, 4]) == 2.5 + + +def test_median_odd_length(): + assert median([3, 1, 2]) == 2 + + +def test_median_even_length(): + assert median([4, 1, 3, 2]) == 2.5 + + +def test_stdev(): + assert math.isclose(stdev([2, 4, 4, 4, 5, 5, 7, 9]), 2.138089935299395) + + +@pytest.mark.parametrize("function", [mean, median, stdev]) +def test_empty_data_raises(function): + with pytest.raises(ValueError): + function([]) diff --git a/python-application-layouts/weatherly/README.md b/python-application-layouts/weatherly/README.md new file mode 100644 index 0000000000..6d7f06a595 --- /dev/null +++ b/python-application-layouts/weatherly/README.md @@ -0,0 +1,37 @@ +# weatherly + +A small weather report CLI in a flat layout. The `weatherly` command comes +from the `[project.scripts]` table in `pyproject.toml`, and `__main__.py` +enables `python -m weatherly`. + +**Note:** This example doesn't call any real weather API. The only provider, +`weatherly/sources/fake.py`, returns canned data so that the CLI runs offline +and without API keys. + +## Installation + +```console +$ python -m pip install -e ".[dev]" +``` + +## Usage + +```console +$ weatherly lisbon --days 2 +Forecast for Lisbon: + Monday: Sunny, 12°C to 21°C + Tuesday: Partly cloudy, 11°C to 19°C +``` + +Thanks to the flat layout, you can also run the package from the project +root without installing it: + +```console +$ python -m weatherly lisbon --days 2 +``` + +## Running the Tests + +```console +$ python -m pytest +``` diff --git a/python-application-layouts/weatherly/pyproject.toml b/python-application-layouts/weatherly/pyproject.toml new file mode 100644 index 0000000000..7636b1f7f1 --- /dev/null +++ b/python-application-layouts/weatherly/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[project] +name = "weatherly" +version = "1.0.0" +description = "A small weather report CLI." +readme = "README.md" +requires-python = ">=3.10" + +[project.optional-dependencies] +dev = ["pytest==9.1.1"] + +[project.scripts] +weatherly = "weatherly.cli:main" diff --git a/python-application-layouts/weatherly/tests/test_cli.py b/python-application-layouts/weatherly/tests/test_cli.py new file mode 100644 index 0000000000..aacda1f656 --- /dev/null +++ b/python-application-layouts/weatherly/tests/test_cli.py @@ -0,0 +1,14 @@ +import pytest + +from weatherly.cli import main + + +def test_main_prints_forecast(capsys): + main(["lisbon", "--days", "1"]) + assert "Forecast for Lisbon:" in capsys.readouterr().out + + +def test_main_exits_on_invalid_days(): + with pytest.raises(SystemExit) as error: + main(["lisbon", "--days", "0"]) + assert error.value.code == 1 diff --git a/python-application-layouts/weatherly/tests/test_forecast.py b/python-application-layouts/weatherly/tests/test_forecast.py new file mode 100644 index 0000000000..ba44dd7914 --- /dev/null +++ b/python-application-layouts/weatherly/tests/test_forecast.py @@ -0,0 +1,26 @@ +import pytest + +from weatherly.forecast import fetch_forecast, format_forecast + + +def test_fetch_forecast_returns_requested_days(): + forecast = fetch_forecast("berlin", days=2) + assert forecast["city"] == "Berlin" + assert len(forecast["days"]) == 2 + + +def test_fetch_forecast_rejects_unknown_source(): + with pytest.raises(LookupError): + fetch_forecast("berlin", source="nowhere") + + +def test_format_forecast(): + forecast = { + "city": "Berlin", + "days": [ + {"date": "Monday", "summary": "Sunny", "low": 12, "high": 21} + ], + } + assert format_forecast(forecast) == ( + "Forecast for Berlin:\n Monday: Sunny, 12°C to 21°C" + ) diff --git a/python-application-layouts/weatherly/weatherly/__init__.py b/python-application-layouts/weatherly/weatherly/__init__.py new file mode 100644 index 0000000000..5becc17c04 --- /dev/null +++ b/python-application-layouts/weatherly/weatherly/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/python-application-layouts/weatherly/weatherly/__main__.py b/python-application-layouts/weatherly/weatherly/__main__.py new file mode 100644 index 0000000000..00ee1c9fce --- /dev/null +++ b/python-application-layouts/weatherly/weatherly/__main__.py @@ -0,0 +1,3 @@ +from weatherly.cli import main + +main() diff --git a/python-application-layouts/weatherly/weatherly/cli.py b/python-application-layouts/weatherly/weatherly/cli.py new file mode 100644 index 0000000000..6e68c4056a --- /dev/null +++ b/python-application-layouts/weatherly/weatherly/cli.py @@ -0,0 +1,23 @@ +import argparse + +from weatherly.forecast import fetch_forecast, format_forecast + + +def main(argv=None): + parser = argparse.ArgumentParser( + prog="weatherly", description="Show the weather forecast for a city." + ) + parser.add_argument("city", help="name of the city") + parser.add_argument( + "-d", + "--days", + type=int, + default=3, + help="number of days to show (default: 3)", + ) + args = parser.parse_args(argv) + try: + forecast = fetch_forecast(args.city, days=args.days) + except LookupError as error: + parser.exit(1, f"weatherly: {error}\n") + print(format_forecast(forecast)) diff --git a/python-application-layouts/weatherly/weatherly/forecast.py b/python-application-layouts/weatherly/weatherly/forecast.py new file mode 100644 index 0000000000..c5ec57e37d --- /dev/null +++ b/python-application-layouts/weatherly/weatherly/forecast.py @@ -0,0 +1,18 @@ +from weatherly.sources import get_source + + +def fetch_forecast(city, days=3, source="fake"): + """Fetch a forecast for city from the given source.""" + client = get_source(source) + return client.forecast(city, days) + + +def format_forecast(forecast): + """Format a forecast as human-readable lines of text.""" + lines = [f"Forecast for {forecast['city']}:"] + for day in forecast["days"]: + lines.append( + f" {day['date']}: {day['summary']}, " + f"{day['low']}°C to {day['high']}°C" + ) + return "\n".join(lines) diff --git a/python-application-layouts/weatherly/weatherly/sources/__init__.py b/python-application-layouts/weatherly/weatherly/sources/__init__.py new file mode 100644 index 0000000000..15b35c41ec --- /dev/null +++ b/python-application-layouts/weatherly/weatherly/sources/__init__.py @@ -0,0 +1,11 @@ +from weatherly.sources.fake import FakeSource + +SOURCES = {"fake": FakeSource} + + +def get_source(name): + """Return an API client for the named weather provider.""" + try: + return SOURCES[name]() + except KeyError: + raise LookupError(f"unknown weather source: {name}") from None diff --git a/python-application-layouts/weatherly/weatherly/sources/fake.py b/python-application-layouts/weatherly/weatherly/sources/fake.py new file mode 100644 index 0000000000..3a2e79c7be --- /dev/null +++ b/python-application-layouts/weatherly/weatherly/sources/fake.py @@ -0,0 +1,20 @@ +"""Offline stand-in for a real weather API client. + +It returns canned data, so the CLI works without network access or API keys. +A real provider would live next to this module, for example, `openweather.py`. +""" + +CANNED_DAYS = [ + {"date": "Monday", "summary": "Sunny", "low": 12, "high": 21}, + {"date": "Tuesday", "summary": "Partly cloudy", "low": 11, "high": 19}, + {"date": "Wednesday", "summary": "Light rain", "low": 9, "high": 15}, + {"date": "Thursday", "summary": "Windy", "low": 8, "high": 14}, + {"date": "Friday", "summary": "Sunny", "low": 10, "high": 20}, +] + + +class FakeSource: + def forecast(self, city, days): + if not 1 <= days <= len(CANNED_DAYS): + raise LookupError(f"days must be between 1 and {len(CANNED_DAYS)}") + return {"city": city.title(), "days": CANNED_DAYS[:days]} diff --git a/python-memory-management/README.md b/python-memory-management/README.md new file mode 100644 index 0000000000..c1bfca4a7b --- /dev/null +++ b/python-memory-management/README.md @@ -0,0 +1,27 @@ +# Memory Management in Python + +This folder provides the code examples for the Real Python tutorial [Memory Management in Python](https://realpython.com/python-memory-management/). + +## Files + +- `refcount.py`: Runs the tutorial's three reference-counting snippets in order and prints `sys.getrefcount(numbers)` after each step. + +## Usage + +```sh +$ python refcount.py +numbers = [1, 2, 3] + sys.getrefcount(numbers) = 2 +more_numbers = numbers + sys.getrefcount(numbers) = 3 +total = sum(numbers) + sys.getrefcount(numbers) = 3 +matrix = [numbers, numbers, numbers] + sys.getrefcount(numbers) = 6 +``` + +Each printed value is one more than the reference count described in the tutorial, because passing `numbers` to `sys.getrefcount()` temporarily adds a reference. Starting with Python 3.14, this extra reference may not appear when you call `sys.getrefcount()` on a local variable inside a function, so run the code at module level, as the script does, to see the same numbers. + +## Requirements + +The code runs on Python 3.6 or later. diff --git a/python-memory-management/refcount.py b/python-memory-management/refcount.py new file mode 100644 index 0000000000..55a23ee12c --- /dev/null +++ b/python-memory-management/refcount.py @@ -0,0 +1,35 @@ +"""Watch the reference count of a list change, step by step. + +Each step runs one of the tutorial's snippets and then prints +sys.getrefcount(numbers). Passing the object to getrefcount() adds a +temporary reference, so the printed value is one more than the +reference count in the tutorial's comments. + +Keep this code at module level, as it is here. Starting with +Python 3.14, CPython can pass a function's local variables to +getrefcount() without adding that extra reference, so the same code +inside a function prints values that are one lower. +""" + +import sys + +numbers = [1, 2, 3] +# Reference count = 1 +print("numbers = [1, 2, 3]") +print(f" sys.getrefcount(numbers) = {sys.getrefcount(numbers)}") + +more_numbers = numbers +# Reference count = 2 +print("more_numbers = numbers") +print(f" sys.getrefcount(numbers) = {sys.getrefcount(numbers)}") + +total = sum(numbers) +# Passing numbers as an argument increases the reference count only +# while sum() runs. Once it returns, the reference count is 2 again. +print("total = sum(numbers)") +print(f" sys.getrefcount(numbers) = {sys.getrefcount(numbers)}") + +matrix = [numbers, numbers, numbers] +# The new list holds three more references: reference count = 5 +print("matrix = [numbers, numbers, numbers]") +print(f" sys.getrefcount(numbers) = {sys.getrefcount(numbers)}") diff --git a/python-thonny/README.md b/python-thonny/README.md new file mode 100644 index 0000000000..1d99747ebd --- /dev/null +++ b/python-thonny/README.md @@ -0,0 +1,42 @@ +# Thonny: The Beginner-Friendly Python Editor + +This folder provides the code examples for the Real Python tutorial [Thonny: The Beginner-Friendly Python Editor](https://realpython.com/python-thonny/). + +The tutorial is about [Thonny](https://thonny.org/), the Python editor. Thonny itself isn't listed in `requirements.txt` because it's the editor you use to open and run these files. The tutorial uses Thonny 5.0. + +## Installation + +The calculator examples use the third-party [`simplecalculator`](https://pypi.org/project/simplecalculator/) package. You can install it with Thonny's package manager (*Tools* > *Manage Packages...*), as shown in the tutorial, or from the command line: + +```console +$ python -m pip install -r requirements.txt +``` + +The code requires Python 3.6 or later. + +## Factorial + +- [`factorial.py`](factorial.py): A recursive factorial function that prints `factorial(3)`. Use it to try out running and stepping through code in Thonny. +- [`factorial_100.py`](factorial_100.py): The same program calling `factorial(100)`, which takes a long time to step through and is a good reason to use the stop button. + +```console +$ python factorial.py +6 +``` + +## Simple Calculator + +- [`calculations_v1.py`](calculations_v1.py): The first version of `calculations.py`, which uses `SimpleCalculator` to compute `2 * 2`. +- [`calculations.py`](calculations.py): The updated version with a bug in `create_add_string()`. Open it in Thonny and use the debugger to find the bug. +- [`calculations_fixed.py`](calculations_fixed.py): The same program with the bug fixed, so you can check your solution. + +```console +$ python calculations_v1.py +4.0 + +$ python calculations.py +0 + +$ python calculations_fixed.py +4.0 +``` diff --git a/python-thonny/calculations.py b/python-thonny/calculations.py new file mode 100644 index 0000000000..90a9098026 --- /dev/null +++ b/python-thonny/calculations.py @@ -0,0 +1,11 @@ +from calculator.simple import SimpleCalculator + + +def create_add_string(x, y): + """Returns a string containing an addition expression.""" + return "x + y" + + +my_calculator = SimpleCalculator() +my_calculator.run(create_add_string(2, 2)) +print(my_calculator.lcd) diff --git a/python-thonny/calculations_fixed.py b/python-thonny/calculations_fixed.py new file mode 100644 index 0000000000..c0cd9b6f74 --- /dev/null +++ b/python-thonny/calculations_fixed.py @@ -0,0 +1,11 @@ +from calculator.simple import SimpleCalculator + + +def create_add_string(x, y): + """Returns a string containing an addition expression.""" + return f"{x} + {y}" + + +my_calculator = SimpleCalculator() +my_calculator.run(create_add_string(2, 2)) +print(my_calculator.lcd) diff --git a/python-thonny/calculations_v1.py b/python-thonny/calculations_v1.py new file mode 100644 index 0000000000..a95a6459ed --- /dev/null +++ b/python-thonny/calculations_v1.py @@ -0,0 +1,5 @@ +from calculator.simple import SimpleCalculator + +my_calculator = SimpleCalculator() +my_calculator.run("2 * 2") +print(my_calculator.lcd) diff --git a/python-thonny/factorial.py b/python-thonny/factorial.py new file mode 100644 index 0000000000..9a76b219ba --- /dev/null +++ b/python-thonny/factorial.py @@ -0,0 +1,8 @@ +def factorial(num): + if num == 1: + return 1 + else: + return num * factorial(num - 1) + + +print(factorial(3)) diff --git a/python-thonny/factorial_100.py b/python-thonny/factorial_100.py new file mode 100644 index 0000000000..ba185d2e37 --- /dev/null +++ b/python-thonny/factorial_100.py @@ -0,0 +1,8 @@ +def factorial(num): + if num == 1: + return 1 + else: + return num * factorial(num - 1) + + +print(factorial(100)) diff --git a/python-thonny/requirements.txt b/python-thonny/requirements.txt new file mode 100644 index 0000000000..4f48e7bcaa --- /dev/null +++ b/python-thonny/requirements.txt @@ -0,0 +1 @@ +simplecalculator==0.0.4