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`
This commit is contained in:
Jon Grace-Cox
2022-08-13 13:22:15 -04:00
committed by GitHub
parent 9b7318417d
commit 1c986d4ad8
15 changed files with 308 additions and 34 deletions

View File

@@ -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:

View File

@@ -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 <command>`.
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
```

View File

@@ -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

View File

@@ -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)

7
anybadge_server.py Executable file
View File

@@ -0,0 +1,7 @@
#!/usr/bin/env python
if __name__ == "__main__":
from anybadge.server.cli import main
import sys
print(sys.argv)
main()

View File

@@ -1,3 +1,5 @@
invoke
pygments
pytest
pytest-cov
requests

View File

@@ -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()

8
docker/test/Dockerfile Normal file
View File

@@ -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 ./

View File

@@ -0,0 +1,4 @@
packaging
pytest
pytest-cov
requests

View File

@@ -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

47
tasks/__init__.py Normal file
View File

@@ -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)

24
tasks/server.py Normal file
View File

@@ -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)

32
tasks/test.py Normal file
View File

@@ -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,
)

View File

@@ -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"])

45
tests/test_server.py Normal file
View File

@@ -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"<html><head><title>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')
)