diff --git a/.github/workflows/build-pipeline.yaml b/.github/workflows/build-pipeline.yaml new file mode 100644 index 0000000..152023c --- /dev/null +++ b/.github/workflows/build-pipeline.yaml @@ -0,0 +1,143 @@ +# This Github Action will: +# jobs: +# build: install Python dependencies +# run tests and lint with a single version of Python +# pypi-publish: build and publish to PyPi (main branch only) - if build job passes +# documentation-publish: build the sphinx-based documentation (release branch only) - if build job passes + +name: Build Pipeline + +on: + push: + branches: + # Push events to main branch + - main + # Push events to develop branch + - develop + # Push events to a release branch + - release/* + # Push events to a feature branch + - feature/* + pull_request: + branches: + # PR events to main branch + - main + # PR events to develop branch + - develop + # PR events to a release branch + - release/* + +jobs: + sonarcloud: + name: SonarCloud + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - name: SonarCloud Scan + uses: SonarSource/sonarcloud-github-action@master + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + build: + + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + python-version: ["3.7", "3.8", "3.9", "3.10", pypy-3.8] + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Get the current branch name + shell: bash + run: echo "::set-output name=branch::${GITHUB_REF#refs/heads/}"; echo "Branch Name = ${GITHUB_REF#refs/heads/}" + id: branch_name + - name: Printing Github Context + env: + GITHUB_CONTEXT: ${{ toJSON(github) }} + run: echo "$GITHUB_CONTEXT" + - name: Install dependencies + run: | + # Update pip and install flake8, pytest and coverage + python -m pip install --upgrade pip + pip install flake8 pytest coverage + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - name: Lint with flake8 + run: | + # Stop the build if there are Python syntax errors or undefined names + flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Test with pytest + working-directory: ./test + run: | + # Run all tests + pytest test_adr_viewer.py + - name: Test accessibility + working-directory: . + run: | + # Install accessibility suite + npm install -g pa11y + python setup.py install + adr-viewer --template-dir adr_viewer/templates + pa11y ./index.html --config pa11y.json + pypi-publish: + needs: [build, sonarcloud] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Get the current branch name + shell: bash + run: echo "::set-output name=branch::${GITHUB_REF#refs/heads/}"; echo "Branch Name = ${GITHUB_REF#refs/heads/}" + id: branch_name + - name: Build and publish to pypi if on a release branch + working-directory: . + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_DEPLOY }} + run: | + BRANCH_NAME="${{ steps.branch_name.outputs.branch }}" + if [[ "${BRANCH_NAME:0:8}" = "release/" ]]; then + RELEASE_NAME="${BRANCH_NAME:8}" + echo "Release Name: ${RELEASE_NAME}" ; + echo "Publishing Release ${RELEASE_NAME} to PyPi" + python -m pip install build ; + python -m pip install twine ; + python -m build ; + python -m twine upload dist/* ; + fi + documentation-publish: + needs: [build, sonarcloud] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Get the current branch name + shell: bash + run: echo "::set-output name=branch::${GITHUB_REF#refs/heads/}"; echo "Branch Name = ${GITHUB_REF#refs/heads/}" + id: branch_name + - name: Build documentation only if on release branch + working-directory: ./documentation + run: | + BRANCH_NAME="${{ steps.branch_name.outputs.branch }}" + if [[ "${BRANCH_NAME:0:8}" = "release/" ]]; then + RELEASE_NAME="${BRANCH_NAME:8}" + echo "Release Name: ${RELEASE_NAME}" ; + echo "Building Documentation for Release ${RELEASE_NAME}" + python -m pip install --user sphinx; + python -m pip install --user faculty_sphinx_theme; + make html; + fi + diff --git a/.gitignore b/.gitignore index 32fc916..e2075ba 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ venv/ dist/ build/ adr_viewer.egg-info/ -.pytest_cache/ \ No newline at end of file +.pytest_cache/ +.DS_Store diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..e00ba0c --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,16 @@ +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-20.04 + tools: + python: "3.9" + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: documentation/source/conf.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..775b1c4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + + +## [1.3.0](https://pypi.org/project/adr-viewer/1.3.0/) - 2022-02-28 + +### Added +- Ignore badly-formed ADR files and report +- Allow template override and pass in a teplate directory +- Customizing server port + +### Fixed +- Some typos + +### Removed +- Support for Python 2.7 + +## [1.2.0](https://pypi.org/project/adr-viewer/1.2.0/) - 2019-06-19 + +### Added +- Support for Pending ADRs + +## [1.1.1](https://pypi.org/project/adr-viewer/1.1.1/) - 2019-02-26 + +### Added +- Python 3 support + + +### Fixed +- Included HTML file in package + +## [1.1.0](https://pypi.org/project/adr-viewer/1.1.0/) - 2018-09-10 + +### Added +- Pypi release details +- Accepted records are green +- Superceded records are now grey and have their titles struck through +- Records with unknown state default to white +- Amended records are yellow +- Since multiple statuses are possible in ADR files, we now extract all `p` objects below the Status header and use those for deciding on which style to apply +- Statuses also now represented by icons (colour-blindness) +- Created and updated this project's ADRs and visualised them +- Improved `README.md` + +## [1.0.1](https://pypi.org/project/adr-viewer/1.0.1/) - 2018-09-02 + +### Added +- Classifiers to `setup.py` + + +## [1.0.0](https://pypi.org/project/adr-viewer/1.0.0/) - 2018-09-02 + +### Initial Release + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..72e4956 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing to adr-viewer + +First of all, thanks for contributing! + +This is an evolving document which details what's needed to build and deploy `adr-viewer`. +Some tasks are only for maintainers, and some are for contributors. + +Tasks and information for maintainers are depicted by :large_orange_diamond: +Tasks and information for contributors are depicted by :large_blue_diamond: + +## Contribution Model :large_blue_diamond: + +adr-viewer uses the "Gitflow" model (as show in the diagram below). This means that contributors can be working on different branches and features simultaneously, whilst still knowing that there is a path toward production for their contributions, be they code or documentation. + + + +Note that for this model to function correctly, branches must be named accordingly, since the build infrastructure uses these names to determine what to build, and (in some cases) what to deploy and where. + +**Branch names** +| Name | Description | Responsibility | +|-----|-----|:--:| +| main | This branch contains the code which is currently in production and therefore deployed to PyPI, and which will be cloned for users to use | :large_orange_diamond: | +| hotfix | A hotfix branch is created when a major bug is detected and needs a fix to the production code immediately|:large_orange_diamond: :large_blue_diamond:| +| release | A release branch is taken when a stable codebase is about to be deployed to production. It must be stable and no further changes can be made to it post-release |:large_orange_diamond: | +|develop| This branch "collects" all the PRs that have been successfully merged, ready for a release to be cut when ready|:large_orange_diamond: | +|feature| Feature branches are where new functionality is developed, bug fixes and general updates are carried out prior to being tested and merged into the develop branch|:large_blue_diamond: | + + +**Making a feature change** :large_blue_diamond: :large_orange_diamond: + +To allow the change process to proceed smoothly, when making a change, please do the following: + +- ensure that you have forked the main repository + +All instructions from here onward relate to YOUR copy of the forked repository + +- create a new feature branch (e.g. feature/29 - where 29 is the issue number) +- checkout that branch locally +- make the change and add any new tests, and make sure the existing tests still pass. +- add the changed/new/deleted files to the tracked files list (git add xxxxxx) +- commit the changes with an appropriate message (stating which issue is fixed) +- push the changes into the repository +- when you are ready, create a PR into the develop branch +- collaborate on the review of the PR until the maintainer merges it into the develop branch +- this PR will then be deployed into the next release + +**Secrets and Tokens** :large_orange_diamond: + +In order for the build to successfully complete, the following tokens and secrets need to be defined: + +SONAR_TOKEN diff --git a/README.md b/README.md index 27199d1..08509a0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # adr-viewer -[![Build Status](https://travis-ci.org/mrwilson/adr-viewer.svg?branch=master)](https://travis-ci.org/mrwilson/adr-viewer) +[![Build Pipeline](https://github.com/mrwilson/adr-viewer/actions/workflows/build-pipeline.yaml/badge.svg)](https://github.com/mrwilson/adr-viewer/actions/workflows/build-pipeline.yaml) + Show off your Architecture Decision Records with an easy-to-navigate web page, either as a local web-server or generated static content. @@ -35,11 +36,66 @@ $ python setup.py install Usage: adr-viewer [OPTIONS] Options: - --adr-path TEXT Directory containing ADR files. [default: doc/adr/] - --output TEXT File to write output to. [default: index.html] - --serve Serve content at http://localhost:8000/ - --port INT Custom server port [default: 8000] - --help Show this message and exit. + --adr-path TEXT Directory containing ADR files. [default: doc/adr/] + --output TEXT File to write output to. [default: index.html] + --serve Serve content at http://localhost:8000/ + --port INTEGER Change port for the server [default: 8000] + --template-dir TEXT Template directory. + --heading TEXT ADR Page Heading [default: ADR Viewer - ] + --config TEXT Configuration settings [default: config.toml] + --help Show this message and exit. +``` + +The configuration file (in [TOML](http://toml.io) format) has settings that control the look of the ADR page. These settings are specifically targetted at the colours of the page to aid with those who have colour-blindness. + +The colours (the example used here is `green`) can be specified in a number of formats: + + - Hex values: #00FF00 + - HTML Colour codes: Green + - RGB values: rgb(0,255,0) + - RGB with alpha: rgba(0,255,0,0) + - HSL values: hsl(0,100%,50%) + - HSL with alpha: hsla(0,100%,50%,0) + +More information about codes and names for HTML colours can be found [here](http://htmlcolorcodes.com). + +The icons for each bar can also be modified using the `.icon` property of each bar. The icon names can be sourced from the [`fontawesome` library's documentation](https://fontawesome.com/v4/icons/). + +Note that only ***FREE*** icons are allowed. ***Pro*** icons will not be displayed. + +```bash +# Configuration for adr-viewer +# in TOML format http://toml.io + +title = "TOML Configuration for adr-viewer colours" + +[page] +# Properties for the page +background-color = "white" + +[status] +# Properties for the bars that display each ADR + +# Accepted +accepted.icon = "fa-check" +accepted.background-color = "lightgreen" + +# Amended +amended.icon = "fa-arrow-down" +amended.background-color = "yellow" + +# Pending +pending.icon = "fa-hourglass-half" +pending.background-color = "lightblue" + +# Superseded +superseded.icon = "fa-times" +superseded.background-color = "lightgrey" +superseded.text-decoration = "line-through" + +# Unknown +unknown.icon = "fa-question" +unknown.background-color = "white" ``` The default for `--adr-path` is `doc/adr/` because this is the default path generated by `adr-tools`. diff --git a/adr_viewer/__init__.py b/adr_viewer/__init__.py index 8093691..3f77931 100644 --- a/adr_viewer/__init__.py +++ b/adr_viewer/__init__.py @@ -1,119 +1 @@ -import glob -from jinja2.loaders import FileSystemLoader -import mistune -import os -from bs4 import BeautifulSoup -from jinja2 import Environment, PackageLoader, select_autoescape -import click -from bottle import Bottle, run - - -def extract_statuses_from_adr(page_object): - status_section = page_object.find('h2', text='Status') - - if status_section and status_section.nextSibling: - current_node = status_section.nextSibling - - while current_node.name != 'h2' and current_node.nextSibling: - current_node = current_node.nextSibling - - if current_node.name == 'p': - yield current_node.text - elif current_node.name == 'ul': - yield from (li.text for li in current_node.children if li.name == "li") - else: - continue - - -def parse_adr_to_config(path): - adr_as_html = mistune.markdown(open(path).read()) - - soup = BeautifulSoup(adr_as_html, features='html.parser') - - status = list(extract_statuses_from_adr(soup)) - - if any([line.startswith("Amended by") for line in status]): - status = 'amended' - elif any([line.startswith("Accepted") for line in status]): - status = 'accepted' - elif any([line.startswith("Superseded by") for line in status]): - status = 'superseded' - elif any([line.startswith("Pending") for line in status]): - status = 'pending' - else: - status = 'unknown' - - header = soup.find('h1') - - if header: - return { - 'status': status, - 'body': adr_as_html, - 'title': header.text - } - else: - return None - - -def render_html(config, template_dir_override=None): - - env = Environment( - loader=PackageLoader('adr_viewer', 'templates') if template_dir_override is None else FileSystemLoader(template_dir_override), - autoescape=select_autoescape(['html', 'xml']) - ) - - template = env.get_template('index.html') - - return template.render(config=config) - - -def get_adr_files(path): - files = glob.glob(path) - files.sort() - return files - - -def run_server(content, port): - print(f'Starting server at http://localhost:{port}/') - app = Bottle() - app.route('/', 'GET', lambda: content) - run(app, host='localhost', port=port, quiet=True) - - -def generate_content(path, template_dir_override=None): - - files = get_adr_files("%s/*.md" % path) - - config = { - 'project_title': os.path.basename(os.getcwd()), - 'records': [] - } - - for index, adr_file in enumerate(files): - - adr_attributes = parse_adr_to_config(adr_file) - - if adr_attributes: - adr_attributes['index'] = index - - config['records'].append(adr_attributes) - else: - print("Could not parse %s in ADR format, ignoring." % adr_file) - - return render_html(config, template_dir_override) - - -@click.command() -@click.option('--adr-path', default='doc/adr/', help='Directory containing ADR files.', show_default=True) -@click.option('--output', default='index.html', help='File to write output to.', show_default=True) -@click.option('--serve', default=False, help='Serve content at http://localhost:8000/', is_flag=True) -@click.option('--port', default=8000, help='Change port for the server', show_default=True) -@click.option('--template-dir', default=None, help='Template directory.', show_default=True) -def main(adr_path, output, serve, port, template_dir): - content = generate_content(adr_path, template_dir) - - if serve: - run_server(content, port) - else: - with open(output, 'w') as out: - out.write(content) +# Package init for adr_viewer \ No newline at end of file diff --git a/adr_viewer/adrviewer.py b/adr_viewer/adrviewer.py new file mode 100755 index 0000000..630d979 --- /dev/null +++ b/adr_viewer/adrviewer.py @@ -0,0 +1,178 @@ +import os +import glob +import toml +import ast + +from bottle import Bottle, run +from bs4 import BeautifulSoup +import click +from jinja2 import Environment, PackageLoader, select_autoescape +from jinja2.loaders import FileSystemLoader +import mistune + + +def extract_statuses_from_adr(page_object): + status_section = page_object.find('h2', text='Status') + + if status_section and status_section.nextSibling: + current_node = status_section.nextSibling + + while current_node.name != 'h2' and current_node.nextSibling: + current_node = current_node.nextSibling + + if current_node.name == 'p': + yield current_node.text + elif current_node.name == 'ul': + yield from (li.text + for li in current_node.children if li.name == "li") + else: + continue + + +def parse_adr_to_config(path): + adr_as_html = mistune.markdown(open(path).read()) + + soup = BeautifulSoup(adr_as_html, features='html.parser') + + status = list(extract_statuses_from_adr(soup)) + if any([line.startswith("Amended") for line in status]): + status = 'amended' + elif any([line.startswith("Accepted") for line in status]): + status = 'accepted' + elif any([line.startswith("Superceded") for line in status]): + status = 'superseded' + elif any([line.startswith("Superseded") for line in status]): + status = 'superseded' + elif any([line.startswith("Pending") for line in status]): + status = 'pending' + else: + status = 'unknown' + + header = soup.find('h1') + + if header: + return { + 'status': status, + 'body': adr_as_html, + 'title': header.text + } + else: + return None + + +def render_html(config, template_dir_override=None): + + env = Environment( + loader=PackageLoader('adr_viewer', 'templates') if template_dir_override is None else FileSystemLoader(template_dir_override), # noqa + autoescape=select_autoescape(['html', 'xml']) + ) + + template = env.get_template('index.html') + + return template.render(config=config) + + +def get_adr_files(path): + files = glob.glob(path) + files.sort() + return files + + +def run_server(content, port): + print(f'Starting server at http://localhost:{port}/') + app = Bottle() + app.route('/', 'GET', lambda: content) + run(app, host='localhost', port=port, quiet=True) + + +def generate_content(path, template_dir_override=None, + heading=None, configuration=None): + + files = get_adr_files("%s/*.md" % path) + + if not heading: + heading = 'ADR Viewer - ' + os.path.basename(os.getcwd()) + + config = { + 'heading': heading, + 'records': [], + 'page': [] + } + + # Set defaults for colours (or use passed in configuration) + conf = {} + if type(configuration) == type(None): + conf = ast.literal_eval('{ \ + "accepted": { \ + "icon": "fa-check", \ + "background-color": "lightgreen"}, \ + "amended": {\ + "icon": "fa-arrow-down", \ + "background-color": "yellow"}, \ + "pending": { \ + "icon": "fa-hourglass-half", \ + "background-color": "lightblue"}, \ + "superseded": { \ + "icon" : "fa-times",\ + "background-color": "lightgrey", \ + "text-decoration": "line-through"}, \ + "unknown": { \ + "icon" : "fa-question", \ + "background-color": "white"}}') + config['page'] = ast.literal_eval('{"background-color": "white"}') + else: + conf = configuration['status'] + config['page'] = configuration['page'] + + # Retrieve properties from configuration + for status in conf: + properties = {} + for property in conf[status]: + properties[property] = conf[status][property] + config[status] = properties + + for index, adr_file in enumerate(files): + + adr_attributes = parse_adr_to_config(adr_file) + + if adr_attributes: + adr_attributes['index'] = index + + config['records'].append(adr_attributes) + else: + print("Could not parse %s in ADR format, ignoring." % adr_file) + + return render_html(config, template_dir_override) + + +@click.command() +@click.option('--adr-path', default='doc/adr/', + help='Directory containing ADR files.', show_default=True) +@click.option('--output', default='index.html', + help='File to write output to.', show_default=True) +@click.option('--serve', default=False, + help='Serve content at http://localhost:8000/', is_flag=True) +@click.option('--port', default=8000, + help='Change port for the server', show_default=True) +@click.option('--template-dir', default=None, + help='Template directory.', show_default=True) +@click.option('--heading', default='ADR Viewer - ', + help='ADR Page Heading', show_default=True) +@click.option('--config', default='config.toml', + help='Configuration settings', show_default=True) +def main(adr_path, output, serve, port, template_dir, heading, config): + from os.path import exists + # Ensure that there is a configuration file + if exists(config): + configuration_file = toml.load(config) + else: + configuration_file = None + + content = generate_content(adr_path, template_dir, + heading, configuration_file) + + if serve: + run_server(content, port) + else: + with open(output, 'w') as out: + out.write(content) diff --git a/adr_viewer/config.toml b/adr_viewer/config.toml new file mode 100644 index 0000000..ebdf706 --- /dev/null +++ b/adr_viewer/config.toml @@ -0,0 +1,32 @@ +# Configuration for adr-viewer +# in TOML format http://toml.io + +title = "TOML Configuration for adr-viewer look and feel" + +[page] +# Properties for the page +background-color = "white" + +[status] +# Properties for the bars that display each ADR + +# Accepted +accepted.icon = "fa-check" +accepted.background-color = "lightgreen" + +# Amended +amended.icon = "fa-arrow-down" +amended.background-color = "yellow" + +# Pending +pending.icon = "fa-hourglass-half" +pending.background-color = "lightblue" + +# Superseded +superseded.icon = "fa-times" +superseded.background-color = "lightgrey" +superseded.text-decoration = "line-through" + +# Unknown +unknown.icon = "fa-question" +unknown.background-color = "white" diff --git a/adr_viewer/templates/index.html b/adr_viewer/templates/index.html index 6f5639b..993b2d0 100644 --- a/adr_viewer/templates/index.html +++ b/adr_viewer/templates/index.html @@ -5,8 +5,9 @@ - -