From 1d0071b5885d6f44c518e800b616c6b6d0c7d5e2 Mon Sep 17 00:00:00 2001 From: Kevin James Date: Wed, 22 Jul 2026 16:22:08 +0100 Subject: [PATCH] feat(config): support configuration via pyproject.toml Refactors coveralls/configuration.py into a package (ci, environment, files, helpers) with __init__ as the entrypoint. changelog: coveralls-python now reads configuration from a ``[tool.coveralls]`` table in ``pyproject.toml`` (no extra dependency required). The legacy ``.coveralls.yml`` is still supported and takes precedence when both exist. --- coveralls/api.py | 23 +- coveralls/configuration.py | 470 ------------------------- coveralls/configuration/__init__.py | 64 ++++ coveralls/configuration/ci.py | 141 ++++++++ coveralls/configuration/environment.py | 36 ++ coveralls/configuration/files.py | 95 +++++ coveralls/configuration/helpers.py | 249 +++++++++++++ docs/usage/configuration.rst | 38 +- poetry.lock | 2 +- pyproject.toml | 1 + tests/api/config_test.py | 10 +- tests/api/configuration_test.py | 16 +- tests/api/conftest.py | 20 ++ tests/api/resolve_test.py | 122 ++++--- tests/api/toml_config_test.py | 178 ++++++++++ uv.lock | 2 + 16 files changed, 899 insertions(+), 568 deletions(-) delete mode 100644 coveralls/configuration.py create mode 100644 coveralls/configuration/__init__.py create mode 100644 coveralls/configuration/ci.py create mode 100644 coveralls/configuration/environment.py create mode 100644 coveralls/configuration/files.py create mode 100644 coveralls/configuration/helpers.py create mode 100644 tests/api/conftest.py create mode 100644 tests/api/toml_config_test.py diff --git a/coveralls/api.py b/coveralls/api.py index 3e6cb6ca..47585bea 100644 --- a/coveralls/api.py +++ b/coveralls/api.py @@ -86,8 +86,6 @@ def _caused_by_timeout(exc: requests.exceptions.RequestException) -> bool: class Coveralls: - config_filename = '.coveralls.yml' - def __init__(self, token_required: bool = True, **kwargs: Any) -> None: """ Initialize the main Coveralls collection entrypoint. @@ -111,27 +109,10 @@ def __init__(self, token_required: bool = True, **kwargs: Any) -> None: self._data: dict[str, Any] | None = None self.config: Config = resolve( - self.config_filename, kwargs, token_required=token_required, + kwargs, token_required=token_required, ) - self.ensure_token() - - def ensure_token(self) -> None: - if self.config.repo_token or not self.config.token_required: - return - - if os.environ.get('GITHUB_ACTIONS'): - raise RuntimeError( - 'Running on Github Actions but GITHUB_TOKEN is not set. Add ' - '"env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}" to your ' - 'step config.', - ) - - raise RuntimeError( - 'No supported CI found and no repo token configured. You have to ' - f'provide either repo_token in {self.config_filename} or set the ' - 'COVERALLS_REPO_TOKEN env var.', - ) + self.config.ensure_token() def merge(self, path: str) -> None: extra = json.loads(pathlib.Path(path).read_text(encoding='utf-8')) diff --git a/coveralls/configuration.py b/coveralls/configuration.py deleted file mode 100644 index b1a307c4..00000000 --- a/coveralls/configuration.py +++ /dev/null @@ -1,470 +0,0 @@ -import dataclasses -import logging -import os -import pathlib -import re -from collections.abc import Mapping -from typing import Any - - -log = logging.getLogger('coveralls.configuration') - -# Trailing integer, e.g. the number at the end of a pull-request URL or path. -NUMBER_REGEX = re.compile(r'(\d+)$') - -DEFAULT_HOST = 'https://coveralls.io/' - -# Used when no CI service is detected and none is configured explicitly. -DEFAULT_SERVICE_NAME = 'coveralls-python' - -# CI services that authenticate coverage uploads themselves, so a Coveralls -# repo token is not required when running on them. -TOKENLESS_CI_SERVICES = frozenset({'travis-ci'}) - -# requests has no wall-clock "total" timeout: a scalar applies separately to -# the connect and read phases. We always resolve an explicit (connect, read) -# tuple so a stalled endpoint can never hang the CLI indefinitely. -DEFAULT_CONNECT_TIMEOUT = 10 -DEFAULT_READ_TIMEOUT = 60 - -DEFAULT_RETRIES = 0 - -# Fields sent to the coveralls.io API -- every job parameter the /jobs endpoint -# accepts and lets the caller set. Everything else on Config controls local -# client behaviour and must never enter the submitted payload. -# See https://docs.coveralls.io/api-jobs-endpoint (service_build_url and -# service_branch are not in that table but are populated from CI and accepted; -# git and source_files are computed elsewhere, not sourced from config). -PAYLOAD_FIELDS = ( - 'repo_token', - 'service_name', - 'service_number', - 'service_job_id', - 'service_job_number', - 'service_pull_request', - 'service_branch', - 'service_build_url', - 'flag_name', - 'parallel', - 'run_at', -) - - -@dataclasses.dataclass -class Config: - # pylint: disable=too-many-instance-attributes - """ - Fully resolved coveralls-python configuration. - - Fields fall into two groups. The :data:`PAYLOAD_FIELDS` are sent to the - coveralls.io API via :meth:`to_payload`; every other field controls local - client behaviour (where to send, how to talk to coverage.py, etc.) and is - deliberately never included in the submitted payload. - """ - - # payload fields - repo_token: str | None = None - service_name: str = DEFAULT_SERVICE_NAME - service_number: str | None = None - service_job_id: str | None = None - service_job_number: str | None = None - service_pull_request: str | None = None - service_branch: str | None = None - service_build_url: str | None = None - flag_name: str | None = None - # None means "unset"; an explicit True/False is forwarded to the API so a - # caller can positively mark a job as non-parallel. - parallel: bool | None = None - # No CI/env loader populates run_at; it is a caller-only field, set via the - # config file or a Coveralls() kwarg, and forwarded because the API accepts - # it (a job timestamp, per the /jobs endpoint). - run_at: str | None = None - - # client settings - host: str = DEFAULT_HOST - skip_ssl_verify: bool = False - token_required: bool = True - base_dir: str = '' - src_dir: str = '' - # True lets coverage.py auto-discover its config file; a str names one. - rcfile: str | bool = True - timeout: float | None = None - connect_timeout: float | None = None - read_timeout: float | None = None - retries: int = DEFAULT_RETRIES - - def __post_init__(self) -> None: - self.timeout = self._validate_timeout('timeout', self.timeout) - self.connect_timeout = self._validate_timeout( - 'connect_timeout', self.connect_timeout, - ) - self.read_timeout = self._validate_timeout( - 'read_timeout', self.read_timeout, - ) - self.retries = self._validate_retries(self.retries) - - @staticmethod - def _validate_timeout(name: str, raw: Any) -> float | None: - if raw is None: - return None - try: - value = float(raw) - except (TypeError, ValueError) as e: - raise ValueError( - f'Invalid {name} value {raw!r}: must be a number.', - ) from e - if value <= 0: - raise ValueError( - f'Invalid {name} value {raw!r}: must be greater than 0.', - ) - return value - - @staticmethod - def _validate_retries(raw: Any) -> int: - # Only genuine ints and integer-valued strings (e.g. "3", from env vars - # or YAML) are accepted. Bools are excluded despite being ints, so - # retries=True is not silently read as 1; everything else is routed - # through int(str(...)), which never truncates, so floats and - # fractional strings (2.0, "1.5", "2.0") all raise. - is_int = isinstance(raw, int) and not isinstance(raw, bool) - try: - value = int(raw) if is_int else int(str(raw)) - except (TypeError, ValueError) as e: - raise ValueError( - f'Invalid retries value {raw!r}: must be an integer.', - ) from e - if value < 0: - raise ValueError( - f'Invalid retries value {raw!r}: must not be negative.', - ) - return value - - @property - def request_timeout(self) -> tuple[float, float]: - """ - Resolve the (connect, read) tuple passed to ``requests``. - - A phase-specific value wins for its phase; otherwise the overall - ``timeout`` applies; otherwise the phase default is used. - """ - connect = self.connect_timeout - if connect is None: - connect = self.timeout - if connect is None: - connect = DEFAULT_CONNECT_TIMEOUT - - read = self.read_timeout - if read is None: - read = self.timeout - if read is None: - read = DEFAULT_READ_TIMEOUT - - return (connect, read) - - def to_payload(self) -> dict[str, Any]: - """Build the subset of config that is submitted to the API.""" - # Include a field when it is set to anything other than None: an - # explicitly-falsey value (parallel=False, a numeric 0 job id) is a - # real choice and must be forwarded; only unset fields are dropped. - return { - name: value - for name in PAYLOAD_FIELDS - if (value := getattr(self, name)) is not None - } - - -_FIELD_NAMES = frozenset(f.name for f in dataclasses.fields(Config)) - -# Config keys (in the config file or as Coveralls() kwargs) that were renamed -# for naming consistency. The old spelling still works but warns, mirroring the -# deprecated CLI flag aliases. -# Permanent alternate spellings accepted in the config file and as Coveralls() -# keyword arguments. Both the canonical name and the alias are long-term -# supported and neither warns: ``coveralls_host`` reads more clearly in a -# .coveralls.yml, while ``host`` matches the ``COVERALLS_HOST`` env var and -# ``--host`` flag. -ALIASES = { - 'coveralls_host': 'host', -} - -# Renamed keys still accepted for backwards-compatibility but deprecated: they -# warn and will be removed in a future release. -DEPRECATED_KEYS = { - 'config_file': 'rcfile', -} - - -def _parse_pr_number(value: str | None) -> str | None: - """ - Extract the pull-request number from a CI-provided value. - - The value may be a bare integer, a path, or a full URL; per the Coveralls - docs the PR number is the trailing integer (e.g. ``.../pull/42`` -> 42). - All CI loaders that read a PR value share this single semantic. - """ - matches = NUMBER_REGEX.findall(value or '') - return matches[-1] if matches else None - - -def _from_generic_ci_environment() -> dict[str, Any]: - # Inspired by the official client: coveralls-ruby in - # lib/coveralls/configuration.rb - # (set_standard_service_params_for_generic_ci). - # The meaning of each var is clarified in: - # https://github.com/lemurheavy/coveralls-public/issues/1558 - config = { - 'service_name': os.environ.get('CI_NAME'), - 'service_number': os.environ.get('CI_BUILD_NUMBER'), - 'service_build_url': os.environ.get('CI_BUILD_URL'), - 'service_job_id': os.environ.get('CI_JOB_ID'), - 'service_branch': os.environ.get('CI_BRANCH'), - } - - config['service_pull_request'] = _parse_pr_number( - os.environ.get('CI_PULL_REQUEST'), - ) - - return {key: value for key, value in config.items() if value} - - -def _detect_ci() -> tuple[str | None, dict[str, Any]]: - # pylint: disable=too-many-return-statements - """ - Detect the specific CI service and its service-identifying fields. - - Returns the service name (or None when no CI is detected, letting the - default apply) plus a partial config of service-identifying fields. - """ - env = os.environ - if env.get('APPVEYOR'): - return 'appveyor', { - 'service_job_id': env.get('APPVEYOR_BUILD_ID'), - 'service_pull_request': env.get('APPVEYOR_PULL_REQUEST_NUMBER'), - } - if env.get('BUILDKITE'): - return 'buildkite', { - 'service_job_id': env.get('BUILDKITE_JOB_ID'), - 'service_pull_request': _parse_pr_number( - env.get('BUILDKITE_PULL_REQUEST'), - ), - } - if env.get('CIRCLECI'): - return 'circleci', { - 'service_job_id': env.get('CIRCLE_NODE_INDEX'), - 'service_number': ( - env.get('CIRCLE_WORKFLOW_ID') or env.get('CIRCLE_BUILD_NUM') - ), - 'service_pull_request': _parse_pr_number( - env.get('CI_PULL_REQUEST'), - ), - } - if env.get('GITHUB_ACTIONS'): - # See https://github.com/lemurheavy/coveralls-public/issues/1710 - # GitHub tokens and standard Coveralls tokens are almost but not quite - # the same -- forcibly using GitHub's flow seems to be more stable. - pr = None - if env.get('GITHUB_REF', '').startswith('refs/pull/'): - pr = env.get('GITHUB_REF', '//').split('/')[2] - run_id = env.get('GITHUB_RUN_ID') - return 'github', { - 'repo_token': env.get('GITHUB_TOKEN'), - 'service_job_id': run_id, - 'service_number': run_id, - 'service_pull_request': pr, - } - if env.get('JENKINS_HOME'): - return 'jenkins', { - 'service_job_id': env.get('BUILD_NUMBER'), - 'service_pull_request': _parse_pr_number( - env.get('CI_PULL_REQUEST'), - ), - } - if env.get('TRAVIS'): - return 'travis-ci', { - 'service_job_id': env.get('TRAVIS_JOB_ID'), - 'service_pull_request': _parse_pr_number( - env.get('TRAVIS_PULL_REQUEST'), - ), - } - if env.get('SEMAPHORE'): - return 'semaphore-ci', { - 'service_job_id': ( - env.get('SEMAPHORE_JOB_UUID') # Classic - or env.get('SEMAPHORE_JOB_ID') # 2.0 - ), - 'service_number': ( - env.get('SEMAPHORE_EXECUTABLE_UUID') # Classic - or env.get('SEMAPHORE_WORKFLOW_ID') # 2.0 - ), - 'service_pull_request': ( - env.get('SEMAPHORE_BRANCH_ID') # Classic - or env.get('SEMAPHORE_GIT_PR_NUMBER') # 2.0 - ), - } - return None, {} - - -def _from_ci_environment( - name: str | None, fields: dict[str, Any], -) -> dict[str, Any]: - # As defined at the bottom of - # https://docs.coveralls.io/supported-ci-services there are a few env vars - # that support any arbitrary CI. We load them first and allow the more - # specific service to overwrite job/number/pr, but the generic CI_NAME - # takes precedence over the service's default name. - config = _from_generic_ci_environment() - - if name: - config.setdefault('service_name', name) - - for key, value in fields.items(): - if value: - config[key] = value - - return config - - -def _from_environment() -> dict[str, Any]: - config: dict[str, Any] = {} - - host = os.environ.get('COVERALLS_HOST') - if host: - config['host'] = host - if os.environ.get('COVERALLS_PARALLEL', '').lower() == 'true': - config['parallel'] = True - if os.environ.get('COVERALLS_SKIP_SSL_VERIFY'): - config['skip_ssl_verify'] = True - - fields = { - 'COVERALLS_BASE_DIR': 'base_dir', - 'COVERALLS_CONNECT_TIMEOUT': 'connect_timeout', - 'COVERALLS_FLAG_NAME': 'flag_name', - 'COVERALLS_RCFILE': 'rcfile', - 'COVERALLS_READ_TIMEOUT': 'read_timeout', - 'COVERALLS_REPO_TOKEN': 'repo_token', - 'COVERALLS_RETRIES': 'retries', - 'COVERALLS_SERVICE_JOB_ID': 'service_job_id', - 'COVERALLS_SERVICE_JOB_NUMBER': 'service_job_number', - 'COVERALLS_SERVICE_NAME': 'service_name', - 'COVERALLS_SERVICE_NUMBER': 'service_number', - 'COVERALLS_SRC_DIR': 'src_dir', - 'COVERALLS_TIMEOUT': 'timeout', - } - for var, key in fields.items(): - value = os.environ.get(var) - if value: - config[key] = value - - return config - - -def _canonicalize_keys( - data: Mapping[str, Any], *, source: str, -) -> dict[str, Any]: - """ - Map alias/deprecated keys to their canonical names. - - Permanent aliases are mapped silently; deprecated keys additionally warn. - An explicit canonical key in the same source always takes precedence. - """ - result: dict[str, Any] = {} - for key, value in data.items(): - canonical = ALIASES.get(key) or DEPRECATED_KEYS.get(key) - if canonical is None: - result[key] = value - continue - if key in DEPRECATED_KEYS: - log.warning( - '%r is deprecated and will be removed in a future release; ' - 'use %r instead (in %s).', key, canonical, source, - ) - if canonical not in data: - result[canonical] = value - return result - - -def _filter_known(data: Mapping[str, Any], *, source: str) -> dict[str, Any]: - known = {} - for key, value in data.items(): - if key in _FIELD_NAMES: - known[key] = value - else: - log.warning( - 'Ignoring unknown config option %r from %s.', key, source, - ) - return known - - -def _from_file(config_filename: str) -> dict[str, Any]: - try: - import yaml # pylint: disable=import-outside-toplevel - except ImportError: - log.warning('PyYAML is not installed, skipping %s.', config_filename) - return {} - - try: - content = (pathlib.Path.cwd() / config_filename).read_text( - encoding='utf-8', - ) - except FileNotFoundError: - log.debug( - 'Missing %s file. Using only env variables.', config_filename, - ) - return {} - - data = _canonicalize_keys( - yaml.safe_load(content) or {}, source=config_filename, - ) - return _filter_known(data, source=config_filename) - - -def resolve( - config_filename: str, - overrides: Mapping[str, Any], - *, - token_required: bool = True, -) -> Config: - """ - Resolve configuration from all sources into a single typed Config. - - Precedence (later wins): CI environment, ``COVERALLS_*`` env vars, the - config file, then explicit overrides (e.g. CLI flags). - - ``token_required`` is not a config value read from any of those sources: it - is a guard against accidental tokenless uploads, calculated here from the - caller's ``token_required`` argument (the CLI derives it from the - ``--debug``/``--output`` flags) and waived automatically on a CI service - that authenticates uploads itself. A ``token_required`` key in the config - file or environment is therefore ignored. - """ - overrides = _canonicalize_keys(overrides, source='arguments') - cleaned = _filter_known( - { - key: value for key, value in overrides.items() - if value is not None - }, - source='arguments', - ) - name, fields = _detect_ci() - - partials = [ - _from_ci_environment(name, fields), - _from_environment(), - _from_file(config_filename), - cleaned, - ] - - merged: dict[str, Any] = {} - for part in partials: - merged.update(part) - merged['token_required'] = ( - token_required and name not in TOKENLESS_CI_SERVICES - ) - # Coerce the boolean flags: a config file may carry a non-bool (e.g. a - # quoted ``parallel: "yes"``), which must not reach the API or a client - # toggle as a stray string. - for flag in ('parallel', 'skip_ssl_verify'): - if flag in merged: - merged[flag] = bool(merged[flag]) - - return Config(**merged) diff --git a/coveralls/configuration/__init__.py b/coveralls/configuration/__init__.py new file mode 100644 index 00000000..9b1a21b0 --- /dev/null +++ b/coveralls/configuration/__init__.py @@ -0,0 +1,64 @@ +from collections.abc import Mapping +from typing import Any + +from .ci import _detect_ci +from .ci import _from_ci_environment +from .ci import TOKENLESS_CI_SERVICES +from .environment import _from_environment +from .files import _from_files +from .helpers import _canonicalize_keys +from .helpers import _filter_known +from .helpers import Config + +__all__ = ['Config', 'resolve'] + + +def resolve( + overrides: Mapping[str, Any], + *, + token_required: bool = True, +) -> Config: + """ + Resolve configuration from all sources into a single typed Config. + + Precedence (later wins): CI environment, ``COVERALLS_*`` env vars, the + config file, then explicit overrides (e.g. CLI flags). + + ``token_required`` is not a config value read from any of those sources: it + is a guard against accidental tokenless uploads, calculated here from the + caller's ``token_required`` argument (the CLI derives it from the + ``--debug``/``--output`` flags) and waived automatically on a CI service + that authenticates uploads itself. A ``token_required`` key in the config + file or environment is therefore ignored. + """ + overrides = _canonicalize_keys(overrides, source='arguments') + cleaned = _filter_known( + { + key: value for key, value in overrides.items() + if value is not None + }, + source='arguments', + ) + name, fields = _detect_ci() + + partials = [ + _from_ci_environment(name, fields), + _from_environment(), + _from_files(), + cleaned, + ] + + merged: dict[str, Any] = {} + for part in partials: + merged.update(part) + merged['token_required'] = ( + token_required and name not in TOKENLESS_CI_SERVICES + ) + # Coerce the boolean flags: a config file may carry a non-bool (e.g. a + # quoted ``parallel: "yes"``), which must not reach the API or a client + # toggle as a stray string. + for flag in ('parallel', 'skip_ssl_verify'): + if flag in merged: + merged[flag] = bool(merged[flag]) + + return Config(**merged) diff --git a/coveralls/configuration/ci.py b/coveralls/configuration/ci.py new file mode 100644 index 00000000..f3356f34 --- /dev/null +++ b/coveralls/configuration/ci.py @@ -0,0 +1,141 @@ +import os +import re +from typing import Any + + +# Trailing integer, e.g. the number at the end of a pull-request URL or path. +NUMBER_REGEX = re.compile(r'(\d+)$') + +# CI services that authenticate coverage uploads themselves, so a Coveralls +# repo token is not required when running on them. +TOKENLESS_CI_SERVICES = frozenset({'travis-ci'}) + + +def _parse_pr_number(value: str | None) -> str | None: + """ + Extract the pull-request number from a CI-provided value. + + The value may be a bare integer, a path, or a full URL; per the Coveralls + docs the PR number is the trailing integer (e.g. ``.../pull/42`` -> 42). + All CI loaders that read a PR value share this single semantic. + """ + matches = NUMBER_REGEX.findall(value or '') + return matches[-1] if matches else None + + +def _from_generic_ci_environment() -> dict[str, Any]: + # Inspired by the official client: coveralls-ruby in + # lib/coveralls/configuration.rb + # (set_standard_service_params_for_generic_ci). + # The meaning of each var is clarified in: + # https://github.com/lemurheavy/coveralls-public/issues/1558 + config = { + 'service_name': os.environ.get('CI_NAME'), + 'service_number': os.environ.get('CI_BUILD_NUMBER'), + 'service_build_url': os.environ.get('CI_BUILD_URL'), + 'service_job_id': os.environ.get('CI_JOB_ID'), + 'service_branch': os.environ.get('CI_BRANCH'), + } + + config['service_pull_request'] = _parse_pr_number( + os.environ.get('CI_PULL_REQUEST'), + ) + + return {key: value for key, value in config.items() if value} + + +def _detect_ci() -> tuple[str | None, dict[str, Any]]: + # pylint: disable=too-many-return-statements + """ + Detect the specific CI service and its service-identifying fields. + + Returns the service name (or None when no CI is detected, letting the + default apply) plus a partial config of service-identifying fields. + """ + env = os.environ + if env.get('APPVEYOR'): + return 'appveyor', { + 'service_job_id': env.get('APPVEYOR_BUILD_ID'), + 'service_pull_request': env.get('APPVEYOR_PULL_REQUEST_NUMBER'), + } + if env.get('BUILDKITE'): + return 'buildkite', { + 'service_job_id': env.get('BUILDKITE_JOB_ID'), + 'service_pull_request': _parse_pr_number( + env.get('BUILDKITE_PULL_REQUEST'), + ), + } + if env.get('CIRCLECI'): + return 'circleci', { + 'service_job_id': env.get('CIRCLE_NODE_INDEX'), + 'service_number': ( + env.get('CIRCLE_WORKFLOW_ID') or env.get('CIRCLE_BUILD_NUM') + ), + 'service_pull_request': _parse_pr_number( + env.get('CI_PULL_REQUEST'), + ), + } + if env.get('GITHUB_ACTIONS'): + # See https://github.com/lemurheavy/coveralls-public/issues/1710 + # GitHub tokens and standard Coveralls tokens are almost but not quite + # the same -- forcibly using GitHub's flow seems to be more stable. + pr = None + if env.get('GITHUB_REF', '').startswith('refs/pull/'): + pr = env.get('GITHUB_REF', '//').split('/')[2] + run_id = env.get('GITHUB_RUN_ID') + return 'github', { + 'repo_token': env.get('GITHUB_TOKEN'), + 'service_job_id': run_id, + 'service_number': run_id, + 'service_pull_request': pr, + } + if env.get('JENKINS_HOME'): + return 'jenkins', { + 'service_job_id': env.get('BUILD_NUMBER'), + 'service_pull_request': _parse_pr_number( + env.get('CI_PULL_REQUEST'), + ), + } + if env.get('TRAVIS'): + return 'travis-ci', { + 'service_job_id': env.get('TRAVIS_JOB_ID'), + 'service_pull_request': _parse_pr_number( + env.get('TRAVIS_PULL_REQUEST'), + ), + } + if env.get('SEMAPHORE'): + return 'semaphore-ci', { + 'service_job_id': ( + env.get('SEMAPHORE_JOB_UUID') # Classic + or env.get('SEMAPHORE_JOB_ID') # 2.0 + ), + 'service_number': ( + env.get('SEMAPHORE_EXECUTABLE_UUID') # Classic + or env.get('SEMAPHORE_WORKFLOW_ID') # 2.0 + ), + 'service_pull_request': ( + env.get('SEMAPHORE_BRANCH_ID') # Classic + or env.get('SEMAPHORE_GIT_PR_NUMBER') # 2.0 + ), + } + return None, {} + + +def _from_ci_environment( + name: str | None, fields: dict[str, Any], +) -> dict[str, Any]: + # As defined at the bottom of + # https://docs.coveralls.io/supported-ci-services there are a few env vars + # that support any arbitrary CI. We load them first and allow the more + # specific service to overwrite job/number/pr, but the generic CI_NAME + # takes precedence over the service's default name. + config = _from_generic_ci_environment() + + if name: + config.setdefault('service_name', name) + + for key, value in fields.items(): + if value: + config[key] = value + + return config diff --git a/coveralls/configuration/environment.py b/coveralls/configuration/environment.py new file mode 100644 index 00000000..75540112 --- /dev/null +++ b/coveralls/configuration/environment.py @@ -0,0 +1,36 @@ +import os +from typing import Any + + +def _from_environment() -> dict[str, Any]: + config: dict[str, Any] = {} + + host = os.environ.get('COVERALLS_HOST') + if host: + config['host'] = host + if os.environ.get('COVERALLS_PARALLEL', '').lower() == 'true': + config['parallel'] = True + if os.environ.get('COVERALLS_SKIP_SSL_VERIFY'): + config['skip_ssl_verify'] = True + + fields = { + 'COVERALLS_BASE_DIR': 'base_dir', + 'COVERALLS_CONNECT_TIMEOUT': 'connect_timeout', + 'COVERALLS_FLAG_NAME': 'flag_name', + 'COVERALLS_RCFILE': 'rcfile', + 'COVERALLS_READ_TIMEOUT': 'read_timeout', + 'COVERALLS_REPO_TOKEN': 'repo_token', + 'COVERALLS_RETRIES': 'retries', + 'COVERALLS_SERVICE_JOB_ID': 'service_job_id', + 'COVERALLS_SERVICE_JOB_NUMBER': 'service_job_number', + 'COVERALLS_SERVICE_NAME': 'service_name', + 'COVERALLS_SERVICE_NUMBER': 'service_number', + 'COVERALLS_SRC_DIR': 'src_dir', + 'COVERALLS_TIMEOUT': 'timeout', + } + for var, key in fields.items(): + value = os.environ.get(var) + if value: + config[key] = value + + return config diff --git a/coveralls/configuration/files.py b/coveralls/configuration/files.py new file mode 100644 index 00000000..de6426a8 --- /dev/null +++ b/coveralls/configuration/files.py @@ -0,0 +1,95 @@ +import logging +import pathlib +import sys +from collections.abc import Mapping +from typing import Any + +from .helpers import _canonicalize_and_filter +from .helpers import TOML_CONFIG_FILE +from .helpers import YAML_CONFIG_FILE + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +log = logging.getLogger('coveralls.configuration.files') + + +def _read_yaml() -> dict[str, Any] | None: + """ + Read the raw ``.coveralls.yml`` mapping, or None when it has no settings. + + Returns None (rather than an empty dict) when the file is absent, empty, or + unreadable because PyYAML is missing, so :func:`_from_files` can tell "this + source provided settings" from "this source is silent" and pick a winner. + """ + path = pathlib.Path.cwd() / YAML_CONFIG_FILE + if not path.exists(): + return None + + try: + import yaml # pylint: disable=import-outside-toplevel + except ImportError: + log.warning('PyYAML is not installed, skipping %s.', YAML_CONFIG_FILE) + return None + + data = yaml.safe_load(path.read_text(encoding='utf-8')) + return data or None + + +def _read_toml() -> dict[str, Any] | None: + """ + Read the raw ``[tool.coveralls]`` mapping from ``pyproject.toml``. + + Returns None when the file or table is absent/empty. A malformed file + surfaces as :class:`tomllib.TOMLDecodeError`; a ``tool.coveralls`` that is + present but not a table is a configuration error and raises ``TypeError``. + """ + path = pathlib.Path.cwd() / TOML_CONFIG_FILE + if not path.exists(): + return None + + with path.open('rb') as handle: + data = tomllib.load(handle) + + section = data.get('tool', {}).get('coveralls') + if section is None: + return None + if not isinstance(section, Mapping): + raise TypeError( + f'Invalid [tool.coveralls] in {TOML_CONFIG_FILE}: expected a ' + f'table, got {type(section).__name__}.', + ) + return dict(section) or None + + +def _from_files() -> dict[str, Any]: + """ + Load the single winning config file. + + Config files are never merged: the first source with settings wins, and + the legacy ``.coveralls.yml`` takes precedence over ``pyproject.toml`` + (matching the community norm where a dedicated file beats pyproject.toml). + Only the winner is canonicalized/filtered, so unknown-key warnings are not + emitted for a file whose values are discarded. + """ + yaml_config = _read_yaml() + toml_config = _read_toml() + + if yaml_config is not None and toml_config is not None: + log.warning( + 'Both %s and [tool.coveralls] in %s were found; using %s and ' + 'ignoring %s. The YAML config is legacy -- consider consolidating ' + 'into %s.', + YAML_CONFIG_FILE, TOML_CONFIG_FILE, YAML_CONFIG_FILE, + TOML_CONFIG_FILE, TOML_CONFIG_FILE, + ) + + if yaml_config is not None: + return _canonicalize_and_filter(yaml_config, source=YAML_CONFIG_FILE) + if toml_config is not None: + source = f'{TOML_CONFIG_FILE} [tool.coveralls]' + return _canonicalize_and_filter(toml_config, source=source) + return {} diff --git a/coveralls/configuration/helpers.py b/coveralls/configuration/helpers.py new file mode 100644 index 00000000..45135bbb --- /dev/null +++ b/coveralls/configuration/helpers.py @@ -0,0 +1,249 @@ +import dataclasses +import logging +import os +from collections.abc import Mapping +from typing import Any + + +log = logging.getLogger('coveralls.configuration.helpers') + +# The config file sources coveralls-python reads. See files._from_files for +# the search/precedence rules. +YAML_CONFIG_FILE = '.coveralls.yml' +TOML_CONFIG_FILE = 'pyproject.toml' + +DEFAULT_HOST = 'https://coveralls.io/' + +# Used when no CI service is detected and none is configured explicitly. +DEFAULT_SERVICE_NAME = 'coveralls-python' + +# requests has no wall-clock "total" timeout: a scalar applies separately to +# the connect and read phases. We always resolve an explicit (connect, read) +# tuple so a stalled endpoint can never hang the CLI indefinitely. +DEFAULT_CONNECT_TIMEOUT = 10 +DEFAULT_READ_TIMEOUT = 60 + +DEFAULT_RETRIES = 0 + +# Fields sent to the coveralls.io API -- every job parameter the /jobs endpoint +# accepts and lets the caller set. Everything else on Config controls local +# client behaviour and must never enter the submitted payload. +# See https://docs.coveralls.io/api-jobs-endpoint (service_build_url and +# service_branch are not in that table but are populated from CI and accepted; +# git and source_files are computed elsewhere, not sourced from config). +PAYLOAD_FIELDS = ( + 'repo_token', + 'service_name', + 'service_number', + 'service_job_id', + 'service_job_number', + 'service_pull_request', + 'service_branch', + 'service_build_url', + 'flag_name', + 'parallel', + 'run_at', +) + + +@dataclasses.dataclass +class Config: + # pylint: disable=too-many-instance-attributes + """ + Fully resolved coveralls-python configuration. + + Fields fall into two groups. The :data:`PAYLOAD_FIELDS` are sent to the + coveralls.io API via :meth:`to_payload`; every other field controls local + client behaviour (where to send, how to talk to coverage.py, etc.) and is + deliberately never included in the submitted payload. + """ + + # payload fields + repo_token: str | None = None + service_name: str = DEFAULT_SERVICE_NAME + service_number: str | None = None + service_job_id: str | None = None + service_job_number: str | None = None + service_pull_request: str | None = None + service_branch: str | None = None + service_build_url: str | None = None + flag_name: str | None = None + # None means "unset"; an explicit True/False is forwarded to the API so a + # caller can positively mark a job as non-parallel. + parallel: bool | None = None + # No CI/env loader populates run_at; it is a caller-only field, set via the + # config file or a Coveralls() kwarg, and forwarded because the API accepts + # it (a job timestamp, per the /jobs endpoint). + run_at: str | None = None + + # client settings + host: str = DEFAULT_HOST + skip_ssl_verify: bool = False + token_required: bool = True + base_dir: str = '' + src_dir: str = '' + # True lets coverage.py auto-discover its config file; a str names one. + rcfile: str | bool = True + timeout: float | None = None + connect_timeout: float | None = None + read_timeout: float | None = None + retries: int = DEFAULT_RETRIES + + def __post_init__(self) -> None: + self.timeout = self._validate_timeout('timeout', self.timeout) + self.connect_timeout = self._validate_timeout( + 'connect_timeout', self.connect_timeout, + ) + self.read_timeout = self._validate_timeout( + 'read_timeout', self.read_timeout, + ) + self.retries = self._validate_retries(self.retries) + + @staticmethod + def _validate_timeout(name: str, raw: Any) -> float | None: + if raw is None: + return None + try: + value = float(raw) + except (TypeError, ValueError) as e: + raise ValueError( + f'Invalid {name} value {raw!r}: must be a number.', + ) from e + if value <= 0: + raise ValueError( + f'Invalid {name} value {raw!r}: must be greater than 0.', + ) + return value + + @staticmethod + def _validate_retries(raw: Any) -> int: + # Only genuine ints and integer-valued strings (e.g. "3", from env vars + # or YAML) are accepted. Bools are excluded despite being ints, so + # retries=True is not silently read as 1; everything else is routed + # through int(str(...)), which never truncates, so floats and + # fractional strings (2.0, "1.5", "2.0") all raise. + is_int = isinstance(raw, int) and not isinstance(raw, bool) + try: + value = int(raw) if is_int else int(str(raw)) + except (TypeError, ValueError) as e: + raise ValueError( + f'Invalid retries value {raw!r}: must be an integer.', + ) from e + if value < 0: + raise ValueError( + f'Invalid retries value {raw!r}: must not be negative.', + ) + return value + + @property + def request_timeout(self) -> tuple[float, float]: + """ + Resolve the (connect, read) tuple passed to ``requests``. + + A phase-specific value wins for its phase; otherwise the overall + ``timeout`` applies; otherwise the phase default is used. + """ + connect = self.connect_timeout + if connect is None: + connect = self.timeout + if connect is None: + connect = DEFAULT_CONNECT_TIMEOUT + + read = self.read_timeout + if read is None: + read = self.timeout + if read is None: + read = DEFAULT_READ_TIMEOUT + + return (connect, read) + + def ensure_token(self) -> None: + """Raise if an upload needs a repo token but none is configured.""" + if self.repo_token or not self.token_required: + return + + if os.environ.get('GITHUB_ACTIONS'): + raise RuntimeError( + 'Running on Github Actions but GITHUB_TOKEN is not set. Add ' + '"env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}" to your ' + 'step config.', + ) + + raise RuntimeError( + 'No supported CI found and no repo token configured. You have to ' + f'provide repo_token in {TOML_CONFIG_FILE} ([tool.coveralls]) or ' + f'{YAML_CONFIG_FILE}, or set the COVERALLS_REPO_TOKEN env var.', + ) + + def to_payload(self) -> dict[str, Any]: + """Build the subset of config that is submitted to the API.""" + # Include a field when it is set to anything other than None: an + # explicitly-falsey value (parallel=False, a numeric 0 job id) is a + # real choice and must be forwarded; only unset fields are dropped. + return { + name: value + for name in PAYLOAD_FIELDS + if (value := getattr(self, name)) is not None + } + + +_FIELD_NAMES = frozenset(f.name for f in dataclasses.fields(Config)) + +# Permanent alternate spellings accepted in the config file and as Coveralls() +# keyword arguments. Both the canonical name and the alias are long-term +# supported and neither warns: ``coveralls_host`` reads more clearly in a +# config file, while ``host`` matches the ``COVERALLS_HOST`` env var and +# ``--host`` flag. +ALIASES = { + 'coveralls_host': 'host', +} + +# Renamed keys still accepted for backwards-compatibility but deprecated: they +# warn and will be removed in a future release. +DEPRECATED_KEYS = { + 'config_file': 'rcfile', +} + + +def _canonicalize_keys( + data: Mapping[str, Any], *, source: str, +) -> dict[str, Any]: + """ + Map alias/deprecated keys to their canonical names. + + Permanent aliases are mapped silently; deprecated keys additionally warn. + An explicit canonical key in the same source always takes precedence. + """ + result: dict[str, Any] = {} + for key, value in data.items(): + canonical = ALIASES.get(key) or DEPRECATED_KEYS.get(key) + if canonical is None: + result[key] = value + continue + if key in DEPRECATED_KEYS: + log.warning( + '%r is deprecated and will be removed in a future release; ' + 'use %r instead (in %s).', key, canonical, source, + ) + if canonical not in data: + result[canonical] = value + return result + + +def _filter_known(data: Mapping[str, Any], *, source: str) -> dict[str, Any]: + known = {} + for key, value in data.items(): + if key in _FIELD_NAMES: + known[key] = value + else: + log.warning( + 'Ignoring unknown config option %r from %s.', key, source, + ) + return known + + +def _canonicalize_and_filter( + data: Mapping[str, Any], *, source: str, +) -> dict[str, Any]: + canonical = _canonicalize_keys(data, source=source) + return _filter_known(canonical, source=source) diff --git a/docs/usage/configuration.rst b/docs/usage/configuration.rst index 6fb1e614..521238f0 100644 --- a/docs/usage/configuration.rst +++ b/docs/usage/configuration.rst @@ -15,7 +15,7 @@ precedence order where the **latest value is used**: * first, the CI environment will be loaded * second, any environment variables will be loaded (eg. those which begin with ``COVERALLS_`` -* third, the config file is loaded (eg. ``./..coveralls.yml``) +* third, the config file is loaded (see :ref:`config-files` below) * finally, any command line flags are evaluated Most often, you will simply need to run coveralls-python with no additional @@ -103,9 +103,33 @@ If you are using named jobs, you can set:: COVERALLS_FLAG_NAME="insert-name-here" -You can also set any of these values in a ``.coveralls.yml`` file in the root of your project repository. If you are planning to use this method, please ensure you install ``coveralls[yaml]`` instead of just the base ``coveralls`` package. +.. _config-files: -Sample ``.coveralls.yml`` file:: +Config files +------------ + +You can also set any of these values in a config file in the root of your +project repository. Two file formats are supported: + +* ``pyproject.toml`` (recommended), under a ``[tool.coveralls]`` table +* ``.coveralls.yml`` (legacy) + +TOML support is always available, so the recommended approach needs no extra +dependencies. Sample ``pyproject.toml``:: + + [tool.coveralls] + service_name = "travis-pro" + repo_token = "mV2Jajb8y3c6AFlcVNagHO20fiZNkXPVy" + parallel = true + host = "https://coveralls.aperture.com" + timeout = 30 + connect_timeout = 5 + read_timeout = 90 + retries = 3 + +The legacy ``.coveralls.yml`` remains fully supported. If you use it, please +ensure you install ``coveralls[yaml]`` instead of just the base ``coveralls`` +package. Sample ``.coveralls.yml`` file:: service_name: travis-pro repo_token: mV2Jajb8y3c6AFlcVNagHO20fiZNkXPVy @@ -116,9 +140,15 @@ Sample ``.coveralls.yml`` file:: read_timeout: 90 retries: 3 +The two files are never merged. If both provide settings, the legacy +``.coveralls.yml`` takes precedence and ``pyproject.toml`` is ignored (with a +warning). New projects should prefer ``pyproject.toml``; if you are migrating, +move your settings across and delete ``.coveralls.yml`` so the TOML config +takes effect. + .. note:: - ``coveralls_host`` and ``host`` are both accepted (in ``.coveralls.yml`` + ``coveralls_host`` and ``host`` are both accepted (in either config file and as ``Coveralls()`` keyword arguments) and are equivalent long-term spellings -- ``coveralls_host`` reads more clearly in a config file, while ``host`` matches the ``COVERALLS_HOST`` environment variable and ``--host`` diff --git a/poetry.lock b/poetry.lock index 2c3c6da1..ef7c344f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1011,4 +1011,4 @@ yaml = ["pyyaml"] [metadata] lock-version = "2.1" python-versions = ">=3.10" -content-hash = "b864d680922dd47f1e07d62eea00e7d5c7bc6f470851a44ee1a00c2c2eabe9ef" +content-hash = "84e0aacd4890b7939f8280d488a46e5d629253eedfe6f200ac01df9183d882c4" diff --git a/pyproject.toml b/pyproject.toml index f3ca88da..faf76bad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ classifiers = [ dependencies = [ "coverage[toml] (>=6.3,<8.0)", "requests (>=2.30.0,<3.0.0)", + "tomli (>=1.1.0) ; python_version < '3.11'", "typer (>=0.12.0,<1.0.0)", "urllib3 (>=2.0.0,<3.0.0)" ] diff --git a/tests/api/config_test.py b/tests/api/config_test.py index 0d8af434..b1f2b36b 100644 --- a/tests/api/config_test.py +++ b/tests/api/config_test.py @@ -3,12 +3,12 @@ import pytest -from coveralls.configuration import _parse_pr_number from coveralls.configuration import Config -from coveralls.configuration import DEFAULT_CONNECT_TIMEOUT -from coveralls.configuration import DEFAULT_HOST -from coveralls.configuration import DEFAULT_READ_TIMEOUT -from coveralls.configuration import PAYLOAD_FIELDS +from coveralls.configuration.ci import _parse_pr_number +from coveralls.configuration.helpers import DEFAULT_CONNECT_TIMEOUT +from coveralls.configuration.helpers import DEFAULT_HOST +from coveralls.configuration.helpers import DEFAULT_READ_TIMEOUT +from coveralls.configuration.helpers import PAYLOAD_FIELDS def test_defaults() -> None: diff --git a/tests/api/configuration_test.py b/tests/api/configuration_test.py index cc5a2549..1ba3ed98 100644 --- a/tests/api/configuration_test.py +++ b/tests/api/configuration_test.py @@ -11,18 +11,17 @@ from coveralls import Coveralls +pytestmark = pytest.mark.usefixtures('isolate_cwd') + + # The per-source resolution rules (CI detection, env vars, file loading, # precedence) are covered in resolve_test.py and config_test.py. These tests # assert the Coveralls entrypoint consumes a resolved, typed Config correctly. -@unittest.mock.patch.object(Coveralls, 'config_filename', '.coveralls.mock') class TestConfigIntegration: @pytest.mark.skipif(yaml is None, reason='requires PyYAML') @unittest.mock.patch.dict(os.environ, {}, clear=True) - def test_reads_config_file( - self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, - ) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / '.coveralls.mock').write_text( + def test_reads_config_file(self, tmp_path: pathlib.Path) -> None: + (tmp_path / '.coveralls.yml').write_text( 'repo_token: xxx\nservice_name: jenkins\n', encoding='utf-8', ) @@ -73,7 +72,6 @@ def test_invalid_timeout_raises_on_construction(self) -> None: Coveralls() -@unittest.mock.patch.object(Coveralls, 'config_filename', '.coveralls.mock') class TestEnsureToken: @unittest.mock.patch.dict( os.environ, @@ -103,8 +101,8 @@ def test_misconfigured(self) -> None: assert str(excinfo.value) == ( 'No supported CI found and no repo token configured. You have to ' - 'provide either repo_token in .coveralls.mock or set the ' - 'COVERALLS_REPO_TOKEN env var.' + 'provide repo_token in pyproject.toml ([tool.coveralls]) or ' + '.coveralls.yml, or set the COVERALLS_REPO_TOKEN env var.' ) @unittest.mock.patch.dict( diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 00000000..5e8c7914 --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,20 @@ +import pathlib + +import pytest + + +@pytest.fixture(scope='function') +def isolate_cwd( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + """ + Run a test in a clean, empty working directory. + + Load-bearing for the config-resolution tests: resolve() and Coveralls() + discover config files (.coveralls.yml, pyproject.toml) relative to the cwd, + so without this a test would read coveralls-python's own pyproject.toml and + resolve against it. It is deliberately not autouse -- other tests in this + package (reporter/wear/encoding) chdir elsewhere on purpose -- so modules + opt in with ``pytestmark = pytest.mark.usefixtures('isolate_cwd')``. + """ + monkeypatch.chdir(tmp_path) diff --git a/tests/api/resolve_test.py b/tests/api/resolve_test.py index dd4224c0..8bef3d50 100644 --- a/tests/api/resolve_test.py +++ b/tests/api/resolve_test.py @@ -1,3 +1,4 @@ +import logging import os import pathlib import unittest.mock @@ -10,16 +11,16 @@ yaml = None # type: ignore[assignment] from coveralls.configuration import Config -from coveralls.configuration import log from coveralls.configuration import resolve +pytestmark = pytest.mark.usefixtures('isolate_cwd') + + def resolve_config( *, token_required: bool = True, **overrides: Any, ) -> Config: - return resolve( - '.coveralls.mock', overrides, token_required=token_required, - ) + return resolve(overrides, token_required=token_required) @unittest.mock.patch.dict(os.environ, {}, clear=True) @@ -311,16 +312,15 @@ def test_override_waives_token() -> None: @pytest.mark.skipif(yaml is None, reason='requires PyYAML') @unittest.mock.patch.dict(os.environ, {}, clear=True) def test_config_file_cannot_waive_token_required( - tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, ) -> None: # token_required is a security guard, not a user-facing setting: a # token_required: false in the config file must be ignored so a committed # .coveralls.yml cannot silently disable the check. - monkeypatch.chdir(tmp_path) - (tmp_path / '.coveralls.mock').write_text( + (tmp_path / '.coveralls.yml').write_text( 'token_required: false\n', encoding='utf-8', ) - config = resolve('.coveralls.mock', {}) + config = resolve({}) assert config.token_required @@ -339,64 +339,69 @@ def test_bool_override_precedence_is_owned_by_resolve() -> None: @pytest.mark.skipif(yaml is None, reason='requires PyYAML') @unittest.mock.patch.dict(os.environ, {}, clear=True) def test_file_source_and_unknown_key_warning( - tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, ) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / '.coveralls.mock').write_text( + (tmp_path / '.coveralls.yml').write_text( 'repo_token: xxx\nservice_name: jenkins\nbogus_key: nope\n', encoding='utf-8', ) - with unittest.mock.patch.object(log, 'warning') as warning: - config = resolve('.coveralls.mock', {}) + with caplog.at_level(logging.WARNING): + config = resolve({}) assert config.repo_token == 'xxx' assert config.service_name == 'jenkins' - warning.assert_called_once_with( - 'Ignoring unknown config option %r from %s.', - 'bogus_key', '.coveralls.mock', - ) + # Assert on the rendered warning, not the lazy-logging format + args: the + # dropped key and its source file are the observable behaviour. + assert 'bogus_key' in caplog.text + assert '.coveralls.yml' in caplog.text @pytest.mark.skipif(yaml is None, reason='requires PyYAML') @unittest.mock.patch.dict(os.environ, {}, clear=True) def test_bool_flags_are_coerced_from_config_file( - tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, ) -> None: # A config file may carry a non-bool (e.g. a quoted parallel: "yes"); it # must be normalized rather than forwarded to the API as a stray string. - monkeypatch.chdir(tmp_path) - (tmp_path / '.coveralls.mock').write_text( + (tmp_path / '.coveralls.yml').write_text( 'repo_token: xxx\nparallel: "yes"\nskip_ssl_verify: "1"\n', encoding='utf-8', ) - config = resolve('.coveralls.mock', {}) + config = resolve({}) assert config.parallel is True assert config.skip_ssl_verify @unittest.mock.patch.dict(os.environ, {}, clear=True) -def test_unknown_override_key_warns_and_is_dropped() -> None: +def test_unknown_override_key_warns_and_is_dropped( + caplog: pytest.LogCaptureFixture, +) -> None: # Same rule as the config file: an unknown key from any source is dropped # with a warning rather than crashing (previously a raw TypeError from # Config(**merged) for an unexpected Coveralls() kwarg). - with unittest.mock.patch.object(log, 'warning') as warning: + with caplog.at_level(logging.WARNING): config = resolve_config(repo_token='xxx', bogus_key='nope') assert config.repo_token == 'xxx' assert not hasattr(config, 'bogus_key') - warning.assert_called_once_with( - 'Ignoring unknown config option %r from %s.', 'bogus_key', 'arguments', - ) + assert 'bogus_key' in caplog.text + assert 'arguments' in caplog.text @pytest.mark.skipif(yaml is not None, reason='requires no PyYAML') @unittest.mock.patch.dict(os.environ, {}, clear=True) -def test_file_source_without_yaml_warns() -> None: - with unittest.mock.patch.object(log, 'warning') as warning: - resolve('.coveralls.mock', {}) - warning.assert_called_once_with( - 'PyYAML is not installed, skipping %s.', '.coveralls.mock', +def test_file_source_without_yaml_warns( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, +) -> None: + # The warning fires only when the YAML file actually exists: an absent file + # is silent so a TOML-only or env-only user is never nagged about PyYAML. + (tmp_path / '.coveralls.yml').write_text( + 'repo_token: xxx\n', encoding='utf-8', ) + with caplog.at_level(logging.WARNING): + resolve({}) + assert 'PyYAML is not installed' in caplog.text + assert '.coveralls.yml' in caplog.text def test_resolve_returns_config_instance() -> None: @@ -404,26 +409,29 @@ def test_resolve_returns_config_instance() -> None: @unittest.mock.patch.dict(os.environ, {}, clear=True) -def test_coveralls_host_alias_is_permanent_and_silent() -> None: +def test_coveralls_host_alias_is_permanent_and_silent( + caplog: pytest.LogCaptureFixture, +) -> None: # coveralls_host and host are both long-term spellings; the alias maps to # host and must NOT emit a deprecation warning. - with unittest.mock.patch.object(log, 'warning') as warning: + with caplog.at_level(logging.WARNING): config = resolve_config( repo_token='x', coveralls_host='https://old.example.com', ) assert config.host == 'https://old.example.com' - warning.assert_not_called() + assert not caplog.records @unittest.mock.patch.dict(os.environ, {}, clear=True) -def test_deprecated_config_file_key_warns() -> None: - with unittest.mock.patch.object(log, 'warning') as warning: +def test_deprecated_config_file_key_warns( + caplog: pytest.LogCaptureFixture, +) -> None: + with caplog.at_level(logging.WARNING): config = resolve_config(repo_token='x', config_file='custom.rc') assert config.rcfile == 'custom.rc' - warning.assert_called_once_with( - '%r is deprecated and will be removed in a future release; use %r ' - 'instead (in %s).', 'config_file', 'rcfile', 'arguments', - ) + assert 'config_file' in caplog.text + assert 'deprecated' in caplog.text + assert 'rcfile' in caplog.text @unittest.mock.patch.dict(os.environ, {}, clear=True) @@ -440,14 +448,13 @@ def test_alias_does_not_override_explicit_canonical() -> None: @pytest.mark.parametrize('content', ['', '\n\n', '# only a comment\n']) @unittest.mock.patch.dict(os.environ, {}, clear=True) def test_empty_config_file_is_ignored( - content: str, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, + content: str, tmp_path: pathlib.Path, ) -> None: # yaml.safe_load() returns None for empty/comment-only files; resolve must # treat that as no config rather than crashing on a None update. - monkeypatch.chdir(tmp_path) - (tmp_path / '.coveralls.mock').write_text(content, encoding='utf-8') + (tmp_path / '.coveralls.yml').write_text(content, encoding='utf-8') - config = resolve('.coveralls.mock', {}) + config = resolve({}) assert config.service_name == 'coveralls-python' assert config.repo_token is None @@ -456,38 +463,37 @@ def test_empty_config_file_is_ignored( @pytest.mark.skipif(yaml is None, reason='requires PyYAML') @unittest.mock.patch.dict(os.environ, {}, clear=True) def test_none_rcfile_override_keeps_file_value( - tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, ) -> None: # The CLI forwards rcfile=None when --rcfile is not passed; that must not # override an rcfile/config_file set in the config file. - monkeypatch.chdir(tmp_path) - (tmp_path / '.coveralls.mock').write_text( + (tmp_path / '.coveralls.yml').write_text( 'repo_token: xxx\nconfig_file: from_yaml.rc\n', encoding='utf-8', ) - config = resolve('.coveralls.mock', {'rcfile': None}) + config = resolve({'rcfile': None}) assert config.rcfile == 'from_yaml.rc' @pytest.mark.skipif(yaml is None, reason='requires PyYAML') @unittest.mock.patch.dict(os.environ, {}, clear=True) def test_alias_and_deprecated_file_keys_still_work( - tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, ) -> None: - monkeypatch.chdir(tmp_path) - (tmp_path / '.coveralls.mock').write_text( + (tmp_path / '.coveralls.yml').write_text( 'repo_token: xxx\n' 'coveralls_host: https://old.example.com\n' 'config_file: custom.rc\n', encoding='utf-8', ) - with unittest.mock.patch.object(log, 'warning') as warning: - config = resolve('.coveralls.mock', {}) + with caplog.at_level(logging.WARNING): + config = resolve({}) assert config.host == 'https://old.example.com' assert config.rcfile == 'custom.rc' - # config_file is deprecated (warns); coveralls_host is a permanent alias. - warning.assert_called_once_with( - '%r is deprecated and will be removed in a future release; use %r ' - 'instead (in %s).', 'config_file', 'rcfile', '.coveralls.mock', - ) + # config_file is deprecated (warns) and names its source file; the + # permanent coveralls_host alias must stay silent. + assert 'config_file' in caplog.text + assert 'deprecated' in caplog.text + assert '.coveralls.yml' in caplog.text + assert 'coveralls_host' not in caplog.text diff --git a/tests/api/toml_config_test.py b/tests/api/toml_config_test.py new file mode 100644 index 00000000..d3c17fd5 --- /dev/null +++ b/tests/api/toml_config_test.py @@ -0,0 +1,178 @@ +import logging +import os +import pathlib +import sys +import unittest.mock + +import pytest +try: + import yaml +except ImportError: + yaml = None # type: ignore[assignment] + +from coveralls.configuration import resolve + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + + +pytestmark = pytest.mark.usefixtures('isolate_cwd') + + +def _write_pyproject(tmp_path: pathlib.Path, body: str) -> None: + (tmp_path / 'pyproject.toml').write_text(body, encoding='utf-8') + + +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_reads_toml_file(tmp_path: pathlib.Path) -> None: + _write_pyproject( + tmp_path, + '[tool.coveralls]\n' + 'repo_token = "xxx"\n' + 'service_name = "jenkins"\n', + ) + config = resolve({}) + assert config.repo_token == 'xxx' + assert config.service_name == 'jenkins' + + +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_toml_support_does_not_require_pyyaml( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, +) -> None: + # TOML parsing is always available (stdlib tomllib / tomli), so a + # pyproject-configured project must work even without PyYAML installed and + # must not emit the PyYAML warning when no .coveralls.yml exists. + _write_pyproject(tmp_path, '[tool.coveralls]\nrepo_token = "xxx"\n') + with caplog.at_level(logging.WARNING): + config = resolve({}) + assert config.repo_token == 'xxx' + assert not caplog.records + + +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_toml_native_booleans_are_preserved(tmp_path: pathlib.Path) -> None: + _write_pyproject( + tmp_path, + '[tool.coveralls]\nrepo_token = "xxx"\nparallel = true\n', + ) + config = resolve({}) + assert config.parallel is True + + +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_toml_unknown_key_warns( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, +) -> None: + _write_pyproject( + tmp_path, + '[tool.coveralls]\nrepo_token = "xxx"\nbogus_key = "nope"\n', + ) + with caplog.at_level(logging.WARNING): + config = resolve({}) + assert config.repo_token == 'xxx' + assert 'bogus_key' in caplog.text + assert 'pyproject.toml' in caplog.text + + +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_toml_aliases_and_deprecated_keys( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, +) -> None: + _write_pyproject( + tmp_path, + '[tool.coveralls]\n' + 'repo_token = "xxx"\n' + 'coveralls_host = "https://old.example.com"\n' + 'config_file = "custom.rc"\n', + ) + with caplog.at_level(logging.WARNING): + config = resolve({}) + assert config.host == 'https://old.example.com' + assert config.rcfile == 'custom.rc' + # config_file warns and names pyproject.toml; coveralls_host stays silent. + assert 'config_file' in caplog.text + assert 'deprecated' in caplog.text + assert 'pyproject.toml' in caplog.text + assert 'coveralls_host' not in caplog.text + + +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_missing_tool_coveralls_table_is_ignored( + tmp_path: pathlib.Path, +) -> None: + # A pyproject.toml without a [tool.coveralls] table (the common case for + # any Python project) provides no settings and must not error. + _write_pyproject(tmp_path, '[tool.other]\nkey = "value"\n') + config = resolve({}) + assert config.service_name == 'coveralls-python' + assert config.repo_token is None + + +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_empty_tool_coveralls_table_is_ignored( + tmp_path: pathlib.Path, +) -> None: + _write_pyproject(tmp_path, '[tool.coveralls]\n') + config = resolve({}) + assert config.service_name == 'coveralls-python' + assert config.repo_token is None + + +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_malformed_toml_raises(tmp_path: pathlib.Path) -> None: + _write_pyproject(tmp_path, '[tool.coveralls]\nrepo_token = \n') + with pytest.raises(tomllib.TOMLDecodeError): + resolve({}) + + +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_non_table_tool_coveralls_raises(tmp_path: pathlib.Path) -> None: + _write_pyproject(tmp_path, '[tool]\ncoveralls = "nope"\n') + with pytest.raises(TypeError, match='expected a table'): + resolve({}) + + +@pytest.mark.skipif(yaml is None, reason='requires PyYAML') +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_yaml_wins_over_toml_and_warns( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, +) -> None: + # First file with settings wins, no merging: the legacy .coveralls.yml + # takes precedence over pyproject.toml and the collision is flagged. + (tmp_path / '.coveralls.yml').write_text( + 'repo_token: from_yaml\n', encoding='utf-8', + ) + _write_pyproject( + tmp_path, + '[tool.coveralls]\nrepo_token = "from_toml"\n', + ) + with caplog.at_level(logging.WARNING): + config = resolve({}) + + assert config.repo_token == 'from_yaml' + # The collision warning names both files and flags the YAML as legacy. + assert '.coveralls.yml' in caplog.text + assert 'pyproject.toml' in caplog.text + assert 'legacy' in caplog.text + + +@pytest.mark.skipif(yaml is None, reason='requires PyYAML') +@unittest.mock.patch.dict(os.environ, {}, clear=True) +def test_empty_yaml_falls_through_to_toml( + tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, +) -> None: + # An empty .coveralls.yml provides no settings, so pyproject.toml is used + # and no collision warning is emitted. + (tmp_path / '.coveralls.yml').write_text('\n', encoding='utf-8') + _write_pyproject( + tmp_path, + '[tool.coveralls]\nrepo_token = "from_toml"\n', + ) + with caplog.at_level(logging.WARNING): + config = resolve({}) + + assert config.repo_token == 'from_toml' + assert not caplog.records diff --git a/uv.lock b/uv.lock index 1b8a98fb..b3c67c15 100644 --- a/uv.lock +++ b/uv.lock @@ -248,6 +248,7 @@ source = { editable = "." } dependencies = [ { name = "coverage", extra = ["toml"] }, { name = "requests" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typer" }, { name = "urllib3" }, ] @@ -271,6 +272,7 @@ requires-dist = [ { name = "coverage", extras = ["toml"], specifier = ">=6.3,<8.0" }, { name = "pyyaml", marker = "extra == 'yaml'", specifier = ">=3.10,<7.0" }, { name = "requests", specifier = ">=2.30.0,<3.0.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" }, { name = "typer", specifier = ">=0.12.0,<1.0.0" }, { name = "urllib3", specifier = ">=2.0.0,<3.0.0" }, ]