Skip to content

For Developers

NikoOinonen edited this page Feb 27, 2024 · 25 revisions

How we develop.

  • Issue first. Initiating any modification to the code should typically involve creating an issue. This encompasses bug reports, inquiries, or requests for additional features.

  • Pull request (PR) is the response to an issue. We often make PRs that target the main branch. In the PR description, we write fixes #NN where #NN corresponds to the issue ID this PR aims to fix.

  • Pre-commit hooks are used to improve the quality of the code. Please apply them before making a PR. See more in the corresponding section.

  • Test-Driven Development (TDD). We first write a test that defines the desired behaviour of the code and then write code that passes that test. See more in the corresponding section.

Code formatting

The code is automatically formatted using the pre-commit tool. Some changes are done automatically, some would require manual edits. Before you open a pull request, please make sure to enable pre-commit in your local copy of the repository

Enable pre-commit hooks

To enable pre-commit hooks, please do the following steps:

  1. Make sure you are in the root folder of the repository.
  2. Install ppafm with its development dependencies:
$ pip install -e .[dev]

Note the -e option that makes the installation editable: the installed ppafm package will read its files from the repository so that if the files are modified, the changes are reflected also in the installed package immediately.

  1. Install pre-commit:
$ pre-commit install

Once this is done, any time you run git commit - the pre-commit hooks will run automatically, acting on the files with staged changes.

Pre-commit configuration can be found in the .pre-commint-config.yaml file in the root folder of the repository.

Not recommended If for some reason, you don't want to run pre-commit, you can add the --no-verify option: git commit --no-verify. However, we intend to merge PRs that pass the pre-commit checks. This policy will get more strict with time.

Disable auto-formatting

Black is notorious for its aggressive formatting. If you think that some parts of the code should not be formatted, you can disable black for a portion of the code using # fmt: off and # fmt: on:

# fmt: off
custom_formatting = [
    0,  1,  2,
    3,  4,  5,
    6,  7,  8,
]
# fmt: on

See an example black's playground.

Continuous Integration

The ppafm repository uses a Continuous Integration (CI) workflow via Github Actions. This means that when certain actions such as pushes and PRs are made, automated workflows are triggered in the Github repository. These actions are defined in the .yml files inside the .github/workflows folder at the root of the repository. You can see the state of the tests for each commit in the commit history, where there is either a green check mark ✅ for success, a red cross ❌ for failure in at least one of the tests, or a yellow circle 🟡 for tests still running. You can click on the icon to see more details. You can also see the full history of run actions by clicking on the Actions tab at the top of the page for the repository. The goal is that all tests are always successful on the main branch, but during development on other branches, it is fine if the tests don't always pass. Just make sure that they pass before merging a PR.

We currently have the following actions:

  • Automated tests on any push and PR. Runs pytest on the tests folder in the root of the repo for all Python versions 3.7 upwards. See below for more details on writing tests.

  • Test the Docker image on any push or PR. Automatically tests building the current docker image and runs a simple test to make sure the code runs with Docker.

  • Make a new release of ppafm on PyPI on every push with a certain tag. See below for more details.

Tests

The goal of the automated tests is to ensure that the code behaves in consistent ways, ideally according to the documentation. This does not mean that we cannot ever change the behaviour of the code, but rather if we do, the tests help to make sure that it is intentional rather than by accident. Good tests are usually black-box tests that only test behaviour without caring about the implementation details so that we can refactor the code internally without having to rewrite the tests.

During the CI workflow, pytest automatically executes all functions starting with test_ inside all .py files starting with test_ inside the tests folder. The code coverage for the current tests is not very good, so if you are ever modifying some part of the code that is not yet tested, consider writing a test for it. You can look at any of the existing files in the current tests folder for examples of how to write the tests. Make sure the function name starts with test_ and includes at least one assert statement that should fail if the code is not working correctly. The tests themselves are not tested, but during the writing of the tests, it is often good to do a sanity check by intentionally breaking the code and seeing that the test fails.

Test-Driven Development (TDD)

TDD typically involves the following steps:

  1. Write a test that fails since the code is not written yet.
  2. Write a minimal code that makes the test succeed.
  3. Improve the quality of the code with better design and maintainability.

Make a new release

To create a new release, clone the repository, install development dependencies with pip install -e '.[dev]', and then execute bumpver update [--major|--minor|--patch] [--tag-num --tag [alpha|beta|rc]]. This will:

  1. Create a tagged release with a bumped version and push it to the repository.
  2. Trigger a GitHub actions workflow that creates a GitHub release and publishes it on PyPI. The new release should appear on the Releases page within a few minutes.

