diff --git a/Dockerfile b/Dockerfile index 3a6b3e0..6e00a74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,37 @@ -FROM python:3.13 +# syntax=docker.io/docker/dockerfile:1.7-labs +FROM python:3.13 AS deps WORKDIR /usr/src/app +COPY dev-requirements.txt ./ +RUN pip install --no-cache-dir -r dev-requirements.txt +RUN rm dev-requirements.txt + +#======================================================= + +FROM deps AS app + +WORKDIR /usr/src/app RUN apt-get update && apt-get install -y ffmpeg COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt +RUN rm requirements.txt + +COPY --exclude=test_*.py src ./src + +# Thanks to https://stackoverflow.com/questions/64776990/python-docker-no-module-found +ENV PYTHONPATH /usr/src/app +EXPOSE 8000 +ENTRYPOINT ["python"] +CMD ["src/main.py"] + +#======================================================= + +FROM deps AS test + +COPY dev-requirements.txt ./ +RUN pip install --no-cache-dir -r dev-requirements.txt COPY . . - -CMD [ "python", "./main.py" ] \ No newline at end of file +# No `CMD`, because there are plenty of potential test commands, no single sensible default. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..1f2087f --- /dev/null +++ b/compose.yaml @@ -0,0 +1,46 @@ +services: + base: &base + tty: true + stdin_open: true + # Necessary because compose does not permit a service to lack a build + build: + context: . + target: deps + entrypoint: python + volumes: + - .:/usr/src/app:delegated + + app: + build: + context: . + target: app + environment: + - DOWNLOAD_DIR=/download + ports: + - "8000:8000" + volumes: + - ./download_dir:/download + + test-base: &test-base + <<: *base + build: + context: . + target: test + + test: + <<: *test-base + command: ["-m", "pytest", "--ignore=integ-tests", "."] + + test-with-output: + <<: *test-base + command: ["-m", "pytest", "-rP", "--ignore=integ-tests", "."] + + test-integ: + <<: *test-base + depends_on: + # TODO - should really have a healthcheck here to avoid race-conditions. "Up" doesn't actually mean "Up and accepting requests" + - app + command: ["-m", "pytest", "integ-tests" ] + volumes: + - ./download_dir:/download + - .:/usr/src/app:delegated # Have to re-specify this because the specification above overrides, rather than appending diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..154e384 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,2 @@ +pytest==8.3.4 +requests==2.28.1 \ No newline at end of file diff --git a/download_dir/README.md b/download_dir/README.md new file mode 100644 index 0000000..2eb21c1 --- /dev/null +++ b/download_dir/README.md @@ -0,0 +1 @@ +A location into which the application can download files when run locally. Particularly useful for sharing with the Integ Test service! diff --git a/integ-tests/test_end-to-end.py b/integ-tests/test_end-to-end.py new file mode 100644 index 0000000..5bc3b93 --- /dev/null +++ b/integ-tests/test_end-to-end.py @@ -0,0 +1,35 @@ +import pathlib +import requests +import time + +def test_download(): + target_file_name = "A Beginner's Guide to the EICAR Test File [bTThnbwxN5g].m4a" + download_path = pathlib.Path('/download') + url = 'https://www.youtube.com/watch?v=bTThnbwxN5g' + + try: + # `app` is injected as a DNS name by the Docker Compose harness + response = requests.post('http://app:8000/download', json={ + 'url': url + }) + assert response.status_code == 202, f"Non-202 response: {response.status_code}. Body: {response.json()}" + time.sleep(2) + for _ in range(5): + if _does_target_file_exist(target_file_name, download_path): + break + else: + time.sleep(3) + else: + assert False, "File not found after 15 seconds" + finally: + for f in download_path.iterdir(): + if f.name != 'README.md': + f.unlink() + +def _does_target_file_exist(file_name: str, dir: pathlib.Path) -> bool: + for f in dir.iterdir(): + if f.name == file_name: + return True + else: + print(f"Found file: {f.name} which does not match") + return False diff --git a/requirements.txt b/requirements.txt index b1df116..6fccefb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -yt-dlp +yt-dlp==2024.10.22 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handler.py b/src/handler.py similarity index 97% rename from handler.py rename to src/handler.py index 31d2865..f940d98 100644 --- a/handler.py +++ b/src/handler.py @@ -69,7 +69,7 @@ class Handler(http.server.SimpleHTTPRequestHandler): def do_GET(self): self.send_response(HTTPStatus.INTERNAL_SERVER_ERROR) - content = 'ERROR: Only HEAD requests are permitted\n'.encode('utf-8') + content = 'ERROR: Only POST requests are permitted\n'.encode('utf-8') self.send_header("Content-type", 'application/json') self.send_header('Content-Length', len(content)) self.end_headers() diff --git a/main.py b/src/main.py similarity index 93% rename from main.py rename to src/main.py index 726bb1f..244c56c 100644 --- a/main.py +++ b/src/main.py @@ -4,7 +4,7 @@ import os import socketserver from functools import partial -from handler import Handler +from src.handler import Handler from multiprocessing import Pool PORT = int(os.environ.get('PORT', '8000')) diff --git a/src/test_handler.py b/src/test_handler.py new file mode 100644 index 0000000..01d3736 --- /dev/null +++ b/src/test_handler.py @@ -0,0 +1,20 @@ +import os +import pathlib +import tempfile + +from src.handler import download + +def test_download(): + video_url = "https://www.youtube.com/watch?v=bTThnbwxN5g" + expected_filename = "A Beginner's Guide to the EICAR Test File [bTThnbwxN5g].m4a" + with tempfile.TemporaryDirectory() as tmpdirname: + os.environ['DOWNLOAD_DIR'] = tmpdirname + download(video_url) + download_dir = pathlib.Path(tmpdirname) + passes = False + for contents in download_dir.iterdir(): + print(f'DEBUG - {contents=}') + if contents.name == expected_filename: + passes = True + break + assert passes, "Could not find downloaded file"