🎃

Nitpick を使ってリポジトリ間の設定ファイルを統一する

2022/09/13に公開

はじめに

以前Googleのソフトウェアエンジニアリングという書籍を読みました。この書籍にはコーディング規約やモノレポ、静的解析について書かれた章があります。モノレポ上にリンター・フォーマッターの設定をすることで組織全体で統一感のあるコーディングがしやすいことが書かれていました。モノレポではなく複数のリポジトリを使って開発するスタイルもありますが、リンター・フォーマッターなどの各種設定を統一するのが難しくなります。
本記事ではNitpick というツールを使って、リポジトリ間の各種設定ファイルを統一する方法を紹介します。

Nitpick について

NitpickはYAML、JSON、TOML、INIファイルなどを管理するツールです。TOMLで記述されたリモートファイルを参照し、そのファイルの設定を手元に反映します。一つのリモートファイルを各リポジトリから参照することでリポジトリ間の設定ファイルを統一できます。

最新版リリースv0.32.0では以下のファイルに対応しています。

  • INI
  • JSON
  • text
  • TOML
  • YAML
  • .editorconfig
  • pylintrc
  • setup.cfg

Nitpickのセットアップ

pipやpoerty、brewなどを使ってインストールできます。とりあえずpipを使って入れてみます。

pip install -U nitpick

Nitpickの実行

Nitpick cliには主に3つのサブコマンドがあります。

  • init
  • fix: リモートファイルの設定を手元に反映する
  • check: fixを実行した際にどんな設定が反映されるかの確認(fixのdry run)

空のディレクトリと.nitpick.tomlを作ってNitpickを実際に触ってみます。

 mkdir nitpick-demo  
 cd nitpick-demo  
 touch .nitpick.toml
余談: 空の .nitpick.tomlを作った理由

作った理由は単純にこれがないとnitpickが動かないからです。.nitpick.tomlではなく、pyproject.tomlgo.modなどROOT_FILESに該当するファイルがあれば動作します。ソースコードを読み込めてないので推測にはなりますが、nitpick fixを実行して各種ファイルを生成する時にプロジェクトのルートから相対パスでファイルを生成するのでROOT_FILESによってプロジェクトのルートディレクトリを特定してるのだと思います。たぶん。

nitpick init

上記のコマンドを実行すると.nitpick.tomlが以下のようになります。デフォルトの設定が記述され、どのリモートファイルを参照するかが書かれてます。py://nitpick/resources/presets/nitpickここを参照するようになってます。

.nitpick.toml
[tool.nitpick]
# Generated by the 'nitpick init' command
# More info at https://nitpick.rtfd.io/en/latest/configuration.html
style = ['py://nitpick/resources/presets/nitpick']

この状態でfixコマンドを実行すると設定が反映されて各種ファイルが生成されます。

nitpick fix
実行結果。長いので省略
.pre-commit-config.yaml:1: NIP103  should exist: Create the file with the contents below, then run 'pre-commit install'
pyproject.toml:1: NIP311  was not found. Create it with this content:
[build-system]
requires = [ "poetry-core>=1.0.0",]
build-backend = "poetry.core.masonry.api"

[tool.black]
line-length = 120

[tool.poetry.dependencies]
python = "^3.9"

[tool.poetry.extras]
lint = [ "pylint",]

