Nitpick を使ってリポジトリ間の設定ファイルを統一する
はじめに
以前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.toml
やgo.mod
などROOT_FILESに該当するファイルがあれば動作します。ソースコードを読み込めてないので推測にはなりますが、nitpick fix
を実行して各種ファイルを生成する時にプロジェクトのルートから相対パスでファイルを生成するのでROOT_FILES
によってプロジェクトのルートディレクトリを特定してるのだと思います。たぶん。
nitpick init
上記のコマンドを実行すると.nitpick.toml
が以下のようになります。デフォルトの設定が記述され、どのリモートファイルを参照するかが書かれてます。py://nitpick/resources/presets/nitpick
はここを参照するようになってます。
[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
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
その他の気になるところ
前の章でnitpick fix
を実行して生成された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
を実行してみます。
[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
を実行してみます。
[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
...
削除した値が再度書き込まれました。削除ではなく編集したファイルで試してもリモートの値に書き変わることが確認できました。
[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ファイルは複数設けることができます。後ろのファイルの設定が優先されるので、設定を上書きしたい場面でも活用できます。
そして、リモートのファイルだけでなくローカルのファイルも設定できます。組織の静的解析の設定が特定のリポジトリにどうしても合わない等の場合に個別で調整できます。
[tool.nitpick]
style = [
"https://example.com/on/the/web/remote-style.toml",
"./my-local-style.toml",
]
リモートファイル側の記述
nitpickで参照する設定ファイルをどう書くのか例を載せておきます。
["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
を見てみると設定が反映されてることが分かります。
[tool.black]
line-length = 120
[tool.poetry.dev-dependencies]
pylint = "*"
まとめ
一度共通のtomlファイルを書くと、その後使い回せるので開発が楽になりそうです。静的解析の設定だけでなく、VSCodeのエディターの設定も作ることでさらに開発者体験が良くなると思います。
以上、Nitpickを使ってリポジトリ間の各種設定ファイルを統一する方法の紹介でした。
参考
Discussion