diff --git a/dev.ps1 b/dev.ps1 index 5b6166a..6507f1e 100644 --- a/dev.ps1 +++ b/dev.ps1 @@ -120,41 +120,19 @@ function Invoke-Uv { } $Env:ENV_SYNCED = $True - # ? Track environment variables to update `.env` with later - $EnvVars = @{} - $EnvVars.Add('PYRIGHT_PYTHON_PYLANCE_VERSION', $PylanceVersion) - $EnvFile = $Env:GITHUB_ENV ? $Env:GITHUB_ENV : "$PWD/.env" - if (!(Test-Path $EnvFile)) { New-Item $EnvFile } - - # ? Get environment variables from `pyproject.toml` - uv run --no-sync --python $PythonVersion dev init-shell | - Select-String -Pattern '^(.+)=(.+)$' | - ForEach-Object { - $Key, $Value = $_.Matches.Groups[1].Value, $_.Matches.Groups[2].Value - if ($EnvVars -notcontains $Key) { $EnvVars.Add($Key, $Value) } - } - - # ? Get environment variables to update in `.env` - $Keys = @() - $Lines = Get-Content $EnvFile | ForEach-Object { - $_ -Replace '^(?.+)=(?.+)$', { - $Key = $_.Groups['Key'].Value - if ($EnvVars.ContainsKey($Key)) { - $Keys += $Key - return "$Key=$($EnvVars[$Key])" - } - return $_ - } - } - # ? Sync environment variables and those in `.env` - $NewLines = $EnvVars.GetEnumerator() | ForEach-Object { - $Key, $Value = $_.Key, $_.Value + # ? Sync `.env` and set environment variables from `pyproject.toml` + $EnvVars = uv run --no-sync --python $PythonVersion dev 'sync-environment-variables' --pylance-version $PylanceVersion + $EnvVars | Set-Content ($Env:GITHUB_ENV ? $Env:GITHUB_ENV : "$PWD/.env") + $EnvVars | Select-String -Pattern '^(.+?)=(.+)$' | ForEach-Object { + $Key, $Value = $_.Matches.Groups[1].Value, $_.Matches.Groups[2].Value Set-Item "Env:$Key" $Value - if ($Keys -notcontains $Key) { return "$Key=$Value" } } - @($Lines, $NewLines) | Set-Content $EnvFile + # ? Environment-specific setup - if ($Devcontainer) { + if ($CI) { + uv run --no-sync --python $PythonVersion dev elevate-pyright-warnings + } + elseif ($Devcontainer) { $Repo = Get-ChildItem '/workspaces' $Packages = Get-ChildItem "$Repo/packages" $SafeDirs = @($Repo) + $Packages @@ -164,9 +142,7 @@ function Invoke-Uv { } } } - elseif ($CI) { - uv run --no-sync --python $PythonVersion dev elevate-pyright-warnings - } + # ? Install pre-commit hooks else { $Hooks = '.git/hooks' diff --git a/docs/conf.py b/docs/conf.py index 08930e2..07c12cf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -172,12 +172,7 @@ def dpath(path: Path, rel: Path = DOCS) -> str: mermaid_d3_zoom = False # ! Autodoc2 nitpicky = True -autodoc2_packages = [ - f"../src/{PACKAGE}", - f"{PACKAGE}_docs", - f"../tests/{PACKAGE}_tests", - f"../scripts/{PACKAGE}_tools", -] +autodoc2_packages = [f"../src/{PACKAGE}", "../packages/_dev/dev"] autodoc2_render_plugin = "myst" # ? Autodoc2 does not currently obey `python_display_short_literal_types` or # ? `python_use_unqualified_type_names`, but `maximum_signature_line_length` makes it a @@ -185,7 +180,7 @@ def dpath(path: Path, rel: Path = DOCS) -> str: # ? https://github.com/sphinx-extensions2/sphinx-autodoc2/issues/58 maximum_signature_line_length = 1 # ? Parse Numpy docstrings -autodoc2_docstring_parser_regexes = [(".*", f"{PACKAGE}_docs.docstrings")] +autodoc2_docstring_parser_regexes = [(".*", "dev.docs.docstrings")] # ! Intersphinx intersphinx_mapping = ISPX_MAPPING nitpick_ignore = [ @@ -204,6 +199,10 @@ def dpath(path: Path, rel: Path = DOCS) -> str: ), (r"py:.*", r"ploomber_engine\.ipython\.+"), (r"py:.*", r"pydantic\..+"), # ? https://github.com/pydantic/pydantic/issues/1339 + ( + r"py:.+", + r"pydantic_settings\..+", + ), # ? https://github.com/pydantic/pydantic/issues/1339 # ? TypeAlias: https://github.com/sphinx-doc/sphinx/issues/10785 (r"py:class", rf"{PACKAGE}.*\.types\..+"), ] diff --git a/packages/_dev/dev/modules.py b/packages/_dev/dev/modules.py new file mode 100644 index 0000000..f649af1 --- /dev/null +++ b/packages/_dev/dev/modules.py @@ -0,0 +1,31 @@ +"""Module names.""" + +from importlib.machinery import ModuleSpec +from pathlib import Path +from types import ModuleType + + +def get_package_dir(package: ModuleType) -> Path: + """Get the directory of a package given the top-level module.""" + return Path(package.__spec__.submodule_search_locations[0]) # type: ignore + + +def get_module_name(module: ModuleType | ModuleSpec | Path | str) -> str: + """Get an unqualified module name. + + Example: `get_module_name(__spec__ or __file__)`. + """ + if isinstance(module, ModuleType | ModuleSpec): + return get_qualified_module_name(module).split(".")[-1] + path = Path(module) + return path.parent.name if path.stem in ("__init__", "__main__") else path.stem + + +def get_qualified_module_name(module: ModuleType | ModuleSpec) -> str: # type: ignore + """Get a fully-qualified module name. + + Example: `get_module_name(__spec__ or __file__)`. + """ + if isinstance(module, ModuleType): + module: ModuleSpec = module.__spec__ # type: ignore + return module.name diff --git a/packages/_dev/dev/tools/__main__.py b/packages/_dev/dev/tools/__main__.py index 1bd4d99..a85705b 100644 --- a/packages/_dev/dev/tools/__main__.py +++ b/packages/_dev/dev/tools/__main__.py @@ -5,15 +5,25 @@ from pathlib import Path from re import finditer, sub from shlex import join, split -from sys import version_info +from tomllib import loads from cyclopts import App +from pydantic import BaseModel from dev.tools import add_changes, environment from dev.tools.environment import escape, run from dev.tools.types import ChangeType -from tomllib import loads + +class Constants(BaseModel): + """Constants for {mod}`~dev.tools.environment`.""" + + pylance_version: str = Path(".pylance-version").read_text(encoding="utf-8").strip() + """Pylance version.""" + + +const = Constants() + APP = App(help_format="markdown") """CLI.""" @@ -24,9 +34,9 @@ def main(): # noqa: D103 @APP.command -def init_shell(): +def sync_environment_variables(pylance_version: str = const.pylance_version): """Initialize shell.""" - log(environment.init_shell()) + log(environment.sync_environment_variables(pylance_version=pylance_version)) @APP.command @@ -100,12 +110,10 @@ def elevate_pyright_warnings(): @APP.command() def build_docs(): """Build docs.""" - run([ - "sphinx-autobuild", - "--show-traceback", - "docs _site", + run( + "sphinx-autobuild --show-traceback docs _site", *[f"--ignore **/{p}" for p in ["temp", "data", "apidocs", "*schema.json"]], - ]) + ) def log(obj): diff --git a/packages/_dev/dev/tools/environment.py b/packages/_dev/dev/tools/environment.py index 07893b5..6e93859 100644 --- a/packages/_dev/dev/tools/environment.py +++ b/packages/_dev/dev/tools/environment.py @@ -1,59 +1,90 @@ -"""Contributor environment.""" +"""Contributor environment setup.""" import subprocess -from collections.abc import Iterable from contextlib import chdir, nullcontext from io import StringIO from pathlib import Path from shlex import quote from sys import executable -from dotenv import load_dotenv +from dotenv import dotenv_values, load_dotenv +from pydantic import BaseModel, Field from pydantic_settings import ( BaseSettings, PyprojectTomlConfigSettingsSource, SettingsConfigDict, ) +import dev +from dev.modules import get_module_name -def init_shell(path: Path | None = None) -> str: - """Initialize shell.""" - with chdir(path) if path else nullcontext(): - environment = Environment().model_dump() - dotenv = "\n".join(f"{k}={v}" for k, v in environment.items()) - load_dotenv(stream=StringIO(dotenv)) - return dotenv +class Constants(BaseModel): + """Constants for {mod}`~dev.tools.environment`.""" -def run(args: str | Iterable[str] | None = None): + dev_tool_config: tuple[str, ...] = ("tool", get_module_name(dev)) + """Path to `dev` tool configuration in `pyproject.toml`.""" + pylance_version_source: str = ".pylance-version" + """Path to Pylance version file.""" + shell: list[str] = ["pwsh", "-Command"] + """Shell invocation for running arbitrary commands.""" + uv_run_wrapper: str = "./Invoke-Uv.ps1" + """Wrapper of `uv run` with extra setup.""" + env: str = ".env" + """Name of environment file.""" + + +const = Constants() + + +def sync_environment_variables( + path: Path | None = None, pylance_version: str = "", setenv: bool = True +) -> str: + """Sync `.env` with `pyproject.toml`, optionally setting environment variables.""" + path = Path(path) if path else Path.cwd() / ".env" + config_env = Config().env + if pylance_version: + config_env["PYRIGHT_PYTHON_PYLANCE_VERSION"] = pylance_version + dotenv = dotenv_values(const.env) + keys_set: list[str] = [] + for key in dotenv: + if override := config_env.get(key): + keys_set.append(key) + dotenv[key] = override + for k, v in config_env.items(): + if k not in keys_set: + dotenv[k] = v + if setenv: + load_dotenv(stream=StringIO("\n".join(f"{k}={v}" for k, v in dotenv.items()))) + return "\n".join(f"{k}={v}" for k, v in dotenv.items()) + + +def run(*args: str): """Run command.""" sep = " " - subprocess.run( - check=True, - args=[ - "pwsh", - "-Command", - sep.join([ - f"& {quote(executable)} -m", - *(([args] if isinstance(args, str) else args) or []), - ]), - ], - ) - - -class Environment(BaseSettings): - """Get environment variables from `pyproject.toml:[tool.env]`.""" - - model_config = SettingsConfigDict( - extra="allow", pyproject_toml_table_header=("tool", "env") - ) + with nullcontext() if Path(const.uv_run_wrapper).exists() else chdir(".."): + subprocess.run( + check=True, args=[*const.shell, sep.join([const.uv_run_wrapper, *args])] + ) - @classmethod - def settings_customise_sources(cls, settings_cls, **_): # pyright: ignore[reportIncompatibleMethodOverride] - """Customize so that all keys are loaded despite not being model fields.""" - return (PyprojectTomlConfigSettingsSource(settings_cls),) + +def run_dev(*args: str): + """Run command from `dev` CLI.""" + run(f"& {quote(executable)} -m", *args) def escape(path: str | Path) -> str: """Escape a path, suitable for passing to e.g. {func}`~subprocess.run`.""" return quote(Path(path).as_posix()) + + +class Config(BaseSettings): + """Get tool config from `pyproject.toml`.""" + + model_config = SettingsConfigDict(pyproject_toml_table_header=const.dev_tool_config) + env: dict[str, str] = Field(default_factory=dict) + + @classmethod + def settings_customise_sources(cls, settings_cls, **_): # pyright: ignore[reportIncompatibleMethodOverride] + """Only load from `pyproject.toml`.""" + return (PyprojectTomlConfigSettingsSource(settings_cls),) diff --git a/packages/_dev/dev/tools/warnings.py b/packages/_dev/dev/tools/warnings.py new file mode 100644 index 0000000..311d47e --- /dev/null +++ b/packages/_dev/dev/tools/warnings.py @@ -0,0 +1,12 @@ +"""Warnings.""" + +from boilercore.warnings import filter_boiler_warnings + + +def filter_boilercv_warnings(): + """Filter certain warnings for `boilercv`.""" + filter_boiler_warnings(other_warnings=WARNING_FILTERS) + + +WARNING_FILTERS = [] +"""Warning filters."""