diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..b0c5b01 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,52 @@ +# Architecture + +## Overview + +This is a minimal "Hello World" web application consisting of a single +static HTML page served by Python's built-in HTTP server. No external +frameworks, databases, or build tools are required. + +## File Structure + +``` +. +├── index.html # Static HTML page displaying "Hello World" +├── server.py # Python HTTP server (stdlib only) +├── Dockerfile # Container image definition +├── docker-compose.yml # One-command container orchestration +├── RUNNING.md # Instructions for running the application +├── ARCHITECTURE.md # This file – architecture documentation +└── tests/ + └── test_server.py # Automated tests for the server +``` + +## Technology Choices + +| Component | Choice | Rationale | +| -------------- | --------------------------------------- | ------------------------------------------------- | +| Web server | `http.server` (Python standard library) | Zero dependencies; ships with every Python 3 install | +| Containerisation | Docker + Docker Compose | Reproducible environment; single-command startup | +| Frontend | Plain HTML + inline CSS | No build step needed for a single static page | +| Testing | `unittest` (Python standard library) | No extra test runner required | + +## How It Works + +1. `server.py` starts a `TCPServer` on `0.0.0.0:8000` using + `SimpleHTTPRequestHandler` pointed at the current working directory. +2. When a browser requests `/`, the handler automatically serves + `index.html` as the directory index. +3. `index.html` renders a centred "Hello World" heading on a white + background. + +## Design Decisions + +* **No framework** – A full web framework (Flask, FastAPI, etc.) would + be overkill for serving a single static file. The standard library + provides everything needed. +* **Bind to `0.0.0.0`** – Ensures the server is reachable from outside + a Docker container (where `127.0.0.1` would not be accessible from + the host). +* **`allow_reuse_address = True`** – Prevents "Address already in use" + errors during quick restart cycles in development. +* **`functools.partial`** – Used to pass the `directory` argument to + `SimpleHTTPRequestHandler` cleanly, avoiding a custom subclass. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9d55c0b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY index.html server.py ./ + +EXPOSE 8000 + +CMD ["python", "server.py"] diff --git a/RUNNING.md b/RUNNING.md new file mode 100644 index 0000000..e888bc1 --- /dev/null +++ b/RUNNING.md @@ -0,0 +1,62 @@ +# Running the Hello World App + +## Prerequisites + +### With Docker + +* [Docker](https://docs.docker.com/get-docker/) (v20+ recommended) +* [Docker Compose](https://docs.docker.com/compose/install/) (v2+) + +### Without Docker + +* [Python 3](https://www.python.org/downloads/) (3.8 or later) + +## Quick Start (Docker) + +```bash +# 1. Clone the repository and enter the directory +git clone && cd + +# 2. Build and start the container +docker compose up --build -d + +# 3. Open in your browser +open http://localhost:8000 # macOS +# or visit http://localhost:8000 manually +``` + +## Quick Start (Without Docker) + +```bash +# 1. Clone the repository and enter the directory +git clone && cd + +# 2. Start the server +python server.py + +# 3. Open http://localhost:8000 in your browser +``` + +## Stop the App + +### Docker + +```bash +docker compose down +``` + +### Without Docker + +Press `Ctrl+C` in the terminal where `server.py` is running. + +## Running Tests + +```bash +python -m pytest tests/ -v +``` + +## What You Should See + +A white page with the text **Hello World** centred on the screen. + +> **Note:** No authentication or demo credentials are required. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a0982e2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + web: + build: . + ports: + - "8000:8000" + restart: unless-stopped diff --git a/index.html b/index.html new file mode 100644 index 0000000..1d5dad7 --- /dev/null +++ b/index.html @@ -0,0 +1,22 @@ + + + + + + Hello World + + + +

Hello World

+ + diff --git a/server.py b/server.py new file mode 100644 index 0000000..d532a4d --- /dev/null +++ b/server.py @@ -0,0 +1,39 @@ +"""Lightweight Python HTTP server that serves static files from the current directory. + +Uses only the Python standard library. Designed to be run directly or +imported and started programmatically via :func:`run_server`. +""" + +from __future__ import annotations + +import functools +import http.server +import socketserver +from typing import Optional + + +def run_server(port: int = 8000, directory: str = ".") -> None: + """Start an HTTP server serving *directory* on *port*. + + The server binds to ``0.0.0.0`` so it is reachable from outside a + Docker container. + + Args: + port: TCP port to listen on. Defaults to ``8000``. + directory: Filesystem directory to serve. Defaults to ``"."``. + """ + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, + directory=directory, + ) + + # Allow quick restart without waiting for TIME_WAIT to expire + socketserver.TCPServer.allow_reuse_address = True + + with socketserver.TCPServer(("0.0.0.0", port), handler) as httpd: + print(f"Serving on http://0.0.0.0:{port}") + httpd.serve_forever() + + +if __name__ == "__main__": + run_server() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..c0e325e --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,214 @@ +"""Automated tests for server.py and index.html. + +Starts the HTTP server in a background thread, verifies that it serves +the expected content, and tears it down after the test suite completes. +""" + +from __future__ import annotations + +import functools +import http.server +import os +import socketserver +import threading +import time +import unittest +import urllib.error +import urllib.request +from typing import Optional + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _find_project_root() -> str: + """Return the absolute path to the project root directory. + + Walks upward from this file's directory until it finds ``index.html`` + and ``server.py`` side-by-side. + """ + current = os.path.dirname(os.path.abspath(__file__)) + # Go up one level (from tests/ to project root) + root = os.path.dirname(current) + if os.path.isfile(os.path.join(root, "index.html")) and os.path.isfile( + os.path.join(root, "server.py") + ): + return root + # Fallback: current working directory + return os.getcwd() + + +class _ServerThread: + """Context manager that runs an HTTP server in a daemon thread.""" + + def __init__(self, port: int, directory: str) -> None: + """Initialise with the desired port and directory to serve.""" + self.port = port + self.directory = directory + self.httpd: Optional[socketserver.TCPServer] = None + self.thread: Optional[threading.Thread] = None + + def __enter__(self) -> "_ServerThread": + """Start the server in a background daemon thread.""" + handler = functools.partial( + http.server.SimpleHTTPRequestHandler, + directory=self.directory, + ) + socketserver.TCPServer.allow_reuse_address = True + self.httpd = socketserver.TCPServer(("127.0.0.1", self.port), handler) + self.thread = threading.Thread(target=self.httpd.serve_forever, daemon=True) + self.thread.start() + # Give the server a moment to bind + time.sleep(0.3) + return self + + def __exit__(self, *exc_info: object) -> None: + """Shut down the server and wait for the thread to finish.""" + if self.httpd is not None: + self.httpd.shutdown() + if self.thread is not None: + self.thread.join(timeout=5) + + +# --------------------------------------------------------------------------- +# Test cases +# --------------------------------------------------------------------------- + +# Pick a port unlikely to collide with a running dev server +TEST_PORT: int = 18_765 +PROJECT_ROOT: str = _find_project_root() + + +class TestServerServing(unittest.TestCase): + """Verify that the HTTP server correctly serves project files.""" + + server: _ServerThread + + @classmethod + def setUpClass(cls) -> None: + """Start the server once for all tests in this class.""" + cls.server = _ServerThread(port=TEST_PORT, directory=PROJECT_ROOT) + cls.server.__enter__() + + @classmethod + def tearDownClass(cls) -> None: + """Shut down the server after all tests complete.""" + cls.server.__exit__(None, None, None) + + # -- Tests -------------------------------------------------------------- + + def _get(self, path: str = "/") -> http.client.HTTPResponse: + """Perform a GET request against the test server.""" + url = f"http://127.0.0.1:{TEST_PORT}{path}" + return urllib.request.urlopen(url, timeout=5) + + def test_root_returns_200(self) -> None: + """GET / should return HTTP 200.""" + response = self._get("/") + self.assertEqual(response.status, 200) + + def test_root_serves_html(self) -> None: + """GET / should return content containing 'Hello World'.""" + response = self._get("/") + body = response.read().decode("utf-8") + self.assertIn("Hello World", body) + + def test_root_content_type_is_html(self) -> None: + """GET / should have a text/html Content-Type.""" + response = self._get("/") + content_type = response.headers.get("Content-Type", "") + self.assertIn("text/html", content_type) + + def test_index_html_direct(self) -> None: + """GET /index.html should return HTTP 200 with Hello World.""" + response = self._get("/index.html") + self.assertEqual(response.status, 200) + body = response.read().decode("utf-8") + self.assertIn("Hello World", body) + + def test_nonexistent_path_returns_404(self) -> None: + """GET /nonexistent should return HTTP 404.""" + url = f"http://127.0.0.1:{TEST_PORT}/nonexistent_page.html" + with self.assertRaises(urllib.error.HTTPError) as ctx: + urllib.request.urlopen(url, timeout=5) + self.assertEqual(ctx.exception.code, 404) + + +class TestIndexHtmlContent(unittest.TestCase): + """Validate the static index.html file on disk.""" + + html: str + + @classmethod + def setUpClass(cls) -> None: + """Read index.html into memory once.""" + path = os.path.join(PROJECT_ROOT, "index.html") + with open(path, encoding="utf-8") as fh: + cls.html = fh.read() + + def test_has_doctype(self) -> None: + """index.html should start with a DOCTYPE declaration.""" + self.assertTrue(self.html.strip().startswith("")) + + def test_has_title(self) -> None: + """index.html should contain a tag with 'Hello World'.""" + self.assertIn("<title>Hello World", self.html) + + def test_has_h1(self) -> None: + """index.html should contain an

with 'Hello World'.""" + self.assertIn("

Hello World

", self.html) + + def test_has_viewport_meta(self) -> None: + """index.html should contain a viewport meta tag.""" + self.assertIn('name="viewport"', self.html) + + def test_white_background(self) -> None: + """index.html should set background-color to white.""" + self.assertIn("background-color: #ffffff", self.html) + + def test_centered_layout(self) -> None: + """index.html should use flexbox centering.""" + self.assertIn("display: flex", self.html) + self.assertIn("justify-content: center", self.html) + self.assertIn("align-items: center", self.html) + + +class TestRunServerImport(unittest.TestCase): + """Verify that server.py can be imported and exposes run_server.""" + + def test_run_server_is_callable(self) -> None: + """run_server should be importable and callable.""" + import importlib + import sys + + # Ensure the project root is on the path + if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + + mod = importlib.import_module("server") + self.assertTrue(callable(getattr(mod, "run_server", None))) + + def test_run_server_default_args(self) -> None: + """run_server should accept port and directory keyword arguments.""" + import importlib + import inspect + import sys + + if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + + mod = importlib.import_module("server") + sig = inspect.signature(mod.run_server) + params = list(sig.parameters.keys()) + self.assertIn("port", params) + self.assertIn("directory", params) + + # Check defaults + self.assertEqual(sig.parameters["port"].default, 8000) + self.assertEqual(sig.parameters["directory"].default, ".") + + +if __name__ == "__main__": + unittest.main()