Source code for efrotools.toolconfig

# 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 pathlib import Path
from typing import TYPE_CHECKING

from efro.terminal import Clr

if TYPE_CHECKING:
    pass


[docs] def install_tool_config(projroot: Path, src: Path, dst: Path) -> None: """Install a config.""" print(f'Creating tool config: {Clr.BLD}{dst}{Clr.RST}') # Special case: if we've got a src .toml and a dst .json, convert. # This can be handy to add annotations/etc. in the src which isn't # possible with json. if src.suffix == '.toml' and dst.suffix == '.json': import tomllib import json with src.open(encoding='utf-8') as infile: contents = tomllib.loads(infile.read()) cfg = json.dumps(contents, indent=2, sort_keys=True) # In normal cases we just push the source file straight through. else: 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', '.pylintrc', '.style.yapf', '.clang-format', '.editorconfig', '.rgignore', ]: 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.project import getprojectconfig from efrotools.pyver import 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 :json-false\n' ' :report_progress t\n' ' :dmypy :json-false))))))))\n', ) # Stick project-root wherever they want. cfg = cfg.replace('__EFRO_PROJECT_ROOT__', str(projroot)) # Project Python version; '3.13', etc. name = '__EFRO_PY_VER__' if name in cfg: cfg = cfg.replace(name, PYVER) # Project Python version as a binary name; 'python3.13', etc. name = '__EFRO_PY_BIN__' if name in cfg: cfg = cfg.replace(name, str(Path(projroot, '.venv', 'bin', 'python'))) # 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, explicit-override """ ).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(projroot) 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 # Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum