Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support config defaults using .pip-tools.toml or pyproject.toml #1863

Merged
merged 27 commits into from
Jun 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1204f72
Set config defaults using pyproject.toml
j00bar May 5, 2023
04af2a5
PR review feedback. Updated README.
j00bar May 7, 2023
06fb793
Switch the toml fallback library to `tomli`
webknjaz May 9, 2023
9ef5f8f
Use `pathlib.Path` objects internally
webknjaz May 9, 2023
0098ee5
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 9, 2023
fc4b8f5
Update the config defaults in CLI ctx in place
webknjaz May 9, 2023
cc28f42
🎨 Separate updating CLI context with the config
webknjaz May 9, 2023
5dab95d
🎨Mv parsing error processing->parse_config_file
webknjaz May 9, 2023
92e455f
🎨 Couple config parsing-n-ctx-assigning w/ walrus
webknjaz May 9, 2023
2e86931
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 9, 2023
f4692a7
🐛 Compare pathlib objects @ precedence test
webknjaz May 9, 2023
c8298e0
🎨 Rename config option callback to `determine_config_file`
webknjaz May 9, 2023
fe16e26
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 9, 2023
cc4a91f
Unwalrus `piptools.utils`
webknjaz May 9, 2023
c3c4795
Simplify config file lookup @ `select_config_file`
webknjaz May 9, 2023
64e3a2a
Iterate over config lookup dirs lazily
webknjaz May 9, 2023
050e278
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 9, 2023
a111641
Drop unnecessary parens from `lru_cache`
webknjaz May 9, 2023
ca76900
Reuse working dir in `select_config_file`
webknjaz May 9, 2023
8627df8
Revert "Drop unnecessary parens from `lru_cache`
webknjaz May 9, 2023
db2f30b
PR review feedback.
j00bar May 25, 2023
bb015ad
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] May 25, 2023
d01af78
Improve test value for non-existant TOML file.
j00bar May 25, 2023
97d30bc
Docstring RST fixes; Py3.7 compat fix.
j00bar May 31, 2023
228f2a7
Rename click callback fn name, clarify docstring.
j00bar Jun 9, 2023
8d9dd8a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 9, 2023
8de18db
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jun 13, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,26 @@ $ pip-compile requirements.in --pip-args "--retries 10 --timeout 30"

### Configuration

You can define project-level defaults for `pip-compile` and `pip-sync` by
writing them to a configuration file in the same directory as your requirements
input files (or the current working directory if piping input from stdin).
By default, both `pip-compile` and `pip-sync` will look first
for a `.pip-tools.toml` file and then in your `pyproject.toml`. You can
also specify an alternate TOML configuration file with the `--config` option.

For example, to by default generate `pip` hashes in the resulting
requirements file output, you can specify in a configuration file

```toml
[tool.pip-tools]
generate-hashes = true

```

Options to `pip-compile` and `pip-sync` that may be used more than once
must be defined as lists in a configuration file, even if they only have one
value.

You might be wrapping the `pip-compile` command in another script. To avoid
confusing consumers of your custom script you can override the update command
generated at the top of requirements files by setting the
Expand Down
3 changes: 3 additions & 0 deletions piptools/locations.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@

# The user_cache_dir helper comes straight from pip itself
CACHE_DIR = user_cache_dir("pip-tools")

# The project defaults specific to pip-tools should be written to this filename
CONFIG_FILE_NAME = ".pip-tools.toml"
23 changes: 22 additions & 1 deletion piptools/scripts/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import shlex
import sys
import tempfile
from pathlib import Path
from typing import IO, Any, BinaryIO, cast

