{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "22a93d13",
   "metadata": {},
   "source": [
    "# Packaging\n",
    "\n",
    "This notebook covers how Python modules are organized into packages, how to install third-party packages, and how to manage project dependencies.\n",
    "\n",
    "**Learning Goals**\n",
    "- Distinguish a module from a package\n",
    "- Understand the role of `__init__.py`\n",
    "- Import from a multi-module package\n",
    "- Install and inspect packages with `pip`\n",
    "- Record and restore dependencies with `requirements.txt`\n",
    "- Understand why virtual environments matter"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a35a547a",
   "metadata": {},
   "source": [
    "## Modules vs. Packages\n",
    "\n",
    "So far you have worked with **modules** — single `.py` files that hold reusable functions or classes. As a project grows, related modules are grouped into a **package**: a folder that Python treats as a single importable unit.\n",
    "\n",
    "| Concept | What it is | Example |\n",
    "|---|---|---|\n",
    "| Module | One `.py` file | `math_tools.py` |\n",
    "| Package | A folder containing modules + `__init__.py` | `my_package/` |\n",
    "| Library | A collection of packages distributed via `pip` | `requests`, `pandas` |\n",
    "\n",
    "The key ingredient that turns a plain folder into a package is the `__init__.py` file inside it."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "77912985",
   "metadata": {},
   "source": [
    "## Package Structure\n",
    "\n",
    "A minimal package looks like this:\n",
    "\n",
    "```\n",
    "my_package/\n",
    "    __init__.py       ← marks the folder as a package (can be empty)\n",
    "    math_tools.py     ← module 1\n",
    "    text_tools.py     ← module 2\n",
    "```\n",
    "\n",
    "`__init__.py` serves two purposes:\n",
    "1. It tells Python that the folder is a package, not just a directory.\n",
    "2. It can optionally re-export names so callers don't need to know which sub-module they come from.\n",
    "\n",
    "The code below creates this layout on disk so you can import and test it in subsequent cells:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5eaa0bad",
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "pkg = Path('my_package')\n",
    "pkg.mkdir(exist_ok=True)\n",
    "\n",
    "# __init__.py — re-exports key names for convenience\n",
    "(pkg / '__init__.py').write_text(\n",
    "    \"from .math_tools import add, square\\n\"\n",
    "    \"from .text_tools import shout\\n\",\n",
    "    encoding='utf-8'\n",
    ")\n",
    "\n",
    "# math_tools.py\n",
    "(pkg / 'math_tools.py').write_text(\n",
    "    \"def add(x, y):\\n\"\n",
    "    \"    return x + y\\n\\n\"\n",
    "    \"def square(x):\\n\"\n",
    "    \"    return x ** 2\\n\",\n",
    "    encoding='utf-8'\n",
    ")\n",
    "\n",
    "# text_tools.py\n",
    "(pkg / 'text_tools.py').write_text(\n",
    "    \"def shout(text):\\n\"\n",
    "    \"    return text.upper() + '!'\\n\",\n",
    "    encoding='utf-8'\n",
    ")\n",
    "\n",
    "print('Package written to:', pkg.resolve())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a41bbc25",
   "metadata": {},
   "source": [
    "## Importing from a Package\n",
    "\n",
    "Once the package folder is on the Python path, you can import from it using dot notation that mirrors the folder structure:\n",
    "\n",
    "```python\n",
    "import my_package                        # import the package itself\n",
    "from my_package import math_tools        # import a specific module\n",
    "from my_package.math_tools import add    # import a specific function\n",
    "from my_package import add               # works if __init__.py re-exports it\n",
    "```\n",
    "\n",
    "The dotted path always maps left-to-right onto the filesystem: `my_package.math_tools` → `my_package/math_tools.py`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "26437441",
   "metadata": {},
   "outputs": [],
   "source": [
    "import sys, importlib\n",
    "\n",
    "# make sure our local folder is on the path\n",
    "if '.' not in sys.path:\n",
    "    sys.path.insert(0, '.')\n",
    "\n",
    "# reload in case the package was already imported earlier in this session\n",
    "import my_package\n",
    "importlib.reload(my_package)\n",
    "\n",
    "# --- three import styles ---\n",
    "\n",
    "# 1. import via the package's __init__.py re-exports\n",
    "from my_package import add, square, shout\n",
    "print(add(3, 4))       # 7\n",
    "print(square(5))       # 25\n",
    "print(shout('hello'))  # HELLO!\n",
    "\n",
    "# 2. import a specific sub-module\n",
    "from my_package import math_tools\n",
    "print(math_tools.add(10, 20))   # 30\n",
    "\n",
    "# 3. import directly from the sub-module\n",
    "from my_package.text_tools import shout as yell\n",
    "print(yell('quiet'))  # QUIET!"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f64db6cc",
   "metadata": {},
   "outputs": [],
   "source": [
    "### Exercise: Extend the Package\n",
    "# Add a third module `list_tools.py` to my_package with a function `flatten(nested)`\n",
    "# that takes a list of lists and returns a single flat list.\n",
    "# Then import and call it here.\n",
    "### Your code starts here.\n",
    "\n",
    "\n",
    "### Your code ends here."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "15a936c0",
   "metadata": {},
   "outputs": [],
   "source": [
    "### Solution\n",
    "from pathlib import Path\n",
    "\n",
    "(Path('my_package') / 'list_tools.py').write_text(\n",
    "    \"def flatten(nested):\\n\"\n",
    "    \"    return [item for sublist in nested for item in sublist]\\n\",\n",
    "    encoding='utf-8'\n",
    ")\n",
    "\n",
    "from my_package.list_tools import flatten\n",
    "print(flatten([[1, 2], [3, 4], [5]]))   # [1, 2, 3, 4, 5]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1babaf23",
   "metadata": {},
   "source": [
    "## Installing Packages with `pip`\n",
    "\n",
    "Python's package installer, **`pip`**, downloads and installs packages from [PyPI](https://pypi.org) (the Python Package Index). The essential commands:\n",
    "\n",
    "| Command | What it does |\n",
    "|---|---|\n",
    "| `pip install requests` | Install a package |\n",
    "| `pip install requests==2.31.0` | Install a specific version |\n",
    "| `pip uninstall requests` | Remove a package |\n",
    "| `pip list` | Show all installed packages |\n",
    "| `pip show requests` | Details about one package (version, location, deps) |\n",
    "| `pip install --upgrade requests` | Upgrade to the latest version |\n",
    "\n",
    "In a notebook you can prefix any shell command with `!` to run it:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0b7f8ac7",
   "metadata": {},
   "outputs": [],
   "source": [
    "import subprocess, sys\n",
    "\n",
    "# show installed packages (first 10 lines)\n",
    "result = subprocess.run(\n",
    "    [sys.executable, '-m', 'pip', 'list'],\n",
    "    capture_output=True, text=True\n",
    ")\n",
    "lines = result.stdout.strip().splitlines()\n",
    "for line in lines[:12]:\n",
    "    print(line)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "319e1b02",
   "metadata": {},
   "outputs": [],
   "source": [
    "# show details for a specific package\n",
    "result = subprocess.run(\n",
    "    [sys.executable, '-m', 'pip', 'show', 'requests'],\n",
    "    capture_output=True, text=True\n",
    ")\n",
    "print(result.stdout if result.stdout else 'requests is not installed.')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b321e8b2",
   "metadata": {},
   "source": [
    "## `requirements.txt` and Reproducibility\n",
    "\n",
    "When you share a project, others need to know which packages to install. A `requirements.txt` file records the exact packages (and optionally their versions) so anyone can recreate your environment with one command:\n",
    "\n",
    "```\n",
    "pip install -r requirements.txt\n",
    "```\n",
    "\n",
    "**Generate it** from your current environment:\n",
    "```\n",
    "pip freeze > requirements.txt\n",
    "```\n",
    "\n",
    "**Minimal format** (version-pinned, recommended for sharing):\n",
    "```\n",
    "requests==2.31.0\n",
    "numpy==1.26.4\n",
    "pandas==2.2.1\n",
    "```\n",
    "\n",
    "**Loose format** (no versions, accepts any compatible release):\n",
    "```\n",
    "requests\n",
    "numpy\n",
    "pandas\n",
    "```\n",
    "\n",
    "Pin versions when reproducibility matters (data science projects, deployed apps). Use loose versions only for development experiments."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c8ec737d",
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "\n",
    "# write a minimal requirements.txt by hand\n",
    "reqs = Path('requirements_demo.txt')\n",
    "reqs.write_text(\n",
    "    \"requests\\n\"\n",
    "    \"numpy\\n\",\n",
    "    encoding='utf-8'\n",
    ")\n",
    "print(reqs.read_text())"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "df48f12c",
   "metadata": {},
   "source": [
    "## Virtual Environments\n",
    "\n",
    "A **virtual environment** is an isolated Python installation for a single project. It keeps each project's dependencies separate so installing a package for one project doesn't break another.\n",
    "\n",
    "```\n",
    "# Create a venv in the current project folder\n",
    "python -m venv .venv\n",
    "\n",
    "# Activate it (macOS/Linux)\n",
    "source .venv/bin/activate\n",
    "\n",
    "# Activate it (Windows)\n",
    ".venv\\Scripts\\activate\n",
    "\n",
    "# Deactivate when done\n",
    "deactivate\n",
    "```\n",
    "\n",
    "Once activated, `pip install` and `python` refer to the isolated environment, not your global Python. Your `requirements.txt` then captures only the packages needed for that project.\n",
    "\n",
    "**Rule of thumb**: one virtual environment per project, activated before any `pip install`."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ae7e2c3a",
   "metadata": {},
   "outputs": [],
   "source": [
    "import sys\n",
    "\n",
    "# show which Python executable is active — in a venv it will point inside .venv/\n",
    "print('Python executable:', sys.executable)\n",
    "print('Version          :', sys.version)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4a571a9c",
   "metadata": {},
   "outputs": [],
   "source": [
    "### Exercise: pip show\n",
    "# Use subprocess to run `pip show` on a package that IS installed in this environment\n",
    "# (e.g., 'pathlib', 'jupyter', or any package from pip list above).\n",
    "# Print the Name and Version lines only.\n",
    "### Your code starts here.\n",
    "\n",
    "\n",
    "### Your code ends here."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "28b68291",
   "metadata": {},
   "outputs": [],
   "source": [
    "### Solution\n",
    "import subprocess, sys\n",
    "\n",
    "result = subprocess.run(\n",
    "    [sys.executable, '-m', 'pip', 'show', 'pip'],\n",
    "    capture_output=True, text=True\n",
    ")\n",
    "for line in result.stdout.splitlines():\n",
    "    if line.startswith('Name') or line.startswith('Version'):\n",
    "        print(line)"
   ]
  }
 ],
 "metadata": {
  "language_info": {
   "name": "python"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
