From 1c986d4ad8300f431f3a0b8cb0e478906789d846 Mon Sep 17 00:00:00 2001 From: Jon Grace-Cox <30441316+jongracecox@users.noreply.github.com> Date: Sat, 13 Aug 2022 13:22:15 -0400 Subject: [PATCH] Add package testing to CI (#68) * Make `build_examples.py` callable from python * Allow tests to run from outside project directory * Add invoke tasks * Add server tests * Run travis tests against wheel package instead of local code * Update `badge.write_badge()` to support `pathlib.Path` * Update `CONTRIBUTING.md` --- .travis.yml | 4 +- CONTRIBUTING.md | 103 +++++++++++++++++++++++++++----- Dockerfile | 11 ++-- anybadge/badge.py | 25 +++++--- anybadge_server.py | 7 +++ build-requirements.txt | 2 + build_examples.py | 7 ++- docker/test/Dockerfile | 8 +++ docker/test/requirements.txt | 4 ++ docker/test/run_docker_tests.sh | 7 +++ tasks/__init__.py | 47 +++++++++++++++ tasks/server.py | 24 ++++++++ tasks/test.py | 32 ++++++++++ tests/test_anybadge.py | 16 ++++- tests/test_server.py | 45 ++++++++++++++ 15 files changed, 308 insertions(+), 34 deletions(-) create mode 100755 anybadge_server.py create mode 100644 docker/test/Dockerfile create mode 100644 docker/test/requirements.txt create mode 100755 docker/test/run_docker_tests.sh create mode 100644 tasks/__init__.py create mode 100644 tasks/server.py create mode 100644 tasks/test.py create mode 100644 tests/test_server.py diff --git a/.travis.yml b/.travis.yml index 7e9167d..5a5fff5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,7 +18,9 @@ script: pre-commit install && pre-commit run --all; fi -- pytest --doctest-modules --cov=anybadge --cov-report html:htmlcov anybadge tests +- python setup.py bdist_wheel && pip install dist/anybadge*.whl +- mkdir tmp && cd tmp +- pytest --doctest-modules --cov=anybadge --cov-report html:htmlcov ../anybadge ../tests before_deploy: - sed -i "s/^version = .*/version = __version__ = \"$TRAVIS_TAG\"/" anybadge/__init__.py deploy: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db27d97..5aa7a66 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,4 +1,5 @@ # Contributing to anybadge + I love your input! I want to make contributing to this project as easy and transparent as possible, whether it's: - Reporting a bug @@ -8,6 +9,7 @@ I love your input! I want to make contributing to this project as easy and trans - Becoming a maintainer ## I use [Github Flow](https://docs.github.com/en/get-started/quickstart/github-flow), so all code changes happen through pull requests + Pull requests are the best way to propose changes to the codebase (I use [Github Flow](https://docs.github.com/en/get-started/quickstart/github-flow)). I actively welcome your pull requests: @@ -19,15 +21,18 @@ Pull requests are the best way to propose changes to the codebase (I use 6. Issue that pull request! ## Any contributions you make will be under the MIT Software License + When you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern. ## Report bugs using Github's [issues](https://github.com/jongracecox/anybadge/issues) + I use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/jongracecox/anybadge/issues/new/choose). ## Write bug reports with detail, background, and sample code + **Great Bug Reports** tend to have: - A quick summary and/or background @@ -41,14 +46,41 @@ I use GitHub issues to track public bugs. Report a bug by People *love* thorough bug reports. ## Use a Consistent Coding Style -Please follow the existing coding style. + +Please follow the existing coding style. Your code should be standardised using +[Python Black](https://github.com/psf/black) using pre-commit when you make commits - please ensure you have +pre-commit installed (see [here](#install-pre-commit)). ## License + By contributing, you agree that your contributions will be licensed under its MIT License. -# Technical stuff +# Development environment + +Setup your development environment with the following steps: + +- [Check out the project](#check-out-the-project) +- [Install build requirements](#install-build-requirements) +- [Install pre-commit](#install-pre-commit) + +## Check out the project + +Clone the project: + +```bash +git clone https://github.com/jongracecox/anybadge.git +``` + +## Install build requirements + +Install build requirements with: + +```bash +pip install -r build-requirements.txt +``` + +## Install pre-commit -## Pre-commit This projects makes use of [pre-commit](https://pre-commit.com) to add some safety checks and create consistency in the project code. When committing changes to this project, please first [install pre-commit](https://pre-commit.com/#install), then activate it for this project: @@ -84,17 +116,58 @@ Fixing examples/color_teal.svg This shows that two files were updated by hooks, and need to be re-added (with `git add`) before trying to commit again. -## Documentation -The `README.md` file contains a table showing example badges for the different built-in colors. If you modify the -appearance of badges, or the available colors please update the table using the following code: +# Development activities -```python -import anybadge -print("""| Color Name | Hex Code | Example | -| ---------- | -------- | ------- |""") -for color in sorted([c for c in anybadge.colors.Color], key=lambda x: x.name): - file = 'examples/color_' + color.name.lower() + '.svg' - url = 'https://cdn.rawgit.com/jongracecox/anybadge/master/' + file - anybadge.Badge(label='Color', value=color, default_color=color.name.lower()).write_badge(file, overwrite=True) - print("| {color} | {hex} | ![]({url}) |".format(color=color.name.lower(), hex=color.value.upper(), url=url)) +## Invoke + +The project has some [Python invoke](https://www.pyinvoke.org/) tasks to help automate things. After installing +build requirements you can run `inv --list` to see a list of available tasks. + +For example: + +```bash +> inv --list +Available tasks: + + build Build the package. + clean Clean up the project area. + examples Generate examples markdown. + server.docker-build + server.docker-run + server.run + test.docker Run dockerised tests. + test.local Run local tests. +``` + +You can get help for a command using `inv --help `. + +Invoke tasks are defined in the `tasks/` directory in the project. Feel free to add new and useful tasks. + +## Running tests + +You can run tests locally using: + +```bash +inv test.local +``` + +When running locally, you will be running tests against the code in the project. This has some disadvantages, +specifically running locally may not detect files that are not included in the package build, e.g. sub-modules, +templates, examples, etc. For this reason we have a containerised test. This can be run using: + +```bash +inv test.docker +``` + +This will clean up the project `dist` directory, build the package locally, build the docker image, +spin up a docker container, install the package and run the tests. The tests should run using the installed +package and not the project source code, so this method should be used as a final test before pushing. + +## Documentation + +The `README.md` file contains a table showing example badges for the different built-in colors. If you modify the +appearance of badges, or the available colors please update the table using the invoke task: + +```bash +inv examples ``` diff --git a/Dockerfile b/Dockerfile index b00691c..0b6059d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,15 @@ -FROM python:3-stretch +FROM python:3-alpine WORKDIR /app -RUN apt-get update +RUN apk update -COPY anybadge.py anybadge_server.py ./ +RUN pip install -U pip && pip install packaging -ENTRYPOINT ./anybadge_server.py +COPY anybadge/ /app/anybadge/ +COPY anybadge_server.py /app/. + +ENTRYPOINT ["./anybadge_server.py"] # Example command to run Docker container # docker run -it --rm -p8000:8000 -e ANYBADGE_LISTEN_ADDRESS="" -e ANYBADGE_LOG_LEVEL=DEBUG labmonkey/anybadge:1.0 diff --git a/anybadge/badge.py b/anybadge/badge.py index c9f08c7..3e992e1 100644 --- a/anybadge/badge.py +++ b/anybadge/badge.py @@ -746,21 +746,28 @@ class Badge: (color, ", ".join(list(Color.__members__.keys()))), ) - def write_badge(self, file_path, overwrite=False) -> None: + def write_badge(self, file_path: Union[str, Path], overwrite=False) -> None: """Write badge to file.""" + if isinstance(file_path, str): + + if file_path.endswith("/"): + raise ValueError("File location may not be a directory.") + + file: Path = Path(file_path) + else: + file = file_path + # Validate path (part 1) - if file_path.endswith("/"): + if file.is_dir(): raise ValueError("File location may not be a directory.") - # Get absolute filepath - path = os.path.abspath(file_path) - if not path.lower().endswith(".svg"): - path += ".svg" + # Ensure we're using a .svg extension + file = file.with_suffix(".svg") # Validate path (part 2) - if not overwrite and os.path.exists(path): - raise RuntimeError('File "{}" already exists.'.format(path)) + if not overwrite and file.exists(): + raise RuntimeError('File "{}" already exists.'.format(file)) - with open(path, mode="w") as file_handle: + with open(file, mode="w") as file_handle: file_handle.write(self.badge_svg_text) diff --git a/anybadge_server.py b/anybadge_server.py new file mode 100755 index 0000000..9d3f915 --- /dev/null +++ b/anybadge_server.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +if __name__ == "__main__": + from anybadge.server.cli import main + import sys + + print(sys.argv) + main() diff --git a/build-requirements.txt b/build-requirements.txt index c29f7e9..d85bee4 100644 --- a/build-requirements.txt +++ b/build-requirements.txt @@ -1,3 +1,5 @@ +invoke pygments pytest pytest-cov +requests diff --git a/build_examples.py b/build_examples.py index 7d99517..dcae30f 100644 --- a/build_examples.py +++ b/build_examples.py @@ -1,14 +1,13 @@ import anybadge -if __name__ == "__main__": +def main(): print( """ | Color Name | Hex | Example | | ------------- | ------- | ------- |""" ) for color in sorted(anybadge.colors.Color): - file = "examples/color_" + color.name.lower() + ".svg" url = "https://cdn.rawgit.com/jongracecox/anybadge/master/" + file @@ -20,3 +19,7 @@ if __name__ == "__main__": print( f"| {color.name.lower():<13} | {color.value.upper():<7} | ![]({f'{url})':<84}|" ) + + +if __name__ == "__main__": + main() diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile new file mode 100644 index 0000000..93ed529 --- /dev/null +++ b/docker/test/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.10.0 + +WORKDIR /work + +RUN apt update && pip install -U pip +COPY requirements.txt ./ +RUN pip install -r ./requirements.txt +COPY run_docker_tests.sh ./ diff --git a/docker/test/requirements.txt b/docker/test/requirements.txt new file mode 100644 index 0000000..3b06fab --- /dev/null +++ b/docker/test/requirements.txt @@ -0,0 +1,4 @@ +packaging +pytest +pytest-cov +requests diff --git a/docker/test/run_docker_tests.sh b/docker/test/run_docker_tests.sh new file mode 100755 index 0000000..4c67829 --- /dev/null +++ b/docker/test/run_docker_tests.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +echo "Running tests..." +mkdir tmp && cd tmp +mkdir tests +pip install /app/dist/anybadge*.whl +pytest --doctest-modules --cov=anybadge --cov-report html:htmlcov /app/anybadge /app/tests diff --git a/tasks/__init__.py b/tasks/__init__.py new file mode 100644 index 0000000..7e0fa0f --- /dev/null +++ b/tasks/__init__.py @@ -0,0 +1,47 @@ +"""Invoke tasks for the project.""" +import glob +import os +import subprocess +from pathlib import Path + +from invoke import task, Collection +from tasks import test, server + +PROJECT_DIR = Path(__file__).parent.parent + +os.chdir(PROJECT_DIR) + + +@task +def build(c): + """Build the package.""" + print("Building package...") + subprocess.run(["python", "setup.py", "bdist_wheel"]) + + +@task +def examples(c): + """Generate examples markdown.""" + print("Generating examples markdown...") + from build_examples import main + + main() + + +def delete_files(files: str): + for file in glob.glob(files): + print(f" Deleting {file}") + subprocess.run(["rm", "-rf", file]) + + +@task() +def clean(c): + """Clean up the project area.""" + print("Cleaning the project directory...") + delete_files("dist/*") + delete_files("tests/test_*.svg") + + +namespace = Collection(test, server) +for fn in [build, examples, clean]: + namespace.add_task(fn) diff --git a/tasks/server.py b/tasks/server.py new file mode 100644 index 0000000..647b55c --- /dev/null +++ b/tasks/server.py @@ -0,0 +1,24 @@ +import subprocess + +from invoke import task + + +@task +def docker_build(c): + print("Building Docker image...") + subprocess.run("docker build . -t anybadge:latest", shell=True) + + +@task +def docker_run(c, port=8000): + print("Running server in Docker container...") + subprocess.run( + f"docker run -it --rm -p{port}:{port}/tcp anybadge:latest --port={port}", + shell=True, + ) + + +@task +def run(c, port=8000): + print("Running server locally...") + subprocess.run(f"python3 anybadge_server.py --port={port}", shell=True) diff --git a/tasks/test.py b/tasks/test.py new file mode 100644 index 0000000..e26dfe0 --- /dev/null +++ b/tasks/test.py @@ -0,0 +1,32 @@ +import subprocess +from pathlib import Path + +from invoke import task + +PROJECT_DIR = Path(__file__).parent.parent + + +@task +def local(c): + """Run local tests.""" + print("Running local tests...") + subprocess.run( + "pytest --doctest-modules --cov=anybadge --cov-report html:htmlcov anybadge tests", + shell=True, + ) + + +@task +def docker(c): + """Run dockerised tests.""" + print("Running containerised tests...") + + subprocess.run("invoke clean", shell=True) + subprocess.run("invoke build", shell=True) + subprocess.run( + f"(cd docker/test && docker build . -t test-anybadge:latest)", shell=True + ) + subprocess.run( + f"docker run -v {PROJECT_DIR}:/app test-anybadge:latest /work/run_docker_tests.sh", + shell=True, + ) diff --git a/tests/test_anybadge.py b/tests/test_anybadge.py index 263b44a..7ca4f25 100644 --- a/tests/test_anybadge.py +++ b/tests/test_anybadge.py @@ -1,8 +1,12 @@ +from pathlib import Path from unittest import TestCase from anybadge import Badge from anybadge.cli import main, parse_args +TESTS_DIR = Path(__file__).parent + + class TestAnybadge(TestCase): """Test case class for anybadge package.""" @@ -234,7 +238,7 @@ class TestAnybadge(TestCase): self.assertTrue("font_size=10" in badge_repr) def test_template_from_file(self): - file = "tests/template.svg" + file = Path(__file__).parent / Path("template.svg") badge = Badge("template from file", value=file, template=file) _ = badge.badge_svg_text @@ -266,8 +270,14 @@ class TestAnybadge(TestCase): with self.assertRaisesRegex( RuntimeError, r'File ".*tests\/exists\.svg" already exists\.' ): - badge.write_badge("tests/exists") - badge.write_badge("tests/exists") + badge.write_badge(TESTS_DIR / Path("exists")) + badge.write_badge(TESTS_DIR / Path("exists")) + + with self.assertRaisesRegex( + RuntimeError, r'File ".*tests\/exists\.svg" already exists\.' + ): + badge.write_badge(str(TESTS_DIR / Path("exists"))) + badge.write_badge(str(TESTS_DIR / Path("exists"))) def test_arg_parsing(self): args = parse_args(["-l", "label", "-v", "value"]) diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..c848709 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,45 @@ +import subprocess +import time + +import requests # type: ignore +from unittest import TestCase + + +class TestAnybadgeServer(TestCase): + """Test case class for anybadge server.""" + + def setUp(self): + if not hasattr(self, "assertRaisesRegex"): + self.assertRaisesRegex = self.assertRaisesRegexp + self.proc = subprocess.Popen( + ["anybadge-server", "-p", "8000", "--listen-address", "127.0.0.1"], + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + time.sleep(1) + + def tearDown(self) -> None: + self.proc.kill() + + def test_server_is_running(self): + """Test that the server is running.""" + self.assertTrue(self.proc.pid > 0) + + def test_server_root_request(self): + """Test that the server can be accessed.""" + url = "http://127.0.0.1:8000" + response = requests.get(url) + self.assertTrue(response.ok) + self.assertTrue( + response.content.startswith(b"Anybadge Web Server.") + ) + + def test_server_badge_request(self): + """Test that the server can be accessed.""" + url = "http://127.0.0.1:8000/?label=Project%20Awesomeness&value=110%" + response = requests.get(url) + self.assertTrue(response.ok) + print(response.content) + self.assertTrue( + response.content.startswith(b'<?xml version="1.0" encoding="UTF-8"?>\n<svg') + )