ballistica/tools/efrotools/toolconfig.py
2023-07-31 19:50:04 -07:00

201 lines
7.0 KiB
Python

# Released under the MIT License. See LICENSE for details.
#
"""Functionality for wrangling tool config files.
This lets us auto-populate values such as python-paths or versions
into tool config files automatically instead of having to update
everything everywhere manually. It also provides a centralized location
for some tool defaults across all my projects.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from efro.terminal import Clr
if TYPE_CHECKING:
from pathlib import Path
def install_tool_config(projroot: Path, src: Path, dst: Path) -> None:
"""Install a config."""
print(f'Creating tool config: {Clr.BLD}{dst}{Clr.RST}')
with src.open(encoding='utf-8') as infile:
cfg = infile.read()
# Some substitutions, etc.
cfg = _filter_tool_config(projroot, cfg)
# Add an auto-generated notice.
comment = None
if dst.name in ['.dir-locals.el']:
comment = ';;'
elif dst.name in [
'.mypy.ini',
'.pycheckers',
'.pylintrc',
'.style.yapf',
'.clang-format',
'.editorconfig',
]:
comment = '#'
if comment is not None:
cfg = (
f'{comment} THIS FILE WAS AUTOGENERATED; DO NOT EDIT.\n'
f'{comment} Source: {src}.\n\n' + cfg
)
with dst.open('w', encoding='utf-8') as outfile:
outfile.write(cfg)
def _filter_tool_config(projroot: Path, cfg: str) -> str:
# pylint: disable=too-many-locals
import textwrap
from efrotools import getprojectconfig, PYVER
# Emacs dir-locals defaults. Note that these contain other
# replacements so need to be at the top.
name = '__EFRO_EMACS_STANDARD_CPP_LSP_SETUP__'
if name in cfg:
cfg = cfg.replace(
name,
';; Set up clangd as our C++ language server.\n'
' (c++-ts-mode . ((eglot-server-programs'
' . ((c++-ts-mode . ("clangd"'
' "--compile-commands-dir=.cache/compile_commands_db"))))))',
)
name = '__EFRO_EMACS_STANDARD_PYTHON_LSP_SETUP__'
if name in cfg:
cfg = cfg.replace(
name,
';; Set up python-lsp-server as our Python language server.\n'
' (python-ts-mode . (\n'
' (eglot-server-programs . (\n'
' (python-ts-mode . ("__EFRO_PY_BIN__" "-m" "pylsp"))))\n'
' (python-shell-interpreter . "__EFRO_PY_BIN__")\n'
' (eglot-workspace-configuration . (\n'
' (:pylsp . (:plugins (\n'
' :pylint (:enabled t)\n'
' :flake8 (:enabled :json-false)\n'
' :pycodestyle (:enabled :json-false)\n'
' :mccabe (:enabled :json-false)\n'
' :autopep8 (:enabled :json-false)\n'
' :pyflakes (:enabled :json-false)\n'
' :rope_autoimport (:enabled :json-false)\n'
' :rope_completion (:enabled :json-false)\n'
' :rope_rename (:enabled :json-false)\n'
' :yapf (:enabled :json-false)\n'
' :black (:enabled t\n'
' :skip_string_normalization t\n'
' :line_length 80\n'
' :cache_config t)\n'
' :jedi (:extra_paths'
' [__EFRO_PYTHON_PATHS_Q_REL_STR__])\n'
' :pylsp_mypy (:enabled t\n'
' :live_mode nil\n'
' :dmypy t))))))))\n',
)
# Stick project-root wherever they want.
cfg = cfg.replace('__EFRO_PROJECT_ROOT__', str(projroot))
# Project Python version; '3.11', etc.
name = '__EFRO_PY_VER__'
if name in cfg:
cfg = cfg.replace(name, PYVER)
# Project Python version as a binary name; 'python3.11', etc.
name = '__EFRO_PY_BIN__'
if name in cfg:
cfg = cfg.replace(name, f'python{PYVER}')
# Colon-separated list of project Python paths.
name = '__EFRO_PYTHON_PATHS__'
if name in cfg:
pypaths = getprojectconfig(projroot).get('python_paths')
if pypaths is None:
raise RuntimeError('python_paths not set in project config')
assert not any(' ' in p for p in pypaths)
cfg = cfg.replace(name, ':'.join(f'{projroot}/{p}' for p in pypaths))
# Quoted relative space-separated list of project Python paths.
name = '__EFRO_PYTHON_PATHS_Q_REL_STR__'
if name in cfg:
pypaths = getprojectconfig(projroot).get('python_paths')
if pypaths is None:
raise RuntimeError('python_paths not set in project config')
assert not any(' ' in p for p in pypaths)
cfg = cfg.replace(name, ' '.join(f'"{p}"' for p in pypaths))
# Short project name.
short_names = {
'ballistica-internal': 'ba-i',
'ballistica': 'ba',
'ballistica-master-server': 'bmas',
'ballistica-master-server-legacy': 'bmasl',
'ballistica-server-node': 'basn',
}
shortname = short_names.get(projroot.name, projroot.name)
cfg = cfg.replace('__EFRO_PROJECT_SHORTNAME__', shortname)
mypy_standard_settings = textwrap.dedent(
"""
# We don't want all of our plain scripts complaining
# about __main__ being redefined.
scripts_are_modules = True
# Try to be as strict as we can about using types everywhere.
no_implicit_optional = True
warn_unused_ignores = True
warn_no_return = True
warn_return_any = True
warn_redundant_casts = True
warn_unreachable = True
warn_unused_configs = True
disallow_incomplete_defs = True
disallow_untyped_defs = True
disallow_untyped_decorators = True
disallow_untyped_calls = True
disallow_any_unimported = True
disallow_subclassing_any = True
strict_equality = True
local_partial_types = True
no_implicit_reexport = True
enable_error_code = redundant-expr, truthy-bool, \
truthy-function, unused-awaitable
"""
).strip()
cfg = cfg.replace('__EFRO_MYPY_STANDARD_SETTINGS__', mypy_standard_settings)
name = '__PYTHON_BLACK_EXTRA_ARGS__'
if name in cfg:
from efrotools.code import black_base_args
bargs = black_base_args()
assert bargs[2] == 'black'
cfg = cfg.replace(
name, '(' + ' '.join(f'"{b}"' for b in bargs[3:]) + ')'
)
# Gen a pylint init hook that sets up our Python paths.
pylint_init_tag = '__EFRO_PYLINT_INIT__'
if pylint_init_tag in cfg:
pypaths = getprojectconfig(projroot).get('python_paths')
if pypaths is None:
raise RuntimeError('python_paths not set in project config')
cstr = 'init-hook=import sys;'
# Stuff our paths in at the beginning in the order they appear
# in our projectconfig.
for i, path in enumerate(pypaths):
cstr += f" sys.path.insert({i}, '{projroot}/{path}');"
cfg = cfg.replace(pylint_init_tag, cstr)
return cfg