import click
Expand All @@ -19,7 +20,7 @@
from .._compat import parse_requirements
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
from ..cache import DependencyCache
from ..exceptions import NoCandidateFound, PipToolsError
from ..locations import CACHE_DIR
from ..locations import CACHE_DIR, CONFIG_FILE_NAME
from ..logging import log
from ..repositories import LocalRequirementsRepository, PyPIRepository
from ..repositories.base import BaseRepository
Expand All @@ -30,6 +31,7 @@
drop_extras,
is_pinned_requirement,
key_from_ireq,
override_defaults_from_config_file,
parse_requirements_from_wheel_metadata,
)
from ..writer import OutputWriter
Expand Down Expand Up @@ -302,6 +304,21 @@ def _determine_linesep(
help="Specify a package to consider unsafe; may be used more than once. "
f"Replaces default unsafe packages: {', '.join(sorted(UNSAFE_PACKAGES))}",
)
@click.option(
"--config",
type=click.Path(
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
allow_dash=False,
path_type=str,
),
help=f"Read configuration from TOML file. By default, looks for a {CONFIG_FILE_NAME} or "
"pyproject.toml.",
is_eager=True,
callback=override_defaults_from_config_file,
)
def cli(
ctx: click.Context,
verbose: int,
Expand Down Expand Up @@ -340,6 +357,7 @@ def cli(
emit_index_url: bool,
emit_options: bool,
unsafe_package: tuple[str, ...],
config: Path | None,
) -> None:
"""
Compiles requirements.txt from requirements.in, pyproject.toml, setup.cfg,
Expand Down Expand Up @@ -391,6 +409,9 @@ def cli(
f"input and output filenames must not be matched: {output_file.name}"
)

if config:
log.info(f"Using pip-tools configuration defaults found in '{config !s}'.")

if resolver_name == "legacy":
log.warning(
"WARNING: the legacy dependency resolver is deprecated and will be removed"
Expand Down
22 changes: 22 additions & 0 deletions piptools/scripts/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import shlex
import shutil
import sys
from pathlib import Path
from typing import cast

import click
Expand All @@ -17,13 +18,15 @@
from .._compat import parse_requirements
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
from .._compat.pip_compat import Distribution
from ..exceptions import PipToolsError
from ..locations import CONFIG_FILE_NAME
from ..logging import log
from ..repositories import PyPIRepository
from ..utils import (
flat_map,
get_pip_version_for_python_executable,
get_required_pip_specification,
get_sys_path_for_python_executable,
override_defaults_from_config_file,
)

DEFAULT_REQUIREMENTS_FILE = "requirements.txt"
Expand Down Expand Up @@ -86,6 +89,21 @@
)
@click.argument("src_files", required=False, type=click.Path(exists=True), nargs=-1)
@click.option("--pip-args", help="Arguments to pass directly to pip install.")
@click.option(
"--config",
type=click.Path(
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
allow_dash=False,
path_type=str,
),
help=f"Read configuration from TOML file. By default, looks for a {CONFIG_FILE_NAME} or "
"pyproject.toml.",
is_eager=True,
callback=override_defaults_from_config_file,
)
def cli(
ask: bool,
dry_run: bool,
Expand All @@ -103,6 +121,7 @@ def cli(
client_cert: str | None,
src_files: tuple[str, ...],
pip_args: str | None,
config: Path | None,
) -> None:
"""Synchronize virtual environment with requirements.txt."""
log.verbosity = verbose - quiet
Expand All @@ -127,6 +146,9 @@ def cli(
log.error("ERROR: " + msg)
sys.exit(2)

if config:
log.info(f"Using pip-tools configuration defaults found in '{config !s}'.")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@j00bar it'd be nice to know why isn't this covered..


if python_executable:
_validate_python_executable(python_executable)

Expand Down
138 changes: 136 additions & 2 deletions piptools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@

import collections
import copy
import functools
import itertools
import json
import os
import re
import shlex
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, TypeVar, cast

if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib

import click
from click.utils import LazyFile
from pip._internal.req import InstallRequirement
Expand All @@ -22,6 +30,7 @@
from pip._vendor.pkg_resources import Distribution, Requirement, get_distribution

from piptools._compat import PIP_VERSION
from piptools.locations import CONFIG_FILE_NAME
from piptools.subprocess_utils import run_python_snippet

if TYPE_CHECKING:
Expand Down Expand Up @@ -405,9 +414,9 @@ def get_required_pip_specification() -> SpecifierSet:
Returns pip version specifier requested by current pip-tools installation.
"""
project_dist = get_distribution("pip-tools")
requirement = next( # pragma: no branch
requirement = next(
(r for r in project_dist.requires() if r.name == "pip"), None
)
) # pragma: no branch
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
assert (
requirement is not None
), "'pip' is expected to be in the list of pip-tools requirements"
Expand Down Expand Up @@ -522,3 +531,128 @@ def parse_requirements_from_wheel_metadata(
markers=parts.markers,
extras=parts.extras,
)


def override_defaults_from_config_file(
ctx: click.Context, param: click.Parameter, value: str | None
) -> Path | None:
"""
Overrides ``click.Command`` defaults based on specified or discovered config
file, returning the ``pathlib.Path`` of that config file if specified or
discovered.

``None`` is returned if no such file is found.

``pip-tools`` will use the first config file found, searching in this order:
an explicitly given config file, a d``.pip-tools.toml``, a ``pyproject.toml``
file. Those files are searched for in the same directory as the requirements
input file, or the current working directory if requirements come via stdin.
"""
if value is None:
config_file = select_config_file(ctx.params.get("src_files", ()))
if config_file is None:
return None
else:
config_file = Path(value)

config = parse_config_file(config_file)
if config:
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
_assign_config_to_cli_context(ctx, config)

return config_file


def _assign_config_to_cli_context(
click_context: click.Context,
cli_config_mapping: dict[str, Any],
) -> None:
if click_context.default_map is None:
click_context.default_map = {}

click_context.default_map.update(cli_config_mapping)


def select_config_file(src_files: tuple[str, ...]) -> Path | None:
"""
Returns the config file to use for defaults given ``src_files`` provided.
"""
# NOTE: If no src_files were specified, consider the current directory the
# NOTE: only config file lookup candidate. This usually happens when a
# NOTE: pip-tools invocation gets its incoming requirements from standard
# NOTE: input.
working_directory = Path.cwd()
src_files_as_paths = (
(working_directory / src_file).resolve() for src_file in src_files or (".",)
)
candidate_dirs = (src if src.is_dir() else src.parent for src in src_files_as_paths)
config_file_path = next(
(
candidate_dir / config_file
for candidate_dir in candidate_dirs
for config_file in (CONFIG_FILE_NAME, "pyproject.toml")
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
if (candidate_dir / config_file).is_file()
),
None,
)
return config_file_path


# Some of the defined click options have different `dest` values than the defaults
NON_STANDARD_OPTION_DEST_MAP: dict[str, str] = {
"extra": "extras",
"upgrade_package": "upgrade_packages",
"resolver": "resolver_name",
"user": "user_only",
}


def get_click_dest_for_option(option_name: str) -> str:
"""
Returns the click ``dest`` value for the given option name.
"""
# Format the keys properly
option_name = option_name.lstrip("-").replace("-", "_").lower()
# Some options have dest values that are overrides from the click generated default
option_name = NON_STANDARD_OPTION_DEST_MAP.get(option_name, option_name)
return option_name


# Ensure that any default overrides for these click options are lists, supporting multiple values
MULTIPLE_VALUE_OPTIONS = [
"extras",
"upgrade_packages",
"unsafe_package",
"find_links",
"extra_index_url",
"trusted_host",
]


@functools.lru_cache()
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
def parse_config_file(config_file: Path) -> dict[str, Any]:
try:
config = tomllib.loads(config_file.read_text(encoding="utf-8"))
except OSError as os_err:
raise click.FileError(
filename=str(config_file),
hint=f"Could not read '{config_file !s}': {os_err !s}",
)
except ValueError as value_err:
raise click.FileError(
filename=str(config_file),
hint=f"Could not parse '{config_file !s}': {value_err !s}",
)

# In a TOML file, we expect the config to be under `[tool.pip-tools]`
piptools_config: dict[str, Any] = config.get("tool", {}).get("pip-tools", {})
piptools_config = {
get_click_dest_for_option(k): v for k, v in piptools_config.items()
}
# Any option with multiple values needs to be a list in the pyproject.toml
for mv_option in MULTIPLE_VALUE_OPTIONS:
if not isinstance(piptools_config.get(mv_option), (list, type(None))):
original_option = mv_option.replace("_", "-")
raise click.BadOptionUsage(
original_option, f"Config key '{original_option}' must be a list"
)
return piptools_config
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies = [
"build",
"click >= 8",
"pip >= 22.2",
"tomli; python_version < '3.11'",
# indirect dependencies
"setuptools", # typically needed when pip-tools invokes setup.py
"wheel", # pip plugin needed by pip-tools
Expand All @@ -57,6 +58,7 @@ testing = [
"pytest >= 7.2.0",
"pytest-rerunfailures",
"pytest-xdist",
"tomli-w",
# build deps for tests
"flit_core >=2,<4",
"poetry_core>=1.0.0",
Expand Down
26 changes: 26 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@
from contextlib import contextmanager
from dataclasses import dataclass, field
from functools import partial
from pathlib import Path
from textwrap import dedent
from typing import Any

import pytest
import tomli_w
from click.testing import CliRunner
from pip._internal.commands.install import InstallCommand
from pip._internal.index.package_finder import PackageFinder
Expand All @@ -26,6 +29,7 @@
from piptools._compat.pip_compat import PIP_VERSION, uses_pkg_resources
from piptools.cache import DependencyCache
from piptools.exceptions import NoCandidateFound
from piptools.locations import CONFIG_FILE_NAME
from piptools.logging import log
from piptools.repositories import PyPIRepository
from piptools.repositories.base import BaseRepository
Expand Down Expand Up @@ -450,3 +454,25 @@ def _reset_log():
with other tests that depend on it.
"""
log.reset()


@pytest.fixture
def make_config_file(tmpdir_cwd):
webknjaz marked this conversation as resolved.
Show resolved Hide resolved
"""
Make a config file for pip-tools with a given parameter set to a specific
value, returning a ``pathlib.Path`` to the config file.
"""

def _maker(
pyproject_param: str, new_default: Any, config_file_name: str = CONFIG_FILE_NAME
) -> Path:
# Make a config file with this one config default override
config_path = Path(tmpdir_cwd) / pyproject_param
config_file = config_path / config_file_name
config_path.mkdir(exist_ok=True)

config_to_dump = {"tool": {"pip-tools": {pyproject_param: new_default}}}
config_file.write_text(tomli_w.dumps(config_to_dump))
return config_file

return _maker
Loading