[tool.poetry.dependencies.pylint]
version = "*"
optional = true
.pre-commit-config.yaml:1: NIP361  was not found. Create it with this content:
repos:
  - repo: https://github.com/PyCQA/bandit
    hooks:
      - id: bandit
        args:
          - --ini
          - setup.cfg
        exclude: tests/
  - repo: https://github.com/psf/black
    hooks:
      - id: black
        args:
          - --safe
          - --quiet
  - repo: https://github.com/asottile/blacken-docs
    hooks:
      - id: blacken-docs
        additional_dependencies:
          - black==22.1.0
  - repo: https://github.com/PyCQA/flake8
    hooks:
      - id: flake8
        additional_dependencies:
          - flake8-blind-except
          - flake8-bugbear
          - flake8-comprehensions
          - flake8-debugger
          - flake8-docstrings
          - flake8-isort
          - flake8-polyfill
          - flake8-pytest
          - flake8-quotes
          - flake8-typing-imports
          - yesqa
  - repo: https://github.com/pre-commit/pygrep-hooks
    hooks:
      - id: python-check-blanket-noqa
      - id: python-check-mock-methods
      - id: python-no-eval
      - id: python-no-log-warn
      - id: rst-backticks
  - repo: https://github.com/pre-commit/pre-commit-hooks
    hooks:
      - id: debug-statements
  - repo: https://github.com/asottile/pyupgrade
    hooks:
      - id: pyupgrade
        args:
          - --py37-plus
  - repo: https://github.com/PyCQA/isort
    hooks:
      - id: isort
  - repo: https://github.com/pre-commit/mirrors-mypy
    hooks:
      - id: mypy
        args:
          - --show-error-codes
  - repo: local
    hooks:
      - id: pylint
        name: pylint
        language: system
        exclude: tests/
        types:
          - python
  - repo: https://github.com/myint/autoflake
    hooks:
      - id: autoflake
        args:
          - --in-place
          - --remove-all-unused-imports
          - --remove-unused-variables
          - --remove-duplicate-keys
          - --ignore-init-module-imports
  - repo: https://github.com/pre-commit/mirrors-prettier
    hooks:
      - id: prettier
        stages:
          - commit
  - repo: https://github.com/commitizen-tools/commitizen
    hooks:
      - id: commitizen
        stages:
          - commit-msg
  - repo: https://github.com/pre-commit/pre-commit-hooks
    hooks:
      - id: end-of-file-fixer
      - id: trailing-whitespace
  - repo: https://github.com/openstack/bashate
    hooks:
      - id: bashate
        args:
          - -i
          - E006
  - repo: https://github.com/shellcheck-py/shellcheck-py
    hooks:
      - id: shellcheck
.codeclimate.yml:1: NIP361  was not found. Create it with this content:
plugins:
  bandit:
    enabled: true
  pep8:
    enabled: false
  pylint:
    enabled: false
  radon:
    enabled: true
    config:
      threshold: C
  sonar-python:
    enabled: true
  fixme:
    enabled: false
  editorconfig:
    enabled: true
  git-legal:
    enabled: true
  markdownlint:
    enabled: false
  shellcheck:
    enabled: true
version: '2'
checks:
  file-lines:
    config:
      threshold: 1000
  method-complexity:
    config:
      threshold: 10
setup.cfg:1: NIP321  has some missing sections. Use this:
[flake8]
ignore = D107,D202,D203,D401,E203,E402,E501,W503
max-line-length = 120
inline-quotes = double
exclude = .tox,build

[isort]
line_length = 120
skip = .tox,build
known_first_party = tests
multi_line_output = 3
include_trailing_comma = True
force_grid_wrap = 0
combine_as_imports = True

[mypy]
ignore_missing_imports = True
follow_imports = normal
strict_optional = True
warn_no_return = True
warn_redundant_casts = True
warn_unused_ignores = False
.pylintrc:1: NIP321  was not found. Create it with this content:
[BASIC]
bad-functions = map,filter
good-names = i,j,k,e,ex,Run,_,id,rv,c

[FORMAT]
max-line-length = 120
max-module-lines = 1000
indent-after-paren = 4

[MASTER]
jobs = 1
extension-pkg-whitelist = pydantic

[REPORTS]
output-format = colorized

[SIMILARITIES]
min-similarity-lines = 4
ignore-comments = yes
ignore-docstrings = yes
ignore-imports = no

[VARIABLES]
dummy-variables-rgx = _$|dummy
.readthedocs.yml:1: NIP361  was not found. Create it with this content:
version: 2
formats: all
sphinx:
  configuration: docs/conf.py
python:
  install:
    - method: pip
      path: .
      extra_requirements:
        - doc
tox.ini:1: NIP321  was not found. Create it with this content:
[coverage:report]
show_missing = True
precision = 2
skip_covered = True
skip_empty = True
sort = Cover

[coverage:run]
branch = True
parallel = True
source = src/
relative_files = True

[testenv]
description = Run tests with pytest and coverage
extras = test

[tox]
isolated_build = True
.github/workflows/python.yaml:1: NIP361  was not found. Create it with this content:
name: Python
on:
  - push
  - pull_request
jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        os:
          - ubuntu-latest
          - windows-latest
          - macos-latest
        python-version:
          - '3.7'
          - '3.8'
          - '3.9'
          - '3.10'
    name: ${{ matrix.python-version }} ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    env:
      PYTHONUNBUFFERED: 1
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v3
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install tox
        run: python -m pip install tox
.editorconfig:1: NIP321  was not found. Create it with this content:
root = True

[*]
end_of_line = lf
insert_final_newline = True
indent_style = space
indent_size = 4
trim_trailing_whitespace = True

[*.py]
charset = utf-8

[*.{js,json}]
charset = utf-8
indent_size = 2

[*.{yml,yaml,md,rb}]
indent_size = 2

[Makefile]
indent_style = tab
package.json:1: NIP341  was not found. Create it with this content:
{
  "name": "<some value here>",
  "release": {
    "plugins": "<some value here>"
  },
  "repository": {
    "type": "<some value here>",
    "url": "<some value here>"
  },
  "version": "<some value here>"
}
Violations: ✅ 10 fixed, ❌ 1 to change manually.
zsh: exit 1     nitpick fix

ls -a                                    
.cache                   .readthedocs.yml          
.codeclimate.yml         package.json    
.editorconfig            pyproject.toml 
.git                     setup.cfg         
.github                  tox.ini      
.nitpick.toml                
.pylintrc                    
.pre-commit-config.yaml    

各々のファイルを見ると静的解析等の設定が記述されていることが確認できます。

余談: pre-commitを動かす

ドキュメントに沿ってpre-commitの動作確認をした際にうまく動きませんでした。.pre-commit-config.yamlを修正すると動くようになったので修正版を貼っておきます。

.pre-commit-config.yaml
.pre-commit-config.yaml
repos:
  - repo: https://github.com/PyCQA/bandit
    rev: "1.7.4"
    hooks:
      - id: bandit
        args:
          - --ini
          - setup.cfg
        exclude: tests/
  - repo: https://github.com/psf/black
    rev: "22.8.0"
    hooks:
      - id: black
        args:
          - --safe
          - --quiet
  - repo: https://github.com/asottile/blacken-docs
    rev: "v1.12.1"
    hooks:
      - id: blacken-docs
        additional_dependencies:
          - black==22.1.0
  - repo: https://github.com/PyCQA/flake8
    rev: "5.0.4"
    hooks:
      - id: flake8
        additional_dependencies:
          - flake8-blind-except
          - flake8-bugbear
          - flake8-comprehensions
          - flake8-debugger
          - flake8-docstrings
          - flake8-isort
          - flake8-polyfill
          - flake8-pytest
          - flake8-quotes
          - flake8-typing-imports
          - yesqa
  - repo: https://github.com/pre-commit/pygrep-hooks
    rev: "v1.9.0"
    hooks:
      - id: python-check-blanket-noqa
      - id: python-check-mock-methods
      - id: python-no-eval
      - id: python-no-log-warn
      - id: rst-backticks
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: "v4.3.0"
    hooks:
      - id: debug-statements
  - repo: https://github.com/asottile/pyupgrade
    rev: "v2.37.3"
    hooks:
      - id: pyupgrade
        args:
          - --py37-plus
  - repo: https://github.com/PyCQA/isort
    rev: "5.10.1"
    hooks:
      - id: isort
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: "v0.971"
    hooks:
      - id: mypy
        args:
          - --show-error-codes
  - repo: https://github.com/myint/autoflake
    rev: "v1.5.3"
    hooks:
      - id: autoflake
        args:
          - --in-place
          - --remove-all-unused-imports
          - --remove-unused-variables
          - --remove-duplicate-keys
          - --ignore-init-module-imports
  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: "v3.0.0-alpha.0"
    hooks:
      - id: prettier
        stages:
          - commit
  - repo: https://github.com/commitizen-tools/commitizen
    rev: "v2.32.3"
    hooks:
      - id: commitizen
        stages:
          - commit-msg
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: "v4.3.0"
    hooks:
      - id: end-of-file-fixer
      - id: trailing-whitespace
  - repo: https://github.com/openstack/bashate
    rev: "2.1.0"
    hooks:
      - id: bashate
        args:
          - -i
          - E006

https://github.com/andreoliwa/nitpick/issues/472

