diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 99d7915ed..0bc7851cd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -43,9 +43,11 @@ jobs: pip install -e . pip install -e ./backend - - if: ${{ !startsWith(matrix.python-version, 'pypy') }} - name: Lint - run: hatch run lint:all + - name: Run static analysis + run: hatch fmt --check + + - name: Check types + run: hatch run types:check - name: Run tests run: hatch run full diff --git a/.linkcheckerrc b/.linkcheckerrc index 9841a8738..5267f06fd 100644 --- a/.linkcheckerrc +++ b/.linkcheckerrc @@ -1,2 +1,6 @@ # https://linkchecker.github.io/linkchecker/man/linkcheckerrc.html +[filtering] +ignore= + https://docs.astral.sh/ruff/rules/.+ + [AnchorCheck] diff --git a/README.md b/README.md index dccea3de1..8f00bec1b 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,10 @@ Hatch is a modern, extensible Python project manager. - Standardized [build system](https://hatch.pypa.io/latest/build/#packaging-ecosystem) with reproducible builds by default - Robust [environment management](https://hatch.pypa.io/latest/environment/) with support for custom scripts - Configurable [Python distribution management](https://hatch.pypa.io/dev/cli/reference/#hatch-python) +- [Static analysis](https://hatch.pypa.io/dev/config/static-analysis/) with sane defaults - Easy [publishing](https://hatch.pypa.io/latest/publish/) to PyPI or other indexes - [Version](https://hatch.pypa.io/latest/version/) management -- Configurable [project generation](https://hatch.pypa.io/latest/config/project-templates/) with sane defaults +- Best practice [project generation](https://hatch.pypa.io/latest/config/project-templates/) - Responsive [CLI](https://hatch.pypa.io/latest/cli/about/), ~2-3x [faster](https://github.com/pypa/hatch/blob/hatch-v1.5.0/.github/workflows/test.yml#L76-L108) than equivalent tools ## Documentation diff --git a/docs/.hooks/render_ruff_defaults.py b/docs/.hooks/render_ruff_defaults.py new file mode 100644 index 000000000..4c434e369 --- /dev/null +++ b/docs/.hooks/render_ruff_defaults.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import os +import re +from collections import defaultdict +from functools import cache + +MARKER_VERSION = '' +MARKER_SELECTED_RULES = '' +MARKER_PER_FILE_IGNORED_RULES = '' +RULE_URLS = {'S': 'https://docs.astral.sh/ruff/rules/#flake8-bandit-s'} + + +@cache +def ruff_data(): + generated_file = os.path.join(os.getcwd(), 'src', 'hatch', 'env', 'internal', 'fmt.py') + with open(generated_file, encoding='utf-8') as f: + lines = f.read().splitlines() + + for i, line in enumerate(lines): + if line.startswith('RUFF_MINIMUM_VERSION'): + block_start = i + break + else: + message = f'Could not find RUFF_MINIMUM_VERSION in {generated_file}' + raise RuntimeError(message) + + data = {} + exec('\n'.join(lines[block_start:]), None, data) # noqa: S102 + return data + + +@cache +def get_ruff_version(): + return ruff_data()['RUFF_MINIMUM_VERSION'] + + +@cache +def get_selected_rules(): + selected_rules = defaultdict(list) + separator = re.compile(r'^(\D+)(\d+)$') + + data = ruff_data() + for rules, preview in ((data['STABLE_RULES'], False), (data['PREVIEW_RULES'], True)): + for rule in rules: + match = separator.search(rule) + if match is None: + message = f'Could not parse rule {rule}' + raise RuntimeError(message) + + group, number = match.groups() + selected_rules[group].append((number, preview)) + + lines = [] + for group, rule_data in sorted(selected_rules.items()): + rule_data.sort(key=lambda x: int(x[0])) + + parts = [] + for number, preview in rule_data: + rule = f'{group}{number}' + part = f'[{rule}](https://docs.astral.sh/ruff/rules/{rule})' + if preview: + part += '^P^' + parts.append(part) + + lines.append(f'- {", ".join(parts)}') + + return '\n'.join(lines) + + +@cache +def get_per_file_ignored_rules(): + lines = [] + for glob, rules in sorted(ruff_data()['PER_FILE_IGNORED_RULES'].items()): + parts = [] + for rule in rules: + url = RULE_URLS.get(rule) or f'https://docs.astral.sh/ruff/rules/{rule}' + parts.append(f'[{rule}]({url})') + + lines.append(f'- `{glob}`: {", ".join(parts)}') + + return '\n'.join(lines) + + +def on_page_read_source( + page, + config, # noqa: ARG001 +): + with open(page.file.abs_src_path, encoding='utf-8') as f: + return ( + f.read() + .replace(MARKER_VERSION, get_ruff_version()) + .replace(MARKER_SELECTED_RULES, get_selected_rules()) + .replace(MARKER_PER_FILE_IGNORED_RULES, get_per_file_ignored_rules()) + ) diff --git a/docs/config/static-analysis.md b/docs/config/static-analysis.md new file mode 100644 index 000000000..aef7fdcd0 --- /dev/null +++ b/docs/config/static-analysis.md @@ -0,0 +1,101 @@ +# Static analysis configuration + +----- + +Static analysis performed by the [`fmt`](../cli/reference.md#hatch-fmt) command is backed entirely by [Ruff](https://github.com/astral-sh/ruff). + +Hatch provides [default settings](#default-settings) that user configuration can [extend](#extending-config). + +## Extending config + +When defining your configuration, be sure to use options that are prefixed by `extend-` such as [`extend-select`](https://docs.astral.sh/ruff/settings/#extend-select), for example: + +=== ":octicons-file-code-16: pyproject.toml" + + ```toml + [tool.ruff.lint] + preview = true + extend-select = ["C901"] + + [tool.ruff.lint.extend-per-file-ignores] + "docs/.hooks/*" = ["INP001", "T201"] + + [tool.ruff.lint.isort] + known-first-party = ["foo", "bar"] + + [tool.ruff.format] + preview = true + quote-style = "single" + ``` + +=== ":octicons-file-code-16: ruff.toml" + + ```toml + [lint] + preview = true + extend-select = ["C901"] + + [lint.extend-per-file-ignores] + "docs/.hooks/*" = ["INP001", "T201"] + + [lint.isort] + known-first-party = ["foo", "bar"] + + [format] + preview = true + quote-style = "single" + ``` + +!!! note + When not [persisting config](#persistent-config), there is no need to explicitly [extend](https://docs.astral.sh/ruff/settings/#extend) the defaults as Hatch automatically handles that. + +## Persistent config + +If you want to store the default configuration in the project, set an explicit path like so: + +```toml config-example +[tool.hatch.format] +config-path = "ruff_defaults.toml" +``` + +Then instruct Ruff to consider your configuration as an extension of the default file: + +=== ":octicons-file-code-16: pyproject.toml" + + ```toml + [tool.ruff] + extend = "ruff_defaults.toml" + ``` + +=== ":octicons-file-code-16: ruff.toml" + + ```toml + extend = "ruff_defaults.toml" + ``` + +Anytime you wish to update the defaults (such as when upgrading Hatch), you must run the [`fmt`](../cli/reference.md#hatch-fmt) command once with the `--sync` flag e.g.: + +``` +hatch fmt --check --sync +``` + +!!! tip + This is the recommended approach since it allows other tools like IDEs to use the default configuration. + +## Default settings + +### Non-rule settings + +- [Line length](https://docs.astral.sh/ruff/settings/#line-length) set to 120 +- Only absolute imports [are allowed](https://docs.astral.sh/ruff/settings/#flake8-tidy-imports-ban-relative-imports), [except for tests](#per-file-ignored-rules) +- The normalized [project name](metadata.md#name) is a [known first party](https://docs.astral.sh/ruff/settings/#isort-known-first-party) import + +### Per-file ignored rules + + + +### Selected rules + +The following rules are based on version of Ruff. + + diff --git a/docs/history/hatch.md b/docs/history/hatch.md index 82997f0f8..a5e2a6ff9 100644 --- a/docs/history/hatch.md +++ b/docs/history/hatch.md @@ -19,11 +19,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Support Python 3.12 - Add installers and standalone binaries - Add the ability to manage Python installations +- Add `fmt` command - The `virtual` environment type can now automatically download requested versions of Python that are not installed - Add `dependency_hash` method to the `environment` interface - The state of installed dependencies for environments is saved as metadata so if dependency definitions have not changed then no checking is performed, which can be computationally expensive - The `build` command now supports backends other than Hatchling -- For new project templates rely only on `requires-python` for configuring the target version Ruff and Black - The default is now `__TOKEN__` when prompting for a username for the `publish` command - Bump the minimum supported version of Hatchling to 1.17.1 - Bump the minimum supported version of `click` to 8.0.6 diff --git a/docs/index.md b/docs/index.md index 85d4ee387..98f922905 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,9 +18,10 @@ Hatch is a modern, extensible Python project manager. - Standardized [build system](build.md#packaging-ecosystem) with reproducible builds by default - Robust [environment management](environment.md) with support for custom scripts - Configurable [Python distribution management](cli/reference.md#hatch-python) +- [Static analysis](config/static-analysis.md) with sane defaults - Easy [publishing](publish.md) to PyPI or other indexes - [Version management](version.md) -- Configurable [project generation](config/project-templates.md) with sane defaults +- Best practice [project generation](config/project-templates.md) - Responsive [CLI](cli/about.md), ~2-3x [faster](https://github.com/pypa/hatch/blob/hatch-v1.5.0/.github/workflows/test.yml#L76-L108) than equivalent tools ## License diff --git a/hatch.toml b/hatch.toml index 47e28dc92..5fea2b621 100644 --- a/hatch.toml +++ b/hatch.toml @@ -44,26 +44,12 @@ report-uncovered-html = "coverage html --skip-covered --skip-empty" generate-summary = "python scripts/generate_coverage_summary.py" write-summary-report = "python scripts/write_coverage_summary_report.py" -[envs.lint] -detached = true +[envs.types] dependencies = [ "mypy>=1.0.0", - "ruff==0.1.6", -] -[envs.lint.scripts] -typing = "mypy --install-types --non-interactive {args:backend/src/hatchling src/hatch tests}" -style = [ - "ruff check {args:.}", - "ruff format --check --diff {args:.}", -] -fmt = [ - "ruff check --fix {args:.}", - "ruff format {args:.}", -] -all = [ - "style", - "typing", ] +[envs.types.scripts] +check = "mypy --install-types --non-interactive {args:backend/src/hatchling src/hatch tests}" [envs.docs] dependencies = [ @@ -126,16 +112,22 @@ version = "cd backend && hatch version {args}" detached = true dependencies = [ "httpx", + "ruff", ] [envs.upkeep.scripts] update-hatch = [ "update-distributions", + "update-ruff", ] update-hatchling = [ "update-licenses", ] update-distributions = "python scripts/update_distributions.py" update-licenses = "python backend/scripts/update_licenses.py" +update-ruff = [ + "python -m pip install --disable-pip-version-check --upgrade ruff", + "python scripts/update_ruff.py", +] [envs.release] detached = true diff --git a/mkdocs.yml b/mkdocs.yml index 60e3ac15b..d3ac20817 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,6 +69,7 @@ nav: - Environments: - Overview: config/environment/overview.md - Advanced: config/environment/advanced.md + - Static analysis: config/static-analysis.md - Context formatting: config/context.md - Project templates: config/project-templates.md - Hatch: config/hatch.md @@ -127,6 +128,7 @@ watch: hooks: - docs/.hooks/expand_blocks.py - docs/.hooks/inject_version.py +- docs/.hooks/render_ruff_defaults.py plugins: # Built-in diff --git a/ruff.toml b/ruff.toml index cb253ea01..2a51462c6 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,601 +1,18 @@ -line-length = 120 - [lint] preview = true -select = [ - "A001", - "A002", - "A003", - "ARG001", - "ARG002", - "ARG003", - "ARG004", - "ARG005", - "ASYNC100", - "ASYNC101", - "ASYNC102", - "B002", - "B003", - "B004", - "B005", - "B006", - "B007", - "B008", - "B009", - "B010", - "B011", - "B012", - "B013", - "B014", - "B015", - "B016", - "B017", - "B018", - "B019", - "B020", - "B021", - "B022", - "B023", - "B024", - "B025", - "B026", - "B028", - "B029", - "B030", - "B031", - "B032", - "B033", - "B034", - "B904", - "B905", - "BLE001", - "C400", - "C401", - "C402", - "C403", - "C404", - "C405", - "C406", - "C408", - "C409", - "C410", - "C411", - "C413", - "C414", - "C415", - "C416", - "C417", - "C418", - "C419", - "COM818", - "DTZ001", - "DTZ002", - "DTZ003", - "DTZ004", - "DTZ005", - "DTZ006", - "DTZ007", - "DTZ011", - "DTZ012", - "E101", - "E112", - "E113", - "E115", - "E116", - "E201", - "E202", - "E203", - "E211", - "E221", - "E222", - "E223", - "E224", - "E225", - "E226", - "E227", - "E228", - "E231", - "E241", - "E242", - "E251", - "E252", - "E261", - "E262", - "E265", - "E266", - "E271", - "E272", - "E273", - "E274", - "E275", - "E401", - "E402", - "E501", - "E701", - "E702", - "E703", - "E711", - "E712", - "E713", - "E714", - "E721", - "E722", - "E731", - "E741", - "E742", - "E743", - "E902", - "E999", - "EM101", - "EM102", - "EM103", - "EXE001", - "EXE002", - "EXE003", - "EXE004", - "EXE005", - "F401", - "F402", - "F403", - "F404", - "F405", - "F406", - "F407", - "F501", - "F502", - "F503", - "F504", - "F505", - "F506", - "F507", - "F508", - "F509", - "F521", - "F522", - "F523", - "F524", - "F525", - "F541", - "F601", - "F602", - "F621", - "F622", - "F631", - "F632", - "F633", - "F634", - "F701", - "F702", - "F704", - "F706", - "F707", - "F722", - "F811", - "F821", - "F822", - "F823", - "F841", - "F842", - "F901", - "FA100", - "FA102", - "FBT001", - "FBT002", - "FLY002", - "FURB105", - "FURB113", - "FURB131", - "FURB132", - "FURB136", - "FURB145", - "FURB148", - "FURB152", - "FURB168", - "FURB169", - "FURB171", - "FURB177", - "G001", - "G002", - "G003", - "G004", - "G010", - "G101", - "G201", - "G202", - "I001", - "I002", - "ICN001", - "ICN002", - "ICN003", - "INP001", - "INT001", - "INT002", - "INT003", - "ISC003", - "LOG001", - "LOG002", - "LOG007", - "LOG009", - "N801", - "N802", - "N803", - "N804", - "N805", - "N806", - "N807", - "N811", - "N812", - "N813", - "N814", - "N815", - "N816", - "N817", - "N818", - "N999", - "PERF101", - "PERF102", - "PERF401", - "PERF402", - "PERF403", - "PGH001", - "PGH002", - "PGH005", - "PIE790", - "PIE794", - "PIE796", - "PIE800", - "PIE804", - "PIE807", - "PIE808", - "PIE810", - "PLC0105", - "PLC0131", - "PLC0132", - "PLC0205", - "PLC0208", - "PLC0414", - "PLC1901", - "PLC2401", - "PLC2403", - "PLC3002", - "PLE0100", - "PLE0101", - "PLE0116", - "PLE0117", - "PLE0118", - "PLE0241", - "PLE0302", - "PLE0307", - "PLE0604", - "PLE0605", - "PLE0704", - "PLE1142", - "PLE1205", - "PLE1206", - "PLE1300", - "PLE1307", - "PLE1310", - "PLE1507", - "PLE1700", - "PLE2502", - "PLE2510", - "PLE2512", - "PLE2513", - "PLE2514", - "PLE2515", - "PLR0124", - "PLR0133", - "PLR0206", - "PLR0402", - "PLR1701", - "PLR1704", - "PLR1706", - "PLR1711", - "PLR1714", - "PLR1722", - "PLR2004", - "PLR5501", - "PLR6201", - "PLR6301", - "PLW0108", - "PLW0120", - "PLW0127", - "PLW0129", - "PLW0131", - "PLW0406", - "PLW0602", - "PLW0603", - "PLW0604", - "PLW0711", - "PLW1501", - "PLW1508", - "PLW1509", - "PLW1510", - "PLW1514", - "PLW1641", - "PLW2101", - "PLW2901", - "PLW3201", - "PLW3301", - "PT002", - "PT003", - "PT006", - "PT007", - "PT008", - "PT009", - "PT010", - "PT011", - "PT012", - "PT013", - "PT014", - "PT015", - "PT016", - "PT017", - "PT018", - "PT019", - "PT020", - "PT021", - "PT022", - "PT024", - "PT025", - "PT026", - "PT027", - "PYI001", - "PYI002", - "PYI003", - "PYI004", - "PYI005", - "PYI006", - "PYI007", - "PYI008", - "PYI009", - "PYI010", - "PYI011", - "PYI012", - "PYI013", - "PYI014", - "PYI015", - "PYI016", - "PYI017", - "PYI018", - "PYI019", - "PYI020", - "PYI021", - "PYI024", - "PYI025", - "PYI026", - "PYI029", - "PYI030", - "PYI032", - "PYI033", - "PYI034", - "PYI035", - "PYI036", - "PYI041", - "PYI042", - "PYI043", - "PYI044", - "PYI045", - "PYI046", - "PYI047", - "PYI048", - "PYI049", - "PYI050", - "PYI051", - "PYI052", - "PYI053", - "PYI054", - "PYI055", - "PYI056", - "RET503", - "RET504", - "RET505", - "RET506", - "RET507", - "RET508", - "RSE102", - "RUF001", - "RUF002", - "RUF003", - "RUF005", - "RUF006", - "RUF007", - "RUF008", - "RUF009", - "RUF010", - "RUF011", - "RUF012", - "RUF013", - "RUF015", - "RUF016", - "RUF017", - "RUF018", - "RUF019", - "RUF100", - "RUF200", - "S101", - "S102", - "S103", - "S104", - "S105", - "S106", - "S107", - "S108", - "S110", - "S112", - "S113", - "S201", - "S301", - "S302", - "S303", - "S304", - "S305", - "S306", - "S307", - "S308", - "S310", - "S311", - "S312", - "S313", - "S314", - "S315", - "S316", - "S317", - "S318", - "S319", - "S320", - "S321", - "S323", - "S324", - "S501", - "S505", - "S506", - "S507", - "S508", - "S509", - "S601", - "S602", - "S604", - "S605", - "S606", - "S607", - "S608", - "S609", - "S612", - "S701", - "S702", - "SIM101", - "SIM102", - "SIM103", - "SIM105", - "SIM107", - "SIM108", - "SIM109", - "SIM110", - "SIM112", - "SIM114", - "SIM115", - "SIM116", - "SIM117", - "SIM118", - "SIM201", - "SIM202", - "SIM208", - "SIM210", - "SIM211", - "SIM212", - "SIM220", - "SIM221", - "SIM222", - "SIM223", - "SIM300", - "SIM910", - "SLF001", - "SLOT000", - "SLOT001", - "SLOT002", - "T100", - "T201", - "T203", - "TCH001", - "TCH002", - "TCH003", - "TCH004", - "TCH005", - "TD004", - "TD005", - "TD006", - "TD007", - "TID251", - "TID252", - "TID253", - "TRIO100", - "TRIO105", - "TRIO109", - "TRIO110", - "TRIO115", - "TRY002", - "TRY003", - "TRY004", - "TRY200", - "TRY201", - "TRY300", - "TRY301", - "TRY302", - "TRY400", - "TRY401", - "UP001", - "UP003", - "UP004", - "UP005", - "UP006", - "UP007", - "UP008", - "UP009", - "UP010", - "UP011", - "UP012", - "UP013", - "UP014", - "UP015", - "UP017", - "UP018", - "UP019", - "UP020", - "UP021", - "UP022", - "UP023", - "UP024", - "UP025", - "UP026", - "UP027", - "UP028", - "UP029", - "UP030", - "UP031", - "UP032", - "UP033", - "UP034", - "UP035", - "UP036", - "UP037", - "UP038", - "UP039", - "UP040", - "UP041", - "W291", - "W292", - "W293", - "W505", - "W605", - "YTT101", - "YTT102", - "YTT103", - "YTT201", - "YTT202", - "YTT203", - "YTT204", - "YTT301", - "YTT302", - "YTT303", +ignore = [ + # Allow lazy imports for responsive CLI + "PLC0415", ] -[isort] -known-first-party = ["hatch", "hatchling"] - -[flake8-quotes] -inline-quotes = "single" - -[flake8-tidy-imports] -ban-relative-imports = "all" - -[per-file-ignores] +[lint.extend-per-file-ignores] "backend/src/hatchling/bridge/app.py" = ["T201"] "backend/tests/downstream/integrate.py" = ["INP001", "T201"] "docs/.hooks/*" = ["INP001", "T201"] "release/macos/build_pkg.py" = ["INP001"] -"**/scripts/*" = ["INP001", "T201"] -"**/tests/**/*" = ["PLC1901", "PLR2004", "PLR6301", "S", "TID252"] + +[lint.isort] +known-first-party = ["hatch", "hatchling"] [format] preview = true diff --git a/scripts/update_ruff.py b/scripts/update_ruff.py new file mode 100644 index 000000000..44fc2badf --- /dev/null +++ b/scripts/update_ruff.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import json +import re +import subprocess +import sys +from importlib.metadata import version +from pathlib import Path + +from utils import ROOT + +# fmt: off +UNSELECTED_RULE_PATTERNS: list[str] = [ + # Allow non-abstract empty methods in abstract base classes + 'B027', + # Allow boolean positional values in function calls, like `dict.get(... True)` + 'FBT003', + # Ignore complexity + 'C901', 'PLR0904', 'PLR0911', 'PLR0912', 'PLR0913', 'PLR0915', 'PLR0916', + # These are dependent on projects themselves + 'AIR\\d+', 'CPY\\d+', 'D\\d+', 'DJ\\d+', 'NPY\\d+', 'PD\\d+', + # Many projects either don't have type annotations or it would take much effort to satisfy this + 'ANN\\d+', + # Don't be too strict about TODOs as not everyone uses them the same way + 'FIX\\d+', 'TD001', 'TD002', 'TD003', + # There are valid reasons to not use pathlib such as performance and import cost + 'PTH\\d+', 'FURB101', + # Conflicts with type checking + 'RET501', 'RET502', + # Under review https://github.com/astral-sh/ruff/issues/8796 + 'PT001', 'PT004', 'PT005', 'PT023', + # Buggy https://github.com/astral-sh/ruff/issues/4845 + 'ERA001', + # Too prone to false positives and might be removed https://github.com/astral-sh/ruff/issues/4045 + 'S603', + # Too prone to false positives https://github.com/astral-sh/ruff/issues/8761 + 'SIM401', + # Allow for easy ignores + 'PGH003', 'PGH004', + # This is required sometimes, and doesn't matter on Python 3.11+ + 'PERF203', + # Potentially unnecessary on Python 3.12+ + 'FURB140', + # Conflicts with formatter, see: + # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules + 'COM812', 'COM819', 'D206', 'D300', 'E111', 'E114', 'E117', 'ISC001', 'ISC002', 'Q000', 'Q001', 'Q002', 'Q003', 'Q004', 'W191', # noqa: E501 +] +PER_FILE_IGNORED_RULES: dict[str, list[str]] = { + '**/scripts/*': [ + # Implicit namespace packages + 'INP001', + # Print statements + 'T201', + ], + '**/tests/**/*': [ + # Empty string comparisons + 'PLC1901', + # Magic values + 'PLR2004', + # Methods that don't use `self` + 'PLR6301', + # Potential security issues like assert statements and hardcoded passwords + 'S', + # Relative imports + 'TID252', + ], +} +# fmt: on + + +def main(): + project_root = Path(__file__).resolve().parent.parent + data_file = project_root / 'src' / 'hatch' / 'env' / 'internal' / 'fmt.py' + + lines = data_file.read_text(encoding='utf-8').splitlines() + for i, line in enumerate(lines): + if line.startswith('RUFF_MINIMUM_VERSION'): + block_start = i + break + else: + message = 'Could not find RUFF_MINIMUM_VERSION' + raise ValueError(message) + + del lines[block_start:] + + process = subprocess.run( # noqa: PLW1510 + [sys.executable, '-m', 'ruff', 'rule', '--all', '--output-format', 'json'], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + encoding='utf-8', + cwd=str(ROOT), + ) + if process.returncode: + raise OSError(process.stdout) + + ignored_pattern = f'^({"|".join(UNSELECTED_RULE_PATTERNS)})$' + stable_rules: set[str] = set() + preview_rules: set[str] = set() + for rule in json.loads(process.stdout): + code = rule['code'] + if re.search(ignored_pattern, code): + continue + + if rule['preview']: + preview_rules.add(code) + else: + stable_rules.add(code) + + latest_version = version('ruff') + lines.append(f'RUFF_MINIMUM_VERSION: str = {latest_version!r}') + + lines.append('STABLE_RULES: tuple[str, ...] = (') + lines.extend(f' {rule!r},' for rule in sorted(stable_rules)) + lines.append(')') + + lines.append('PREVIEW_RULES: tuple[str, ...] = (') + lines.extend(f' {rule!r},' for rule in sorted(preview_rules)) + lines.append(')') + + lines.append('PER_FILE_IGNORED_RULES: dict[str, list[str]] = {') + for ignored_glob, ignored_rules in sorted(PER_FILE_IGNORED_RULES.items()): + lines.append(f' {ignored_glob!r}: [') + lines.extend(f' {rule!r},' for rule in sorted(ignored_rules)) + lines.append(' ],') + lines.append('}') + + lines.append('') + data_file.write_text('\n'.join(lines), encoding='utf-8') + + +if __name__ == '__main__': + main() diff --git a/src/hatch/cli/__init__.py b/src/hatch/cli/__init__.py index 92f92fbb1..da2d776df 100644 --- a/src/hatch/cli/__init__.py +++ b/src/hatch/cli/__init__.py @@ -9,6 +9,7 @@ from hatch.cli.config import config from hatch.cli.dep import dep from hatch.cli.env import env +from hatch.cli.fmt import fmt from hatch.cli.new import new from hatch.cli.project import project from hatch.cli.publish import publish @@ -105,10 +106,14 @@ def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interact if interactive is None and running_in_ci(): interactive = False - app = Application(ctx.exit, verbose - quiet, color, interactive) + app = Application(ctx.exit, verbosity=verbose - quiet, enable_color=color, interactive=interactive) app.env_active = os.environ.get(AppEnvVars.ENV_ACTIVE) - if app.env_active and ctx.get_parameter_source('env_name').name == 'DEFAULT': + if ( + app.env_active + and (param_source := ctx.get_parameter_source('env_name')) is not None + and param_source.name == 'DEFAULT' + ): app.env = app.env_active else: app.env = env_name @@ -196,6 +201,7 @@ def hatch(ctx: click.Context, env_name, project, verbose, quiet, color, interact hatch.add_command(config) hatch.add_command(dep) hatch.add_command(env) +hatch.add_command(fmt) hatch.add_command(new) hatch.add_command(project) hatch.add_command(publish) diff --git a/src/hatch/cli/fmt/__init__.py b/src/hatch/cli/fmt/__init__.py new file mode 100644 index 000000000..bba41313e --- /dev/null +++ b/src/hatch/cli/fmt/__init__.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import click + +if TYPE_CHECKING: + from hatch.cli.application import Application + + +@click.command(short_help='Lint and format source code') +@click.argument('args', nargs=-1) +@click.option('--check', is_flag=True, help='Only check for errors rather than fixing them') +@click.option('--preview/--no-preview', default=None, help='Preview new rules and formatting') +@click.option('--linter', '-l', is_flag=True, help='Only run the linter') +@click.option('--formatter', '-f', is_flag=True, help='Only run the formatter') +@click.option('--sync', is_flag=True, help='Sync the default config file with the current version of Hatch') +@click.pass_obj +def fmt( + app: Application, + *, + args: tuple[str, ...], + check: bool, + preview: bool | None, + linter: bool, + formatter: bool, + sync: bool, +): + """Format and lint source code.""" + from hatch.env.internal.fmt import InternalFormatEnvironment + + if linter and formatter: + app.abort('Cannot specify both --linter and --formatter') + + environment = cast( + InternalFormatEnvironment, app.prepare_internal_environment('fmt', config=app.project.config.fmt) + ) + if sync and not environment.config_path: + app.abort('The --sync flag can only be used when the `tool.hatch.format.config-path` option is defined') + + commands: list[list[str]] = [] + if not formatter: + commands.append(environment.get_linter_command(*args, check=check, preview=preview)) + + if not linter: + commands.append(environment.get_formatter_command(*args, check=check, preview=preview)) + + with app.project.location.as_cwd(), environment.command_context(): + if not environment.config_path or sync: + environment.write_config_file(preview=preview) + + for command in commands: + process = app.platform.run_command(command) + if process.returncode: + app.abort(code=process.returncode) diff --git a/src/hatch/cli/terminal.py b/src/hatch/cli/terminal.py index c1faf5104..e110d6999 100644 --- a/src/hatch/cli/terminal.py +++ b/src/hatch/cli/terminal.py @@ -132,7 +132,7 @@ def __output(self, text): class Terminal: - def __init__(self, verbosity, enable_color, interactive): + def __init__(self, *, verbosity: int, enable_color: bool | None, interactive: bool | None): self.verbosity = verbosity self.console = Console( force_terminal=enable_color, @@ -158,7 +158,7 @@ def __init__(self, verbosity, enable_color, interactive): self._style_spinner = 'simpleDotsScrolling' @cached_property - def kv_separator(self) -> Style: + def kv_separator(self) -> Text: return self.style_warning('->') def style_success(self, text: str) -> Text: @@ -310,8 +310,8 @@ def status(self) -> BorrowedStatus: is_interactive=self.console.is_interactive, verbosity=self.verbosity, spinner_style=self._style_spinner, - waiting_style=self._style_level_waiting, - success_style=self._style_level_success, + waiting_style=self._style_level_waiting, # type: ignore[arg-type] + success_style=self._style_level_success, # type: ignore[arg-type] initializer=lambda: setattr(self.platform, 'displaying_status', True), # type: ignore[attr-defined] finalizer=lambda: setattr(self.platform, 'displaying_status', False), # type: ignore[attr-defined] ) diff --git a/src/hatch/config/utils.py b/src/hatch/config/utils.py index a20e3a18f..c1fed4270 100644 --- a/src/hatch/config/utils.py +++ b/src/hatch/config/utils.py @@ -1,7 +1,14 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + import tomlkit -from tomlkit.toml_document import TOMLDocument -from hatch.utils.fs import Path +if TYPE_CHECKING: + from tomlkit.items import InlineTable + from tomlkit.toml_document import TOMLDocument + + from hatch.utils.fs import Path def save_toml_document(document: TOMLDocument, path: Path): @@ -9,5 +16,5 @@ def save_toml_document(document: TOMLDocument, path: Path): path.write_atomic(tomlkit.dumps(document), 'w', encoding='utf-8') -def create_toml_document(config: dict) -> TOMLDocument: +def create_toml_document(config: dict) -> InlineTable: return tomlkit.item(config) diff --git a/src/hatch/env/internal/__init__.py b/src/hatch/env/internal/__init__.py index 2841bb882..e71526649 100644 --- a/src/hatch/env/internal/__init__.py +++ b/src/hatch/env/internal/__init__.py @@ -12,5 +12,10 @@ def get_internal_environment_class(env_name: str) -> type[InternalEnvironment]: return InternalBuildEnvironment + if env_name == 'fmt': + from hatch.env.internal.fmt import InternalFormatEnvironment + + return InternalFormatEnvironment + message = f'Unknown internal environment: {env_name}' raise ValueError(message) diff --git a/src/hatch/env/internal/fmt.py b/src/hatch/env/internal/fmt.py new file mode 100644 index 000000000..aae67b339 --- /dev/null +++ b/src/hatch/env/internal/fmt.py @@ -0,0 +1,780 @@ +from __future__ import annotations + +from functools import cached_property +from typing import TYPE_CHECKING, Any + +from hatch.env.internal.interface import InternalEnvironment + +if TYPE_CHECKING: + from hatch.utils.fs import Path + + +class InternalFormatEnvironment(InternalEnvironment): + def get_base_config(self) -> dict: # noqa: PLR6301 + return { + 'skip-install': True, + 'dependencies': [f'ruff=={RUFF_MINIMUM_VERSION}'], + } + + @cached_property + def config_path(self) -> str: + return self.config.get('config-path', '') + + def get_linter_command(self, *args, check: bool, preview: bool | None) -> list[str]: + if preview is None: + preview = self.linter_preview + + command_args = ['ruff', 'check'] + if not self.config_path: + if self.internal_user_config_file is None: + command_args.extend(['--config', str(self.internal_config_file)]) + else: + command_args.extend(['--config', str(self.internal_user_config_file)]) + + if not check: + command_args.append('--fix') + + if preview: + command_args.append('--preview') + + if args: + command_args.extend(args) + else: + command_args.append('.') + + return command_args + + def get_formatter_command(self, *args, check: bool, preview: bool | None) -> list[str]: + if preview is None: + preview = self.formatter_preview + + command_args = ['ruff', 'format'] + if not self.config_path: + if self.internal_user_config_file is None: + command_args.extend(['--config', str(self.internal_config_file)]) + else: + command_args.extend(['--config', str(self.internal_user_config_file)]) + + if check: + command_args.extend(['--check', '--diff']) + + if preview: + command_args.append('--preview') + + if args: + command_args.extend(args) + else: + command_args.append('.') + + return command_args + + @cached_property + def internal_config_file(self) -> Path: + from base64 import urlsafe_b64encode + from hashlib import sha256 + + project_id = urlsafe_b64encode(sha256(str(self.root).encode()).digest())[:8].decode() + return self.isolated_data_directory / '.config' / project_id / 'ruff_defaults.toml' + + def construct_config_file(self, *, preview: bool | None) -> str: + if preview is None: + preview = self.linter_preview + + lines = ['line-length = 120', '', '[lint]'] + + # Selected rules + rules = list(STABLE_RULES) + if preview: + rules.extend(PREVIEW_RULES) + rules.sort() + + lines.append('select = [') + lines.extend(f' "{rule}",' for rule in rules) + lines.extend((']', '')) + + # Ignored rules + lines.append('[lint.per-file-ignores]') + for glob, ignored_rules in PER_FILE_IGNORED_RULES.items(): + lines.append(f'"{glob}" = [') + lines.extend(f' "{ignored_rule}",' for ignored_rule in ignored_rules) + lines.append(']') + + # Default config + lines.extend( + ( + '', + '[lint.flake8-tidy-imports]', + 'ban-relative-imports = "all"', + '', + '[lint.isort]', + f'known-first-party = ["{self.metadata.name.replace("-", "_")}"]', + ) + ) + + # Ensure the file ends with a newline to satisfy other linters + lines.append('') + + return '\n'.join(lines) + + def write_config_file(self, *, preview: bool | None) -> None: + config_contents = self.construct_config_file(preview=preview) + if self.config_path: + (self.root / self.config_path).write_atomic(config_contents, 'w', encoding='utf-8') + return + + self.internal_config_file.parent.ensure_dir_exists() + self.internal_config_file.write_text(config_contents) + + # TODO: remove everything below once this is fixed https://github.com/astral-sh/ruff/issues/8737 + if self.internal_user_config_file is None: + return + + if self.user_config_file is None: + return + + old_contents = self.user_config_file.read_text() + config_path = str(self.internal_config_file).replace('\\', '\\\\') + contents = ( + f'{old_contents}\n[tool.ruff]\nextend = "{config_path}"' + if self.user_config_file.name == 'pyproject.toml' + else f'extend = "{config_path}"\n{old_contents}' + ) + self.internal_user_config_file.write_text(contents) + + @cached_property + def internal_user_config_file(self) -> Path | None: + if self.user_config_file is None: + return None + + return self.internal_config_file.parent / self.user_config_file.name + + @cached_property + def user_config_file(self) -> Path | None: + # https://docs.astral.sh/ruff/configuration/#config-file-discovery + for possible_config in ('.ruff.toml', 'ruff.toml', 'pyproject.toml'): + if (config_file := (self.root / possible_config)).is_file(): + return config_file + + return None + + @cached_property + def user_config(self) -> dict[str, Any]: + if self.user_config_file is None: + return {} + + from hatch.utils.toml import load_toml_data + + return load_toml_data(self.user_config_file.read_text()) + + @cached_property + def linter_preview(self) -> bool: + return self.get_config('lint').get('preview', False) + + @cached_property + def formatter_preview(self) -> bool: + return self.get_config('format').get('preview', False) + + def get_config(self, section: str) -> dict[str, Any]: + if self.user_config_file is None: + return {} + + if self.user_config_file.name == 'pyproject.toml': + return self.user_config.get('tool', {}).get('ruff', {}).get(section, {}) + + return self.user_config.get(section, {}) + + +RUFF_MINIMUM_VERSION: str = '0.1.6' +STABLE_RULES: tuple[str, ...] = ( + 'A001', + 'A002', + 'A003', + 'ARG001', + 'ARG002', + 'ARG003', + 'ARG004', + 'ARG005', + 'ASYNC100', + 'ASYNC101', + 'ASYNC102', + 'B002', + 'B003', + 'B004', + 'B005', + 'B006', + 'B007', + 'B008', + 'B009', + 'B010', + 'B011', + 'B012', + 'B013', + 'B014', + 'B015', + 'B016', + 'B017', + 'B018', + 'B019', + 'B020', + 'B021', + 'B022', + 'B023', + 'B024', + 'B025', + 'B026', + 'B028', + 'B029', + 'B030', + 'B031', + 'B032', + 'B033', + 'B034', + 'B904', + 'B905', + 'BLE001', + 'C400', + 'C401', + 'C402', + 'C403', + 'C404', + 'C405', + 'C406', + 'C408', + 'C409', + 'C410', + 'C411', + 'C413', + 'C414', + 'C415', + 'C416', + 'C417', + 'C418', + 'C419', + 'COM818', + 'DTZ001', + 'DTZ002', + 'DTZ003', + 'DTZ004', + 'DTZ005', + 'DTZ006', + 'DTZ007', + 'DTZ011', + 'DTZ012', + 'E101', + 'E401', + 'E402', + 'E501', + 'E701', + 'E702', + 'E703', + 'E711', + 'E712', + 'E713', + 'E714', + 'E721', + 'E722', + 'E731', + 'E741', + 'E742', + 'E743', + 'E902', + 'E999', + 'EM101', + 'EM102', + 'EM103', + 'EXE001', + 'EXE002', + 'EXE003', + 'EXE004', + 'EXE005', + 'F401', + 'F402', + 'F403', + 'F404', + 'F405', + 'F406', + 'F407', + 'F501', + 'F502', + 'F503', + 'F504', + 'F505', + 'F506', + 'F507', + 'F508', + 'F509', + 'F521', + 'F522', + 'F523', + 'F524', + 'F525', + 'F541', + 'F601', + 'F602', + 'F621', + 'F622', + 'F631', + 'F632', + 'F633', + 'F634', + 'F701', + 'F702', + 'F704', + 'F706', + 'F707', + 'F722', + 'F811', + 'F821', + 'F822', + 'F823', + 'F841', + 'F842', + 'F901', + 'FA100', + 'FA102', + 'FBT001', + 'FBT002', + 'FLY002', + 'G001', + 'G002', + 'G003', + 'G004', + 'G010', + 'G101', + 'G201', + 'G202', + 'I001', + 'I002', + 'ICN001', + 'ICN002', + 'ICN003', + 'INP001', + 'INT001', + 'INT002', + 'INT003', + 'ISC003', + 'N801', + 'N802', + 'N803', + 'N804', + 'N805', + 'N806', + 'N807', + 'N811', + 'N812', + 'N813', + 'N814', + 'N815', + 'N816', + 'N817', + 'N818', + 'N999', + 'PERF101', + 'PERF102', + 'PERF401', + 'PERF402', + 'PGH001', + 'PGH002', + 'PGH005', + 'PIE790', + 'PIE794', + 'PIE796', + 'PIE800', + 'PIE804', + 'PIE807', + 'PIE808', + 'PIE810', + 'PLC0105', + 'PLC0131', + 'PLC0132', + 'PLC0205', + 'PLC0208', + 'PLC0414', + 'PLC3002', + 'PLE0100', + 'PLE0101', + 'PLE0116', + 'PLE0117', + 'PLE0118', + 'PLE0241', + 'PLE0302', + 'PLE0307', + 'PLE0604', + 'PLE0605', + 'PLE1142', + 'PLE1205', + 'PLE1206', + 'PLE1300', + 'PLE1307', + 'PLE1310', + 'PLE1507', + 'PLE1700', + 'PLE2502', + 'PLE2510', + 'PLE2512', + 'PLE2513', + 'PLE2514', + 'PLE2515', + 'PLR0124', + 'PLR0133', + 'PLR0206', + 'PLR0402', + 'PLR1701', + 'PLR1711', + 'PLR1714', + 'PLR1722', + 'PLR2004', + 'PLR5501', + 'PLW0120', + 'PLW0127', + 'PLW0129', + 'PLW0131', + 'PLW0406', + 'PLW0602', + 'PLW0603', + 'PLW0711', + 'PLW1508', + 'PLW1509', + 'PLW1510', + 'PLW2901', + 'PLW3301', + 'PT002', + 'PT003', + 'PT006', + 'PT007', + 'PT008', + 'PT009', + 'PT010', + 'PT011', + 'PT012', + 'PT013', + 'PT014', + 'PT015', + 'PT016', + 'PT017', + 'PT018', + 'PT019', + 'PT020', + 'PT021', + 'PT022', + 'PT024', + 'PT025', + 'PT026', + 'PT027', + 'PYI001', + 'PYI002', + 'PYI003', + 'PYI004', + 'PYI005', + 'PYI006', + 'PYI007', + 'PYI008', + 'PYI009', + 'PYI010', + 'PYI011', + 'PYI012', + 'PYI013', + 'PYI014', + 'PYI015', + 'PYI016', + 'PYI017', + 'PYI018', + 'PYI019', + 'PYI020', + 'PYI021', + 'PYI024', + 'PYI025', + 'PYI026', + 'PYI029', + 'PYI030', + 'PYI032', + 'PYI033', + 'PYI034', + 'PYI035', + 'PYI036', + 'PYI041', + 'PYI042', + 'PYI043', + 'PYI044', + 'PYI045', + 'PYI046', + 'PYI047', + 'PYI048', + 'PYI049', + 'PYI050', + 'PYI051', + 'PYI052', + 'PYI053', + 'PYI054', + 'PYI055', + 'PYI056', + 'RET503', + 'RET504', + 'RET505', + 'RET506', + 'RET507', + 'RET508', + 'RSE102', + 'RUF001', + 'RUF002', + 'RUF003', + 'RUF005', + 'RUF006', + 'RUF007', + 'RUF008', + 'RUF009', + 'RUF010', + 'RUF011', + 'RUF012', + 'RUF013', + 'RUF015', + 'RUF016', + 'RUF100', + 'RUF200', + 'S101', + 'S102', + 'S103', + 'S104', + 'S105', + 'S106', + 'S107', + 'S108', + 'S110', + 'S112', + 'S113', + 'S301', + 'S302', + 'S303', + 'S304', + 'S305', + 'S306', + 'S307', + 'S308', + 'S310', + 'S311', + 'S312', + 'S313', + 'S314', + 'S315', + 'S316', + 'S317', + 'S318', + 'S319', + 'S320', + 'S321', + 'S323', + 'S324', + 'S501', + 'S506', + 'S508', + 'S509', + 'S601', + 'S602', + 'S604', + 'S605', + 'S606', + 'S607', + 'S608', + 'S609', + 'S612', + 'S701', + 'SIM101', + 'SIM102', + 'SIM103', + 'SIM105', + 'SIM107', + 'SIM108', + 'SIM109', + 'SIM110', + 'SIM112', + 'SIM114', + 'SIM115', + 'SIM116', + 'SIM117', + 'SIM118', + 'SIM201', + 'SIM202', + 'SIM208', + 'SIM210', + 'SIM211', + 'SIM212', + 'SIM220', + 'SIM221', + 'SIM222', + 'SIM223', + 'SIM300', + 'SIM910', + 'SLF001', + 'SLOT000', + 'SLOT001', + 'SLOT002', + 'T100', + 'T201', + 'T203', + 'TCH001', + 'TCH002', + 'TCH003', + 'TCH004', + 'TCH005', + 'TD004', + 'TD005', + 'TD006', + 'TD007', + 'TID251', + 'TID252', + 'TID253', + 'TRY002', + 'TRY003', + 'TRY004', + 'TRY200', + 'TRY201', + 'TRY300', + 'TRY301', + 'TRY302', + 'TRY400', + 'TRY401', + 'UP001', + 'UP003', + 'UP004', + 'UP005', + 'UP006', + 'UP007', + 'UP008', + 'UP009', + 'UP010', + 'UP011', + 'UP012', + 'UP013', + 'UP014', + 'UP015', + 'UP017', + 'UP018', + 'UP019', + 'UP020', + 'UP021', + 'UP022', + 'UP023', + 'UP024', + 'UP025', + 'UP026', + 'UP027', + 'UP028', + 'UP029', + 'UP030', + 'UP031', + 'UP032', + 'UP033', + 'UP034', + 'UP035', + 'UP036', + 'UP037', + 'UP038', + 'UP039', + 'UP040', + 'W291', + 'W292', + 'W293', + 'W505', + 'W605', + 'YTT101', + 'YTT102', + 'YTT103', + 'YTT201', + 'YTT202', + 'YTT203', + 'YTT204', + 'YTT301', + 'YTT302', + 'YTT303', +) +PREVIEW_RULES: tuple[str, ...] = ( + 'E112', + 'E113', + 'E115', + 'E116', + 'E201', + 'E202', + 'E203', + 'E211', + 'E221', + 'E222', + 'E223', + 'E224', + 'E225', + 'E226', + 'E227', + 'E228', + 'E231', + 'E241', + 'E242', + 'E251', + 'E252', + 'E261', + 'E262', + 'E265', + 'E266', + 'E271', + 'E272', + 'E273', + 'E274', + 'E275', + 'FURB105', + 'FURB113', + 'FURB131', + 'FURB132', + 'FURB136', + 'FURB145', + 'FURB148', + 'FURB152', + 'FURB168', + 'FURB169', + 'FURB171', + 'FURB177', + 'LOG001', + 'LOG002', + 'LOG007', + 'LOG009', + 'PERF403', + 'PLC0415', + 'PLC1901', + 'PLC2401', + 'PLC2403', + 'PLE0704', + 'PLR1704', + 'PLR1706', + 'PLR6201', + 'PLR6301', + 'PLW0108', + 'PLW0604', + 'PLW1501', + 'PLW1514', + 'PLW1641', + 'PLW2101', + 'PLW3201', + 'RUF017', + 'RUF018', + 'RUF019', + 'S201', + 'S505', + 'S507', + 'S702', + 'TRIO100', + 'TRIO105', + 'TRIO109', + 'TRIO110', + 'TRIO115', + 'UP041', +) +PER_FILE_IGNORED_RULES: dict[str, list[str]] = { + '**/scripts/*': [ + 'INP001', + 'T201', + ], + '**/tests/**/*': [ + 'PLC1901', + 'PLR2004', + 'PLR6301', + 'S', + 'TID252', + ], +} diff --git a/src/hatch/project/config.py b/src/hatch/project/config.py index d8b0a1573..660021603 100644 --- a/src/hatch/project/config.py +++ b/src/hatch/project/config.py @@ -29,6 +29,7 @@ def __init__(self, root, config, plugin_manager=None): self._matrix_variables = None self._publish = None self._scripts = None + self._fmt = None self._cached_env_overrides = {} @property @@ -461,6 +462,18 @@ def scripts(self): return self._scripts + @property + def fmt(self): + if self._fmt is None: + config = self.config.get('format', {}) + if not isinstance(config, dict): + message = 'Field `tool.hatch.format` must be a table' + raise TypeError(message) + + self._fmt = config + + return self._fmt + def finalize_env_overrides(self, option_types): # We lazily apply overrides because we need type information potentially defined by # environment plugins for their options diff --git a/src/hatch/template/files_default.py b/src/hatch/template/files_default.py index ebe86ae85..be94dad2a 100644 --- a/src/hatch/template/files_default.py +++ b/src/hatch/template/files_default.py @@ -166,86 +166,12 @@ def __init__(self, template_config: dict, plugin_config: dict): [[tool.hatch.envs.all.matrix]] python = ["3.8", "3.9", "3.10", "3.11", "3.12"] -[tool.hatch.envs.lint] -detached = true +[tool.hatch.envs.typing] dependencies = [ - "black>=23.1.0", "mypy>=1.0.0", - "ruff>=0.0.243", ] -[tool.hatch.envs.lint.scripts] -typing = "mypy --install-types --non-interactive {{args:{package_location}{template_config['package_name']} tests}}" -style = [ - "ruff {{args:.}}", - "black --check --diff {{args:.}}", -] -fmt = [ - "black {{args:.}}", - "ruff --fix {{args:.}}", - "style", -] -all = [ - "style", - "typing", -] - -[tool.black] -line-length = 120 -skip-string-normalization = true - -[tool.ruff] -line-length = 120 -select = [ - "A", - "ARG", - "B", - "C", - "DTZ", - "E", - "EM", - "F", - "FBT", - "I", - "ICN", - "ISC", - "N", - "PLC", - "PLE", - "PLR", - "PLW", - "Q", - "RUF", - "S", - "T", - "TID", - "UP", - "W", - "YTT", -] -ignore = [ - # Allow non-abstract empty methods in abstract base classes - "B027", - # Allow boolean positional values in function calls, like `dict.get(... True)` - "FBT003", - # Ignore checks for possible passwords - "S105", "S106", "S107", - # Ignore complexity - "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", -] -unfixable = [ - # Don't touch unused imports - "F401", -] - -[tool.ruff.isort] -known-first-party = ["{template_config['package_name']}"] - -[tool.ruff.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.ruff.per-file-ignores] -# Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +[tool.hatch.envs.typing.scripts] +check = "mypy --install-types --non-interactive {{args:{package_location}{template_config['package_name']} tests}}" [tool.coverage.run] source_pkgs = ["{template_config['package_name']}", "tests"] diff --git a/src/hatch/template/files_feature_ci.py b/src/hatch/template/files_feature_ci.py index 4854cadf6..f121c1cbf 100644 --- a/src/hatch/template/files_feature_ci.py +++ b/src/hatch/template/files_feature_ci.py @@ -41,6 +41,9 @@ class CommandLinePackage(File): - name: Install Hatch run: pip install --upgrade hatch + - name: Run static analysis + run: hatch fmt --check + - name: Run tests run: hatch run cov """ # noqa: E501 diff --git a/src/hatch/utils/network.py b/src/hatch/utils/network.py index 999639ddb..70b9b901b 100644 --- a/src/hatch/utils/network.py +++ b/src/hatch/utils/network.py @@ -3,7 +3,7 @@ import time from contextlib import contextmanager from secrets import choice -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Generator import httpx @@ -15,7 +15,7 @@ @contextmanager -def streaming_response(*args: Any, **kwargs: Any) -> httpx.Response: +def streaming_response(*args: Any, **kwargs: Any) -> Generator[httpx.Response, None, None]: attempts = 0 while True: attempts += 1 diff --git a/tests/cli/fmt/__init__.py b/tests/cli/fmt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/cli/fmt/test_fmt.py b/tests/cli/fmt/test_fmt.py new file mode 100644 index 000000000..e4bb2346c --- /dev/null +++ b/tests/cli/fmt/test_fmt.py @@ -0,0 +1,713 @@ +from __future__ import annotations + +from subprocess import CompletedProcess + +import pytest + +from hatch.config.constants import ConfigEnvVars +from hatch.env.internal.fmt import PER_FILE_IGNORED_RULES, PREVIEW_RULES, STABLE_RULES +from hatch.project.core import Project + + +def construct_ruff_defaults_file(rules: tuple[str, ...]) -> str: + lines = ['line-length = 120', '', '[lint]'] + + # Selected rules + lines.append('select = [') + lines.extend(f' "{rule}",' for rule in sorted(rules)) + lines.extend((']', '')) + + # Ignored rules + lines.append('[lint.per-file-ignores]') + for glob, ignored_rules in PER_FILE_IGNORED_RULES.items(): + lines.append(f'"{glob}" = [') + lines.extend(f' "{ignored_rule}",' for ignored_rule in ignored_rules) + lines.append(']') + + # Default config + lines.extend( + ( + '', + '[lint.flake8-tidy-imports]', + 'ban-relative-imports = "all"', + '', + '[lint.isort]', + 'known-first-party = ["my_app"]', + ) + ) + + # Ensure the file ends with a newline to satisfy other linters + lines.append('') + + return '\n'.join(lines) + + +@pytest.fixture(scope='module') +def defaults_file_stable() -> str: + return construct_ruff_defaults_file(STABLE_RULES) + + +@pytest.fixture(scope='module') +def defaults_file_preview() -> str: + return construct_ruff_defaults_file(STABLE_RULES + PREVIEW_RULES) + + +@pytest.fixture(scope='module', autouse=True) +def ruff_on_path(): + import shutil + + return shutil.which('ruff') or 'ruff' + + +class TestDefaults: + def test_fix(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_stable): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + config_dir = next(root_data_path.iterdir()) + default_config = config_dir / 'ruff_defaults.toml' + user_config = config_dir / 'pyproject.toml' + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'check', '--config', str(user_config), '--fix', '.'], + shell=False, + ), + mocker.call( + [ruff_on_path, 'format', '--config', str(user_config), '.'], + shell=False, + ), + ] + + assert default_config.read_text() == defaults_file_stable + + old_contents = (project_path / 'pyproject.toml').read_text() + config_path = str(default_config).replace('\\', '\\\\') + assert ( + user_config.read_text() + == f"""\ +{old_contents} +[tool.ruff] +extend = "{config_path}\"""" + ) + + def test_check(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_stable): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--check') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + config_dir = next(root_data_path.iterdir()) + default_config = config_dir / 'ruff_defaults.toml' + user_config = config_dir / 'pyproject.toml' + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'check', '--config', str(user_config), '.'], + shell=False, + ), + mocker.call( + [ruff_on_path, 'format', '--config', str(user_config), '--check', '--diff', '.'], + shell=False, + ), + ] + + assert default_config.read_text() == defaults_file_stable + + old_contents = (project_path / 'pyproject.toml').read_text() + config_path = str(default_config).replace('\\', '\\\\') + assert ( + user_config.read_text() + == f"""\ +{old_contents} +[tool.ruff] +extend = "{config_path}\"""" + ) + + +class TestPreview: + def test_fix_flag(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_preview): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--preview') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + config_dir = next(root_data_path.iterdir()) + default_config = config_dir / 'ruff_defaults.toml' + user_config = config_dir / 'pyproject.toml' + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'check', '--config', str(user_config), '--fix', '--preview', '.'], + shell=False, + ), + mocker.call( + [ruff_on_path, 'format', '--config', str(user_config), '--preview', '.'], + shell=False, + ), + ] + + assert default_config.read_text() == defaults_file_preview + + old_contents = (project_path / 'pyproject.toml').read_text() + config_path = str(default_config).replace('\\', '\\\\') + assert ( + user_config.read_text() + == f"""\ +{old_contents} +[tool.ruff] +extend = "{config_path}\"""" + ) + + def test_check_flag(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_preview): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--check', '--preview') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + config_dir = next(root_data_path.iterdir()) + default_config = config_dir / 'ruff_defaults.toml' + user_config = config_dir / 'pyproject.toml' + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'check', '--config', str(user_config), '--preview', '.'], + shell=False, + ), + mocker.call( + [ruff_on_path, 'format', '--config', str(user_config), '--check', '--diff', '--preview', '.'], + shell=False, + ), + ] + + assert default_config.read_text() == defaults_file_preview + + old_contents = (project_path / 'pyproject.toml').read_text() + config_path = str(default_config).replace('\\', '\\\\') + assert ( + user_config.read_text() + == f"""\ +{old_contents} +[tool.ruff] +extend = "{config_path}\"""" + ) + + def test_config_fallback_linter(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_preview): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + original_user_config = project_path / 'pyproject.toml' + original_user_config.write_text(f'{original_user_config.read_text()}\n[tool.ruff.lint]\npreview = true') + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--check') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + config_dir = next(root_data_path.iterdir()) + default_config = config_dir / 'ruff_defaults.toml' + user_config = config_dir / 'pyproject.toml' + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'check', '--config', str(user_config), '--preview', '.'], + shell=False, + ), + mocker.call( + [ruff_on_path, 'format', '--config', str(user_config), '--check', '--diff', '.'], + shell=False, + ), + ] + + assert default_config.read_text() == defaults_file_preview + + old_contents = (project_path / 'pyproject.toml').read_text() + config_path = str(default_config).replace('\\', '\\\\') + assert ( + user_config.read_text() + == f"""\ +{old_contents} +[tool.ruff] +extend = "{config_path}\"""" + ) + + def test_config_fallback_formatter(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_stable): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + original_user_config = project_path / 'pyproject.toml' + original_user_config.write_text(f'{original_user_config.read_text()}\n[tool.ruff.format]\npreview = true') + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--check') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + config_dir = next(root_data_path.iterdir()) + default_config = config_dir / 'ruff_defaults.toml' + user_config = config_dir / 'pyproject.toml' + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'check', '--config', str(user_config), '.'], + shell=False, + ), + mocker.call( + [ruff_on_path, 'format', '--config', str(user_config), '--check', '--diff', '--preview', '.'], + shell=False, + ), + ] + + assert default_config.read_text() == defaults_file_stable + + old_contents = (project_path / 'pyproject.toml').read_text() + config_path = str(default_config).replace('\\', '\\\\') + assert ( + user_config.read_text() + == f"""\ +{old_contents} +[tool.ruff] +extend = "{config_path}\"""" + ) + + +class TestComponents: + def test_only_linter(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_stable): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--linter') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + config_dir = next(root_data_path.iterdir()) + default_config = config_dir / 'ruff_defaults.toml' + user_config = config_dir / 'pyproject.toml' + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'check', '--config', str(user_config), '--fix', '.'], + shell=False, + ), + ] + + assert default_config.read_text() == defaults_file_stable + + old_contents = (project_path / 'pyproject.toml').read_text() + config_path = str(default_config).replace('\\', '\\\\') + assert ( + user_config.read_text() + == f"""\ +{old_contents} +[tool.ruff] +extend = "{config_path}\"""" + ) + + def test_only_formatter(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_stable): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--formatter') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + config_dir = next(root_data_path.iterdir()) + default_config = config_dir / 'ruff_defaults.toml' + user_config = config_dir / 'pyproject.toml' + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'format', '--config', str(user_config), '.'], + shell=False, + ), + ] + + assert default_config.read_text() == defaults_file_stable + + old_contents = (project_path / 'pyproject.toml').read_text() + config_path = str(default_config).replace('\\', '\\\\') + assert ( + user_config.read_text() + == f"""\ +{old_contents} +[tool.ruff] +extend = "{config_path}\"""" + ) + + def test_select_multiple(self, hatch, helpers, temp_dir, config_file, mocker): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--linter', '--formatter') + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + """ + Cannot specify both --linter and --formatter + """ + ) + + +class TestArguments: + def test_forwarding(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_stable): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--', '--foo', 'bar') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + config_dir = next(root_data_path.iterdir()) + default_config = config_dir / 'ruff_defaults.toml' + user_config = config_dir / 'pyproject.toml' + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'check', '--config', str(user_config), '--fix', '--foo', 'bar'], + shell=False, + ), + mocker.call( + [ruff_on_path, 'format', '--config', str(user_config), '--foo', 'bar'], + shell=False, + ), + ] + + assert default_config.read_text() == defaults_file_stable + + old_contents = (project_path / 'pyproject.toml').read_text() + config_path = str(default_config).replace('\\', '\\\\') + assert ( + user_config.read_text() + == f"""\ +{old_contents} +[tool.ruff] +extend = "{config_path}\"""" + ) + + +class TestConfigPath: + def test_sync_without_config(self, hatch, helpers, temp_dir, config_file, mocker): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + + mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--sync') + + assert result.exit_code == 1, result.output + assert result.output == helpers.dedent( + """ + The --sync flag can only be used when the `tool.hatch.format.config-path` option is defined + """ + ) + + def test_sync(self, hatch, temp_dir, config_file, mocker, ruff_on_path, defaults_file_stable): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + default_config_file = project_path / 'ruff_defaults.toml' + assert not default_config_file.is_file() + + project = Project(project_path) + config = dict(project.raw_config) + config['tool']['hatch']['format'] = {'config-path': 'ruff_defaults.toml'} + config['tool']['ruff'] = {'extend': 'ruff_defaults.toml'} + project.save_config(config) + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt', '--sync') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + assert not root_data_path.is_dir() + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'check', '--fix', '.'], + shell=False, + ), + mocker.call( + [ruff_on_path, 'format', '.'], + shell=False, + ), + ] + + assert default_config_file.read_text() == defaults_file_stable + + def test_no_sync(self, hatch, temp_dir, config_file, mocker, ruff_on_path): + config_file.model.template.plugins['default']['tests'] = False + config_file.save() + + project_name = 'My.App' + + with temp_dir.as_cwd(): + result = hatch('new', project_name) + + assert result.exit_code == 0, result.output + + project_path = temp_dir / 'my-app' + data_path = temp_dir / 'data' + data_path.mkdir() + default_config_file = project_path / 'ruff_defaults.toml' + default_config_file.touch() + + project = Project(project_path) + config = dict(project.raw_config) + config['tool']['hatch']['format'] = {'config-path': 'ruff_defaults.toml'} + config['tool']['ruff'] = {'extend': 'ruff_defaults.toml'} + project.save_config(config) + + run = mocker.patch('subprocess.run', return_value=CompletedProcess([], 0, stdout=b'')) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.exists', return_value=True) + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.dependency_hash', return_value='') + mocker.patch('hatch.env.internal.fmt.InternalFormatEnvironment.command_context') + + with project_path.as_cwd(env_vars={ConfigEnvVars.DATA: str(data_path)}): + result = hatch('fmt') + + assert result.exit_code == 0, result.output + assert not result.output + + root_data_path = data_path / 'env' / '.internal' / 'fmt' / '.config' + assert not root_data_path.is_dir() + + assert run.call_args_list == [ + mocker.call( + [ruff_on_path, 'check', '--fix', '.'], + shell=False, + ), + mocker.call( + [ruff_on_path, 'format', '.'], + shell=False, + ), + ] + + assert not default_config_file.read_text() diff --git a/tests/helpers/templates/new/default.py b/tests/helpers/templates/new/default.py index ba4305491..06418f8d5 100644 --- a/tests/helpers/templates/new/default.py +++ b/tests/helpers/templates/new/default.py @@ -124,86 +124,12 @@ def get_files(**kwargs): [[tool.hatch.envs.all.matrix]] python = ["3.8", "3.9", "3.10", "3.11", "3.12"] -[tool.hatch.envs.lint] -detached = true +[tool.hatch.envs.typing] dependencies = [ - "black>=23.1.0", "mypy>=1.0.0", - "ruff>=0.0.243", ] -[tool.hatch.envs.lint.scripts] -typing = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" -style = [ - "ruff {{args:.}}", - "black --check --diff {{args:.}}", -] -fmt = [ - "black {{args:.}}", - "ruff --fix {{args:.}}", - "style", -] -all = [ - "style", - "typing", -] - -[tool.black] -line-length = 120 -skip-string-normalization = true - -[tool.ruff] -line-length = 120 -select = [ - "A", - "ARG", - "B", - "C", - "DTZ", - "E", - "EM", - "F", - "FBT", - "I", - "ICN", - "ISC", - "N", - "PLC", - "PLE", - "PLR", - "PLW", - "Q", - "RUF", - "S", - "T", - "TID", - "UP", - "W", - "YTT", -] -ignore = [ - # Allow non-abstract empty methods in abstract base classes - "B027", - # Allow boolean positional values in function calls, like `dict.get(... True)` - "FBT003", - # Ignore checks for possible passwords - "S105", "S106", "S107", - # Ignore complexity - "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", -] -unfixable = [ - # Don't touch unused imports - "F401", -] - -[tool.ruff.isort] -known-first-party = ["{kwargs['package_name']}"] - -[tool.ruff.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.ruff.per-file-ignores] -# Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +[tool.hatch.envs.typing.scripts] +check = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" [tool.coverage.run] source_pkgs = ["{kwargs['package_name']}", "tests"] diff --git a/tests/helpers/templates/new/feature_ci.py b/tests/helpers/templates/new/feature_ci.py index bbd6f2da5..c4e03981c 100644 --- a/tests/helpers/templates/new/feature_ci.py +++ b/tests/helpers/templates/new/feature_ci.py @@ -47,6 +47,9 @@ def get_files(**kwargs): - name: Install Hatch run: pip install --upgrade hatch + - name: Run static analysis + run: hatch fmt --check + - name: Run tests run: hatch run cov """, # noqa: E501 diff --git a/tests/helpers/templates/new/feature_cli.py b/tests/helpers/templates/new/feature_cli.py index 90e2a0ac1..c12c6c14d 100644 --- a/tests/helpers/templates/new/feature_cli.py +++ b/tests/helpers/templates/new/feature_cli.py @@ -158,86 +158,12 @@ def {kwargs['package_name']}(): [[tool.hatch.envs.all.matrix]] python = ["3.8", "3.9", "3.10", "3.11", "3.12"] -[tool.hatch.envs.lint] -detached = true +[tool.hatch.envs.typing] dependencies = [ - "black>=23.1.0", "mypy>=1.0.0", - "ruff>=0.0.243", ] -[tool.hatch.envs.lint.scripts] -typing = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" -style = [ - "ruff {{args:.}}", - "black --check --diff {{args:.}}", -] -fmt = [ - "black {{args:.}}", - "ruff --fix {{args:.}}", - "style", -] -all = [ - "style", - "typing", -] - -[tool.black] -line-length = 120 -skip-string-normalization = true - -[tool.ruff] -line-length = 120 -select = [ - "A", - "ARG", - "B", - "C", - "DTZ", - "E", - "EM", - "F", - "FBT", - "I", - "ICN", - "ISC", - "N", - "PLC", - "PLE", - "PLR", - "PLW", - "Q", - "RUF", - "S", - "T", - "TID", - "UP", - "W", - "YTT", -] -ignore = [ - # Allow non-abstract empty methods in abstract base classes - "B027", - # Allow boolean positional values in function calls, like `dict.get(... True)` - "FBT003", - # Ignore checks for possible passwords - "S105", "S106", "S107", - # Ignore complexity - "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", -] -unfixable = [ - # Don't touch unused imports - "F401", -] - -[tool.ruff.isort] -known-first-party = ["{kwargs['package_name']}"] - -[tool.ruff.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.ruff.per-file-ignores] -# Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +[tool.hatch.envs.typing.scripts] +check = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" [tool.coverage.run] source_pkgs = ["{kwargs['package_name']}", "tests"] diff --git a/tests/helpers/templates/new/feature_no_src_layout.py b/tests/helpers/templates/new/feature_no_src_layout.py index 623921e14..e7fd24eae 100644 --- a/tests/helpers/templates/new/feature_no_src_layout.py +++ b/tests/helpers/templates/new/feature_no_src_layout.py @@ -124,86 +124,12 @@ def get_files(**kwargs): [[tool.hatch.envs.all.matrix]] python = ["3.8", "3.9", "3.10", "3.11", "3.12"] -[tool.hatch.envs.lint] -detached = true +[tool.hatch.envs.typing] dependencies = [ - "black>=23.1.0", "mypy>=1.0.0", - "ruff>=0.0.243", ] -[tool.hatch.envs.lint.scripts] -typing = "mypy --install-types --non-interactive {{args:{kwargs['package_name']} tests}}" -style = [ - "ruff {{args:.}}", - "black --check --diff {{args:.}}", -] -fmt = [ - "black {{args:.}}", - "ruff --fix {{args:.}}", - "style", -] -all = [ - "style", - "typing", -] - -[tool.black] -line-length = 120 -skip-string-normalization = true - -[tool.ruff] -line-length = 120 -select = [ - "A", - "ARG", - "B", - "C", - "DTZ", - "E", - "EM", - "F", - "FBT", - "I", - "ICN", - "ISC", - "N", - "PLC", - "PLE", - "PLR", - "PLW", - "Q", - "RUF", - "S", - "T", - "TID", - "UP", - "W", - "YTT", -] -ignore = [ - # Allow non-abstract empty methods in abstract base classes - "B027", - # Allow boolean positional values in function calls, like `dict.get(... True)` - "FBT003", - # Ignore checks for possible passwords - "S105", "S106", "S107", - # Ignore complexity - "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", -] -unfixable = [ - # Don't touch unused imports - "F401", -] - -[tool.ruff.isort] -known-first-party = ["{kwargs['package_name']}"] - -[tool.ruff.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.ruff.per-file-ignores] -# Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +[tool.hatch.envs.typing.scripts] +check = "mypy --install-types --non-interactive {{args:{kwargs['package_name']} tests}}" [tool.coverage.run] source_pkgs = ["{kwargs['package_name']}", "tests"] diff --git a/tests/helpers/templates/new/licenses_empty.py b/tests/helpers/templates/new/licenses_empty.py index c850bee84..76e81f8ad 100644 --- a/tests/helpers/templates/new/licenses_empty.py +++ b/tests/helpers/templates/new/licenses_empty.py @@ -87,86 +87,12 @@ def get_files(**kwargs): [[tool.hatch.envs.all.matrix]] python = ["3.8", "3.9", "3.10", "3.11", "3.12"] -[tool.hatch.envs.lint] -detached = true +[tool.hatch.envs.typing] dependencies = [ - "black>=23.1.0", "mypy>=1.0.0", - "ruff>=0.0.243", ] -[tool.hatch.envs.lint.scripts] -typing = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" -style = [ - "ruff {{args:.}}", - "black --check --diff {{args:.}}", -] -fmt = [ - "black {{args:.}}", - "ruff --fix {{args:.}}", - "style", -] -all = [ - "style", - "typing", -] - -[tool.black] -line-length = 120 -skip-string-normalization = true - -[tool.ruff] -line-length = 120 -select = [ - "A", - "ARG", - "B", - "C", - "DTZ", - "E", - "EM", - "F", - "FBT", - "I", - "ICN", - "ISC", - "N", - "PLC", - "PLE", - "PLR", - "PLW", - "Q", - "RUF", - "S", - "T", - "TID", - "UP", - "W", - "YTT", -] -ignore = [ - # Allow non-abstract empty methods in abstract base classes - "B027", - # Allow boolean positional values in function calls, like `dict.get(... True)` - "FBT003", - # Ignore checks for possible passwords - "S105", "S106", "S107", - # Ignore complexity - "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", -] -unfixable = [ - # Don't touch unused imports - "F401", -] - -[tool.ruff.isort] -known-first-party = ["{kwargs['package_name']}"] - -[tool.ruff.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.ruff.per-file-ignores] -# Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +[tool.hatch.envs.typing.scripts] +check = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" [tool.coverage.run] source_pkgs = ["{kwargs['package_name']}", "tests"] diff --git a/tests/helpers/templates/new/licenses_multiple.py b/tests/helpers/templates/new/licenses_multiple.py index 5a4a314d6..01e4fc85a 100644 --- a/tests/helpers/templates/new/licenses_multiple.py +++ b/tests/helpers/templates/new/licenses_multiple.py @@ -127,86 +127,12 @@ def get_files(**kwargs): [[tool.hatch.envs.all.matrix]] python = ["3.8", "3.9", "3.10", "3.11", "3.12"] -[tool.hatch.envs.lint] -detached = true +[tool.hatch.envs.typing] dependencies = [ - "black>=23.1.0", "mypy>=1.0.0", - "ruff>=0.0.243", ] -[tool.hatch.envs.lint.scripts] -typing = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" -style = [ - "ruff {{args:.}}", - "black --check --diff {{args:.}}", -] -fmt = [ - "black {{args:.}}", - "ruff --fix {{args:.}}", - "style", -] -all = [ - "style", - "typing", -] - -[tool.black] -line-length = 120 -skip-string-normalization = true - -[tool.ruff] -line-length = 120 -select = [ - "A", - "ARG", - "B", - "C", - "DTZ", - "E", - "EM", - "F", - "FBT", - "I", - "ICN", - "ISC", - "N", - "PLC", - "PLE", - "PLR", - "PLW", - "Q", - "RUF", - "S", - "T", - "TID", - "UP", - "W", - "YTT", -] -ignore = [ - # Allow non-abstract empty methods in abstract base classes - "B027", - # Allow boolean positional values in function calls, like `dict.get(... True)` - "FBT003", - # Ignore checks for possible passwords - "S105", "S106", "S107", - # Ignore complexity - "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", -] -unfixable = [ - # Don't touch unused imports - "F401", -] - -[tool.ruff.isort] -known-first-party = ["{kwargs['package_name']}"] - -[tool.ruff.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.ruff.per-file-ignores] -# Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +[tool.hatch.envs.typing.scripts] +check = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" [tool.coverage.run] source_pkgs = ["{kwargs['package_name']}", "tests"] diff --git a/tests/helpers/templates/new/projects_urls_empty.py b/tests/helpers/templates/new/projects_urls_empty.py index f175cdf3e..253ce7b71 100644 --- a/tests/helpers/templates/new/projects_urls_empty.py +++ b/tests/helpers/templates/new/projects_urls_empty.py @@ -119,86 +119,12 @@ def get_files(**kwargs): [[tool.hatch.envs.all.matrix]] python = ["3.8", "3.9", "3.10", "3.11", "3.12"] -[tool.hatch.envs.lint] -detached = true +[tool.hatch.envs.typing] dependencies = [ - "black>=23.1.0", "mypy>=1.0.0", - "ruff>=0.0.243", ] -[tool.hatch.envs.lint.scripts] -typing = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" -style = [ - "ruff {{args:.}}", - "black --check --diff {{args:.}}", -] -fmt = [ - "black {{args:.}}", - "ruff --fix {{args:.}}", - "style", -] -all = [ - "style", - "typing", -] - -[tool.black] -line-length = 120 -skip-string-normalization = true - -[tool.ruff] -line-length = 120 -select = [ - "A", - "ARG", - "B", - "C", - "DTZ", - "E", - "EM", - "F", - "FBT", - "I", - "ICN", - "ISC", - "N", - "PLC", - "PLE", - "PLR", - "PLW", - "Q", - "RUF", - "S", - "T", - "TID", - "UP", - "W", - "YTT", -] -ignore = [ - # Allow non-abstract empty methods in abstract base classes - "B027", - # Allow boolean positional values in function calls, like `dict.get(... True)` - "FBT003", - # Ignore checks for possible passwords - "S105", "S106", "S107", - # Ignore complexity - "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", -] -unfixable = [ - # Don't touch unused imports - "F401", -] - -[tool.ruff.isort] -known-first-party = ["{kwargs['package_name']}"] - -[tool.ruff.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.ruff.per-file-ignores] -# Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +[tool.hatch.envs.typing.scripts] +check = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" [tool.coverage.run] source_pkgs = ["{kwargs['package_name']}", "tests"] diff --git a/tests/helpers/templates/new/projects_urls_space_in_label.py b/tests/helpers/templates/new/projects_urls_space_in_label.py index 904740b6a..56222dbb5 100644 --- a/tests/helpers/templates/new/projects_urls_space_in_label.py +++ b/tests/helpers/templates/new/projects_urls_space_in_label.py @@ -122,86 +122,12 @@ def get_files(**kwargs): [[tool.hatch.envs.all.matrix]] python = ["3.8", "3.9", "3.10", "3.11", "3.12"] -[tool.hatch.envs.lint] -detached = true +[tool.hatch.envs.typing] dependencies = [ - "black>=23.1.0", "mypy>=1.0.0", - "ruff>=0.0.243", ] -[tool.hatch.envs.lint.scripts] -typing = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" -style = [ - "ruff {{args:.}}", - "black --check --diff {{args:.}}", -] -fmt = [ - "black {{args:.}}", - "ruff --fix {{args:.}}", - "style", -] -all = [ - "style", - "typing", -] - -[tool.black] -line-length = 120 -skip-string-normalization = true - -[tool.ruff] -line-length = 120 -select = [ - "A", - "ARG", - "B", - "C", - "DTZ", - "E", - "EM", - "F", - "FBT", - "I", - "ICN", - "ISC", - "N", - "PLC", - "PLE", - "PLR", - "PLW", - "Q", - "RUF", - "S", - "T", - "TID", - "UP", - "W", - "YTT", -] -ignore = [ - # Allow non-abstract empty methods in abstract base classes - "B027", - # Allow boolean positional values in function calls, like `dict.get(... True)` - "FBT003", - # Ignore checks for possible passwords - "S105", "S106", "S107", - # Ignore complexity - "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", -] -unfixable = [ - # Don't touch unused imports - "F401", -] - -[tool.ruff.isort] -known-first-party = ["{kwargs['package_name']}"] - -[tool.ruff.flake8-tidy-imports] -ban-relative-imports = "all" - -[tool.ruff.per-file-ignores] -# Tests can use magic values, assertions, and relative imports -"tests/**/*" = ["PLR2004", "S101", "TID252"] +[tool.hatch.envs.typing.scripts] +check = "mypy --install-types --non-interactive {{args:src/{kwargs['package_name']} tests}}" [tool.coverage.run] source_pkgs = ["{kwargs['package_name']}", "tests"] diff --git a/tests/project/test_config.py b/tests/project/test_config.py index a395d80d2..693750991 100644 --- a/tests/project/test_config.py +++ b/tests/project/test_config.py @@ -55,6 +55,17 @@ def construct_matrix_data(env_name, config, overrides=None): return {'config': config, 'envs': envs} +class TestFormat: + def test_not_table(self, isolation): + with pytest.raises(TypeError, match='Field `tool.hatch.format` must be a table'): + _ = ProjectConfig(isolation, {'format': 9000}).fmt + + def test_default(self, isolation): + project_config = ProjectConfig(isolation, {}) + + assert project_config.fmt == project_config.fmt == {} + + class TestEnv: def test_not_table(self, isolation): with pytest.raises(TypeError, match='Field `tool.hatch.env` must be a table'):