Additional notes:

  • Use the --dry option to preview the release change.
  • The release tag (e.g. a/b/rc) is determined from the last release. Use the --tag option to switch the release tag.
  • To update the tag number, use the --tag-num option.
  • This package follows semantic versioning.

Examples:

bumpver update --minor  # To update minor version, e.g. 1.0.0 -> 1.1.0 or 1.0.0b0 -> 1.1.0b0
bumpver update --patch  # To update patch version, e.g. 1.0.0 -> 1.0.1 or 1.0.0b0 -> 1.0.1b0
bumpver update --tag-num  # To update tag number, e.g. 1.0.0b0 -> 1.0.0b1
bumpver update --patch --tag alpha  # To make an alpha release with the next patch number, e.g. 1.0.0 -> 1.0.1a0
bumpver update --tag final  # To make a final release (no tag), e.g. 1.0.0b0 -> 1.0.0

C++ extension compilation

Normally the C++ extensions are compiled once during the build of the pip package. However, during development, it is often required to compile the C++ extensions continually after each modification. To help with this, ppafm offers the environment variable PPAFM_RECOMPILE, which when set to any non-empty value will make the C++ code recompile every time the extensions are imported. You could, e.g., set the variable at the beginning of a script, export PPAFM_RECOMPILE=1, or on a per run basis, PPAFM_RECOMPILE=1 ./run.sh.

Sphinx documentation

Our Python API documentation pages are built using sphinx and hosted on ReadTheDocs at https://ppafm.readthedocs.io/en/latest/.

Basics

The documentation is built using sphinx, which is a system for building html pages for Python APIs with little effort. The source code for the documentation is located in the repo at doc/sphinx/source. There you find a file called conf.py, which has some configuration options for how the documentation is built and how it looks, as well as bunch of files with the .rst extension, which determine what content the built html pages have.

Writing docstrings

Sphinx uses the reStructuredText (reST) format for it's content. It's a markup language like Markdown that is used here in Github, but with different syntax. However, reST is sometimes a bit complicated, so we use the napoleon extension in sphinx that allows writing the docstrings in Google-style, which is often more readable. A docstring in this style looks like the following:

def example_function(arg1, arg2):
'''
A plain language description of what the function does. This is followed by a list of arguments
and (possibly) return values.

Arguments:
    arg1: Description of argument 1.
    arg2: Description of argument 2. If the description for the argument is very long,
        it can be split into multiple lines.

Returns:
    ret1: Description of return value 1.
    ret2: Description of return value 2.
'''

Notice the empty lines between the description and the arguments and the return values. Also notice that all of the arguments and return values are indented at the same level. These are necessary for the content to be correctly parsed.

You can also use any directives supported by reST such as making links to other functions, classes, or methods:

A link to a class in the same module: :class:`myClass`
A link to a method in the same module: :meth:`myMethod`
A link to a function outside the current module: :func:`.MyFunc`. Notice the dot before the function name that allows for search outside the current module.

See also https://stackoverflow.com/questions/71798784/sphinx-autodoc-how-to-cross-link-to-a-function-documented-on-another-page/71836616#71836616.

For an example of a working docstring, see the AFMulator class, and how it looks compiled.

What goes into the source files

The structure of the doc pages is determined by the instructions in the .rst files in the source folder. The index.rst file is for the landing page and the other files are for the modules in the package. Inside the files you find directives such as

.. automodule:: ppafm.ml.Generator
   :members:
   :undoc-members:
   :show-inheritance:

This tells that in this place in the document, there should be an automatically generated documentation for all the members in the module ppafm.ml.Generator along with some options of what should be included. The directives are determined in the autodoc extension for sphinx. For the most part you don't need to modify these, unless you add a completely new module, or remove or rename an existing module, or want to have more control over what appears in the doc pages. By default, all members (classes, functions, methods) except those that start with an underscore _ are included.

Building the docs

Since we setup a workflow with ReadTheDocs, the doc pages will get automatically built and hosted at https://ppafm.readthedocs.io/en/latest/ every time there is a commit to the main branch. However, you might also want to manually build the html pages on your local machine to test how your new code and docstring looks like in the built docs before a commit. For this you need to have the packages sphinx and furo installed in your active environment. These are installed automatically if you install ppafm with the [dev] option as shown above.

Navigate in the repo to doc/sphinx and issue the command

make html

and after the build is done you will find the result in a new folder build/html under the current directory. Open the generated index.html in your browser to view the built doc pages.

Sometimes when making changes, the changes may not seem to appear in the built document even after a rebuild. In these cases it may help to do a hard refresh in the browser (e.g. Ctrl+F5 in Firefox) so that the browser cache is dropped. Otherwise, deleting the entire build directory may help.

Note: DO NOT push the build directory to the repository. It is already included in the .gitignore so this should not be easy to do on accident.