Skip to content

Commit

Permalink
PR review feedback. Updated README.
Browse files Browse the repository at this point in the history
  • Loading branch information
j00bar committed May 7, 2023
1 parent 298e708 commit 6734ea0
Show file tree
Hide file tree
Showing 8 changed files with 143 additions and 69 deletions.
23 changes: 23 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,29 @@ Any valid ``pip`` flags or arguments may be passed on with ``pip-compile``'s
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 file. 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

.. code-block:: toml
# In a .pip-tools.toml file
[pip-tools]
generate-hashes = true
# In a pyproject.toml file
[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"
9 changes: 5 additions & 4 deletions piptools/scripts/compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,19 @@
from .._compat import parse_requirements
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
from ..resolver import BacktrackingResolver, LegacyResolver
from ..utils import (
UNSAFE_PACKAGES,
callback_config_file_defaults,
dedup,
drop_extras,
is_pinned_requirement,
key_from_ireq,
parse_requirements_from_wheel_metadata,
pyproject_toml_defaults_cb,
)
from ..writer import OutputWriter

Expand Down Expand Up @@ -313,9 +313,10 @@ def _determine_linesep(
allow_dash=False,
path_type=str,
),
help="Path to a pyproject.toml file with specialized defaults for pip-tools",
help=f"Read configuration from TOML file. By default, looks for a {CONFIG_FILE_NAME} or "
"pyproject.toml.",
is_eager=True,
callback=pyproject_toml_defaults_cb,
callback=callback_config_file_defaults,
)
def cli(
ctx: click.Context,
Expand Down
8 changes: 5 additions & 3 deletions piptools/scripts/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@
from .._compat import parse_requirements
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 (
callback_config_file_defaults,
flat_map,
get_pip_version_for_python_executable,
get_required_pip_specification,
get_sys_path_for_python_executable,
pyproject_toml_defaults_cb,
)

DEFAULT_REQUIREMENTS_FILE = "requirements.txt"
Expand Down Expand Up @@ -97,9 +98,10 @@
allow_dash=False,
path_type=str,
),
help="Path to a pyproject.toml file with specialized defaults for pip-tools",
help=f"Read configuration from TOML file. By default, looks for a {CONFIG_FILE_NAME} or "
"pyproject.toml.",
is_eager=True,
callback=pyproject_toml_defaults_cb,
callback=callback_config_file_defaults,
)
def cli(
ask: bool,
Expand Down
84 changes: 56 additions & 28 deletions piptools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@
import os
import re
import shlex
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, TypeVar, cast

import click
import toml

if sys.version_info >= (3, 11):
import tomllib
else:
import toml
from click.utils import LazyFile
from pip._internal.req import InstallRequirement
from pip._internal.req.constructors import install_req_from_line, parse_req_from_line
Expand All @@ -25,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 @@ -408,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
assert (
requirement is not None
), "'pip' is expected to be in the list of pip-tools requirements"
Expand Down Expand Up @@ -527,30 +533,34 @@ def parse_requirements_from_wheel_metadata(
)


def pyproject_toml_defaults_cb(
def callback_config_file_defaults(
ctx: click.Context, param: click.Parameter, value: str | None
) -> str | None:
"""
Defaults for `click.Command` parameters should be override-able in pyproject.toml
Returns the path to the config file with defaults being used, or `None` if no such file is
found.
Returns the path to the configuration file found, or None if no such file is found.
Defaults for `click.Command` parameters should be override-able in a config file. `pip-tools`
will use the first file found, searching in this order: an explicitly given config file, a
`.pip-tools.toml`, a `pyproject.toml` file. Those files are searched for in the same directory
as the requirements input file.
"""
if value is None:
config_file = find_pyproject_toml(ctx.params.get("src_files", ()))
config_file = select_config_file(ctx.params.get("src_files", ()))
if config_file is None:
return None
else:
config_file = value
config_file = Path(value)

try:
config = parse_pyproject_toml(config_file)
config = parse_config_file(config_file)
except OSError as e:
raise click.FileError(
filename=config_file, hint=f"Could not read '{config_file}': {e}"
filename=str(config_file), hint=f"Could not read '{config_file}': {e}"
)
except ValueError as e:
raise click.FileError(
filename=config_file, hint=f"Could not parse '{config_file}': {e}"
filename=str(config_file), hint=f"Could not parse '{config_file}': {e}"
)

if not config:
Expand All @@ -560,28 +570,34 @@ def pyproject_toml_defaults_cb(
defaults.update(config)

ctx.default_map = defaults
return config_file
return str(config_file)


def find_pyproject_toml(src_files: tuple[str, ...]) -> str | None:
def select_config_file(src_files: tuple[str, ...]) -> Path | None:
"""
Returns the config file to use for defaults given `src_files` provided.
"""
if not src_files:
# If no src_files were specified, we consider the current directory the only candidate
candidates = [Path.cwd()]
candidate_dirs = [Path.cwd()]
else:
# Collect the candidate directories based on the src_file arguments provided
src_files_as_paths = [
Path(Path.cwd(), src_file).resolve() for src_file in src_files
]
candidates = [src if src.is_dir() else src.parent for src in src_files_as_paths]
pyproject_toml_path = next(
candidate_dirs = [
src if src.is_dir() else src.parent for src in src_files_as_paths
]
config_file_path = next(
(
str(candidate / "pyproject.toml")
for candidate in candidates
if (candidate / "pyproject.toml").is_file()
candidate_dir / config_file
for candidate_dir in candidate_dirs
for config_file in (CONFIG_FILE_NAME, "pyproject.toml")
if (candidate_dir / config_file).is_file()
),
None,
)
return pyproject_toml_path
return config_file_path


# Some of the defined click options have different `dest` values than the defaults
Expand All @@ -593,8 +609,8 @@ def find_pyproject_toml(src_files: tuple[str, ...]) -> str | None:
}


def mutate_option_to_click_dest(option_name: str) -> str:
"Mutates an option from how click/pyproject.toml expect them to the click `dest` value"
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
Expand All @@ -614,15 +630,27 @@ def mutate_option_to_click_dest(option_name: str) -> str:


@functools.lru_cache()
def parse_pyproject_toml(config_file: str) -> dict[str, Any]:
pyproject_toml = toml.load(config_file)
config: dict[str, Any] = pyproject_toml.get("tool", {}).get("pip-tools", {})
config = {mutate_option_to_click_dest(k): v for k, v in config.items()}
def parse_config_file(config_file: Path) -> dict[str, Any]:
if sys.version_info >= (3, 11):
# Python 3.11 stdlib tomllib load() requires a binary file object
with config_file.open("rb") as ifs:
config = tomllib.load(ifs)
else:
# Before 3.11, using the external toml library, load requires the filename
config = toml.load(str(config_file))
# In a pyproject.toml file, we expect the config to be under `[tool.pip-tools]`, but in our
# native configuration, it would be just `[pip-tools]`.
if config_file.name == "pyproject.toml":
config = config.get("tool", {})
piptools_config: dict[str, Any] = config.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(config.get(mv_option), (list, type(None))):
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 config
return piptools_config
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ dependencies = [
"build",
"click >= 8",
"pip >= 22.2",
"toml >= 0.10.1; python_version<'3.11'",
# indirect dependencies
"setuptools", # typically needed when pip-tools invokes setup.py
"toml >= 0.10.1",
"wheel", # pip plugin needed by pip-tools

]
Expand All @@ -58,6 +58,7 @@ testing = [
"pytest >= 7.2.0",
"pytest-rerunfailures",
"pytest-xdist",
"toml >= 0.10.1",
# build deps for tests
"flit_core >=2,<4",
"poetry_core>=1.0.0",
Expand Down
16 changes: 10 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,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 @@ -455,15 +456,18 @@ def _reset_log():


@pytest.fixture
def make_pyproject_toml_conf(tmpdir_cwd):
def _maker(pyproject_param, new_default):
# Make a pyproject.toml with this one config default override
def make_config_file(tmpdir_cwd):
def _maker(pyproject_param, new_default, config_file_name=CONFIG_FILE_NAME):
# Make a config file with this one config default override
config_path = Path(tmpdir_cwd) / pyproject_param
config_file = config_path / "pyproject.toml"
config_path.mkdir()
config_file = config_path / config_file_name
config_path.mkdir(exist_ok=True)

config_to_dump = {"pip-tools": {pyproject_param: new_default}}
if config_file_name == "pyproject.toml":
config_to_dump = {"tool": config_to_dump}
with open(config_file, "w") as ofs:
toml.dump({"tool": {"pip-tools": {pyproject_param: new_default}}}, ofs)
toml.dump(config_to_dump, ofs)
return config_file

return _maker
Loading

0 comments on commit 6734ea0

Please sign in to comment.