diff --git a/.git_archival.txt b/.git_archival.txt new file mode 100644 index 0000000..8fb235d --- /dev/null +++ b/.git_archival.txt @@ -0,0 +1,4 @@ +node: $Format:%H$ +node-date: $Format:%cI$ +describe-name: $Format:%(describe:tags=true,match=*[0-9]*)$ +ref-names: $Format:%D$ diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cf9df9b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf +.git_archival.txt export-subst diff --git a/.github/TEST_FAILURE_REPORT_TEMPLATE.md b/.github/TEST_FAILURE_REPORT_TEMPLATE.md new file mode 100644 index 0000000..d232cba --- /dev/null +++ b/.github/TEST_FAILURE_REPORT_TEMPLATE.md @@ -0,0 +1,10 @@ +--- +title: '{{ env.TITLE }} ({{ date | date("YYYY-MM-DD") }})' +labels: ['type::bug', 'type::testing', 'source::auto'] +--- + +The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC + +Full run: https://github.com/anaconda/conda-anaconda-telemetry/actions/runs/{{ env.RUN_ID }} + +(This post will be updated if another test fails today, as long as this issue remains open.) diff --git a/.github/disclaimer.txt b/.github/disclaimer.txt new file mode 100644 index 0000000..fd50654 --- /dev/null +++ b/.github/disclaimer.txt @@ -0,0 +1,2 @@ +Copyright (C) 2024 Anaconda, Inc +SPDX-License-Identifier: BSD-3-Clause diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml new file mode 100644 index 0000000..031f487 --- /dev/null +++ b/.github/workflows/pre_commit.yml @@ -0,0 +1,26 @@ +name: Pre-Commit + +on: + push: + branches: + - main + pull_request: + branches: + - main + merge_group: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 + with: + python-version: '3.11' + cache: pip + - run: pip install pre-commit + - run: pre-commit run --all-files diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2af336a..868adec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,9 @@ on: # https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads#pull_request pull_request: + # https://docs.github.com/en/webhooks/webhook-events-and-payloads#merge_group + merge_group: + # https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads#workflow_dispatch workflow_dispatch: @@ -63,13 +66,40 @@ jobs: tests: needs: changes - if: needs.changes.outputs.code == 'true' + if: github.event_name == 'schedule' || needs.changes.outputs.code == 'true' + + defaults: + run: + # https://github.com/conda-incubator/setup-miniconda#use-a-default-shell + shell: bash -el {0} # bash exit immediately on error + login shell - runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.9", "3.12"] - os: ["macos-latest", "ubuntu-latest", "windows-latest"] + python-version: ['3.9', '3.10', '3.11', '3.12'] + os: [ubuntu-latest, windows-latest, macos-13, macos-latest] + exclude: + # Windows: only test lowest and highest Python versions + - os: windows-latest + python-version: '3.10' + - os: windows-latest + python-version: '3.11' + # macOS x86_64: only test lowest Python version + - os: macos-13 + python-version: '3.10' + - os: macos-13 + python-version: '3.11' + - os: macos-13 + python-version: '3.12' + # macOS arm64: only test highest Python version + - os: macos-14 + python-version: '3.9' + - os: macos-14 + python-version: '3.10' + - os: macos-14 + python-version: '3.11' + runs-on: ${{ matrix.os }} + env: + ErrorActionPreference: Stop # powershell exit immediately on error steps: # Clean checkout of specific git ref needed for package metadata version @@ -90,17 +120,19 @@ jobs: path: ~/conda_pkgs_dir key: cache-${{ env.HASH }} - - uses: conda-incubator/setup-miniconda@d2e6a045a86077fb6cad6f5adf368e9076ddaa8d # v3 - name: Setup Miniconda + - name: Setup Miniconda + uses: conda-incubator/setup-miniconda@d2e6a045a86077fb6cad6f5adf368e9076ddaa8d # v3.1.0 with: - python-version: ${{ matrix.python-version }} - channels: defaults run-post: false # skip post cleanup + # conda not preinstalled in arm64 runners + miniconda-version: ${{ runner.arch == 'ARM64' && 'latest' || null }} + architecture: ${{ runner.arch }} + channels: defaults - name: Conda Install run: > conda install - --name test + --channel conda-canary/label/dev --yes --file tests/requirements.txt --file tests/requirements-ci.txt @@ -108,16 +140,17 @@ jobs: # TODO: how can we remove this step? - name: Install Self - run: conda run --name test pip install --no-build-isolation --no-deps e . + run: pip install -e . - name: Conda Info - run: conda info --verbose + # view test env info (not base) + run: python -m conda info --verbose - name: Conda Config run: conda config --show-sources - name: Conda List - run: conda list --show-channel-urls --name test + run: conda list --show-channel-urls - name: Run Tests # Windows is sensitive to long paths, using `--basetemp=${{ runner.temp }} to @@ -130,21 +163,63 @@ jobs: --basetemp=${{ runner.temp }} -n auto - build: + # required check + analyze: needs: [tests] + if: '!cancelled()' + + runs-on: ubuntu-latest + steps: + - name: Determine Success + uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe # v1.2.2 + id: alls-green + with: + # permit jobs to be skipped if there are no code changes (see changes job) + allowed-skips: ${{ toJSON(needs) }} + jobs: ${{ toJSON(needs) }} + + - name: Checkout Source + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + # source code is needed to report failures + if: always() && github.event_name != 'pull_request' && steps.alls-green.outputs.result == 'failure' + + - name: Report Failures + if: always() && github.event_name != 'pull_request' && steps.alls-green.outputs.result == 'failure' + uses: JasonEtco/create-an-issue@1b14a70e4d8dc185e5cc76d3bec9eab20257b2c5 # v2.9.2 + env: + GITHUB_TOKEN: ${{ secrets.AUTO_REPORT_TEST_FAILURE }} + RUN_ID: ${{ github.run_id }} + TITLE: Tests failed + with: + filename: .github/TEST_FAILURE_REPORT_TEMPLATE.md + update_existing: false + + # canary builds + build: + needs: [analyze] # only build canary build if # - prior steps succeeded, - # - this is the main branch + # - this is the main repo, and + # - we are on the main, feature, or release branch if: >- - success() + !cancelled() && !github.event.repository.fork + && ( + github.ref_name == 'main' + || startsWith(github.ref_name, 'feature/') + || endsWith(github.ref_name, '.x') + ) strategy: matrix: include: - runner: ubuntu-latest - subdir: noarch - python-version: 3.12 - + subdir: linux-64 + - runner: macos-13 + subdir: osx-64 + - runner: macos-latest + subdir: osx-arm64 + - runner: windows-latest + subdir: win-64 runs-on: ${{ matrix.runner }} steps: # Clean checkout of specific git ref needed for package metadata version @@ -156,71 +231,52 @@ jobs: clean: true fetch-depth: 0 - - name: Hash + Timestamp - run: echo "HASH=${{ runner.os }}-${{ runner.arch }}-Py${{ matrix.python-version }}-$(date -u "+%Y%m")" >> $GITHUB_ENV - - - name: Cache Conda - uses: actions/cache@6849a6489940f00c2f30c0fb92c6274307ccb58a # v4.1.2 + # Explicitly use Python 3.11 since each of the OSes has a different default Python + - name: Setup Python + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0 with: - path: ~/conda_pkgs_dir - key: cache-${{ env.HASH }} + python-version: '3.11' - - uses: conda-incubator/setup-miniconda@d2e6a045a86077fb6cad6f5adf368e9076ddaa8d # v3 - name: Setup Miniconda - with: - python-version: ${{ matrix.python-version }} - channels: defaults - run-post: false # skip post cleanup - # conda not preinstalled in arm64 runners - miniconda-version: ${{ runner.arch == 'ARM64' && 'latest' || null }} - architecture: ${{ runner.arch }} - - - name: Build package - # make sure we don't run on forks - if: github.repository_owner == 'anaconda' - id: build - shell: bash -l {0} - env: - # Run conda-build in isolated activation to properly package the software - _CONDA_BUILD_ISOLATED_ACTIVATION: 1 - run: | - set -euo pipefail - conda activate test - conda install --yes --quiet conda-build anaconda-client - # git needs to be installed after conda-build - # see https://github.com/conda/conda/issues/11758 - # see https://github.com/conda/actions/pull/47 - conda install --yes --quiet git - conda info - conda config --show-sources - conda list - conda build --croot=${{ runner.temp }}/pkgs --override-channels -c conda-canary/label/dev -c defaults recipe - - - name: Set variables - id: set-vars + - name: Detect Label + shell: python run: | - echo "PACKAGE_LABEL=${{ github.ref_name == 'main' && 'dev' || format('{0}-{1}', github.event.repository.name, github.ref_name) }}" >> $GITHUB_ENV - echo "PACKAGE_NAME=${{ github.event.repository.name }}" >> $GITHUB_ENV - - - name: Upload package - # make sure we don't run in branches - if: github.ref_name == 'main' - id: upload - shell: bash -l {0} - run: | - echo "::group::Uploading package" - anaconda \ - --token="${{ secrets.ANACONDA_ORG_TOKEN }}" \ - upload \ - --force \ - --register \ - --no-progress \ - --user="distribution-plugins" \ - --label="$PACKAGE_LABEL" \ - ${{ runner.temp }}/pkgs/${{ matrix.subdir }}/$PACKAGE_NAME-*.tar.bz2 - echo "Uploaded the following files:" - basename -a ./pkgs/${{ matrix.subdir }}/$PACKAGE_NAME-*.tar.bz2 - echo "::endgroup::" - - echo "Use this command to try out the build:" - echo "conda install -c distribution-plugins/label/$PACKAGE_LABEL $PACKAGE_NAME" + import re + from pathlib import Path + from os import environ + from subprocess import check_output + + # unless otherwise specified, commits are uploaded to the dev label + # e.g., `main` branch commits + envs = {"ANACONDA_ORG_LABEL": "dev"} + + if "${{ github.ref_name }}".startswith("feature/"): + # feature branch commits are uploaded to a custom label + envs["ANACONDA_ORG_LABEL"] = "${{ github.ref_name }}" + elif re.match(r"\d+(\.\d+)+\.x", "${{ github.ref_name }}"): + # release branch commits are added to the rc label + # see https://github.com/conda/infrastructure/issues/760 + _, name = "${{ github.repository }}".split("/") + envs["ANACONDA_ORG_LABEL"] = f"rc-{name}-${{ github.ref_name }}" + + # if no releases have occurred on this branch yet then `git describe --tag` + # will misleadingly produce a version number relative to the last release + # and not relative to the current release branch, if this is the case we need + # to override the version with a derivative of the branch name + + # override the version if `git describe --tag` does not start with the branch version + last_release = check_output(["git", "describe", "--tag"]) + prefix = "${{ github.ref_name }}"[:-1] # without x suffix + if not last_release.startswith(prefix): + envs["VERSION_OVERRIDE"] = "${{ github.ref_name }}" + + Path(environ["GITHUB_ENV"]).write_text("\n".join(f"{name}={value}" for name, value in envs.items())) + + - name: Create & Upload + uses: conda/actions/canary-release@6e72e0db87e72f0020e493aeb02f864363bd9258 # v24.11.1 + with: + package-name: ${{ github.event.repository.name }} + subdir: ${{ matrix.subdir }} + anaconda-org-channel: distribution-plugins + anaconda-org-label: ${{ env.ANACONDA_ORG_LABEL }} + anaconda-org-token: ${{ secrets.ANACONDA_ORG_TOKEN }} + conda-build-argumetns: --channel conda-canary/label/dev diff --git a/.gitignore b/.gitignore index cce9b0a..b88ac40 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,4 @@ cython_debug/ # conda development environment ./env .channel/ +_version.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6671160..0b7307d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,27 +1,80 @@ repos: + # generic verification and formatting - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - - id: check-yaml - exclude: "(mkdocs.yml|recipe/meta.yaml)" - - id: end-of-file-fixer - - id: trailing-whitespace - - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.13.0' # Use the sha / tag you want to point at + # standard end of line/end of file cleanup + - id: mixed-line-ending + - id: end-of-file-fixer + - id: trailing-whitespace + # ensure syntaxes are valid + - id: check-toml + - id: check-yaml + exclude: | + (?x)^( + recipe/meta.yaml | + docker-compose.yaml + ) + - id: check-json + # catch git merge/rebase problems + - id: check-merge-conflict + # Python verification and formatting + - repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.5.5 + hooks: + # auto inject license blurb + - id: insert-license + files: \.py$ + args: [--license-filepath, .github/disclaimer.txt, --no-extra-eol, --use-current-year] + - repo: https://github.com/adamchainz/blacken-docs + rev: 1.19.1 + hooks: + # auto format Python codes within docstrings + - id: blacken-docs + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.3 + hooks: + # lint & attempt to correct failures (e.g. pyupgrade) + - id: ruff + args: [--fix] + # compatible replacement for black + - id: ruff-format + - repo: https://github.com/macisamuele/language-formatters-pre-commit-hooks + rev: v2.14.0 hooks: - - id: mypy - additional_dependencies: ['types-requests'] - - repo: https://github.com/asottile/pyupgrade - rev: v3.19.0 + - id: pretty-format-toml + args: [--autofix, --trailing-commas] + - repo: https://github.com/jumanjihouse/pre-commit-hook-yamlfmt + rev: 0.2.3 + hooks: + - id: yamlfmt + # ruamel.yaml doesn't line wrap correctly (?) so set width to 1M to avoid issues + args: [--mapping=2, --offset=2, --sequence=4, --width=1000000, --implicit_start] + exclude: | + (?x)^( + recipe/meta.yaml + ) + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.29.4 + hooks: + # verify github syntaxes + - id: check-github-workflows + - id: check-renovate + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 hooks: - - id: pyupgrade - args: ["--py310-plus"] - - repo: https://github.com/akaihola/darker - rev: v2.1.1 + - id: mypy + additional_dependencies: [types-requests] + - repo: meta + # see https://pre-commit.com/#meta-hooks hooks: - - id: darker - additional_dependencies: [black==22.10.0] - - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 + - id: check-hooks-apply + - id: check-useless-excludes + - repo: local hooks: - - id: flake8 + - id: git-diff + name: git diff + entry: git diff --exit-code + language: system + pass_filenames: false + always_run: true diff --git a/conda_anaconda_telemetry/__init__.py b/conda_anaconda_telemetry/__init__.py index e69de29..8c97011 100644 --- a/conda_anaconda_telemetry/__init__.py +++ b/conda_anaconda_telemetry/__init__.py @@ -0,0 +1,32 @@ +# Copyright (C) 2024 Anaconda, Inc +# SPDX-License-Identifier: BSD-3-Clause +"""A conda plugin for Anaconda Telemetry.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from typing import Final + +#: Application name. +APP_NAME: Final = "conda-anaconda-telemetry" + +try: + from ._version import __version__ +except ImportError: + # _version.py is only created after running `pip install` + try: + from setuptools_scm import get_version + + __version__ = get_version(root="..", relative_to=__file__) + except (ImportError, OSError, LookupError): + # ImportError: setuptools_scm isn't installed + # OSError: git isn't installed + # LookupError: setuptools_scm unable to detect version + # conda-anaconda-telemetry follows SemVer, so the dev version is: + # MJ.MN.MICRO.devN+gHASH[.dirty] + __version__ = "0.0.0.dev0+placeholder" + +#: Application version. +APP_VERSION: Final = __version__ diff --git a/conda_anaconda_telemetry/hooks.py b/conda_anaconda_telemetry/hooks.py index 5bc520e..74fc6a7 100644 --- a/conda_anaconda_telemetry/hooks.py +++ b/conda_anaconda_telemetry/hooks.py @@ -1,3 +1,7 @@ +# Copyright (C) 2024 Anaconda, Inc +# SPDX-License-Identifier: BSD-3-Clause +"""Conda plugin that adds telemetry headers to requests made by conda.""" + from __future__ import annotations import functools @@ -6,19 +10,20 @@ import typing from conda.base.context import context -from conda.common.configuration import PrimitiveParameter from conda.cli.main_list import list_packages +from conda.common.configuration import PrimitiveParameter from conda.common.url import mask_anaconda_token from conda.models.channel import all_channel_urls -from conda.plugins import hookimpl, CondaRequestHeader, CondaSetting +from conda.plugins import CondaRequestHeader, CondaSetting, hookimpl try: - from conda_build import __version__ as CONDA_BUILD_VERSION + from conda_build import __version__ as conda_build_version except ImportError: - CONDA_BUILD_VERSION = "n/a" + conda_build_version = "n/a" if typing.TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Sequence + from typing import Callable logger = logging.getLogger(__name__) @@ -53,16 +58,21 @@ REQUEST_HEADER_HOSTS = {"repo.anaconda.com", "conda.anaconda.org"} -def timer(func): +def timer(func: Callable) -> Callable: + """Log the duration of a function call.""" + @functools.wraps(func) - def wrapper_timer(*args, **kwargs): + def wrapper_timer(*args: tuple, **kwargs: dict) -> Callable: + """Wrap the given function.""" if logger.getEffectiveLevel() <= logging.INFO: tic = time.perf_counter() value = func(*args, **kwargs) toc = time.perf_counter() elapsed_time = toc - tic logger.info( - f"function: {func.__name__}; duration (seconds): {elapsed_time:0.4f}" + "function: %s; duration (seconds): %0.4f", + func.__name__, + elapsed_time, ) return value @@ -72,9 +82,7 @@ def wrapper_timer(*args, **kwargs): def get_virtual_packages() -> tuple[str, ...]: - """ - Uses the ``conda.base.context.context`` object to retrieve registered virtual packages - """ + """Retrieve the registered virtual packages from conda's context.""" return tuple( f"{package.name}={package.version}={package.build}" for package in context.plugin_manager.get_virtual_package_records() @@ -82,24 +90,18 @@ def get_virtual_packages() -> tuple[str, ...]: def get_channel_urls() -> tuple[str, ...]: - """ - Returns a list of currently configured channel URLs with tokens masked - """ + """Return a list of currently configured channel URLs with tokens masked.""" channels = list(all_channel_urls(context.channels)) return tuple(mask_anaconda_token(c) for c in channels) def get_conda_command() -> str: - """ - Use ``sys.argv`` to determine the conda command that is current being run - """ + """Use ``sys.argv`` to determine the conda command that is current being run.""" return context._argparse_args.cmd def get_package_list() -> tuple[str, ...]: - """ - Retrieve the list of packages in the current environment - """ + """Retrieve the list of packages in the current environment.""" prefix = context.active_prefix or context.root_prefix _, packages = list_packages(prefix, format="canonical") @@ -107,27 +109,21 @@ def get_package_list() -> tuple[str, ...]: def get_search_term() -> str: - """ - Retrieve the search term being used when search command is run - """ + """Retrieve the search term being used when search command is run.""" return context._argparse_args.match_spec def get_install_arguments() -> tuple[str, ...]: - """ - Get the position argument which have specified via the ``install`` or ``create`` commands - """ + """Get the parsed position argument.""" return context._argparse_args.packages @timer @functools.lru_cache(None) def get_sys_info_header_value() -> str: - """ - Return ``;`` delimited string of extra system information - """ + """Return ``;`` delimited string of extra system information.""" telemetry_data = { - "conda_build_version": CONDA_BUILD_VERSION, + "conda_build_version": conda_build_version, "conda_command": get_conda_command(), } @@ -139,70 +135,51 @@ def get_sys_info_header_value() -> str: @timer @functools.lru_cache(None) def get_channel_urls_header_value() -> str: - """ - Return ``FIELD_SEPARATOR`` delimited string of channel URLs - """ + """Return ``FIELD_SEPARATOR`` delimited string of channel URLs.""" return FIELD_SEPARATOR.join(get_channel_urls()) @timer @functools.lru_cache(None) def get_virtual_packages_header_value() -> str: - """ - Return ``FIELD_SEPARATOR`` delimited string of virtual packages - """ + """Return ``FIELD_SEPARATOR`` delimited string of virtual packages.""" return FIELD_SEPARATOR.join(get_virtual_packages()) @timer @functools.lru_cache(None) def get_install_arguments_header_value() -> str: - """ - Return ``FIELD_SEPARATOR`` delimited string of channel URLs - """ + """Return ``FIELD_SEPARATOR`` delimited string of channel URLs.""" return FIELD_SEPARATOR.join(get_install_arguments()) @timer @functools.lru_cache(None) def get_installed_packages_header_value() -> str: - """ - Return ``FIELD_SEPARATOR`` delimited string of install arguments - """ + """Return ``FIELD_SEPARATOR`` delimited string of install arguments.""" return FIELD_SEPARATOR.join(get_package_list()) class HeaderWrapper(typing.NamedTuple): - """ - Object that wraps ``CondaRequestHeader`` and adds a ``size_limit`` field - """ + """Object that wraps ``CondaRequestHeader`` and adds a ``size_limit`` field.""" header: CondaRequestHeader size_limit: int def validate_headers( - custom_headers: list[HeaderWrapper], + header_wrappers: Sequence[HeaderWrapper], ) -> Iterator[CondaRequestHeader]: - """ - Makes sure that all headers combined are not larger than ``SIZE_LIMIT``. + """Make sure that all headers combined are not larger than ``SIZE_LIMIT``. Any headers over their individual limits will be truncated. """ - total_max_size = sum(header.size_limit for header in custom_headers) - assert ( - total_max_size <= SIZE_LIMIT - ), f"Total header size limited to {SIZE_LIMIT}. Exceeded with {total_max_size=}" - - for wrapper in custom_headers: + for wrapper in header_wrappers: wrapper.header.value = wrapper.header.value[: wrapper.size_limit] yield wrapper.header -def _conda_request_headers(): - if not context.plugins.anaconda_telemetry: - return - +def _conda_request_headers() -> Sequence[HeaderWrapper]: custom_headers = [ HeaderWrapper( header=CondaRequestHeader( @@ -247,7 +224,7 @@ def _conda_request_headers(): ) ) - if command in {"install", "create"}: + elif command in {"install", "create"}: custom_headers.append( HeaderWrapper( header=CondaRequestHeader( @@ -258,20 +235,23 @@ def _conda_request_headers(): ) ) - yield from validate_headers(custom_headers) + return custom_headers @hookimpl -def conda_session_headers(host: str): - try: - if host in REQUEST_HEADER_HOSTS: - yield from _conda_request_headers() - except Exception as exc: - logger.debug("Failed to collect telemetry data", exc_info=exc) +def conda_session_headers(host: str) -> Iterator[CondaRequestHeader]: + """Return a list of custom headers to be included in the request.""" + if context.plugins.anaconda_telemetry: + try: + if host in REQUEST_HEADER_HOSTS: + yield from validate_headers(_conda_request_headers()) + except Exception as exc: + logger.debug("Failed to collect telemetry data", exc_info=exc) @hookimpl -def conda_settings(): +def conda_settings() -> Iterator[CondaSetting]: + """Return a list of settings that can be configured by the user.""" yield CondaSetting( name="anaconda_telemetry", description="Whether Anaconda Telemetry is enabled", diff --git a/develop.sh b/develop.sh index bb52982..70df2f3 100755 --- a/develop.sh +++ b/develop.sh @@ -1,3 +1,5 @@ +#!/bin/bash + # Used to switch CONDA_EXE to the one located in development environment # To use this script, run `source develop.sh` @@ -8,8 +10,8 @@ fi CONDA_ENV_DIR="./env" -conda create -p "$CONDA_ENV_DIR" --file tests/requirements.txt --file tests/requirements-ci.txt --yes +conda create -p "$CONDA_ENV_DIR" --channel conda-canary/label/dev --file tests/requirements.txt --file tests/requirements-ci.txt --yes conda activate "$CONDA_ENV_DIR" -pip install --no-deps --no-index --no-build-isolation -e . +pip install -e . CONDA_EXE="$CONDA_PREFIX/condabin/conda" diff --git a/docker-compose.yaml b/docker-compose.yaml index 2d19d68..1c91bd8 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -19,11 +19,7 @@ services: soft: -1 hard: -1 healthcheck: - test: - [ - "CMD-SHELL", - "curl --output /dev/null --silent --head --fail -u elastic:${ES_LOCAL_PASSWORD} http://elasticsearch:${ES_LOCAL_PORT}", - ] + test: [CMD-SHELL, 'curl --output /dev/null --silent --head --fail -u elastic:${ES_LOCAL_PASSWORD} http://elasticsearch:${ES_LOCAL_PORT}'] interval: 5s timeout: 5s retries: 10 @@ -34,7 +30,7 @@ services: condition: service_healthy image: docker.elastic.co/elasticsearch/elasticsearch:${ES_LOCAL_VERSION} container_name: ${KIBANA_SETTINGS_LOCAL_CONTAINER_NAME} - restart: 'no' + restart: no command: > bash -c ' echo "Setup the kibana_system password"; @@ -59,11 +55,7 @@ services: - XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY=${KIBANA_ENCRYPTION_KEY} - ELASTICSEARCH_PUBLICBASEURL=http://localhost:${ES_LOCAL_PORT} healthcheck: - test: - [ - "CMD-SHELL", - "curl -s -I http://kibana:5601 | grep -q 'HTTP/1.1 302 Found'", - ] + test: [CMD-SHELL, curl -s -I http://kibana:5601 | grep -q 'HTTP/1.1 302 Found'] interval: 10s timeout: 10s retries: 20 diff --git a/pyproject.toml b/pyproject.toml index cf9b219..d6c58ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,11 @@ [build-system] -requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +requires = [ + "hatchling >=1.12.2", + "hatch-vcs >=0.2.0", +] [project] -name = "conda-anaconda-telemetry" -description = "A conda plugin for Anaconda telemetry" -readme = "README.md" -license = {file = "LICENSE"} classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python :: 3", @@ -16,21 +15,96 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy" + "Programming Language :: Python :: Implementation :: PyPy", ] -requires-python = ">=3.9" dependencies = [ "conda >=24.9", ] +description = "A conda plugin for Anaconda Telemetry" dynamic = [ - "version" + "version", ] +license = {file = "LICENSE"} +name = "conda-anaconda-telemetry" +readme = "README.md" +requires-python = ">=3.9" [project.entry-points.conda] conda-anaconda-telemetry = "conda_anaconda_telemetry.hooks" -[tool.setuptools.packages] -find = {} +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", # ignore type checking imports +] +omit = [ + "tests/*", +] +show_missing = true +skip_covered = true +sort = "Miss" + +[tool.coverage.run] +# store relative paths in coverage information +relative_files = true + +[tool.hatch.build.hooks.vcs] +version-file = "conda_anaconda_telemetry/_version.py" [tool.hatch.version] source = "vcs" + +[tool.hatch.version.raw-options] +local_scheme = "dirty-tag" + +[tool.pytest.ini_options] +addopts = [ + "--color=yes", + # "--cov=conda_anaconda_telemetry", # passed in test runner scripts instead (avoid debugger) + "--cov-report=term", # print summary table to screen + "--cov-report=xml", # for codecov/codecov-action upload + "--tb=native", + "-vv", +] +testpaths = ["tests"] + +[tool.ruff] +target-version = "py39" + +[tool.ruff.lint] +extend-per-file-ignores = {"tests/*" = ["D", "S101"]} +ignore = ["D203", "D213", "ISC001"] +# see https://docs.astral.sh/ruff/rules/ +select = [ + "A", # flake8-builtins + "ANN", # flake8-annotations + "ARG", # flake8-unused-arguments + "B", # flake8-bugbear + "C", # flake8-commas + "C4", # flake8-comprehensions + "C90", # mccabe + "D", # pydocstyle + "DTZ", # flake8-datetimez + "E", # pycodestyle errors + "ERA", # eradicate + "F", # pyflakes + "FA", # flake8-future-annotations + "G", # flake8-logging-format + "I", # isort + "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging + "N", # pep8-naming + "PIE", # flake8-pie + "PTH", # flake8-use-pathlib + "RUF", # Ruff-specific rules + "S", # flake8-bandit + "SIM", # flake8-simplify + "T10", # flake8-debugger + "TCH", # flake8-type-checking + "UP", # pyupgrade + "W", # pycodestyle warnings + "YTT", # flake8-2020 +] + +[tool.setuptools] +packages = ["conda_anaconda_telemetry"] diff --git a/recipe/meta.yaml b/recipe/meta.yaml index e0dda43..07fd3f7 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -2,7 +2,7 @@ package: name: {{ name|lower }} - version: "{{ GIT_DESCRIBE_TAG }}.{{ GIT_BUILD_STR }}" + version: {{ os.getenv("VERSION_OVERRIDE") or GIT_DESCRIBE_TAG }}.{{ GIT_BUILD_STR }} source: # git_url only captures committed code @@ -19,16 +19,16 @@ requirements: host: - python >=3.9 - pip - - hatchling - - hatch-vcs + - hatchling >=1.12.2 + - hatch-vcs >=0.2.0 run: - python >=3.9 - - conda-canary/label/dev::conda + - conda >24.9.2 test: requires: - - conda-canary/label/dev::conda - pip + - conda >24.9.2 commands: - conda --version - pip check @@ -41,7 +41,7 @@ about: summary: Anaconda Telemetry conda plugin description: Anaconda Telemetry for conda adds helps us understand how conda is being used. license: BSD-3-Clause - dev_url: https://github.com/anaconda/{{ name }} + license_file: LICENSE extra: recipe-maintainers: diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index e10f006..0000000 --- a/setup.cfg +++ /dev/null @@ -1,3 +0,0 @@ -[flake8] -max-line-length = 99 -ignore = E126,E133,E226,E241,E242,E302,E704,E731,E722,W503,E402,W504,F821,E203 diff --git a/tests/requirements-ci.txt b/tests/requirements-ci.txt index 52a48f0..bc392d6 100644 --- a/tests/requirements-ci.txt +++ b/tests/requirements-ci.txt @@ -1,11 +1,9 @@ # renovate: datasource=conda depName=main/conda-build conda-build >=24.9.0 # renovate: datasource=conda depName=main/hatchling -hatchling ==1.25.0 +hatchling >=1.12.2 # renovate: datasource=conda depName=main/hatch-vcs -hatch-vcs ==0.3.0 -# renovate: datasource=conda depName=main/pip -pip ==24.2 +hatch-vcs >=0.2.0 # renovate: datasource=conda depName=main/pytest pytest ==7.4.4 # renovate: datasource=conda depName=main/pytest-cov diff --git a/tests/requirements.txt b/tests/requirements.txt index 8ad35ea..b57d43f 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,2 +1,2 @@ -conda-canary/label/dev::conda +conda >24.9.2 python >=3.9 diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 31fc1e8..5f36570 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -1,43 +1,56 @@ +# Copyright (C) 2024 Anaconda, Inc +# SPDX-License-Identifier: BSD-3-Clause +from __future__ import annotations + import logging +from typing import TYPE_CHECKING +from unittest.mock import MagicMock import pytest from conda_anaconda_telemetry.hooks import ( - conda_session_headers, - conda_settings, - HEADER_INSTALL, HEADER_CHANNELS, - HEADER_SYS_INFO, - HEADER_VIRTUAL_PACKAGES, + HEADER_INSTALL, HEADER_PACKAGES, HEADER_SEARCH, + HEADER_SYS_INFO, + HEADER_VIRTUAL_PACKAGES, + SIZE_LIMIT, + _conda_request_headers, + conda_session_headers, + conda_settings, timer, ) +if TYPE_CHECKING: + from pytest import CaptureFixture, MonkeyPatch + from pytest_mock import MockerFixture + + #: Host used across all tests TEST_HOST = "repo.anaconda.com" +TEST_PACKAGES = [ + "defaults/osx-arm64::sqlite-3.45.3-h80987f9_0", + "defaults/osx-arm64::pcre2-10.42-hb066dcc_1", + "defaults/osx-arm64::libxml2-2.13.1-h0b34f26_2", +] + + +def mock_list_packages(*args: tuple, **kwargs: dict) -> tuple: # noqa: ARG001 + return 0, TEST_PACKAGES + @pytest.fixture(autouse=True) -def packages(mocker): +def packages(mocker: MockerFixture) -> list: """ Mocks ``conda_anaconda_telemetry.hooks.list_packages`` """ - packages = [ - "defaults/osx-arm64::sqlite-3.45.3-h80987f9_0", - "defaults/osx-arm64::pcre2-10.42-hb066dcc_1", - "defaults/osx-arm64::libxml2-2.13.1-h0b34f26_2", - ] - - def mock_list_packages(*args, **kwargs): - return 0, packages - mocker.patch("conda_anaconda_telemetry.hooks.list_packages", mock_list_packages) - - return packages + return TEST_PACKAGES -def test_conda_request_header_default_headers(mocker): +def test_conda_request_header_default_headers(mocker: MockerFixture) -> None: """ Ensure default headers are returned """ @@ -62,7 +75,9 @@ def test_conda_request_header_default_headers(mocker): ) -def test_conda_request_header_with_search(monkeypatch, mocker): +def test_conda_request_header_with_search( + monkeypatch: MonkeyPatch, mocker: MockerFixture +) -> None: """ Ensure default headers are returned when conda search is invoked """ @@ -86,7 +101,9 @@ def test_conda_request_header_with_search(monkeypatch, mocker): ) -def test_conda_request_header_with_install(monkeypatch, mocker): +def test_conda_request_header_with_install( + monkeypatch: MonkeyPatch, mocker: MockerFixture +) -> None: """ Ensure default headers are returned when conda search is invoked """ @@ -110,7 +127,7 @@ def test_conda_request_header_with_install(monkeypatch, mocker): ) -def test_conda_request_header_when_disabled(monkeypatch, mocker): +def test_conda_request_header_when_disabled(mocker: MockerFixture) -> None: """ Make sure that nothing is returned when the plugin is disabled via settings """ @@ -120,14 +137,14 @@ def test_conda_request_header_when_disabled(monkeypatch, mocker): assert not tuple(conda_session_headers(TEST_HOST)) -def test_timer_in_info_mode(caplog): +def test_timer_in_info_mode(caplog: CaptureFixture) -> None: """ Ensure the timer decorator works and logs the time taken in INFO mode """ caplog.set_level(logging.INFO) @timer - def test(): + def test() -> int: return 1 assert test() == 1 @@ -138,7 +155,7 @@ def test(): assert "function: test; duration (seconds):" in caplog.text -def test_conda_settings(): +def test_conda_settings() -> None: """ Ensure the correct conda settings are returned """ @@ -150,7 +167,9 @@ def test_conda_settings(): assert settings[0].parameter.default.value is True -def test_conda_session_headers_with_exception(mocker, caplog): +def test_conda_session_headers_with_exception( + mocker: MockerFixture, caplog: CaptureFixture +) -> None: """ When any exception is encountered, ``conda_session_headers`` should return nothing and log a debug message. @@ -167,9 +186,40 @@ def test_conda_session_headers_with_exception(mocker, caplog): assert "Exception: Boom" in caplog.text -def test_conda_session_headers_with_non_matching_url(mocker, caplog): +def test_conda_session_headers_with_non_matching_url() -> None: """ When any exception is encountered, ``conda_session_headers`` should return nothing and log a debug message. """ assert list(conda_session_headers("https://example.com")) == [] + + +@pytest.mark.parametrize( + "command,argparse_mock", + ( + ( + ["conda", "install", "package"], + MagicMock(packages=["package"], cmd="install"), + ), + ( + ["conda", "search", "package"], + MagicMock(match_spec=["package"], cmd="search"), + ), + (["conda", "update", "package"], MagicMock(packages=["package"], cmd="update")), + ), +) +def test_header_wrapper_size_limit_constraint( + monkeypatch: MonkeyPatch, + mocker: MockerFixture, + command: list[str], + argparse_mock: MagicMock, +) -> None: + """ + Ensures that the size limit is being adhered to when all ``HeaderWrapper`` + objects are combined + """ + monkeypatch.setattr("sys.argv", command) + mocker.patch("conda_anaconda_telemetry.hooks.context._argparse_args", argparse_mock) + + headers = _conda_request_headers() + assert sum(header.size_limit for header in headers) <= SIZE_LIMIT