approvals-validator 0.2.1
A CLI tool to validate changeset approvals.
Requires Python: >=3.8

Approval Validator
==================

A CLI to validate that sufficient approvals have been received for a changeset
in the context of a project.

<!-- markdown-toc start - Don't edit this section. Run M-x markdown-toc-refresh-toc -->
**Table of Contents**

- [Approval Validator](#approval-validator)
    - [Installation](#installation)
    - [Usage](#usage)
    - [Requirements](#requirements)
    - [Dependencies](#dependencies)
    - [Tests](#tests)
    - [Design Notes](#design-notes)
        - [`ChangeSet`, `ChangedDirectory`](#changeset-changeddirectory)
        - [`file_utils`](#fileutils)
        - [`exceptions`](#exceptions)
    - [Performance](#performance)

<!-- markdown-toc end -->

Installation
------------

The quickest way to use the tool in anger is to install it using `pip`:
`pip install approvals-validator`.

Alternatively, run the executable from the project root. Install dependencies
with `bin/setup`.

Usage
-----

```text
% validate_approvals --help

Usage: validate_approvals REQUIRED_FLAGS

  Validate that the correct approvals have been received to approve changes
  to the given files.

  Note: Multiple approvers and/or changed files can be passed as CSV strings.

  Example:

    validate_approvals --approvers alovelace,eclarke --changed-files src/com/twitter/follow/Follow.java

Options:
  -a, --approvers USERNAMES       Username(s) of approvals.  [required]
  -c, --changed-files FILE_PATHS  File paths. [required]
  -h, --help                      Show this message and exit.
```

Requirements
------------

- Python >= 3.8.0 (for `functools.cached_property`)

A `.tool-versions` file is included for [asdf][asdf] users.

[asdf]: https://github.com/asdf-vm/asdf

Dependencies
------------

- [click](https://github.com/pallets/click)
- [pytest](https://pytest.org/en/latest/)

The test-runner script (`./test`) will attempt to install dependencies in a
virtualenv at project root named `./env`.

For reference, `bin/setup` usage instructions:

```text
Usage:
  ./bin/setup [OPTIONS] ENV

Install dependencies for `validate_approvals` in a virtualenv at project root.

Available environments:

 dev     Install all dependencies
 prod    Install minimal dependencies for running `validate_approvals`
 test    Install minimal and test dependencies

Available options:

 --silent  Run without verbose output
```

Tests
-----

A test runner script is included to run the entire test suite and display code
coverage metrics. Pass the `--docker` flag to (re-)build a Docker image and
run tests with Docker.

Acceptances tests are written in Bash script, unit and integration tests in
Python with pytest.

```text
% ./test

Running acceptance tests...
./validate_approvals -c data/minimal/y/file -a B
./validate_approvals -c data/minimal/y/file -a A,C
./validate_approvals -c data/minimal/y/file -a D
./validate_approvals --approvers alovelace,ghopper --changed-files data/repo/src/com/twitter/follow/Follow.java,data/repo/src/com/twitter/user/User.java
./validate_approvals --approvers alovelace --changed-files data/repo/src/com/twitter/follow/Follow.java
./validate_approvals --approvers eclarke --changed-files data/repo/src/com/twitter/follow/Follow.java
./validate_approvals --approvers alovelace,eclarke --changed-files data/repo/src/com/twitter/follow/Follow.java
./validate_approvals --approvers mfox --changed-files data/repo/src/com/twitter/tweet/Tweet.java

Running pytest tests...

Running mypy on 11 files... done with status 0
Success: no issues found in 11 source files
...............................                                   [100%]

---------- coverage: platform darwin, python 3.8.0-final-0 -----------
Name                                                 Stmts   Miss  Cover
------------------------------------------------------------------------
approval_validator/__init__.py                           3      0   100%
approval_validator/change_set.py                        14     14     0%
approval_validator/changed_directory.py                 30      0   100%
approval_validator/cli_utils.py                         14     14     0%
approval_validator/exceptions.py                         9      2    78%
approval_validator/file_utils.py                        74      0   100%
approval_validator/tests/__init__.py                     0      0   100%
approval_validator/tests/changed_directory_test.py      37      0   100%
approval_validator/tests/file_utils_test.py             59      4    93%
------------------------------------------------------------------------
TOTAL                                                  240     34    86%
```

Design Notes
------------

The script entrypoint is the CLI function in the executable
`validate_approvals`.

The `approval_validator.cli_utils` module defines how arguments are parsed.

### `ChangeSet`, `ChangedDirectory`

The main classes are `ChangeSet` and `ChangedDirectory`.

The former models an entire changeset (i.e., all the files passed via the
`--changed-files` flag), the latter each individual entry in the list of files
passed to `--changed_files`.

```py
# approval_validator/changed_directory.py L24-37

@cached_property
def affected_directories(self) -> Tuple[Path, ...]:
    return util.find_dependent_dirs(self.directory)

@cached_property
def approved(self) -> bool:
    """
    Return true if sufficient approval has been received for this
    ChangedDirectory.
    """
    for impacted_dir in self.impacted_directories:
        if not self.__change_approved(impacted_dir):
            return False
    return True
```

### `file_utils`

File-parsing and directory-traversal logic is housed in the `file_utils` module.

### `exceptions`

Defines `ApprovalValidatorError`, the base class for library-specific
exceptions, and `ProjectRootNotFoundError`, which is raised when a project root
can't be found.

```py
# approval_validator/exceptions.py L8-20

class ProjectRootNotFoundError(ApprovalValidatorError):
    """Raised when a project root can't be found."""
    def __init__(self, start_dir):
        self.start_dir = start_dir

    def __str__(self):
        message = f"""
        Project root search failed. Started from: {self.start_dir}

        Note: We detect the presence of a project root using the entries of
        PROJECT_ROOT_FILES. (see: approval_validator/file_utils.py)
        """
        return f"\n\n{cleandoc(message)}"
```

Performance
-----------

Caching improved running time by ~20%. The following facilities are used:

- [`functools.cached_property`][cp]
- [`functools.lru_cache`][lru]

[cp]: https://docs.python.org/3/library/functools.html#functools.cached_property
[lru]: https://docs.python.org/3/library/functools.html#functools.lru_cache