その他の気になるところ

前の章でnitpick fixを実行して生成されたsetup.cfgを使って気になる動作を確認してみます。

setup.cfg
[flake8]
ignore = D107,D202,D203,D401,E203,E402,E501,W503
max-line-length = 120
inline-quotes = double
exclude = .tox,build

[isort]
line_length = 120
skip = .tox,build
known_first_party = tests
multi_line_output = 3
include_trailing_comma = True
force_grid_wrap = 0
combine_as_imports = True

[mypy]
ignore_missing_imports = True
follow_imports = normal
strict_optional = True
warn_no_return = True
warn_redundant_casts = True
warn_unused_ignores = False

設定を追加してfixをしたとき

リモートの設定にない値を追加してnitpick fixを実行してみます。

setup.cfg
[flake8]
- ignore = D107,D202,D203,D401,E203,E402,E501,W503
+ ignore = D107,D202,D203,D401,E203,E402,E501,W503,D100
max-line-length = 120
...
nitpick fix
No violations found. ✨ 🍰 ✨

追加した設定は削除されることがなく、変更は起きませんでした。

設定を消してfixを実行したとき

リモートの設定にある値を削除してnitpick fixを実行してみます。

setup.cfg
[flake8]
- ignore = D107,D202,D203,D401,E203,E402,E501,W503
+ ignore = D107,D202,D203,D401,E203,E402,E501
max-line-length = 120
...
$ nitpick fix
setup.cfg:1: NIP322  has missing values in the 'ignore' key. Include those values:
[flake8]
ignore = (...),W503
Violations: ✅ 1 fixed.
zsh: exit 1     nitpick fix

$ cat setup.cfg
[flake8]
ignore = D107,D202,D203,D401,E203,E402,E501,W503
max-line-length = 120
...

削除した値が再度書き込まれました。削除ではなく編集したファイルで試してもリモートの値に書き変わることが確認できました。

setup.cfg
[flake8]
ignore = D107,D202,D203,D401,E203,E402,E501,W503
- max-line-length = 120
+ max-line-length = 80
...
$ nitpick fix
setup.cfg:1: NIP323 : [flake8]max-line-length is 80 but it should be like this:
[flake8]
max-line-length = 120
Violations: ✅ 1 fixed.
zsh: exit 1     nitpick fix

$ cat setup.cfg
[flake8]
ignore = D107,D202,D203,D401,E203,E402,E501,W503
max-line-length = 120
...

複数設定の反映と設定の上書き

参照するtomlファイルは複数設けることができます。後ろのファイルの設定が優先されるので、設定を上書きしたい場面でも活用できます。
そして、リモートのファイルだけでなくローカルのファイルも設定できます。組織の静的解析の設定が特定のリポジトリにどうしても合わない等の場合に個別で調整できます。

example
[tool.nitpick]
style = [
    "https://example.com/on/the/web/remote-style.toml",
    "./my-local-style.toml",
]

リモートファイル側の記述

nitpickで参照する設定ファイルをどう書くのか例を載せておきます。

remote-style.toml
["pyproject.toml".tool.black]
line-length = 120

["pyproject.toml".tool.poetry.dev-dependencies]
pylint = "*"

["setup.cfg".flake8]
ignore = "D107,D202,D203,D401"
max-line-length = 120
inline-quotes = "double"

["setup.cfg".isort]
line_length = 120
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 0
combine_as_imports = true

[".github/workflows/python.yaml"]
name = "Python"
on = ["push", "pull_request"]

[".github/workflows/python.yaml".jobs.build.strategy]
fail-fast = false

このファイルを参照してnitpick fixを実行すると各ファイルが作成されます。pyproject.tomlを見てみると設定が反映されてることが分かります。

pyproject.toml
[tool.black]
line-length = 120

[tool.poetry.dev-dependencies]
pylint = "*"

まとめ

一度共通のtomlファイルを書くと、その後使い回せるので開発が楽になりそうです。静的解析の設定だけでなく、VSCodeのエディターの設定も作ることでさらに開発者体験が良くなると思います。
以上、Nitpickを使ってリポジトリ間の各種設定ファイルを統一する方法の紹介でした。

参考

https://nitpick.readthedocs.io/en/latest/index.html
https://monorepo.tools/

Discussion