diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb85b01 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +*~ +*.swp +*.pyc +*.egg-info +.nfs* +.coverage* +*.DS_Store +.envrc +coverage.xml +test_results.xml +junit-coverage.xml +html/ +build/ +doc/api/ +dist/ +cache/ +.venv/ +_citools/ +_work/ +.mypy_cache/ +.pytest_cache/ +changelog.md +.pixi/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..e83d3a9 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +include: + - project: software/dev-profile + file: /gitlab/pixi.yml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..037e6f7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.3 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + args: [ --install-types, --non-interactive, --no-strict-optional, --ignore-missing-imports ] + exclude: '^.*/data/second_config\.py$' + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: check-ast + - id: check-added-large-files + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: check-case-conflict + - id: trailing-whitespace + - id: end-of-file-fixer + - id: debug-statements + - repo: https://github.com/fsfe/reuse-tool + rev: v3.0.2 + hooks: + - id: reuse + exclude: | + (?x)( + ^.pixi/| + ^.pixi.lock| + ) diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..67ba8c6 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright © 2023 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +version: 2 + +build: + os: "ubuntu-22.04" + tools: + python: "3.11" + +python: + install: + - method: pip + path: . + extra_requirements: + - doc diff --git a/.reuse/dep5 b/.reuse/dep5 new file mode 100644 index 0000000..4bc53b8 --- /dev/null +++ b/.reuse/dep5 @@ -0,0 +1,10 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: clapper +Upstream-Contact: Andre Anjos +Source: https://gitlab.idiap.ch/software/clapper + +Files: + pixi.lock + tests/data/* +Copyright: Copyright © 2022 Idiap Research Institute +License: BSD-3-Clause diff --git a/LICENSES/BSD-3-Clause.txt b/LICENSES/BSD-3-Clause.txt new file mode 100644 index 0000000..086d399 --- /dev/null +++ b/LICENSES/BSD-3-Clause.txt @@ -0,0 +1,11 @@ +Copyright (c) . + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7df19de --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ + + +[![latest-docs](https://img.shields.io/badge/docs-latest-orange.svg)](https://clapper.readthedocs.io/en/latest/) +[![build](https://gitlab.idiap.ch/software/clapper/badges/main/pipeline.svg)](https://gitlab.idiap.ch/software/clapper/commits/main) +[![coverage](https://gitlab.idiap.ch/software/clapper/badges/main/coverage.svg)](https://www.idiap.ch/software/biosignal/docs/software/clapper/main/coverage/index.html) +[![repository](https://img.shields.io/badge/gitlab-project-0000c0.svg)](https://gitlab.idiap.ch/software/clapper) + + +# Configuration Support for Python Packages and CLIs + +This package provides a way to define command-line-interface (CLI) applications +such that user options can be stored in Python-based configuration files and +read-out automatically. It also provides a rather simple RC (global +configuration) file support that can be used by modules to read +application-wide default values. + +For installation and usage instructions, check-out our documentation. diff --git a/doc/api.rst b/doc/api.rst new file mode 100644 index 0000000..646b2f2 --- /dev/null +++ b/doc/api.rst @@ -0,0 +1,23 @@ +.. SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +.. +.. SPDX-License-Identifier: BSD-3-Clause + +.. _clapper.api: + +============ + Python API +============ + +This section includes information for using the Python API of ``clapper``. + + +.. autosummary:: + :toctree: api + + clapper.rc + clapper.config + clapper.logging + clapper.click + + +.. include:: links.rst diff --git a/doc/click.rst b/doc/click.rst new file mode 100644 index 0000000..84e9199 --- /dev/null +++ b/doc/click.rst @@ -0,0 +1,306 @@ +.. SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +.. +.. SPDX-License-Identifier: BSD-3-Clause + +.. _clapper.click: + +====================== + Command-Line Helpers +====================== + +This package provides a few handy additions to the :py:mod:`click` command-line +interface (CLI) library, allowing one to build even more powerful CLIs. + + +.. _clapper.click.verbosity: + +Verbosity Option +---------------- + +The :py:func:`clapper.click.verbosity_option` :py:mod:`click` decorator allows +one to control the logging-level of a pre-defined :py:class:logging.Logger. +Here is an example usage. + +.. code-block:: python + + import clapper.click + import logging + + # retrieve the base-package logger + logger = logging.getLogger(__name__.split(".", 1)[0]) + + + @clapper.click.verbosity_option(logger) + def cli(verbose): + pass + +The verbosity option binds the command-line (``-v``) flag usage to setting the +:py:class:`logging.Logger` level by calling :py:meth:`logging.Logger.setLevel` +with the appropriate logging level, mapped as such: + +* 0 (the user has provide no ``-v`` option on the command-line): + ``logger.setLevel(logging.ERROR)`` +* 1 (the user provided a single ``-v``): ``logger.setLevel(logging.WARNING)`` +* 2 (the user provided the flag twice, ``-vv``): + ``logger.setLevel(logging.INFO)`` +* 3 (the user provide the flag thrice or more, ``-vvv``): + ``logger.setLevel(logging.DEBUG)`` + +.. note:: + If you do not care about the ``verbose`` parameter in your command and only + rely on the decorator to set the logging level, you can set ``expose_value`` + to ``False``: + + .. code-block:: python + + @clapper.click.verbosity_option(logger, expose_value=False) + def cli(): + pass + + + +.. _clapper.click.configcommand: + +Config Command +-------------- + +The :py:class:`clapper.click.ConfigCommand` is a type of +:py:class:`click.Command` in which declared CLI options may be either passed +via the command-line, or loaded from a :ref:`clapper.config`. It works by +reading the Python configuration file and filling up option values pretty much +as :py:mod:`click` would do, with one exception: CLI options can now be of any +Pythonic type. + +To implement this, a CLI implemented via :py:class:`clapper.click.ConfigCommand` +may not declare any arguments, only options. All arguments are interpreted as +configuration files, from where option values will be set, in order. Any type +of configuration resource can be provided (file paths, python modules or +entry-points). Command-line options take precedence over values set in +configuration files. The order of configuration files matters, and the final +values for CLI options follow the same rules as in +:ref:`clapper.config.chain-loading`. + +Options that may be read from configuration files must also be marked with the +custom click-type :py:class:`clapper.click.ResourceOption`. + +Here is an example usage of this class: + +.. literalinclude:: example_cli.py + :caption: Example CLI with config-file support + :language: python + + +If a configuration file is setup like this: + +.. literalinclude:: example_options.py + :caption: Example configuration file for the CLI above + :language: python + + +Then end result would be this: + +.. command-output:: python example_cli.py example_options.py + + +Notice that configuration options on the command-line take precedence: + +.. command-output:: python example_cli.py --str=baz example_options.py + + +Configuration options can also be loaded from `package entry-points`_ named +``test.app``. To do this, a package setup would have to contain a group named +``test.app``, and list entry-point names which point to modules containing +variables that can be loaded by the CLI application. For example, would a +package declare this entry-point: + +.. code-block:: python + + entry_points={ + # some test entry_points + 'test.app': [ + 'my-config = path.to.module.config', + ... + ], + }, + +Then, the application shown above would also be able to work like this: + +.. code-block:: shell + + python example_cli.py my-config + + +Options with type :py:class:`clapper.click.ResourceOption` may also point to +individual resources (specific variables on python modules). This may be, +however, a more seldomly used feature. Read the class documentation for +details. + + +.. _clapper.click.aliasedgroups: + +Aliased Command Groups +---------------------- + +When designing an CLI with multiple subcommands, it is sometimes useful to be +able to shorten command names. For example, being able to use ``git ci`` +instead of ``git commit``, is a form of aliasing. To do so in :py:mod:`click` +CLIs, it suffices to subclass all command group instances with +:py:class:`clapper.click.AliasedGroup`. This should include groups and +subgroups of any depth in your CLI. Here is an example usage: + + +.. literalinclude:: example_alias.py + :caption: Example CLI with group aliasing support + :language: python + +You may then shorten the command to be called such as this: + +.. command-output:: python example_alias.py pu + + +.. _clapper.click.config_helpers: + +Experiment Options (Config) Command-Group +----------------------------------------- + +When building complex CLIs in which support for `configuration +<:ref:clapper.config>`_ is required, it may be convenient to provide users with +CLI subcommands to display configuration resources (examples) shipped with the +package. To this end, we provide an easy to plug :py:class:`click.Group` +decorator that attaches a few useful subcommands to a predefined CLI command, +from your package. Here is an example on how to build a CLI to do this: + + +.. literalinclude:: example_config.py + :caption: Implementation a command group to affect the RC file of an application. + :language: python + + +Here is the generated command-line: + +.. command-output:: python example_config.py --help + + +You may try to use that example application like this: + +.. code-block:: shell + + # lists all installed resources in the entry-point-group + # "clapper.test.config" + $ python doc/example_config.py list + module: tests.data + complex + complex-var + first + first-a + first-b + second + second-b + second-c + verbose-config + + # describes a particular resource configuration + # Adding one or more "-v" (verbosity) options affects + # what is printed. + $ python doc/example_config.py describe "complex" -vv + Configuration: complex + Python Module: tests.data.complex + + Contents: + cplx = dict( + a="test", + b=42, + c=3.14, + d=[1, 2, 37], + ) + + # copies the module pointed by "complex" locally (to "local.py") + # for modification and testing + $ python doc/example_config.py copy complex local.py + $ cat local.py + cplx = dict( + a="test", + b=42, + c=3.14, + d=[1, 2, 37], + ) + +.. _clapper.click.rc_helpers: + +Global Configuration (RC) Command-Group +--------------------------------------- + +When building complex CLIs in which support for `global configuration +<:ref:clapper.rc>`_ is required, it may be convenient to provide users with CLI +subcommands to display current values, set or get the value of specific +configuration variables. For example, the ``git`` CLI provides the ``git +config`` command that fulfills this task. Here is an example on how to build a +CLI to affect your application's global RC file: + + +.. literalinclude:: example_defaults.py + :caption: Implementation a command group to affect the RC file of an application. + :language: python + + +Here is the generated command-line: + +.. command-output:: python example_defaults.py --help + + +You may try to use that example application like this: + +.. code-block:: shell + + $ python example_defaults.py set foo 42 + $ python example_defaults.py set bla.float 3.14 + $ python example_defaults.py get bla + {'float': 3.14} + $ python example_defaults.py show + foo = 42 + + [bla] + float = 3.14 + $ python example_defaults.py rm bla + $ python example_defaults.py show + foo = 42 + $ + + +.. _clapper.click.entrypoins: + +Multi-package Command Groups +---------------------------- + +You may have to write parts of your CLI in different software packages. We +recommend you look into the `Click-Plugins extension module `_ +as means to implement this in a Python-oriented way, using the `package +entry-points`_ (plugin) mechanism. + +.. _clapper.click.log_parameters: + +Log Parameters +-------------- + +The :py:func:`clapper.click.log_parameters` :py:mod:`click` method allows +one to log the parameters used within the current click context and their value for debuging purposes. +Here is an example usage. + +.. code-block:: python + + import clapper.click + import logging + + # retrieve the base-package logger + logger = logging.getLogger(__name__) + + + @clapper.click.verbosity_option(logger, short_name="vvv") + def cli(verbose): + clapper.click.log_parameters(logger) + +A pre-defined :py:class:`logging.Logger` have to be provided and, optionally, +a list of parameters to ignore can be provided as well, as a Tuple. + + +.. include:: links.rst diff --git a/doc/conf.py b/doc/conf.py new file mode 100644 index 0000000..fd9baec --- /dev/null +++ b/doc/conf.py @@ -0,0 +1,126 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +import pathlib +import time + +from importlib.metadata import distribution + +# -- General configuration ----------------------------------------------------- + +# If your documentation needs a minimal Sphinx version, state it here. +needs_sphinx = "1.3" + +# Add any Sphinx extension module names here, as strings. They can be extensions +# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. +extensions = [ + "sphinx.ext.todo", + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.doctest", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "auto_intersphinx", + "sphinx_autodoc_typehints", + "sphinx_copybutton", + "sphinx_inline_tabs", + "sphinxcontrib.programoutput", +] + +# Be picky about warnings +nitpicky = True + +# Ignores stuff we can't easily resolve on other project's sphinx manuals +nitpick_ignore = [] + +# Allows the user to override warnings from a separate file +nitpick_path = pathlib.Path("nitpick-exceptions.txt") +if nitpick_path.exists(): + for line in nitpick_path.open(): + if line.strip() == "" or line.startswith("#"): + continue + dtype, target = line.split(None, 1) + target = target.strip() + nitpick_ignore.append((dtype, target)) + +# Always includes todos +todo_include_todos = True + +# Generates auto-summary automatically +autosummary_generate = True + +# Create numbers on figures with captions +numfig = True + +# If we are on OSX, the 'dvipng' path maybe different +dvipng_osx = pathlib.Path("/Library/TeX/texbin/dvipng") +if dvipng_osx.exists(): + pngmath_dvipng = dvipng_osx + +# Add any paths that contain templates here, relative to this directory. +templates_path = ["_templates"] + +# The suffix of source filenames. +source_suffix = ".rst" + +# The main toctree document. +master_doc = "index" + +# General information about the project. +project = "clapper" +package = distribution(project) + +copyright = f"{time.strftime('%Y')}, Idiap Research Institute" # noqa: A001 + +# The short X.Y version. +version = package.version +# The full version, including alpha/beta/rc tags. +release = version + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +exclude_patterns = ["links.rst"] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = "sphinx" +pygments_dark_style = "monokai" + +# A list of ignored prefixes for module index sorting. +# modindex_common_prefix = [] + +# Some variables which are useful for generated material +project_variable = project.replace(".", "_") +short_description = package.metadata["Summary"] +owner = ["Idiap Research Institute"] + +# -- Options for HTML output --------------------------------------------------- + +html_theme = "furo" + +html_theme_options = { + "source_edit_link": f"https://gitlab.idiap.ch/software/{project}/-/edit/main/doc/{{filename}}", +} + +html_title = f"{project} {release}" + +# -- Post configuration -------------------------------------------------------- + +# Default processing flags for sphinx +autoclass_content = "class" +autodoc_member_order = "bysource" +autodoc_default_options = { + "members": True, + "undoc-members": True, + "show-inheritance": True, +} + +auto_intersphinx_packages = [("python", "3"), "click"] +auto_intersphinx_catalog = "catalog.json" + +# Doctest global setup +sphinx_source_dir = pathlib.Path.cwd().resolve() +doctest_global_setup = f""" +import os +data = os.path.join('{sphinx_source_dir}', 'data') +""" diff --git a/doc/config.rst b/doc/config.rst new file mode 100644 index 0000000..1885b0c --- /dev/null +++ b/doc/config.rst @@ -0,0 +1,177 @@ +.. SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +.. +.. SPDX-License-Identifier: BSD-3-Clause + +.. _clapper.config: + +==================================== + Experimental Configuration Options +==================================== + +In this section, we deal with configuration values that must typically be +provided by the user on a per-job basis. That is, every time the user executes +an application, the options may change. Global defaults are not very good for +these type of options, that are typically stored in configuration files read by +command-line options. + +Instead of yet-another-configuration-file format, we propose to use Python +itself to define configuration options. Variables set on the file act as +options themselves, and can assume any format or type. A mechanism of chain +loading allows an overwriting behaviour to take place. + +Because configuration files are Python files, they can be distributed with your +application, within the module itself, and are easy to find. Using `package +entry-points`_, it is possible to create shortcuts to important configuration +files provided with the package, for easy access. + + +Loading configuration options +----------------------------- + +There is only one single function that matters in this module: +:py:func:`clapper.config.load`. You should use it to load Python configuration +options: + +To load a configuration file, containing options into a dictionary mapping +variable names to values (of any Python type), use +:py:func:`clapper.config.load`: + +.. doctest:: + + >>> import os.path + >>> from clapper.config import load + >>> options = load([os.path.join(data, "basic_config.py")]) + + +If the function :py:func:`clapper.config.load` succeeds, it returns a +python module containing variables which represent the configuration resource. +For example, if the file ``basic_config.py`` contained: + +.. literalinclude:: data/basic_config.py + :language: python + :linenos: + :caption: "basic_config.py" + + +Then, the object ``options`` would look like this: + +.. doctest:: + + >>> print(f"a = {options.a}\nb = {options.b}") + a = 1 + b = 3 + + +.. _clapper.config.chain-loading: + +Chain Loading +------------- + +It is possible to implement chain configuration loading and overriding by +passing iterables with more than one filename to +:py:func:`clapper.config.load`. Suppose we have two configuration files +which must be loaded in sequence: + +.. literalinclude:: data/basic_config.py + :caption: "basic_config.py" (first to be loaded) + :language: python + :linenos: + +.. literalinclude:: data/second_config.py + :caption: "second_config.py" (loaded after basic_config.py) + :language: python + :linenos: + + +Then, one can chain-load them like this: + +.. doctest:: + + >>> import os.path + >>> from clapper.config import load + >>> file1 = os.path.join(data, "basic_config.py") + >>> file2 = os.path.join(data, "second_config.py") + >>> configuration = load([file1, file2]) + >>> print(f"a = {configuration.a} \nb = {configuration.b} \nc = {configuration.c}") # doctest: +NORMALIZE_WHITESPACE + a = 1 + b = 6 + c = 4 + + +The user wanting to override the values needs to manage the overriding and the +order in which the override happens. + + +.. _clapper.config.entry_points: + +Entry Points and Python Modules +------------------------------- + +The function :py:func:`clapper.config.load` can also load config files through +module entry-points, or Python module names. Entry-points are simply aliases +to Python modules and objects. To load entry-points via +:py:func:`clapper.config.load`, you must provide the group name of the entry +points. For example, if in your package setup, you defined the following +entry-points to 2 python modules such as the examples above: + +.. code-block:: python + + entry_points={ + ... + 'mypackage.config': [ + 'basic = mypackage.config.basic', + 'second = mypackage.config.second', + ], + ... + +You could do the same as such: + +.. code-block:: python + + >>> from clapper.config import load + >>> configuration = load(["basic", "second"], entry_point_group="mypackage.config") + >>> print(f"a = {configuration.a} \nb = {configuration.b} \nc = {configuration.c}") + a = 1 + b = 6 + c = 4 + +Or even refer to the module names themselves (instead of the entry-point names): + +.. code-block:: python + + >>> from clapper.config import load + >>> configuration = load(["mypackage.config.basic", "mypackage.config.second"]) + >>> print(f"a = {configuration.a} \nb = {configuration.b} \nc = {configuration.c}") + a = 1 + b = 6 + c = 4 + +Of course, mixture of entry-point names, paths and module names are also acceptable: + +.. code-block:: python + + >>> configuration = load(["basic", "mypackage.config.second"], entry_point_group="mypackage.config") + >>> print(f"a = {configuration.a} \nb = {configuration.b} \nc = {configuration.c}") + a = 1 + b = 6 + c = 4 + + +.. _clapper.config.resource: + +Loading Single Objects +---------------------- + +The function :py:func:`clapper.config.load` can also be used to load the +contents of specific variables within configuration files. To do this, you need +provide the name of an attribute to load. + +.. doctest:: + + >>> import os.path + >>> from clapper.config import load + >>> load([os.path.join(data, "basic_config.py")], attribute_name="b") + 3 + + +.. include:: links.rst diff --git a/doc/data/basic_config.py b/doc/data/basic_config.py new file mode 100644 index 0000000..3f973a8 --- /dev/null +++ b/doc/data/basic_config.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +a = 1 +b = a + 2 diff --git a/doc/data/second_config.py b/doc/data/second_config.py new file mode 100644 index 0000000..87a8957 --- /dev/null +++ b/doc/data/second_config.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +# the b variable from the last config file is available here +c = b + 1 # noqa: F821 +b = b + 3 # noqa: F821 diff --git a/doc/example_alias.py b/doc/example_alias.py new file mode 100644 index 0000000..0339694 --- /dev/null +++ b/doc/example_alias.py @@ -0,0 +1,33 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause +"""An example script to demonstrate config-file option readout.""" + +# To improve loading performance, we recommend you only import the very +# essential packages needed to start the CLI. Defer all other imports to +# within the function implementing the command. + +import clapper.click +import click + + +@click.group(cls=clapper.click.AliasedGroup) +def main(): + """Declare main command-line application.""" + pass + + +@main.command() +def push(): + """Push subcommand.""" + click.echo("push was called") + + +@main.command() +def pop(): + """Pop subcommand.""" + click.echo("pop was called") + + +if __name__ == "__main__": + main() diff --git a/doc/example_cli.py b/doc/example_cli.py new file mode 100644 index 0000000..85013db --- /dev/null +++ b/doc/example_cli.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause +"""An example script to demonstrate config-file option readout.""" + +# To improve loading performance, we recommend you only import the very +# essential packages needed to start the CLI. Defer all other imports to +# within the function implementing the command. + +import click + +from clapper.click import ConfigCommand, ResourceOption, verbosity_option +from clapper.logging import setup + +logger = setup(__name__.split(".", 1)[0]) + + +@click.command( + context_settings={ + "show_default": True, + "help_option_names": ["-?", "-h", "--help"], + }, + # if configuration 'modules' must be loaded from package entry-points, + # then must search this entry-point group: + entry_point_group="test.app", + cls=ConfigCommand, + epilog="""\b +Examples: + + $ test_app -vvv --integer=3 +""", +) +@click.option("--integer", type=int, default=42, cls=ResourceOption) +@click.option("--flag/--no-flag", default=False, cls=ResourceOption) +@click.option("--str", default="foo", cls=ResourceOption) +@click.option( + "--choice", + type=click.Choice(["red", "green", "blue"]), + cls=ResourceOption, +) +@verbosity_option(logger=logger) +@click.version_option(package_name="clapper") +@click.pass_context +def main(ctx, **_): + """Test our Click interfaces.""" + # Add imports needed for your code here, and avoid spending time loading! + + # In this example, we just print the loaded options to demonstrate loading + # from config files actually works! + for k, v in ctx.params.items(): + if k in ("dump_config", "config"): + continue + click.echo(f"{k}: {v}") + + +if __name__ == "__main__": + main() diff --git a/doc/example_config.py b/doc/example_config.py new file mode 100644 index 0000000..d9770e8 --- /dev/null +++ b/doc/example_config.py @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +from clapper.click import config_group +from clapper.logging import setup + +logger = setup(__name__.split(".", 1)[0]) + + +@config_group(logger=logger, entry_point_group="clapper.test.config") +def main(**kwargs): + """Use this command to list/describe/copy package config resources.""" + pass + + +if __name__ == "__main__": + main() diff --git a/doc/example_defaults.py b/doc/example_defaults.py new file mode 100644 index 0000000..e4b99db --- /dev/null +++ b/doc/example_defaults.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +from clapper.click import user_defaults_group +from clapper.logging import setup +from clapper.rc import UserDefaults + +logger = setup(__name__.split(".", 1)[0]) +rc = UserDefaults("myapp.toml", logger=logger) + + +@user_defaults_group(logger=logger, config=rc) +def main(**kwargs): + """Use this command to affect the global user defaults.""" + pass + + +if __name__ == "__main__": + main() diff --git a/doc/example_logging.py b/doc/example_logging.py new file mode 100644 index 0000000..70a9ae7 --- /dev/null +++ b/doc/example_logging.py @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +import logging + +from clapper.logging import setup + +logger = setup(__name__.split(".", 1)[0], format="%(levelname)s: %(message)s") +logger.setLevel(logging.INFO) +logger.info("test message") diff --git a/doc/example_options.py b/doc/example_options.py new file mode 100644 index 0000000..4e48871 --- /dev/null +++ b/doc/example_options.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +integer = 1000 +flag = True +choice = "blue" +str = "bar" # noqa: A001 diff --git a/doc/index.rst b/doc/index.rst new file mode 100644 index 0000000..0a36b95 --- /dev/null +++ b/doc/index.rst @@ -0,0 +1,46 @@ +.. SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +.. +.. SPDX-License-Identifier: BSD-3-Clause + +.. _clapper: + +==================================================== + Configuration Support for Python Packages and CLIs +==================================================== + +.. todolist:: + +This package provides a way to define command-line-interface (CLI) applications +such that user options can be stored in Python-based configuration files and +read-out automatically. It also provides a rather simple RC file support, +based on TOML_ that can be used by modules to read application-wide default +values. + +The project depends on an external Python package for CLI development, called +:py:mod:`click`, the tomli_ TOML_ parser, and the standard :py:mod:`logging` +modules. As a framework, no messages are directly printed to the screen. + + +Documentation +------------- + +.. toctree:: + :maxdepth: 2 + + install + rc + config + logging + click + api + + +Indices and tables +------------------ + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` + + +.. include:: links.rst diff --git a/doc/install.rst b/doc/install.rst new file mode 100644 index 0000000..b663998 --- /dev/null +++ b/doc/install.rst @@ -0,0 +1,70 @@ +.. SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +.. +.. SPDX-License-Identifier: BSD-3-Clause + +.. _clapper.install: + +============== + Installation +============== + +Installation may follow one of two paths: deployment or development. Choose the +relevant tab for details on each of those installation paths. + + +.. tab:: Deployment (pip/uv) + + Install using pip_, or your preferred Python project management solution (e.g. + uv_, rye_ or poetry_). + + **Stable** release, from PyPI: + + .. code:: sh + + pip install clapper + + **Latest** development branch, from its git repository: + + .. code:: sh + + pip install git+https://gitlab.idiap.ch/software/clapper@main + + +.. tab:: Deployment (pixi) + + Use pixi_ to add this package as a dependence: + + .. code:: sh + + pixi add clapper + + +.. tab:: Development + + Checkout the repository, and then use pixi_ to setup a full development + environment: + + .. code:: sh + + git clone git@gitlab.idiap.ch:software/clapper + pixi install --frozen + + .. tip:: + + The ``--frozen`` flag will ensure that the latest lock-file available + with sources is used. If you'd like to update the lock-file to the + latest set of compatible dependencies, remove that option. + + If you use `direnv to setup your pixi environment + `_ + when you enter the directory containing this package, you can use a + ``.envrc`` file similar to this: + + .. code:: sh + + watch_file pixi.lock + export PIXI_FROZEN="true" + eval "$(pixi shell-hook)" + + +.. include:: links.rst diff --git a/doc/links.rst b/doc/links.rst new file mode 100644 index 0000000..bd9b26c --- /dev/null +++ b/doc/links.rst @@ -0,0 +1,18 @@ +.. SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +.. +.. SPDX-License-Identifier: BSD-3-Clause + +.. _python: http://www.python.org +.. _pip: https://pip.pypa.io/en/stable/ +.. _uv: https://github.com/astral-sh/uv +.. _rye: https://github.com/astral-sh/rye +.. _poetry: https://python-poetry.org +.. _pixi: https://pixi.sh +.. _mamba: https://mamba.readthedocs.io/en/latest/index.html +.. _toml: https://toml.io +.. _tomli: https://pypi.org/project/tomli/ +.. _package entry-points: https://packaging.python.org/en/latest/specifications/entry-points/ +.. _click: http://click.pocoo.org/ +.. _click-plugins: https://github.com/click-contrib/click-plugins +.. _logging-tree module: https://pypi.org/project/logging_tree/ +.. _xdg-defaults: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html diff --git a/doc/logging.rst b/doc/logging.rst new file mode 100644 index 0000000..40b8c59 --- /dev/null +++ b/doc/logging.rst @@ -0,0 +1,119 @@ +.. SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +.. +.. SPDX-License-Identifier: BSD-3-Clause + +.. _clapper.logging: + +============================ + Logging Helpers and Policy +============================ + +We advise the use of the Python :py:mod:`logging` module to log messages from +your library. If you are unfamiliar with the design and use of that standard +Python module, we suggest you read our :ref:`clapper.logging.rationale`. + +We provide a single a method in this library to help setup a particular +:py:class:`logging.Logger` to output to a (text-based) stream. The +documentation of :py:func:`clapper.logging.setup` explains in details what it +does. To use it in an application, follow this pattern: + +.. code-block:: python + + import logging + from clapper.logging import setup + logger = setup("mypackage", format="%(levelname)s: %(message)s") + logger.setLevel(logging.INFO) # set log-level as you wish + logger.info("test message") # use at application level, normally + INFO: test message + + +To help with setting the base logger level via the CLI, we provide a +:py:mod:`click` :ref:`clapper.click.verbosity`. A full example can be seen at +:ref:`clapper.click.configcommand` and :ref:`clapper.click.rc_helpers`. + + +.. _clapper.logging.rationale: + +Logging Setup Rationale +----------------------- + +In essence, each library may be composed of a hierarchical tree of loggers +attached to a base root logger. The tree resembles the Python module system +where module hierarchies are defined by ``.`` (dots). It is common practice +that each module in a library or application logs *exclusively* to its own +private logger, as such: + +.. code-block:: python + + # in library.module1 + import logging + logger = logging.getLogger(__name__) # __name__ == library.module1 + + # now log normally through this module + logger.info(f"info test from module {__name__}") + + # in library.module2 + import logging + logger = logging.getLogger(__name__) # __name__ == library.module2 + + # now log normally through this module + logger.info(f"info test from module {__name__}") + + +By the way the logging module is set up by default, no messages should be seen +on your console by virtue of the above code. To actually be able to see +messages, one needs to associate the various loggers to an output (e.g. by +connecting these loggers to a console output handler). + +If you start Python, import your library, and inspect the logging system +hierarchy (e.g., use the `logging-tree module`_), the logging system should +have these instantiated loggers: + +.. code-block:: + + + RootLogger + + + Logger("library") + + + Logger("library.module1") + + + Logger("library.module2") + + +The logger for your ``library`` is instantiated because the loggers for the +submodules ``library.module1`` and ``library.module2`` were created (when you +called :py:func:`logging.getLogger`, and imported those modules). The loggers +are arranged in a hierarchy as you would expect, with a default ``RootLogger`` +in the very top. + +Messages generated at a lower-level logger (e.g. ``library.module2``) will be +handled by handlers attached to: + +* ``Logger("library.module2")`` +* ``Logger("library")`` +* ``RootLogger`` + +in this order. If any of these levels have a handler attached and properly +configured to output informational messages, then you will be able to see +printouts on your screen (or log file). + +Because of this structure and functioning, affecting the ``RootLogger`` is +seldomly advisable, since it may affect logging of **all libraries loaded by +your application**. For example, if your application imports ``scipy``, and +that library uses the logging module, changing the ``RootLogger`` may imply in +logging messages showing up at your console also for ``scipy``. This is rarely +useful, unless you want to debug those other modules. + +In this context, these are our recommendations: + +* If you are designing a library without applications, we recommend you **do + not setup any logging handlers** anywhere in your modules, and log as + explained above. If you do this, then users of your library will not have + unwanted logging messages from your library on their screens or output files. +* If you provide an application with your library, e.g. a CLI application, then + configure the package "base" logger (``Logger("library")`` in the example + above), so all messages from your package are visible upon user + configuration. + + +.. include:: links.rst diff --git a/doc/nitpick-exceptions.txt b/doc/nitpick-exceptions.txt new file mode 100644 index 0000000..05c35b4 --- /dev/null +++ b/doc/nitpick-exceptions.txt @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +py:class module diff --git a/doc/rc.rst b/doc/rc.rst new file mode 100644 index 0000000..548a131 --- /dev/null +++ b/doc/rc.rst @@ -0,0 +1,98 @@ +.. SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +.. +.. SPDX-License-Identifier: BSD-3-Clause + +.. _clapper.rc: + +============================== + Global Configuration Options +============================== + +In this section, we deal with configuration values that must typically be +provided by the user on a per-machine or per-system basis. Example values that +are local to a system or machine can be, for example, access credentials to a +database, or the root location of files used in a Machine Learning (ML) +pipeline. Typically, in these cases, developers want to allow users to +configure such values once and have a programmatic way to access such values at +run time. Module :py:mod:`clapper.rc` provides code to facilitate the +implementation and setup of this functionality. + + +Storage +------- + +Global configuration options are stored in TOML_ format, in a file whose +location is specified by you. The class :py:class:`clapper.rc.UserDefaults` can +load such a file and provide access to values set therein: + +.. code-block:: python + + >>> from clapper import rc + >>> defaults = rc.UserDefaults("myapprc.toml") + +.. note:: + + If the input filename given upon the construction of + :py:class:`clapper.rc.UserDefaults` is not absolute, it is considered + relative to the value of the environment variable ``$XDG_CONFIG_HOME``. In + UNIX-style operating systems, the above example would typically resolve to + ``${HOME}/.config/myapprc.toml``. Check the `XDG defaults `_ + for specifics. + + +Reading and writing values +-------------------------- + +You may use dictionary methods to get and set variables on any +:py:class:`clapper.rc.UserDefaults`, besides all other methods related to +mapping types (such as ``len()`` or ``setdefault()``). + + +Writing changes back +-------------------- + +To write changes back to the configuration file, use the +:py:meth:`clapper.rc.UserDefaults.write` method, which requires no parameters, +writing directly to the "default" location set during construction: + +.. code-block:: python + + >>> defaults.write() + + +.. warning:: + + This command will override the current configuration file and my erase any + user comments added by the user. To avoid this, simply edit your + configuration file by hand. + + +Adding a global RC functionality to your module +----------------------------------------------- + +To add a global object that reads user defaults into your application, we +recommend you create a module containing a configured instance of +:py:class:`clapper.rc.UserDefaults`. Then, within your command-line interface, +import that module to trigger reading out the necessary variables. For +example: + + +.. code-block:: python + + # module "config" + from clapper.rc import UserDefaults + rc = UserDefaults("~/.myapprc.toml", "MYAPPRC") + + # module "cli" + from .config import rc + value = rc["section"]["value-of-interest"] + + +Defining a command-line interface to the RC functionality +--------------------------------------------------------- + +We provide command plugins for you to define CLI-based get/set operations on +your configuration file. This is discussed at :ref:`clapper.click`. + + +.. include:: links.rst diff --git a/pixi.lock b/pixi.lock new file mode 100644 index 0000000..c4f109f --- /dev/null +++ b/pixi.lock @@ -0,0 +1,4167 @@ +version: 4 +environments: + build-ci: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.1.0-py312h30efb56_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hd590300_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.2.2-hbcca054_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2024.2.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-1.16.0-py312hf06ca03_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.3.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.1.7-unix_pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cmarkgfm-0.8.0-py312h98912ed_3.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-42.0.7-py312hbcc2302_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.13.6-h5008d03_3.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.3.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/editables-0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.2.0-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/expat-2.6.2-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.14.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.14.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.1.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/hatch-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.24.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.0.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.27.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperlink-21.0.0-pyhd3deb0d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-7.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-7.1.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-5.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jeepney-0.8.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.2.0-pyha804496_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h55db66e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.6.2-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-h77fa898_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.80.0-hf2295e7_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-h77fa898_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.17-hd590300_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.3-h2797004_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-13.2.0-hc0a3c3a_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-3.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.4.20240210-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nh3-0.2.17-py312h4b3b743_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.3.0-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-24.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.43-hcad00b1_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pkginfo-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd3deb0d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.18.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha2e5f31_6.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.3-hab00c5b_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python_abi-3.12-4_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/readme_renderer-42.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.31.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-1.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-13.7.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/secretstorage-3.3.3-py312h7900ff3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.12.4-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/trove-classifiers-2024.4.10-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/twine-5.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.11.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/userpath-1.7.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.1.39-h0ea3d13_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/versioningit-3.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.26.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/xdg-6.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.17.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.22.0-py312hd58854c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.5-hfc55251_0.conda + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.1.0-py312h9f69965_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2024.2.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-1.16.0-py312h8e38eb3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.3.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.1.7-unix_pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cmarkgfm-0.8.0-py312h02f2b3b_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.3.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/editables-0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.2.0-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.14.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.14.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.1.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/hatch-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.24.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.0.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.27.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperlink-21.0.0-pyhd3deb0d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-7.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-7.1.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-5.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.2.0-pyh534df25_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-17.0.6-h5f092b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.6.2-hebf3989_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.3-h091b4b1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.2.13-h53f4e23_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-3.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.4.20240210-h078ce10_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nh3-0.2.17-py312h5280bc4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.3.0-h0d3ecfb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-24.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pkginfo-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd3deb0d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.18.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha2e5f31_6.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.12.3-h4a7b5fc_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python_abi-3.12-4_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/readme_renderer-42.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.31.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-1.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-13.7.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.12.4-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/trove-classifiers-2024.4.10-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/twine-5.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.11.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/userpath-1.7.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.1.39-h4dd2748_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/versioningit-3.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.26.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/xdg-6.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.17.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.22.0-py312h7975427_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.5-h4f39d0f_0.conda + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/alabaster-0.7.16-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-23.2.0-pyh71513ae_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/auto-intersphinx-1.0.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.14.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.12.3-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/binaryornot-0.4.4-py_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/boolean.py-4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.1.0-py312h30efb56_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hd590300_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.2.2-hbcca054_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2024.2.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-1.16.0-py312hf06ca03_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.3.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/chardet-5.2.0-py312h7900ff3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.3.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.1.7-unix_pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cmarkgfm-0.8.0-py312h98912ed_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/coverage-7.5.1-py312h9a8786e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cryptography-42.0.7-py312hbcc2302_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.13.6-h5008d03_3.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.3.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/editables-0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.2.0-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/expat-2.6.2-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/fancycompleter-0.9.1-py312h7900ff3_1007.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.14.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/furo-2024.5.6-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.14.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.1.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/hatch-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.24.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.0.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.27.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperlink-21.0.0-pyhd3deb0d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-73.2-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.5.36-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/imagesize-1.4.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-7.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-7.1.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-5.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jeepney-0.8.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.2.0-pyha804496_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h55db66e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.6.2-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-h77fa898_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.80.0-hf2295e7_6.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-h77fa898_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.17-hd590300_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.3-h2797004_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-13.2.0-hc0a3c3a_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.12.6-h232c23b_2.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.39-h76b75d6_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/license-expression-30.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/lxml-5.2.1-py312hb90d8a5_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-3.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-2.1.5-py312h98912ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.4.20240210-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/nh3-0.2.17-py312h4b3b743_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.8.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.3.0-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-24.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.43-hcad00b1_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pdbpp-0.10.3-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pkginfo-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-3.7.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd3deb0d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.18.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyrepl-0.9.0-py312h98912ed_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha2e5f31_6.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-5.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.3-hab00c5b_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-debian-0.1.36-py_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/python_abi-3.12-4_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2024.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.1-py312h98912ed_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/readme_renderer-42.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.31.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-1.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/reuse-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-13.7.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.4.3-py312h5715c7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/secretstorage-3.3.3-py312h7900ff3_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-69.5.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.5-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-7.3.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-autodoc-typehints-2.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-basic-ng-1.0.0b2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-copybutton-0.5.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-inline-tabs-2023.4.21-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-1.0.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-1.0.6-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-programoutput-0.17-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-1.0.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.12.4-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/trove-classifiers-2024.4.10-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/twine-5.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.11.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.0.1-py312h8572e83_4.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/userpath-1.7.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/uv-0.1.39-h0ea3d13_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/versioningit-3.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.26.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wmctrl-0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/xdg-6.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h7f98852_2.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.17.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.22.0-py312hd58854c_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.5-hfc55251_0.conda + - pypi: . + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/alabaster-0.7.16-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/anyio-4.3.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/attrs-23.2.0-pyh71513ae_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/auto-intersphinx-1.0.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/babel-2.14.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.0.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.12.3-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/binaryornot-0.4.4-py_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/boolean.py-4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.1.0-py312h9f69965_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2024.2.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-1.16.0-py312h8e38eb3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.3.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/chardet-5.2.0-py312h81bd7bf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.3.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.1.7-unix_pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cmarkgfm-0.8.0-py312h02f2b3b_3.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/coverage-7.5.1-py312h7e5086c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.3.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/editables-0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.2.0-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/fancycompleter-0.9.1-py312h81bd7bf_1007.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.14.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/furo-2024.5.6-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/h11-0.14.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/h2-4.1.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/hatch-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.24.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hpack-4.0.0-pyh9f0ad1d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/httpx-0.27.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/hyperlink-21.0.0-pyhd3deb0d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-73.2-hc8870d7_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.5.36-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/imagesize-1.4.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-7.1.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-7.1.0-hd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-5.3.0-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/keyring-25.2.0-pyh534df25_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-17.0.6-h5f092b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.6.2-hebf3989_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.17-h0d3ecfb_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.3-h091b4b1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.12.6-h0d0cfa8_2.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libxslt-1.1.39-h223e5b9_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.2.13-h53f4e23_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/license-expression-30.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/lxml-5.2.1-py312h8f698c5_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-3.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-2.1.5-py312he37b823_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.4.20240210-h078ce10_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/nh3-0.2.17-py312h5280bc4_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.8.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.3.0-h0d3ecfb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-24.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pdbpp-0.10.3-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pkginfo-1.10.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-3.7.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd3deb0d_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pygments-2.18.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyrepl-0.9.0-py312he37b823_9.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha2e5f31_6.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-5.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.12.3-h4a7b5fc_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-debian-0.1.36-py_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python_abi-3.12-4_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytz-2024.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.1-py312h02f2b3b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/readme_renderer-42.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.31.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-1.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/reuse-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/rich-13.7.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.4.3-py312h3402d49_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-69.5.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.5-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-7.3.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-autodoc-typehints-2.1.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-basic-ng-1.0.0b2-pyhd8ed1ab_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-copybutton-0.5.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinx-inline-tabs-2023.4.21-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-1.0.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-1.0.6-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-programoutput-0.17-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-1.0.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.12.4-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/trove-classifiers-2024.4.10-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/twine-5.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.11.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.0.1-py312h389731b_4.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/userpath-1.7.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.1.39-h4dd2748_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/versioningit-3.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.26.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/wmctrl-0.5-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/xdg-6.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h3422bc3_2.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/zipp-3.17.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.22.0-py312h7975427_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.5-h4f39d0f_0.conda + - pypi: . + qa-ci: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/binaryornot-0.4.4-py_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/boolean.py-4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.1.0-py312h30efb56_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hd590300_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.2.2-hbcca054_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2024.2.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/cffi-1.16.0-py312hf06ca03_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.3.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/chardet-5.2.0-py312h7900ff3_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.3.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.1.7-unix_pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.3.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.14.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.5.36-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h55db66e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.6.2-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-h77fa898_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-h77fa898_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.3-h2797004_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-13.2.0-hc0a3c3a_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/license-expression-30.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-2.1.5-py312h98912ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.4.20240210-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.8.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.3.0-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-3.7.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha2e5f31_6.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.3-hab00c5b_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-debian-0.1.36-py_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/python_abi-3.12-4_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.1-py312h98912ed_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.31.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/reuse-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.4.3-py312h5715c7c_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-69.5.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.0.1-py312h8572e83_4.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.26.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/xdg-6.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h7f98852_2.tar.bz2 + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/noarch/binaryornot-0.4.4-py_1.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/boolean.py-4.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.1.0-py312h9f69965_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/certifi-2024.2.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-1.16.0-py312h8e38eb3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.3.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/chardet-5.2.0-py312h81bd7bf_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.3.2-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.1.7-unix_pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/distlib-0.3.8-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/filelock-3.14.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/identify-2.5.36-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/idna-3.7-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.3-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-17.0.6-h5f092b4_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.6.2-hebf3989_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.3-h091b4b1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.2.13-h53f4e23_5.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/license-expression-30.1.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-2.1.5-py312he37b823_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.4.20240210-h078ce10_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.8.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.3.0-h0d3ecfb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pre-commit-3.7.0-pyha770c72_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha2e5f31_6.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.12.3-h4a7b5fc_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/python-debian-0.1.36-py_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python_abi-3.12-4_cp312.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.1-py312h02f2b3b_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/requests-2.31.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/reuse-3.0.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.4.3-py312h3402d49_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-69.5.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.0.1-py312h389731b_4.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.2.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.26.1-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/xdg-6.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h3422bc3_2.tar.bz2 + test-ci-alternative: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hd590300_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.2.2-hbcca054_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.1.7-unix_pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/coverage-7.5.1-py311h331c9d8_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.2.0-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h55db66e_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.6.2-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-h77fa898_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-h77fa898_7.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.3-h2797004_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.4.20240210-h59595ed_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.3.0-hd590300_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-24.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-5.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.9-hb806964_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/python_abi-3.11-4_cp311.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/xdg-6.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 + - pypi: . + osx-arm64: + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/click-8.1.7-unix_pyh707e725_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/coverage-7.5.1-py311hd3f4193_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.2.0-pyhd8ed1ab_2.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.6.2-hebf3989_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.3-h091b4b1_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.2.13-h53f4e23_5.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.4.20240210-h078ce10_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.3.0-h0d3ecfb_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-24.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.5.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-8.2.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-5.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.9-h932a869_0_cpython.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/python_abi-3.11-4_cp311.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2 + - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + - conda: https://conda.anaconda.org/conda-forge/noarch/xdg-6.0.0-pyhd8ed1ab_0.conda + - conda: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 + - pypi: . +packages: +- kind: conda + name: _libgcc_mutex + version: '0.1' + build: conda_forge + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2 + sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726 + md5: d7c89558ba9fa0495403155b64376d81 + license: None + size: 2562 + timestamp: 1578324546067 +- kind: conda + name: _openmp_mutex + version: '4.5' + build: 2_gnu + build_number: 16 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2 + sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22 + md5: 73aaf86a425cc6e73fcf236a5a46396d + depends: + - _libgcc_mutex 0.1 conda_forge + - libgomp >=7.5.0 + constrains: + - openmp_impl 9999 + license: BSD-3-Clause + license_family: BSD + size: 23621 + timestamp: 1650670423406 +- kind: conda + name: alabaster + version: 0.7.16 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/alabaster-0.7.16-pyhd8ed1ab_0.conda + sha256: fd39ad2fabec1569bbb0dfdae34ab6ce7de6ec09dcec8638f83dad0373594069 + md5: def531a3ac77b7fb8c21d17bb5d0badb + depends: + - python >=3.9 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/alabaster + size: 18365 + timestamp: 1704848898483 +- kind: conda + name: anyio + version: 4.3.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/anyio-4.3.0-pyhd8ed1ab_0.conda + sha256: 86aca4a31c09f9b4dbdb332cd9a6a7dbab62ca734d3f832651c0ab59c6a7f52e + md5: ac95aa8ed65adfdde51132595c79aade + depends: + - exceptiongroup >=1.0.2 + - idna >=2.8 + - python >=3.8 + - sniffio >=1.1 + - typing_extensions >=4.1 + constrains: + - trio >=0.23 + - uvloop >=0.17 + license: MIT + license_family: MIT + purls: + - pkg:pypi/anyio + size: 102331 + timestamp: 1708355504396 +- kind: conda + name: attrs + version: 23.2.0 + build: pyh71513ae_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/attrs-23.2.0-pyh71513ae_0.conda + sha256: 77c7d03bdb243a048fff398cedc74327b7dc79169ebe3b4c8448b0331ea55fea + md5: 5e4c0743c70186509d1412e03c2d8dfa + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/attrs + size: 54582 + timestamp: 1704011393776 +- kind: conda + name: auto-intersphinx + version: 1.0.3 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/auto-intersphinx-1.0.3-pyhd8ed1ab_0.conda + sha256: e4267429a056d1ffe73e482b4b61fbec6426108033ea080b76f4105b8c8fba90 + md5: 82ae03558e448895c9853852e6a8c996 + depends: + - lxml + - packaging + - python >=3.9 + - requests + - sphinx + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/auto-intersphinx + size: 28581 + timestamp: 1687786070078 +- kind: conda + name: babel + version: 2.14.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/babel-2.14.0-pyhd8ed1ab_0.conda + sha256: 8584e3da58e92b72641c89ff9b98c51f0d5dbe76e527867804cbdf03ac91d8e6 + md5: 9669586875baeced8fc30c0826c3270e + depends: + - python >=3.7 + - pytz + - setuptools + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/babel + size: 7609750 + timestamp: 1702422720584 +- kind: conda + name: backports + version: '1.0' + build: pyhd8ed1ab_3 + build_number: 3 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/backports-1.0-pyhd8ed1ab_3.conda + sha256: 711602276ae39276cb0faaca6fd0ac851fff0ca17151917569174841ef830bbd + md5: 54ca2e08b3220c148a1d8329c2678e02 + depends: + - python >=2.7 + license: BSD-3-Clause + license_family: BSD + size: 5950 + timestamp: 1669158729416 +- kind: conda + name: backports.tarfile + version: 1.0.0 + build: pyhd8ed1ab_1 + build_number: 1 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/backports.tarfile-1.0.0-pyhd8ed1ab_1.conda + sha256: 7ba30f32daad2e7ca251508525185ba170eedc14123572611c2acf261c7956b3 + md5: c747b1d79f136013c3b7ebcba876afa6 + depends: + - backports + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/backports-tarfile + size: 31951 + timestamp: 1712700751335 +- kind: conda + name: beautifulsoup4 + version: 4.12.3 + build: pyha770c72_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/beautifulsoup4-4.12.3-pyha770c72_0.conda + sha256: 7b05b2d0669029326c623b9df7a29fa49d1982a9e7e31b2fea34b4c9a4a72317 + md5: 332493000404d8411859539a5a630865 + depends: + - python >=3.6 + - soupsieve >=1.2 + license: MIT + license_family: MIT + purls: + - pkg:pypi/beautifulsoup4 + size: 118200 + timestamp: 1705564819537 +- kind: conda + name: binaryornot + version: 0.4.4 + build: py_1 + build_number: 1 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/binaryornot-0.4.4-py_1.tar.bz2 + sha256: 8f65c16a9f85285e1f704a26d4c5ced25f46544f5cc20dc8a4aebd7796f8011a + md5: a556fa60840fcb9dd739d186bfd252f7 + depends: + - chardet + - python + license: BSD-3-Clause + license_family: BSD + size: 378445 + timestamp: 1531097907306 +- kind: conda + name: boolean.py + version: '4.0' + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/boolean.py-4.0-pyhd8ed1ab_0.conda + sha256: 7b3ee20479c6a169137ed6129e1a83941a51c25c71e5c2470787805595fc664b + md5: 46250fe31e1cdc42a316bbd2ec870e24 + depends: + - python >=3.6 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/boolean-py + size: 28706 + timestamp: 1690384476510 +- kind: conda + name: brotli-python + version: 1.1.0 + build: py312h30efb56_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/brotli-python-1.1.0-py312h30efb56_1.conda + sha256: b68706698b6ac0d31196a8bcb061f0d1f35264bcd967ea45e03e108149a74c6f + md5: 45801a89533d3336a365284d93298e36 + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + - python >=3.12.0rc3,<3.13.0a0 + - python_abi 3.12.* *_cp312 + constrains: + - libbrotlicommon 1.1.0 hd590300_1 + license: MIT + license_family: MIT + purls: + - pkg:pypi/brotli + size: 350604 + timestamp: 1695990206327 +- kind: conda + name: brotli-python + version: 1.1.0 + build: py312h9f69965_1 + build_number: 1 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.1.0-py312h9f69965_1.conda + sha256: 3418b1738243abba99e931c017b952771eeaa1f353c07f7d45b55e83bb74fcb3 + md5: 1bc01b9ffdf42beb1a9fe4e9222e0567 + depends: + - libcxx >=15.0.7 + - python >=3.12.0rc3,<3.13.0a0 + - python >=3.12.0rc3,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + constrains: + - libbrotlicommon 1.1.0 hb547adb_1 + license: MIT + license_family: MIT + purls: + - pkg:pypi/brotli + size: 343435 + timestamp: 1695990731924 +- kind: conda + name: bzip2 + version: 1.0.8 + build: h93a5062_5 + build_number: 5 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h93a5062_5.conda + sha256: bfa84296a638bea78a8bb29abc493ee95f2a0218775642474a840411b950fe5f + md5: 1bbc659ca658bfd49a481b5ef7a0f40f + license: bzip2-1.0.6 + license_family: BSD + size: 122325 + timestamp: 1699280294368 +- kind: conda + name: bzip2 + version: 1.0.8 + build: hd590300_5 + build_number: 5 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hd590300_5.conda + sha256: 242c0c324507ee172c0e0dd2045814e746bb303d1eb78870d182ceb0abc726a8 + md5: 69b8b6202a07720f448be700e300ccf4 + depends: + - libgcc-ng >=12 + license: bzip2-1.0.6 + license_family: BSD + size: 254228 + timestamp: 1699279927352 +- kind: conda + name: ca-certificates + version: 2024.2.2 + build: hbcca054_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.2.2-hbcca054_0.conda + sha256: 91d81bfecdbb142c15066df70cc952590ae8991670198f92c66b62019b251aeb + md5: 2f4327a1cbe7f022401b236e915a5fef + license: ISC + size: 155432 + timestamp: 1706843687645 +- kind: conda + name: ca-certificates + version: 2024.2.2 + build: hf0a4a13_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/ca-certificates-2024.2.2-hf0a4a13_0.conda + sha256: 49bc3439816ac72d0c0e0f144b8cc870fdcc4adec2e861407ec818d8116b2204 + md5: fb416a1795f18dcc5a038bc2dc54edf9 + license: ISC + size: 155725 + timestamp: 1706844034242 +- kind: conda + name: certifi + version: 2024.2.2 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/certifi-2024.2.2-pyhd8ed1ab_0.conda + sha256: f1faca020f988696e6b6ee47c82524c7806380b37cfdd1def32f92c326caca54 + md5: 0876280e409658fc6f9e75d035960333 + depends: + - python >=3.7 + license: ISC + purls: + - pkg:pypi/certifi + size: 160559 + timestamp: 1707022289175 +- kind: conda + name: cffi + version: 1.16.0 + build: py312h8e38eb3_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/cffi-1.16.0-py312h8e38eb3_0.conda + sha256: 1544403cb1a5ca2aeabf0dac86d9ce6066d6fb4363493643b33ffd1b78038d18 + md5: 960ecbd65860d3b1de5e30373e1bffb1 + depends: + - libffi >=3.4,<4.0a0 + - pycparser + - python >=3.12.0rc3,<3.13.0a0 + - python >=3.12.0rc3,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/cffi + size: 284245 + timestamp: 1696002181644 +- kind: conda + name: cffi + version: 1.16.0 + build: py312hf06ca03_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/cffi-1.16.0-py312hf06ca03_0.conda + sha256: 5a36e2c254603c367d26378fa3a205bd92263e30acf195f488749562b4c44251 + md5: 56b0ca764ce23cc54f3f7e2a7b970f6d + depends: + - libffi >=3.4,<4.0a0 + - libgcc-ng >=12 + - pycparser + - python >=3.12.0rc3,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/cffi + size: 294523 + timestamp: 1696001868949 +- kind: conda + name: cfgv + version: 3.3.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/cfgv-3.3.1-pyhd8ed1ab_0.tar.bz2 + sha256: fbc03537a27ef756162c49b1d0608bf7ab12fa5e38ceb8563d6f4859e835ac5c + md5: ebb5f5f7dc4f1a3780ef7ea7738db08c + depends: + - python >=3.6.1 + license: MIT + license_family: MIT + purls: + - pkg:pypi/cfgv + size: 10788 + timestamp: 1629909423398 +- kind: conda + name: chardet + version: 5.2.0 + build: py312h7900ff3_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/chardet-5.2.0-py312h7900ff3_1.conda + sha256: 584804790b465c8e28b3c3fcea8e774cb659fe479afd8adc0d39406e8c220194 + md5: af3980cc4690716a5510c8a08cb06238 + depends: + - python >=3.12.0rc3,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: LGPL-2.1-only + license_family: GPL + purls: + - pkg:pypi/chardet + size: 260197 + timestamp: 1695468803539 +- kind: conda + name: chardet + version: 5.2.0 + build: py312h81bd7bf_1 + build_number: 1 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/chardet-5.2.0-py312h81bd7bf_1.conda + sha256: 2451501ec933017ff77c18436884baa90b289420c43ad4bdb4fe60d55e5dcdfd + md5: ea728c39b7453cb5f7177bc44ee22c73 + depends: + - python >=3.12.0rc3,<3.13.0a0 + - python >=3.12.0rc3,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + license: LGPL-2.1-only + license_family: GPL + purls: + - pkg:pypi/chardet + size: 263585 + timestamp: 1695469015195 +- kind: conda + name: charset-normalizer + version: 3.3.2 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/charset-normalizer-3.3.2-pyhd8ed1ab_0.conda + sha256: 20cae47d31fdd58d99c4d2e65fbdcefa0b0de0c84e455ba9d6356a4bdbc4b5b9 + md5: 7f4a9e3fcff3f6356ae99244a014da6a + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/charset-normalizer + size: 46597 + timestamp: 1698833765762 +- kind: pypi + name: clapper + version: 1.0.2.dev25+gee4b1c9.d20240507 + path: . + sha256: ac46327331340c51eac87abc6979e632e0ee7a4543b30351185e2fab64818629 + requires_dist: + - click>=8 + - tomli + - tomli-w + - xdg + - auto-intersphinx ; extra == 'doc' + - furo ; extra == 'doc' + - sphinx ; extra == 'doc' + - sphinx-autodoc-typehints ; extra == 'doc' + - sphinx-copybutton ; extra == 'doc' + - sphinx-inline-tabs ; extra == 'doc' + - sphinxcontrib-programoutput ; extra == 'doc' + - pre-commit ; extra == 'qa' + - pytest ; extra == 'test' + - pytest-cov ; extra == 'test' + requires_python: '>=3.10' + editable: true +- kind: conda + name: click + version: 8.1.7 + build: unix_pyh707e725_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/click-8.1.7-unix_pyh707e725_0.conda + sha256: f0016cbab6ac4138a429e28dbcb904a90305b34b3fe41a9b89d697c90401caec + md5: f3ad426304898027fc619827ff428eca + depends: + - __unix + - python >=3.8 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/click + size: 84437 + timestamp: 1692311973840 +- kind: conda + name: cmarkgfm + version: 0.8.0 + build: py312h02f2b3b_3 + build_number: 3 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/cmarkgfm-0.8.0-py312h02f2b3b_3.conda + sha256: 2e95c3797cd2796f32de8408626d63cb1283f2b7b0826021d2e26cc58d9231a0 + md5: ffedee35be7a5015d09e2660a66b89c9 + depends: + - cffi >=1.0.0 + - python >=3.12.0rc3,<3.13.0a0 + - python >=3.12.0rc3,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/cmarkgfm + size: 113474 + timestamp: 1695670347968 +- kind: conda + name: cmarkgfm + version: 0.8.0 + build: py312h98912ed_3 + build_number: 3 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/cmarkgfm-0.8.0-py312h98912ed_3.conda + sha256: 1a9e60b18664c22f872435a1d2b1d727e37ea4159736b116afff364b9577dc02 + md5: 0c9c09134b2fb151c2bd8181b2c56080 + depends: + - cffi >=1.0.0 + - libgcc-ng >=12 + - python >=3.12.0rc3,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/cmarkgfm + size: 135963 + timestamp: 1695669875921 +- kind: conda + name: colorama + version: 0.4.6 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/colorama-0.4.6-pyhd8ed1ab_0.tar.bz2 + sha256: 2c1b2e9755ce3102bca8d69e8f26e4f087ece73f50418186aee7c74bef8e1698 + md5: 3faab06a954c2a04039983f2c4a50d99 + depends: + - python >=3.7 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/colorama + size: 25170 + timestamp: 1666700778190 +- kind: conda + name: coverage + version: 7.5.1 + build: py311h331c9d8_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/coverage-7.5.1-py311h331c9d8_0.conda + sha256: 2ecb21dc0efec42419c50f63daf1db0d6910f47db1b2653ebc5c43f76302024e + md5: 9f35e13e3b9e05e153b78f42662061f6 + depends: + - libgcc-ng >=12 + - python >=3.11,<3.12.0a0 + - python_abi 3.11.* *_cp311 + - tomli + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/coverage + size: 369007 + timestamp: 1714846741185 +- kind: conda + name: coverage + version: 7.5.1 + build: py311hd3f4193_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/coverage-7.5.1-py311hd3f4193_0.conda + sha256: 6eaa811402fc3433bd891179410a434d0826da1f44579eccccc9dbb632769403 + md5: 81834421a20531c880f6c0a5342f3922 + depends: + - __osx >=11.0 + - python >=3.11,<3.12.0a0 + - python >=3.11,<3.12.0a0 *_cpython + - python_abi 3.11.* *_cp311 + - tomli + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/coverage + size: 368146 + timestamp: 1714846963260 +- kind: conda + name: coverage + version: 7.5.1 + build: py312h7e5086c_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/coverage-7.5.1-py312h7e5086c_0.conda + sha256: dc3d6d36edd2587da94cd0045ccf3460cf84ce77a40f62db4a75d3653e96c8d6 + md5: 08067b92914143861a65b650dd0af4d0 + depends: + - __osx >=11.0 + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + - tomli + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/coverage + size: 359716 + timestamp: 1714846946149 +- kind: conda + name: coverage + version: 7.5.1 + build: py312h9a8786e_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/coverage-7.5.1-py312h9a8786e_0.conda + sha256: 272e507f0ea567ec4c9cf2621c27d34eec5aaa70ebea5d03d508b33b4497de17 + md5: 2d24a25dab0d00182eeed1ba9b64a12d + depends: + - libgcc-ng >=12 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - tomli + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/coverage + size: 360545 + timestamp: 1714846745949 +- kind: conda + name: cryptography + version: 42.0.7 + build: py312hbcc2302_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/cryptography-42.0.7-py312hbcc2302_0.conda + sha256: 91fa2d4229096ecffa36e71a33f2163d1138dc1ef98a0be20ba0e5905e420a85 + md5: 7bc0e1aae21b2e82d03959931f4294f0 + depends: + - cffi >=1.12 + - libgcc-ng >=12 + - openssl >=3.3.0,<4.0a0 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: Apache-2.0 AND BSD-3-Clause AND PSF-2.0 AND MIT + license_family: BSD + purls: + - pkg:pypi/cryptography + size: 1978679 + timestamp: 1715044173081 +- kind: conda + name: dbus + version: 1.13.6 + build: h5008d03_3 + build_number: 3 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/dbus-1.13.6-h5008d03_3.tar.bz2 + sha256: 8f5f995699a2d9dbdd62c61385bfeeb57c82a681a7c8c5313c395aa0ccab68a5 + md5: ecfff944ba3960ecb334b9a2663d708d + depends: + - expat >=2.4.2,<3.0a0 + - libgcc-ng >=9.4.0 + - libglib >=2.70.2,<3.0a0 + license: GPL-2.0-or-later + license_family: GPL + size: 618596 + timestamp: 1640112124844 +- kind: conda + name: distlib + version: 0.3.8 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/distlib-0.3.8-pyhd8ed1ab_0.conda + sha256: 3ff11acdd5cc2f80227682966916e878e45ced94f59c402efb94911a5774e84e + md5: db16c66b759a64dc5183d69cc3745a52 + depends: + - python 2.7|>=3.6 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/distlib + size: 274915 + timestamp: 1702383349284 +- kind: conda + name: docutils + version: 0.21.2 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/docutils-0.21.2-pyhd8ed1ab_0.conda + sha256: 362bfe3afaac18298c48c0c6a935641544077ce5105a42a2d8ebe750ad07c574 + md5: e8cd5d629f65bdf0f3bb312cde14659e + depends: + - python >=3.9 + license: CC-PDDC AND BSD-3-Clause AND BSD-2-Clause AND ZPL-2.1 + purls: + - pkg:pypi/docutils + size: 403226 + timestamp: 1713930478970 +- kind: conda + name: editables + version: '0.5' + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/editables-0.5-pyhd8ed1ab_0.conda + sha256: de160a7494e7bc72360eea6a29cbddf194d0a79f45ff417a4de20e6858cf79a9 + md5: 9873878e2a069bc358b69e9a29c1ecd5 + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/editables + size: 10988 + timestamp: 1705857085102 +- kind: conda + name: exceptiongroup + version: 1.2.0 + build: pyhd8ed1ab_2 + build_number: 2 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/exceptiongroup-1.2.0-pyhd8ed1ab_2.conda + sha256: a6ae416383bda0e3ed14eaa187c653e22bec94ff2aa3b56970cdf0032761e80d + md5: 8d652ea2ee8eaee02ed8dc820bc794aa + depends: + - python >=3.7 + license: MIT and PSF-2.0 + purls: + - pkg:pypi/exceptiongroup + size: 20551 + timestamp: 1704921321122 +- kind: conda + name: expat + version: 2.6.2 + build: h59595ed_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/expat-2.6.2-h59595ed_0.conda + sha256: 89916c536ae5b85bb8bf0cfa27d751e274ea0911f04e4a928744735c14ef5155 + md5: 53fb86322bdb89496d7579fe3f02fd61 + depends: + - libexpat 2.6.2 h59595ed_0 + - libgcc-ng >=12 + license: MIT + license_family: MIT + size: 137627 + timestamp: 1710362144873 +- kind: conda + name: fancycompleter + version: 0.9.1 + build: py312h7900ff3_1007 + build_number: 1007 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/fancycompleter-0.9.1-py312h7900ff3_1007.conda + sha256: 12f78c53b9dac0ecfb1650a339bf8b950ba8f127a108eb09c3adda44a79ef31c + md5: 9dfab523f1136690d861fe337034dbee + depends: + - pyrepl >=0.8.2 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/fancycompleter + size: 26174 + timestamp: 1709160998274 +- kind: conda + name: fancycompleter + version: 0.9.1 + build: py312h81bd7bf_1007 + build_number: 1007 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/fancycompleter-0.9.1-py312h81bd7bf_1007.conda + sha256: 895d2bdd1e56d28be8be0a46adbce92fcdf08e1b0dca073b99f2f4b5211603f9 + md5: 3b30d90c8ca61010a6f85eb9ce2a049f + depends: + - pyrepl >=0.8.2 + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/fancycompleter + size: 26498 + timestamp: 1709161451678 +- kind: conda + name: filelock + version: 3.14.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/filelock-3.14.0-pyhd8ed1ab_0.conda + sha256: 6031be667e1b0cc0dee713f1cbca887cdee4daafa8bac478da33096f3147d38b + md5: 831d85ae0acfba31b8efd0f0d07da736 + depends: + - python >=3.7 + license: Unlicense + purls: + - pkg:pypi/filelock + size: 15902 + timestamp: 1714422911808 +- kind: conda + name: furo + version: 2024.5.6 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/furo-2024.5.6-pyhd8ed1ab_0.conda + sha256: 1c99f4b62b84b66b78a74d5781bf92c3ab1795c4d18476c4f7580dee0c8f3a07 + md5: c5d6d467e2d8a74cdd2a888d8e348950 + depends: + - beautifulsoup4 + - pygments >=2.7 + - python >=3.7 + - sphinx >=6.0,<8.0 + - sphinx-basic-ng + license: MIT + purls: + - pkg:pypi/furo + size: 83103 + timestamp: 1715029589799 +- kind: conda + name: h11 + version: 0.14.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/h11-0.14.0-pyhd8ed1ab_0.tar.bz2 + sha256: 817d2c77d53afe3f3d9cf7f6eb8745cdd8ea76c7adaa9d7ced75c455a2c2c085 + md5: b21ed0883505ba1910994f1df031a428 + depends: + - python >=3 + - typing_extensions + license: MIT + license_family: MIT + purls: + - pkg:pypi/h11 + size: 48251 + timestamp: 1664132995560 +- kind: conda + name: h2 + version: 4.1.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/h2-4.1.0-pyhd8ed1ab_0.tar.bz2 + sha256: bfc6a23849953647f4e255c782e74a0e18fe16f7e25c7bb0bc57b83bb6762c7a + md5: b748fbf7060927a6e82df7cb5ee8f097 + depends: + - hpack >=4.0,<5 + - hyperframe >=6.0,<7 + - python >=3.6.1 + license: MIT + license_family: MIT + purls: + - pkg:pypi/h2 + size: 46754 + timestamp: 1634280590080 +- kind: conda + name: hatch + version: 1.10.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/hatch-1.10.0-pyhd8ed1ab_0.conda + sha256: c640173693bb4279977cb6f0ec5589080f8884c0b884425370a67333abfca96d + md5: b607d2f0443d35dc50251c50ea96fb10 + depends: + - click >=8.0.6 + - hatchling >=1.24.2 + - httpx >=0.22.0 + - hyperlink >=21.0.0 + - keyring >=23.5.0 + - packaging >=23.2 + - pexpect >=4.8,<5 + - platformdirs >=2.5.0 + - python >=3.8 + - rich >=11.2.0 + - shellingham >=1.4.0 + - tomli-w >=1.0 + - tomlkit >=0.11.1 + - userpath >=1.7,<2 + - uv >=0.1.35 + - virtualenv >=20.26.1 + - zstandard <1.0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/hatch + size: 174266 + timestamp: 1714687924505 +- kind: conda + name: hatchling + version: 1.24.2 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/hatchling-1.24.2-pyhd8ed1ab_0.conda + sha256: 1161601871d8aa6c5ff7719a277462cdf0160351a88f2a84a22d6ead3b90150f + md5: 28cef29029f6da70e7a987a76a3599a4 + depends: + - editables >=0.3 + - importlib-metadata + - packaging >=21.3 + - pathspec >=0.10.1 + - pluggy >=1.0.0 + - python >=3.7 + - tomli >=1.2.2 + - trove-classifiers + license: MIT + license_family: MIT + purls: + - pkg:pypi/hatchling + size: 63793 + timestamp: 1713757830609 +- kind: conda + name: hpack + version: 4.0.0 + build: pyh9f0ad1d_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/hpack-4.0.0-pyh9f0ad1d_0.tar.bz2 + sha256: 5dec948932c4f740674b1afb551223ada0c55103f4c7bf86a110454da3d27cb8 + md5: 914d6646c4dbb1fd3ff539830a12fd71 + depends: + - python + license: MIT + license_family: MIT + purls: + - pkg:pypi/hpack + size: 25341 + timestamp: 1598856368685 +- kind: conda + name: httpcore + version: 1.0.5 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/httpcore-1.0.5-pyhd8ed1ab_0.conda + sha256: 4025644200eefa0598e4600a66fd4804a57d9fd7054a5c8c45e508fd875e0b84 + md5: a6b9a0158301e697e4d0a36a3d60e133 + depends: + - anyio >=3.0,<5.0 + - certifi + - h11 >=0.13,<0.15 + - h2 >=3,<5 + - python >=3.8 + - sniffio 1.* + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/httpcore + size: 45816 + timestamp: 1711597091407 +- kind: conda + name: httpx + version: 0.27.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/httpx-0.27.0-pyhd8ed1ab_0.conda + sha256: fdaf341fb2630b7afe8238315448fc93947f77ebfa4da68bb349e1bcf820af58 + md5: 9f359af5a886fd6ca6b2b6ea02e58332 + depends: + - anyio + - certifi + - httpcore 1.* + - idna + - python >=3.8 + - sniffio + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/httpx + size: 64651 + timestamp: 1708531043505 +- kind: conda + name: hyperframe + version: 6.0.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/hyperframe-6.0.1-pyhd8ed1ab_0.tar.bz2 + sha256: e374a9d0f53149328134a8d86f5d72bca4c6dcebed3c0ecfa968c02996289330 + md5: 9f765cbfab6870c8435b9eefecd7a1f4 + depends: + - python >=3.6 + license: MIT + license_family: MIT + purls: + - pkg:pypi/hyperframe + size: 14646 + timestamp: 1619110249723 +- kind: conda + name: hyperlink + version: 21.0.0 + build: pyhd3deb0d_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/hyperlink-21.0.0-pyhd3deb0d_0.tar.bz2 + sha256: 026cb82ada41be9ee2836a2ace526e85c4603e77617887c41c6e62c9bde798b3 + md5: 1303beb57b40f8f4ff6fb1bb23bf0553 + depends: + - idna >=2.6 + - python + license: MIT + license_family: MIT + purls: + - pkg:pypi/hyperlink + size: 72732 + timestamp: 1610092261086 +- kind: conda + name: icu + version: '73.2' + build: h59595ed_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/icu-73.2-h59595ed_0.conda + sha256: e12fd90ef6601da2875ebc432452590bc82a893041473bc1c13ef29001a73ea8 + md5: cc47e1facc155f91abd89b11e48e72ff + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: MIT + license_family: MIT + size: 12089150 + timestamp: 1692900650789 +- kind: conda + name: icu + version: '73.2' + build: hc8870d7_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/icu-73.2-hc8870d7_0.conda + sha256: ff9cd0c6cd1349954c801fb443c94192b637e1b414514539f3c49c56a39f51b1 + md5: 8521bd47c0e11c5902535bb1a17c565f + license: MIT + license_family: MIT + size: 11997841 + timestamp: 1692902104771 +- kind: conda + name: identify + version: 2.5.36 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/identify-2.5.36-pyhd8ed1ab_0.conda + sha256: dc98ab2233d3ed3692499e2a06b027489ee317658cef9277ec23cab00236f31c + md5: ba68cb5105760379432cebc82b45af40 + depends: + - python >=3.6 + - ukkonen + license: MIT + license_family: MIT + purls: + - pkg:pypi/identify + size: 78375 + timestamp: 1713673091737 +- kind: conda + name: idna + version: '3.7' + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/idna-3.7-pyhd8ed1ab_0.conda + sha256: 9687ee909ed46169395d4f99a0ee94b80a52f87bed69cd454bb6d37ffeb0ec7b + md5: c0cc1420498b17414d8617d0b9f506ca + depends: + - python >=3.6 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/idna + size: 52718 + timestamp: 1713279497047 +- kind: conda + name: imagesize + version: 1.4.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/imagesize-1.4.1-pyhd8ed1ab_0.tar.bz2 + sha256: c2bfd7043e0c4c12d8b5593de666c1e81d67b83c474a0a79282cc5c4ef845460 + md5: 7de5386c8fea29e76b303f37dde4c352 + depends: + - python >=3.4 + license: MIT + license_family: MIT + purls: + - pkg:pypi/imagesize + size: 10164 + timestamp: 1656939625410 +- kind: conda + name: importlib-metadata + version: 7.1.0 + build: pyha770c72_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/importlib-metadata-7.1.0-pyha770c72_0.conda + sha256: cc2e7d1f7f01cede30feafc1118b7aefa244d0a12224513734e24165ae12ba49 + md5: 0896606848b2dc5cebdf111b6543aa04 + depends: + - python >=3.8 + - zipp >=0.5 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/importlib-metadata + size: 27043 + timestamp: 1710971498183 +- kind: conda + name: importlib_metadata + version: 7.1.0 + build: hd8ed1ab_0 + subdir: noarch + noarch: generic + url: https://conda.anaconda.org/conda-forge/noarch/importlib_metadata-7.1.0-hd8ed1ab_0.conda + sha256: 01dc057a45dedcc742a71599f67c7383ae2bf873be6018ebcbd06ac8d994dedb + md5: 6ef2b72d291b39e479d7694efa2b2b98 + depends: + - importlib-metadata >=7.1.0,<7.1.1.0a0 + license: Apache-2.0 + license_family: APACHE + size: 9444 + timestamp: 1710971502542 +- kind: conda + name: importlib_resources + version: 6.4.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/importlib_resources-6.4.0-pyhd8ed1ab_0.conda + sha256: c6ae80c0beaeabb342c5b041f19669992ae6e937dbec56ced766cb035900f9de + md5: c5d3907ad8bd7bf557521a1833cf7e6d + depends: + - python >=3.8 + - zipp >=3.1.0 + constrains: + - importlib-resources >=6.4.0,<6.4.1.0a0 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/importlib-resources + size: 33056 + timestamp: 1711041009039 +- kind: conda + name: iniconfig + version: 2.0.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/iniconfig-2.0.0-pyhd8ed1ab_0.conda + sha256: 38740c939b668b36a50ef455b077e8015b8c9cf89860d421b3fff86048f49666 + md5: f800d2da156d08e289b14e87e43c1ae5 + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/iniconfig + size: 11101 + timestamp: 1673103208955 +- kind: conda + name: jaraco.classes + version: 3.4.0 + build: pyhd8ed1ab_1 + build_number: 1 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/jaraco.classes-3.4.0-pyhd8ed1ab_1.conda + sha256: 538b1c6df537a36c63fd0ed83cb1c1c25b07d8d3b5e401991fdaff261a4b5b4d + md5: 7b756504d362cbad9b73a50a5455cafd + depends: + - more-itertools + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/jaraco-classes + size: 12223 + timestamp: 1713939433204 +- kind: conda + name: jaraco.context + version: 5.3.0 + build: pyhd8ed1ab_1 + build_number: 1 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/jaraco.context-5.3.0-pyhd8ed1ab_1.conda + sha256: 9e2aeacb1aed3ab4fc5883a357e8a874e12f687af300f8708ec12de2995e17d2 + md5: 72d7ad2dcd0f37eccb2ee35a1c8f6aaa + depends: + - backports.tarfile + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/jaraco-context + size: 12456 + timestamp: 1714372284922 +- kind: conda + name: jaraco.functools + version: 4.0.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/jaraco.functools-4.0.0-pyhd8ed1ab_0.conda + sha256: d2e866fd22a48eaa2f795b6a3b0bf16f066293322ce04dd65cca36267160ead6 + md5: 547670a612fd335eaa5ffbf0fa75cb64 + depends: + - more-itertools + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/jaraco-functools + size: 15192 + timestamp: 1701695329516 +- kind: conda + name: jeepney + version: 0.8.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/jeepney-0.8.0-pyhd8ed1ab_0.tar.bz2 + sha256: 16639759b811866d63315fe1391f6fb45f5478b823972f4d3d9f0392b7dd80b8 + md5: 9800ad1699b42612478755a2d26c722d + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/jeepney + size: 36895 + timestamp: 1649085298891 +- kind: conda + name: jinja2 + version: 3.1.3 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/jinja2-3.1.3-pyhd8ed1ab_0.conda + sha256: fd517b7dd3a61eca34f8a6f9f92f306397149cae1204fce72ac3d227107dafdc + md5: e7d8df6509ba635247ff9aea31134262 + depends: + - markupsafe >=2.0 + - python >=3.7 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/jinja2 + size: 111589 + timestamp: 1704967140287 +- kind: conda + name: keyring + version: 25.2.0 + build: pyh534df25_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/keyring-25.2.0-pyh534df25_0.conda + sha256: 29ffedc5e90f850a66007174f3785eb6a322a93cc6df9e8c9a7646f7761c694a + md5: acaf59f096327bc5757c91303cae99ca + depends: + - __osx + - importlib_metadata >=4.11.4 + - importlib_resources + - jaraco.classes + - jaraco.context + - jaraco.functools + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/keyring + size: 36710 + timestamp: 1714167932993 +- kind: conda + name: keyring + version: 25.2.0 + build: pyha804496_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/keyring-25.2.0-pyha804496_0.conda + sha256: 3a6dc8525071aa1016b81d24ee3845a2c26280b863392d7551b40a6c8d0f60c0 + md5: 7a14341f0ed09e83e28b28140f058ae0 + depends: + - __linux + - importlib_metadata >=4.11.4 + - importlib_resources + - jaraco.classes + - jaraco.context + - jaraco.functools + - jeepney >=0.4.2 + - python >=3.8 + - secretstorage >=3.2 + license: MIT + license_family: MIT + purls: + - pkg:pypi/keyring + size: 36608 + timestamp: 1714167807674 +- kind: conda + name: ld_impl_linux-64 + version: '2.40' + build: h55db66e_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.40-h55db66e_0.conda + sha256: ef969eee228cfb71e55146eaecc6af065f468cb0bc0a5239bc053b39db0b5f09 + md5: 10569984e7db886e4f1abc2b47ad79a1 + constrains: + - binutils_impl_linux-64 2.40 + license: GPL-3.0-only + license_family: GPL + size: 713322 + timestamp: 1713651222435 +- kind: conda + name: libcxx + version: 17.0.6 + build: h5f092b4_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-17.0.6-h5f092b4_0.conda + sha256: 119d3d9306f537d4c89dc99ed99b94c396d262f0b06f7833243646f68884f2c2 + md5: a96fd5dda8ce56c86a971e0fa02751d0 + depends: + - __osx >=11.0 + license: Apache-2.0 WITH LLVM-exception + license_family: Apache + size: 1248885 + timestamp: 1715020154867 +- kind: conda + name: libexpat + version: 2.6.2 + build: h59595ed_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.6.2-h59595ed_0.conda + sha256: 331bb7c7c05025343ebd79f86ae612b9e1e74d2687b8f3179faec234f986ce19 + md5: e7ba12deb7020dd080c6c70e7b6f6a3d + depends: + - libgcc-ng >=12 + constrains: + - expat 2.6.2.* + license: MIT + license_family: MIT + size: 73730 + timestamp: 1710362120304 +- kind: conda + name: libexpat + version: 2.6.2 + build: hebf3989_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.6.2-hebf3989_0.conda + sha256: ba7173ac30064ea901a4c9fb5a51846dcc25512ceb565759be7d18cbf3e5415e + md5: e3cde7cfa87f82f7cb13d482d5e0ad09 + constrains: + - expat 2.6.2.* + license: MIT + license_family: MIT + size: 63655 + timestamp: 1710362424980 +- kind: conda + name: libffi + version: 3.4.2 + build: h3422bc3_5 + build_number: 5 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.4.2-h3422bc3_5.tar.bz2 + sha256: 41b3d13efb775e340e4dba549ab5c029611ea6918703096b2eaa9c015c0750ca + md5: 086914b672be056eb70fd4285b6783b6 + license: MIT + license_family: MIT + size: 39020 + timestamp: 1636488587153 +- kind: conda + name: libffi + version: 3.4.2 + build: h7f98852_5 + build_number: 5 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.2-h7f98852_5.tar.bz2 + sha256: ab6e9856c21709b7b517e940ae7028ae0737546122f83c2aa5d692860c3b149e + md5: d645c6d2ac96843a2bfaccd2d62b3ac3 + depends: + - libgcc-ng >=9.4.0 + license: MIT + license_family: MIT + size: 58292 + timestamp: 1636488182923 +- kind: conda + name: libgcc-ng + version: 13.2.0 + build: h77fa898_7 + build_number: 7 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-13.2.0-h77fa898_7.conda + sha256: 62af2b89acbe74a21606c8410c276e57309c0a2ab8a9e8639e3c8131c0b60c92 + md5: 72ec1b1b04c4d15d4204ece1ecea5978 + depends: + - _libgcc_mutex 0.1 conda_forge + - _openmp_mutex >=4.5 + constrains: + - libgomp 13.2.0 h77fa898_7 + license: GPL-3.0-only WITH GCC-exception-3.1 + size: 775806 + timestamp: 1715016057793 +- kind: conda + name: libglib + version: 2.80.0 + build: hf2295e7_6 + build_number: 6 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libglib-2.80.0-hf2295e7_6.conda + sha256: d2867a1515676f3b64265420598badb2e4ad2369d85237fb276173a99959eb37 + md5: 9342e7c44c38bea649490f72d92c382d + depends: + - libffi >=3.4,<4.0a0 + - libgcc-ng >=12 + - libiconv >=1.17,<2.0a0 + - libzlib >=1.2.13,<1.3.0a0 + - pcre2 >=10.43,<10.44.0a0 + constrains: + - glib 2.80.0 *_6 + license: LGPL-2.1-or-later + size: 3942450 + timestamp: 1713639388280 +- kind: conda + name: libgomp + version: 13.2.0 + build: h77fa898_7 + build_number: 7 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libgomp-13.2.0-h77fa898_7.conda + sha256: 781444fa069d3b50e8ed667b750571cacda785761c7fc2a89ece1ac49693d4ad + md5: abf3fec87c2563697defa759dec3d639 + depends: + - _libgcc_mutex 0.1 conda_forge + license: GPL-3.0-only WITH GCC-exception-3.1 + size: 422336 + timestamp: 1715015995979 +- kind: conda + name: libiconv + version: '1.17' + build: h0d3ecfb_2 + build_number: 2 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libiconv-1.17-h0d3ecfb_2.conda + sha256: bc7de5097b97bcafcf7deaaed505f7ce02f648aac8eccc0d5a47cc599a1d0304 + md5: 69bda57310071cf6d2b86caf11573d2d + license: LGPL-2.1-only + size: 676469 + timestamp: 1702682458114 +- kind: conda + name: libiconv + version: '1.17' + build: hd590300_2 + build_number: 2 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libiconv-1.17-hd590300_2.conda + sha256: 8ac2f6a9f186e76539439e50505d98581472fedb347a20e7d1f36429849f05c9 + md5: d66573916ffcf376178462f1b61c941e + depends: + - libgcc-ng >=12 + license: LGPL-2.1-only + size: 705775 + timestamp: 1702682170569 +- kind: conda + name: libnsl + version: 2.0.1 + build: hd590300_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hd590300_0.conda + sha256: 26d77a3bb4dceeedc2a41bd688564fe71bf2d149fdcf117049970bc02ff1add6 + md5: 30fd6e37fe21f86f4bd26d6ee73eeec7 + depends: + - libgcc-ng >=12 + license: LGPL-2.1-only + license_family: GPL + size: 33408 + timestamp: 1697359010159 +- kind: conda + name: libsqlite + version: 3.45.3 + build: h091b4b1_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.45.3-h091b4b1_0.conda + sha256: 4337f466eb55bbdc74e168b52ec8c38f598e3664244ec7a2536009036e2066cc + md5: c8c1186c7f3351f6ffddb97b1f54fc58 + depends: + - libzlib >=1.2.13,<1.3.0a0 + license: Unlicense + size: 824794 + timestamp: 1713367748819 +- kind: conda + name: libsqlite + version: 3.45.3 + build: h2797004_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.45.3-h2797004_0.conda + sha256: e2273d6860eadcf714a759ffb6dc24a69cfd01f2a0ea9d6c20f86049b9334e0c + md5: b3316cbe90249da4f8e84cd66e1cc55b + depends: + - libgcc-ng >=12 + - libzlib >=1.2.13,<1.3.0a0 + license: Unlicense + size: 859858 + timestamp: 1713367435849 +- kind: conda + name: libstdcxx-ng + version: 13.2.0 + build: hc0a3c3a_7 + build_number: 7 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-13.2.0-hc0a3c3a_7.conda + sha256: 35f1e08be0a84810c9075f5bd008495ac94e6c5fe306dfe4b34546f11fed850f + md5: 53ebd4c833fa01cb2c6353e99f905406 + license: GPL-3.0-only WITH GCC-exception-3.1 + size: 3837704 + timestamp: 1715016117360 +- kind: conda + name: libuuid + version: 2.38.1 + build: h0b41bf4_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.38.1-h0b41bf4_0.conda + sha256: 787eb542f055a2b3de553614b25f09eefb0a0931b0c87dbcce6efdfd92f04f18 + md5: 40b61aab5c7ba9ff276c41cfffe6b80b + depends: + - libgcc-ng >=12 + license: BSD-3-Clause + license_family: BSD + size: 33601 + timestamp: 1680112270483 +- kind: conda + name: libxcrypt + version: 4.4.36 + build: hd590300_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda + sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c + md5: 5aa797f8787fe7a17d1b0821485b5adc + depends: + - libgcc-ng >=12 + license: LGPL-2.1-or-later + size: 100393 + timestamp: 1702724383534 +- kind: conda + name: libxml2 + version: 2.12.6 + build: h0d0cfa8_2 + build_number: 2 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libxml2-2.12.6-h0d0cfa8_2.conda + sha256: a5c10af641d6accf3effb3c3a3c594d931bb374f9e3e796719f3ecf769cfb0fc + md5: 27577d561de7659487b062c363d8a527 + depends: + - icu >=73.2,<74.0a0 + - libiconv >=1.17,<2.0a0 + - libzlib >=1.2.13,<1.3.0a0 + - xz >=5.2.6,<6.0a0 + license: MIT + license_family: MIT + size: 588638 + timestamp: 1713314780561 +- kind: conda + name: libxml2 + version: 2.12.6 + build: h232c23b_2 + build_number: 2 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libxml2-2.12.6-h232c23b_2.conda + sha256: 0fd41df7211aae04f492c8550ce10238e8cfa8b1abebc2215a983c5e66d284ea + md5: 9a3a42df8a95f65334dfc7b80da1195d + depends: + - icu >=73.2,<74.0a0 + - libgcc-ng >=12 + - libiconv >=1.17,<2.0a0 + - libzlib >=1.2.13,<1.3.0a0 + - xz >=5.2.6,<6.0a0 + license: MIT + license_family: MIT + size: 704938 + timestamp: 1713314718258 +- kind: conda + name: libxslt + version: 1.1.39 + build: h223e5b9_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libxslt-1.1.39-h223e5b9_0.conda + sha256: 2f1d99ef3fb960f23a63f06cf65ee621a5594a8b4616f35d9805be44617a92af + md5: 560c9cacc33e927f55b998eaa0cb1732 + depends: + - libxml2 >=2.12.1,<3.0.0a0 + license: MIT + license_family: MIT + size: 225705 + timestamp: 1701628966565 +- kind: conda + name: libxslt + version: 1.1.39 + build: h76b75d6_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libxslt-1.1.39-h76b75d6_0.conda + sha256: 684e9b67ef7b9ca0ca993762eeb39705ec58e2e7f958555c758da7ef416db9f3 + md5: e71f31f8cfb0a91439f2086fc8aa0461 + depends: + - libgcc-ng >=12 + - libxml2 >=2.12.1,<3.0.0a0 + license: MIT + license_family: MIT + size: 254297 + timestamp: 1701628814990 +- kind: conda + name: libzlib + version: 1.2.13 + build: h53f4e23_5 + build_number: 5 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.2.13-h53f4e23_5.conda + sha256: ab1c8aefa2d54322a63aaeeefe9cf877411851738616c4068e0dccc66b9c758a + md5: 1a47f5236db2e06a320ffa0392f81bd8 + constrains: + - zlib 1.2.13 *_5 + license: Zlib + license_family: Other + size: 48102 + timestamp: 1686575426584 +- kind: conda + name: libzlib + version: 1.2.13 + build: hd590300_5 + build_number: 5 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.2.13-hd590300_5.conda + sha256: 370c7c5893b737596fd6ca0d9190c9715d89d888b8c88537ae1ef168c25e82e4 + md5: f36c115f1ee199da648e0597ec2047ad + depends: + - libgcc-ng >=12 + constrains: + - zlib 1.2.13 *_5 + license: Zlib + license_family: Other + size: 61588 + timestamp: 1686575217516 +- kind: conda + name: license-expression + version: 30.1.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/license-expression-30.1.1-pyhd8ed1ab_0.conda + sha256: 72fa44117cfd8e76274d4350a75c0badf269550ee32772efe6d77628f7569539 + md5: b64341a51378dcd6924388737c5aac6f + depends: + - boolean.py >=4.0.0 + - python >=3.7 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/license-expression + size: 93614 + timestamp: 1690394219675 +- kind: conda + name: lxml + version: 5.2.1 + build: py312h8f698c5_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/lxml-5.2.1-py312h8f698c5_0.conda + sha256: f38d4af8de94a46335bcde3b1a5fc10d40992023e61c969142de0e9dd719ae0a + md5: 93e9a75ec1b7df64c653986c27b1b78f + depends: + - __osx >=11.0 + - libxml2 >=2.12.6,<3.0a0 + - libxslt >=1.1.39,<2.0a0 + - libzlib >=1.2.13,<1.3.0a0 + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause and MIT-CMU + purls: + - pkg:pypi/lxml + size: 1149527 + timestamp: 1713573008455 +- kind: conda + name: lxml + version: 5.2.1 + build: py312hb90d8a5_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/lxml-5.2.1-py312hb90d8a5_0.conda + sha256: 38395a99140602aec3b2e979deffca9485fad503d7ea7ec882704652e5829878 + md5: d260ebc72791a941c239029ea631bd44 + depends: + - libgcc-ng >=12 + - libxml2 >=2.12.6,<3.0a0 + - libxslt >=1.1.39,<2.0a0 + - libzlib >=1.2.13,<1.3.0a0 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause and MIT-CMU + purls: + - pkg:pypi/lxml + size: 1400706 + timestamp: 1713572667229 +- kind: conda + name: markdown-it-py + version: 3.0.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/markdown-it-py-3.0.0-pyhd8ed1ab_0.conda + sha256: c041b0eaf7a6af3344d5dd452815cdc148d6284fec25a4fa3f4263b3a021e962 + md5: 93a8e71256479c62074356ef6ebf501b + depends: + - mdurl >=0.1,<1 + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/markdown-it-py + size: 64356 + timestamp: 1686175179621 +- kind: conda + name: markupsafe + version: 2.1.5 + build: py312h98912ed_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-2.1.5-py312h98912ed_0.conda + sha256: 273d8efd6c089c534ccbede566394c0ac1e265bfe5d89fe76e80332f3d75a636 + md5: 6ff0b9582da2d4a74a1f9ae1f9ce2af6 + depends: + - libgcc-ng >=12 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + constrains: + - jinja2 >=3.0.0 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/markupsafe + size: 26685 + timestamp: 1706900070330 +- kind: conda + name: markupsafe + version: 2.1.5 + build: py312he37b823_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-2.1.5-py312he37b823_0.conda + sha256: 61480b725490f68856dd14e646f51ffc34f77f2c985bd33e3b77c04b2856d97d + md5: ba3a8f8cf8bbdb81394275b1e1d271da + depends: + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + constrains: + - jinja2 >=3.0.0 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/markupsafe + size: 26382 + timestamp: 1706900495057 +- kind: conda + name: mdurl + version: 0.1.2 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/mdurl-0.1.2-pyhd8ed1ab_0.conda + sha256: 64073dfb6bb429d52fff30891877b48c7ec0f89625b1bf844905b66a81cce6e1 + md5: 776a8dd9e824f77abac30e6ef43a8f7a + depends: + - python >=3.6 + license: MIT + license_family: MIT + purls: + - pkg:pypi/mdurl + size: 14680 + timestamp: 1704317789138 +- kind: conda + name: more-itertools + version: 10.2.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/more-itertools-10.2.0-pyhd8ed1ab_0.conda + sha256: 9e49e9484ff279453f0b55323a3f0c7cb97440c74f69eecda1f4ad29fae5cd3c + md5: d5c98e9706fdc5328d49a9bf2ce5fb42 + depends: + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/more-itertools + size: 54469 + timestamp: 1704738585811 +- kind: conda + name: ncurses + version: 6.4.20240210 + build: h078ce10_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.4.20240210-h078ce10_0.conda + sha256: 06f0905791575e2cd3aa961493c56e490b3d82ad9eb49f1c332bd338b0216911 + md5: 616ae8691e6608527d0071e6766dcb81 + license: X11 AND BSD-3-Clause + size: 820249 + timestamp: 1710866874348 +- kind: conda + name: ncurses + version: 6.4.20240210 + build: h59595ed_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.4.20240210-h59595ed_0.conda + sha256: aa0f005b6727aac6507317ed490f0904430584fa8ca722657e7f0fb94741de81 + md5: 97da8860a0da5413c7c98a3b3838a645 + depends: + - libgcc-ng >=12 + license: X11 AND BSD-3-Clause + size: 895669 + timestamp: 1710866638986 +- kind: conda + name: nh3 + version: 0.2.17 + build: py312h4b3b743_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/nh3-0.2.17-py312h4b3b743_0.conda + sha256: 60067873dda1f5433fee8e2b7c02a32785153d2be73c75cfffae47ca4566a9c2 + md5: cfa305e03624c82d451a5ef250960bbd + depends: + - libgcc-ng >=12 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/nh3 + size: 607053 + timestamp: 1711545731955 +- kind: conda + name: nh3 + version: 0.2.17 + build: py312h5280bc4_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/nh3-0.2.17-py312h5280bc4_0.conda + sha256: b5ff8a687db7ef51fe5f854fe37975f08b70a2ad0ff585d9444e4e3bd77b3d95 + md5: 7a6d211257e3d264516ae4d67ce181a4 + depends: + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/nh3 + size: 582437 + timestamp: 1711545995406 +- kind: conda + name: nodeenv + version: 1.8.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/nodeenv-1.8.0-pyhd8ed1ab_0.conda + sha256: 1320306234552717149f36f825ddc7e27ea295f24829e9db4cc6ceaff0b032bd + md5: 2a75b296096adabbabadd5e9782e5fcc + depends: + - python 2.7|>=3.7 + - setuptools + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/nodeenv + size: 34358 + timestamp: 1683893151613 +- kind: conda + name: openssl + version: 3.3.0 + build: h0d3ecfb_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.3.0-h0d3ecfb_0.conda + sha256: 51f9be8fe929c2bb3243cd0707b6dfcec27541f8284b4bd9b063c288fc46f482 + md5: 25b0e522c3131886a637e347b2ca0c0f + depends: + - ca-certificates + constrains: + - pyopenssl >=22.1 + license: Apache-2.0 + license_family: Apache + size: 2888226 + timestamp: 1714466346030 +- kind: conda + name: openssl + version: 3.3.0 + build: hd590300_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.3.0-hd590300_0.conda + sha256: fdbf05e4db88c592366c90bb82e446edbe33c6e49e5130d51c580b2629c0b5d5 + md5: c0f3abb4a16477208bbd43a39bd56f18 + depends: + - ca-certificates + - libgcc-ng >=12 + constrains: + - pyopenssl >=22.1 + license: Apache-2.0 + license_family: Apache + size: 2895187 + timestamp: 1714466138265 +- kind: conda + name: packaging + version: '24.0' + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/packaging-24.0-pyhd8ed1ab_0.conda + sha256: a390182d74c31dfd713c16db888c92c277feeb6d1fe96ff9d9c105f9564be48a + md5: 248f521b64ce055e7feae3105e7abeb8 + depends: + - python >=3.8 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/packaging + size: 49832 + timestamp: 1710076089469 +- kind: conda + name: pathspec + version: 0.12.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pathspec-0.12.1-pyhd8ed1ab_0.conda + sha256: 4e534e66bfe8b1e035d2169d0e5b185450546b17e36764272863e22e0370be4d + md5: 17064acba08d3686f1135b5ec1b32b12 + depends: + - python >=3.7 + license: MPL-2.0 + license_family: MOZILLA + purls: + - pkg:pypi/pathspec + size: 41173 + timestamp: 1702250135032 +- kind: conda + name: pcre2 + version: '10.43' + build: hcad00b1_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.43-hcad00b1_0.conda + sha256: 766dd986a7ed6197676c14699000bba2625fd26c8a890fcb7a810e5cf56155bc + md5: 8292dea9e022d9610a11fce5e0896ed8 + depends: + - bzip2 >=1.0.8,<2.0a0 + - libgcc-ng >=12 + - libzlib >=1.2.13,<1.3.0a0 + license: BSD-3-Clause + license_family: BSD + size: 950847 + timestamp: 1708118050286 +- kind: conda + name: pdbpp + version: 0.10.3 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pdbpp-0.10.3-pyhd8ed1ab_0.tar.bz2 + sha256: c3f3996853853501af5ee936ebbd5a3de2eb1e73a078c7d4c541dbd97b315248 + md5: 3efee795aeb50ae2ca1ac732b529e603 + depends: + - fancycompleter + - pygments + - python >=3.4 + - wmctrl + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/pdbpp + size: 25010 + timestamp: 1626016866544 +- kind: conda + name: pexpect + version: 4.9.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pexpect-4.9.0-pyhd8ed1ab_0.conda + sha256: 90a09d134a4a43911b716d4d6eb9d169238aff2349056f7323d9db613812667e + md5: 629f3203c99b32e0988910c93e77f3b6 + depends: + - ptyprocess >=0.5 + - python >=3.7 + license: ISC + purls: + - pkg:pypi/pexpect + size: 53600 + timestamp: 1706113273252 +- kind: conda + name: pkginfo + version: 1.10.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pkginfo-1.10.0-pyhd8ed1ab_0.conda + sha256: 3e833f907039646e34d23203cd5c9cc487a451d955d8c8d6581e18a8ccef4cee + md5: 8c6a4a704308f5d91f3a974a72db1096 + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pkginfo + size: 28142 + timestamp: 1709561205511 +- kind: conda + name: platformdirs + version: 4.2.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.2.1-pyhd8ed1ab_0.conda + sha256: 5718fef2954f016834058ae1d359e407ff8e2e847b35ab43d5d91bcf22d5578d + md5: d478a8a3044cdff1aa6e62f9269cefe0 + depends: + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/platformdirs + size: 20248 + timestamp: 1713912912262 +- kind: conda + name: pluggy + version: 1.5.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pluggy-1.5.0-pyhd8ed1ab_0.conda + sha256: 33eaa3359948a260ebccf9cdc2fd862cea5a6029783289e13602d8e634cd9a26 + md5: d3483c8fc2dc2cc3f5cf43e26d60cabf + depends: + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pluggy + size: 23815 + timestamp: 1713667175451 +- kind: conda + name: pre-commit + version: 3.7.0 + build: pyha770c72_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pre-commit-3.7.0-pyha770c72_0.conda + sha256: b7a1d56fb1374df77019521bbcbe109ff17337181c4d392918e5ec1a10a9df87 + md5: 846ba0877cda9c4f11e13720cacd1968 + depends: + - cfgv >=2.0.0 + - identify >=1.0.0 + - nodeenv >=0.11.1 + - python >=3.9 + - pyyaml >=5.1 + - virtualenv >=20.10.0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pre-commit + size: 180574 + timestamp: 1711480432386 +- kind: conda + name: ptyprocess + version: 0.7.0 + build: pyhd3deb0d_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/ptyprocess-0.7.0-pyhd3deb0d_0.tar.bz2 + sha256: fb31e006a25eb2e18f3440eb8d17be44c8ccfae559499199f73584566d0a444a + md5: 359eeb6536da0e687af562ed265ec263 + depends: + - python + license: ISC + purls: + - pkg:pypi/ptyprocess + size: 16546 + timestamp: 1609419417991 +- kind: conda + name: pycparser + version: '2.22' + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyhd8ed1ab_0.conda + sha256: 406001ebf017688b1a1554b49127ca3a4ac4626ec0fd51dc75ffa4415b720b64 + md5: 844d9eb3b43095b031874477f7d70088 + depends: + - python >=3.8 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/pycparser + size: 105098 + timestamp: 1711811634025 +- kind: conda + name: pygments + version: 2.18.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pygments-2.18.0-pyhd8ed1ab_0.conda + sha256: 78267adf4e76d0d64ea2ffab008c501156c108bb08fecb703816fb63e279780b + md5: b7f5c092b8f9800150d998a71b76d5a1 + depends: + - python >=3.8 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/pygments + size: 879295 + timestamp: 1714846885370 +- kind: conda + name: pyrepl + version: 0.9.0 + build: py312h98912ed_9 + build_number: 9 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/pyrepl-0.9.0-py312h98912ed_9.conda + sha256: 9e9d59e511c8a8e0792386bec59a87e584768f2856b1131191c6e98f50b03cc2 + md5: a56b87ccd13bc27b0e8ce66c75abe79e + depends: + - libgcc-ng >=12 + - ncurses + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: ISC + license_family: OTHER + purls: + - pkg:pypi/pyrepl + size: 104985 + timestamp: 1709131190353 +- kind: conda + name: pyrepl + version: 0.9.0 + build: py312he37b823_9 + build_number: 9 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/pyrepl-0.9.0-py312he37b823_9.conda + sha256: 41fd0010d8520d26a56e1585cc46b4bbcfbb94b9e3bcebcbe18885ea14a06fa6 + md5: 4c4540b6c01647e1737670f4f1142f7e + depends: + - ncurses + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + license: ISC + license_family: OTHER + purls: + - pkg:pypi/pyrepl + size: 105769 + timestamp: 1709131653373 +- kind: conda + name: pysocks + version: 1.7.1 + build: pyha2e5f31_6 + build_number: 6 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha2e5f31_6.tar.bz2 + sha256: a42f826e958a8d22e65b3394f437af7332610e43ee313393d1cf143f0a2d274b + md5: 2a7de29fb590ca14b5243c4c812c8025 + depends: + - __unix + - python >=3.8 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/pysocks + size: 18981 + timestamp: 1661604969727 +- kind: conda + name: pytest + version: 8.2.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pytest-8.2.0-pyhd8ed1ab_0.conda + sha256: 02227fea7b50132a75fb223c2d796306ffebd4dc6324897455f17cb54d16683d + md5: 088ff7e08f4f10a06190468048c2a353 + depends: + - colorama + - exceptiongroup >=1.0.0rc8 + - iniconfig + - packaging + - pluggy <2.0,>=1.5 + - python >=3.8 + - tomli >=1 + constrains: + - pytest-faulthandler >=2 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pytest + size: 257122 + timestamp: 1714308481448 +- kind: conda + name: pytest-cov + version: 5.0.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pytest-cov-5.0.0-pyhd8ed1ab_0.conda + sha256: 218306243faf3c36347131c2b36bb189daa948ac2e92c7ab52bb26cc8c157b3c + md5: c54c0107057d67ddf077751339ec2c63 + depends: + - coverage >=5.2.1 + - pytest >=4.6 + - python >=3.8 + - toml + license: MIT + license_family: MIT + purls: + - pkg:pypi/pytest-cov + size: 25507 + timestamp: 1711411153367 +- kind: conda + name: python + version: 3.11.9 + build: h932a869_0_cpython + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.11.9-h932a869_0_cpython.conda + sha256: a436ceabde1f056a0ac3e347dadc780ee2a135a421ddb6e9a469370769829e3c + md5: 293e0713ae804b5527a673e7605c04fc + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.6.2,<3.0a0 + - libffi >=3.4,<4.0a0 + - libsqlite >=3.45.3,<4.0a0 + - libzlib >=1.2.13,<1.3.0a0 + - ncurses >=6.4.20240210,<7.0a0 + - openssl >=3.2.1,<4.0a0 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - xz >=5.2.6,<6.0a0 + constrains: + - python_abi 3.11.* *_cp311 + license: Python-2.0 + size: 14644189 + timestamp: 1713552154779 +- kind: conda + name: python + version: 3.11.9 + build: hb806964_0_cpython + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/python-3.11.9-hb806964_0_cpython.conda + sha256: 177f33a1fb8d3476b38f73c37b42f01c0b014fa0e039a701fd9f83d83aae6d40 + md5: ac68acfa8b558ed406c75e98d3428d7b + depends: + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.6.2,<3.0a0 + - libffi >=3.4,<4.0a0 + - libgcc-ng >=12 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.45.3,<4.0a0 + - libuuid >=2.38.1,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.2.13,<1.3.0a0 + - ncurses >=6.4.20240210,<7.0a0 + - openssl >=3.2.1,<4.0a0 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - xz >=5.2.6,<6.0a0 + constrains: + - python_abi 3.11.* *_cp311 + license: Python-2.0 + size: 30884494 + timestamp: 1713553104915 +- kind: conda + name: python + version: 3.12.3 + build: h4a7b5fc_0_cpython + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/python-3.12.3-h4a7b5fc_0_cpython.conda + sha256: c761fb3713ea66bce3889b33b6f400afb2dd192d1fc2686446e9d8166cfcec6b + md5: 8643ab37bece6ae8f112464068d9df9c + depends: + - __osx >=11.0 + - bzip2 >=1.0.8,<2.0a0 + - libexpat >=2.6.2,<3.0a0 + - libffi >=3.4,<4.0a0 + - libsqlite >=3.45.2,<4.0a0 + - libzlib >=1.2.13,<1.3.0a0 + - ncurses >=6.4.20240210,<7.0a0 + - openssl >=3.2.1,<4.0a0 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - xz >=5.2.6,<6.0a0 + constrains: + - python_abi 3.12.* *_cp312 + license: Python-2.0 + size: 13207557 + timestamp: 1713206576646 +- kind: conda + name: python + version: 3.12.3 + build: hab00c5b_0_cpython + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.3-hab00c5b_0_cpython.conda + sha256: f9865bcbff69f15fd89a33a2da12ad616e98d65ce7c83c644b92e66e5016b227 + md5: 2540b74d304f71d3e89c81209db4db84 + depends: + - bzip2 >=1.0.8,<2.0a0 + - ld_impl_linux-64 >=2.36.1 + - libexpat >=2.6.2,<3.0a0 + - libffi >=3.4,<4.0a0 + - libgcc-ng >=12 + - libnsl >=2.0.1,<2.1.0a0 + - libsqlite >=3.45.2,<4.0a0 + - libuuid >=2.38.1,<3.0a0 + - libxcrypt >=4.4.36 + - libzlib >=1.2.13,<1.3.0a0 + - ncurses >=6.4.20240210,<7.0a0 + - openssl >=3.2.1,<4.0a0 + - readline >=8.2,<9.0a0 + - tk >=8.6.13,<8.7.0a0 + - tzdata + - xz >=5.2.6,<6.0a0 + constrains: + - python_abi 3.12.* *_cp312 + license: Python-2.0 + size: 31991381 + timestamp: 1713208036041 +- kind: conda + name: python-debian + version: 0.1.36 + build: py_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/python-debian-0.1.36-py_0.tar.bz2 + sha256: 7006309bf371fffc81f875baa63c29ffb33bf8074fdd33d0d68154e58ea6c7ff + md5: 079bbbbc928d759853d44a1de630d3c1 + depends: + - chardet + - python + - six + license: GPL-3.0-or-later + license_family: GPL + purls: + - pkg:pypi/python-debian + size: 66742 + timestamp: 1572978048259 +- kind: conda + name: python_abi + version: '3.11' + build: 4_cp311 + build_number: 4 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/python_abi-3.11-4_cp311.conda + sha256: 0be3ac1bf852d64f553220c7e6457e9c047dfb7412da9d22fbaa67e60858b3cf + md5: d786502c97404c94d7d58d258a445a65 + constrains: + - python 3.11.* *_cpython + license: BSD-3-Clause + license_family: BSD + size: 6385 + timestamp: 1695147338551 +- kind: conda + name: python_abi + version: '3.11' + build: 4_cp311 + build_number: 4 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/python_abi-3.11-4_cp311.conda + sha256: 4837089c477b9b84fa38a17f453e6634e68237267211b27a8a2f5ccd847f4e55 + md5: 8d3751bc73d3bbb66f216fa2331d5649 + constrains: + - python 3.11.* *_cpython + license: BSD-3-Clause + license_family: BSD + size: 6492 + timestamp: 1695147509940 +- kind: conda + name: python_abi + version: '3.12' + build: 4_cp312 + build_number: 4 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/python_abi-3.12-4_cp312.conda + sha256: 182a329de10a4165f6e8a3804caf751f918f6ea6176dd4e5abcdae1ed3095bf6 + md5: dccc2d142812964fcc6abdc97b672dff + constrains: + - python 3.12.* *_cpython + license: BSD-3-Clause + license_family: BSD + size: 6385 + timestamp: 1695147396604 +- kind: conda + name: python_abi + version: '3.12' + build: 4_cp312 + build_number: 4 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/python_abi-3.12-4_cp312.conda + sha256: db25428e4f24f8693ffa39f3ff6dfbb8fd53bc298764b775b57edab1c697560f + md5: bbb3a02c78b2d8219d7213f76d644a2a + constrains: + - python 3.12.* *_cpython + license: BSD-3-Clause + license_family: BSD + size: 6508 + timestamp: 1695147497048 +- kind: conda + name: pytz + version: '2024.1' + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/pytz-2024.1-pyhd8ed1ab_0.conda + sha256: 1a7d6b233f7e6e3bbcbad054c8fd51e690a67b129a899a056a5e45dd9f00cb41 + md5: 3eeeeb9e4827ace8c0c1419c85d590ad + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pytz + size: 188538 + timestamp: 1706886944988 +- kind: conda + name: pyyaml + version: 6.0.1 + build: py312h02f2b3b_1 + build_number: 1 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/pyyaml-6.0.1-py312h02f2b3b_1.conda + sha256: b6b4027b89c17b9bbd8089aec3e44bc29f802a7d5668d5a75b5358d7ed9705ca + md5: a0c843e52a1c4422d8657dd76e9eb994 + depends: + - python >=3.12.0rc3,<3.13.0a0 + - python >=3.12.0rc3,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + - yaml >=0.2.5,<0.3.0a0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pyyaml + size: 182705 + timestamp: 1695373895409 +- kind: conda + name: pyyaml + version: 6.0.1 + build: py312h98912ed_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/pyyaml-6.0.1-py312h98912ed_1.conda + sha256: 7f347a10a7121b08d79d21cd4f438c07c23479ea0c74dfb89d6dc416f791bb7f + md5: e3fd78d8d490af1d84763b9fe3f2e552 + depends: + - libgcc-ng >=12 + - python >=3.12.0rc3,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - yaml >=0.2.5,<0.3.0a0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/pyyaml + size: 196583 + timestamp: 1695373632212 +- kind: conda + name: readline + version: '8.2' + build: h8228510_1 + build_number: 1 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8228510_1.conda + sha256: 5435cf39d039387fbdc977b0a762357ea909a7694d9528ab40f005e9208744d7 + md5: 47d31b792659ce70f470b5c82fdfb7a4 + depends: + - libgcc-ng >=12 + - ncurses >=6.3,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 281456 + timestamp: 1679532220005 +- kind: conda + name: readline + version: '8.2' + build: h92ec313_1 + build_number: 1 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/readline-8.2-h92ec313_1.conda + sha256: a1dfa679ac3f6007362386576a704ad2d0d7a02e98f5d0b115f207a2da63e884 + md5: 8cbb776a2f641b943d413b3e19df71f4 + depends: + - ncurses >=6.3,<7.0a0 + license: GPL-3.0-only + license_family: GPL + size: 250351 + timestamp: 1679532511311 +- kind: conda + name: readme_renderer + version: '42.0' + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/readme_renderer-42.0-pyhd8ed1ab_0.conda + sha256: 61e03765ebdb168fc8747e8183db4067b55888c89d59e0f4f53b5b4046846cda + md5: fdc16f5dc3a911d8f43f64f814a45961 + depends: + - cmarkgfm >=0.8.0 + - docutils >=0.13.1 + - nh3 >=0.2.14 + - pygments >=2.5.1 + - python >=3.8 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/readme-renderer + size: 17373 + timestamp: 1694242843889 +- kind: conda + name: requests + version: 2.31.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/requests-2.31.0-pyhd8ed1ab_0.conda + sha256: 9f629d6fd3c8ac5f2a198639fe7af87c4db2ac9235279164bfe0fcb49d8c4bad + md5: a30144e4156cdbb236f99ebb49828f8b + depends: + - certifi >=2017.4.17 + - charset-normalizer >=2,<4 + - idna >=2.5,<4 + - python >=3.7 + - urllib3 >=1.21.1,<3 + constrains: + - chardet >=3.0.2,<6 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/requests + size: 56690 + timestamp: 1684774408600 +- kind: conda + name: requests-toolbelt + version: 1.0.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/requests-toolbelt-1.0.0-pyhd8ed1ab_0.conda + sha256: 20eaefc5dba74ff6c31e537533dde59b5b20f69e74df49dff19d43be59785fa3 + md5: 99c98318c8646b08cc764f90ce98906e + depends: + - python >=3.6 + - requests >=2.0.1,<3.0.0 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/requests-toolbelt + size: 43939 + timestamp: 1682953467574 +- kind: conda + name: reuse + version: 3.0.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/reuse-3.0.1-pyhd8ed1ab_0.conda + sha256: 72a0e7a88fa4d763fccae959585d83b696a41539bfc1ebd72b8e5582cf8c1dbe + md5: cfbbf3b2ba6d90fe13ec3b59dca5fa5f + depends: + - binaryornot + - boolean.py + - jinja2 + - license-expression + - python >=3.6 + - python-debian + - requests + - setuptools + license: GPL-3.0-or-later AND Apache-2.0 AND CC0-1.0 AND CC-BY-SA-4.0 + purls: + - pkg:pypi/reuse + size: 146563 + timestamp: 1705680540750 +- kind: conda + name: rfc3986 + version: 2.0.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/rfc3986-2.0.0-pyhd8ed1ab_0.tar.bz2 + sha256: dd6bfb7c4248ba7612f2e6e4a066d6804ba96dfcaeddf43475a2c846ccfcc396 + md5: d337886e38f965bf97aaec382ff6db00 + depends: + - python >=3.4 + license: Apache-2.0 + license_family: APACHE + purls: + - pkg:pypi/rfc3986 + size: 34075 + timestamp: 1641825125307 +- kind: conda + name: rich + version: 13.7.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/rich-13.7.1-pyhd8ed1ab_0.conda + sha256: 2b26d58aa59e46f933c3126367348651b0dab6e0bf88014e857415bb184a4667 + md5: ba445bf767ae6f0d959ff2b40c20912b + depends: + - markdown-it-py >=2.2.0 + - pygments >=2.13.0,<3.0.0 + - python >=3.7.0 + - typing_extensions >=4.0.0,<5.0.0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/rich + size: 184347 + timestamp: 1709150578093 +- kind: conda + name: ruff + version: 0.4.3 + build: py312h3402d49_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/ruff-0.4.3-py312h3402d49_0.conda + sha256: 096cbdaab7d766b774c452c392fabbb47653ed4370400c2f620647a5187b7773 + md5: 61bd91af1cda46986708cddcdfdf635d + depends: + - __osx >=11.0 + - libcxx >=16 + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + constrains: + - __osx >=11.0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ruff + size: 5818141 + timestamp: 1714794574870 +- kind: conda + name: ruff + version: 0.4.3 + build: py312h5715c7c_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/ruff-0.4.3-py312h5715c7c_0.conda + sha256: 09dbfc6055263a14d1034f6571f5faf7201f5312363e03cb28703a85a19d6a28 + md5: 9a138fc0b732a0d2fd9c97e5fb304619 + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ruff + size: 6301226 + timestamp: 1714793018454 +- kind: conda + name: secretstorage + version: 3.3.3 + build: py312h7900ff3_2 + build_number: 2 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/secretstorage-3.3.3-py312h7900ff3_2.conda + sha256: 0479e3f8c8e90049a6d92d4c7e67916c6d6cdafd11a1a31c54c785cce44aeb20 + md5: 39067833cbb620066d492f8bd6f11dbf + depends: + - cryptography + - dbus + - jeepney >=0.6 + - python >=3.12.0rc3,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/secretstorage + size: 31766 + timestamp: 1695551875966 +- kind: conda + name: setuptools + version: 69.5.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/setuptools-69.5.1-pyhd8ed1ab_0.conda + sha256: 72d143408507043628b32bed089730b6d5f5445eccc44b59911ec9f262e365e7 + md5: 7462280d81f639363e6e63c81276bd9e + depends: + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/setuptools + size: 501790 + timestamp: 1713094963112 +- kind: conda + name: shellingham + version: 1.5.4 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/shellingham-1.5.4-pyhd8ed1ab_0.conda + sha256: 3c49a0a101c41b7cf6ac05a1872d7a1f91f1b6d02eecb4a36b605a19517862bb + md5: d08db09a552699ee9e7eec56b4eb3899 + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/shellingham + size: 14568 + timestamp: 1698144516278 +- kind: conda + name: six + version: 1.16.0 + build: pyh6c4a22f_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/six-1.16.0-pyh6c4a22f_0.tar.bz2 + sha256: a85c38227b446f42c5b90d9b642f2c0567880c15d72492d8da074a59c8f91dd6 + md5: e5f25f8dbc060e9a8d912e432202afc2 + depends: + - python + license: MIT + license_family: MIT + purls: + - pkg:pypi/six + size: 14259 + timestamp: 1620240338595 +- kind: conda + name: sniffio + version: 1.3.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sniffio-1.3.1-pyhd8ed1ab_0.conda + sha256: bc12100b2d8836b93c55068b463190505b8064d0fc7d025e89f20ebf22fe6c2b + md5: 490730480d76cf9c8f8f2849719c6e2b + depends: + - python >=3.7 + license: Apache-2.0 + license_family: Apache + purls: + - pkg:pypi/sniffio + size: 15064 + timestamp: 1708953086199 +- kind: conda + name: snowballstemmer + version: 2.2.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/snowballstemmer-2.2.0-pyhd8ed1ab_0.tar.bz2 + sha256: a0fd916633252d99efb6223b1050202841fa8d2d53dacca564b0ed77249d3228 + md5: 4d22a9315e78c6827f806065957d566e + depends: + - python >=2 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/snowballstemmer + size: 58824 + timestamp: 1637143137377 +- kind: conda + name: soupsieve + version: '2.5' + build: pyhd8ed1ab_1 + build_number: 1 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/soupsieve-2.5-pyhd8ed1ab_1.conda + sha256: 54ae221033db8fbcd4998ccb07f3c3828b4d77e73b0c72b18c1d6a507059059c + md5: 3f144b2c34f8cb5a9abd9ed23a39c561 + depends: + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/soupsieve + size: 36754 + timestamp: 1693929424267 +- kind: conda + name: sphinx + version: 7.3.7 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinx-7.3.7-pyhd8ed1ab_0.conda + sha256: 41101e2b0b8722087f06bd73251ba95ef89db515982b6a89aeebfa98ebcb65a1 + md5: 7b1465205e28d75d2c0e1a868ee00a67 + depends: + - alabaster >=0.7.14,<0.8.dev0 + - babel >=2.9 + - colorama >=0.4.5 + - docutils >=0.18.1,<0.22 + - imagesize >=1.3 + - importlib-metadata >=4.8 + - jinja2 >=3.0 + - packaging >=21.0 + - pygments >=2.14 + - python >=3.9 + - requests >=2.25.0 + - snowballstemmer >=2.0 + - sphinxcontrib-applehelp + - sphinxcontrib-devhelp + - sphinxcontrib-htmlhelp >=2.0.0 + - sphinxcontrib-jsmath + - sphinxcontrib-qthelp + - sphinxcontrib-serializinghtml >=1.1.9 + - tomli >=2.0 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/sphinx + size: 1345378 + timestamp: 1713555005540 +- kind: conda + name: sphinx-autodoc-typehints + version: 2.1.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinx-autodoc-typehints-2.1.0-pyhd8ed1ab_0.conda + sha256: f331eda04d540a0ebdceea03faaef6f1a57db93cc1ac74a71512e6a20cd4e125 + md5: ab586f5de577d96a890f0ffc48de7956 + depends: + - python >=3.9 + - sphinx >=7.3.5 + license: MIT + license_family: MIT + purls: + - pkg:pypi/sphinx-autodoc-typehints + size: 23394 + timestamp: 1713418816794 +- kind: conda + name: sphinx-basic-ng + version: 1.0.0b2 + build: pyhd8ed1ab_1 + build_number: 1 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinx-basic-ng-1.0.0b2-pyhd8ed1ab_1.conda + sha256: 3c7a6a8bb6c9921741ef940cd61ff1694beac3c95ca7e9ad4b0ea32e2f6ac2fa + md5: a631f5c7b7f5045448f966ad71aa2881 + depends: + - python >=3.7 + - sphinx >=4.0,<8.0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/sphinx-basic-ng + size: 20316 + timestamp: 1690475062890 +- kind: conda + name: sphinx-copybutton + version: 0.5.2 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinx-copybutton-0.5.2-pyhd8ed1ab_0.conda + sha256: 7ea21f009792e7c69612ddba367afe0412b3fdff2e92f439e8cd222de4b40bfe + md5: ac832cc43adc79118cf6e23f1f9b8995 + depends: + - python >=3 + - sphinx >=1.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/sphinx-copybutton + size: 17801 + timestamp: 1681468271927 +- kind: conda + name: sphinx-inline-tabs + version: 2023.4.21 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinx-inline-tabs-2023.4.21-pyhd8ed1ab_0.conda + sha256: 142f45bb224380f13f800ae3769f0d2aa3efcd9c49e5389b48863d03c08a801a + md5: 4addb035e43d09440597352079305513 + depends: + - beautifulsoup4 + - python >=3.6 + - sphinx >3 + license: MIT + license_family: MIT + purls: + - pkg:pypi/sphinx-inline-tabs + size: 12423 + timestamp: 1682112748389 +- kind: conda + name: sphinxcontrib-applehelp + version: 1.0.8 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-applehelp-1.0.8-pyhd8ed1ab_0.conda + sha256: 710013443a063518d587d2af82299e92ab6d6695edf35a676ac3a0ccc9e3f8e6 + md5: 611a35a27914fac3aa37611a6fe40bb5 + depends: + - python >=3.9 + - sphinx >=5 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/sphinxcontrib-applehelp + size: 29539 + timestamp: 1705126465971 +- kind: conda + name: sphinxcontrib-devhelp + version: 1.0.6 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-devhelp-1.0.6-pyhd8ed1ab_0.conda + sha256: 63a6b60653ef13a6712848f4b3c4b713d4b564da1dae571893f1a3659cde85f3 + md5: d7e4954df0d3aea2eacc7835ad12671d + depends: + - python >=3.9 + - sphinx >=5 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/sphinxcontrib-devhelp + size: 24474 + timestamp: 1705126153592 +- kind: conda + name: sphinxcontrib-htmlhelp + version: 2.0.5 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.0.5-pyhd8ed1ab_0.conda + sha256: 512f393cfe34cb3de96ade7a7ad900d6278e2087a1f0e5732aa60fadee396d99 + md5: 7e1e7437273682ada2ed5e9e9714b140 + depends: + - python >=3.9 + - sphinx >=5 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/sphinxcontrib-htmlhelp + size: 33499 + timestamp: 1705118297318 +- kind: conda + name: sphinxcontrib-jsmath + version: 1.0.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_0.conda + sha256: d4337d83b8edba688547766fc80f1ac86d6ec86ceeeda93f376acc04079c5ce2 + md5: da1d979339e2714c30a8e806a33ec087 + depends: + - python >=3.5 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/sphinxcontrib-jsmath + size: 10431 + timestamp: 1691604844204 +- kind: conda + name: sphinxcontrib-programoutput + version: '0.17' + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-programoutput-0.17-pyhd8ed1ab_0.tar.bz2 + sha256: d5f95e7d2398f747f1c21a9d7853215fac224ac178c9cdb45ebc8de43ce9654e + md5: 16fcf2061ca925fae993df41314f990f + depends: + - python >=3.6 + - sphinx >=1.7.0 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/sphinxcontrib-programoutput + size: 19270 + timestamp: 1663101050183 +- kind: conda + name: sphinxcontrib-qthelp + version: 1.0.7 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-1.0.7-pyhd8ed1ab_0.conda + sha256: dd35b52f056c39081cd0ae01155174277af579b69e5d83798a33e9056ec78d63 + md5: 26acae54b06f178681bfb551760f5dd1 + depends: + - python >=3.9 + - sphinx >=5 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/sphinxcontrib-qthelp + size: 27005 + timestamp: 1705126340442 +- kind: conda + name: sphinxcontrib-serializinghtml + version: 1.1.10 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_0.conda + sha256: bf80e4c0ff97d5e8e5f6db0831ba60007e820a3a438e8f1afd868aa516d67d6f + md5: e507335cb4ca9cff4c3d0fa9cdab255e + depends: + - python >=3.9 + - sphinx >=5 + license: BSD-2-Clause + license_family: BSD + purls: + - pkg:pypi/sphinxcontrib-serializinghtml + size: 28776 + timestamp: 1705118378942 +- kind: conda + name: tk + version: 8.6.13 + build: h5083fa2_1 + build_number: 1 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-h5083fa2_1.conda + sha256: 72457ad031b4c048e5891f3f6cb27a53cb479db68a52d965f796910e71a403a8 + md5: b50a57ba89c32b62428b71a875291c9b + depends: + - libzlib >=1.2.13,<1.3.0a0 + license: TCL + license_family: BSD + size: 3145523 + timestamp: 1699202432999 +- kind: conda + name: tk + version: 8.6.13 + build: noxft_h4845f30_101 + build_number: 101 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h4845f30_101.conda + sha256: e0569c9caa68bf476bead1bed3d79650bb080b532c64a4af7d8ca286c08dea4e + md5: d453b98d9c83e71da0741bb0ff4d76bc + depends: + - libgcc-ng >=12 + - libzlib >=1.2.13,<1.3.0a0 + license: TCL + license_family: BSD + size: 3318875 + timestamp: 1699202167581 +- kind: conda + name: toml + version: 0.10.2 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/toml-0.10.2-pyhd8ed1ab_0.tar.bz2 + sha256: f0f3d697349d6580e4c2f35ba9ce05c65dc34f9f049e85e45da03800b46139c1 + md5: f832c45a477c78bebd107098db465095 + depends: + - python >=2.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/toml + size: 18433 + timestamp: 1604308660817 +- kind: conda + name: tomli + version: 2.0.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/tomli-2.0.1-pyhd8ed1ab_0.tar.bz2 + sha256: 4cd48aba7cd026d17e86886af48d0d2ebc67ed36f87f6534f4b67138f5a5a58f + md5: 5844808ffab9ebdb694585b50ba02a96 + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/tomli + size: 15940 + timestamp: 1644342331069 +- kind: conda + name: tomli-w + version: 1.0.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/tomli-w-1.0.0-pyhd8ed1ab_0.tar.bz2 + sha256: efb5f78a224c4bb14aab04690c9912256ea12c3a8b8413e60167573ce1282b02 + md5: 73506d1ab4202481841c68c169b7ef6c + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/tomli-w + size: 10052 + timestamp: 1638551820635 +- kind: conda + name: tomlkit + version: 0.12.4 + build: pyha770c72_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/tomlkit-0.12.4-pyha770c72_0.conda + sha256: 8d45c266bf919788abacd9828f4a2101d7216f6d4fc7c8d3417034fe0d795a18 + md5: 37c47ea93ef00dd80d880fc4ba21256a + depends: + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/tomlkit + size: 37173 + timestamp: 1709043886347 +- kind: conda + name: trove-classifiers + version: 2024.4.10 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/trove-classifiers-2024.4.10-pyhd8ed1ab_0.conda + sha256: cbc8e5c5f82b1eeff7aa21aaff77757336c1e6d64a4255b071c783acd60f4618 + md5: 9622d541e2314c0207bebdc0359fa478 + depends: + - python >=3.7 + license: Apache-2.0 + license_family: Apache + purls: + - pkg:pypi/trove-classifiers + size: 18444 + timestamp: 1712814840654 +- kind: conda + name: twine + version: 5.0.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/twine-5.0.0-pyhd8ed1ab_0.conda + sha256: 8dd14197546abf76980994db8d4e0ac861b395750d2968d259a38e80ed3e8013 + md5: e3482aff5aebc831cba9ac6b74395c6c + depends: + - importlib_metadata >=3.6 + - keyring >=15.1 + - pkginfo >=1.8.1 + - python >=3.8 + - readme_renderer >=35.0 + - requests >=2.20 + - requests-toolbelt >=0.8.0,!=0.9.0 + - rfc3986 >=1.4.0 + - rich >=12.0.0 + - urllib3 >=1.26.0 + license: Apache-2.0 + license_family: Apache + purls: + - pkg:pypi/twine + size: 32579 + timestamp: 1707690947551 +- kind: conda + name: typing_extensions + version: 4.11.0 + build: pyha770c72_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/typing_extensions-4.11.0-pyha770c72_0.conda + sha256: a7e8714d14f854058e971a6ed44f18cc37cc685f98ddefb2e6b7899a0cc4d1a2 + md5: 6ef2fc37559256cf682d8b3375e89b80 + depends: + - python >=3.8 + license: PSF-2.0 + license_family: PSF + purls: + - pkg:pypi/typing-extensions + size: 37583 + timestamp: 1712330089194 +- kind: conda + name: tzdata + version: 2024a + build: h0c530f3_0 + subdir: noarch + noarch: generic + url: https://conda.anaconda.org/conda-forge/noarch/tzdata-2024a-h0c530f3_0.conda + sha256: 7b2b69c54ec62a243eb6fba2391b5e443421608c3ae5dbff938ad33ca8db5122 + md5: 161081fc7cec0bfda0d86d7cb595f8d8 + license: LicenseRef-Public-Domain + size: 119815 + timestamp: 1706886945727 +- kind: conda + name: ukkonen + version: 1.0.1 + build: py312h389731b_4 + build_number: 4 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/ukkonen-1.0.1-py312h389731b_4.conda + sha256: 7336cf66feba973207f4903c20b05c3c82e351246df4b6113f72d92b9ee55b81 + md5: 6407429e0969b58b8717dbb4c6c15513 + depends: + - cffi + - libcxx >=15.0.7 + - python >=3.12.0rc3,<3.13.0a0 + - python >=3.12.0rc3,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ukkonen + size: 13948 + timestamp: 1695549890285 +- kind: conda + name: ukkonen + version: 1.0.1 + build: py312h8572e83_4 + build_number: 4 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/ukkonen-1.0.1-py312h8572e83_4.conda + sha256: f9a4384d466f4d8b5b497d951329dd4407ebe02f8f93456434e9ab789d6e23ce + md5: 52c9e25ee0a32485a102eeecdb7eef52 + depends: + - cffi + - libgcc-ng >=12 + - libstdcxx-ng >=12 + - python >=3.12.0rc3,<3.13.0a0 + - python_abi 3.12.* *_cp312 + license: MIT + license_family: MIT + purls: + - pkg:pypi/ukkonen + size: 14050 + timestamp: 1695549556745 +- kind: conda + name: urllib3 + version: 2.2.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/urllib3-2.2.1-pyhd8ed1ab_0.conda + sha256: d4009dcc9327684d6409706ce17656afbeae690d8522d3c9bc4df57649a352cd + md5: 08807a87fa7af10754d46f63b368e016 + depends: + - brotli-python >=1.0.9 + - pysocks >=1.5.6,<2.0,!=1.5.7 + - python >=3.7 + license: MIT + license_family: MIT + purls: + - pkg:pypi/urllib3 + size: 94669 + timestamp: 1708239595549 +- kind: conda + name: userpath + version: 1.7.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/userpath-1.7.0-pyhd8ed1ab_0.tar.bz2 + sha256: c8cbddd625340e1b00b53bafabc764526ee85f7ddb91018424bab0eea057796d + md5: 5bf074c9253a3bf914becfc50757406f + depends: + - click + - python >=3.6 + license: MIT + license_family: MIT + purls: + - pkg:pypi/userpath + size: 17423 + timestamp: 1632758637093 +- kind: conda + name: uv + version: 0.1.39 + build: h0ea3d13_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/uv-0.1.39-h0ea3d13_0.conda + sha256: 763d149b6f4f5c70c91e4106d3a48409c48283ed2e27392578998fb2441f23d8 + md5: c3206e7ca254e50b3556917886f9b12b + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + license: Apache-2.0 OR MIT + size: 11891252 + timestamp: 1714233659570 +- kind: conda + name: uv + version: 0.1.39 + build: h4dd2748_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/uv-0.1.39-h4dd2748_0.conda + sha256: aa67169938fb547d9572a1ae1212c26509c304c22e0ba1958735bd55b78001a2 + md5: 0fc74a6d575456de2b3d677a61bc6302 + depends: + - __osx >=11.0 + - libcxx >=16 + constrains: + - __osx >=11.0 + license: Apache-2.0 OR MIT + size: 8766887 + timestamp: 1714234774736 +- kind: conda + name: versioningit + version: 3.1.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/versioningit-3.1.1-pyhd8ed1ab_0.conda + sha256: 9f90b1e10aae23761464407f2535de9b905df92a2d2c564a7f7fe3c64735dcbf + md5: d471782c9969774528f6d62ebc8a06d8 + depends: + - importlib-metadata >=3.6 + - packaging >=17.1 + - python >=3.7 + - tomli >=1.2,<3.0 + license: MIT + license_family: MIT + purls: + - pkg:pypi/versioningit + size: 161217 + timestamp: 1714400685966 +- kind: conda + name: virtualenv + version: 20.26.1 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/virtualenv-20.26.1-pyhd8ed1ab_0.conda + sha256: d603f8608f353a7aaa794c00bd3df71aafd5b56bf53af3e9c3dfe135203a4f33 + md5: 4e1cd2faf006a6e62c148f95cef0cac2 + depends: + - distlib <1,>=0.3.7 + - filelock <4,>=3.12.2 + - platformdirs <5,>=3.9.1 + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/virtualenv + size: 3459994 + timestamp: 1714439521015 +- kind: conda + name: wmctrl + version: '0.5' + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/wmctrl-0.5-pyhd8ed1ab_0.conda + sha256: b7526024b323b43ab8af687adeb6ee8f40aba70a9ee5939317d1b6b50e050061 + md5: eee592c2bd3901849b3732ff1da58049 + depends: + - attrs + - python >=3 + license: MIT + license_family: MIT + purls: + - pkg:pypi/wmctrl + size: 10415 + timestamp: 1695590958853 +- kind: conda + name: xdg + version: 6.0.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/xdg-6.0.0-pyhd8ed1ab_0.conda + sha256: d42e6523ae7b552faf24f0b9a8d6ad95f41720fccdaa1be548abd2ece5c095e0 + md5: b484cd48062264f3cc16b58572c21411 + depends: + - python >=3.6 + license: ISC + purls: + - pkg:pypi/xdg + size: 9808 + timestamp: 1677532487640 +- kind: conda + name: xz + version: 5.2.6 + build: h166bdaf_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/xz-5.2.6-h166bdaf_0.tar.bz2 + sha256: 03a6d28ded42af8a347345f82f3eebdd6807a08526d47899a42d62d319609162 + md5: 2161070d867d1b1204ea749c8eec4ef0 + depends: + - libgcc-ng >=12 + license: LGPL-2.1 and GPL-2.0 + size: 418368 + timestamp: 1660346797927 +- kind: conda + name: xz + version: 5.2.6 + build: h57fd34a_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/xz-5.2.6-h57fd34a_0.tar.bz2 + sha256: 59d78af0c3e071021cfe82dc40134c19dab8cdf804324b62940f5c8cd71803ec + md5: 39c6b54e94014701dd157f4f576ed211 + license: LGPL-2.1 and GPL-2.0 + size: 235693 + timestamp: 1660346961024 +- kind: conda + name: yaml + version: 0.2.5 + build: h3422bc3_2 + build_number: 2 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/yaml-0.2.5-h3422bc3_2.tar.bz2 + sha256: 93181a04ba8cfecfdfb162fc958436d868cc37db504c58078eab4c1a3e57fbb7 + md5: 4bb3f014845110883a3c5ee811fd84b4 + license: MIT + license_family: MIT + size: 88016 + timestamp: 1641347076660 +- kind: conda + name: yaml + version: 0.2.5 + build: h7f98852_2 + build_number: 2 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/yaml-0.2.5-h7f98852_2.tar.bz2 + sha256: a4e34c710eeb26945bdbdaba82d3d74f60a78f54a874ec10d373811a5d217535 + md5: 4cb3ad778ec2d5a7acbdf254eb1c42ae + depends: + - libgcc-ng >=9.4.0 + license: MIT + license_family: MIT + size: 89141 + timestamp: 1641346969816 +- kind: conda + name: zipp + version: 3.17.0 + build: pyhd8ed1ab_0 + subdir: noarch + noarch: python + url: https://conda.anaconda.org/conda-forge/noarch/zipp-3.17.0-pyhd8ed1ab_0.conda + sha256: bced1423fdbf77bca0a735187d05d9b9812d2163f60ab426fc10f11f92ecbe26 + md5: 2e4d6bc0b14e10f895fc6791a7d9b26a + depends: + - python >=3.8 + license: MIT + license_family: MIT + purls: + - pkg:pypi/zipp + size: 18954 + timestamp: 1695255262261 +- kind: conda + name: zstandard + version: 0.22.0 + build: py312h7975427_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.22.0-py312h7975427_0.conda + sha256: af2e7339e65d85c02c0097e16961bf8aa6d2eb0705644ddd82f722583bd6b134 + md5: 10c282af2c570a5a52173fd571693ec6 + depends: + - cffi >=1.11 + - python >=3.12,<3.13.0a0 + - python >=3.12,<3.13.0a0 *_cpython + - python_abi 3.12.* *_cp312 + - zstd >=1.5.5,<1.5.6.0a0 + - zstd >=1.5.5,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/zstandard + size: 331245 + timestamp: 1698830496330 +- kind: conda + name: zstandard + version: 0.22.0 + build: py312hd58854c_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.22.0-py312hd58854c_0.conda + sha256: da76216a4868d7f1a777c726e090a1acb0225a30905170ce042870016b874fe8 + md5: 6532ce0d6b7b6c77081ba102d3540a81 + depends: + - cffi >=1.11 + - libgcc-ng >=12 + - python >=3.12,<3.13.0a0 + - python_abi 3.12.* *_cp312 + - zstd >=1.5.5,<1.5.6.0a0 + - zstd >=1.5.5,<1.6.0a0 + license: BSD-3-Clause + license_family: BSD + purls: + - pkg:pypi/zstandard + size: 415099 + timestamp: 1698830281446 +- kind: conda + name: zstd + version: 1.5.5 + build: h4f39d0f_0 + subdir: osx-arm64 + url: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.5-h4f39d0f_0.conda + sha256: 7e1fe6057628bbb56849a6741455bbb88705bae6d6646257e57904ac5ee5a481 + md5: 5b212cfb7f9d71d603ad891879dc7933 + depends: + - libzlib >=1.2.13,<1.3.0a0 + license: BSD-3-Clause + license_family: BSD + size: 400508 + timestamp: 1693151393180 +- kind: conda + name: zstd + version: 1.5.5 + build: hfc55251_0 + subdir: linux-64 + url: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.5-hfc55251_0.conda + sha256: 607cbeb1a533be98ba96cf5cdf0ddbb101c78019f1fda063261871dad6248609 + md5: 04b88013080254850d6c01ed54810589 + depends: + - libgcc-ng >=12 + - libstdcxx-ng >=12 + - libzlib >=1.2.13,<1.3.0a0 + license: BSD-3-Clause + license_family: BSD + size: 545199 + timestamp: 1693151163452 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..731d68e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,219 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +[build-system] +requires = ["hatchling", "versioningit"] +build-backend = "hatchling.build" + +[project] +name = "clapper" +dynamic = ["version"] +requires-python = ">=3.10" +description = "Configuration Support for Python Packages and CLIs" +readme = "README.md" +license = "BSD-3-Clause" +authors = [{ name = "Andre Anjos", email = "andre.anjos@idiap.ch" }] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Natural Language :: English", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = ["click>=8", "tomli", "tomli-w", "xdg"] + +[project.urls] +documentation = "https://clapper.readthedocs.io/en/latest/" +homepage = "https://pypi.org/project/clapper" +repository = "https://gitlab.idiap.ch/software/clapper" +changelog = "https://gitlab.idiap.ch/software/clapper/-/releases" + +[project.optional-dependencies] +qa = ["pre-commit"] +doc = [ + "sphinx", + "furo", + "sphinx-autodoc-typehints", + "sphinxcontrib-programoutput", + "auto-intersphinx", + "sphinx-copybutton", + "sphinx-inline-tabs", +] +test = ["pytest", "pytest-cov"] + +[project.entry-points."clapper.test.config"] +first = "tests.data.basic_config" +first-a = "tests.data.basic_config:a" +first-b = "tests.data.basic_config:b" +second = "tests.data.second_config" +second-b = "tests.data.second_config:b" +second-c = "tests.data.second_config:c" +complex = "tests.data.complex" +complex-var = "tests.data.complex:cplx" +verbose-config = "tests.data.verbose_config" +error-config = "tests.data.doesnt_exist" + +[tool.pixi.project] +channels = ["conda-forge"] +platforms = ["linux-64", "osx-arm64"] + +[tool.pixi.system-requirements] +linux = "4.19.0" + +[tool.pixi.dependencies] +click = ">=8" +tomli = "*" +tomli-w = "*" +xdg = "*" + +[tool.pixi.feature.self.pypi-dependencies] +clapper = { path = ".", editable = true } + +[tool.pixi.feature.py311.dependencies] +python = "~=3.11.0" + +[tool.pixi.feature.py312.dependencies] +python = "~=3.12.0" + +[tool.pixi.feature.qa.dependencies] +pre-commit = "*" +ruff = "*" +reuse = "*" + +[tool.pixi.feature.qa.tasks] +qa-install = "pre-commit install" +qa = "pre-commit run --all-files" +qa-ci = "pre-commit run --all-files --show-diff-on-failure --verbose" + +[tool.pixi.feature.doc.dependencies] +sphinx = "*" +furo = "*" +sphinx-autodoc-typehints = "*" +sphinxcontrib-programoutput = "*" +auto-intersphinx = "*" +sphinx-copybutton = "*" +sphinx-inline-tabs = "*" + +[tool.pixi.feature.doc.tasks] +doc-clean = "rm -rf doc/api && rm -rf html" +doc = "sphinx-build -aEW doc html" +doctest = "sphinx-build -aEb doctest doc html/doctest" + +[tool.pixi.feature.test.dependencies] +pytest = "*" +pytest-cov = "*" + +[tool.pixi.feature.test.tasks] +test = "pytest -sv tests/" +test-ci = "pytest -sv --cov-report 'html:html/coverage' --cov-report 'xml:coverage.xml' --junitxml 'junit-coverage.xml' --ignore '.profile' tests/" + +[tool.pixi.feature.build.dependencies] +hatch = "*" +versioningit = "*" +twine = "*" + +[tool.pixi.feature.build.tasks] +build = "hatch build" +check = "twine check dist/*" +upload = "twine upload dist/*" + +[tool.pixi.feature.dev.dependencies] +pdbpp = "*" +uv = "*" + +[tool.pixi.environments] +default = { features = ["qa", "build", "doc", "test", "dev", "py312", "self"] } +qa-ci = { features = ["qa", "py312"] } +build-ci = { features = ["build", "py312"] } +test-ci-alternative = { features = ["test", "py311", "self"] } + +[tool.hatch.version] +source = "versioningit" + +[tool.versioningit.next-version] +method = "smallest" + +[tool.versioningit.format] +# Example formatted version: 1.2.4.dev42+ge174a1f +distance = "{next_version}.dev{distance}+{vcs}{rev}" +# Example formatted version: 1.2.4.dev42+ge174a1f.d20230922 +distance-dirty = "{next_version}.dev{distance}+{vcs}{rev}.d{build_date:%Y%m%d}" + +[tool.hatch.build.targets.sdist] +include = [ + "src/**/*.py", + "tests/**/*.py", + "tests/**/*.cfg", + "doc/**/*.rst", + "doc/**/*.txt", + "doc/**/*.py", + "LICENSES/*.txt", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/clapper"] + +[tool.ruff] +line-length = 88 +target-version = "py310" + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint] +select = [ + "A", # https://docs.astral.sh/ruff/rules/#flake8-builtins-a + "COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com + "D", # https://docs.astral.sh/ruff/rules/#pydocstyle-d + "E", # https://docs.astral.sh/ruff/rules/#error-e + "F", # https://docs.astral.sh/ruff/rules/#pyflakes-f + "I", # https://docs.astral.sh/ruff/rules/#isort-i + "ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc + "LOG", # https://docs.astral.sh/ruff/rules/#flake8-logging-log + "N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n + "PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth + "Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q + "RET", # https://docs.astral.sh/ruff/rules/#flake8-return-ret + "SLF", # https://docs.astral.sh/ruff/rules/#flake8-self-slf + "T10", # https://docs.astral.sh/ruff/rules/#flake8-debugger-t10 + "T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20 + "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up + "W", # https://docs.astral.sh/ruff/rules/#warning-w + #"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g + #"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn +] +ignore = [ + "COM812", # https://docs.astral.sh/ruff/rules/missing-trailing-comma/ + "D100", # https://docs.astral.sh/ruff/rules/undocumented-public-module/ + "D102", # https://docs.astral.sh/ruff/rules/undocumented-public-method/ + "D104", # https://docs.astral.sh/ruff/rules/undocumented-public-package/ + "D105", # https://docs.astral.sh/ruff/rules/undocumented-magic-method/ + "D107", # https://docs.astral.sh/ruff/rules/undocumented-public-init/ + "D203", # https://docs.astral.sh/ruff/rules/one-blank-line-before-class/ + "D202", # https://docs.astral.sh/ruff/rules/no-blank-line-after-function/ + "D205", # https://docs.astral.sh/ruff/rules/blank-line-after-summary/ + "D212", # https://docs.astral.sh/ruff/rules/multi-line-summary-first-line/ + "D213", # https://docs.astral.sh/ruff/rules/multi-line-summary-second-line/ + "E302", # https://docs.astral.sh/ruff/rules/blank-lines-top-level/ + "E402", # https://docs.astral.sh/ruff/rules/module-import-not-at-top-of-file/ + "E501", # https://docs.astral.sh/ruff/rules/line-too-long/ + "ISC001", # https://docs.astral.sh/ruff/rules/single-line-implicit-string-concatenation/ +] + +[tool.ruff.lint.isort] +# Use a single line between direct and from import. +lines-between-types = 1 + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.ruff.lint.per-file-ignores] +"tests/*.py" = ["D", "E501"] +"doc/conf.py" = ["D"] + +[tool.pytest.ini_options] +addopts = ["--cov=clapper", "--cov-report=term-missing", "--import-mode=append"] +junit_logging = "all" +junit_log_passing_tests = false diff --git a/src/clapper/__init__.py b/src/clapper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/clapper/click.py b/src/clapper/click.py new file mode 100644 index 0000000..38084a2 --- /dev/null +++ b/src/clapper/click.py @@ -0,0 +1,874 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause +"""Helpers to build command-line interfaces (CLI) via :py:mod:`click`.""" + +import functools +import inspect +import logging +import pathlib +import pprint +import shutil +import time +import typing + +from importlib.metadata import EntryPoint + +import click +import tomli + +from click.core import ParameterSource + +from .config import load, mod_to_context, resource_keys +from .rc import UserDefaults + +module_logger = logging.getLogger(__name__) +"""Module logger.""" + +_COMMON_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) +"""Common click context settings.""" + + +def verbosity_option( + logger: logging.Logger, + short_name: str = "v", + name: str = "verbose", + dflt: int = 0, + **kwargs: typing.Any, +) -> typing.Callable[..., typing.Any]: + """Click-option decorator that adds a ``-v``/``--verbose`` option to a cli. + + This decorator adds a click option to your CLI to set the log-level on a + provided :py:class:`logging.Logger`. You must specifically determine the + logger that will be affected by this CLI option, via the ``logger`` option. + + .. code-block:: python + + @verbosity_option(logger=logger) + + The verbosity option has the "count" type, and has a default value of 0. + At each time you provide ``-v`` options on the command-line, this value is + increased by one. For example, a CLI setting of ``-vvv`` will set the + value of this option to 3. This is the mapping between the value of this + option (count of ``-v`` CLI options passed) and the log-level set at the + provided logger: + + * 0 (no ``-v`` option provided): ``logger.setLevel(logging.ERROR)`` + * 1 (``-v``): ``logger.setLevel(logging.WARNING)`` + * 2 (``-vv``): ``logger.setLevel(logging.INFO)`` + * 3 (``-vvv`` or more): ``logger.setLevel(logging.DEBUG)`` + + + Arguments: + + logger: The :py:class:`logging.Logger` to be set. + + short_name: Short name of the option. If not set, then use ``v`` + + name: Long name of the option. If not set, then use ``verbose`` -- + this will also become the name of the contextual parameter for click. + + dlft: The default verbosity level to use (defaults to 0). + + **kwargs: Further keyword-arguments to be forwarded to the underlying + :py:func:`click.option` + + + Returns + ------- + A callable, that follows the :py:mod:`click`-framework policy for + option decorators. Use it accordingly. + """ + + def custom_verbosity_option(f): + def callback(ctx, param, value): + ctx.meta[name] = value + log_level: int = { # type: ignore + 0: logging.ERROR, + 1: logging.WARNING, + 2: logging.INFO, + 3: logging.DEBUG, + }[value] + + logger.setLevel(log_level) + logger.debug(f'Level of Logger("{logger.name}") was set to {log_level}') + return value + + return click.option( + f"-{short_name}", + f"--{name}", + count=True, + type=click.IntRange(min=0, max=3, clamp=True), + default=dflt, + show_default=True, + help=( + f"Increase the verbosity level from 0 (only error and " + f"critical) messages will be displayed, to 1 (like 0, but adds " + f"warnings), 2 (like 1, but adds info messags), and 3 (like 2, " + f"but also adds debugging messages) by adding the --{name} " + f"option as often as desired (e.g. '-vvv' for debug)." + ), + callback=callback, + **kwargs, + )(f) + + return custom_verbosity_option + + +class ConfigCommand(click.Command): + """A :py:class:`click.Command` that can read options from config files. + + .. warning:: + + In order to use this class, you **have to** use the + :py:class:`ResourceOption` class also. + + + Arguments: + + name: The name to be used for the configuration argument + + *args: Unnamed parameters passed to :py:class:`click.Command` + + help: Help message associated with this command + + entry_point_group: Name of the entry point group from which + entry-points will be searched + + **kwargs: Named parameters passed to :py:class:`click.Command` + """ + + config_argument_name: str + """The name of the config argument.""" + + entry_point_group: str + """The name of entry point that will be used to load the config files.""" + + def __init__( + self, + name: str, + *args: tuple, + help: str | None = None, # noqa: A002 + entry_point_group: str | None = None, + **kwargs: typing.Any, + ) -> None: + self.entry_point_group = entry_point_group + configs_argument_name = "CONFIG" + + # Augment help for the config file argument + self.extra_help = f"""\n\nIt is possible to pass one or several Python +files (or names of ``{entry_point_group}`` entry points or module names) as +{configs_argument_name} arguments to the command line which contain the parameters listed below as Python variables. The options through the command-line (see below) +will override the values of configuration files. You can run this command with +`` -H example_config.py`` to create a template config file.""" + help = (help or "").rstrip() + self.extra_help # noqa: A001 + super().__init__(name, *args, help=help, **kwargs) + + # Add the config argument to the command + def configs_argument_callback(ctx, param, value): + config_context = load(value, entry_point_group=self.entry_point_group) + + config_context = mod_to_context(config_context) + ctx.config_context = config_context + module_logger.debug("Augmenting click context with config context") + return value + + click.argument( + configs_argument_name, + nargs=-1, + callback=configs_argument_callback, + is_eager=True, # runs first and unconditionally + )(self) + + # Option for config file generation + click.option( + "-H", + "--dump-config", + type=click.File(mode="wt"), + help="Name of the config file to be generated", + is_eager=True, + callback=self.dump_config, + )(self) + + def dump_config( + self, + ctx: typing.Any, + param: typing.Any, + value: typing.TextIO | None, + ) -> None: + """Generate configuration file from parameters and context. + + Using this function will conclude the command-line execution. + + + Arguments: + + ctx: Click context + """ + + config_file = value + if config_file is None: + return + + module_logger.debug(f"Generating configuration file `{config_file}'...") + config_file.write('"""') + config_file.write( + f"Configuration file automatically generated at " + f"{time.strftime('%d/%m/%Y')}.\n\n{ctx.command_path}\n" + ) + + if self.help: + h = self.help.replace(self.extra_help, "").replace("\b\n", "") + config_file.write(f"\n{h.rstrip()}") + + if self.epilog: + config_file.write("\n\n{}".format(self.epilog.replace("\b\n", ""))) + + config_file.write('\n"""\n') + + for param in self.params: + if not isinstance(param, ResourceOption): + # we can only handle ResourceOptions + continue + + config_file.write(f"\n# {param.name} = {str(param.default)}\n") + config_file.write('"""') + + if param.required: + begin, dflt = "Required parameter", "" + else: + begin, dflt = ( + "Optional parameter", + f" [default: {param.default}]", + ) + + config_file.write(f"{begin}: {param.name} ({', '.join(param.opts)}){dflt}") + + if param.help is not None: + config_file.write(f"\n{param.help}") + + if ( + isinstance(param, ResourceOption) + and param.entry_point_group is not None + ): + config_file.write( + f"\nRegistered entries are: " + f"{resource_keys(param.entry_point_group)}" + ) + + config_file.write('"""\n') + + click.echo(f"Configuration file `{config_file.name}' was written; exiting") + + config_file.close() + ctx.exit() + + +class CustomParamType(click.ParamType): + """Custom parameter class allowing click to receive complex Python types as + parameters. + """ + + name = "custom" + + +class ResourceOption(click.Option): + """An extended :py:class:`click.Option` that automatically loads resources + from config files. + + This class comes with two different functionalities that are independent and + could be combined: + + 1. If used in commands that are inherited from :py:class:`ConfigCommand`, + it will lookup inside the config files (that are provided as argument to + the command) to resolve its value. Values given explicitly in the + command line take precedence. + + 2. If ``entry_point_group`` is provided, it will treat values given to it + (by any means) as resources to be loaded. Loading is done using + :py:func:`.config.load`. Check :ref:`clapper.config.resource` for more + details on this topic. The final value cannot be a string. + + You may use this class in three ways: + + 1. Using this class (without using :py:class:`ConfigCommand`) AND + (providing ``entry_point_group``). + 2. Using this class (with :py:class:`ConfigCommand`) AND (providing + `entry_point_group`). + 3. Using this class (with :py:class:`ConfigCommand`) AND (without providing + `entry_point_group`). + + Using this class without :py:class:`ConfigCommand` and without providing + `entry_point_group` does nothing and is not allowed. + """ + + entry_point_group: str | None + """If provided, the strings values to this option are assumed to be entry + points from ``entry_point_group`` that need to be loaded. + + This may be different than the wrapping :py:class:`ConfigCommand`. + """ + + string_exceptions: list[str] | None + """If provided and ``entry_point_group`` is provided, the code will not + treat strings in ``string_exceptions`` as entry points and does not try to + load them.""" + + def __init__( + self, + param_decls=None, + show_default=False, + prompt=False, + confirmation_prompt=False, + hide_input=False, + is_flag=None, + flag_value=None, + multiple=False, + count=False, + allow_from_autoenv=True, + type=None, # noqa: A002 + help=None, # noqa: A002 + entry_point_group=None, + required=False, + string_exceptions=None, + **kwargs, + ) -> None: + # By default, if unspecified, click options are converted to strings. + # By using ResourceOption's, however, we allow for complex user types + # to be set into options. So, if no specific ``type``, a ``default``, + # the ``count`` flag, or ``is_flag`` is given, we assume this is a + # "custom" parameter type, and do not convert values to strings. + if ( + (type is None) + and (kwargs.get("default") is None) + and (count is False) + and (is_flag is None) + ): + type = CustomParamType() # noqa: A001 + + self.entry_point_group = entry_point_group + if entry_point_group is not None: + name, _, _ = self._parse_decls(param_decls, kwargs.get("expose_value")) + help = help or "" # noqa: A001 + help += ( # noqa: A001 + f" Can be a `{entry_point_group}' entry point, a module name, or " + f"a path to a Python file which contains a variable named `{name}'." + ) + help = help.format(entry_point_group=entry_point_group, name=name) # noqa: A001 + + super().__init__( + param_decls=param_decls, + show_default=show_default, + prompt=prompt, + confirmation_prompt=confirmation_prompt, + hide_input=hide_input, + is_flag=is_flag, + flag_value=flag_value, + multiple=multiple, + count=count, + allow_from_autoenv=allow_from_autoenv, + type=type, + help=help, + required=required, + **kwargs, + ) + self.string_exceptions = string_exceptions or [] + + def consume_value( + self, ctx: click.Context, opts: dict + ) -> tuple[typing.Any, ParameterSource]: + """Retrieve value for parameter from appropriate context. + + This method will retrive the value of its own parameter from the + appropriate context, by trying various sources. + + Parameters + ---------- + ctx + The click context to retrieve the value from + opts + command-line options, eventually passed by the user + + + Returns + ------- + A tuple containing the parameter value (of any type) and the source + it used to retrieve it. + """ + + if (not hasattr(ctx, "config_context")) and self.entry_point_group is None: + raise TypeError( + "The ResourceOption class is not meant to be used this way. " + "See package documentation for details." + ) + + module_logger.debug(f"consuming resource option for {self.name}") + value = opts.get(self.name) + source = ParameterSource.COMMANDLINE + + # if value is not given from command line, lookup the config files given as + # arguments (not options). + if value is None: + # if this class is used with the ConfigCommand class. This is not always + # true. + if hasattr(ctx, "config_context"): + value = ctx.config_context.get(self.name) + + # if not from config files, lookup the environment variables + if value is None: + value = self.value_from_envvar(ctx) + source = ParameterSource.ENVIRONMENT + + # if not from environment variables, lookup the default value + if value is None: + value = ctx.lookup_default(self.name) + source = ParameterSource.DEFAULT_MAP + + if value is None: + value = self.get_default(ctx) + source = ParameterSource.DEFAULT + + return value, source + + def type_cast_value(self, ctx: click.Context, value: typing.Any) -> typing.Any: + """Convert and validate a value against the option's type. + + This method considers the option's ``type``, ``multiple``, and ``nargs``. + Furthermore, if the an ``entry_point_group`` is provided, it will load + it. + + Arguments: + + ctx: The click context to be used for casting the value + + value: The actual value, that needs to be cast + + + Returns + ------- + The cast value + """ + value = super().type_cast_value(ctx, value) + + # if the value is a string and an entry_point_group is provided, load it + if self.entry_point_group is not None: + while isinstance(value, str) and value not in self.string_exceptions: + value = load( + [value], + entry_point_group=self.entry_point_group, + attribute_name=self.name, + ) + + return value + + +class AliasedGroup(click.Group): + """Class that handles prefix aliasing for commands. + + Basically just implements get_command that is used by click to choose the + command based on the name. + + Example + ------- + To enable prefix aliasing of commands for a given group, + just set ``cls=AliasedGroup`` parameter in click.group decorator. + """ + + def get_command(self, ctx, cmd_name): + """get_command with prefix aliasing.""" + rv = click.Group.get_command(self, ctx, cmd_name) + if rv is not None: + return rv + matches = [x for x in self.list_commands(ctx) if x.startswith(cmd_name)] + if not matches: + return None + + if len(matches) == 1: + return click.Group.get_command(self, ctx, matches[0]) + + ctx.fail(f"Too many matches: {', '.join(sorted(matches))}") # noqa: RET503 + + +def user_defaults_group( + logger: logging.Logger, + config: UserDefaults, +) -> typing.Callable[..., typing.Any]: + """Add a command group to read/write RC configuration. + + This decorator adds a whole command group to a user predefined function + which is part of the user's CLI. The command group allows the user to get + and set options through the command-line interface: + + .. code-block:: python + + import logging + from expose.rc import UserDefaults + from expose.click import user_defaults_group + + logger = logging.getLogger(__name__) + user_defaults = UserDefaults("~/.myapprc") + ... + + + @user_defaults_group(logger=logger, config=user_defaults) + def rc(**kwargs): + '''Use this command to affect the global user configuration.''' + pass + + + Then use it like this: + + .. code-block:: shell + + $ user-cli rc --help + usage: ... + """ + + def group_decorator( + func: typing.Callable[..., typing.Any], + ) -> typing.Callable[..., typing.Any]: + @click.group( + cls=AliasedGroup, + no_args_is_help=True, + context_settings=_COMMON_CONTEXT_SETTINGS, + ) + @verbosity_option(logger=logger) + @functools.wraps(func) + def group_wrapper(**kwargs): + return func(**kwargs) + + @group_wrapper.command(context_settings=_COMMON_CONTEXT_SETTINGS) + @verbosity_option(logger=logger) + def show(**_: typing.Any) -> None: + """Show the user-defaults file contents.""" + click.echo(str(config).strip()) + + @group_wrapper.command( + no_args_is_help=True, + context_settings=_COMMON_CONTEXT_SETTINGS, + ) + @click.argument("key") + @verbosity_option(logger=logger) + def get(key: str, **_: typing.Any) -> None: + """Print a key from the user-defaults file. + + Retrieves the value of the requested KEY and displays it. The KEY + may contain dots (``.``) to access values from subsections in the + TOML_ document. + """ + try: + click.echo(config[key]) + except KeyError: + raise click.ClickException( + f"Cannot find object named `{key}' at `{config.path}'", + ) + + @group_wrapper.command( + name="set", + no_args_is_help=True, + context_settings=_COMMON_CONTEXT_SETTINGS, + ) + @click.argument("key") + @click.argument("value") + @verbosity_option(logger=logger) + def set_(key: str, value: str, **_: typing.Any) -> None: + """Set the value for a key on the user-defaults file. + + If ``key`` contains dots (``.``), then this sets nested subsection + variables on the configuration file. Values are parsed and + translated following the rules of TOML_. + + .. warning:: + + This command will override the current configuration file and my + erase any user comments added by hand. To avoid this, simply + edit your configuration file by hand. + """ + try: + tmp = tomli.loads(f"v = {value}") + value = tmp["v"] + except tomli.TOMLDecodeError: + pass + + try: + config[key] = value + config.write() + except KeyError: + logger.error( + f"Cannot set object named `{key}' at `{config.path}'", + exc_info=True, + ) + raise click.ClickException( + f"Cannot set object named `{key}' at `{config.path}'", + ) + + @group_wrapper.command( + no_args_is_help=True, context_settings=_COMMON_CONTEXT_SETTINGS + ) + @click.argument("key") + @verbosity_option(logger=logger) + def rm(key: str, **_: typing.Any) -> None: + """Remove the given key from the configuration file. + + This command will remove the KEY from the configuration file. If + the input key corresponds to a section in the configuration file, + then the whole configuration section will be removed. + + .. warning:: + + This command will override the current configuration file and my + erase any user comments added by hand. To avoid this, simply + edit your configuration file by hand. + """ + try: + del config[key] + config.write() + except KeyError: + logger.error( + f"Cannot delete object named `{key}' at `{config.path}'", + exc_info=True, + ) + raise click.ClickException( + f"Cannot delete object named `{key}' at `{config.path}'", + ) + + return group_wrapper + + return group_decorator + + +def config_group( + logger: logging.Logger, + entry_point_group: str, +) -> typing.Callable[..., typing.Any]: + """Add a command group to list/describe/copy job configurations. + + This decorator adds a whole command group to a user predefined function + which is part of the user's CLI. The command group provdes an interface to + list, fully describe or locally copy configuration files distributed with + the package. Commands accept both entry-point or module names to be + provided as input. + + .. code-block:: python + + import logging + from expose.click import config_group + + logger = logging.getLogger(__name__) + ... + + + @config_group(logger=logger, entry_point_group="mypackage.config") + def config(**kwargs): + '''Use this command to list/describe/copy config files.''' + pass + + + Then use it like this: + + .. code-block:: shell + + $ user-cli config --help + usage: ... + """ + + def group_decorator( + func: typing.Callable[..., typing.Any], + ) -> typing.Callable[..., typing.Any]: + @click.group(cls=AliasedGroup, context_settings=_COMMON_CONTEXT_SETTINGS) + @verbosity_option(logger=logger) + @functools.wraps(func) + def group_wrapper(**kwargs): + return func(**kwargs) + + @group_wrapper.command( + name="list", + context_settings=_COMMON_CONTEXT_SETTINGS, + ) + @click.pass_context + @verbosity_option(logger=logger) + def list_(ctx, **_: typing.Any): + """List installed configuration resources.""" + from importlib.metadata import entry_points # type: ignore + + entry_points: dict[str, EntryPoint] = { # type: ignore + e.name: e for e in entry_points(group=entry_point_group) + } + + # all modules with configuration resources + modules: set[str] = { + # note: k.module does not exist on Python < 3.9 + k.value.split(":")[0].rsplit(".", 1)[0] + for k in entry_points.values() # type: ignore + } + keep_modules: set[str] = set() + for k in sorted(modules): + if k not in keep_modules and not any( + k.startswith(to_keep) for to_keep in keep_modules + ): + keep_modules.add(k) + modules = keep_modules + + # sort data entries by originating module + entry_points_by_module: dict[str, dict[str, EntryPoint]] = {} + for k in modules: + entry_points_by_module[k] = {} + for name, ep in entry_points.items(): # type: ignore + # note: ep.module does not exist on Python < 3.9 + module = ep.value.split(":", 1)[0] # works on Python 3.8 + if module.startswith(k): + entry_points_by_module[k][name] = ep + + for config_type in sorted(entry_points_by_module): + # calculates the longest config name so we offset the printing + longest_name_length = max( + len(k) for k in entry_points_by_module[config_type].keys() + ) + + # set-up printing options + print_string = " %%-%ds %%s" % (longest_name_length,) + # 79 - 4 spaces = 75 (see string above) + description_leftover = 75 - longest_name_length + + click.echo(f"module: {config_type}") + for name in sorted(entry_points_by_module[config_type]): + ep = entry_points[name] # type: ignore + + if (ctx.parent.params["verbose"] >= 1) or ( + ctx.params["verbose"] >= 1 + ): + try: + obj = ep.load() + + if ":" in ep.value: # it's an object + summary = ( + f"[{type(obj).__name__}] {pprint.pformat(obj)}" + ) + summary = summary.replace("\n", " ") + else: # it's a whole module + summary = "[module] " + doc = inspect.getdoc(obj) + if doc is not None: + summary += doc.split("\n\n")[0] + summary = summary.replace("\n", " ") + else: + summary += "[undocumented]" + + except Exception as ex: + summary = "(cannot be loaded; add another -v for details)" + if (ctx.parent.params["verbose"] >= 2) or ( + ctx.params["verbose"] >= 2 + ): + logger.exception(ex) + + else: + summary = "" + + summary = ( + (summary[: (description_leftover - 3)] + "...") + if len(summary) > (description_leftover - 3) + else summary + ) + + click.echo(print_string % (name, summary)) + + @group_wrapper.command( + no_args_is_help=True, context_settings=_COMMON_CONTEXT_SETTINGS + ) + @click.pass_context + @click.argument( + "name", + required=True, + nargs=-1, + ) + @verbosity_option(logger=logger) + def describe(ctx, name, **_: typing.Any): + """Describe a specific configuration resource.""" + from importlib.metadata import entry_points # type: ignore + + entry_points: dict[str, EntryPoint] = { # type: ignore + e.name: e for e in entry_points(group=entry_point_group) + } + + for k in name: + if k not in entry_points: # type: ignore + logger.error(f"Cannot find configuration resource `{k}'") + continue + ep = entry_points[k] # type: ignore + click.echo(f"Configuration: {ep.name}") + click.echo(f"Python object: {ep.value}") + click.echo("") + mod = ep.load() + + if ":" not in ep.value: + if (ctx.parent.params["verbose"] >= 1) or ( + ctx.params["verbose"] >= 1 + ): + fname = inspect.getfile(mod) + click.echo("Contents:") + with pathlib.Path(fname).open() as f: + click.echo(f.read()) + else: # only output documentation, if module + doc = inspect.getdoc(mod) + if doc and doc.strip(): + click.echo("Documentation:") + click.echo(doc) + + @group_wrapper.command( + no_args_is_help=True, context_settings=_COMMON_CONTEXT_SETTINGS + ) + @click.argument( + "source", + required=True, + nargs=1, + ) + @click.argument( + "destination", + required=True, + nargs=1, + ) + @verbosity_option(logger=logger) + def copy(source, destination, **_: typing.Any): + """Copy a specific configuration resource so it can be modified + locally. + """ + from importlib.metadata import entry_points # type: ignore + + entry_points: dict[str, EntryPoint] = { # type: ignore + e.name: e for e in entry_points(group=entry_point_group) + } + + if source not in entry_points: # type: ignore + logger.error(f"Cannot find configuration resource `{source}'") + return 1 + ep = entry_points[source] # type: ignore + mod = ep.load() + src_name = inspect.getfile(mod) + logger.info(f"cp {src_name} -> {destination}") + shutil.copyfile(src_name, destination) + + return None + + return group_wrapper + + return group_decorator + + +def log_parameters(logger_handle: logging.Logger, ignore: tuple[str] | None = None): + """Log the click parameters with the logging module. + + Parameters + ---------- + logger + The :py:class:`logging.Logger` handle to write debug information into. + ignore + List of the parameters to ignore when logging. (Tuple) + """ + ignore = ignore or tuple() + ctx = click.get_current_context() + # do not sort the ctx.params dict. The insertion order is kept in Python 3 + # and is useful (but not necessary so works on Python 2 too). + for k, v in ctx.params.items(): + if k in ignore: + continue + logger_handle.debug("%s: %s", k, v) diff --git a/src/clapper/config.py b/src/clapper/config.py new file mode 100644 index 0000000..9d9a7b2 --- /dev/null +++ b/src/clapper/config.py @@ -0,0 +1,344 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause +"""Functionality to implement python-based config file parsing and loading.""" + +import logging +import pathlib +import pkgutil +import types +import typing + +from importlib.abc import FileLoader +from importlib.metadata import EntryPoint, entry_points + +logger = logging.getLogger(__name__) + +_LOADED_CONFIGS = [] +"""Small gambiarra (https://www.urbandictionary.com/define.php?term=Gambiarra) +to avoid the garbage collector to collect some already imported modules.""" + + +def _load_context(path: str, mod: types.ModuleType) -> types.ModuleType: + """Load the Python file as module, returns a resolved context. + + This function is implemented in a way that is both Python 2 and Python 3 + compatible. It does not directly load the python file, but reads its + contents in memory before Python-compiling it. It leaves no traces on the + file system. + + Arguments: + + path: The full path of the Python file to load the module contents from + + mod: A preloaded module to use as a default context for the next module + loading. You can create a new module using :py:mod:`types` as in: + + .. code-block:: python + + ctxt: dict[str, typing.Any] = {} + m = types.ModuleType("name") + m.__dict__.update(ctxt) + + ``ctxt`` is a python dictionary mapping strings to object values + representing the contents of the module to be created. + + + Returns + ------- + A python module with the fully resolved context + """ + # executes the module code on the context of previously imported modules + with pathlib.Path(path).open("rb") as f: + exec(compile(f.read(), path, "exec"), mod.__dict__) + + return mod + + +def _get_module_filename(module_name: str) -> str | None: + """Resolve a module name to an actual Python file. + + This function will return the path to the file containing the module named + at ``module_name``. Values for this parameter are dot-separated module + names such as ``expose.config``. + + + Arguments: + + module_name: The name of the module to search + + + Returns + ------- + The path that corresponds to file implementing the provided module name + """ + try: + loader = pkgutil.get_loader(module_name) + if isinstance(loader, FileLoader): + return str(loader.path) + return None + except (ImportError,): + return None + + +def _object_name( + path: str | pathlib.Path, common_name: str | None +) -> tuple[str, str | None]: + if isinstance(path, pathlib.Path): + path = str(path) + + r = path.rsplit(":", 1) + return r[0], (common_name if len(r) < 2 else r[1]) + + +def _resolve_entry_point_or_modules( + paths: list[str | pathlib.Path], + entry_point_group: str | None = None, + common_name: str | None = None, +) -> tuple[list[str], list[str], list[str]]: + """Resolve a mixture of paths, entry point names, and module names to + path. + + This function can resolve actual file system paths, ``setup.py`` + entry-point names and module names to a set of file system paths. + + Examples of things that can be resolved by this function are: + ``["/tmp/config.py", "my-config", "expose.config"]`` (an actualy filesystem + path, an entry-point described in a ``setup.py`` file, or the name of a + python module. + + Parameters + ---------- + paths + An iterable strings that either point to actual files, are entry point + names, or are module names. + entry_point_group + The entry point group name to search in entry points. + common_name + It will be used as a default name for object names. See the + ``attribute_name`` parameter from :py:func:`load`. + + + Returns + ------- + A tuple containing three lists of strings with: + + * The resolved paths pointing to existing files + * The valid python module names to bind each of the files to, and + finally, + * The name of objects that are supposed to be picked from paths + + + Raises + ------ + ValueError + If one of the paths cannot be resolved to an actual path to a file. + """ + + if entry_point_group is not None: + entry_point_dict: dict[str, EntryPoint] = { + e.name: e for e in entry_points(group=entry_point_group) + } + else: + entry_point_dict = {} + + files = [] + module_names = [] + object_names = [] + + for path in paths: + module_name = "user_config" # fixed module name for files with full paths + resolved_path, object_name = _object_name(path, common_name) + + # if it already points to a file, then do nothing + if pathlib.Path(resolved_path).is_file(): + pass + + # If it is an entry point name, collect path and module name + elif resolved_path in entry_point_dict: + entry = entry_point_dict[resolved_path] + module_name = entry.module + object_name = entry.attr if entry.attr else common_name + + resolved_path = _get_module_filename(module_name) + if resolved_path is None or not pathlib.Path(resolved_path).is_file(): + raise ValueError( + f"The specified entry point `{path}' pointing to module " + f"`{module_name}' and resolved to `{resolved_path}' does " + f"not point to an existing file." + ) + + # If it is not a path nor an entry point name, it is a module name then? + else: + # if we have gotten here so far then path must resolve as a module + resolved_path = _get_module_filename(resolved_path) + if resolved_path is None or not pathlib.Path(resolved_path).is_file(): + raise ValueError( + f"The specified path `{path}' is not a file, a entry " + f"point name, or a known-module name" + ) + + files.append(resolved_path) + module_names.append(module_name) + object_names.append(object_name) + + return files, module_names, object_names + + +def load( + paths: list[str | pathlib.Path], + context: dict[str, typing.Any] | None = None, + entry_point_group: str | None = None, + attribute_name: str | None = None, +) -> types.ModuleType | typing.Any: + """Load a set of configuration files, in sequence. + + This method will load one or more configuration files. Every time a + configuration file is loaded, the context (variables) loaded from the + previous file is made available, so the new configuration file can override + or modify this context. + + Parameters + ---------- + paths + A list or iterable containing paths (relative or absolute) of + configuration files that need to be loaded in sequence. Each + configuration file is loaded by creating/modifying the context + generated after each file readout. + context + If provided, start the readout of the first configuration file with the + given context. Otherwise, create a new internal context. + entry_point_group + If provided, it will treat non-existing file paths as entry point names + under the ``entry_point_group`` name. + attribute_name + If provided, will look for the ``attribute_name`` variable inside the + loaded files. Paths ending with ``some_path:variable_name`` can + override the ``attribute_name``. The ``entry_point_group`` must + provided as well ``attribute_name`` is not ``None``. + + + Returns + ------- + A module representing the resolved context, after loading the provided + modules and resolving all variables. If ``attribute_name`` is given, + the object with the given ``attribute_name`` name (or the name provided + by user) is returned instead of the module. + + + Raises + ------ + ImportError + If attribute_name is given but the object does not exist in the paths. + ValueError + If attribute_name is given but entry_point_group is not given. + """ + + # resolve entry points to paths + resolved_paths, names, object_names = _resolve_entry_point_or_modules( + paths, entry_point_group, attribute_name + ) + + ctxt = types.ModuleType("initial_context") + if context is not None: + ctxt.__dict__.update(context) + + # Small gambiarra (https://www.urbandictionary.com/define.php?term=Gambiarra) + # to avoid the garbage collector to collect some already imported modules. + _LOADED_CONFIGS.append(ctxt) + + # if no paths are provided, return context + if not resolved_paths: + return ctxt + + mod = None + for k, n in zip(resolved_paths, names): + logger.debug("Loading configuration file `%s'...", k) + mod = types.ModuleType(n) + # remove the keys that might break the loading of the next config file. + ctxt.__dict__.pop("__name__", None) + ctxt.__dict__.pop("__package__", None) + # do not propogate __ variables + context = {k: v for k, v in ctxt.__dict__.items() if not k.startswith("__")} + mod.__dict__.update(context) + _LOADED_CONFIGS.append(mod) + ctxt = _load_context(k, mod) + + if not attribute_name: + return mod + + # We pick the last object_name here. Normally users should provide just one + # path when enabling the attribute_name parameter. + attribute_name = object_names[-1] + if attribute_name is not None and not hasattr(mod, attribute_name): + raise ImportError( + f"The desired variable `{attribute_name}' does not exist in any of " + f"your configuration files: {', '.join(resolved_paths)}" + ) + + return getattr(mod, attribute_name) + + +def mod_to_context(mod: types.ModuleType) -> dict[str, typing.Any]: + """Convert the loaded module of :py:func:`load` to a dictionary context. + + This function removes all the variables that start and end with ``__``. + + Parameters + ---------- + mod + a Python module, e.g., as returned by :py:func:`load`. + + Returns + ------- + The context that was in ``mod``, as a dictionary mapping strings to + objects. + """ + return { + k: v + for k, v in mod.__dict__.items() + if not (k.startswith("__") and k.endswith("__")) + } + + +def resource_keys( + entry_point_group: str, + exclude_packages: tuple[str, ...] = tuple(), + strip: tuple[str, ...] = ("dummy",), +) -> list[str]: + """Read and returns all resources that are registered on a entry-point + group. + + Entry points from the given ``exclude_packages`` list are ignored. Notice + we are using :py:mod:`importlib.metadata` to load entry-points, and that + that entry point distribution (``.dist`` attribute) was only added to + Python in version 3.10. We therefore currently only verify if the named + resource does not start with any of the strings provided in + `exclude_package``. + + Parameters + ---------- + entry_point_group + The entry point group name. + exclude_packages + List of packages to exclude when finding resources. + strip + Entrypoint names that start with any value in ``strip`` will be + ignored. + + + Returns + ------- + Alphabetically sorted list of resources matching your query + """ + + ret_list = [ + k.name + for k in entry_points(group=entry_point_group) + if ( + (not k.name.strip().startswith(exclude_packages)) + and (not k.name.startswith(strip)) + ) + ] + ret_list = list(dict.fromkeys(ret_list)) # order-preserving uniq + return sorted(ret_list) diff --git a/src/clapper/logging.py b/src/clapper/logging.py new file mode 100644 index 0000000..c5845a7 --- /dev/null +++ b/src/clapper/logging.py @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause +""":py:class:`logging.Logger` setup and stream separation.""" + +import logging +import sys +import typing + + +# debug and info messages are written to sys.stdout +class _InfoFilter(logging.Filter): + """Filter-class to delete any log-record with level above + :any:`logging.INFO` **before** reaching the handler. + """ + + def __init__(self): + super().__init__() + + def filter(self, record): + return record.levelno <= logging.INFO + + +def setup( + logger_name: str, + format: str = "[%(levelname)s] %(message)s (%(name)s, %(asctime)s)", # noqa: A002 + low_level_stream: typing.TextIO = sys.stdout, + high_level_stream: typing.TextIO = sys.stderr, +) -> logging.Logger: + """Return a logger object that is ready for console logging. + + Retrieves (as with :py:func:`logging.getLogger()`) the given logger, and + then attaches 2 handlers (defined on the module) to it: + + 1. A :py:class:`logging.StreamHandler` to output messages with level equal + or lower than ``logging.INFO`` to the text-I/O stream + ``low_level_stream``. This is implemented by attaching a filter to the + respective stream-handler to limit message records at this level. + 2. A :py:class:`logging.StreamHandler` to output warning, error messages + and above to the text-I/O stream ``high_level_stream``, with an internal + level set to ``logging.WARNING``. + + A new formatter, with the format string as defined by the ``format`` + argument is set on both handlers. In this way, the global logger level can + still be controlled from one single place. If output is generated, then it + is sent to the right stream. + + Parameters + ---------- + logger_name + The name of the module to generate logs for + format + The format of the logs, see :py:class:`logging.LogRecord` for more + details. By default, the log contains the logger name, the log time, + the log level and the massage. + low_level_stream + The stream where to output info messages and below + high_level_stream + The stream where to output warning messages and above + + Returns + ------- + The configured logger. The same logger can be retrieved using the + :py:func:`logging.getLogger` function. + """ + + logger = logging.getLogger(logger_name) + + formatter = logging.Formatter(format) + + handlers_installed = {k.name: k for k in logger.handlers} + debug_logger_name = f"debug_info+{logger_name}" + + # First check that logger with a matching name or stream is not already + # there before attaching a new one. + if (debug_logger_name not in handlers_installed) or ( + getattr(handlers_installed[debug_logger_name], "stream") != low_level_stream + ): + debug_info = logging.StreamHandler(low_level_stream) + debug_info.setLevel(logging.DEBUG) + debug_info.setFormatter(formatter) + debug_info.addFilter(_InfoFilter()) + debug_info.name = debug_logger_name + logger.addHandler(debug_info) + + error_logger_name = f"warn_err+{logger_name}" + + # First check that logger with a matching name or stream is not already + # there before attaching a new one. + if (error_logger_name not in handlers_installed) or ( + getattr(handlers_installed[error_logger_name], "stream") != high_level_stream + ): + warn_err = logging.StreamHandler(high_level_stream) + warn_err.setLevel(logging.WARNING) + warn_err.setFormatter(formatter) + warn_err.name = error_logger_name + logger.addHandler(warn_err) + + return logger diff --git a/src/clapper/rc.py b/src/clapper/rc.py new file mode 100644 index 0000000..6bc2af5 --- /dev/null +++ b/src/clapper/rc.py @@ -0,0 +1,190 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause +"""Implements a global configuration system setup and readout.""" + +import collections.abc +import io +import json +import logging +import pathlib +import typing + +import tomli +import tomli_w +import xdg + + +class UserDefaults(collections.abc.MutableMapping): + """Contains user defaults read from the user TOML configuration file. + + Upon intialisation, an instance of this class will read the user + configuration file defined by the first argument. If the input file is + specified as a relative path, then it is considered relative to the + environment variable ``${XDG_CONFIG_HOME}``, or its default setting (which is + operating system dependent, c.f. `XDG defaults`_). + + This object may be used (with limited functionality) like a dictionary. In + this mode, objects of this class read and write values to the ``DEFAULT`` + section. The ``len()`` method will also return the number of variables set + at the ``DEFAULT`` section as well. + + Parameters + ---------- + path + The path, absolute or relative, to the file containing the user + defaults to read. If `path` is a relative path, then it is considered + relative to the directory defined by the environment variable + ``${XDG_CONFIG_HOME}`` (read `XDG defaults + `_ + for details on the default location of this directory in the various + operating systems). The tilde (`~`) character may be used to represent + the user home, and is automatically expanded. + logger + A logger to use for messaging operations. If not set, use this + module's logger. + + Attributes + ---------- + path + The current path to the user defaults base file. + """ + + def __init__( + self, + path: str | pathlib.Path, + logger: logging.Logger | None = None, + ) -> None: + self.logger = logger or logging.getLogger(__name__) + + self.path = pathlib.Path(path).expanduser() + + if not self.path.is_absolute(): + self.path = xdg.xdg_config_home() / self.path + + self.logger.info(f"User configuration file set to `{str(self.path)}'") + self.data: dict[str, typing.Any] = {} + self.read() + + def read(self) -> None: + """Read configuration file, replaces any internal values.""" + if self.path.exists(): + self.logger.debug("User configuration file exists, reading contents...") + self.data.clear() + + with self.path.open("rb") as f: + contents = f.read() + + # Support for legacy JSON file format. Remove after sometime + # FYI: today is September 16, 2022 + try: + data = json.loads(contents) + self.logger.warning( + f"Converting `{str(self.path)}' from (legacy) JSON " + f"to (new) TOML format" + ) + self.update(data) + self.write() + self.clear() + # reload contents + with self.path.open("rb") as f: + contents = f.read() + except ValueError: + pass + + self.data.update(tomli.load(io.BytesIO(contents))) + + else: + self.logger.debug("Initializing empty user configuration...") + + def write(self) -> None: + """Store any modifications done on the user configuration.""" + if self.path.exists(): + backup = pathlib.Path(str(self.path) + "~") + self.logger.debug(f"Backing-up {str(self.path)} -> {str(backup)}") + self.path.rename(backup) + + with self.path.open("wb") as f: + tomli_w.dump(self.data, f) + + self.logger.info(f"Wrote configuration at {str(self.path)}") + + def __str__(self) -> str: + t = io.BytesIO() + tomli_w.dump(self.data, t) + return t.getvalue().decode(encoding="utf-8") + + def __getitem__(self, k: str) -> typing.Any: + if k in self.data: + return self.data[k] + + if "." in k: + # search for a key with a matching name after the "." + parts = k.split(".") + base = self.data + for n in range(len(parts)): + if parts[n] in base: + base = base[parts[n]] + if (not isinstance(base, dict)) and (n < (len(parts) - 1)): + # this is an actual value, not another dict whereas it + # should not as we have more parts to go + break + else: + break + subkey = ".".join(parts[(n + 1) :]) + if subkey in base: + return base[subkey] + + # otherwise, defaults to the default behaviour + return self.data.__getitem__(k) + + def __setitem__(self, k: str, v: typing.Any) -> None: + assert isinstance(k, str) + + if "." in k: + # sets nested subsections + parts = k.split(".") + base = self.data + for n in range(len(parts) - 1): + base = base.setdefault(parts[n], {}) + if not isinstance(base, dict): + raise KeyError( + f"You are trying to set configuration key " + f"{k}, but {'.'.join(parts[:(n + 1)])} is already a " + f"variable set in the file, and not a section" + ) + base[parts[-1]] = v + return v + + # otherwise, defaults to the default behaviour + return self.data.__setitem__(k, v) + + def __delitem__(self, k: str) -> None: + assert isinstance(k, str) + + if "." in k: + # search for a key with a matching name after the "." + parts = k.split(".") + base = self.data + for n in range(len(parts) - 1): + if parts[n] in base: + base = base[parts[n]] + if not isinstance(base, dict): + # this is an actual value, not another dict whereas it + # should not as we have more parts to go + break + else: + break + subkey = ".".join(parts[(n + 1) :]) + if subkey in base: + del base[subkey] + return None + + # otherwise, defaults to the default behaviour + return self.data.__delitem__(k) + + def __iter__(self) -> typing.Iterator[str]: + return self.data.__iter__() + + def __len__(self) -> int: + return self.data.__len__() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f7ce69b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,90 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +import contextlib +import io +import pathlib +import warnings + +import pytest + +from click.testing import CliRunner +from pytest import fixture + +""" + +In your tests: + + @cli_runner + def test_foo(cli_runner): + r = cli_runner.invoke(mycli, ["mycommand"]) + assert r.exit_code == 0 + +In `some_command()`, add: + + @cli.command() + def mycommand(): + import pytest; pytest.set_trace() + +Then run via: + + $ pytest --pdb-trace ... + +Note any tests checking CliRunner stdout/stderr values will fail when +--pdb-trace is set. + +""" + + +def pytest_addoption(parser) -> None: + parser.addoption( + "--pdb-trace", + action="store_true", + default=False, + help="Allow calling pytest.set_trace() in Click's CliRunner", + ) + + +class MyCliRunner(CliRunner): + def __init__(self, *args, in_pdb=False, **kwargs) -> None: + self._in_pdb = in_pdb + super().__init__(*args, **kwargs) + + def invoke(self, cli, args=None, **kwargs): + params = kwargs.copy() + if self._in_pdb: + params["catch_exceptions"] = False + + return super().invoke(cli, args=args, **params) + + def isolation(self, input_=None, env=None, color=False): + if self._in_pdb: + if input_ or env or color: + warnings.warn( + "CliRunner PDB un-isolation doesn't work if input/env/color are passed" + ) + else: + return self.isolation_pdb() + + return super().isolation(input=input_, env=env, color=color) + + @contextlib.contextmanager + def isolation_pdb(self): + s = io.BytesIO(b"{stdout not captured because --pdb-trace}") + yield (s, not self.mix_stderr and s) + + +@pytest.fixture +def cli_runner(request) -> MyCliRunner: + """A wrapper round Click's test CliRunner to improve usefulness.""" + return MyCliRunner( + # workaround Click's environment isolation so debugging works. + in_pdb=request.config.getoption("--pdb-trace") + ) + + +@fixture +def datadir(request) -> pathlib.Path: + """Returns the directory in which the test is sitting.""" + return pathlib.Path(request.module.__file__).parents[0] / "data" diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/basic_config.py b/tests/data/basic_config.py new file mode 100644 index 0000000..0a6d67b --- /dev/null +++ b/tests/data/basic_config.py @@ -0,0 +1,4 @@ +"""Example configuration module.""" + +a = 1 +b = a + 2 diff --git a/tests/data/complex.py b/tests/data/complex.py new file mode 100644 index 0000000..f35e968 --- /dev/null +++ b/tests/data/complex.py @@ -0,0 +1,6 @@ +cplx = dict( + a="test", + b=42, + c=3.14, + d=[1, 2, 37], +) diff --git a/tests/data/oldjson.cfg b/tests/data/oldjson.cfg new file mode 100644 index 0000000..638ffae --- /dev/null +++ b/tests/data/oldjson.cfg @@ -0,0 +1,9 @@ +{ + "string": "this is a string", + "integer": 42, + "float": 3.14, + "bar.boolean": false, + "bar.int": 15, + "baz.foo.int": 35, + "baz.foo.float": 2.78 +} diff --git a/tests/data/second_config.py b/tests/data/second_config.py new file mode 100644 index 0000000..c908703 --- /dev/null +++ b/tests/data/second_config.py @@ -0,0 +1,3 @@ +# the b variable from the last config file is available here +c = b + 1 # noqa: F821 +b = b + 3 # noqa: F821 diff --git a/tests/data/test_dump_config.py b/tests/data/test_dump_config.py new file mode 100644 index 0000000..a4ce209 --- /dev/null +++ b/tests/data/test_dump_config.py @@ -0,0 +1,16 @@ +"""Configuration file automatically generated at 09/09/2022. + +test + +Test command. + +Examples! +""" + +# test = /my/path/test.txt +"""Required parameter: test (-t, --test) +Path leading to test blablabla""" + +# verbose = 0 +"""Optional parameter: verbose (-v, --verbose) [default: 0] +Increase the verbosity level from 0 (only error and critical) messages will be displayed, to 1 (like 0, but adds warnings), 2 (like 1, but adds info messags), and 3 (like 2, but also adds debugging messages) by adding the --verbose option as often as desired (e.g. '-vvv' for debug).""" diff --git a/tests/data/test_dump_config2.py b/tests/data/test_dump_config2.py new file mode 100644 index 0000000..d0e7d51 --- /dev/null +++ b/tests/data/test_dump_config2.py @@ -0,0 +1,45 @@ +"""Configuration file automatically generated at 10/09/2022. + +test + +Blablabla bli blo. + + Parameters + ---------- + xxx : :any:`list` + blabla blablo + yyy : callable + bli bla blo bla bla bla + + [CONFIG]... BLA BLA BLA BLA +""" + +# database = None +"""Required parameter: database (--database, -d) +bla bla bla Can be a `clapper.test.config' entry point, a module name, or a path to a Python file which contains a variable named `database'. +Registered entries are: ['complex', 'complex-var', 'error-config', 'first', 'first-a', 'first-b', 'second', 'second-b', 'second-c', 'verbose-config']""" + +# annotator = None +"""Required parameter: annotator (--annotator, -a) +bli bli bli Can be a `clapper.test.config' entry point, a module name, or a path to a Python file which contains a variable named `annotator'. +Registered entries are: ['complex', 'complex-var', 'error-config', 'first', 'first-a', 'first-b', 'second', 'second-b', 'second-c', 'verbose-config']""" + +# output_dir = None +"""Required parameter: output_dir (--output-dir, -o) +blo blo blo""" + +# force = False +"""Optional parameter: force (--force, -f) [default: False] +lalalalalala""" + +# array = 1 +"""Optional parameter: array (--array) [default: 1] +lililili""" + +# database_directories_file = ~/databases.txt +"""Optional parameter: database_directories_file (--database-directories-file) [default: ~/databases.txt] +lklklklk""" + +# verbose = 0 +"""Optional parameter: verbose (-v, --verbose) [default: 0] +Increase the verbosity level from 0 (only error and critical) messages will be displayed, to 1 (like 0, but adds warnings), 2 (like 1, but adds info messags), and 3 (like 2, but also adds debugging messages) by adding the --verbose option as often as desired (e.g. '-vvv' for debug).""" diff --git a/tests/data/userdefaults_ex1.cfg b/tests/data/userdefaults_ex1.cfg new file mode 100644 index 0000000..478ccb3 --- /dev/null +++ b/tests/data/userdefaults_ex1.cfg @@ -0,0 +1,8 @@ +string = "this is a string" +integer = 42 +float = 3.14 +boolean = true +array = ["abc", 2, 2.78] + +[bar] +boolean = false diff --git a/tests/data/verbose_config.py b/tests/data/verbose_config.py new file mode 100644 index 0000000..181d8cd --- /dev/null +++ b/tests/data/verbose_config.py @@ -0,0 +1 @@ +verbose = 2 diff --git a/tests/test_click.py b/tests/test_click.py new file mode 100644 index 0000000..c3980f4 --- /dev/null +++ b/tests/test_click.py @@ -0,0 +1,339 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +import difflib +import logging + +import click + +from clapper.click import ( + AliasedGroup, + ConfigCommand, + ResourceOption, + log_parameters, + verbosity_option, +) +from click.testing import CliRunner + + +def test_prefix_aliasing(): + @click.group(cls=AliasedGroup) + def cli(): + pass + + @cli.command() + def test(): + click.echo("OK") + + @cli.command(name="test-aaa") + def test_aaa(): + click.echo("AAA") + + runner = CliRunner() + result = runner.invoke(cli, ["te"], catch_exceptions=False) + assert result.exit_code != 0 + + result = runner.invoke(cli, ["test"], catch_exceptions=False) + assert result.exit_code == 0 + assert "OK" in result.output, (result.exit_code, result.output) + + result = runner.invoke(cli, ["test-a"], catch_exceptions=False) + assert result.exit_code == 0 + assert "AAA" in result.output, (result.exit_code, result.output) + + result = runner.invoke(cli, ["test-aaaa"], catch_exceptions=False) + assert result.exit_code != 0 + + +def test_commands_with_config_1(): + # random test + @click.command(cls=ConfigCommand, entry_point_group="clapper.test.config") + def cli(**_): + pass + + runner = CliRunner() + result = runner.invoke(cli, ["first"]) + assert result.exit_code == 0 + + +def test_commands_with_config_2(): + # test option with valid default value + @click.command(cls=ConfigCommand, entry_point_group="clapper.test.config") + @click.option("-a", type=click.INT, cls=ResourceOption) + def cli(a, **_): + assert isinstance(a, int), (type(a), a) + click.echo(f"{a}") + + runner = CliRunner() + + result = runner.invoke(cli, ["first"]) + assert result.exit_code == 0 + assert result.output.strip() == "1" + + result = runner.invoke(cli, ["-a", "2"]) + assert result.exit_code == 0 + assert result.output.strip() == "2" + + result = runner.invoke(cli, ["-a", "3", "first"]) + assert result.exit_code == 0 + assert result.output.strip() == "3" + + result = runner.invoke(cli, ["first", "-a", "3"]) + assert result.exit_code == 0 + assert result.output.strip() == "3" + + +def test_commands_with_config_3(): + # test required options + @click.command(cls=ConfigCommand, entry_point_group="clapper.test.config") + @click.option("-a", cls=ResourceOption, required=True) + def cli(a, **_): + click.echo(f"{a}") + + runner = CliRunner() + + result = runner.invoke(cli, []) + assert result.exit_code == 2 + + result = runner.invoke(cli, ["first"]) + assert result.exit_code == 0 + assert result.output.strip() == "1" + + result = runner.invoke(cli, ["-a", "2"]) + assert result.exit_code == 0 + assert result.output.strip() == "2" + + result = runner.invoke(cli, ["-a", "3", "first"]) + assert result.exit_code == 0 + assert result.output.strip() == "3" + + result = runner.invoke(cli, ["first", "-a", "3"]) + assert result.exit_code == 0 + assert result.output.strip() == "3" + + +def _assert_config_dump(output, ref, ref_date): + with output.open("rt") as f, ref.open() as f2: + diff = difflib.ndiff(f.readlines(), f2.readlines()) + important_diffs = [k for k in diff if k.startswith(("+", "-"))] + + # check and remove differences on the generation date + if ( + len(important_diffs) >= 2 + and important_diffs[0].startswith( + '- """Configuration file automatically generated at ' + ) + and important_diffs[1].startswith( + '+ """Configuration file automatically generated at ' + ) + ): + important_diffs = important_diffs[2:] + + important_diffs = "".join(important_diffs) + + assert len(important_diffs) == 0, ( + f"Differences between " + f"{str(output)} and {str(ref)} files observed: " + f"{important_diffs}" + ) + + +def test_config_dump(tmp_path, datadir): + @click.command(cls=ConfigCommand, epilog="Examples!") + @click.option( + "-t", + "--test", + required=True, + default="/my/path/test.txt", + help="Path leading to test blablabla", + cls=ResourceOption, + ) + @verbosity_option(logging.getLogger(__name__), cls=ResourceOption) + def test(**_): + """Test command.""" + pass + + runner = CliRunner() + output = tmp_path / "test_dump.py" + result = runner.invoke( + test, + ["-H", str(output)], + catch_exceptions=False, + ) + ref = datadir / "test_dump_config.py" + assert result.exit_code == 0 + _assert_config_dump(output, ref, "10/09/2022") + + +def test_config_dump2(tmp_path, datadir): + @click.command(cls=ConfigCommand, entry_point_group="clapper.test.config") + @click.option( + "--database", + "-d", + required=True, + cls=ResourceOption, + entry_point_group="clapper.test.config", + help="bla bla bla", + ) + @click.option( + "--annotator", + "-a", + required=True, + cls=ResourceOption, + entry_point_group="clapper.test.config", + help="bli bli bli", + ) + @click.option( + "--output-dir", + "-o", + required=True, + cls=ResourceOption, + help="blo blo blo", + ) + @click.option( + "--force", "-f", is_flag=True, cls=ResourceOption, help="lalalalalala" + ) + @click.option( + "--array", + type=click.INT, + default=1, + cls=ResourceOption, + help="lililili", + ) + @click.option( + "--database-directories-file", + cls=ResourceOption, + default="~/databases.txt", + help="lklklklk", + ) + @verbosity_option(logging.getLogger(__name__), cls=ResourceOption) + def test(**_): + """Blablabla bli blo. + + Parameters + ---------- + xxx : :any:`list` + blabla blablo + yyy : callable + bli bla blo bla bla bla + + [CONFIG]... BLA BLA BLA BLA + """ + pass + + runner = CliRunner() + output = tmp_path / "test_dump.py" + result = runner.invoke(test, ["test", "-H", str(output)], catch_exceptions=False) + + ref = datadir / "test_dump_config2.py" + assert result.exit_code == 0 + _assert_config_dump(output, ref, "10/09/2022") + + +def test_config_command_with_callback_options(): + @click.command(cls=ConfigCommand, entry_point_group="clapper.test.config") + @verbosity_option(logging.getLogger(__name__), envvar="VERBOSE", cls=ResourceOption) + @click.pass_context + def cli(ctx, **_): + verbose = ctx.meta["verbose"] + assert verbose == 2 + + runner = CliRunner() + result = runner.invoke(cli, ["verbose-config"]) + assert result.exit_code == 0 + + runner = CliRunner(env=dict(VERBOSE="1")) + result = runner.invoke(cli, ["verbose-config"]) + assert result.exit_code == 0 + + runner = CliRunner(env=dict(VERBOSE="2")) + result = runner.invoke(cli) + assert result.exit_code == 0 + + +def test_resource_option(): + # test usage without ConfigCommand and with entry_point_group + @click.command() + @click.option( + "-a", "--a", cls=ResourceOption, entry_point_group="clapper.test.config" + ) + def cli1(a): + assert a == 1 + + runner = CliRunner() + result = runner.invoke(cli1, ["-a", "tests.data.basic_config"]) + assert result.exit_code == 0 + + # test usage without ConfigCommand and without entry_point_group + # should raise a TypeError + @click.command() + @click.option("-a", "--a", cls=ResourceOption) + def cli2(**_): + raise ValueError("Should not have reached here!") + + runner = CliRunner() + result = runner.invoke(cli2, ["-a", "1"], catch_exceptions=True) + assert result.exit_code != 0 + assert isinstance(result.exception, TypeError) + assert str(result.exception).startswith("The ResourceOption class is not") + + # test ResourceOption with string_exceptions + @click.command() + @click.option( + "-a", + "--a", + cls=ResourceOption, + string_exceptions=("tests.data.basic_config"), + entry_point_group="clapper.test.config", + ) + def cli3(a): + assert a == "tests.data.basic_config" + + runner = CliRunner() + result = runner.invoke(cli3, ["-a", "tests.data.basic_config"]) + assert result.exit_code == 0 + + +def test_log_parameter(): + # Fake logger that checks if log_parameters accesses it + class DummyLogger: + def __init__(self): + self.accessed = False + + def debug(self, s, k, v): + self.accessed = True + + @click.command() + @click.option( + "-a", + "--a", + ) + def cli_log(a): + dummy_logger = DummyLogger() + log_parameters(dummy_logger) + assert dummy_logger.accessed + + runner = CliRunner() + result = runner.invoke(cli_log, ["-a", "aparam"]) + assert result.exit_code == 0 + + +def test_log_parameter_with_ignore(): + # Fake logger that ensures that the parameter 'a' is ignored + class DummyLogger: + def debug(self, s, k, v): + assert "a" not in k + + @click.command() + @click.option("-a", "--a") + @click.option( + "-b", + "--b", + ) + def cli_log(a, b): + log_parameters(DummyLogger(), ignore=("a")) + + runner = CliRunner() + result = runner.invoke(cli_log, ["-a", "aparam", "-b", "bparam"]) + assert result.exit_code == 0 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5536731 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,192 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +import filecmp +import io + +import pytest + +from clapper.click import config_group +from clapper.config import load, mod_to_context +from clapper.logging import setup as logger_setup +from click.testing import CliRunner + + +def test_basic(datadir): + c = load([datadir / "basic_config.py"]) + assert hasattr(c, "a") and c.a == 1 + assert hasattr(c, "b") and c.b == 3 + + ctx = mod_to_context(c) + assert ctx == {"a": 1, "b": 3} + + +def test_empty(): + c = load([]) + ctx = mod_to_context(c) + assert len(ctx) == 0 + + +def test_basic_with_context(datadir): + c = load([datadir / "basic_config.py"], {"d": 35, "a": 0}) + assert hasattr(c, "a") and c.a == 1 + assert hasattr(c, "b") and c.b == 3 + assert hasattr(c, "d") and c.d == 35 + + +def test_chain_loading(datadir): + file1 = datadir / "basic_config.py" + file2 = datadir / "second_config.py" + c = load([file1, file2]) + assert hasattr(c, "a") and c.a == 1 + assert hasattr(c, "b") and c.b == 6 + + +def test_config_with_module(): + c = load( + [ + "tests.data.basic_config", + "tests.data.second_config", + "tests.data.complex", + ] + ) + assert hasattr(c, "a") and c.a == 1 + assert hasattr(c, "b") and c.b == 6 + assert hasattr(c, "cplx") and isinstance(c.cplx, dict) + + +def test_config_with_entry_point(): + c = load(["first", "second", "complex"], entry_point_group="clapper.test.config") + assert hasattr(c, "a") and c.a == 1 + assert hasattr(c, "b") and c.b == 6 + assert hasattr(c, "cplx") and isinstance(c.cplx, dict) + + +def test_config_with_entry_point_file_missing(): + with pytest.raises(ValueError): + load(["error-config"], entry_point_group="clapper.test.config") + + +def test_config_with_mixture(datadir): + c = load( + [ + datadir / "basic_config.py", + "tests.data.second_config", + "complex", + ], + entry_point_group="clapper.test.config", + ) + assert hasattr(c, "a") and c.a == 1 + assert hasattr(c, "b") and c.b == 6 + assert hasattr(c, "cplx") and isinstance(c.cplx, dict) + + +def test_config_not_found(datadir): + with pytest.raises(ValueError): + load([datadir / "basic_config.pz"]) + + +def test_config_load_attribute(): + a = load(["tests.data.basic_config"], attribute_name="a") + assert a == 1 + + +def test_config_load_no_attribute(): + with pytest.raises(ImportError): + _ = load(["tests.data.basic_config"], attribute_name="wrong") + + +@pytest.fixture +def cli_messages(): + messages = io.StringIO() + logger = logger_setup( + "test-click-loading", + format="[%(levelname)s] %(message)s", + low_level_stream=messages, + high_level_stream=messages, + ) + + @config_group(logger=logger, entry_point_group="clapper.test.config") + def cli(**_): + """This is the documentation provided by the user.""" + pass + + return (cli, messages) + + +def test_config_click_config_list(cli_messages): + cli = cli_messages[0] + runner = CliRunner() + result = runner.invoke(cli, ["list"]) + assert result.exit_code == 0 + assert result.output.startswith("module: tests.data") + assert "(cannot be loaded; add another -v for details)" not in result.output + + +def test_config_click_config_list_v(cli_messages): + cli = cli_messages[0] + runner = CliRunner() + result = runner.invoke(cli, ["list", "-v"]) + assert result.exit_code == 0 + assert result.output.startswith("module: tests.data") + assert "(cannot be loaded; add another -v for details)" in result.output + assert "[module] Example configuration module" in result.output + + +def test_config_click_config_list_vv(cli_messages): + cli, messages = cli_messages + runner = CliRunner() + result = runner.invoke(cli, ["list", "-vv"]) + assert result.exit_code == 0 + assert result.output.startswith("module: tests.data") + assert "(cannot be loaded; add another -v for details)" in result.output + assert "[module] Example configuration module" in result.output + assert "NameError" in messages.getvalue() + + +def test_config_click_config_describe(cli_messages): + cli = cli_messages[0] + runner = CliRunner() + result = runner.invoke(cli, ["describe", "first"]) + assert result.exit_code == 0 + assert result.output.startswith("Configuration: first") + assert "Example configuration module" in result.output + assert "a = 1" not in result.output + assert "b = a + 2" not in result.output + + +def test_config_click_config_describe_v(cli_messages): + cli = cli_messages[0] + runner = CliRunner() + result = runner.invoke(cli, ["describe", "first", "-v"]) + assert result.exit_code == 0 + assert result.output.startswith("Configuration: first") + assert "a = 1" in result.output + assert "b = a + 2" in result.output + + +def test_config_click_describe_error(cli_messages): + cli, messages = cli_messages + runner = CliRunner() + result = runner.invoke(cli, ["describe", "not-found"]) + assert result.exit_code == 0 + assert "Cannot find configuration resource `not-found'" in messages.getvalue() + + +def test_config_click_copy(cli_messages, datadir, tmp_path): + cli = cli_messages[0] + runner = CliRunner() + dest = tmp_path / "file.py" + result = runner.invoke(cli, ["copy", "first", str(dest)]) + assert result.exit_code == 0 + assert filecmp.cmp(datadir / "basic_config.py", dest) + + +def test_config_click_copy_error(cli_messages, datadir, tmp_path): + cli, messages = cli_messages + runner = CliRunner() + dest = tmp_path / "file.py" + result = runner.invoke(cli, ["copy", "firstx", str(dest)]) + assert result.exit_code == 0 + assert "[ERROR] Cannot find configuration resource `firstx'" in messages.getvalue() diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..910186b --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,197 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +import io +import logging + +import clapper.logging +import click + +from clapper.click import verbosity_option +from click.testing import CliRunner + + +def test_logger_setup(): + lo = io.StringIO() + hi = io.StringIO() + + logger = clapper.logging.setup( + "awesome.logger", + format="%(message)s", + low_level_stream=lo, + high_level_stream=hi, + ) + logger.setLevel(logging.DEBUG) + + logger.debug("debug message") + logger.info("info message") + + logger.warning("warning message") + logger.error("error message") + + assert lo.getvalue() == "debug message\ninfo message\n" + assert hi.getvalue() == "warning message\nerror message\n" + + +def test_logger_click_no_v(): + lo = io.StringIO() + hi = io.StringIO() + + logger = clapper.logging.setup( + "awesome.logger", + format="%(message)s", + low_level_stream=lo, + high_level_stream=hi, + ) + + @click.command() + @verbosity_option(logger=logger) + def cli(**_): + logger.debug("debug message") + logger.info("info message") + logger.warning("warning message") + logger.error("error message") + + runner = CliRunner() + result = runner.invoke(cli, []) + assert result.exit_code == 0 + + assert lo.getvalue() == "" + + assert hi.getvalue() == "error message\n" + + +def test_logger_click_v(): + lo = io.StringIO() + hi = io.StringIO() + + logger = clapper.logging.setup( + "awesome.logger", + format="%(message)s", + low_level_stream=lo, + high_level_stream=hi, + ) + + @click.command() + @verbosity_option(logger=logger) + def cli(**_): + logger.debug("debug message") + logger.info("info message") + logger.warning("warning message") + logger.error("error message") + + runner = CliRunner() + result = runner.invoke(cli, ["-v"]) + assert result.exit_code == 0 + + assert lo.getvalue() == "" + assert hi.getvalue() == "warning message\nerror message\n" + + +def test_logger_click_vv(): + lo = io.StringIO() + hi = io.StringIO() + + logger = clapper.logging.setup( + "awesome.logger", + format="%(message)s", + low_level_stream=lo, + high_level_stream=hi, + ) + + @click.command() + @verbosity_option(logger=logger) + def cli(**_): + logger.debug("debug message") + logger.info("info message") + logger.warning("warning message") + logger.error("error message") + + runner = CliRunner() + result = runner.invoke(cli, ["-vv"]) + assert result.exit_code == 0 + + assert lo.getvalue() == "info message\n" + assert hi.getvalue() == "warning message\nerror message\n" + + +def test_logger_click_vvv(): + lo = io.StringIO() + hi = io.StringIO() + + logger = clapper.logging.setup( + "awesome.logger", + format="%(message)s", + low_level_stream=lo, + high_level_stream=hi, + ) + + @click.command() + @verbosity_option(logger=logger) + def cli(**_): + logger.debug("debug message") + logger.info("info message") + logger.warning("warning message") + logger.error("error message") + + runner = CliRunner() + result = runner.invoke(cli, ["-vvv"]) + assert result.exit_code == 0 + + assert "debug message\ninfo message\n" in lo.getvalue() + assert hi.getvalue() == "warning message\nerror message\n" + + +def test_logger_click_3x_verbose(): + lo = io.StringIO() + hi = io.StringIO() + + logger = clapper.logging.setup( + "awesome.logger", + format="%(message)s", + low_level_stream=lo, + high_level_stream=hi, + ) + + @click.command() + @verbosity_option(logger=logger) + def cli(**_): + logger.debug("debug message") + logger.info("info message") + logger.warning("warning message") + logger.error("error message") + + runner = CliRunner() + result = runner.invoke(cli, 3 * ["--verbose"]) + assert result.exit_code == 0 + + assert "debug message\ninfo message\n" in lo.getvalue() + assert hi.getvalue() == "warning message\nerror message\n" + + +def test_logger_click_3x_verb(): + lo = io.StringIO() + hi = io.StringIO() + + logger = clapper.logging.setup( + "awesome.logger", + format="%(message)s", + low_level_stream=lo, + high_level_stream=hi, + ) + + @click.command() + @verbosity_option(logger=logger, name="verb") + def cli(**_): + logger.debug("debug message") + logger.info("info message") + logger.warning("warning message") + logger.error("error message") + + runner = CliRunner() + result = runner.invoke(cli, 3 * ["--verb"]) + assert result.exit_code == 0 + + assert "debug message\ninfo message\n" in lo.getvalue() + assert hi.getvalue() == "warning message\nerror message\n" diff --git a/tests/test_rc.py b/tests/test_rc.py new file mode 100644 index 0000000..39d5719 --- /dev/null +++ b/tests/test_rc.py @@ -0,0 +1,352 @@ +# SPDX-FileCopyrightText: Copyright © 2022 Idiap Research Institute +# +# SPDX-License-Identifier: BSD-3-Clause + +import filecmp +import logging +import os +import pathlib +import shutil + +import pytest + +from clapper.click import user_defaults_group +from clapper.rc import UserDefaults +from click.testing import CliRunner + + +def _check_userdefaults_ex1_contents(rc): + # checks that it matches the contents of that file + + assert rc["string"] == "this is a string" + assert rc["integer"] == 42 + assert rc["float"] == 3.14 + assert rc["boolean"] is True + assert rc["array"] == ["abc", 2, 2.78] + assert rc["bar"]["boolean"] is False + assert rc["bar.boolean"] is False + + +def test_rc_basic_loading(datadir): + # tests if we can simply read an RC file + rc = UserDefaults(datadir / "userdefaults_ex1.cfg") + _check_userdefaults_ex1_contents(rc) + + with pytest.raises(KeyError): + assert rc["float.error"] == 3.14 + + +def test_rc_loading_from_xdg_config_home(datadir): + # tests if we can simply read an RC file from the XDG_CONFIG_HOME + _environ = dict(os.environ) # or os.environ.copy() + try: + os.environ["XDG_CONFIG_HOME"] = str(datadir) + rc = UserDefaults("userdefaults_ex1.cfg") + _check_userdefaults_ex1_contents(rc) + + with pytest.raises(KeyError): + assert rc["float.error"] == 3.14 + finally: + os.environ.clear() + os.environ.update(_environ) + + +def test_rc_init_empty(tmp_path): + rc = UserDefaults(tmp_path / "new-rc") + assert not rc + + +def _check_tree(d, sect, var, val): + assert sect in d + assert var in d[sect] + assert d[sect][var] == val + + +def test_rc_write(tmp_path): + rc = UserDefaults(tmp_path / "new-rc") + assert not rc + + rc["section1.an_int"] = 15 + rc["section1.a_bool"] = True + rc["section1.a_float"] = 3.1415 + rc["section1.baz"] = "fun" + rc["section1.bar"] = "Python" + + # checks contents before writing + assert rc["section1"]["an_int"] == 15 + assert rc["section1"]["a_bool"] is True + assert rc["section1"]["a_float"] == 3.1415 + assert rc["section1"]["baz"] == "fun" + assert rc["section1"]["bar"] == "Python" + + # checks contents before writing - different way + assert rc["section1.an_int"] == 15 + assert rc["section1.a_bool"] is True + assert rc["section1.a_float"] == 3.1415 + assert rc["section1.baz"] == "fun" + assert rc["section1.bar"] == "Python" + + rc.write() + + assert (tmp_path / "new-rc").exists() + + rc2 = UserDefaults(tmp_path / "new-rc") + assert len(rc2) == 1 + assert rc["section1"]["an_int"] == 15 + assert rc["section1.an_int"] == 15 + assert rc["section1"]["a_bool"] is True + assert rc["section1.a_bool"] is True + assert rc["section1"]["a_float"] == 3.1415 + assert rc["section1.a_float"] == 3.1415 + assert rc["section1"]["baz"] == "fun" + assert rc["section1.baz"] == "fun" + assert rc["section1"]["bar"] == "Python" + assert rc["section1.bar"] == "Python" + + +def test_rc_delete(tmp_path): + rc = UserDefaults(tmp_path / "new-rc") + assert not rc + + rc["an_int"] = 15 + rc["a_bool"] = True + rc["a_float"] = 3.1415 + rc["section1.baz"] = "fun" + rc["section1.bar"] = "Python" + + assert rc["an_int"] == 15 + assert rc["a_bool"] is True + assert rc["a_float"] == 3.1415 + assert rc["section1.baz"] == "fun" + assert rc["section1.bar"] == "Python" + + # delete something that exists + del rc["an_int"] + assert "an_int" not in rc + + with pytest.raises(KeyError): + del rc["error"] + + with pytest.raises(KeyError): + del rc["section1.baz.error"] + + with pytest.raises(KeyError): + del rc["section2.baz.error"] + + +def test_rc_backup_on_write(tmp_path): + rc = UserDefaults(tmp_path / "new-rc") + assert not rc + + rc["section1.an_int"] = 15 + rc.write() + assert (tmp_path / "new-rc").exists() + + rc.write() + assert (tmp_path / "new-rc").exists() + assert (tmp_path / "new-rc~").exists() + + assert filecmp.cmp(tmp_path / "new-rc", tmp_path / "new-rc~") + + +def test_rc_clear(): + rc = UserDefaults("does-not-exist") + assert not rc + + rc["section2.another_int"] = 42 + rc.clear() + + assert not rc + assert not pathlib.Path("does-not-exist").exists() + + +def test_rc_reload(tmp_path): + rc = UserDefaults(tmp_path / "new-rc") + rc["section1.foo"] = "bar" + rc["section1.an_int"] = 15 + rc.write() + assert len(rc) == 1 + + rc2 = UserDefaults(tmp_path / "new-rc") # change that and reload first + rc2["section2.another_int"] = 42 + rc2.write() + + # now reload and see what happened + rc.read() + assert len(rc) == 2 + assert rc["section1"]["foo"] == "bar" + assert rc["section1"]["an_int"] == 15 + assert rc2["section2"]["another_int"] == 42 + + +def test_rc_str(tmp_path): + rc = UserDefaults(tmp_path / "new-rc") + rc["foo"] = "bar" + rc["section1.an_int"] = 15 + rc.write() + + assert (tmp_path / "new-rc").open().read() == str(rc) + + +def test_rc_json_legacy(datadir, tmp_path): + shutil.copy(datadir / "oldjson.cfg", tmp_path) + rc = UserDefaults(tmp_path / "oldjson.cfg") + + assert rc["string"] == "this is a string" + assert rc["integer"] == 42 + assert rc["bar"] == {"boolean": False, "int": 15} + assert rc["baz.foo"] == {"int": 35, "float": 2.78} + assert rc["baz"]["foo"]["int"] == 35 + + +def test_rc_click_loading(datadir): + # tests if we can simply read an RC file + rc = UserDefaults(datadir / "userdefaults_ex1.cfg") + logger = logging.getLogger("test-click-loading") + + @user_defaults_group(logger=logger, config=rc) + def cli(**_): + """This is the documentation provided by the user.""" + pass + + runner = CliRunner() + + # test "show" + result = runner.invoke(cli, ["show"]) + assert result.exit_code == 0 + assert result.output.strip() == str(rc).strip() + + # test "get" + result = runner.invoke(cli, ["get", "string"]) + assert result.exit_code == 0 + assert result.output.strip() == "this is a string" + + result = runner.invoke(cli, ["get", "bar.boolean"]) + assert result.exit_code == 0 + assert result.output.strip() == "False" + + result = runner.invoke(cli, ["get", "bar"]) + assert result.exit_code == 0 + assert result.output.strip() == "{'boolean': False}" + + result = runner.invoke(cli, ["get", "wrong.wrong"]) + assert result.exit_code != 0 + assert "Error: Cannot find object named `wrong.wrong'" in result.output + + result = runner.invoke(cli, ["get", "bar.wrong"]) + assert result.exit_code != 0 + assert "Error: Cannot find object named `bar.wrong'" in result.output + + +def test_rc_click_writing(datadir, tmp_path): + # let's copy the user defaults to a temporary file so we can change it + shutil.copy(datadir / "userdefaults_ex1.cfg", tmp_path) + + rc = UserDefaults(tmp_path / "userdefaults_ex1.cfg") + logger = logging.getLogger("test-click-writing") + + @user_defaults_group(logger=logger, config=rc) + def cli(**_): + """This is the documentation provided by the user.""" + pass + + runner = CliRunner() + + result = runner.invoke(cli, ["set", "string", "a different string"]) + result = runner.invoke(cli, ["get", "bar.boolean"]) + assert result.exit_code == 0 + assert result.output.strip() == "False" + + result = runner.invoke(cli, ["set", "bar.boolean", "true"]) + result = runner.invoke(cli, ["get", "bar.boolean"]) + assert result.exit_code == 0 + assert result.output.strip() == "True" + + result = runner.invoke(cli, ["set", "new-section.date", "2022-02-02"]) + result = runner.invoke(cli, ["get", "new-section.date"]) + assert result.exit_code == 0 + assert result.output.strip() == "2022-02-02" + + result = runner.invoke(cli, ["rm", "new-section.date"]) + result = runner.invoke(cli, ["get", "new-section.date"]) + assert result.exit_code != 0 + assert "Error: Cannot find object named `new-section.date'" in result.output + + result = runner.invoke(cli, ["rm", "new-section"]) + result = runner.invoke(cli, ["get", "new-section"]) + assert result.exit_code != 0 + assert "Error: Cannot find object named `new-section'" in result.output + + result = runner.invoke(cli, ["rm", "bar"]) + assert result.exit_code == 0 + result = runner.invoke(cli, ["get", "bar"]) + assert result.exit_code != 0 + assert "Error: Cannot find object named `bar'" in result.output + + +def test_rc_click_no_directory(datadir, tmp_path): + # artificially removes surrounding directory to create an error + shutil.copy(datadir / "userdefaults_ex1.cfg", tmp_path) + + rc = UserDefaults(tmp_path / "userdefaults_ex1.cfg") + logger = logging.getLogger("test-click-writing") + + @user_defaults_group(logger=logger, config=rc) + def cli(**_): + """This is the documentation provided by the user.""" + pass + + runner = CliRunner() + + shutil.rmtree(tmp_path) + result = runner.invoke(cli, ["set", "color", "purple"]) + assert result.exit_code != 0 + assert isinstance(result.exception, FileNotFoundError) + + +def test_rc_click_cannot_set(tmp_path): + rc = UserDefaults(tmp_path / "test.toml") + logger = logging.getLogger("test-click-cannot-set") + + @user_defaults_group(logger=logger, config=rc) + def cli(**_): + """This is the documentation provided by the user.""" + pass + + runner = CliRunner() + + result = runner.invoke(cli, ["set", "string", "a different string"]) + result = runner.invoke(cli, ["set", "bar.boolean", "true"]) + assert result.exit_code == 0 + + assert (tmp_path / "test.toml").exists() + + result = runner.invoke(cli, ["set", "bar.boolean.error", "50"]) + assert result.exit_code != 0 + assert "Error: Cannot set object named `bar.boolean.error'" in result.output + + +def test_rc_click_cannot_delete(tmp_path): + rc = UserDefaults(tmp_path / "test.toml") + logger = logging.getLogger("test-click-cannot-delete") + + @user_defaults_group(logger=logger, config=rc) + def cli(**_): + """This is the documentation provided by the user.""" + pass + + runner = CliRunner() + + result = runner.invoke(cli, ["set", "string", "a different string"]) + result = runner.invoke(cli, ["set", "bar.boolean", "true"]) + assert result.exit_code == 0 + + assert (tmp_path / "test.toml").exists() + + result = runner.invoke(cli, ["rm", "new-section"]) + assert result.exit_code != 0 + assert "Error: Cannot delete object named `new-section'" in result.output + + # the existing section should still work + result = runner.invoke(cli, ["rm", "bar"]) + assert result.exit_code == 0