pax_global_header00006660000000000000000000000064151757620400014521gustar00rootroot0000000000000052 comment=921b1fa36afa2faca35f5e54d366f27816bed407 BrianPugh-cyclopts-921b1fa/000077500000000000000000000000001517576204000156455ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/.codecov.yml000066400000000000000000000006011517576204000200650ustar00rootroot00000000000000coverage: status: project: default: # Commits pushed to main should not make the overall # project coverage decrease by more than 2% target: auto threshold: 2% patch: default: # Be tolerant on code coverage diff on PRs to limit # noisy red coverage status on github PRs. target: auto threshold: 20% BrianPugh-cyclopts-921b1fa/.github/000077500000000000000000000000001517576204000172055ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/.github/FUNDING.yml000066400000000000000000000014561517576204000210300ustar00rootroot00000000000000# These are supported funding model platforms github: [BrianPugh]# Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] BrianPugh-cyclopts-921b1fa/.github/contributing.md000066400000000000000000000055251517576204000222450ustar00rootroot00000000000000## Environment Setup 1. We use [uv](https://docs.astral.sh/uv/) for managing virtual environments and dependencies. Once uv is installed, run `uv sync --all-extras` in this repo to get started. 2. For managing linters, static-analysis, and other tools, we use [pre-commit](https://pre-commit.com/#installation). Once Pre-commit is installed, run `uv run pre-commit install` in this repo to install the hooks. Using pre-commit ensures PRs match the linting requirements of the codebase. ## Documentation Whenever possible, please add docstrings to your code! We use [numpy-style napoleon docstrings](https://sphinxcontrib-napoleon.readthedocs.io/en/latest/#google-vs-numpy). To confirm docstrings are valid, build the docs by running `uv run make html` in the `docs/` folder. I typically write docstrings first, it will act as a guide to limit scope and encourage unit-testable code. Good docstrings include information like: 1. If not immediately obvious, what is the intended use-case? When should this function be used? 2. What happens during errors/edge-cases. 3. When dealing with physical values, include units. ## Unit Tests We use the [pytest](https://docs.pytest.org/) framework for unit testing. Ideally, all new code is partnered with new unit tests to exercise that code. If fixing a bug, consider writing the test first to confirm the existence of the bug, and to confirm that the new code fixes it. Unit tests should only test a single concise body of code. If this is hard to do, there are two solutions that can help: 1. Restructure the code. Keep inputs/outputs to be simple variables. Avoid complicated interactions with state. 2. Use [pytest-mock](https://pytest-mock.readthedocs.io/en/latest/) to mock out external interactions. 3. Run tests with `python -m pytest`. ## Coding Style In an attempt to keep consistency and maintainability in the code-base, here are some high-level guidelines for code that might not be enforced by linters. * Use f-strings. * Keep/cast path variables as `pathlib.Path` objects. Do not use `os.path`. For public-facing functions, cast path arguments immediately to `Path`. * Use magic-methods when appropriate. It might be better to implement ``MyClass.__call__()`` instead of ``MyClass.run()``. * Do not return sentinel values for error-states like `-1` or `None`. Instead, raise an exception. * Avoid deeply nested code. Techniques like returning early and breaking up a complicated function into multiple functions results in easier to read and test code. * Consider if you are double-name-spacing and how modules are meant to be imported. E.g. it might be better to name a function `read` instead of `image_read` in the module `my_package/image.py`. Consider the module name-space and whether or not it's flattened in `__init__.py`. * Only use multiple-inheritance if using a mixin. Mixin classes should end in `"Mixin"`. BrianPugh-cyclopts-921b1fa/.github/dependabot.yml000066400000000000000000000007661517576204000220460ustar00rootroot00000000000000# To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "pip" # See documentation for possible values directory: "/" # Location of package manifests schedule: interval: "weekly" BrianPugh-cyclopts-921b1fa/.github/workflows/000077500000000000000000000000001517576204000212425ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/.github/workflows/deploy.yaml000066400000000000000000000016621517576204000234270ustar00rootroot00000000000000name: Build package and push to PyPi on: workflow_dispatch: push: tags: - "v*.*.*" jobs: build: runs-on: ubuntu-latest env: PYTHON: 3.12 steps: - name: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 # Needed for hatch-vcs to get version from git tags - name: Install uv uses: astral-sh/setup-uv@v5 with: enable-cache: true - name: Set up python ${{ env.PYTHON }} id: setup-python run: uv python install ${{ env.PYTHON }} - name: Install project run: uv sync --all-extras - name: Build package run: uv build - name: Publish package if: github.event_name != 'workflow_dispatch' run: uv publish --token ${{ secrets.PYPI_TOKEN }} - uses: actions/upload-artifact@v4 if: always() with: name: dist path: dist/ BrianPugh-cyclopts-921b1fa/.github/workflows/tests.yaml000066400000000000000000000055601517576204000232760ustar00rootroot00000000000000# Regular tests # # Use this to ensure your tests are passing on every push and PR (skipped on # pushes which only affect documentation). # # You should make sure you run jobs on at least the *oldest* and the *newest* # versions of python that your codebase is intended to support. name: tests on: push: branches: - main pull_request: jobs: test: timeout-minutes: 45 defaults: run: shell: bash runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-15, windows-latest] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] env: OS: ${{ matrix.os }} PYTHON: ${{ matrix.python-version }} steps: - name: Set OS Environment Variables (Windows) if: runner.os == 'Windows' run: | echo 'ACTIVATE_PYTHON_VENV=.venv/scripts/activate' >> $GITHUB_ENV - name: Set OS Environment Variables (not Windows) if: runner.os != 'Windows' run: | echo 'ACTIVATE_PYTHON_VENV=.venv/bin/activate' >> $GITHUB_ENV - name: Check out repository uses: actions/checkout@v4 with: fetch-depth: 0 # Needed for hatch-vcs to get version from git tags - name: Install uv uses: astral-sh/setup-uv@v5 with: enable-cache: true - name: Set up python ${{ matrix.python-version }} id: setup-python run: uv python install ${{ matrix.python-version }} - name: Install library run: uv sync --all-extras - name: Cache pre-commit uses: actions/cache@v4 with: path: ~/.cache/pre-commit/ key: pre-commit-${{ runner.os }}-${{ env.pythonLocation }}-${{ hashFiles('.pre-commit-config.yaml') }} - name: Pre-commit run run: uv run pre-commit run --show-diff-on-failure --color=always --all-files - name: Check tests folder existence id: check_test_files uses: andstor/file-existence-action@v3 with: files: "tests" - name: Run tests if: steps.check_test_files.outputs.files_exists == 'true' run: | uv run pytest --run-slow --cov=cyclopts --cov-config=pyproject.toml --cov-report term --cov-report xml --junitxml=testresults.xml uv run coverage report - name: Upload coverage to Codecov if: steps.check_test_files.outputs.files_exists == 'true' uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} flags: unittests env_vars: OS,PYTHON name: Python ${{ matrix.python-version }} on ${{ runner.os }} #---------------------------------------------- # make sure docs build #---------------------------------------------- - name: Build HTML docs run: uv run sphinx-build -b html -W docs/source/ docs/build/html BrianPugh-cyclopts-921b1fa/.gitignore000066400000000000000000000102521517576204000176350ustar00rootroot00000000000000##--------------------------------------------------- # Automated documentation .gitignore files ##--------------------------------------------------- # Automatically generated API documentation stubs from sphinx-apidoc docs/source/packages # Automatically converting README from markdown to rST docs/bin docs/source/readme.rst docs/source/assets ##--------------------------------------------------- # Continuous Integration .gitignore files ##--------------------------------------------------- # Ignore test result XML files testresults.xml coverage.xml ##--------------------------------------------------- # Python default .gitignore ##--------------------------------------------------- # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so *.pyd # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation /docs/_build/ /docs/build/ # PyBuilder target/ # Pycharm /.idea/dictionaries /.idea/modules.xml /.idea/shelf /.idea/usage.statistics.xml /.idea/workspace.xml # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don’t work, or not # install all needed dependencies. #Pipfile.lock # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ ##--------------------------------------------------- # Windows default .gitignore ##--------------------------------------------------- # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db # Dump file *.stackdump # Folder config file [Dd]esktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msix *.msm *.msp # Windows shortcuts *.lnk ##--------------------------------------------------- # Linux default .gitignore ##--------------------------------------------------- # Editor backup files *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* ##--------------------------------------------------- # Mac OSX default .gitignore ##--------------------------------------------------- # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk # Cython cyclopts/_c_extension.c cyclopts/*.html # Auto-generated version file (only exists after build) cyclopts/_version.py # line-profiler *.lprof # Misc dev /*.py /draw.toml /*.md /*.html /*.rst # Editor config .vscode poetry.lock tests/**/uv.lock /coverage.json BrianPugh-cyclopts-921b1fa/.pre-commit-config.yaml000066400000000000000000000027011517576204000221260ustar00rootroot00000000000000exclude: ^(uv.lock|.idea/|tests/__snapshots__/) repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.14.2" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 hooks: - id: check-added-large-files - id: check-case-conflict - id: check-shebang-scripts-are-executable - id: check-merge-conflict - id: check-json - id: check-toml - id: check-xml - id: check-yaml - id: debug-statements exclude: '^tests/(test_py3.*\.py|py312/)' - id: destroyed-symlinks - id: detect-private-key - id: end-of-file-fixer exclude: ^LICENSE|\.(html|csv|txt|svg|py)$ - id: pretty-format-json args: ["--autofix", "--no-ensure-ascii", "--no-sort-keys"] - id: requirements-txt-fixer - id: trailing-whitespace args: [--markdown-linebreak-ext=md] exclude: \.(html|svg)$ - repo: https://github.com/fredrikaverpil/creosote.git rev: v4.1.0 hooks: - id: creosote - repo: https://github.com/codespell-project/codespell rev: v2.4.1 hooks: - id: codespell additional_dependencies: - tomli - repo: https://github.com/crate-ci/typos rev: v1 hooks: - id: typos - repo: https://github.com/RobertCraigie/pyright-python rev: v1.1.408 hooks: - id: pyright BrianPugh-cyclopts-921b1fa/.readthedocs.yaml000066400000000000000000000012611517576204000210740ustar00rootroot00000000000000# Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 build: os: "ubuntu-22.04" tools: python: "3.10" jobs: post_create_environment: # Install uv - pip install uv post_install: # Install project with 'docs' extra using uv pip - uv pip install -e .[docs] # Build documentation in the docs/ directory with Sphinx sphinx: configuration: docs/source/conf.py fail_on_warning: true # Prevent ReadTheDocs from installing the package automatically python: install: [] # If using Sphinx, optionally build your docs in additional formats such as PDF formats: - pdf BrianPugh-cyclopts-921b1fa/CONTRIBUTING.md000066400000000000000000000100531517576204000200750ustar00rootroot00000000000000# Contributing to Cyclopts Thank you for your interest in contributing to Cyclopts! This guide will help you get started. ## Code of Conduct Please be respectful and constructive in all interactions. We are committed to providing a welcoming and inclusive experience for everyone. ## Where to Start Looking for something to work on? Check out issues labeled [`good first issue`](https://github.com/BrianPugh/cyclopts/labels/good%20first%20issue) on GitHub. These are curated to be approachable for new contributors. If you're exploring the codebase, these are good entry points: - `cyclopts/core.py` — the main `App` class and CLI lifecycle - `cyclopts/bind.py` — token-to-parameter binding - `cyclopts/_convert.py` — type conversion logic - `cyclopts/parameter.py` — parameter configuration API ## Getting Started ### Prerequisites - Python 3.10 or later - [uv](https://docs.astral.sh/uv/) (recommended package manager) ### Setting Up Your Development Environment 1. Fork and clone the repository: ```bash # Replace with your fork URL, if appropriate git clone https://github.com/BrianPugh/cyclopts.git cd cyclopts ``` 2. Install dependencies (including dev extras): ```bash uv sync --all-extras ``` 3. Install pre-commit hooks: ```bash uv run pre-commit install ``` ## Development Workflow ### Running Tests ```bash # Run all tests uv run pytest # Run all tests with coverage uv run pytest --cov=cyclopts --cov-config=pyproject.toml --cov-report term # Run a specific test file uv run pytest tests/test_core.py # Run a specific test function uv run pytest tests/test_core.py::test_function_name ``` Tests automatically run in isolated temporary directories. Python 3.12+ specific tests live in `tests/py312/` and are skipped on older versions. ### Linting and Formatting Pre-commit hooks run automatically on `git commit`. You can also run them manually: ```bash # Run all checks uv run pre-commit run --all-files # Run individual tools uv run ruff check --fix # Linting uv run ruff format # Formatting uv run pyright # Type checking ``` ### Code Style - **Line length:** 120 characters - **Docstrings:** NumPy-style convention - **Type hints:** Pyright strict mode - **Target Python:** 3.10+ (do not use syntax or features exclusive to newer versions without version guards) ### Building Documentation ```bash cd docs make html ``` ## Submitting Changes ### Pull Requests 1. Create a feature branch from `main`. 2. Make your changes, adding tests for new functionality. 3. Ensure all checks pass: ```bash uv run pre-commit run --all-files uv run pytest ``` 4. Push your branch and open a pull request against `main`. ### Commit Messages and PR Descriptions - Write clear, concise commit messages describing *what* changed and *why*. - Reference related issues in your PR description (e.g., `Fixes #123`). - There is no changelog to update — that is handled by the maintainers. ## Testing a Pull Request If a PR has been opened to fix an issue you reported, you can test it by installing Cyclopts directly from the PR branch: ```bash pip install git+https://github.com/BrianPugh/cyclopts.git@branch-name ``` Or with uv: ```bash uv pip install git+https://github.com/BrianPugh/cyclopts.git@branch-name ``` Replace `branch-name` with the branch listed on the PR. Alternatively, you can clone the repo and install in editable mode into your project's virtual environment: ```bash git clone https://github.com/BrianPugh/cyclopts.git cd cyclopts git checkout branch-name # Activate your project's virtual environment, then: pip install -e . ``` Verify the fix against your original reproducer and report back on the PR. ## Reporting Issues Open an issue on [GitHub](https://github.com/BrianPugh/cyclopts/issues) with: - A minimal reproducible example. - Your Python version and Cyclopts version (`python -c "import cyclopts; print(cyclopts.__version__)"`). - The expected vs. actual behavior. ## License By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE). BrianPugh-cyclopts-921b1fa/LICENSE000066400000000000000000000261351517576204000166610ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright CURRENT_YEAR_HERE YOUR_NAME_HERE Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. BrianPugh-cyclopts-921b1fa/README.md000066400000000000000000000235451517576204000171350ustar00rootroot00000000000000
![Python compat](https://img.shields.io/badge/>=python-3.10-blue.svg) [![PyPI](https://img.shields.io/pypi/v/cyclopts.svg)](https://pypi.org/project/cyclopts/) [![ReadTheDocs](https://readthedocs.org/projects/cyclopts/badge/?version=latest)](https://cyclopts.readthedocs.io) [![codecov](https://codecov.io/gh/BrianPugh/cyclopts/graph/badge.svg?token=HA393WIYUK)](https://codecov.io/gh/BrianPugh/cyclopts)
--- **Documentation:** https://cyclopts.readthedocs.io **Source Code:** https://github.com/BrianPugh/cyclopts --- Cyclopts is a modern, easy-to-use command-line interface (CLI) framework that aims to provide an intuitive & efficient developer experience. # Why Cyclopts? - **Intuitive API**: Quickly write CLI applications using a terse, intuitive syntax. - **Advanced Type Hinting**: Full support of all builtin types and even user-specified (yes, including [Pydantic](https://docs.pydantic.dev/latest/), [Dataclasses](https://docs.python.org/3/library/dataclasses.html), and [Attrs](https://www.attrs.org/en/stable/api.html)). - **Rich Help Generation**: Automatically generates beautiful help pages from **docstrings** and other contextual data. - **Extendable**: Easily customize converters, validators, token parsing, and application launching. # Installation Cyclopts requires Python >=3.10; to install Cyclopts, run: ```console pip install cyclopts ``` # Quick Start - Import `cyclopts.run()` and give it a function to run. ```python from cyclopts import run def foo(loops: int): for i in range(loops): print(f"Looping! {i}") run(foo) ``` Execute the script from the command line: ```console $ python start.py 3 Looping! 0 Looping! 1 Looping! 2 ``` When you need more control: - Create an application using `cyclopts.App`. - Register commands with the `command` decorator. - Register a default function with the `default` decorator. ```python from cyclopts import App app = App() @app.command def foo(loops: int): for i in range(loops): print(f"Looping! {i}") @app.default def default_action(): print("Hello world! This runs when no command is specified.") app() ``` Execute the script from the command line: ```console $ python demo.py Hello world! This runs when no command is specified. $ python demo.py foo 3 Looping! 0 Looping! 1 Looping! 2 ``` With just a few additional lines of code, we have a full-featured CLI app. See [the docs](https://cyclopts.readthedocs.io) for more advanced usage. # Compared to Typer Cyclopts is what you thought Typer was. Cyclopts's includes information from docstrings, support more complex types (even Unions and Literals!), and include proper validation support. See [the documentation for a complete Typer comparison](https://cyclopts.readthedocs.io/en/latest/vs_typer/README.html). Consider the following short 29-line Cyclopts application: ```python import cyclopts from typing import Literal app = cyclopts.App() @app.command def deploy( env: Literal["dev", "staging", "prod"], replicas: int | Literal["default", "performance"] = "default", ): """Deploy code to an environment. Parameters ---------- env Environment to deploy to. replicas Number of workers to spin up. """ if replicas == "default": replicas = 10 elif replicas == "performance": replicas = 20 print(f"Deploying to {env} with {replicas} replicas.") if __name__ == "__main__": app() ``` ```console $ my-script deploy --help Usage: my-script.py deploy [ARGS] [OPTIONS] Deploy code to an environment. ╭─ Parameters ────────────────────────────────────────────────────────────────────────────────────╮ │ * ENV --env Environment to deploy to. [choices: dev, staging, prod] [required] │ │ REPLICAS --replicas Number of workers to spin up. [choices: default, performance] [default: │ │ default] │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ $ my-script deploy staging Deploying to staging with 10 replicas. $ my-script deploy staging 7 Deploying to staging with 7 replicas. $ my-script deploy staging performance Deploying to staging with 20 replicas. $ my-script deploy nonexistent-env ╭─ Error ────────────────────────────────────────────────────────────────────────────────────────────╮ │ Error converting value "nonexistent-env" to typing.Literal['dev', 'staging', 'prod'] for "--env". │ ╰────────────────────────────────────────────────────────────────────────────────────────────────────╯ $ my-script --version 0.0.0 ``` In its current state, this application would be impossible to implement in Typer. However, lets see how close we can get with Typer (47-lines): ```python import typer from typing import Annotated, Literal from enum import Enum app = typer.Typer() class Environment(str, Enum): dev = "dev" staging = "staging" prod = "prod" def replica_parser(value: str): if value == "default": return 10 elif value == "performance": return 20 else: return int(value) def _version_callback(value: bool): if value: print("0.0.0") raise typer.Exit() @app.callback() def callback( version: Annotated[ bool | None, typer.Option("--version", callback=_version_callback) ] = None, ): pass @app.command(help="Deploy code to an environment.") def deploy( env: Annotated[Environment, typer.Argument(help="Environment to deploy to.")], replicas: Annotated[ int, typer.Argument( parser=replica_parser, help="Number of workers to spin up.", ), ] = replica_parser("default"), ): print(f"Deploying to {env.name} with {replicas} replicas.") if __name__ == "__main__": app() ``` ```console $ my-script deploy --help Usage: my-script deploy [OPTIONS] ENV:{dev|staging|prod} [REPLICAS] Deploy code to an environment. ╭─ Arguments ─────────────────────────────────────────────────────────────────────────────────────╮ │ * env ENV:{dev|staging|prod} Environment to deploy to. [default: None] [required] │ │ replicas [REPLICAS] Number of workers to spin up. [default: 10] │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Options ───────────────────────────────────────────────────────────────────────────────────────╮ │ --help Show this message and exit. │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ $ my-script deploy staging Deploying to staging with 10 replicas. $ my-script deploy staging 7 Deploying to staging with 7 replicas. $ my-script deploy staging performance Deploying to staging with 20 replicas. $ my-script deploy nonexistent-env Usage: my-script.py deploy [OPTIONS] ENV:{dev|staging|prod} [REPLICAS] Try 'my-script.py deploy --help' for help. ╭─ Error ─────────────────────────────────────────────────────────────────────────────────────────╮ │ Invalid value for '[REPLICAS]': nonexistent-env │ ╰─────────────────────────────────────────────────────────────────────────────────────────────────╯ $ my-script --version 0.0.0 ``` The Typer implementation is 47 lines long, while the Cyclopts implementation is just 29 (38% shorter!). Not only is the Cyclopts implementation significantly shorter, but the code is easier to read. Since Typer does not support Unions, the choices for ``replica`` could not be displayed on the help page. Cyclopts is much more terse, much more readable, and much more intuitive to use. # Contributing Contributions are welcome! See [CONTRIBUTING.md](https://github.com/BrianPugh/cyclopts/blob/main/CONTRIBUTING.md) for development setup, coding standards, and how to submit a pull request. BrianPugh-cyclopts-921b1fa/assets/000077500000000000000000000000001517576204000171475ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/assets/favicon-192.png000066400000000000000000001642131517576204000216220ustar00rootroot00000000000000PNG  IHDRݾPgAMA a cHRMz&u0`:pQ< pHYs  iTXtXML:com.adobe.xmp 72 2 72 1 560 1 560 T+IDATxw|>?)[YewcL/^H! fS %[n{]ɲ-2Xڝry@R~ޛgl14PQ5.&AΟHȈ6$RRuvZ3QD0+{g]|fɬ ^U&cLDL]"Q$ LLdb-$HeһIP)JHm$A9" sJӻRfAd!X)N]8V Қ2[^̩fHPPIxI|os"`!aX }Ow=g\ٓU 4JFNc܁Q' Y=+6lcλXsƍ.t0X"RL:Neu'Gq_|\֞ ݓ; \p "f"aI(jޱdޝS;z⫫qԌHD6<3ƞ\.8f5( N- ソhdЪG%}\IXb ?,3G<:G~OoX{kR38ɶ#G3={zOV ɸFyB@̤aFݸf;o3.ںwoy¸1CGהՔl"ŞH .AA31bcCc<yNt|Nt [H'R9{ E$`9.mdYyo^yϣ,_Z8(iOHiAL*twuV[_N??FdkY lxzԧEyMCG6npr#hGUCvˊ2H$ H%.ff0tS$f)+^x9Oz''wwsH'[6uBNy$/c@4#-a2Zz 3Ks _Z`8vݽM-L${ GԣӈzR`V|AhoK$4JATJ V0 XwnnZSuO2"O|q-0s~vֺe7GNx< ?j&aSiC1fE28)%=W~z=q[9iHbI,R-fbMB]LІӎqSb{kt7]_\y[9 S> ۑd}C\@}5\2YX>2m7&q"Q^I{|A&Xυ;1oµFO@̊#v?ŝ/?ԣr$ ]~7'|d/Ci4N-(,7 n9FZ@ ͺ~!- jAҾrҙnj2:nu;*d!zJ0A=!uY?߆Mߛ9fDF]/ M:'xnp`t` @L~rعa5Kn2L31xH_iF`?TŚLf!-h}< fH8;=9|֞9q[_ٴO30 f|y>𢩑@9<:fl XyfLAh>߽OO9aㆈtcm& aJJ;7 !`%r5{pֈS3K>Y`sY8ᘉLWx㥻ՇKb=o$ ,3ٲs>盧PQ>P@ňRflt?'@`b2$ 2l DwtO׿Y5g\z/ʵ ԝ?:c2=gg[*Sݒgؘޚ7+R?xxYؖŌ?׃o#'L?Pkf[7A ,M+- iݸn?_Z%k?E:cRcD)# îI 'DҶ$$@mZ}x_ ȟ=,v 2]`a HXHx?pEgu2I # 3s|$6,X'ç-3u׏~qG3<0O d@8mY3Aq:qSN(x~*C q\ 4ЩOaHC4HH̚<3`uMnYxG=DZhWeA2HxwIe Qf[HDxC fXP;v0 Oi2췿zb9ng_uި#="$نӨM;tw{O7\#>c⑓ nv^4I<:mʄxvƭeCdfJ lPc konU׶eO+7_=uqyE´ hN?ȍ 9鰃I`HTY!Nip0ffN=urdj* POJ/`HiIRx x2bf&biH \xJ9VK˲ЦeXB C ȢY{k5kM)e/8 Hi b&MRR(͚tqR;$Ɏ'!5+Xs;-c*{ ͤIٟ?^lhnp'sαOvtvu:y ٶ;s{Q':':ѭvJ![i>T4" x~+R|ohyN=(Ύ"_P19`0+sjF[^x@N^.dޝ-]}㎜4yܠRJC)NH2c `@(c%vlkkvn4k4YwwHF(#4 %%Y@~qn(`Ȳ)*fPa8J*l_ʝ Ne%yS&|~Xk)VE<Ås˾soƌUPo3ORW")H(%M!dp~rɗymm3Ϙ4i܈A%\amAgX>ffaMoz?Yu`T9$.oUJhb K>Xw/yQI{DG@֭^/<]pjSviӖu>_y)Z^#wb3'yKcH7>]R&|_?Yk~I+("} x k4{d0{핣+\OeFOczȘ ;%\~ŚX[뺦fe}"W9EPl_s8h՛ǟ~fQenÏs JIBEuRiRpMrqzt,  skS>Wn[R!ùJF/b?Q R-!p@ڈ} ׫[s,.|ӏ=p, J–Ll0 "$RFRO$T ddF[Ԋs]hWl $h,ZQUe͜\22XM:=?7_FtS" F)=6SAt@gI)ia(mj޲n˪|hKc}CxJidg ۠xB1"w~v 7ON͐Hh&?$HA+Ӌw0 ).5_}-n^l݋ҧ3ޙ;s>ɶ*~:uQ2!X3 !|3_8G7q8D' 2ޝ[]XPwb&`Оf6{֞-==rӬ0;wzٸW?UkUUjW)m [Dr#Yi6ih(YڻVn0qno~g}oaaUS>fHu)*m=E3A# ,O&ŒX9thwGG-KŚ'!J¹l_Ff(+#'?'*#d2w2- [әVB4{0")Cs17f{D)`Mt%aIiXyԖ{?/df>g~'~* O;tpީ>(<(b#1!iO@O>givekY3CfOo#N,L/? cscw/ GU~_?}?eל]48Xtكo_nnKylHaز{OqE5 mYPȉb]~N`o=zꈪJ_Mr<Z4+?F'uCC㆏ K }~S+GB\('q#&.](ORJ 4CY KLLyM~HA{X@![YYl_=3'MxǍ`!qM,dfuIH0iگ\5LcԸ!O'\OI!)pJ+J5)h4'Z RgZDlTTo趻ޟbɖ3?Sx zwѕC:Zؽk凟'(/ )J OnZF Pf*,ixʳ 3(*%+.J8M]_|>~W' xկ~\>201峟7g_ee|6Oţ&MԞB;#9#Hx;;ɶ! XS+p\ BE  i&nr#f YO=~xwf?Gw5>N9oqÆYg۱jtƨ? K{`(<"`ٴug8;hϥ= sh b{_|dYM+:7VWgdC`Fش d PۉC~@Jk;[aIW캇 זd'ՄM֖y9mX|ϫJD;θLW.[s͢ [7I$4uڵǜr&$[r=!$C(nڟboF `vLßkt]rH)xC䑹7t!<,=%,̒~'l8iǜnêxӸܜ^Aqa $a$ͣjoظsMw=tϽ Nav3s_yɩsb6g7v˟ںIkڵcKP<62<2Hf 7m5ȩuȔnOJK?_],|%ʗ8dr>zw[-Xtvƺs7sN.**p/aG+fj-?5v3Arct b xCgԽ=!ѥR@4su O߹謋xn'3g[VR9fPᚭC~Mmx w1Er[w^35e_^tIM=Wr{ɧD2ݭf(!HJ@g1ÇDDBkOxQ,$cdcCۧ7vvtmW+:!5vqLyP$puܴR%E *(/򋀫K)AH巜H/?jWE5dԥ}~ݚdGGn  U!&2 L5dWǟV~੧9wQG}"$PxwctΎ!5ʪghFiihAI,\4M32(]xʴ)2ܳrr UxMe[#)g_&?p1uTWy:B >ۦ򢂔֚ n,L:hlko4Ȼl :(ڢ HX b#MaJSE^X!#+ϪK=i+}m.w8cG}h߃TڀXV1' |xM?gl kuv֮2kއ@Y㏮)5|Ș+K3sg@q~a;JRLy#.B X<. REnq< Jg[p rb_Xq[{sј LQh X cH{pCY/hÈ4Ȳu_ܼ7Ŗ{%]M|c=_u_+ KakQգ*^|;n~Oܐ7}˜i K4'_HS*d "C.c{22!kGV #AV0#ȆK| !RaYl_caJ~$aK^w-et/l Ů@t D:Z]aiEfjF~bP53ʕPqt +r ӭY ?'~经:}R~U Ňc^zQN0% sp>7_{yGHD2F)U*2ۡr ALˆ1'q:$E(% \MR7ۋ>W? ` N(R C(ή- j* )ںy ciPFC ) h­x_.uJ#2w4}9++?r3_,xͺ_|%գKHͦ8Oԏa$`>\;KaQhJRZӮ%^;3/`"*:a*la )dzN=vokc4P/qAQb4#J($ SIDEF |yyB9iTCTe~S!+b' =ꊂ Ia ֆ fN8fN$$l6Ls o aZ)VN~n"z,ZJ,sQ_U `Q^=?߶noЉI+s:%4[X9{0Mo%餦Vh%[! %4"*@Kw&&Eo-a/P {NHLZFДd2yRPL9dƧK 3,T^H 52$DIƁܜ\4:.05;'^ Lj4gPF1-n 1& (֡ c;qo&CyɲoyQYY?}I6u?xqnjΊ!aT&]?@WH{|0RHO@|:Y9o762e6~֩! @Щ{==0DQBlڣOU-kWoeD?|9;%N}wǓ7,̮Gdu<PSRݰbmSOZPj| 7㐭=htAfxk&}0)ٶ mgw˓A_q<,R \./> `N#tַuz*iq5>:7k{'}HJyxo_qaC͒t )\#Tm$z˶Ǒ`AY38F*-ھwW &Aśt>qW_r<)Ct^52J.9#а֫} SI!{ l>E +(Z~ƒaM"SN9j/[|cW4moo4]I'a45tmY BVKk{GK k@Jfwfa7Vp"hdvTw|Me( PG`RtbJ)Qx cѸ $Y' BZ1!L :u __h-[o'_ؒɆQ[sms^\\]3c:vQ8)fM*)$ }GqL^M٥^S/ege03}xThYz˼2^wfNFP PꈩO5GҡP"O4<&dmh^w-ָA5:ZW/ /+$`i.:j}[ʀFoW4yMlef![H$5[L,dEʫ*6,5Kb iXeukg}-K\U{1r 20RCiPrtI.8۷\k6NK7a9k,k! 흦iI 8hr;.rr\TkK͛;/'D&5t(ixs+;mQZ%"d~U~}Og&+a5׬7XK UvK{"+'vY3O8 iNi)OV9vq߯VVZU*8 H4'f<)̤SV(klj'!+̬0% ҴW h  I2R1{o{|KrO"I(OILƉDWӸ;:ow_zȾclM- `mnp#3Z; iL&79l) eȤc;s& 1ǖgdȨ,*<eLZs03jN*Avw֐n^pTFFd3+&hgY#R N$up_-TĽPHϞɯ}Z$֢aCfϥ>(Ԗ d3x}Hd)Hndt4DDq>־<'+u&6| \U Ȭ.6}MY^MCZCr<ցH%iTNI>&lݶ(sXzLՐK>ϧv1f.uX2gSL["NbRMboe"I]2DNl ɄTص| *}=eJmi}uQK_tH)XK\1%0aǽf#SS;5T0::`a?3_NdE^ci+^;}\&7{a<>1 wsQ&vW>@cG0bT*$A'?9+j,\ڦFtꔢAEp+n.#rРRJgtItR28!I6 IFO2kɘ*,@mv>@WI a$*|wv׽ #3Te3B C0]VL d6&:[`9Kpa~jkyMg(`}+rAZ' >Szn8SNѵSs`XIBzW`^h .r-aYsGy\E~I Inݽ#F4H8==oX|07 b";?^عcǝp6Yfe{:j ;WaShE:;زzUt6l}M2;r]$?l-uvu^j޶]dXeE \bnnN+Yg}Ol"P]2M+iהNӎv97\{ӏ~;~U9zظ|4Liv$IH$HY)0|> +ōM8f_tOj'NڹrE"uwH3rp^k"wz"SNJ̳~t)WNv~sිZ㕏eٶwbAɼϾ6=hhrHh |(L&-`U?ŖD23=m8}izІXu+ԜGN>4i# Sܷ }.0N G?iLnL'4K1 d|n\t 3^:̫`ƊP9\K )% X[Kٺt(-OI苎lGp6|/,7چ>vֱ&=Kslfԩ"l%4p~>lNwEy1֬Z]XA㤙2?js {α>ضL-`'B>GEb8d]vy/xigol-*Nn!TR Xi ~ö꺺3Ơ42Jpn:8эo~5ınؖhk3 jDkg,o8Y{'djQdfޜ5.=jI\ L\[nRCB$Hfã1x~\>gP_lP@\l.(ǜr9ǝѻO|_q,O@{CG͝4ª &)#߾g{˶59~xӸO@%ctq{K J=eKKku֚04LC157;ڕ,<{mݶ0L M !dS\W'58N t:yPR 1BDJv9= 5=OOJCih}'eé>4v8nw0;t\rNBEJ)1R`sTKm}fgHе~;_.,i 2lpQS؜ouyc3;Q>Ĭ yY7\|/[mmMHҬIS<.\6БW!gزG *7[Sy\~V3~PF')O; f@5C^B% CCJԆkAcS~ t%IcvS3_*+)m_<%t  S(9anxâ__ձvSbhцmOţYGrX9F>b7Hk;`O;_~wFL=`X9R()!LϿcfD,N)҂I6nČw-߿cO6 [xes8|X3@ӵ̩A$ҀDR{Z)ŚDҐRJae-}QRu8MO!M޲êd(?GfI \6[ݸ.G1cߛu#{!&MZ y7wlX54P찤NFr@Y}?;uTuv@z͝ߺvĬXaͺy孵u#KeA`яCiEԯ}(N 8pӖ)hlC[wwl\өmrrMhnk{%w m.d$b7?\suΙ6|U‰R{ԧrh?:%ZfLK(gl' wxyk٦ ٲc44O(0yY%UpaHFgWwHѹ75vv65|KSRZe=O>x5*oySkזk9:YR_>|a3o߰=/TKAgZ;;}IYk_@\63:+`/ΐf';z~bF Ƌ+ryI޲x矌;LG$.`ԋOc' fؽaMVg]rzAYxs#/ ;Dp,0,#ygwO,^?Pd]~9c:dXVVeaB$TYP !E53+N^|]GGK[ghڹ~G$߽\d?=ܓ#"uIFsSWGrn]Pks.^zTu$C&eZl_SuT";>]& @u$Vبʱ 3]qoWeyC((j2K7̙qG@J&^QTn]}_.^~Gu5n^[?s/-ue:[´MW{Ft@߱}ć)C.eϜ5?<yUܝl۽_ak2\ӬkY*wD`S14rrjFy]}+ox7f}/:`<~Ь4qf~|pKD$P*ʰ(XYSі<Q߽}u"rgͱ5~i6 ^r];1z5Uտy੥3W\ 13[nٳ̬*"ׇ0Sf i~!ٽ^ݿAh܈qKW-=i_T;lBNQaוt (MHh5,!ECʆL||)۸nO]|ZNY$N+=tpxwߺܬ, `.*lNt@pwwv.ߠH;Fp#A$)^4~gf;~x 'LZ[G_xw׮_ZX E"0@*Dᄚ/߾fq,Ok"b6m Dr~n@;, K-ZARٓ9 g!5Jo׷{_^|#FO|S.k+'=/M0 iM_p[Ħ1S9zJeȪA#= "Jt0JGcO?}oQW]t^&&$-s$IO-ؠμ//Y87KB6hPµnGlصKsN>;osVo$vaIQώ6~rD2fg^ͷ1y)縮 sPYR[7)7(')pW4>=zt խ|≣|5Kodv{ &20X-u ~oec`?>a|e9ܒصs7gy= Njq4-2 9鹾k=]|)Nuc)=Dτj}P%@\%s}3^VMU:n^^6][0>ud O(6Օ2ԽN]C )U]GTcg=fjw=3rQy"50LCڍ6nZͫ~6!_ Fd -K 6UTTEVfS8 3#fk6FrmM2?_y MT5?_m[/݉ 6lXkgw)}e@1X[h_w@bTI6Q_I ԺeFHtO-m FWUU-u-$Ɋv}zЙG-!3*Ny k+}exc"H&0k ϭ=8TA0orɊ_~9V$q hC'Br?w5q+=B6,M )StB wf?~]vVN:kgz_.H=)]RRƃT 9 g?3GXu&\CJiy$n29vlS]f>3Gi 4wڨ R..(|XɋMpQg|+u˕ENŁ@̇h.-%Uzxb QQ7EV̯bV7ȹdd$  Q/|`Āfb ~ #',^{W.sHhϓR2R+,S}Ț;늦 s2P 4JQO.A$̔")v4 J+T$#YO %2 ~)sn޾ #x0 &TŊ9z,ɁˇRc֖a7EoGcmܽg oHJ9M^N)7YeT_x?yjW{^K=I]}Ѽ`Ccc}ۮ;jE܍ X0|Iyj]X=W2tʫrlh oB3QmƬ7*[s0LqJ"@ж⃥5ٴw?ppO{a$6=p.> SIuV0585: $];Ջ%C>j[nwO~ticۂ|Bgиڲ)ey%-ёS% SqSS&ضP`Pyo>M9^DzTaܤALR.?go*)q,ےe /޶b[4DBvr 3ȵF\q;ۛv/^3|/Fi  -$IKVpg_~5_{wm!mcO,c2li> ;pQl?cV{]O.yWWˮ]?OmېҔ$°ѹgy_yų}AUIx 2$;'3G/!oDBM&iXN +SВcjퟮ+ d7> 8Yefww@$/TOTow9ťEIϑ}xǐDt `2+c5~ҺqBdstgWoߺyͮ5[dC[,^rT8ZމD@ӆLi[޼iK 'qҔP+!,>(#ޔWy-у?DY/>ؒ/j*um;{J҂*/W ɱYϻkNvgeLĽXf !z5gX He&+d74lѵ?:xR=$@MY'w=jha]V6vu(Gv]/LP^[)}F[Mx}E3 vltoԳϘ4qc6mpv+Ecw~Y``)GDyN9gcIYUgsrE\W ^PWqkg $L?p^=3GO4m;.g^5;~Gլߺwgs4)a^~oHm7tz|RF鴋|rP"%X `YmY>r3&y'?&_zyO=XՐJ/)ޞ/>ST !tC]sB?$&U+rW)>FzxTSBẢڧ?\OP`[k?R `9`dI];2ϴ_ ZF!UJäֆd!mHޡJk ׅ}+/$酡քgɀ_ `G "࢒PeUp@no~rÖm ^zÖzsz7KwO|˶|5\hټc ?3z3>#W[lؽd+EdWKJ?G0`k WBXԋ9f>t-$ɋwvk.IaiC* ]jFz/Dg[ǃ?Uwe Yr ;S{بa U<83CgaEnH:(=9mwF fBVTevw%::xİ1 - ]?&"D[4{nbM[o}'9|𐚪P~dW{EkP@t//oѸw~ao, "}kB T65a>ԖL@dv"셌W;kYiÉΔI%FMb55\닇4iX HCZW _1Ŝ:G9fz@AĆ "jE*'Mb?n^XQ+r)1dh޺탫9<`e#J4@|1Np7AcW/i! f$t"xgstc~L"0zRdV. k}z9= ³BכHb`M{~f:A HJv}Mn<ʼn5 D CV/Y #0!C0CB fV; o VUR~ǮB{ %y]C7|Fɵrrl\>dȄ  Z9yO? APqҠ~x;z>$`[]ۜfHfH7dQ;nI1[ԬKH1m]Kl_Vnl5@` H@^$˖K?=a6޲fO G=W>8#'/Ryee2@ĤL%ް^w>@(b5NޔeGtwv9׼3 P]9מ߾͔fj!aBBN.wiN w5%v,D]`gγ<Ԛ |r%v7~ePbBRuZ[VK_>}sO'tp~/^40 ad,hpbXa #MhF6$|g뿟 Gy)-rJnkJnY/&l{Jek?fN.=GK-.> oaHpڅJN4`7?8}<{#_+_{U8ZJIDL RU_;wϲ#dO3Py^*@0''Í .z44젫\>@hc,<=<?!KyލԽmko xbM׿ʣk'&`&ֆ7V\z};6lutǚk$b vnj 0g C4Qq<,ߨCX nkdK c꒩'Pvp]LZSRs]rm7\z䉓`sɢ_]]YziY+CA}/}r 4-.R*SD,,@uSdgeFض5h¨3_?~NId@H(V<#](n[x]9ɨvԸNI(t}¬|5b֤ sW#Q5CE.0k( |gk)*$UL ]2w6v$;JCѸ24XaIIǴd!K$])1.Lcw{CsxUmܲb]Vnc5{õeA'E9vv^}e3?XFkCÖeK?jXIM^VD<Ҟ![rEk^NNV8K&__-0񵙯WV}#T=FV±*2_|)w[t(c<=: d' XJ̞b!;<@~e'9 !qdғ0iI9I'":5'ց1ltGsD`H 9#*AYd<=Ӵ$u{+:hW"ǟe9m;c"p=u)U[ܰF{<{1%sW>fZt۽zODog^jM0yǠ5յT .9H{X.gJF\\.P1&6IMt;TF8a9vs8BpQc `͞MC*k8a U" -A]U ByӕWui۬k{o]:eV--SU#h+E*۵蜵[=sSce [,%xzK7o4Oŧr% 9ɺ NDlg=7j'w-uDmLsw|ljPUU I.CL$yD&t=| 56 (A@$}҈'o~nV#欼m |"&ŷ8${8F$"3 5<ҥz@^Ⴠpg(ڎGM@EX+\ B$ʎ~e#` 0 Uj Gm[xM]wePr>72yfY%+o-%%Բ+oz ke|5ӮN`*v[䔟6A=I0k/1QuҦ)q,nD笺؊ّΖ1-,,7pKC-fo5A5kz![HU6YJM1f{"?Ep"x3>ܛ/"qz fPowi.J"41UH"S X p_-KVLbBxX׽{,?e)OpG|~c@c$I@$`{Mυ$Luteu{3ox¢0+1[U"cS>C/Ce=΅B[4SRFcz BޜB~ګvKJT a8a, CFm{9ӗ3fd+ TV,V);qiLP-f(88_Ic?'ǘUcJ+an⠾ Y{4^k߹;,Vh`?#c`{Z_ mN!>;}ﹷf<|6sq]Umrzʼ?~1/BIǽ v Q5,No+Lys CУn}㌛X \ݨO '57a1EK"(ً!乇9XsS^AduT̄DTt01΅h'@ !jDZqֶVeA':#"5 kԣWϷ~^#F '2L ~}ܬcq릂f۹9zF% /-;6yM^oߒݭGßŏ]Nol۾5≠09nJKtpD>[X!{3_0oXȄ9,Xpf~JaF˺v K|LTIuէ3ueCwـdLfff\+CQ̹Pd1[;/)Jcy1)$HJ ]sZd1UtMmh4ZzfN@ =g^zZр`, }fK^pѐ/o<ۯS,rD--dA2aDU-$:;16HLuȆsƝH0@2*Z_V?j#>+#a֗4|% 7VW[T :؂ILQ\snr]r_csbbqt0!!K# 1SB~DsL"HTx(&Tٽ D''3FHG IqvF4bڔێ8V)q`rNj! 0(m:meדHq)*ͮ`'!4"uI\pRDUG\O\y۟ŘN)I9;mxc7Fx)! @ ֔ʈ5:iC2̌ A ɻ06ǯ2"Q_ 1JwschML0458txϼNIl %1uvJjVm2)Щ@[oC0Pՠ 1\[e46Y94=^.8\*n{|s^0W;3{&F(7y0 &t%َDtfN$ܠDBDݙ@ VZ?x@ݶTpIv٫E҄a|ZsWUK֔쪋0dmcC' 3cۜf6D1H2eIB9Armݼ4MaRPzK6E=:61[M \M\ǖG'St^FYx#?fuLTe ތMVh$lkd#r!@D,y+?~3~crclsqا=pC4 !=1$nvB@)&X@n) BDF AB {]I)L l?dIZ8$K P TjPk˵]ذ!IqZH+6 & D‚Ƙs)ni5(VH-QVmi&T%=ιfwaғ# S9̜g6,~ K4`!ahg̕W^5᪤ĈYYyŅef_:nt,~ߚ_&9m6b)6'FG"mLl# RD,So~WX1`OuSDac>_k)2ZbH~Ci #3"B @8ũ)1fQ@zÉ @u@a p.@L5޽rz>5;6|Ƌ/L0!tC`LK'W 1.QOEc~gK,- Cj5 P$EpA$JC:!  XAM3Imjґ nqѴk'*&~gQL>KE1ɡAL 8i.f oz;t Y(t^mg[o,|C'b$a0`b4[*K|˂pov1-r?"E!@`k[}fޅPGHƮ#n_L'2^` m08zĴZ0elbU"VIU5@4V}\wxƕAGxIk D5'3*v}fHciXml"TrT„SuEV Yiʺ7*50%c;&&ʌOH*[7[K$_BpPj;dmWodf_ )t_,]rUK]']9r,6բ f` w^g̽3jExaʕ ."iGQ|*JY $8BrPU`#!h0x"d=b)YwL ;1N'JM+XaaY1J[tTڣw+QRC:|øDWJFLl|lugp9HTe3j#&]t{lLXgđՆ-H\p3# ;`0dQ怤Ccà:#dB˴BMV蔹jE!:ΣnD;x[-9ceךeǏ\MQq ϚE|20ʪmמC/~1@Aç6w^wϊ3HT\, ~a e5atY6]e-JW4v!:p.(`pA(+޸WN^ WA;.,D7&x_br.$c~9yI)qG8qd8?z}/Q_v5S suθjH42Vй$˚01tFeRUJ%]7 X j8ԵȚmG}=? Aͣ ix'Z拒};$'@8\$hѓ?`,iҽ7t2X 8(` BX`!`Lਭengd"k ,. p-`dDD@h pEZ>Ǟ4鹼0;Rt*&o-QyRG@LU5e0Bf^Zw}k͗cTS#G5̹ NàaclqKu׆C&=5$ "4U@]u'vm)ټɋub8,kq7W6qWd%P~mu?  z߉Ww5$=#fРGmc+Xt!p8;t@btZ!!0!`\q3'/fzs~L89q"cܰ}+}흋ƌ.T(R^[i$fdAi6CYh+X]333K8`qOFX`SoLQE "2Tהo@!]msY->W^Zqu ԙoȖf? tT4"))`@ k^oxuձg i0`ڽrerjXs)BxSU46N$-3˖Ppy%0v6bIDcs0֟b猁@X B Cε;'nkܳgDD蜓cў- kkڪ̜Vr5o~μHn6J?OBf"0 `P]_Ԕ*ph#\{ʹ={v?[׺x|}Ͻ$UԭSvRlTFb\BTTLLa 9?ȏ|;Od"$<5*7+%3#&:fKb)`\Zؾ#c%C<-Ù4}>2BrN|C .S!3 VX½/wnͻ~KG7z!79%ZH[%]'7j%JZo>У;,:mY/E{-Ũe61wB0McQQ|mKt{bڲ: Lb^2\I@<`Płu_~9iU)YsǪ'2$^I.κ 0}"8﷥[v Κح3&>990 *HŚcmӒӣrr]`Ll8h\z0a 1G 008"`%q̈́AZ4YeKD (زfO}lZ ;e'$Q"Yj2O tX[]k޾}_~;km?=/ YX"&A1@iYH"\CQvJ!%,@hLbhםO6g!'Ip88+A3MW9/!8YOO$ #'7TӲ6A\0D1?5mUsug*.R|ǒiuǟ~%bbuy,̯aT"\^Jl c2]%IK`N!Y# 64]G 씕)*n߻mæ?伂aE٩v=11t0DuUphѲ{7o&SW^vٛѩWܢ,kM aqĔ_6ܵ.զ+5'DkF(] dSR;eg奂" 0@~n$0W5"PH0MzzvLOKNIP3O/*zw|+F@H($R;XWrM5 zw`ʪ<уyitm3mmCaIAx]g%L$NuwX0dE$1Fs%+gcGF U(1FH6J+oyUGK=R5tؘx}Nɶ(+G\3 Sq}5S=ϔdpQƹalWݴl׋zZ]$-%FVh8˜Ж_-_a#{"LvlwdkRTS]bnA%GJ*QO@3xx´(y0D`*fI Bji]pjwPebǾ`Nc] (…''X !8c3537Iӌ|)D*Hew(+Q0ٮfG3Қm~JA"BJˈŀi 0Ttθnhf .SEe&cBs,cS!3 G@%mU:sǚ ]8 `4xY#khsHnp0Ffq{% <{ZcxL.] lQΜ8\fG'6y tf q/[}>qB10Xp`@sC0ASEeZݯ.*0z,K6ҪN !@5s+|K/Թ{kk R[0ǫ69DC!LRũ}ÇI/,c#Pj]ƭX_QZQ|tώ}ɼN 0n؞=:^ )A D'x}n)4b Ng Le)Xg @UU :#*^KzqAQ!0F砥p9-;κ1Eh0+s.#o0S [?xڜRn| `H'\"{*<;|kqI1]vQV჏.NJEr*SA?P=`rr97%_hnE,Ei(y㔄#T'^5fDfnzrV&zR7A !x=2)BsF($|yy})+"}qDVXnBs,P0)joϺa({ ep X)-n<| :  NRT!L)7+ VWU[2^lV,~]lc 2NzEuMo!Cg3D YjZ"Փ!EKS>1#׽>qYr:Xz󵆡;tGl۶HY=^!bmr $"l3\#ˆڕ8{Z\jzM{ݵ5u%e c9<$]}C&Km\`u~}ч?2kl=cSr:tu%Zɉ82T @kX]n<ڻlq.a` SrRi* \]} Hm7)"m+!HL4DOVX."+ǎ{z.`Ϋ{~Nȫv|5{Vݰ|Ѹ5p줋 ӽGV//_ÆǭRm7c,`TԕE\"Q(VUn ܲb!j_fx?|ǫ,j֖=Vܱÿo7ŷwzѣ&Jvf573G`mҿsmk]s?@NaM|浧f MKIKMIiٚ*BHDTFBV \ LEKv#wn-^p'l?PGw)Dp2͞Ma(`  GuIVnpP.|`ǡʧ= XL-V<xjMESJ0a&T=suʹ{YGgLc Hģ"Nh{l*›c|ٶ TïKaa[@U`?s:`/0nhXwdˮz?pּg"Ԡ3)v WVmJxdƝ3_|̞`z?͚}KW-\w_z_>wn-+9x!X4LzmRamLH BvuhQׁpI]M',u8zX o}UKjZTfQF$W (n~X"XK({[ u7GT!X8&G(}9=᝛ "­j%e= T1\ԑ>FjE\7I䚆H_B%JMmę9BEP`׏۲d%cs%'jy sNux|~#|˒?JOwZ㲭>5/W}_Lz$3Uz{o.w$&:?zi iW\~:(JM+-N8:?XzU r g}f]jNTNzծ\1Fa3=ޚJ Cp@ĤC@?kn0Y,vDF@n԰7T8;uژ\ZZtr5USubQ4]%s]ղ+83>nNEP&Y˫_#@e(N >i* $SO3h?A1<#'Eps)# @\ͫv ^B)$v1212p@M~|,pXk !VzY2םV~< i;],.̑REs^[2>?-lXrl޵Gl{~EI=mˎ+g1ǠMN{,M{'|<燎]:~Ν=RCc&N io>zU%w} laE90c#jA9̹p~aXHC`j֖ڹ( ah$w{boWBW k* pmi`ۚ;֬:C\H.]SC xs*Tf8Ag4T!BCAKX 1 k|;͢ $3 ƙ ĩJՊ1`LpB ?ļablcDTL:E2hduqØ3nW+ڰkcVo@(~}O9S]?~S&OkZ!j:v? zxGwpprztlnj\kܓQ`X[nh%{tyw_~l:0o}T3+>vO8\C9iWgcl{]uS7NQUxx;\WwӬ w^Ha vv&pYMvS)55%:67UMj@U9dRwEidUEpΘy7,@j0HWᐊ0f\hEgA?Tmṗ:O#l@ɞe, 3*жSn?΅=߼7'TUݴO?R_|na93qW0rT݉xt{o@b'ʷ_2*W:r'^gHWJsR?HIOL׿CWpDD3cDЩӟ?'һ3ׯuXҪf{Nr;dt,wb*_ ];/02Xsܷo߼DtGb~=wDT \S$)k{ԂԬboCʣH0C Ŋ6օ F)-M ^\u5y c}YŪ#J1>Oאn_9Y1] YrڦW~c lˌmnԃ l  s`~z y 4]cHs{!j+;5uPY+yDٹf7)&V*KJ۲oÖv& SRYx}MЛ;?TYPr wauCr8`-Cjlr2 ŊqwۥOzAibvMA]<ۂܡmoxؚtNX,XlcK)JO}[^ji1庉7\t @ SJ3C`A0p`dERt,Ce[6.E;V~˳x$9*+րvOغoOb]G:zT[}i y? e-ejlQ Ez߆$#3%=lMG:Gź.uR3xPS, aŢDWk߿ :ɖ,ª.[`CMʼna4Z ;W-ypYi5duӈPhfű-ԣz͟vFNFX AVbw@˩ne0:olfaFn=C/WX[c[a)\qX tUˊw|ل{43;k܅sOjA^k@U{O3sb7eu~t[ .Ȃ!` .<SQ~4V'X6 nU,R2MlY_K~\L8.΁~Of> IsWVWF;6-sTѰ뮱aALMGTY8B Q1oK*Nݻg럿eה4ݵ{Kos1B38uwSW4rӳ͍O-Kç?*^TԵ06>V"D865?rY_0ĭOHSp~Md *65+>{?P[twqrB2pȈ={ڝ1WHb)޻Y_w]"hGw>k+L!t`N*Z-ʖ+pL9Fo]n~wݑ=v51J74 S!aec͟|Y1'%P9Q;+<;F%,j9P* /ۮ-I,u_>{ %a&{ۣ{K]2bا|}+S&_Z'ϼWLZ|͗_}Yu*$8P,(+Eg ޾?[!uHU?x>$=)Άڊf~AޣEIKۗo*$dMKv8튅ā@ֿpw*軞< Eewl/1 :]2-OM˾ztۻf/gxvFx !>]N Р[ay}zp4omӱw~ O<:=5jt풟t7}W_ȽJV D^еK}iy#wm%8ZP@. DeН *-;i.7(ҲL6V/Z; qZCWo4| <}J|e'f s׮}=.>TWЃχ^mzJu T02E#NE عA-"A8X?pUϯ=8wޝ\oeӖ];׿ˮ;`zIpڍ?-/i`FXc*0UqXQ 8 C&ys`u9,_vU-Kޭ%crn.+ח:Vs 5x %j߯sP&L,ӌnS.Qh0V4744ݰ(,TϴA]kC> Q0KNv%&'IN{f^~}|زkE8G| X:*ڻK?t%#ږdPVpSg^o8&O{wok7_f}%'%-w ^;G׭?{乧nOJ=/ YQ" j} !bq Rr."Ֆ5̶wLx7t@@h!CҲ{яZ{?d>r:$źfndRr%xPny=T1B @)4}p9duԌ1'RB\MtMP('VKN}q=527zD$‰v5[0];6A@A6+A/d aZ[]ҸƜagm/9]׳{hȕ F0TUUv˺꺝q 8c>wtF43 .oE{{jsz]saDr^ θ?/RY<1϶G77S_\z!oA''r a`,@ӿ|޶`$z9@B%u]7/?1~e)Ii)H=Gs吡Cj]WSoMk7&ز~wMU/n.!J"$D;#>ě5G:yX^8##?7:%Gթ1P|duYH2#]?t5_x{טY~A5DmS`\$YBB `'yfqJ0nMq+< o胛l=uCG/& FoKYnK?B}F6CCjG[˾y$ʾq2z`V )QjoXnNڣ{nݻ$:/tؑC[贂믺ٝ<`Df=VzoK`1IHtF^s#ӏ Ak!}U?͙pղBc[1'Q)Y 1>%UoW㱃Tř8dXQcVWYv=.9Pu-L MMIMqi?|8w U"9c0H#!@gt"T6!1 5i'`@ +pU(cvb F2eC5|a IwC;FPOܻ0O#G7~l׽wљ )ݪA =PUgR4Lc0cB t;qqCzyAx~z-/4 WuٜoMw 1$B ˚[ 5u5C:W!g,v @!WqqI'N8 \*'2N!@ x%I aWC-AԬ/ Gܹv2;g'37Kp3$d$d%s]S|_߱gaFUq912c"04 fPV ٩Hlvls`SPE#^99@.#*rܡbR-ZX)DoOS]I $qa 11&536.Zšw--p@ىļ%EA6砅 *82edPw<Ô PxUF)Gܴj1c/$;;+jz^3HB`,Q B>Xqd0 P ,;R2r:̸8qbܿk:.,͒ʙ.@JJM?ϝM?vd5/zglp˃v+opרhA #]p .HX18&HRB2' .bʾ D4K휭e s!߳|M((hR{CwmJwFf:SaBp :dYm6#[ Nl HF".O<jw@ "Z-J?Ad$DBHrt|j~AZ>zhэK)cpzge6+D0B2:egtqو{оCw1SzE]sbE012ނ㈅c+-#=*ITE' iަJW~1Ӧ8oK㏤:,1&vƝغ/s rq&!KX (%ɱ11v9B8ѫ[<%%qM>yLuǃ77CvPo*0cm-w3"d +OzEo.|jwk(vgR+9,N_-9Tvx*gTX3 @D d'YOF՛yW'>'eƨ( $Uւ̎]hptZ!S^Aa|ECWn|;c!{UiHPCv,ڹgat9j~i:duֿe׎)O 6 ImYEVynnŋ.8fIO੣6`(toLJQ$!keS墥_$8Rf%$%Z%`aώw>Sm3ڹgi<5?^wm(.-RtXm7")BiqbYMuQF +7wDb:k~_P_WJ/*+kP=>_]C]eEu[mBZ%ȡL1]A@j95; PɫO~F0v&4Êsiad)*֕.߳вk-@zop\@@PkØ׹6ِ%l\9mFk|+JaJ*-uhIe'xetkfLJCA@@[ۏrI@ڹ+N`ڗE?Xy&x߾;/CI͑azu*8dPNFJrR"< }>lڦ>sSbR2RS8}Z[Ss2BA"HƂJwc/p/|i 'O07bs_8@tf=&fM#JtUMu캱{ujQ.y= 8gHpdjG k4TUUʫ&ߛ>sYL}@/6z>iYvW#*6*k븎$u.(^[IFcV: D;p c6X9v3ځ9㧁`[ SYWsH!-hgJ+.[gK'_6>@fW_,G>EPL.EΌn_|I셿捈wdZڽGGq:ɍi8:Š3=ͮEu)D8!ɾ~͚1:+2EcG~۷AD,=&]9,?/MM5ıۏX:N/\C4z q AHAr LuaGV,F V;2}\Һ.a >ϳvťu\g^z#vxzgO_ۼ/S]!z:kWaǍ~+F'QX)!K8G$>]QM?WHPMSXXsxܾu5Mmޱx^u5+#gL3ʏ 3F8HB1TC@b(RBR48ts֎D+: 52aF୻"rQ80(@`SMɺܬvcpw2sÂ9K/v9ub1\r;mG6}zv̌η'bjDCA6S"OxcA6[;Ԏ#c*QHqepUBAdFCUS{vin ._Ea?ꋺA ]D:rǎeu,~moHԁ{&J 8cRjDs34v`"әPD2̀MJX^Eo?ߣ{nsqWUgvR }/"@Rݻf14&ނy؆K!q8(KHu'>} ?r@nJ=ڦ8l.D: NԁM=-ހO5徊J5+>4߰l Z?b¶W Hy˷G9`Xg"KL7H[LO GYd cjRnZTUæBp!$I7Vwu?)9L7d~ vB`c3kԦX0!α Vj'? +Oj8]f$Yc-D(#O6{[P#L,eIx].&j[|MJVg{Ұ B>_͗ny+v+Fo/ۻe¹Y׳zֻ&ĥ!IoҖnDaN{G*M!@%=+6l޻hvԈiSv&_p`y[+A`f8*˫RӁhkhLqa@C= dh+ 3{dv '7q,aU/-y[PRFҜ?$.=Hc& S&::WpQ\+[=Ɓ%Iq(X1<ƒ@;3p!D9(~nj{geò b/[8}rcOW|+JWMI9hӵ%fLp8~mƎ%~of0"VSIb ԙ5V]hw@.0 iW mݘ?!3IWâʨҊ]V@BrtlllXE#UBM`L@a$8sB0n5IEv%bSKxz\(V97?F=x3z )aDžuen*V7_F|L-Y>y[-mpEn~b#3)קGpqALٔ C}C(iH*\ kaW#3fff r~QYMu(|v~_Vu^Hût[cwڝvHq.g}"a**s`h*)Ȼp!@H0?O3z|LM̈8 :ٿ-qormtt1 /8IǛ&?2(/RuPnu[& `*Xɴ&Sy7^iyQ]{wꘟkrY,,I=8g<.d:p=|=q4:Qj\̯?cs-2`Sg%A {:b`#[5kkR22\5_~~ Cϥ_bc7hFaKX8o VTuqnE\|˟ ?s좴UU՚ܤ buY.V۽r+ a))1Ψ(JB&#W $o 6Kܕ*cS;2eL_nj͚->UA~B`(.hh lM!y2C7x@7ohuhD8> B9C`B,vo43 tS魌sPz$[rlr xVQeUR ٿ'G@abQsUAݏD 1ߎ# p4 P3nZSW+!qU] >R)י%{z^Aou4*'s+֯u?ʘ#o)-TDЬ)~q P\v"ƕ,IDGBf/Gr9@5OMjC`Mv8-QA;@c N|߂T"} j @B&&T\%֞ѩ" "Nc<Z r$&@έqʯ`$a΅j'leMO^4{7L&OUM݉jN`Y)>pbҥSoHr)"QJNaCk.eqf?'38XcSL8p L):xp_\3q_ #faB/31 S(&TfM6:Շ!q2 LBIZ 6U].0!܊9-byA0"qMe ϯyz0CACS/tQVy- ro5p0pb]pVcDˮE9R0ƨo\u_^6 !Mܼ7"wf?7^=NBEG;tǿzF'L3sb\C_-btq@b|t;qv9p. l> R[  !^Sqq !AQTW,IB"SJ8L0gaN(nP1FJ %79 ~?/R aM-?lk:/.WRST( 7tƮ#Lc?uȚ@`5IENDB`BrianPugh-cyclopts-921b1fa/assets/favicon.png000066400000000000000000012622651517576204000213200ustar00rootroot00000000000000PNG  IHDR008gAMA a cHRMz&u0`:pQ< pHYs  YiTXtXML:com.adobe.xmp 1 ^bIDATxw%Wu'{W 7wAjVNPA 」8x{yy8`09 !!!PlnVsNUQV7?9uϩa}W.w 1y!'zy^=_=ӾU@I(I B^LJC%uecVA(AR<3{S"H4Iz c &(ϺKSee!D I$ڝePY5ދD""6c"Dl!-B$ "3 UBFzP"eU2rQB6I?FD"``z@5@88!( lD" &8B@!1$F 6P1@DEUC "1@QEIEC$ׄd #"Ƣ!%aiWUQ̚8>{ھqa|E/>kOt:D ~viz DYm4F]4z w'Ҳ 6z(Y&զ&6Z- `SP C֑`fFU"UU(W(`4 d,dSXBdH5XėΦ R"4 XˉCEUZ1l 1wYU5\LzS" 5>y#e蔲΄u+@5;aO0k1s;iJ 4PUjb#bZ-#|+18i- 59CDTKJ-fc@E JDpWŠ%C!VƔY-b)J4[ɭs֐"R!a H1P`fپzW:D3OT1 TŔFԝYOZJ]= D `DmKL]g#(Pk-|E&BTJtŕ8BZ caSE(C{q#Kߵ :bqy׬qYZ9#&$-Ce"mJᘚTTA`kE 2,dx̒ϕ{ P0Ce>sO`_:{5H*QTPP3Ojl6e@"]g"!(%mK5*QEPׂabk—&,K@@h0%HiSZ@Z CSkr932n$W a֚2/jMZK51Jh9ac'&{z{aL;. Sa,bPR0Q]6ߝO=tߠ$V1fy]N`'V+DLl81%>wIB05>^\-cDbs&Q̆B L? -qҺkP 0)SJtJd>/ce`$2㋋M>(Dv媦RUH*EY4K9%P1$O:@PRXjOL#8 -ز3YUPaa`ٳRHdizz Q3FOToABnAh&z.iN‛9% ,Jn~8Euzf:=3(&*WK΁ 8#htfENȒM aJ:9 `N26)!BV-՗ \9DO탦/21ֈjmA¥TF*b&1T–D 8j3L"1 {S6~VP^ny7/zRRiiF92UɔiP37r/e kU^\( K$Jq*mĢf8 A, E<(4Nf=䚠E=q ſw}YTs[A$:O%c HHw+7F#j JT0+"@2/UAlf6{F,hJ ~ƌYԠ23c%/4ȴŊ82hi(55@G^B"}?yOSf.F WYҢ]VQJ QAI>xk)"1(`ֱ‰y/d;a@tV)VTxL (QUs },ߑrbQ2(d1,r\IUd&f3jbtbeII⢈DQ1%U_ri֍;f"261U9k1|0l(!lH r0KD;U TŴe6~MAD'|Ot$&ⓄHh~/o@G1+ǝ_s3PFk -Lv2E/ցҹ)"Rm 6*:-rRL| b!c5IT>MHLi-!kZb!!65" &cLjXieuXh!+Mu^,ԓhWa@Z|AѬ,KZ,@X6y\ % )ۆ1?uZI bD2tI I QgD6CPĂ >*8嬳ԀD+t!O\fִz,oYx43h3['I=%3I7s*󎗣Y# sc*:]TNҙ:fD&6a&0JIEQJрїy(*lK,1D jItԵg2NF5A3<%'sniє'C"F-"}}W`ҷ)dt*:WZX=3(C"(BE4@|o8 \_T4>լ*QJ`9AEslP:&H6C "Q+*.ˌ ݥd_Zkb-[F0U-1Xp2eyGd:?t 橚>ADPNF L;xzqT^|v6"s>j·,/$}Ƈx ٻN݉<_NO^l֑JLi-#b4ETEa.qYUx* Ah' T'Zv6f8Oc0<+GFgy|]IiN'']=}1 +xOe,X/z"xxo 1 1Թ4b!b8`29!Y,T 4궂R}0>jZQ( 8Unw8g/T^woV=7*QDf&fb FφYC! BŦN#Zx1TC%u+ (g̠J gQ?s v)R7S2%φEH caUl+}%Z$Ja 3UfքU )m@08!5*';1|8+E>Hy*@nZ!($9xQlMYITX&q&!m&p.&x6\$FNZDL[&) -b*U?Jǵ<PuPz Df*JBlTb*r;nLSq5 xꦄ JunH:vG@d*[& DX-\lM+ڲPFKyLz@Z"T115 -q iGl &Nk"Ę2("_.H S*ݺ$* )VƂh{s4'>1k@(`2C(e}w>hs6{%_λK/ͻ{oCKFy7CZth Ll]E]g3_9o{A'+=GZ(`;e˖&4+q `R,Vt1jKQi4Au1i5UQ/P1`(jy'"4dIxmӋ+$qC [(#(=229]w־|+WXꓟΝϝa=;GZVp֦o>C/5jɐd;u?}de=Qk>O=|?}~.&ET}jAHֿUA0i)~^+25c:__@SNaL@D8tzfvyd*}~Ɏi8lX%Fw[?٤y:R򺫱 I,N%Kb& bY`tq%S}j )2y82Fi2.3'M'25Ub!%:+fT,C1D&|.̉!$#=C>r׿~[|gw}\|YW8txرsHIn۶WUJP0wNPX7 t߽;`UޱdRE;I,YON@>SV8 JqVԭR) 4{}H߃t>8ʉS=iK}GH e霵у#g=##rmlcG/]WAc:ee(,`u?ľN/TF' :wD2e44NU*i`&c؁Ȑ(L,xVs[p|_]|?}ܿ##߸"/KľvUbCUEN]k5t]U qѽ{z68LB1IF bR~1Z-=cQz޼',XH caij AQѱ217?l)'ZG^xO6SH #1e5SB32v:)x T1iF3݊d HG':mdz#$֜O}G9XH[Me^-v6q6xHUtʺ*CB_KDCر2؂kӿnʍo'f32^0N]u~ʸb뮽f{)|ٓ]+ oyk @ E(kiwf(qbg_β6>jv9>:xѲg>}?nO_vONt&y衧bJu"Ib,| k8\cdە*"9 ak L`UqGF&ly'蒋y@Q"~ -i8`yyɦQvO'[57^iziG\VC3~9X)fQn v`>hT$4 6#%+o=8$T #b&CD H\b O J9UBlFee]{+-׿W]y-?|>+_u%=ƕksv6C>f?o?} .n4[3VWUE*ѥQ8] oA0h4=wxx>'G 8OLmZ@o0 C|3Y,snT55P'NHE\Kտ`QP(PV@ 1v{r5W\qu{_wڑ%W_.l_~9睷~Oyن+VhMz5g]pcn~f߸Y'5i>:Cɼ}Ip$K\M - ivhV`*<ԉ jhn1DCv>'iA'xIL.G<6r(* CDTQձ! J0IRH|f>Zyk_qR@,@{4zui̝.. @"hx^ OHYLY! _͇G7{Ԝ, cf譱%AUUE$B5H˗=z(s>>s>ɏskKgږd kp.ȲZYY-+C~u_vݥֹ(}Ͻ'Njkۿq冁xfGa R~'>/\v.t>$NNh>;!vn9&hsD@ZߦMPn؇YUFwNR4o>(Y,uT4y;gҧ&AU0.˜ !ttݠs7`*Edy*AKWn}_X[O۹"\klcY}FS;v?|߿ZwݕA&E&٨x%%fPuf^pB CH iDh 0C/ϺˢԕlRUt&9(`%b G>d[wС}=\}jGҎF#P*ѻ)ԥ6B Ȣ%$F y޸}Ǔo]}{sg`uG/:hni2fZ"9jV67J($85?z#.~y?{Fq:xoE "0t;^Y B aꜞ&bi2 0N SNh_ߟ6Wt3EV$YGc ublop@d-=_ݞqV@EZ݉PϏ_FFnYP5 -;6y8J^xvρn\s_snt8Ө" Sw~)iHϻ=m;m6 p)~;ߺqϾG><4شejjG;@P[:*L(x^>r̫g~ިymW^e^|]7}%Wo^=21>.@ u\t)ap*o4ע(K-W;/|˻ 6fջk \b0t5. c q{#H  gFOը(ί{X{<묳~z{ˑC:SЧENw:EO3G{Aj**Y=viԛlJioQؗoswOFGGFI`}=+P|KLT45߹ *JSjDbڷ|S֮Y}>/}VuV&jev XB DJDI # $MkW\űGoh`,MpZ=)X,qi|)g=QcL]B:gʖ<8`L!G9Obz^ACv ;;w>k/jt*{*H Û8yMo;Q Kn>?n8g cDBj4IXzt&|4pI٩TdfkE꒾zvlTBޱ̧?{k-w[(f[۴?=;pӕO:;0zoa}e4%$ D;x6vl;ԚeK<䶨Ib3gb N0d!䆑e6Cl|_i=,ؾX0zK$$ $(4QPTLҐ KExk0C%VE$ĖwZCK': XȑڲvUg $@DoqD),QNt3 'ia| :  nu 1nίo/}- \U*&b"%vѤT &1" h)!|>x4+ Ϫg}O Ԟ_퉝-#{|_hlں' fy!65RԠn|Kږ{z15K0P2(FMY(bDIiQ77~W[g΋.pH솋)O|ئ'}Yb 5ل(%O~ʜ|4A,;o;MsFFE[ԚؿvI9xӭGGG^^npG:i0Ǵ.k>7Z3[; >w9k -Y0eZEa!:W~8_k$z 4׉ *^Krdtd1~}05+WpuW_}=?*ƣJOIJG&xw.^_2b y%Rx>1:O=/5C$͘BD Qb4jGjl6??|˗ܢE7Hmǀ~睶8<26jÝII;đ3Pe XgrFghg,ӐUT@g@`۸ryG|C)gv׽]!G };y;zL<~H/-q"kR?m͜9[n98 Т#GEխ?K.z˦sVIɰBxi*ۮ RYn.R0HI(yAjK"j6l' L#]w4 A;~ͷ}]Y/m:PCE-"Ln*C 8CWT+x]@A,41OyE?̎f)Q`$0NSVUCkWWm~L__/k ?֑ k w&85` ; Tyeq:kM'ZgHu~BZjش[>66h0$3q9 *gKia6&+DL Q}B-Lc&I,FLÇfVa"ƱpHLzg]QhKZ 漝 .ITDjذe(% BeOan2x$6($*1;o5_L`ǵ=9v浿K?Gwh6=ѱc?(Ə.^d;ܜC/dˋ.ktu T e2Ln;^?ᯌ'a2D"fTHia}T2gE#?~F)*G}P4xDCg LH gT:f9h=s6x!2 ֆK)ŞބeM۽o{~]7,T9 .0^BRtq n-L"K2 )8qcto/EPCዬe1ߧ@LB&qٿu7lt"7=C.M,]|1&k[e8Қ\l+}_ǟ-Ez=LXO&~gk{ -;ݗEZ|5+WJATycm>kFUy&r$֓j E"Nr FP_Ճ*H+j}]`3*P+p&`|{7">F{UF# sDQvhG 'n|~}閛n|ۮ{ݵqN1tew4(H!Zb!T Z_Ux^$NHD*>]QfӉR$EkDJrOAVzk[~2ALZuvg"BbK| 0+FJ 1Y]ټ6E;E_>7˿g>B^KiV+'ԶO˧~~6B\f39u*$D$#;|dd+-:x//Z_KzMuZbikʲ|rӻ&[†_޽?/غEAݬ*] 3Sfc2(5`{}C#:Wb#(B y 7RV.t&-?xUrfʤLkIPJQ‡2j:+d١QN߬~!coNձUE D$PZ}If~,!xgrX+P,}A`I?{.vHi^" %Jia?\84#B\qťWO=חefE.\%^xsr#b"K޾1o֤W0TA xvI ä%l_㮏c<ʡAPtJEfIcdi̷ԦC[s>m[jM;SCGG>50+MS.T}h^_2:Q<k;c?{9:X B$Pet =S}~|O<% V'Z2B !jtn&g"l跁F]L>MxqBXim>+J!mwBg`dY]}ђE;}W]}ɥWoR_=2vD'|gƷjU0|z (,[riWFNZP A44EuЁs>v`pcTI9Νo]y A5Gi&giFJL5ʜ1ɐށNocpXfeKۿv+_׿WA kIr݊eۼ1MKK:ݔF['ۥwGKsEl>}j1Hz|;n_N`XBRFq1׮J \~Eĺ k~W~g H{w=P%.:/]( OD*Bs4|=ɓtwMR@uƪ U?)gim|t@s; \hD.8@HgO}_?D`k-ֺFm xu O%*#Epu-榓l)l^!r_z ryecE!hTMa|>wwfw$*T ))1l "?rs D(ghwƆe?霳~?_|Clwڙ-}!Ч Ϗ&5A cTlfb_J&|wsѹKW,)%W AC&K׿񵻞.iDB1cޞ%׸l'5V #fMf[l Ig J`؃4δs9Zje b Nq3sXd}b!KF'^}ˌ]u.>[^nWn#BH̖"y^`#P 7ꍑ—W-W~펻ǎuH,x@ G>vX7d -u>(k@jr?P:qfMb 9k +YhҨ "2R"%eHlh\y_*\{׿w αWTY2WdkJq1@wl]=oߑRXD@&qe !I nKW޴SZQ.M#1xم=4gcx ̮$0 J#=e:}&6֓>PtnNYochOzN뜳7~o?͗_uc._|9ZT)DPRB;Oث-Zr7K3hm7IAvݜ:7 |>υRp0qZ2l!`U2" !.+*1V6|2ja֑u0 Lg?ǟrz{Ģe6nHj>;#G._~r֝ƚ"RB"T]T eruu;59YbС#U׼*<W<9v_vW]ջ)FαV7Ji#Qh-a\:UʲV/ h:tnʆs\3Pjr g(q`"ʢ{6EO~,Z4p-סA ^Y$ձr"P0J"6lMq.ܸϷ㱨Hu?i5 w'AO?MOԒzdxd^D~v8L8: a9V *-peK.z/`j?Lk>B1%V\Uē $D vQC@0l8B DEfO,4i.xBs\Ns\6,#qR(@߼]NdI֚la֦G׺zC?G/=|&ǎ o})gY*Si)V3brܽBʾ7uMv]WTbJl$ 5j=vZ)ӀAU\a i`LgfE:Uu3cėz۽yr$6* A9}_˖/1 eKIc`SC8 "`֩&:%J<9S%XKm˟uxH(Ѭ&=7CyTűG~1tL&CO> NzXހA `WU{=p׃>Ħ Vޞ(jY/t򥡘|TȔ֨ ZaE T;#GG:z4iԗX_o -7y&vcdf6UM$Ĩ1HLelTnکJ,K,d,Fm%c#i$Yٳz׳{?7;fD?[n×䒺/;$W\,Ue R=s.H9Fy]71I(i=aBbvFd\숱 " +{'I,eԧ?ёC|~?OKF=rHZi>|ٴe˖+V\sͫiadQ'gUZYb1;NHaA}o>lL;_]?^zɢK޾{;֎W_uo[+'{&&}1)aJG&" Or̩d6mҕ+P>zdɪدv9Ÿ?k_sg?=DU ǟOo筂oc*B 2)81A!Ym$ ͊chLR:c#{po?39Ѯe=m@mlmblN8\͛|w?{푱#L#H! ULR` zF'˪KVeu2>}dzv~s_*[{ #bCYjG2C!LLvll'-wBkHӴ RE"bKdY221IB-yknDj6&Io}!2`Bq ݶћoeE{GE8+Elj"Rnj1ccc/sw,U,Z6 %U qLqҬƲ4IϞ{߻qј%ɦMLRIۤhkbY~۷;ٷla^(B MZCD.~lo2{)f@U6uźMXx:@VG;iT-:xaDP'; 7;Ӎ*+lgޚE*FR-!$bV(w&=?׷rWk c_oĻ-|=[=(ZS']j2)PKvN__~%&!Hv{xHfѺh$!|| m|ti/L̦-M9:yŝJL:;>v!9gꀀTN8v%!YG6#u?+?cɮv`mJ}>St"ȰlS ,]~|s]o#M)J. v.C3Rs|݊{?ѣ}Gkb|б|vVcRO݇?|s?ל*^ֳI")#EidCyG ٖ^K^K쒕.>{KJy'N瓹h.B)lo-S5i$Ӱ=cc;ncbذH٧yD FE88e}YaMjޕC7/Z>v .:2Y>kcG-kXc!:vռ4Hۻ;/:} :[iɽ˖q᫨2'4ey`Q ;uBG~ѱzwQ)B-'LzA7EukF{챲,;NeO=~}w]!-+T8Dxց!E1Rh "ڴgSĨPC,ZocG$22!6L@^;1,]l}(Ҙ~?3zwUGEKo|[]v%Hy ԎcM;yrV6W߸{_p>aG}'oذhѢ2/4|xP,;6 [fu<4>:|p| 5Ӹo<3ԃO_O?/;F,(3zW^K_uXZj`CֺٱV۷y!?н~CT XUz{{/^+ׯ]O۷'ǎרM۬,+׭xo+՚ X*_QIb%h鴅굁fOrel,H XWGێ9dU,UUc_.g}][cJ7YJgwm-CjpQxKZШ7ug_1,'F:J2Ocvx 51oЖxa-_G?x&6وUh5ɗ S6_r)|8t={>s֯^<4}c.>82R'V,gIt|Gd{.R\wZB+׮4z0lp;xPg:]꩓0iڒ2 @ @h";p,;"bbaYaYR璤SW#W=I6A |DL- gN`xTl(**cbfb'UkNBTA䣤>j% cQK CoM%&v !rFz/^HDBRi߸{aebjdWޑc#[|HM)!Z˵Z]|x[n5F&FAԞW*bI$y'u%Q|_3;>;EfVP4Ӥ $ O<}x0ME%f#{wUWRT"}!Yw,TTwn|6 565+ >IbQz^w}O_oij],%' Ag˳St3=Sl j)5vu/Lx S^TFOUKW7 V[^$XړcÖ/oڸLX[=4w]뉛(s; e+03z$> -:<8ُ}6/sde=W_ai8JwtUa %b2up~g>⾡]O8:6ޘp1ƮԸvk浯{wss+(.Dd\8aTٛqƙ%UJZgдX "2l$aac\E%zz-[%CE" o6֯׾rpH3$U,B%W%F__˛fK {l3 =vDHV'm+o uɦ;E dZtN=ByD)&*S=!CI񱨑)!H_㞷-u C'{R,&|##XP+}ޭuraQD$ hI/}1*"!I޾fOK.7{&$ndxf&Z3Yp/,WP#K<,:ɧFda7i ZϦ5.x޺{tXqN#e@p W~?`BJ8B j#C`{ӤVmuN+"PbDˣLPU⚫.C0fRMeP%aZqc\i1G%>+oԿf&=cIM"JрJa'vr]xek\sl[`T=<%vYgdoybl$}$ ̤QbP/xnf;%cknJq\F/!Vk~Q9n:*\fbU%e6mwhf4Ă@FK-[yʵW,jY{1ZBbK>$z+I R+gYx[Zϵzʭg^ܿnb<<>k=4M&k (jH .h孫_ٳOo_j2[hd1fJID|E%4Vi,#%=4<|N 4NVtZ|41fFcNX"N >x%&uDif- $`A$I@H?/>T#6"OjVJAh77^x6zN'7L %L`|eQfs* ,]@ RC 8[q ou~'&D TXHETH#Vh!a2gY]MTK(jDXdqѻxhiFLW_uժޕG'GٴeJ5*1f"6I۱/v%n1WCSӜSDd@"H|"@RB)4uW.^vsؿEKF(CTAcۜACA̤TuegN蔽cw=~ߝ[PFc'iZAS-]sޘ1zz.]zG68$Ԙ6!(iNMOo޵^q劳J_!ʲ >#Z!cb+;v!9břQe(*Z٧ %徯?O>82rh-}4\#llEĞF?0tn_suړ-_R@6{֔䍀Q2" sOeH:n9r[zv۔䔏̼8''%Gy,)zcOUr$$sID E8 Cl 2IL6ܞw] q-XLҬC.ekVٚ 2 Qj]I:O]#>(beCA}=ejyO -#cW,ZQZ `X!=[ѨvYm*`w7Zm=;#H'ZKjA .v{U_UH W]9~tgvE \7!s6Ɍi:ͤ,rk%?س۵[N\X"2 }koNz,pzepw9MD "[#$чw<ٟ `".9:Ā0)##QѷoI*tYoֲL3c6Tmo_8Bc(ri/o}䶧ݱh4Hw}ޥ{31A=9mq2i6Y~( (YҤ!%ۿջo(>ٽI֍sY6nO; -:n7Ϭ8g545 s]fwL;U2E9\4Wߙ~;/"^4o Ix" ka5DQ0cJKrLlDD#6zۣ&c`ּ]yk-_ Yi)LBe)AT(Ct:EQV ʪwҴ6Q .[ ^&0xKf7՛Z\r}#tft%e޽;!4ˇ~⎻~ŵ4V/:oWoא@ %X|O?#QenPxj_5k\UV(UbPΗ>uϟ@bn` 12N,ߴlymZtlrdҷXz8Nx4ơL`KǙѴ/}-wL{;XP! 떭ܸbꁡVX3:2>gȗF 6*6@l2m42g)bʵo|tkueYꍺ$1FOn{ugՆx]`k 6}nϞ݇|]-6%˚)[v&;c-ٰq}P_k?s=ץɢ z ""W"WB7AβIOd ` h>N&v*j4+TEHBl1!}LԤ)+ 9GEўOmԓ>[wd{c'ҍgK 0Ī3t mwF`Y i㹅K]&+Tb J d.]v\s՗CeQDN,BDBTH y/&s*Y8::vz h" ۇniR FP|yW{՚?}| &Cv4ӏ@cO}rzܗTD .:7^F@4UN3tKpx/= pRx^a?vRCDB`ht5K/x5k],/>;[˗.*}'xkc$*Uw"6h1FD$h\1BbL GT4TTU% a9 &2bDl f@-&("J\~;ht}ÇO,^-JYGXOk+|?0!ŭ'ѫ/;99߿|4,2x@aH ̭Bҙp֩ jFzͪ]'11FAƐHUlX6%3a2A}Q HDJTrQYl5ȆgLEP*jDըtchSױazI b`P*(k޾ ۿrSfYVT4h͚ .8^׼tIF$ nI jEcҚlu:CDL]}f>P45)eRnygl#g\cXfCl['UbaQu'sZ/H;dc=CCCCG )v[zydxDROj1$ɋW]j6i&R(\&DDb!EYK De M&OL};O) %o$Iow-_쌏#%"rWյ+'/~xgBOLQ"#bF#{oH{<'P$A D ]w֒o:{݆ԺW%VԲdѓ)yP1ESTtض2@Ѳ[Һ[Xvҳ6oW B,GGGnݺ3!Cb+jGD9&i{t| &PL:њhK/ѦAB(TjY?C> @:ﲾLiTE8a| D )\">;I`G$u`qI!H@o \bB}xs5bBuMӁSxv{&a$D"6!(T$K*.2Tɘ C !hj{ngoW$uKV1˖,Zr1l7?{u@b$4 [+T5(d+ϋH`f6Ɛ*lMs.Y*0H8b {)bX0TNM!bc>w>![Cص#|(;RVlGCN5ӷbHfyW~V)nDi e UdU 8xvw?'Ӥ*pR5fjZoOϻhd1~dl?,JdH]ˇn%?#H$2Dpښ>+#[+:mwRmr㭯y_ kSf=xj&ԝ1&m$j+ե>Q8VđcG6ё'6eYE1<|hllT!F i߁GFy}{A8Izؙ ֟;{w`"(lb*8mM1PbwΐR|)ѬکN13f#Jl'>ECl6z>xPoo@;ݳgŗ\<:zguEQ jޞ=Ƶ[&79&`zl4$Qf`El֪/%FkT^!цeԙ΃C.;3şj9ZQ1FN3shVfҮ%ѭTRTo`4I;?':&&*߿w;{(D4h ٨ * BcX1.EL>2DQ,!f2^vo_ݗ )D" ƐHe av Sу*IldChdfe:Ne@I-kNLLLLCȫ}m<uWN׽g8s1OE^XiN:`}K1$֮&(USc){gv526l=#j}6Xk\z}NVF7s,|I=:QouJ{ي j~w9D}lIUKW_sçPo6 -Y K.7_r'5{x>QS %X:N&L/a_*():Yƴg*`&"(ZbLj x >ёFYēKОΉɉ_fOg>>bCe7oe?eY__5y%K֮YcǎёMٸaO>166rgvޝkKZbXK }\*䝤ބa!APD%:$4`dUAXe 9NS:/hls+H&U R0QBD92,S2ƺl8MX!i $XO2DH!soe.ҍ7qޖȵʁ \&%*|+/S"e#{v}'؆ ,&PN.\2^L#m{ (҇F Iepp %{:xٝ ກBZ"۩KJybR&;{A;XVTR,[YcqαIr|؉O\_'rdYVթF D/2zs?`")=kBBT}s_~dv*`:BܻbQߚu7ܛSb@B_;6ګHHI,<顬7թcg6oOˆKI0Y  .8oFK;6?~~dou|ScI+ʪ56>y|Sȁ-fsF &I5ݨBQ A7޶ǧ*Um%hy,ΉsIE<Ŋ&~\i RJ=\*u=3?T>W\~gݳw;jկ~-LNŧN~W83t~R ,sHHUzh\m&͗~ړ'OZk 7\"MszOOumٲqժU~>jfօB<< I,r9ӬKɝh%g8V ZF#,pN ˫דLmi೹qΑy9`9gDk3$-8xt'Ҙ^$f:_vV\~e+mغYfC(grt|Q%Ii8͌bcZk eL8h]lqmlG_I"~'&Zd$U`y]El8"2XAGfffeqtFX)RX@-;ou]tGg>'_pIW*v"Z$=3>˯;IXZc zvnqC"4 d-sG_76Ņ Ycf4:9>t#cCKr(RX\޳4k6׆uwIi[f\clUʡ*+k1rQ.~ۖǎǶ^oUO{k2!<@|ökVz0CN<xfAHF{>an[ Αk\F|k?XB)>"V(g %2]?70M͊VSB2c' r;yˇNAJHK(y_'-/tu@yzd\q6 PJ*z*:nm:өVISl{BH0Ee( T7/֦=duvtŤ2'1ZX2d:s]Brna;>m}rQ{]w#9oE^MgqġN%Z# (j+muk uRF>om@X@GsIfj4(PqJ8 kZouS;n-ϴL7j=A(e7[-KmmRș΅Վq&'%ESj^LgPxJq@ MW-lRqD.G)9NL6Tnj/MӴ^5( ?2Z&&V:;7l(@h~pn.9IF!2`DHL2c?$D\`Z} _u .c. <(N@elL{[=gJ02)z\?87=jG8C9cHp^k e4dmDd4Fq:-{gz9vC?`q9=U^\ ;{MYG HΕu-"z2tksCCFkK#\<ӝK  q)?wC$Ȑ1nfn%+3mK:BŜ&֚ahnҀHL29eS Ξ %=ApѓL=3hŋTR@w-kIi$ FzOBF78/D:[~+WsL&jD,HH-Rooᏸ,0Z'֢m}=c" U3/.og|5js1ɘW裻ۊ{;||0%T =/Thgsٌ8NQѬWl&g Z9 8p!dC7usZ̗L:1$ CfyNp9>9niXə鉱Kd-%€$"bTf "Cd# pYL &*kHhq-`=CeL ps9#Zä̑FL QhZ /xGWqۍzhWoO[Աcm=ޙٙ$I<pCG{@$dynҹb8we :&ͱhGO-Y]V#~—ZTB@ Tɼԟ97ï4d \E⣗eQgVH/:m,/ l/:JDMd"^gKW<2AL R1~1WHZ)u1_p DNIڹk>20 *yvfCRDRoxas&DB 8-!9 D]-|B<-_/c,Th_oUR69ϋN863Zp[DȐs&ͺ<\(dZku3Y"JD$EJJ!5: "kDlʦ8ch$N3`HL7)\zQ$GhDq&6ꖈ$䄐֚Ffd'Njnu[Ϝ>{>O э1vOF(MbΘ(JE?见0Ν@5kCl77AZtV0:7~s$箮*Hj{W~SJ1ϳxsj%`%:Q`oy{fd@˰7O=x܋kpp֫6fϴwe [Yp0Җ J B Z`"&ZH05@9B2Qx6+vx1kfR f]]L\ҰC!u 2.f4NNxѶi*-jXC-X-@OZ?^ښdKtj"HotsŶ\7<_2HDvݵoIlʏ‡ǃ}Ͼ?>\4&@bɊEa[u֪Xb 2rugXm 8d4YEm#S_ӯ?ggfg G 2%lEKW/^咮ΥdSDPle i74FSFpqÆh{O fl6*n64G(3#GAƭ5qeUUC ]zQJHhChwH"]sr3Sfȑ0M\6T‘`#/X2>6n]tww~Doo﫯9#߫TE&ӻW_{׬Y~u͛u/Y<{]vܸ}q;ΛgWofvV?; ≓wbbrQKW8pp25<QkBXh14hZ젩Sc}> ֏S6<2wy'йϗ@ YS׾º *3o*0΀1"DK$%{9#9\Ԣa=|g^dWiV{ur}v(,(,8iH9= "H)- @@Z,">7rIۯ~~\[Gۢ5U3B+4E]0$7/4; ]T&J1>[ex@f(8 (ס@i{X^m4(W)hB[ GN}gXZ;9w}s5m^!¦MSQT%8 9Ϟz7̳csd2GIm}O<=?KϓAe ױ}g-KuqXh{OLMs{4}߲:}!Bha߿^^^Fx0vO?΢Z2)X~ϋ'gZFya&Z5LS0Za~Ŧ8M^=ݷSN;n2'?MxՍl[q?y+]B1JMnk[lͩ߾C 岱o~mi~ +[ Vs:>/?Q*( m*-iOdͅR̩wGGFoidž[?__}6 OLLU5#q@m0@ȝs&֖!z2 2jy}GO<_*ƆfO;}ؼeMA:l4Yce[G w(Jm^oT^t@ 3S+0fA J>4xԩBx}GXJ(lKj eKOzܾ͎̄suD.O+SU?v.~Pj١7vwu= oL>753ձ 889-/RqS>2Nq@aM3DB#Ș^8+B xϵOH_v|/'LH"bBRre4NIO_=;tБC߰}mvޠ&"4xߑ$D:Kqj ![cD|CnT"Ι"^ 1 bB@eOqNq~U+žCQ2&Zcmwg>&'?W#燕M ڧPÖhڥ&CgΞJS>ё_FFعo`(R*CdF3$<0 IhgYvATbndi62ȹ^ HQZM?/?!2 GmK 3᪵6n[~ )cRd\xkB\@6m6?Zs:d/ޕ(.'`cwuwWŗFndPZF%N*739k @Jr:Jލ[օ\XM۸e}g424w9;uęJ13_\CC3գ_3ԅ՚͔4\y-;nSq IjR?6^b}3],sLj+y&/?+C'&&,{Rp ȰݻVƌ/5)gU8s cgZ5`1`>zyvtdJ^m=|3tGϭ[s/'St3NmZ$V@_=wݹdYқvv|-[⟝?{;}x\#?vIJ+֭_jKWYjMLfxx^[=;.|%iDKXK] 1%<+ĐcHz?;ufH Xg?N!~wzؾƞ߼n)$7F $m 1( yViG>M"R'=Kf U |?UsA8sgM% [~lI0jFЅ(1Z%i3mﺧEO!WXҿdO=5;=Ө֭2''_?19=11L Zo("6似svV00ZNjo%jyvxϻq-}.mj3x̀4o1k-eLQ4?AK?N/~VZ'm1IV "p[LhR6ykH0[>^NMؠrۇhu]|Iؕ7B_>c T@d9ɼ'{[|}{r3g}bZ[(wN4*3c`l>e@0& 9"1DFĀ/ش麯}屿ffaBhYsůjdvV& )9>]fր֠aR ~FC{ٱfڨSLZ J]׬[뒲eww 9GZy+u->s kP@J\)WnO,9ga ue"r`@4['MKipť;Ze[Ϝ9cS};cǏ?{ّQ1ܳ?G ,}rpdtx_s6Smڴ'NeKJ= 9C@"guDLs~̆-`0Il<~fppt}! \}z-#̇beN*d jڔt*LRʸ0JiG:"b1buȸ5r4[Q\ څ: S7VBA` h u/K)R%曷p``K:wHG/?xĉr'IYKTj@3c,:~V"_}{259q,nqs]F!ss^YV# ) .LH K qD,tyr}!GH2a(-]>f^wI3f5U&eQ!ܸeÝ޻a΂!tz=Q۔Nb>Su{V͸}'ϓ*1JٙXerqCcJ4ق_̑s32>oZp[@xCs2AT;͌O?O 0 ztV*6=|$F yg2a:pĠ-Ho*']oIM9>r.[hooo$OzZJ1Ͻ%ƙ/P>٣wH\C%{{&9btiҁ .a˲-=w ˧FFy('6oڴnoG 3QWW_{y~ -2<<<<:l 7Z>Uk4-:[*@!pD}h8)}uQt1`\f[?*ruvI?`k) ^ L#LEcSCc#k(j3s+kdȐ%q!8|P@u{w}GGOu+Zg.<ӷx7cp0::yIG Qpz+C(kLr!@\{SLr/+목R$B)6"ԄZL)EV+nDg԰u~} *Ap8A;oЏݧ5b*%Yi&UkoDg::N>79 $dB8"8k&d uZbD.]>*J3եk/_էFB:c^\:,6\ӇϔXCCr=`Q1\}wj;eUI FWOT/[bݻTߕc*h 1d;s}kw !` +49w0JJܜ)y,qGQR|ӄ-gE١']a;[4N1[Wa]ЛHH2~Dߏ+{Ξ{(ټeӯ?eX81[ _2D5df3 01ChJRy-oo IYPF^IJK:e8IW6x%hNA=0/ȅ";31ݯ~PL;G,Zb}[޲esG6Oe}R=kR"m9XD3.[k 5OcRK/޾yQT9JzBs)n{k952#cMW̖NUh/F/N ?г?eW_9Xk[vܺgKgϽ}ز'~[n\aUz<݉ k+"kʕrؾ @ +7G $hww (dѣR3ޮ{n4G}#Sccc/ /4,9\S'mŞJgy[g>QU JܐoFyXrq֢, thٓ]&wp-, ȶ"2 J/5" (F <h4ʍF6i2ΐd dm+ Q;sc' OO6vO[BF(ID4ibGOwu_N_l)U=D>/&1ERaKMY YQ?[9x3& 14D+D߹m[2ņF 3LA&%pmUbs:5`]jȹ1E0ٸҵ]E98,XTcDB8_4b5QOWѽ>\lXwX^{zCU=UzLC_Nώ*l{^!'=?4 3_v?Ʃd-yyx8t u7nVlY5S2cɢC#0J8 8n)8gZ74: _@G8%+J^|]]q(ىXghlrC]yG5M 3"#HڴqukzJSLMK}|)Z7u:Kbp+Oٌte*lo+ݽOMLLO͎ M;V)(ڎLb{'O'Ѳif,'[H:1 +˕ʩӃzVrYga菟;ʋ/ŕrUwU/{[ONOu/޾`Qw[Gd%8cQq]0׭Y@ց`7~3006>>2<*~L?+Ix¶/}+@DAitz"a[chbz-;r=]=+RSM䀱Jd]#,\7Xmf501tE ׸u {ą',y{Ahvm)Rd02B8R}|&z:XάDžbPmL6S(F !Tҕ-XV#"bZΌVYǎ,*JBdsh1 &IZg1$򌁬0,ªvp+4B-OiyR橧vv8RN?wF{;?OY?~ϗZ}CoGN\g h˄gt.rfvd%kjBD)/X fZq@"]ԀI{ vc{a:Nƙqֲ$z4Z!5V#+é_ 9c9h5nVfR ,Z(5 mu9x ׬^Wşsº 5Cǀ80ցrҀ;xlx|꩓#Cg'JY'2H3][8Fd1?ܬz?$!e=!w~dԹA)+3XCDDdemOU*<~'JO qVg“~fgaK=)|0>5ܿLWiUH/e'j͆o?ILƙ</,eduN w El:V6ڨf ?3]޶}A.ukgfg\_D4J CZѥgK!^§iY0^{r"B64;ugs ~/>=nZLkJ޼~-1:<ݙ窳Ӗ!S(Ky,lZE!c\HVISU9vh^G*H ؟_M[_`\2V\Y(qT D8x1 $Emy {u1uRw+G]=G >x=?:%=o='WW]{|pXGoko^F<q`4XLj9\ &}60/~'DG:&|GOqsa@ sD eiճV/`HGDXK!iH.& #Dl&Zpy&dGlѺ&NKB pv.]e 8c4>wԩMB%iy-7:KcX _!<Ϙ_ 8ƀA[o.rhl&k%}-3%3dƵ[cCg[z{7ܺ)!;EJdTDRAƒ4go|ñWNκd ggl2 =CW^wFmb8::|UF'FeRj:r@ <]@b n k*pI@،5kى}凝Q׿'OJ 6A@}=ٙ=KsY \-wE V6ti:Ut9 cX|i˔Q&iW&=Lε|޽K ƹsԨPq,c9Ɛl<9W ?\i/}8mܸCH=F-]R9b AQx-E"?;׿:9@ p`iwv58kHژ󰫭 dHDeP0pit 7uPFo(%?:s!P'V|Lr4I 2& gS"`2 Luş}|PO+GJO+H.C@ Sxl~ՙl,;{J20i^ tq@ "B ԓWr`˱ vttnyo/.n:cZ%})<]!.pktN"}E;]FNJ3yMQ-y81ZQU#O;zs2n p[,]ng:=~宬((2.%FA@ 3KퟮYr}MԪLEѺ5+WZ>u(o֕+WRܥ PR:q?u%zR)0eFY&XK߄ ȁF0¥$@2VHe:V VkBtyKL\޷ޝ$X͞8t*O:/-_dŊ|}KsԿQO9_a?y}#)]ÇE/wa߈ȓ9пbdFQ6MmGO_Gx7YRwg&KG"B(_W< \>zC8\8p4Z882tҐB :9"BYѵzÊѡRxAXK8=.%`vf3ut/G,_]&~ל͋WsPsa{Μr%mBeT&%@pD^~iq51>(kU6Y}H"RJr9!<2hp)UKU3k(H.BK2H=ŴY,g"/8G .ǵ]9'ѼGkTowǽMѡyZD!rscm&'f˳ \p`Do ͹e]REHH`-$#rd[8[Ige=r2& ^;@0z# ħύw eK3Kӣ~9u&5'G?O<uvv;qBBB`ZJJiƂ3_x?ވ#C<$ŌYK_M\̽$g*ri\Öo6Xp4Z 4 ܂i&ԪR15 c߱^YZ*vdJ׿KW=s~ddr6jX9bzm0oݏ_ 3 ebHH }gzidxZb:c\t/]thv$.R/=eTEDsY놲b. ZkRWt ` `qcyBǦF?]wS/^)ֆ4r`I4ι0ζ(\ ޵g#_:K^V"8CbՔ\&65[Ksف2rH2$q~Ȣr{TLs`|Î?qC/xm噮 ۴Ur̵$o` ?BqNRqkJ6͊E?f&g$4|’ޛ6!z!vi@P 2 AK- ZLѣ33tii9[oνo͹~P(!ny)K0 ǖ 3eB& }A7 `At-U/ r6퀖XKvGϟf6q/=^33gO/,0H퓏?}1:Z3^79bE%7P a@ĺ s3Z@c\C%c I@ )_:yM˗<{Xl≧۷K(YpC=G_=:!IZ`;sBg4 %/TJ!Svtu!AfGma?21z$K:DŽ`ZZ/<=sd-sx.-kUr\0j.&MSBMR 9YR\~/868ޏf NMX-9 |3ݰ[: W4.8B*%Xc !&iX@ 2T牡͸44#G'ts19=zݪ{};Ƨj|bbdkQ#Z+Rji^,4&R]C/}{cϩz5Vm߰Tn'l`bm^,s- o{Ţ4Ot -2YҞz#2338.Ŷbgg‘s'f2>1+ajm.99j9ȹ8 n{p0pm(Iq.7 Xav`Xv|Bq.]T٨@uKz̩gYo6 !FGG=۹b Ҵ3\讠8[ʑqsm!C6qet '%GDg- 0djdKy /  ۺN/ RhT:e!'d ,-+-;馾P&g12*kSd6ᩚ;]Iܩ/?~w~s;=yt%I @LSƧpɘ3`z7nZ:Jjl ϧ:MӴhd5FJp!ʋJ dW_ЛJGijbғLvI3LJ=|+w~uޘjSs`CDA2e#BZ/^ѡt:R0#ھ?zBHRt.Y7Uz-#/ OMN>Gf:m/LUjH f"!㙠(t ZE=Q2Us^R+Us駩DF[J\FݔB:o[iYCI:cH ]ݻn691qTǚK:Z1 fRw =ϷȅgԹ™[7 |,BbZ:;[2]=a{駟ޛ:-XQK 35jzՠHRXƍĦa/:s\"iI17c -20NH8l Yd 3A#`1׀<,':qC?⧻tHs9q`:A=^~8[gEr9|ɕnņз2$`H =.B$ֹ9 Gd I@xNgKS\ssC OUxKګ7=xq)*Y @p[muFx|wwrj67Qk͆R)Bl7l]Ƕ @@ 2&F'O$b9!0nck5|oó,D~1.֜ kmxӝgk30r6Wٗ(wWv^*-1j8Z(X "G 1LO.T,|^o09blƍ[_Gműs9F[j?sMj=g-900_7&$YdIWO3CB\>"T"B&g9 }ܕ`3Džu  uزD֙VuJ5}1ȸ4v٥gO>tjnА 8 =YY7y8x|]7n_ RE>ӕrSB{H"X|О`^Cӳ3\ 9BHgsGf

}'gҦmxjۍϟyݿн·㶤\އn۷U+V,YR^޸fŲ/~N˵|tw.Z4?3{@&D.kT`L$x{W䄲qZZ=0Z3Y'CCښ~Yx\ٽrl\+! 'uV R0 b 3v19m~rd sDJEƃUUZD"VZNC_=>uDk0 $xy" 1e(>=5U,yߒuI2Рh}뷟71,ƱɩZݾleo-2&3Gs;J5BJ֪q.*᱇Mv ,YwoڴNI>ʄܯ7k캉Į&snYK/붬^q%`,sؽ{۶z@N*1Ge2G}|=cO06oڒ*=8rʵf.R\V*eQl?{xPHiH]<]JzqpNh[hG$83*"g4qU˻{:$u3/B#c19*W98qxLjc]/S!8"a$13R`dxxdd$"uM9#Є ͭ! qc#os CbAPjtz2䳖L[h ܹR 8cU &ra_ u3566⃺o=69;i GONOa@ mظCbɾE~`1yF'i3|4gsЋvR 9.8S@`)B[ e(_]z..#)8{z3Fe5D?!/5u?O`QZ"f>c,:Yg2燆\fCLO'q ER)J)n|+*Bk5 FGF$iV>m&en^!^hL~98ݾCGxrٜJ]J"MGyVVp'9 ɡD=Ox+mw|QpqLuR4 (ʪDK!C%0jb5Cp/߾}ƞR>!lZ+@k<R= l ]?  gazn!'G@J%nƅ6*Q[3JӍ#!E\xKz{W_~˖/ LJq+zt=Wiܦ.4F;0r9mLA02= gb|bb|bb]}SS{KJU$ߖeClgKWr B+4-q'!bоjYq֒g{O>n|)#5}|~ɳ ! x-g8L}-5P 7FRsic峃#^+zŭ[VDdH" c"H.H$5Vig̜|3Mx`HB.[i*7KIUwF11)s,)m2&/9k|ZQĮn)$9B CXPJ9g;glcgi,iAR?ۯ&K>kѶ&&p0[?5yjv)ӌlyfx8 pWq̋f0yw<|x3Z 8mX1mIIU˱qX=w1)0~>qLxͷܰ=mG~]j :wZ|\ӽ k BfHyc0C h zW.D =wॗ (O>8v)g Y +Wk[rl[wqٌEY6?d5 g8GMQJqAZ9)%c"MfTKOz'Ƒk +OdrzOD {{ Uibl&]3gqs9kBr!she,0!Z9g-xbI<䂑rM`5ãS3X_Rj-:<~ԡlT iܪ/~BaoؖZKrfuzrtTMR}Xʔ F@NP"OVfA+0g4+3Qe֨: %Q& #_mb.kz!tǴv6u1>|~hj|"SBhc[ .6IWW\0)c!_h@-qmSd< :\TvC˯e1^U/E/Ɵ8!Z9OFw_/~Ѡc-J|trD 12 iuȤp]7cc9'|vOku8Yi-JyErϑYCfVjܐ}ZYZJ%O2#0;"..4rr!\;ĄPWMF U \mk @.C H9GGszims ki :|Ix_S 26GΤ3ȑke1e)V/f?ɏ%t|z&e?[_|'wKT+ g251Y$2IBZ/|ΝekGk\#/6I4r7fU3Õtzl$Fa@ePpJtv}.MHgDzSp4~OZ;]5]ܶղ-PG7,QZ>lAz觤_=Ī55e.d:t/o6[*̂lGO߼.69!-sH)хf\0^|-72CO0\2gA@?RÜռ9ר'N22AG (ۿdYOo? DtY_G^"%\En4Cb*gN #h8,+C (ZmBoS`!><3;I &!H#96M=雦>}!c{"2ٴnmԥ6{"Μ#-:3LX9lOw+?c yBDWM·^/j_at]+ 0B=UffhdlxT%gdhԩS=ݽLL7i3ftL:EynUK}a$8F&~o{֍z;;G*g$ghQ EY!sb\I2u ڽ/SQ #YN,ʹ82)ȑ9E|4" %kay ]`گhF5?uBRp~пh1s FoyG}8I% DKօ@FJ;2ù(*wx󱃯3ه/O:Ōcl6ٙJ%urְ (9eY1Q}GO~Pa\ikV_V- # Zyt1/iVm8R[u+#gNs GgfNO-pgY f>d@Cҩ1X.8c,ڂ @|72}!$I-3P.8"Ih4ߗR@ǔL7qѩc=݋-2MOM[ /xu8 !0l%?|q.ZBdG~i & ι%,1drv:r owhJ saGdĹv@ HCzgO ܿ;KRʕ'koULx@(WŒ*]\fŒ~2vzrVhhO4| K+KNKF\lN6/&3soժUH1VTù W^}wn֓Vvˆ`nЙ];o_jE[xqZIT;<:e^25ݾ D A:`SGQ䗙FW.Up^tHH V9d۴i`C&8!d/1ə"ӧOQ[- JhjtuFA븓sf {mgӺjEY], ,.z={?a'&rH0PZg20VsOe'^=}庛f 6 B":xQ])>!X Ir6U"qL"}~c4=3U%eIIFo߱cK^z{|f\s6*z;F338r)ui ɀYcѺ٬24JIiC{~&C/nftvv~^= M7GG/ZԗemY'..*\SZҵy ..N8}kKIJN%#DC6 T2($d Iu}Q^m|~x0rype`a>ӓ噡!/ ܹ=/?;O8_ȗ: SO}όz(tP)u˗fT(HZ 'K?0@DVfY-֗n"l.N[0#Nk@-xy|fLQ(TYc['9V_IG#21T- "ЫGOoy`.Yf7xfܺmVXgϯېtVX¤Zg 9p31 " W\ZDdWXNѲ oHpxكkmRpqg-YTh+$ x&ʄaT*^)&Z Urje'*S!`ÊnG&-[z~/;K{e6<7>44=B@Lʆj*\0Aܸ촞:uҮYg+Z^X+luVa=h_8cYDζ:0u.r9;x/Yb`M 1BY8zCgfztRh̖Q&Z4Iu%rdJ Z. PT*J__yiuQF!=9ƙ2ד4!Xn5/ZՅHZ.X0C{9WsnNP nv36-R")ɴH%[ޓ7-~,I%R6ES)6jfg4r,*{nN' sު[@B zwskP`?={NT>W 2 OQ2 Lu#"+i7(}>dk/]7 ZFKrZY R8G:g?;9k}}S\O81dBJNm࡬ۮUD. $͘}\4 @@:kX^϶ۙ c KUJ&BSj;3u$M, u`7ŋsǏϲ;OۏScCsf7#l`#}}<{,%ZS&3|!{~r.̧[׼S|l]ATr;$ȃDRHUZ1@BzgRi&'>T̿i!K Ȁ 2e;1^Ru0{;/ܥyg@BM ܾs(k5bGj{&GFc9\]DCRGKH:Vvmp}maj|Rt%EFh;}#g^?Ѫ7/,=OGoħe321rq_\ 'Fe̾وRBȋ_=DN/}W|ive3 Ǿ_Wc$ͪZPpv="rZ)Wb8/GqTRvJ,a ü"MV!yxWFdž ;U¨*% 0&E$!, EW  dP=1Q_U=`f`h\s;_kۿ]CA8򎽒.!`=̒'FK3O={ YƁbe5ӎ"kxldX+$=WBbJ9~^sD Hc`sOWB?S?~;A2{ͻaX((u%UJY߳f3׎;}洱P*|h;wrk' /M$3t:`Zj}yi @!')Xo|ZٷT:_ /]{rqȟ'r앉L2gJ @EFX2金]]}%C@iB 2#|n$,$PXcQx(}c. q;40ёZkHTݾm@ ڝ(brv͵ν7Ł0Tdϡ/|ų"6]Փ4Cؑ%BczY OSm9v8j%O,cC6 AyBs*:$w~8U?j _ 4Ϝ /1mWqAeiRJJ~fnwwBAe*IյZvyQt:]ʚ' qi-ƦgBRP2Σ`X0 P7T\ @قqƄ׻Z4PJJ O}QX>[. u[[zEq޳JYk7[/?_ jQ~ܹN]4X3GL8;W%%:ЇUBFB\)UJB)Ao&VUaDvi~\|ԙ3^Z@W"W 2y/ǜLxhp([r0 ZPcct[G位*Zm~_+Kʥj J9juzd5)XkʊlK/@?}%=z|ȺL_fwv7{oziT:w׮s|n]QQpņ b+4;-]) ;ΞMx|tnc%A&wUF$%:o>O$@%ڱ[Tr&>h4Mt9,VK*bp(Z5'D"Ã3?3[o;|S\ACY ")rzuV^W''' 9v_3LVZP4ZI7} 7J 7dRؚF. QPN=PGe~mu'YD-\_,‚,WsFb8Z*EJW[Z]mw;;J@sT/{(v{-fQ{$߿Gk}L&KRH~GsHl\=KA>|A!_}7& 'v \NRdY]spGod )f}n#9RtI#rë/ld:dxđn^Fa! VDx@KQ\(֥p)|>PQ@J` 'pq6Η^~_|| oﯬ,?yTX[t868"kR.^~(%b3``vޒٳg߾hDTJK/ݺgݩъBijw.^ά8@fw<4OwNg^c ֿ'M[Ap?g3g,-;s1ԡFٹ$ *|>>n+K$EZnzKif "q~W\i @#h *%dl#"OL+$ɤnF}if8sVWzY3Ziu]$gP/M31?ZnۮmRg(8]f uaM 7_|U|R/ J I*b5(݅BZ~FB$lS3l/}SD\Y7G)nU֖K^oەr;Mq*ZYJurV*UMN+cCB)r>M{@A*!"}|C@ z >}ّ;A!Ȭp 2np8@`BT0B*t,"b{㋹^?-"ȶO z;wTu 5##01!822R.W?23}ڽkϷ(T?epWBzmH¨n77?74W*R;NvR ɹ놇N_\^pBZ <STtH$f$.E3\iaZ'@1 ǹ!DfNOߗ|GhMۣBnyqttQ3nAnƣ>x ”}e2о~o\UO\j 2/uw(+¸!I sLqyyZjZ\\pWz,R%v[ƀBB> !ШwӇ!F׍|[ͭ(W 0Ȝw6eOZJ9͚{, yu=q_]>̺PВ<#Qr9?mS@nEaT8KLJUpN(cP4A >uLTBW^yo==9= 3W{(PO~o[xw+Irt <3ͥi:4iK[%"3OOOz'u@ HHKM3WZzFDKRU,; +ʥ*kmEwqr=rccc㓓SSSO?sGŋc^ܹsZJs Tqr@ /~/-#w}v]+zuYwя~O!dP|i= 4̦M>6Z*&Oq)!RX#bh}JIPeH̼ O)5UBkD9WB(˲rz}aT/;{zy7f PIޯ-gA~iF{edxD(0:`nt)~X;(F|+VXgzWSVVƓF#xa{ɬݫx_B0<<Ɉqyq{Kok[z+3ft661pyC60JEĄR c=*_ !13VJfMMUF'H[ ÿn01NRBk8fU#)Vs;wygOF^|fsKVg$;wv||R^IٶmG/~?~I(h6VV/͛Gsq[N"*B@N`|jhhD'$ {!!9@ !JR(MSD 3Rd0MLZmxd푧~Rnm&Ips>#>"m%%#Ãkk녀iX*}õAzV9{\ :uP,Dq|I( XO[3~[OvdPJ+!5ʴfIۮ};>ȋֲ0 Bɓ'.8KƨV/--t[`'OHi:έK?m8zgc83/66)!N "({dBHQ@QX8`CCA^!ֺkm2391yСǾtb[*wE595J[gl Ð1qu{]dZY{l&lSP26>56n?|(_3-!kFyzhԛR/^}5̅(%Y3 Zj!R{()PAiLIzU2KbֲEݹp|^R f2}.|EXރB J@fvhw08X][k(ij*\Z Xx,jiח&9cBTLLjJ!=܅o?37?}|9^|{tLxcaL"{DXu\"B оrsRwff~e}z;wI[_핓?'.!{*(z̩ B#" mH8q;=Svpylphl'o}cdϙ3 A"ғ#pH(Lm?O>灻Z$ n!*Q?D !lr3ooYC! @~cw׆o_+/..,RTݺiKC2}q׎ܵ{[]8?IH S>)kAȹܦ7+CTdQpS,$%C y}ys?gΜW~^p:& /@"DUcKޒ{Fcňz" p^$JIJ)!3/۷+/Q, P8.  㯒w\.E\[c#DXGtza$eJdm}/k5nPF | 7niff rAH)sGrpsJɕNޟQAP(yOWVeąRephlNˀA*/*r.$I8kZַ7{wG&džO=kR;@A˔+IDw'%N` ,V*O?mMqH)Z VӧVSv, #ceG}Zcw`{op[ʃ%յ *vS Uxk xlIr9 ʯ_p vFtb& %#?bC}Ab j!l`) MB'>c{g~&[Ehk *x&8MPz^*3 fYvĉ5iYET[{JN{c^Ͳ̃"htdT+uo$CA_ZBܵg,eFIe]nK YD*E6zXߏ:!p|bl;T,@i! Wk^:aҥ^;D+"IloeQ`Tҥ=$yC^j9d!Ǻ1NMi_;vFeEFԯZcGi;%7b•O|G}ɗ#}8Z~C{}3*WnLôj,ò<|]Cձm4=䳤4;5fR}0?bMi ųggT)GaXhLm ]Ff@9‘Z ~ҿՍޚNƅ";j/^7kn6}x3902@u]И+E?g*Η$n+?^};Iͱ yFWVe_2D}XaPx'?v>\_߽gZKMd$N\*@A*#G| v),,.8qniPe& |fJ '&*JgÞ̄JAlZ_*q}|lZ}^-3Y뉽c 'gMޘ60(<}|n缳kʣcI\5gwH|T}^0K . rՕj `ؔbu6UI @V%DX r@u<!tm1Ɂ=㓯9Mpy%vh`[jkǎ=c[W[nQ48{ޛv.y5uw]2M hKk2-I "9`G?`%*z깗N6I׿y۽W? +A(Mj2Jd8rq4<\ni]q@08B`"ϡ7w|\󍢀wŖFο`ַ &F*7l\FxUpOJ#@@dMi&<J[O}F0T˵$eX*Vo?3_mc?:bX*Ql6v^ۻgVٷoٟXv.4 Pdۦkx. 7WCo$6 TDX) ܹsRSYf6`*0Jm߱[oJ[)zbbi(o u|{5KDO$@گ% !$2aO^B{w綧 R !\g T2o1'Z[kZ@Ato~nv3{g?!nn-rם xs ǰ[6 <YVEV(zWK^7[r{ l_y-Fy( %2F\8~^inW,dz/40i(弗mZ2 I}l5'n~Nz򂐹ę^+>p=Ua|l*3]|dӑOUjK8 yVBm KF^ҕcuk0e%@K^? `q_KOjkT6T(ʊ A?;eY_zcpXXz"'r)s&M@,d7$g2_[ҢւBXRw5r.MU%0 p :KhQpmR95g Ҏ}v5ڭg_}񵅳ԟ>B {v}{ZЭ}mF'kR  @Ŗw蓾U!Zfd*Vse~xŋVW_?zl`b:SF&M1FZ12R-E]>阽 8JKKAȇJ10B6k*GPMᶩCRilۋK/1 !I4ՠ QVJo~HeLr(F'!*pKB,{.]áO?O_`w|щggM ZZٽcw(VWj6Њ`] Yj%3cd=i!aZ)[&h !!wW?A04^aBId.$%1 KZF')jZ@t)G2I(A1qgҴ75KI ˫kLE1|qBV$KV6ddLPrmqu ޟOG "Rf0?fN AJf%iP&A1$ *R֙1;vܵk BT*iC|;\4";O6{R 2T$Tq.bϦL @\Nfr?ZqjAvF(v@y'b-궺7ػsv<0TE%Wc{2t[$3„L3۸Պc.Cw=Kf3mkV. ȹ6(‰aF"VW*^?}ۄB0cZ;O,@aKD0a@u [,dI llX ~]L*$$&aINRE0}m,}+;-DPV&H t0Y) \~h`C (K(1K;wyリbGw:pyrD׶ ޹;w]7rx7,fzoL -tdf\d``⤗}2ե0 .A+aN+[|DdK/|~eeeii b&`{8A5B"Iȩ@…^b9A~R;I;rgZ] B@t@xttިK,yƷόLD^טpdt(Ʀ&PܳolxDEq@|^ˀEp(3@rJ'o?' a(`t;M~:~]4NM'IvR)oR,8Ҟݚ /rH aCb"G  = }fVHF(H]tyqumlj( a ͯo+uErwNL)}9plr\b{ngCA@GgN> >ًgZ ^,]}w]lu}t m;( O @H 4 +ZhPɡZ9{]wa T(I;w΂@#B\3Ᵽ+ϋ,|z 7Ӎ rfBN*V.c@̆<8kJx~f<Ҩl]&ؤK%(i\U+i|'>uAtzaq>_PfAD~JWi'qc,L2JZ/]Y׿vu jP5λ|õISh`| no^?E-b7x7v7 o3~#*x'FJ/(%+l돔\=}%cpXf]bQYZd>tӡGϞwJ Ky`d`Koz<1NRn1s+=e&Mtaa]ohZ P~{|G1fCM-:"o9 tum6?׌5q uO5sQ;`fFTP_j<Də=t44%5YP AD,۴ȌL"~ɯ~+!~M{oV@?ȼr 8nOtR5ugOR˴͗b9^אָx/GJn:91Z,brnٳuFLB@̜;ʫ2So///Xi,;\&iu <1GJkMscCoUEe7X(ZvL+hX-q~Ow©O\>u~ҜIzJ "hd*, W$OHU8!0 ݴ\.3l| ĬfYo4DZ38H:-z1edε_|)N̅bQHh (A${O^J%Bdibɖ%8DqaY{!1o#388hNȔT@(s\c: X*eYt7 ,J;oo3.5(3YED^TZ0PKnTQ͌sЦPznrP>6$ܱKf d&g,qg_;/c=i|2#R-cg2P$4UR_K;{oo?w{CiFhz#E ZZ@oTPƫeQ I/A:pF .Ԓ!rt%~PWG@^)ykʠ̰ATd{@2} AVm~INsB>1 Nnu0;q*V X(vY*w˸03GzGN$j@INK󋎨&_^$F0J:Pad ̎' "hٳT:TM&}\;5l oـ^걋e2`pGG(̲̂Vv$6]cJuuXBXckbkv\qtdEEB`u?٠o'X7:2Z_gG$jt5'$Ҳmb2)D2rO?ܳnu{w|냃ɩQ_ f)[礔?q+WZvLBY/(&ʵKw]]hw^YieoEݘ탕m}md, *a -@^׃^ !Pi d[CyDz}[z@9VQdY*t{LXk:Dܧ`B5^k4ڕB/}ٶ:A9 #O=xg=M+MѿJ Εe"uZ hpd/ˬY83=c "Oy$\[&Z[Cτ̲^\,r꭬3yB;m/{3gQ*8g eރ@Yjb+Hn Ӕ&#^٣J2]wqlJ"b@LN@H < lU@ B*eFX[_׾ٌJ7r?Ո- ޖ+1Z_K45Jyjwe!&Q|XA @I3#tyaif՗]줖Cι@{Y?+R'y B(}K#c|/&k2 TQ+#)oP d`d3gsRECp !.\NӬP,^$;nGDtR3aFq0,+_/7Zz+) >Nxܖ7H!ɬ> Z1{J60k0M%cM 7atԫ'QZD^yΏ«+FFDj˽ _EDᥒ88BIޫ ԁ(Is<3͛v999<== RkE@ #Jݷty|lLa_/MS%w`O_4RjbȜE\LWsKKis;t2u >0(JF%gxط-] FĎB@NBg>5lCEOxRa[[;Yf=R69du)hl۶XsÇv(כ{mrpr`ll0 ܹ5 8|ڿg7qHwAXN/{-ӧ,Ůggdz0jE:Od٭ONUk^BVޣRSO{ٗWכvϵ Y J(`>eb*jߘ_UNZ(}uӧOSv" n)-YB$Z`us h: c#c+ ř>P8Zg`!ԐB! n9\$EZt:N ;)P rs)>Z >y,CǾXw]3 vxd[ luu}6.vJy %EZe0 9[lho4%&Do]cȅKjp5oF[# (AXVV5/<#36Ƿ)G z"@c0j;xfڤIډǞzsT˘8 Z"0ybOX }w+›EO\ia㼁E+z݈?jm|\1$Χi):{r<OJj6!W> Ͳu;n\f zhdo-57N6G)c1$-j ,RN1>|7 ][Hh cx˷;m8CE)m"_ w20'otf'3w\5Z0<4ehAΤ";>84x]vt`ًgvmߓ >ScSs-V/DJ2ydCoR @\#xkss~ۭ^J'Bc2/3;"OāY[]]A@nCC:֗ۍ[78t|kx,,Ȭc`ֻ 8č4\?6df'm`x gf?\s_mx'>:Ȭh7g^?z~8W)닳>:Iv|tj.CZ~Tay\ a^"W[߉3xQ z9cһV|'fOXj*1_XŁs=۽  ڞ?wT9*tV/-CՆl*@AGn9G92f yj ז@Q}@)tKT((@!J°X|:3y&dm!1vN>3s) p5kf %5`MB V#!KKK帜3<#W(j Ωl~]*B$Kư5=CN1}#[/{=Iϑe0 |kyeiok4Ӭ5>T+˛Kp$p#F"Q^5h"h} BȬgzQm묒2 CO:, K%u(EPp>Ɩdž'ƣ £/6f_#SױNZjP vI%7} LR(PM,  Ğ;'n۶#wxճ2KQ|cc&H3[40R=t롓^t zƒE{ y(DӇzֺzp,Rlc>~7v4_yt!m$IL0666<< p{JIu]爈IJ-tTt (n@HP߱}9k#օb  {xp֧D`b*$j5\$ ʃNZFaTh7/p-w.AM,bet|+=|,/%ł1)~b7_҇tSX"u=ޅopA@vM/i]z B `f 0q箱|>,ĥz_=jjjk8$dgs26>Usطg?Z=ás=P I 0 A^`3FNN' r@C~zvA  ,qd}#*K6 Q)N}趼fãLяt{i;~hS(esK|'x CB q'#'f+#leg-%qȐJPf&`7<:t˝ŹFVv"P 5 3doj}K':Yg2_;xV@!]#oz H\.K c:ҁPjYHJtZ'jt;Oqo2!蒯Oc]˗/-,Z2` 2 euUP D.Z˝ Qc jR2ZJE #GTj5o}.'!QUJJRGƳTMIJrr!#w*J_84S^[( @OtqV{ȽϽ~nCwE!칒k}wHBJ*OCGO࿟1`Fc !dYyo'U1qQi]Fnx[鞇n-OĽz/ҩ.K-IfJ J ݥOَ\_Z.Ja\Ir_{f=ݴDN֌B(^d:,( T048<>>9H`)҅ByIP.LwB"R|rn;-rt#2R>N`F`\+[ڋ^AxQ/oA2(OOWgXZ~zPב]+.oҥUà9l6Ȟg'DfRcpa~vնz]1D{;84{O裏ݺgw~KoJq5 !/b83.&I rD$jޭ.۱#G}sYf ̤"ЖLi:c^z⹤H IώO..,W~̴)3w8~ςeA-AW" HzϞ9f+]~-8H(ǦF&nd?_>#q,1Xu;&:u#s~n^YZON"J_YB1]TC:IH1,ղ_]_gn{z[Ԉ`e A&3`}!8c/ Tffyi2 H)2 $ VZJtzC:MG^0⮱=2~`ӗX 4[/zFd5:Wm, $C(m.EqSDm,W{Cܾ{wle)daOd-Jydq V.ͤ(zx㟸AJч38л^\a_ZZ$e6xXZ-PI0,Pkᝫ Cڝo~Kj!;@E( /~3>ޣr Ez/d8&PyB0䍮;o.MwM|`@@XeEh١:C'AE@;ό= l+Xr-s'yAB@[WBD`_E^O b562:1Pٷ{jxtⵓZE_^j(ceDBͯNӶۃCSNM9k;Pf"W|–!LF;U`2b}}w*f+"s: ;L MB!e@Zkx\_>cs^;ykf`\2Φޥ忂@k¾}&Ƙ/844s Lx?oj#:(H-<) 7)19f!}^߯vd)邎~syG>133{fc'[! CB\$3gg\YOr׎wѡ__Yu$I|2҂JӀWs )eaq;q5ILARn6ڝnjnv'_W1 ssq&RG&@ ˍzBɣD\cf!_o' NvrJ9it5a蚉e49XǞ*SC#@ɛm#ck.jCw8;{/Sۗ-um$H%1I Ҽ1 [uk~[= ir`ߞOQ\E C%}~ƭ9k+dT,;&~·nMTRƘz@R*͞饗f.^ Pm BO}e^[N$ `b!\k/<|Cgy7= лX$; B{/p,eVj,: u}oRmFJSFy m.0K3/<|:01ǡ_~%BBH`nh-//O0-,.=ztQ 6@v7PVz#$Uefʩm^7\,""teM7 d8 ˸'XԚ˭ϼ+F?SDZ /`?>o0TчEBE3&GЕeY}|CJNW K_R1kJ-GB!J}۞c#I=9>Z*3  (kTvOxzG{Iol|q9+jETֻ4KZ0jiC!dG2"gpWLZ Jp0܆V<"l /(jMOK/!uf 9#c^~ \e4#]OաY+**Wv뵸<:+ֳ\Y+|o}o,,H1(PRbR B flBägm.ߓ11)8ΕJBs{#~9_va6+[R ;f P kvZH: F!uSֺ;v%׏J,3&*w^}v[뭵6 Ah/\m{z 9T!\XNF9XgNCJ|~\15L_;{ P&o޵^:RJ PD5eٜKd ҙ7x!YMXX phhX( ?xsHo3hbK(`X>s^ eI866JhB)T6sײ&1)dw;[奵JYZfHi@H Ah~w7*JC}W}\!',JK( b42<92kώnk={v{ӗO= qFH< :`z/րxT^Io>R~S;&#Do7kG\>wチ80BvV敔(C%0 }G9 Es^(s@)ow{Xf/lt$)`%48:{<H pKW4I\\nvBK'ǷS)5W| ̬Bj'ӏD̮6#߷w2Dg$ݵC1(sz@B2iqϜ_o[NG,Ml^+*J䭐vCr95Pov3VY,BQ cTq>>uE&`z@~ 9tm'7 Ј@z@Ȱa GQYtJiEt9H1er.)@۵o/ϕʕTpKS#i'7\Nq)vS9R@fIO }$@ O:DuX,HVei`隌8 rJt Z@(46)),Qf4sĄ@!DʽFvN\Z <'@XA.I 7{, ^^tI.kk_](#4/ORohȰ˖-`*?|YS 0xRWi_IϞ;uAf<:LB)RJ-m*^s?*7cvKZff.TL|ƻ/ӼpߛBY 7zVukCn3zB-t]-*Fl K3|>'ŴjBQ O̯uAVNJ{f/9Y)MfWOTzhKK@B I=x_~I ~06hoȼGD쌑H@IQ?3^|ebtྃɱmˋ+)[AiW* 0P(~?~qVЁg~:Η@Obo`%Ξ;'/3  JRv}K>U舊Jime")$Z gѱj?049#2һw)GJcmz)I ZhxJKؐo4fPC`맺p:1/9ہFafN“ 6i$YȠ{RoZ\_' 71Ig'Ud. Kibh ]ᢻW?, Nm>s޲с>tpb%Ix տt:\&])&:X.ưly K1H^@emmٱ}{mdhfanqaуՠ@"50Ϳ9ps?xp\.}{116B!NJϼŷPn{VCA_]CZ(@R!moYcD>A fI .j23Y06Z DR6gӨtUH4Yu,[W'FJB;/8K 8 D8Οd\\=rgmrzB ]f8HT"yݷ,_}f< N@ҦZFT_Q:&.sBD:Hl*Je&cfYȪPyn떿;wGܙd||3. P뵴] ڱ|Oyӛ_So,FA<:<$Y R0a#}7OJ4nfG^H*Cj׾׏iK!JI)Y+yJ !TQؘ\0}W֖emfj3 @؟;F OnJ耺eri3N|䁇;6 ^H Q0D(ŃĐJ!ϫH )QHW\Yj6j:eBIABT2_( U,sZc=q} >xsWh}5HWKاsY5Q*20hvNW-6X2kEqR[.Wkη D,TJGyyyuY{V.aۭڀ'ϞP yȪ+Æx P_ pRvɄR+>1i.҄ҤV>Bܱ{ P~?B9õF}{E$`PkY;WtmeOs K3^~w*H ±wS;b^h_>H[z|Qߧ 7 zPu3"@M .bTlLE9Dt:QfN>582b-+Jt,)̴-kĹE?wKw}%u @Du?~pV!x@1)0aBf@> oCZY %3)Dd@WW/\/6#gϞ3ƴMֱ쎞?}åz_R?ݹk!CyQo?w>K3 FM=P(q^"RrT*7, )Z -()&,6RHHJ0Tsҷ}4` B$C!J)tN>{~^oԃ<;?w쩁$й*%Ⱥ<3BC/@k.bh3I޼F&mlI3z4IccE,<"*c\)|n]Zk}O~sO[b{,9^RgZ'"%FLȲc˰۵g/̯cog {_]_YH]t1U2>Lf{gϣ켮@t3|ӝjQ(LHgJHJ2%[eyvv/yI^^ ++8C8e˖%YEQ1 tq  i,,.}|{?bG/% s tXJ+r|gڹo//]ezRJ=SJ8rE/n??ZZ Zћ*NicDVh)RH @ 8cɋ_y233&Zm*3P(i[AP?aBA!ϠQkU,H2@s6_=qx^;\ .s@ړ!~6dHbT*\:{.է_}]mRDr %+tL u2ܺ;Yjf@LSQ`k7@Юmx gb4u3uo}}&_!K_zN,aLBB@f2Z]G4ב|?X.?OV v "DYΌ&B%>lnz~fabρ; ՊZ:|޷AwHVb/,B,2 9f#P2@8@mC=t]w _4߿{^9tR|*Wů/,޳;>=c=ұ<7R#R2mҮJl 2JX·AHk-LRIvLD̆um&s/~S ="63XgŚ`B@p͟!"#ods0ߴk: ; v텹T2IP(? ?~Ӵ>xOm߹gλNmnv2OPx~zTB3gRJww2)k?,@^_ R骴к’T޳JHyE?JVa|?hf1zQ2#@(M)*eD)1M@@k0Ɛa. FCK]|jQ0@{Yc.^;u\qMhPpF ,!}G]v|sx$rZRƪ v<|"3Mu$)4qhF'@L$SUhΞcl+u^ˬ7]Ddpk;dž)@jAȀ+ 5kџ~g|k[ %LUjޮٙF{җ\o=g8y.\0ys;ϟEeZj&6{R,^;z~˟ѿyG_QgCCx_9G6 \#ݴ&⻺3 FʏTj:8SWjJ=_jm_ 5́{*IZ /zgJErҕZV$f~PJ3B/| 3Qh =݅bV(L^g,\˪٩hd][FF\9s "@ H.n񇱠O9|<Ćȁ }iS(f,@㌔ ) M{}rr4TcX9r+2,$~O:09ņ[p!y׉V5WC"T(WhxM;q܅fy K~om~[6l("[+V0 R(5D?^zex|OdQ{#L+XgOWʥ 1kzï'~GǺ^m۶/b˥M655ډǏ?WrFn#3zՈ{A$KЁ ,Jcqqe?O5V둢#߆p)󵼔ָfU5uѡQ/K%&C9 bOv ,UR*(^*bAGf#NPF//N{W!t ]aPRײ)udE1 DpG-h7t/\z;Z9B_ 02 *>xGpᩓpsO1xW:ۀJ(ABZ#Bb2\ l]QO UipT,DQ (ںukWsV*  T*i/-V;ՖDjd&C->=|mjM/u*ZǸH.;]2uֳ=er:(g:C'N PJYIIƯ|#?{ࡇ\\w ~j3/xr^_{m==~YB;w읟_Y_ٳŅï={BvlKpH'ݳ9׫rI+u^ <tv]eRSכ2,4ꡩE@ Jv܆`9B -X'NRKe"!R+BŲ2&b\C1,IV]w*;StKZ =0-+(`q=/^9r{؉<΍=5~e&YKhY 鈽gVbtJ\lwOZKڜL ~U|df0d0شe1xřmRSETd\ u(Uo,֪ vC:99YDaX)[!PvlHn޼λvwu?3޳R ʞu ұK:RP;95a\"zEei߿}&Fǧ.O~WsfX_vݺmv9koNj+OH ,RK!@5.K2!T툙1˸h/h4ɛ5-v!LLnGG6ܹOTqhhYr ȁ k,$|Ykmr߷e՞M*(P)bYXˍ dX:}W$)X#(6 {Vo_SmV"84[,of-Uy܊ }{w~A_X8y_ڷ'-a+)&s9X IOf۷ |nYMMMCB;*CMLDu~0̚Cb4, m^c', o>M}={vz H @L\lA/c[:wT :A,pgΜ~ool߾5j}yXpD(MbBWuU^@$fFZf)@QY̚!{6I|'܇|lbܙK]YcA+/I/,AWE{MM*NR!C")yX+**匍TPTv9usKD$RHT;*kz(-;vęXY1܀j ˰jOP*Sm-r&[u=yo~YU.G :BD(sNAz̬ZZyg|g,c(*0!-Ւ(w޹%By݇ʻO]EAu~avfz-nPhɽ_7߷Oa7|mv-\XTJ.-qGf;/..Hs]\ D$Y?zc=,iz'GG;qu-ɧ|j5Z=J$M/M.ͅA,!XKa<ÂZgÜ1ff' "ARN=-cWk{exBY}Aʳ+'{~҉C޽c@Ͻ_>O~Ge &`BH U'Y[<3e 6'HA!jܬ++˴" D&b'!0yu`>OԮ4<"5J_Q\3;n’ylG_&Dљ+e˜I!S3JbSOCNVk{,=ׇ^oZtXȠ0'gFJ'|1J28C=zHoNyQdYۧbG}}B) JzzC{fvzJo~rs3竽?Z'~Za? Ƿh=43;>? AY;k'/E%59D@*fUZs$ B"hsNΒ"LI{ K-c:;Yu&eb! H#^Z4:ZS /醙m'Ӄ3KZ}q"ΗWTZsuTH鉜, [h~~FVA^^ (A;e9]_pN"@*,M*NlIDg=ӯ|޴y3 8cg&fů>5t$WHe$!Ʈ_eC` ն;7gXE(4A+N'6%fN9YCVW Y[D Yg}d%y3y8/(_BPT*Y* f!uYwO]11v ?XSY l:Zexj:@X3acm4M^oi]i4Yj Q9ы/+巗gïpYjBގDk#pV,.-}9qngf#sU, i:GvaS466Vtj <c^Ge@D&LnV$Db hs`[αY;Winj[DDR:$Q{sT. <gALR)"h͵s4sje3`ů<ŚV1ܥ k`'p7Ca{~wyrKGe2|`!ibUsJ=&oHAB vژ\KЬAk|z_S)d OiSʒ'/Ȣ;_ζVFXP9w w[~h4JrFsvv4MɁ1.M\_@Woɯɗ^}y(rţýY}E2yj<Ɯ }? 6rYYkZJHX g- 5n$Ψ`bG*\.tU_q`jsbUCRãO}Kw맠/ILn;[OT)z|4CtV#9}vu2R3X`\s4n@=t]c9Ii 8AK.71֚F^'4FZ}oլ`"&[8}9%}IK 7-0R()%H!:?{20w9T+~8R#yF* )5fƍ|k֓Ƭx3Z|E}O3s*|HkԀ0p7DD}! (e %Ii{$c}!D=Yg㸝綯P(TfgI7/|O|! 5Vf?C{@  oݾeemQoeX'C&t D04;?yC ѪתˋBz،֮pey܎W)0H `1 g7msf<bFFUbBE.^.©WaxhO=a^hT|ԹʇAЩ1cr#;hi)w;!0QѴqrh(&ilay?PB f6yn`"F)0jPd qdi|=](>IiyO3TM>E KO=?2D]dO@O7 ]0s6kb8q b~]5ko*^o d)T>@ @Ȁɒv̌3%Ug6\+].;IbXʲTWjֈ@O};/;q&^<^]`%ܱssw9$B Y&e[izCwYZĀf[ !} &Mo;zыQ w$vr5(G6'4vc7yJKj֒tN48\=-Ziw*{ 9b3X^tXBfTЙ@d!JT|شs;x*hQs`רX]@)HP _|gx뾝_?4t0$HZbwȷK52o~۰[) s=stKLrySZ0R[gq;˩ 5@$TZ< Uo=szff*?//Lw6 oCv^ڪu@ %ஞ8/6{;ۚN/4AV.OYUe"N1xWilF]: ?0(^IC]Q/>Pg 7N໿;vI|23cQڈRKW._KӜJg]&}OZ\1H1oԘ]_ϦIW9?* RIc/N ]]]}AX(I:"[D[m;i)@cI\J >ajE:\:2~KzfŬ,d~ڙ@%܆ن%L..9Ӭ֧x`o1,R9sRWjy]]2)t mg74{e"= ݽul>u߮<67k׫-vL ray_E}F8;5vnZ]8",*c-N-$I<ɝ- _:ebʙ{ٵ{+0 7*LĀ?ỷ/2d|0n&&A[_fkD̫+(vٜu1<<<ϴnI[pdW&Lo . :X<@̜[57\oFjD$p2ag_]x [,@ w0 ͩH,䉡Dy}:Ζ([t"MZ\<:t蕓'O˕Jaq~ ٦ԚYN|}^c>*(y`JE2 #{ccC#B(:D֘$wXC@~;r UX MQ0fX=C̒Y+&F3&T: e\J @C i-h lh`9ߘ19`߫E_ܫM.C#@RFU`ze[b[Q$B^mAb{o=.pƚXzG^{}aw܊983ڑ;޳es%~"J%q0!0ͤTlATL#'~3Ii꧝D@.>G.ږ@Οpߣ]MCےiMi =}hMgJGXv{p|=;*Lo|-t_SG>,ĥ(!(%٫7 8gHVHWt_ouneF9gg/B?{Q%VD1nčr(Y"j7v^*͉Nn2u"I,+^}ttX^ Pjfեӯ"#Rޞn"`H)2ZmS\b@ "4 bHƢDW daeW(m܆Re˦QGM^;eȭ 5:t69wu)ؾk֝ۋ1\Zy9@8ӳ3ՙ+zsjHJ<]БcKe! A4<0080nvN+PRHk%,-ׁ=YўfI)u-;348<6EEgZRܲg;K_?'nL"/,-~_ȧ?N:?ڷu[q~*qΌa@tX@7*=NEhɥ6Z* A].}cbR1=sC@& ;յ}֮.y1HIff(䌜VIj@QkKȪǭs.mej 23"R#zRX$ L]8u} u@@f e+˵R!%4(C w,4PO1\Ҹdn SJZGG;__zfoC"fB v8'̬]bP]Yr+tRbCYgUB(sű /7QZ)"9繐s\ 摞>핕~W>d3iJ[w!7&>\28xy.ZsO$ 3u6IX<;- EZV3zO{R$Nޞbγ|d$rRV)鈤Fk@ ~o3wu+o֒$:xhؤ! 42U _ed`Uj۶;@U7ͼgg{yīrR(S.WufrLl&$1X?H[3r,`$H><͋h֭W.O|h;j}PZYZ%\`k%H4d!W۔%U tLhMgUWϪ{apgu:3#Ȉ9pr@pq5R \ߖg*'0~yh5sM[BĄE?35:38b9D1B-\Pצ'{+ tOoߙmlQ 3-qJVg;6޿BP VU={a/t 1y $31^?[­ƂP/u;y~I/PH)vPĴCp=zsB䌉 %{2tǷ$) r -ʼnwyFLLJH1gJe mW\>7c/~gz/z~XH Z=Ի9 J3$}mӳ8-J}qׇQ|&'Bd_xĹ`_k@f2䐔V3@X;`rxʺ{⎑FUWO%!0k[ܪaD Q0@f ܁6fdY*vUfb@A"5z;!cp[rY[{xhw0b/lٵ?hؽ)JպlGU\F68!3#gm%y Y_yi)´CYo/OF@Di-ID -H噘:{@M29A:ԼWµB~W9SǷ94a`Nvnsa=gc\T@1 k[I|ySg'ҙMG_>E2eR>y)Vl??EM+sKZ?oD))<JIŽboorۤdP(}e!g,K `$N0VBh9^nYϮZ @bly$!9{e¤N iY 6gR6d'u"9 y!|˧X l &)%\re|v>0<^aLXz~@~ey TR@Cf'N<5:Qe9sIIv< / 8  _ACmj/5Y|]2T 諯<'dvH(Y$dr4J+pYIn*Jw 0 2dz@߷@cf.m9 bbnO mkkY']9(mĉ UN NM2`0wAᇕխ +Hjuw-./%y6:<R(_{q;Ly(}uyQ/"$.Xct\Cȣ/%RT)+H w&".P r"iPMbp̙Hcݾkd Zr Lѫ]AAZckͧ&yώl_weIWs1C3$/Z4/,ZOPvXvjf-~G?}ӳ=݃*zɐXm^I/|I+, I\מ<=\[wo)u-Z[F4kyV^9tW(|isccNsZdއKX9e B$Q^ݬ7"vXly'H]niyJqAwڕ/Զ oO=4y@:ӴˇNؿ2>s.홯O4{SBH-JfKʥrs~Iu:U&JophБA65_36Cm"s |Oϼptan14B, NӐuWPPݻg`nah `9]KKY FܑZG^*o `ܭpHA<#K3J*HK) RR naA #!BFHʑw)g2ej0y+гL4xB3 <7daz1k$YGþ3C͝>;$C=wn3K?gLA{w_>så)vRoE8`9~o갾")47U_Yd "$ۥʣ}=[ǸQ)MӞ&r HHDOy>fC*! Ϡ 3%}^3nOZOX6.O͖Gno8SO$V*Oƒl\>{e$aoaԞ}Q׫GNYsn`955yrR7=59<& 'L 4M}w*?z-1H<JD@&6;@$05 4 @ً҅-m|]5q4JkSS=/׿&f/{By3 [+$RP 3㤧XLؗ^frjbW[rzB WuoyAI@PXg3*)B9Z68{'^>yA Q YF@9ۼDܶu"RY6juZG} F b}#8&!D@M+W%`BvV'GKZqW=_$D`Ķ=ܨ/G*zRDoPC` !9Y8|yVᆪvO.m{w=Z ;PBk֛Z\΃fn~v*Om*0lgX8soC1oiUN{,,{?Wk8pI[ #Un9&nzZ\B}xI\,65.zz|8 R>" &UV]%sD~Wz;Y7%'ش3|`ӻIo~t?0 Ru66r2ȾR-eť/L^pw?gP}{U̞֗ܝ] (Л( 9vzenF,*zâeiwؠe n@p;nGbSN0\%:vdc+@TZ\}6ՆZю,\$1 ^;Ƃ=7˞7E_dR%Hrʇʅ#/}/ΖJzY͚}Vf_zyiږB~2|ZmW` GǶl"E jg"θQǗty}* WSyeb$Οle(Ԫ@j %L8 _]yr0,fR5v)*f|W}S;Pc=%,4;7qeSK3ST6~Ssxyi D咖QAjm]ڼƌD|c)N vlBZLb} 11tNb+N>|`BZ]<Z}p=A_1@PT"3|?Χq (-_3؎Ο-]>9B!|Gغcdʳ_s.;~ԥ~);vl76PI CI-b35'溡~*#0. N Z^[EDҀ77GߗO@^u ]4͂0\2{i|˦϶[e67l//WGGs,, MSRU]E~|8mTrtp[9֦P;6L`\dU(޵硏<4ҹ_2!SyAJ@@"B_*͕:Uې[Xi^B I+ͅ&%Էҕc g䘜X HBȨv@ԡc`75k'w]7^hhVG%xJe6)<ΗR&s t,0*O#mݾ_I!_`oO}_JRmXh4__Zhۭ?'W,!pQ(ǝr @U_v#IMW&gbeqo6n\=~ݻjj6߶cϑsH%pCpx҈#rvU No`$@N·qf bѭCݕ~ڨ3<7@ݕҎ2VKR:c]ťsgL;g&ϱ)w׮0̛M,th1l wFA240WI2R! < B8ىCq8!v6v 9ésS߆4ZrX28ց%GRGOZkIcAblx K/?w~yqLe^u(xګtQNr yU;﮽>3s1UP,w3߲$٨^IJFWWO_wPŪ}-^0۬)%RD9Efc<7Cbdx$ɪۺcXj孨+EWJIܞ,2 |*kIݐt2!Hxrެb)4.WykOYƀ-,f};w#@*qC> RAn};nV1;%UNFh{ホ*3(qY@PR8rD tAWzmOԚo[Pž[=O^lL#*nƐVuP~6Ibuqy\̀] +Y`: uXmf FnGZ,(]ZoT{4B8V@466VzЃ?Y):cWBZ7I% E.(j˛?ǎ~8է1_޹sL ى,ށb&A>6jf3a7 $ёMg5yI(G~{{Z]ڽwWT[Gz ⣏thth~qnk|_ ;ʘ YRtņ^ aFC~+_v{ga@Cȭk.i5[z"hSfZ0>5OsBkmvmk.no[Pr(WM-z1P~קÏVǾBerX[aXغI7$C|%|%\T -uQlY+R2Bw_/d _(GĔqnԌ=twҎ_~y4ipldr J"<ϬAhwtYs&žwPJrq}eiJt@Sk=/|}amXK gKSKEggi ink?J嶷ӿ?3?vBV]O_n/_/8Ve۱YCT W@yz@ `GJAH@k ,i@^O!bT #=XjyW/:Ѯ6.?{$KS;>kչK~O<Ð"$l㕩zܿqoʲ@3~I;CWwמ'6,v `"N[fH7-Ёh߅ƷIIH>$:KdmT{{m4?T.^]t nϽWI?x؉CiCT_wa|N,4]i{J~㧗^# 9j s6Wy(Rש\'K]Z{#y;o,>"n[rB{-ݍ=v+ϿxPSgU'@%C0$ (*DܹQBHD&.-?Yv容V]Qf@FA CC=ZC]|^?t~ FdA0Y$ z:u 5hQ;W}!_,wPzL T>Kbh|=Fi :PġDKtf3Lz jS)EF`5? t C"^kBeǜJO"t 8+@os%=s /ַnݔłG1)Rae?ټ rxĩdsN ):sQ6-sUq^;L@&gX} aQd$6W/W˭xeRCVJQTR Y+ Po^g"u֎M;B+sGJ4wI,I*~)D kO`J ovWaA+5low0XY]aڜ09 `ɭeYTzW_jRJ J,4*#14\8|J;<874x4_ R8Kc%4ҩBOQ -:}B \EO(U*ֺu/wvR)Eobr^%:I CQ v|m,Ԇ~@Yyw^Y#8fBZJ[(^tPw&(rw4D(#򋫞w{^;2Z#*%١{xf?g-\B^J%.h<MFVQ+܉k_{C;GKUJf>ta {`N4|;/]24B(L,P @[Sʂ`r$^dv`ڪɅ[WC ,mc|聙'k9+,[>y?IlEP|8rHV-=Yؑ@#F_7FAZKkZqnoֱ 16WJu]*YO-Fi\w7IV-L:Fb SA #3+sىv00 @q=kZHC!|!<&E+ܽwXiU"oà[yU~ 3 ’OmZ6-F)ML(n1_4@$4Tm9zERJ#-k3qKޓ_tY'me~gnFUXŷ V/qr+].3'./NkS +2dLbYѶ^ si(2MwÍ33L(PIK0G 2+b[+/U_:λz/rŎ@ $-`/(LĹbRQaMlۻyx`۶6'\M δUI&V:,֠k{!0ƫ @gO~_v J_S2k; RcV.#je*AٮtXHmF P)uxҹfl,CfŌ@E$?rJS-,뎏CZ wQ@!xs8;9ί B?q})31@q`t .e=6"_o~B*cb0&O$ͣa;g2crk<!z+`\*(% %r,<%<+Y?g>#ⷌQZJ3~蹅$;75 PZ w{w?# a_kB`R놓nϋGHAҲD1{]wsqz I)/,WŔSZ2 7BL;:|B5 7fX7;jRF&,l_l^uHBnѮ_M[ B;PKze.^\: wK+RݺD-(3]t M ˚ {>h/Rr"oH͙rܶmdcϿt4%+!`R\,iV (0 Kzv#p'4ϐXߟ &9=\*,$iZ|`Ú`r:^<| W8ׁ0.kvcfn*硞.`ΐV9:9wD꼝۽oqz'^l$BR:١ZccKƷloKZy&k4I:.o7 UD%e!*( g QR%.q 0_5^?}^=+SE(x` 8c ZFA!P ?692Hrdt*ݑ_mWJ7QoMvw 0 .z#3̲8jɝ' "@B3)DeSe]O{mtE+ʔ(  L}kf@)l3Lϙ}^kU[o3 I4 f@Rά;}g(:VB -OuJZ+ԹB ,>I,*r,7nݸ칉jm]j-o+EqS"0 :9z'' ]I9v}śwmR&$ezܢ#z2gZDZK~W>)Awgbcqq1k(޵}8HFD@BADDYnӤ+J}wFվ#ڍ%p]6 -NVP5ؠ L\ۑv10g\f: $5 ӏlrX$BF:b!BPH8>sasBVebMKC=s޷%IrR(4en7]?/toMYJ2==ԲC:;33>? }p-o <1}@a&?,]ك-[6ۿy?Wbhpʯ$$|Fa%&9 XT`@_R~! A)#'põm[|Pt<PA TvR_={~y;+#Ã|eǞ]8B,Q-B<3 FG8SQVjvaVsSb5ղeeX_AFp@h s@>\?&-ض]wʎMYs#v# !VP0(X?p[nx''hkzqv4A({ґ kS3BpNAȬtqxnډ9~S_oٽgg!rHxv̮>=};K\m#YD#l6fTJ]LlynŜٽ qadty3ǎ5[bw(qja)ٲqĭ.\섔'KK#{$et;:[ tMCjvb`uXVi/|2۩\^+ ]\9uzVdPD:ks.b="3x{ L@Yfm&){ 0 NZ/@@,ldY60ЏHya(<3sF{J {o{F>Ȳ<=/R\)3z'z+ːZGmN< pZХu7*0/Z4"\ ۑ}eslx" B`@XuaQd)x &phq.{5R{,=wOѡ`džOo-bҚ!.Xg,ب@! bTvGgYkV9;=Eq b|+_8 qT.3g`hhhvf6In豣ip rcLRYXX`Je`p0 NQ\7xdV (F.46:&n=qt}i"-k*U-ꆹAA'`f z+0KddRv>\~$[n|_89;7]IޜoqIŒ6Uݼq}DEZ;;-m.hU{9MwR)GBmċu׳g~fxfSheh͡`rN׭X:z(n68t7\ˍ={4xキxE$n" F=Q,?/AX "WT*$>rdxJR+E V &:Q ( y 27:O.?Oxu/OOTJeh4 <{ "ln"n7NY0 r|z1z`p0IW_llx\,oKLC]N.6#0 h,Pnm@!w7R~ 4 >S[ih\KE= ^ɅǛ!ɡTo[T<)kڥvl77$y'%, CelkXXV= )6޳u3hjc{޺yG&ff5r ?3qA4JqX%Jm$B'#iw6O|('ơ݅٥_z*EC}f HG EZ 1'g@.Y,Ŵ 2ֱxpi#ra rW/ ??BS.gl;t5VR̳Tjq E4AsfjPKV!4Hb誒%B닿/]:.u_E|@DPڭ?tpb棿3$,Մ(lp瞭Zp-j"˖$k+osq:dH$"gDI U O<;7%o޹I+'D+(YDD@ofLq=;PǎY&;b? O<宯T?j?RX r9t9^#yHJ Ȋ4sKՕrߣ5DϹe:PP+m,gL\ Ѕ8ڶ}SgfA.bpuhr* *$F@dDDCNf6VUjB* tx Lu# JO}F~68@ʹd *qcarb9O3PA8@Ѐ~^",n'Ks9f0?{IyY钪P Hx}y"9vQ4=9940V NũzjAAiy}}m$QVw OppVRqllT޹gAI;iJ! _O'wk#AH)?31F 'ϟgΜ'O|"D`/ H5E@9͋qy(e=䯇,%GFqt*RvmWm*}s.1h\{y}ͿBDE0t]WT!˻qv8=xv= *!"y."ti^i(]`EA`?WrYL;TVnk.hQ*%Bt!AhLλr r=EP߼0>7Wqs`ngR0dzz;BvA؜ Q[kϕC1S>837>89}@ (kPT ]S`U%Sz,=&^=^zqȃ_/je>uZ}zϏ~Re^c08X6YqPŒj.K՟_x; N9Żƫj5@륏 3/:<70xݮoy2ǟ [bR(-;[(iص}C۱3j6텑P!|* :X@<@OεzڌAIR|x\e)hZ`dv+A7żB& `Q,b(#pZd& X34$W ("\V_ln,Ws-6:483;;8З;O=vxyQR=yb-&DG} \ \nuo>K:tS;z=qh"ժ R"YuD_=^+W.?/ Rk^F< _G}?/uo@Ԝ+G<  ;։^(-\TkE%\fۗ:6GIo4WSj%g{ @{4G{-;ޢ0 @'Lg^th^ye]H^"[̐(ħ.=v!˟;B5*ƢRj٥ca+\Q^aYy`DPۻMt@g!4}or/KU ;w9>{ރʖN}ǾC<i[Q9pIO܅g\8q r M /r^6w A[o=}v^ejY\ˆW ^Z 1J(,̎/5C#$.5p JUX*|#Ͽ KˉV*C(6z_yَ3XYqPa|І 'opt@[ADzwF_h\^W\D#,{s/b t6@Sl5,][lm.5gz "eGhIՕ*=8RD`w"/ޱ782C 7kfT\!w]`$)oI+mo<3?jߵ:i;,2zah<Bu9L"\2#WX u6a^b|{k1۠.7"!Bf&T|Y3<&D°OQ$ 2ܳ;Bi@"" =g^Ұ|p 5<%)02>Pp_էdc6uJ /yeiiO=~GF6m\l7<э @EQirb,F1M7n乵, Srm';(Y"–+JSZ& ndP9Z z3=nP:==ev]& O<{aLdCz 8r3GN>y0RaA=٪%Brf1(dyfj D (0 @.@q@7mX{`VEM__,cag{JxhFg yJD\8uQb[wz{n׾ݿo~ixpk;R4"I.X^kqU^z@<=z04bv(DW{fКxFGSD "h FŅ 1XFaW T>ujn!:9=;f'O,1XVX+H's)t3ZCW/ ` [ %iT3D.OfNPa_*EJ!\cس3/-ut l6ZJj&MX'Dij9q )fڙmV;/vP# 1ĝ9ysTtAiJwhkwlsq0iyr=ﶾ, %P &ʏ?yϑga=X,^)MDNj.* }ZڄΑeVgwnwvY[ cB9x:JEQt#GUJȑ#y2PT0=H{^'|PL ()8s™'~kssG]O9ԭoغX^r  ˷_ # BZ@rs3C!)Amٶctl]Dk>M^V²Jᒕ@B}|0l_P#Ģ|4A:{|sa@j4X֌%t0*dDD\ hlߦݷ_[A$L,~nv h@V*(ffk}lOLL޳g޽qtoDFQT JE?044knBԄV+2cR{wcNZ}RGejғ=qG Lh `&RVXz Ko5h5;p~oyzϾr`B {RC=&ѿV΄5 SHy ([ϜgQ)α|ԙÃe'^G7"X(q˓L 89{lq`;]7z/='yf7=7;qr:F?ОѼqD(P1uے/fGᎱMw0kh{hLNC"^kb9{ߏΫ˳KIXdR{=lݿn- &IDy><206)xԱ9p~z/B0<2ܘ^8thv7lWdzaB yuGN>Ԧ B)ă'>=8i4Ǣ0QQr⋐PCAu['ӳs'_8W6(knE 3apC\[oR.,ԇGf1IY)eBoP0e o "T#P:rmm/e%T} j[I4P-G8@ߦ͛gNb6l/ֻi[Io e4*aփgGy~$߼~7 Wa^Gy!X[!0 !yٜ Jy$wPO=u(d^&}9 (3Y;F:-v,B處V<{ K'Յ%hIEs )w?".0=]Xy(`LoXaS͋z=$Y!PY[iHAPXz{  q!vDH]E=)&ibwlΐǒP_b{GL9۲uS 山sY'J}|D|6օrML@(]LZI{ӞmN{֫㇯Fm!HnO.fz/秦&&ijC vZa'CPy[(ZXo(.iWZ pά&YGO ")jw,.R*@H:DL24j|>MnM`;vZe떓'O>=U:NbQHyx87d)im]}&^ CmI@8Cçzi)KS' Г+ F#kZV\Fkaf0 {f`3{iy"I/dyՠ'Zf&bgN6NB"`j+yKued 2&dIA{1IW?2X=.@J@)Ari8n,O*0޸w}hg?e~Y-bl PgmBžF˂Y\utQ ը_"c2YYiR-/wuJ84~{ToG4Z3 hDР{?3FgrBJdX<8_&W?;&q]JivBL2|D&NǒB:e y@8D !B-RH3 >KqEᆾXcwo}o8|PBEAP64*f ӥd 7:nXņB,t\1_@߂vDMKݾ{O}'ʃ'&\jxEUP (EPd[Db >k[k}Od[EFg*F\;_6OCd!ȳ) x@AMJTf{wOs97ugwAf.akg4Yls9_iu;wl>_—H-hsTT*٣Fq`{@,˟xN@EZa F08R+W'PfobZ%|KWro4&œuPֱ3 V3dkQ[~jdM&*(Ry׷ocﮉ!K` $(,Vuh83U]\3~pJm7P@@E"-\hӨ;E|:kش}g$YjwɄ 7Rvhof&PٓB^&,?XYGܓx@@HU*UgC1QVzmYS.ZG:۱ûʓıC]{٣&N(ZUL:𚖅pEH!)P1Ovlۿ]ǥh+z]H`XvofZ5Z!J9 6CP j+wv%\Ƹ)(0OIib]׹0?a}Y}G&ܡK A*AFD@Mj@E~"awl>q#^ =n7?~7?9 QOb>cҢnU{/6fFnZpQR?5{ƷqӟݷH!pCcv͒4@,^[68800y[ox;€H)Rw 1bmù??:|{՘ ET2ks@YeU(e4FƝN_u6츹6{ژKY9Eo->G,Lj=70 0#'O/>8;lա M!@=+?`A$2mg\wfDYY79Nk{ '([ h6 (ca f=:ﵾ Ѧ,O|\ZVzU& ]R<"Z'fvbH x^n5z~H/7s9Ln < gܙP|qn 敩k J-s>0XZۋ:OىЦu?sG~r|Zahu(b2 +6KYsmsב4KF|1Jr:8] 0 cjC%;y|ك_}$ -ˊt;# h\N-n;kݰykEBELd4 bkW <9 r'|x/.Z,;t8g?aُo>ʖNld&BQDԣ)0x/*μԽyߍa\}6GDR:.}.LM)\n}m/~4~C dmI~FTZ1 r@l |QJWmB v`rX ޼"{ fe2ۆðZK ̓硞܃B+xB-Kg(hH3X:{$@oݵyK ːMmhZg.CT (f(+1urR00nhR(gQL8 x=ҳ=:sMVQ(i>f3g͛(<1g޹wˮ}ۋxjv2 hhd0Zׯ&EheFQmPO>ſ)mt {Z+J}H@ rX "!eבʘnR{Wɞh+j/>fL{HضJohg{xރa% @DVF`n=Ӈ^x]֮bU;xQ=ף2}nۍzZ$"1ﳗu&KBKYHoaY9q lC *l,OFCҎOZMIH\0 cL~pU KN }š]}%Y868d̂;蕢7jubE ;$Mjo|j* =:<덻ݺ= "]C"e$-^iF Ba4%kZ1"Xf`]{J_&* APڱ^jG0`ǵ>F8yҷuMq02#˿ĵ4Ruݺui:kKxs Ma! "8 h^="nPGh.@lKw񮅌. ׿ }ᡇ Z#cwx4<5qjϵ[7<އazL4<âHڤ]]W)4 :y$ZzXDЍXH3{nuuiCcI[^\hVr8U/B,VZ yV#8qJsssfw{_F`Y< DKz'LPdrMC,.žRpP,cSgz篾jgZ]VTCZ!!j_4:j=SYzڻh Po6N?L4wz"[hz 2;F!nĢU7@`\TG7i$GCe%@#(f@BE;ăf&.?j "٦)_wH H\΂JYRs<=0othP,LO;?Tԓoux}^gN'R>=vsTV ty.VJ޸3%ΉuuIA`^?Kv< "20e -IrW?sC.Jg@n;pm-ׯ]GgT&`dٹ{Dg!xL|7eL%.z+ X:|d 5t&!UT,sΩ);}z! TSuO (L OytdEH"B\)˃RZͽD+ҏ;/L=}H;KDEA /}ӪT~˻SnڅR:+qnŗW{!V+b{jbXƶ$uf[>ؙ7| Ϟ?}{WCv=,"'p%Rvu@;}ٹ%PTw?,~-)uVѢAto>{lBX HG#B ȁh{ǾlӨRii$V .O:W]9z+sp /9c`c"S\@EzZ>5 ! L.cl(q œ@gP W}|?-^ AL$ AFT/ܸc5dIqR?[]_4G@DJZgZ%/4Ipo{gFF)0 sN ԦmouX;v>%-,CjUBrv[o]N@Po̞9 ΁Hr!ɦ;}+iҁDQXw8pvvsI2 @=loZ'8os"CR7:J]kSسƱCɲb\"qw%Z2PN!R%C65B)%w{׍:ί }NonCY8FΚv~nbq~b,ϴJRTGƨbÄ|ҥ^^Zs2aRu9h=6:˲ 0zfGJz«WSo`B.1X_a(|92EL ny['&rvC^gJsT*]qEYi=奛UN-R-˭oxkֳ =Me8ه.ơ"De{ @ۇG'fxd@&BwMn&2jdqU@%0J+sOÑiDF9>tPHV: H]p, w r}}X.s.=`up?qZkhEyX&I 3rAT}M;v] > Ο03SԦY<;3ŀ a5k`澾~f罳5ѯ,Vk{;|19Π4*`P`JA@\e?4QrQ7~{`yXB`zٽ׳0ڄay}NF0X $xpр_ρ#{/(B}{kGl/}Ou9'@E,Bbe<E~ӕRYذm}Tu9z5k0 o{?S"@ $Ye-)ZEq\V/@]0 D5G MP*P0Z]?:{b4-aPܿIlx&vHR @A@Π^h*xn HfNT7"`"; b*BhPJ[@QJ0[ꯩ8oADQO7Kڮ΁ G=Z8iX/=,1bv;i&fv~Γg>pHփֻظs{>#wml$2c2xN҉J'g]Ywcu߾pGWc~,4D}-m߶~}>`ylkŕ4B+rCkJL>Y,KF4:|[\u5p57̷ l1Tz{2VEDkM(NΟ>D8^XXرgNFڹf3˲~G;y5޿RTl:ccsDZ¨YFje:qxMXηW}s:g-+́)F" gC "ƏlBݶվX4CQb Cn q54" F''fsDZNGj$K@Zʟd rLѡaB Q.bz:%MsA{`@۝HGŢ2_Y`;w^ϟ:=~0,Mi;!*y1%[iG[oƛnѣ]t|oά@$Z;^+}ݗ&tSJ!{Mm޺a|ܬ/̞9?~nn*'T&۟SywgyZ:_^~a&"0QlrხS({TS\A DqWھkddgy.\?]R]?? sFJwl^cۦϟ_KK??OLoۺmjzjrjRGGo馓O={{oi=Ox޲sm[cv]V(fv7CTY:J|_H{4XFb'> ιS$Lds@{3['|zt{}#=.sd1J0(?[?ŻRfy׵.LNg$D@D!*@+}+"0Bu<4v\O~D@!4o\=|t@L&8eq':!/EuRҘY+@!Pq_<6P,:ЂGy;փyt:* l; Jm4{K€ndY'K@iC_9.F&O*{>J+XK'QlV8X-OJ8zt_P4>+^ QžvkY_i&,/aaqfS_/DJ DBw}-QVzI22HVU~GF+Z+")a &>400p…ٟ[gB /KFn0Amws6/O<;1{&Iq/=qgl4۶y;uܹǏ]?xI@?\i{1 xD@to3'f?68Sem\-p`AVv ")0A+for&͞܃!ƱXjHZ!gYrnjgܒh:vvcLhAA4]rt2Yg:>9== ]H@ ,MI* #Ŵڿmҩ)6*"R"㞋xkmJ];6xÞ8l:"#ұ,; .w "J)u|pK¨HoxÏ|LoeV$ {w\ę9֣[F RZQ׸le9˔$}͇Y Y%2# 1?u##gfB*/+P:,Gwts8 bj) \ʧޔʐܲ-R*(eE~gȰ˳O}( @sJNjYuι^@՗ky#>I.}קi/}C_+URZ;.$kGpŊS%J7X &kP!xZ;琈qYk!$X3>fZt=Az(n?CP4@|(&a]5Bhg/L:Y\p쒬hWi!-{aaX_ &`A8y?/<ܣC k _;4J!ϗ&2 VV70&bw:@_/&严‚(lܸedUk85S-ŁtlpO;mbzhro/FA6aܩYc/V3 Rh%;olne=RFB^ˊv7 j5EFbفY/F* )j󶭣%EIʠ(O=k}C+"! #1/)\":jwro.58Yo['Z;{l4=/ q#Ԛifq; 5o r1 *DMg|?{Ruݚc@F·Hr!l !tp^>X9ٳx+B VPXirj5O>C_*עPOO$lSr^,$ @qDxAAR(LTD0dfD ;$>Ag#H/y (N|#>To/l+ZV* <dMz e(emڶ鹓'Ov&CgW Is s3s9K؇ N4{r>zA$ffp[o7jth_;Ǻ\lu_+jVkEfH"A( QQ. oкA4A%}7{䙅spn +Rbqsdߎ|/,[VDQ'MmL}9DĄύI@S{\Fp,H XNOY7ZEuݿ3uYHP g_ڶ,V{d5VPϲFDE *ϞI,/\>qaUnu!q!}:Q)4a\,"E*t;"\, 2RA^Ry9Nh֋`&JG Mss?g*w;]jVY'^`yx]›E;דs 7/~6<7?Rr켰AS% u؞VJg9W[><V FXLus-3hE{u}@xDLqr|jLS!(<fh7,H $xۻwg@ jrn> #T$H9AC)"?GΟ?n\bD G 2L&*kNQ7fL[ʁ**Qa"ݮR*(] 5#Q7 Kb] xb-ZYӅ:.-i oֺ-[Rņ2P$ |# "916|GzO<|'ϒngNaOEɅ?v{~S .'ڳGZ>ܘ9Bkiq˦ N^l=ܺHɳ,)uǢ(ZC@@bVKss.,$ާ??ow2IA,˗5MyIf7K@E鉙}GVԂC[{nE9cbƨj6sbWXV%/ 3 yBDFfk~(.|N ^9΁4Q0!n[7qC?`-QƎAEJ "؃P3)G/1O֍(dxo BxvZ8PdF&Bke9բ| ڶc5PoUlɳ~9Z Ba=EJABlL`6Ou\&~ zf`,{?X2F+IJ^uɊO @A]Ho own/ h3t{܁-7WkHY1[Y׏m޴GA➻D4Z}}}%s&,!Ҡ#躅N={/X+I("iPQ_9~ꙇY[0`,8(;q񓇲liAd:3fe! #`&'>'}FF/ދ Y: qeɺ<|ncǞ{مqf+V@U|ɾZa w^S4ߋ^Rž'A$ٰ80=aw/k rN1KiN 5dyF-S"TCb`NN8Pk0VȘIT$ZU7o|OP}ݶBUD2&뇱 )譒NN59&uatg;y{Hy_ѨO#"s]m*Ϟ:sϜw#BMosjAD" E- Re<;)  B9, pQ`i"6s(^daS BX| DZ*Ο;$I82PO;W/=I.( H Z  gglqu B!ТqGWj(! Ke6f!&Ӯwz䩻?s#gVV5%p(@ :)T#vں7Bo@Hy#B8è0??3=2:4;}v>H+I\@B_,mۆVk͌Ts z^²h/Lkmb/Y7F](HEj4BTՒY8<bw)DQJwJ|?Ti޽)%ޯD驊_"_uK0gomq욛p_F'@O@+vؙ{~GƎ82}|X˓{M@ls3޵:Nҩ0WxgB}@(e6mympqk4U?{N獎.T3խ r ƤSQIl$۶{ӿ773>(@Q9.&ͅO埅'2<]UKV|I,fM:2+>Vex{jLMlߵ\.Xc|^\ѷ`k& I U4}zā玞OYQG$==T:p}oo.zJ!"_+ ((UT9Ν>i"t*D$^Pb22:ykX]jwgՃ2/k,9y] cwYUquq왓 @|  N!WHΝ[GG_8yQ_,UK<f qt5YL_sOvAÀ:yյUaԓw ay [it?uw8T~=  {h膡& @A Q!)g"(yg+3w\ڹppʞtmcLhDDeAK>'Ma86]l54 32:4e=]$:ah8S xDï?l5 4@"B8y۞u/AN+c^T2 VHBqavg/wZE"aYQ FºuiKMmVk[|/%_Kai.OMhu Аږ aXl?/41Iz鹙]{4YTЅ`UL`={yiv7XҶQ6?yMV)ճG3(-8[Sxi7i qyNջY K lL8tHAW]?0a_QyIAim4{M$apVB\RQ\ԛGm0( ")(/? O>CAnЄ{s*Pw\ZlkgCWҳI"Ri* )/<~xpEHs?׵7D4I{ttE7 yB((ntpġ?夽% AR!cK>~G[rWF^zdr^Vf؀Z*M_].W)㜠BO+3笪o{v5$zɺ0 ؎`X?2>=znZ-s糤fa((:-k|*'( C!fyNF`|믹pw}ރӤ;Jk.&Mx_8xC?8ʙJc7G_kb斑]^.U0/Uk$Yl΍UF#e{\ t؏x)jVJVZ ouՑ_btW#@_sbH#{v4ID8Rv48:8:6O=q?7+PFCEFa>9n}aRck( GFŤan3c/*dyu Nk6\[.!_]vyBriM*o{˛&AT,3SJbep ~}+/NR#="M4 t\+TIj0D$|Y.D":-`]r.Bsϟ=y B"Sh{\m;pW\T(fr^̅Szw"ATڱ{hS6s2`9Y?{RzÛnݻ{b7]XowBTTʐi_B Ck]X|rۛ6 чͺ7҂3W ;smwy^+VA]FAL9~d@L;~]O~S_k ".{{o覀 B)bkf/u %tLH^$e ͅ\Y ;I T ƈYhZ&cI;)creY܃{ѽ9@TzX-og] SS׿=r =)[EȾ4&8K(D~t(Ju7, u@cViͯ#s>lhMOL `˳3Ǟ;QZ$s,)9] v\}]Ɂ8y ^J$nh{~ss!zlzWZe$ SV<)R S/˛kU}u@wJv7~7~F0 0yA%mZlVZ*B\*eY~O!dIr` Z)'l~Ҟ;, R€0ږ{-AZt#kX\^pRB*^`% sQE(**|k*=r;D \ҀWJKӮ@Z@Uj8M^(X*q10]R7k2??XF {\?GE4 g͓W\1Sm$O<_^ ,/|wU[W Zf2(*;@sq q)M,Dv˛ggL(Ф9nǶM> EhmQ9 LʘPmOmoȪe)Vwj.;~{2U?Wk?V?v\Ok "y(8q|ZY"vD9"wR6cr"R.W##y?q^¢yvpŞ=7\uP[^\_%M~_z ?RJ6Y?fmw6OdyNC#u< 2"b*p yΛāK2dg/T 2.p+idR u UYGѭK&'N%IO,;_kkNg0PVS2|񕥜:hzH  8Ke`ABBe?wWxX2"~UBBF(``D{_$,ç.w*+l߱c׮]J8-[&4uin:?Q@k]=߹ee4" BƛRC#*J}eť.BD"EJX[sVa%3wQ_!PF8PCjo޲RJ,aWW2[3|@lUDX  QDD3 1ܶY^l6;>%]^NJ3GJv\.Qt j=RyQ@x*wHR)6ZDt;uoyO\i v]QАkw曒<B )*|&hXoMtԩaxdgs(SZ]7]?g~fa~[yƈ/uՀ@/QR.;7ⱱ׳+8ÂV~dsACjYY(5!iiPPnY-ۗ|xr;9pHղY?ui4VOy߻Ls9$DDE!mIrkũ@p|QC: 0);to7L9F0 $x4h] 3;f$,U+oy[=߽TW1("0 LT;?;qP=Pf{;흛>[ `M,urSFUKW}!! 80@'k{6%W PMF6fu4VbF$grq2KhʥϨ?—rMxM?M"Eгf@fŌTQX {)ƈ]/qQwjdgO].^?7?g~O۲{@C#qεO8`@J(( F[^jal-GQYA0,$ \8ZXi͊Rň1 ( +0Jڌj^i%EiCV1 ,Rᑧ^m(a3 OԇM/,#vn7q9&ϒ,|Y\Nn޸+”=D;_~^ Y\`Cn)lvMZvMq|0|摣@C(r3?sʹsjM] }˕֠5!s rn?~ShLۜ'|3DS.`a`uwc+͖2R%Y:[ṋfi;˳ ,!~ 8EaD' u%& ="0(|(R HEZu Dщɩ7կ}&0Q٫qڹݍ "03 02z)xr>&0#4@(~Jv|V۴yr5@'N/4\ =)%P<+dD"8[-w8[U(`Ryni {65]Vhnq_ 2;b=x&9$DDD SQSyܔ˵zIC2{xpgCRa~괯ڳgM77|obbbe+OG (8I;;O=̡ y1`غ_VGRP&!q^XPs1PpwHu5e]ӽ eȠcϤ@Ay甩}s튽ݱ F`@BO+:!CFVC>&lhibkv_eF%n 3K\Ayr9:}ʊS sGRcaЌa')M6Pw>Zi/ٯ _ 7E!RZ36 , 1 RJ+[^r&ǶnR za#iA苟SiC?k׃r왝V X~"gID{vչ}w\Y^'׷b0RݺqUWN<7 "8{3O}H\ICJ @/# 4:JQmrqiDjb]PtѩG[kY I j)[ t`/5} ${`< &R@"6p1AUUlk}=0"IFY7oUH^CDI?ėaYnaH߸G)`Eޯ V_1\ pUl 粥~A),a`ȝQw E;&n?=FvH1TrC6 m69?مs+9o.vu^+OfE0+Bh|_NB~xTB|<4? Ņ^*3v;_u1>)u4#on G!7VneY%h( 3FϜQ/ywxp4J5X5j&Ăd[ϞH+V ask31s\*!6&ͳ4ϔVݤרrh|jhu6E_BѠQA9޷񡉌{ j$(-Q.٬|q-$l@\fwl˟xmxtϛ +v=nͿwΝ}Ǿwc%0@ !ojM­{7\s̊{Î ON%c"ET l<arZKC%GoMhpURrrZ^ZƱ8y,-W*, "6n4:i7m>}b p\Ro4RgTjPN=W9wOs o Bޱ5F&7Sccn ' Qb]GO]ܲK3ĸ^Kx-.=uCx2=f݈б{dt6q~Z-=+c (OB:Z7]g_ JP x@`A+x}슫o*mm4%""@`\9=D ĥ`q7v [gO:&rYQz\|cJLQ0J؛Z#$AOf~sK(n9?' `-@@BIT^5@Yz{õWo8q$ ޅٗ-pHG c'[Y/ss3-w2̀2l+ؑ)@_Hwx0bY["> U4^0 ;KKڔL T.id%1tgyclwܱrn1zC``,s_7 !q=7KddZ 0 ֛@]y徟|{=ywO QrJQ'jQ vCuW=Fh |!DT1/1 &b#9,7WjL6jiw3\% gNL@ M0^ Fm=םO"!:44'/B}7s5%)+6Aa Y8+pرq;G];;ͅv{g|];CMv38D4A zi?Ͳ('jj睈u#97Aa/+wwK:*Da ?[277WՂ @D D@Di ɰрMEQѶ;Fĩu|S=;'#D%Z2skR-ȂG /aaP0-U&MW|9c)LZ0(굚v6U)K@J@0fшE~J'Om߻ 0aUC&DE,);Uƛ~\+7{fϝ659y%-v W1ҵn>wȸ̹Ť*( RˑAEtuOY4:^{FmػS8u쾧ϞnYyrz>륤~!)c@boRZn&iYEN/w{bNu۹ZmRE1&#pT^}RJ ԰*$L>f%m9H9)a:rfB( xBU׷l7H@53D+A[7wz5',9.괷^?ǢG.͜m; ilـ#zK#Hfc?ua عμ\ OusV2=6 LԨJ|୷nnySgl{Dѳղzjnk9|F'J7] (=׃7ةZ˥8P:hZiΚ0w㚫?^TLlGNkAݻ xT.]pdHy9kT+?~wͷuf dq5~J;uQ%p{km^AqlWz6˭ 6٫s[63s3"n۶JѴML""13 Tb8{nsk8 àEAT۱ D͜;g>c="iZ*Mٹ(z5++CC02#U$:Ei4E&Lmڲbɑ7), ("H(olIQXFt-|"h7#घ]:P zX[n~ύ^o!ctBc d죋&UC&d𥰄"*޴ sbÿOrYH4vW^a8Y/*E׾_iSBDgARJR6c6%IS6%gC"g*s˭Au8A' {5v_-ޮ1 fB ?HϾ(Eb)$O>-?p+wδ-y{KKJ4V>vgHaFfL0fVO݋weѽ3 ($3pfڏ ޝGYG{xDuªXYi~@1 HhAaff4ahB%ihc@y&L@&':޸ os++'7߼k{D!" ^講?w#`hFF~^O^se%0 2UEEΖIH.u2(4D7~5[khL@R92~^t^,842VE<~{|^ @j)< 4 #哻Q9 6{̙'Rٌ|N0]Ph z`!352yuW?gVe<)hgQAC%SH@P\]1Y_}Oπ9>>`Ѩ\ZIgg= a?޸u/߯o{ E!hƱ^ׯAWJ2;>:Z+U R\i-LLmr޷1{E'y|Gj@*i! |͆dAK2iC^ƆG7}fOCpJeG )De;4sN:|̙};*SG'Jc¬#y1lӧOW'j{oBc_< aXk(20 _^Ki](3}O= #eU \+OmyvfZbN֚4M^϶RP640Er.=_SGPU#0Q~/HHbA|])Mʱ#Sze)^H^ƸUu}܂w|gjjL Ai x1dpGD$<6*S=^_.BȜQ2R\rvݽkN.= `b::R]ZrO>y\)Equu,&%_^ko-V K{G?~(2Go}vS4Yh쯨o"xv w=б=u Ƚ,' gy7:c]w+6zEHRB`M>TP6{Tl`n/}K|>@*;5Ԇ-g;jRj Vl.!Z|]~ #( 9x'I p+znCd{ga2 PPz].\@G|Eۨ;p-Λn =[B@*+|M\FVc,@ |KqQMuS|GP HZi-D}pvvS8 yBBEc:1!$oti,uXv6C9}nG}վ` Vcs?B" "՛'sr\wIZݺqw:%>8H*By0@}K_GޯV[Zu7zw+ AvطWurGa Cx1ܙOg!H7KBfֺniYk nAD"U08ǥ7ܰqCO8 5j& WJ*2 me{\ D$zMq@v(JP=~͞SqDh1O>{mockX(Iep۾=~+TK99@y޼u|-,ZgmxgR^VVq_=|f!HA08EPTfk? F1*½GY?0 $a ZFãAL|m(<{v6N-#;\J̔bye&݄L(Ub\Ib@2ePr~o˨+ߨnvf)9tvc1>6U+n_fA3V[B`E#Q:pET%ct096i<ӟ?C xP*笰{b#;wAZYQ",oܻ-Ղ~VT)6.ڎED0DфjNꥐT"D{OZu]]PءԶTtLm[|+V Ga81PQuvlj6MQ!WXǨkOJA?xO>eŇy^ɓw>:2E #*)*,CFmT-fvΝ)\nUJ\O}zٞyVX0JBvp\O=XI`T&޳!D2&';ݥ4lٺeQ@ UA,kM2G@uNPD%U#!Zb R!TQ*O&GQ[b6)`/|@"P~UB%]DBM!\PՂY,|ӳ3cAj85659H`绁FJE^qvk^ᙝnw~;'*(<m@dOxGn+[(;V~Xɳk)0,lY{WO=$mkYie0.!ޑRآ6tn5 (A8<(œ=9̡=9B)80 M7u&5 zz gYa ^cJ Ҽ/B TƤR;{f9v8"eAF.,.eH HTL/AAI8{(HCc Daf"^>$B&cN/>r[y(FaS?W9Q@m a&쌢r!gvn>0e˸ufIƈPCR@7O<ԗ)cٸ)xM-_0ePJL>f 1! l@V\OH80{.Zu GfaZ$~ QbIwdSHdX;'ƛ~E_?H&&G [cRTW7 ѓu0\b!VDj:X9c&E}.?ȁZҞ;-*1( 2Pۗ[nk&RJ8R6QZӲ{Nsx()P%4 "zf ܰy=;W-{Wh幓3mرu_R %yhzY?p=ӝ;3o@YP2;hfGTt "VJe J<>1\?KÃ8)|~sB8˒\߲P3(fcҎPFT༏rk:# rkثN1QHƋosӵyAZWxvlll-?FB.ĨjcacBwCv UFϹ5@J *F`tM_L;+?IFJ ܩ7ޱ^  [R7Qή Źky_pdifiTcjuJiT'{Yښ <VYBGLsDiC"+"B"B%255v54#G9y8 I;@R"ÞHUH̱.{&y~! ;RbNRau#?O3zIvnngR`rɹF 3ɳo~Q8 \.K/3K>2,p9@Eʦ.{>_ljEWg(Rm;YŐ <ϑ92n$iׇJcCz1H]O1/VFaH8M! (", f rVRwS'V}Lxݴoj9|-b`SNO\}*rָQZs`]nsΐBdTwٱw߮[n|O;ɧ~om ݆ٙͅ4mew2NK}/qTjc*0 &dl;O)0^/7ګ7s$"`W}( ( Ktr88 5^\"/A/\/֗i/. sgY5Bee* vt[\1l۩תq\Jz᪰^^NYTXB Xa P|' JriusXoM[xnHe*r͟IOBERʆ r?{PmL ! U P ѫ6-R:\j7ĜWI'#a@i_D<2VliCRU#IXY ^FBqAlx7yDLz>'^yCMxDgsьJj۵KO?jGAIDAfaA`<}E#!T C@Dzlb1R_k=Tðr}|8z76KM=xҫCKH\l*)<M2*oA7Llu3eT VF7iMp6C""Uwi)L--4VZ (U+3z?-/g']"]]62>~rmGZ U`$5&jãP:[:͕FVJ06mZqnE{'<9Q۷Ǎ /z]>\cGaG}HϓX\p]^jMi]+|9J 'F74ԙ3(p\ Hťx~{ucozW:8<5ށHh {IFIҊ ?v2iwգ?8BPL"9cO=؃S&n| V+R~8v `/,9Ie(l0u돽!,JTwMt)=tJYM=8H,"$IA$ ^n54Mn4z~˛6:S'$}@b@Q8Y6hHk@WEFc RBz~W5KV\ 0(^f9'JCR+^J&,,7^ W#$a5Ue }g~=3s3Vyei422gWmތعwd"2\ FsgVLg{GA9jh0dy-|]j mE:I3g | u2 <ϵRCC es*:.W!PCȐȊZoۚ9fm0`@=Jk'Ma%K̩Eͷi|V%+V*Njlذ{E &}WH(@JR&*Vo_"Vwðށs\"gλ 0=/P He)D&Dr{voٰq5PwwO48vzA>{oye0J<;H9 =*ԫ xa^h^H*>m>ZX k_W+^;vlyqqxrUnW#\Pתu"bk (Rn7xh@U*҂.֗;YX'|ryy 4xܚzI'M6Uʕt*2ÿxxP5*sKGw׼m?ӏ'RxIȣ>҅ê46< Nʫ|c7On bD4.6*c G?}};ʭ$I(D ţ@ PbCn-8k@\uEVDtz(tEM/6t93...S/k9X/M 5TJn>DFhu*G> GY!ԇTNmk~R6OxG'a10Ff"Tؕ⒁ ܶ}3" 7^Twlݝ 'lƍmiTsp?j@\ҫUz`欚 k`Jdz7vLWAO<"hb*.IYWP,<,ND1^^n=SDÆH]Cw2i` hck ۼi+"tIE@@e~+_9>qȹ uP|e$ċd8XǢJ2 {%@qD=B =|ϝV[H!n<;pɲO=q CH03;۪l޲oV=vmy;(Syg2[iuZjmMo|=;vq*< ]ԡEg`k-!0 rTݺy[g) H giE՘. D!yjmn\"Gܥv!VGyѹ@2ZB}7LLm& RmTr9wmRk: .}x9"7oX; `J%eQ$]pv HauC 9k2CZA܎+ֵ<8XO,el ~=~W?>bsv(~Z-j_}z12g?{;nݸ-]O}D):"@1dOlkQ7-7zS\2{ϷI菖WV|mz[[~^wݱgfg6onyuY:]& RFqkoܾJtΤ}[W"=[6\g u>(XFkd DZ&e1*lruZ8.έsxA6j,wZrIť zxrhB]cO+h"wlonޱ[ET|VFP-:eFx|Ĥ`N@t|p$_׿ ]H,dU0|F~L>}쳂NG/8n{W0X;_fȺh$\[[M:2FecӮ%q~zz2/^XD#@ݎ]i-;vr "\q8oۻo[T}?x׭A4jv% eYskrT1M׌4jZYmݲ_λ1"s6aY)ى bJQ]=$i>><@ +J+%ٵ H` ~PYAVfqz?6]+t\U6muAʼd.; DV}H;:vkpd:^{˂ ϝx4mr&6! {kRČaP,;vMM>y%ڸas\}ơ#{h`Q\?}=H hժW3G0.SpNKC+Q)5[tEP)u^r7^֡Rtn7n_ x^ƒd2sIB>Z[^Lw'HmE|>f>tQj%.ؑoڼ@"zژSO;r {MxcemTJ tc!tv&B?<Ɔ!WޓW ~$rY :2 B%S }qݤ#:6_Xh;6:kIJoggIOH,ŷ3O^].@~G^λv}DEE .u[KfX"DhZ#)j;E@ޞKo.LpQu9 Hi aM(7:Q%ɑ;z"yNh%n;\nF=fy-Hn`|6 JƐ/(ryӞ(BDD$ĵɥ/@k#3L wS4/~P<:GAUߊ[]½4ADFykyWrܻs˭pT*߃w~f$z52Y&K Ȟ\ qW'+z %hKB#g;:|H*444:1e-;/~dt*iv;pyq7m30;Dz!@|~v>b!P@OAXw'Tn#a$ɖjmt !|iZ~2鳽vOGp1IR)Ɩ,C̲<"0*BB5࿱HuhM66>nsL,OX獺&*A(}45%HGN;Oc{ǟ  H,,/Tz0Gy De9)+vha uf~?>x~/]hsH&'n|h2,\G V tA`(ѹ^H/wDZcI[!T696yter7*^Dv{U,3 SCC+K爜,*){ z?nAԶm1%?U1a-3'/-hJh,<%sI"'Rl 7Q3#+*O1:@i": 7/3; S#CGGG hHr}ہHb3];4U {1r@"`Q8Һ!"hL1FgsFe9ZGaX 23!sݵ7g  KQ)- F|P@%*H*+Y?^8u]QYi`a΍H#*PYf/X~HH  =vD3s 0s&?w|x/yEs tgyc<|#N@VW*x\χP(N Ms=ٜ^aʛqС_?tǭM|tA3eB}8Կwz}_<=1*P*U ]JVTKAG|7& P#6arbj-!"4^!ywM]0O }/4oaGiJkFfAQBD'@n?JM #l.GM7li6ۈ@!  B[FPXqMj_5Z{'@>[/<RT.W{NaAVfZE v6'%P4(*ey@T!^Dzy{ELB}}ίΝ.K Xmo&14K&(Uj(M|:n]$C0D.|+\XPгέ4 \>CR޷-ƫoMoHȪ&=OrU`  jCY]wλɅp\Ň'N8sZwGoAa5dϲtnXlWawF֊s "T:].ad-zR[؂=wsODJdFa= & 1׳/>w?g/"NWaE]t~Ǿ ęsGqކ&%<˼F 1tׯe.-,Ɨ>#@˥R';x߾n[a[{R%\А}faJ%ha1 3備U6"\Ȑd@+Dlv+C򅒩@ӭJTB9RD VCR9;V[0s.@`PQL>iMC,9U:mB:r hй5 W*14p̞/A/F"ݓei0t N:Һͷ8~2V)śBd/g\" b򗿼\)al<_:]9.vBPޥK H]D!H^ꍽ8Ϯg`uFzcD<t~Gq=D,yc<1@y?0*!+PAsGq9KX{:Tڲuffn~n~a7DžRIFK_paTp9=VEezz>Tk$IZqW;Ŵ!+u}{=Ҹ\fMz fvבi򜈀$/<-h{PwuXpMU6mPre As\RdSەFlBX4>|A!`Mا?wGشk05q}E)\~̂Ck "U4kig9I,ڄNy'6n[l-r'xA QHwz5{ fDQ"9MPypzu=/kK͕N2?U d=9@Q_~\b4( yt-9Êj}Z"ņxOOt;ًxGh9zEȫDQecZU*R\>מ!if Vԩ]{w$Z a,}$)X^AKXTcǎjԁO$JR*%KUE"(C❷wͫߋڽ’1TbW *OEG"E5DNɧ&(w@+E@ Hʁ6_w=z_b Z\s.p.NAy?5 !*"ώ~f  ȸѱ֥0,:S'/ L )[*r7@}1٣C#hk TǏ|O>ԑyEjC;kFAH">[iF.lmF{O,H+t.ˤ^D \qJ @f>.Ka%]$n WQP-.UAubDQe˖JK\TK g.* zID; *1"yJ@Go(]{v~g>Š_93A.sH<{?J%VҤuEwֺÇ^jlyOpÆ HU@Xʤ} ) Ә.P*,Dgr9vzjchəzmd۞>9aY(t 3*Ș _IV7 Y{K/3##QY*F$Hyf4j#RsI #S2Sl%TĐUn2?[Y[}PRҀF$*ڶ}&KaҶSq핵\z돽O?9PJslDcD(/+BDgMA*lM$H'N+Q`PksyURX'='!׬@ϏLsDbky駟~ַ\phjCCndp$(< v{ yWq Wb:x I΢F}F++~yiJqyrbP-,,--0lѴVΞ:iDFHu$ͽOFx-(5 `( -Xq|ie1GʉH[n|gD.a0;+@e(e^*136TPԢHbӄflbzzxwxyak_tYނ۾lܼ oكO:VN^.@= +|۳T{rH RdGȉU+@5)蠟O?tgfvFZ,Xy 賏3ʞ&kRyN j/e׀]t=`ڔG;s˥j;c6\msTEN ]Ϟ^X\{Þ-4nT-JJ5!:T\h s,2*E htn߱ٶQr\ M" kf5Tݳ uw9r %6G hAX~ɿ1:NA g@ZObJO]hhhJxY\^+rR)n3T:ɩqxDo}-cOcUp H 3gIfl?M,7PNR,,n4"sP5m%dՃO<GgNbL#ӎFu֫vxx|rbӦn+QEaD nQ2K}8Gs("yA?12<6ivv^}q$?T6jMeTs}DFBk]9(+a HEZ+I)'O/8mŒhҋ@T*r#ӳ3n ?wbw[Y ߫Y~is9뮻??~jOL4U3ٻȅcԺIBͥHs(;qny'bsUӯyw;QZ{x8pZO8IB2cEqw!t_˱u_Pjh 4.0ie&ϝ[63gωZX:(<h6M[8'9WoyƐGy*mݲ=[W["HHD S6P/kţ#H27*Y˓rPHfwCz%+d_.f 8~{|^Y^ܰqP@/uɭ?Eˣ<}NHZwG.׽H`B0!/Ő ^恂r KG*Z"\z,J9|;OSq5U9@iXuBDϬdN}Oοݯ|@ e|}}W͕[uuPd6Q(d:oWl4W(A(m3N;K*e>]¿{_uZF. )^6%,"bY3C2ŷ*aFDa@Uۛ9311V++݉M^./XmtPWa>jm:-,0pvm|{Sľ!]z+> QX$\:7P!mU^ ll =g qF "pur>ݹ[Z" `vѡRsL P/X^. b6ylfP?hX H3jgϾm;6[q,[J:oyi/zPHTH)R rbtR=}VEVͪM;Djw;~?r >Cq\@ ,Tdž_Ci6Q9r_D?8 aZS[w "uD;#D{vkWchg?J=Os1BX`Ν]o~|w] JzY_ie)$U/G1wg. 8Ңѥf΅*O<@t z&p`rcV]8u1zY\ 0!h8Ҙxʀ:"QU/~'MHC_{;>~.XRÛ 7N4=O~/h?U`(ku]Fa*^6/o)kUessh:AjqݮVW\qu}rvffl16>흫׍9y&''7|[{eq{:h `9HTβ%[c{<~J<,~|iG?}URڿ}dzccwu}ࡇjN e2ER%) \e1(NsZ챩ՇґQ@@D[a兂Aev\7vABL˜&)"f|כo+ 6繠oH\I  LH/kk'x(?PUJ^C@HP;x`@@*52q]vpBOn:Jk:!4I1̠Q1k5E+Z+8JRSD$A-ݤU<Դ-y$1J9ffzH6&BG9H(Tj|'"ic5Ykpf ][Z39F  2B}E;[PNJMoI#*EY|]aH -2 pk fʳdՅ)kf&ewg144XDĐqٳ}}߼X3fYwO74؞4_4=2XRv"0"MݭB@k ,v"k I !$(CH:) ڊwm[MPqĦ F elz#x5"WPHGȾ $#H9cc\sM6SA`I4qBFDA$j&p\ iF$6VkjjB4DQD(\ic2AF $Z $/cp׬tZ7cmA#;.V  `toXꪵh:1Eza#m!MA 庩#!Ia*/7Z+%5V8>ՒR)JEugT,GP Zf{ -_~ղ!)HR D":5aVy9x~Ƒ*p!o?7H&( s8rͲ-d$i F\#xs l%Q+m}>1!%?$IVLb@2ѠlS`KBmyS sP/8LJ%N-8AґihRr0vR=2*xJ( iԂbƬA4A ds& -'&ݺq͖8^ˀ^R;bc]Gfj;w<;>6ZCJHmT*\8t0g-nwi[JչmWmuUեXGR3 /Hc޲U Tf\>y|l`pZU+stY " 6.:j JIw8$af9 I/d%mtTتa %9i"br-.4dbb#)T# +JR= m1H9|ITvIRF"ǁ C@\4psiI@qS@kي$/7;slv.L#UjH A1Xw~{ɤu)YU](EXS8wocfF1.t9-ha\>8JuH/~rsfH8?% Ȗ.z{s)ײ٬f53EUdrM9f4S.r2S `Fwkm 22AB8#Y~)RJNq™6dN^ӚxhLm󢍛u>F"@>Xdm;>i<=$f@hN _k8Nvzj}. Ԁj>ͩ6z,V*͢/SRBFpF6³;􅿛ITgnMβU=b$H^OF"$QsElqu#3*GFQJ [o%6U8;mm&lG ڢG=x$`*auZ^mPR], )RRB2R*MS"M"ٱ6ry {N)ub/9/֚B<'rV 8һpqcAsxݏo$m7ny;޹a͚S\MH$\7HkId]Fk46'v5ۻ3aCRH R•8Nǎ9/|cm@LźAv;l)mŌJdtFCJ֚4/;:;3;{ 8֭ƆQ1ŢX}ŊOM$yVo-nj*QXf,NPfFw{$S. 2Ebg+OVeJlb ,d0M/z߶88ĜLV iz2;x`6AmM 0+l2rEe˗ V-kjPLu%(R}t '9|mO_te}{eA@:(!AHS4E3͂2f {[w\0BHAVaX*\Rg-"n.OSHlٸ=/4lЁ^?ֹnU,fj5!4AI)d|JQ1BC^y/) $n-+lC,)d-4^[uHrj~424bqEZm77ݝqT!L z=׾ C]Ӑ>=5]@J%:ѤPyZ @L,o/t5lipu7ypľAŸ;1Xl,,#LJmjWXxBmor|'499Q* &I$n= Q(t;H \/):.:68>&IC&[D2Oҋj+1#Zo|V p w{wWÙK._#hV.=6ƣ0jq m@R]kˍΉj:@z隳5 0E.ݲ&5?Z_5ֈS4=MDJM oӺGGXGzu;y<$A8yҥKd|?<j م&۩uJ"+W6(I~weCS`cuuA\^&I 2^6$UPs9$l$ormmH˕ёᡥK;yddСclG:2B#l:v;rYO=səIʍ}۟8r'O0Ѐ0(5!"[0ӵ='JS0:31@͑h"N{Stpfw/~4') Vh t6c3Y)Z>y]O=n?|pW f<:wHWJD9u7^Vu)q0$15XStX=dzjnp.`NF'R\!)Hԑ,I\(xD) @jᄑ'g Tؘݷg7^xi*Z Blm3@)8K(~I~A#Bb p@ 9Cr^$K L(6 َ~͏ 09zȓO=>WZ:fP;fMLןCwǝ݁۷*޾M FC5XD4>mQyb5㣮9ё[ h#JZ9rٲF~X{G{&:L.2VcNvp$Jl01٧PHrѤ4[*9 z9YZ7X%2$ ,hiBb@8 >e(!R/Ɓ}.ie6_ne-)4X8+(65\6[f;Gl[|$ d`HsPZrM[7gjt>wT 4pڧyEsb wmy/75<ػ0 {#0-՗ ry FzogwKj}qbX,s)ixaZ?Ğ@b ŋ,A`͖!YO3y3Ε![$wYS^^ J>5Ձ- nPCF&9ǁ(Q\dn.ol1 ^@ H:;V!)KL>yK)㆗Rr69sBȢ! wT$)wwdHÄXssq1\9ą|NRR֪iXNq.ӳzuW<`ooo[9\*ELN?xP_k0>1ӟO0nX]dcBTHضmm_aw#$PJbD3iw(B̺9, `Bf 65r㪎v qR) c-7OW_^` !$ɡΣ,2# "l@ggg-7Щ4Ӵjbq "0֖RXq֙ѓGHq/w4U|jt@03%N^6gW;1)x1w rn@ں5>"g!yl)P҉9zlt3_a< Zҡ3‘*)1O-F)2j )96^yGzYHj )4jRR=2 K$vjj8Neؓ'g[l01U&ڨFOLjǽLo Ȓ4d2$_ȣGOrbhj/~ow?%IcQ0I^lV\ɧx iMVԆ}JB20xzpPԯr_\:f#ЃOljcY"9*5)(`yۤC"}ϼݙʴwf,BaWc*AQ=l3d#kE&AĀ/[vڄC$&7"/]9v5# VjQ CޥHmSN+ CxMM Hfi脰i ؟&FB]mAeRWR/ ja%y/KZfmwwRBnF]GGsvDc%ױ6iǎ~ ,ZJ2CzzEv86504\Z*kSc DAX$.#miu+kcW)%mpxlv`I?+M͖|&Zd3RkmRopbwOLQ9:l38/%3c>^t%BоGWo۶~=A}{OΔRdnJHl: 6@ڵs͖eTGII" )FR&/ؾKY "m,P1JKw(VFD(5*w0j˷IҨϦN&ki#On(d`H icwY06vXO=7띳)[DgZ&@V p%=S82B6o.KdEk22H 4 R(ד$RɁ[nQə$NB(Hq@ bXLdzf vfZS],򅂶\ˆԐ0)w1o4FB)zXiFэQO(Od -W&`fFg39~Z.tZ= 2IRqy2IcG9g4H^A{2`kMXz|ko~/|=(5LP$A@FHL쒋%WlΥVrN\&VI?ͪ^]ApJ=GqW̦Qǜte*ev.FqUȎE5C} E=N7UMj|)=Ju <HP ?) hT ghjf@05T;wx "(B'0]*%I.\rW -ڀ'Bh;xmk۵W4Ⱉ8x#`WUNM828]=K.Y1;Ǧg*7o|&/Kh6B"&䊆T!bP\ b&+406leFm|\Z4Lұjè9Rȍg{w E&IYai6p ذ R  J )5`>U>ud! X! z-hA>lfVG(Gt5=6Oxţ<{5H2CKLJ͠4ꮫ7 .\b[$@ u $J]OX4I"H qdcqj {>_;3pđk\)TDQ'!13$nrݶu/[ijgilO?^N'0:FLݥ9 4d ʩ}Ƹ ȂɌTB)4Br^E u~˦8Q*r]=O(\o*)#h3 2k2o*u}4йe>b$~9狓Rbe_U5(UiH%1c*UEjä?я] =N0 Xz﨨n2ykZ~Jv ; a[GQ(%̌~ߺ[/ DFkmRA$I b%gT(R):3 !5R(mLBj!T^B:Rd\*cxv Flc4PuI m^7􉏽[?~|c>H4(<"4P#_\F#gu$F|kُC\/H`dLl$B N $I"02"c%oo)\BcDsoZ5_={1 3nXd \R kijR!ڛ߲bbo&Lkpd s۬O6;\ łbS2 :8њ%`t %v=ԉzi Nl5pMiC.]QXu)^v[? ryR-5(^`4h-`l  % Afdd$m>~hw͢㴉$LNY~wbxxk.BٹQpꕂ$M\?&"Zc$T[A h 0G;?CqBxIB6r-[J[$t[g޾-2*E0`-7-|9OBOX _܆as 1pgvzw ##ۦ.gW*45BV>u;\#@L4t4{dĸjϻn[”p<^;[f;Z)w:9 is^loa 20B(P$~[~Yҡ-|-yިRtH! 2g+ N5z+4U+'F (D<~r3Z!%n.|׷#۞S/#fKМ4je~ZDV2]G}hS{K4(GزHњNJP4v% s**:V>K[. _oa,p P.骏&v$~^F@Zҹke}LMSlӆ4RF"16gOƧ3E5Snl{$]{f,oٿ_ۈ'L|71|TfJ0jH#7^~#+:(/M#ѩBPA5-Bx݆lcjt}=tjRAh ׈ dJvIo[Շffm IA0=KNч?wAu^w߱O|}N u-w=s{F0g}\lD+Th 1?pl}BdkaȄR)lhRxOQ-2 !`FT^;t$,q8cHk1]|yb! 2cyva4aIgbsS,dŚXf|qSO]X8lYCczsCWlBCpq&<_0МKQ58XTcGO+.\n{voڶy֍na24qcxJ|-3dPYҵSz!RVkk/ݶc?3K/Yē (c8I%q>#ց{U*tt+V0&J8Q\`% oի_Aͩd,W%DPgg3;?㐒B wW/,0Z?T*/~Quw=Љq,UjaXlb)!u_lϺmCd:\DkwQpʦpA`'!NS#f"/4PEQ%ԩᅰWE$ <߫5jXk-@I)N8dpp@vM})]X~^},uuE$D'5gIXnD@<~Ilk [FyShbIVjn*]JYC]׍8L;;n&__8>41Qm[uSbLWO7I H#F`cQqT#+SGw?!4+q@DyCqg{nMt骍K2 @цQ[/FP>'ǽƻUO"ͯSMstd|&bK!i ("q;ju"$Lֈ8lhkf1('g晭YX<\ ]ruH `5C'̀ "JN5Gцͫ?恲3jlY@0Zִ̈DZ{P?v1|iSlٯ;>buLdĮX226RmTmbsxt5BQE{~_o= ?{1)%)L=olۺ|æt JZRo];<^$!.$+fH xYU[JE&A`,^PӤ汅 l@ O< N, Bd[.w wwv0'"[F" &1sE0l5_&F]PVJZTj1"qڲ`,)UM(}We FQHE5S 2&v\:lb3\;=GS3B';$6pSʝQd(MLZ*gQmf\vTgZlI$!7 o!l}߹jdI`-6'kZn $!$ce[dAA'|߽?rq*̵/}TjUDa@ n%Kswx "bc)4qb44V(@DHr30)]p~PJAnyrz}{6n]Ο{z9,99o6-$f'?h y7"2\~@BZj3m7yLi}޸r|W7^չ쯖]g`NdcTWnMU<`._3Jv K<<>u8Bhxo7=3;g$XXn fcyє@sId- bŠ\o|'_>>{׮w]?O=_ػ֮Yf??&׿K1|ht`q `<-?gXm/ ᵾQ$rӤq؉2cW~_xltȑ?Bg}_;Bg[HSp:WnX3z oleːU)kP&"-^=0 s W;7Ʀ,狩 EҚs.jw$8HW-\3ԵÇeˣ;?Hr*@^<NТi+j4F}w~-1edͦW=W3mo,sIzsĨHQʀN[Dg؂%jZ+ !E0b m@0p?g5c NRmbH*P X`_"W#n2ҪO?Óm}BQiDsc7'jl<;vB ۔@"0h%$dl6޷aA]sɊlSQ ] /r|f$~k!W.rH" fz@|3zg{{k/$1y@g>4N?WL_}~uG}oڵ\.qD; |BU+ qbЕ-[jR!)S-O rb" RZ#'1Vʍl&$ ~Z_Ȩ 4oB2_{ʐĚTri3=oqn~ɇVƟߵ;I͒KS]Cـ1z|o!!i)D4Әqe&zjQ&)&Lm* Bk\8+sCO<+I!^$ L qbj[o{NVM)2sGw`{z2ivYG;_q P$qIپ ߐƞ?p@#XrF@8r,B.n2Ed-x5[upm2eLY naN]s_o4$Bcڭږw҅yt{vK˥jX ?5P=-B6NªP)DPƏ++&֚ G Y  t˪wm鲞-ynǎQH(M(^Z YڇdV^~YGWoG3-Z"Wi;AR](}6veE^ {r#0!{,n~)|8 ڬO~TȀr MLwQc"2Y 9|UK+ӕ0J"Ēe0(8 9^MRJ@zu6MR\ll(D*J}#@l-& p|Rj:Db&7j뚶.r}F67;kLq=Z&ZDNNvGtv붇~_1ǭWu Kt K[GMoή~G '6f$[v]fu&Q4q%6om2plbȖ-GHGDژRh͖=SQhe2J9h旀\^[8O ұg##pI9;Z@ləcS'gٿ{η._lѿhk ?3]-bC݈gs刺8wX}@s*Ϡ8 p*`.T] z^ɑLgGTOѠDrQebB DM4CI-5ʙ[[˟߼f* +6;\pt%_ ȧjPF67%37p#;fSyp!']vLVf{Q:~EM?xT"0'?n:m" lV#jb7-F /C> 1c˘hYs۳APd.)e`LM+]iв]VoG|g2 Q&ɕؔor-E/xwozب'<+D(=s!k^̥tƦRzҷHQ( K5*:'Gz)Y)[T.mMۈ3IGHċ'JԿh z8*D6$iRNSxEAA۲qRӆ)ZU}Wt.2|@ugLG֔Q%#|o~>ǿ'u]y{"I>M$ԌE%t1zX]TWPkzW^++yQ*EaP5SH  () xX\_Mo7} 0k<>-P P `p898Cj!:H"c읿kKj3%%|#c_w_zJЁwCݼqɦu˗^s ߾b_ҍ5ClP`⦗XHK9m-jsh9xCkּoK-7'5ǵc?wwnzÒ K*\:H2APUh%i~_ >LA4s5oӆ W^CDlZP2 $ڴj/ O}rw0:39NCd$<}-7*J΀wyKzͯJOoHp啗=۟+/dӾ6m y%:qme+4)uYE x,=k*y*V@ȐPHT)i@BOL23gf6hݻ?ϭl[zHONu5Pd&NS^' 3*!n/H~"2$`nimaMNLxikoCGQ266^&l>ڲY@\X$ u*gz_g&Q>%Xt׍Cgxbg>ˆV[y-WNӚ"Y6/Os;v<] c!w 7nw%g?Sn3`s" rL1B{"jߺ6 -7憇W\5kѦޕpC61^ acZdnMa_iK =C۫sj4;ʛo֛ç֨B&jv3ꞟ_z=h.y[Ε*B)PMe*m j "+!2Y @&6r60KOm&%7q? SO:;u=zM^ry/ٵ{3O=)=[M sU++ٙ{eˇڶ+.\o\>:t HDL,JJ ~~~I{Ч?ȓN4Xҹj̀x^6;zqQw޾K>FJݷ+N9P3sSmH2AkR8BH0 3jnF5[Gʑ[FՑ2\Vhx1IT0z ~_}o>wd=~73f- $j6B#y v)͎oʢufXzž8?US'ܓg|7haH)CMkVz[f+\c֯JDckt0 b :~5SƂ4 A2B-(CZt۽dP{W&ry*Svl߾kNkL !&:&"XIXp ZwSʅ~d[6I9Jɓ^\ f̈HR4u3wÓHQlxiсw]x<26gȘĴ5zjx^FGYO=O;^mbfl%~ǛL #2`RZMNےan0!7]d贳r5V)SM$N/|G>?ÿI~*;]y)(aBFI8clZHH qR F{{VOUE$eSDf˖(O-Rcn^wsOsO)pw؄ FF8d7U4h\qoȡ4tM)|kf KVF.gz;5=ェ9gsͳpj]1tGBHD9@Z# Xl X$1"lA[cp EbLäg*TcI:5~NJeV^;햎pkk2XpEa񃇵 ]tí~]ҞSòAV !Lm-vE@tQ-C%&$tC$^Y@:jQlæ⒭2NS 6qJ;>1z7~K~Q* p~FM/_k_G{|PXP&]ysK.ۖgglnՖ4Hc<$N;oK..iڦ,aXW_|Ϟ>F3ok/w3zAnr+[v}u>+s9ǓÀ(4V7IӅc3I}\!3wvv ᄍpxx8}}Ԉ*JᱍiDQ HGpG/rt -k~?[W|>i˦K8eu3[KLM\y5_ۯ99,*Cj:xK6u}@ 6F :8%x^S+!05MZ@fv$ @!f (uH GxqaSAX\8?N>Pmx)<9"@{٫\m7 +zeShR(' hǞ> B{'n}۝tH&1Z$ ZSe$ی-A`+02/D4 YAh!$07|h`ercڑ􌱱t2HJ'J&Ҝ=/B8fL&IuZU\iޫtt\P!ߝ&vҾYɞ /.yDL\7y5W]{g3O=~ELs<n-1)m^Ph @qMhٲ;N# m:ys;Msg'/yNpq~e!|H "N>7^mv 9[7o_š5:akz*/W^V5Lh6ԒVFm4Hk @*&$ /W̝hZd-`*]e/T>O$bJE;1g{e6ĊC+_ [o.S+<26:a;xh!!V?aAFtҁMק:o= Hh(.CV׃޼8n-l } =C=}kѣ'\+qIvJV+ AD"Mҹٹct!SJO1>Hq k٘X+W.dعRcz&!cYk:;mEV/Ak_ @ҩHFoЮɚ{߷n>fHKHj| Ipt.><|S-9[>-uK׬&i/Ei ʱ ʋ}=kgړ2޷~xOT]EVd vwnD0VZ HSsM犙l^M;շ~l\. )6;ĀHbж[JP4„cyD?Ri*%Ihm _mk7#Z =ۑ}j?xl!~9-)UQ /]2v =؀NYsA\n}UwcYNM7>>o}oddl ]!Dy*OB!?77iƮ$-ɉ 6qmCYd\1H.!-=sBMb6>>WzFDNyzf;ۮVOj*Q5t3 2L FXlc4:R %;h|1/'ʻάQld[3@BrD7F79DAZ$Z)X[xë}BG"AB0r>d??٩~s=+,Z%Jh]ـmW w8@l-lؔot.Sܾ| TQE%O:7ܫ K%%5k:d Fi!}C&hѣ [/۶~Wd3Wೢyz1J;x7^y֍Wo|[MʑqS{3/ kGWo Rjwk1BT멩\6֤ QB}n܄X`p p}f'ks7u$:96urxlʫ._GǵZ\Ik#KF'OntQA}k>;;cm?r-':VF-~gHx=q{{걝#'-_|w_T\.-_L3M< \3GNڢDz}WQ\T39QlNMxJRhW &I ":9#:ŒQC<Yd/+Z#r ~i<77SŶb{6RҨǹ|EVƉFq"Rvtłۨ2~k|@j+ĉ`+m9G=l xN o|w<83ggߥi*_[37=({h ۿ|=9 FyptͯZܖ¬+Ex6n22 ?~1ήo-3cʤ J!j< H@ȶIyk\Zo*#Ǯ哚y=:GqTsbVMqFB^(RsEReD;݅-][#*GN׍fH}ǯ&eBg׾ВbAo7§zf`p;۷Q,8rh`P-oZ2uWZwj޾iDYqfzO={\{6~$l488̎] 9RGI )8nmDQx!yeVlD|uz`!ǥ|>j(8no+:bQp&Z8W+{ZEr5gf"FB$ZF6t+!nQk@WWynSBEU8aK}I@(H0rew}]ɶdOG%&Yv}?@z Q@⪍k=E(' z1b; A 0[ g1.p/z `4nlG7??ӈssf=#ct\,>[֣h\&#j:Yԯ~ltjr+khvn^d0mz;Xtٽ}{;>. fb ?._>8\qxqbj1094yٍ:mZ @OOOXhGF4:zPK%-7p[E.b\HXkq4IgfHI )(Iʕ]7RJdFjgnBjX_1]Y&rO(Uo47&}'Zu|䓏>Qlr³E喬]jZ , 3K=Q_|atd_ c`Eg6_Kp臧FEfE碐ɰ ;_9B1[>44p[x{R8 ֡;y̼wo~0 I ?M$-( hNcnM*Q깙j[G0"lFb&M "!wA%b3ᐁsХD]wO >Ѓ @5,iNkΤ'pZ$e P.YEhv17* &S\ o (ʷ)^yMd8B0FCRQW~n݆;4>+!#$ڷg8_yõ'Gz:FclER*$,] sD8^G~&(-=AZ:|K"R)kphq鉙rܾ c0&q H7[(&WpQy͏H`֓o~~'6_ |aٺM^zL4`܂ޏU <#fu]2543;/+zuH%S8@2cv6#&N<'˳#O|0VvءX _Կ Cv8bW09 $@$A1S%Imk[-Wd+[%]_ɢ$D1'Da"&9T>aQ==A"JկzNs^k]F83]j'M9vsO<02!s;v޴jM|k3A' }tԐQ:!'9n$wbb;w|=Y"رH]q7+WG-{Po{5ׄ2N=TFvgT v$8NHFXJ/u C @߈v҄`g@V 6f( A܆]vo:.E([6oIH&"6nǻ?13pj xko TMcR$^Xt RE"[7V@Bfck‹_WjuHBηi0)8%A0ִ2%.>}#i(tNcgp7tSџ#N2'(`A`#:>~HirA_ #; +No?8:XZZ,;EY(+zi8gmDO:>\.ʙs,*_gKrr2@(:ӉJjb2Vī.yF!\'{~AS!0*K֕s*u]ñWSp#aٔ_zGVA̝;36D s%8@f;02 2!H@g HtM[5ʥzkD)\;;dICl @ z9šC2޵k@'P#ʱ ڰq}PTRaKZ+!2s6{Zc@{{":+1.(j|?wdsJ_wϐVX,Cr9uj,uw @ioqR%:]4X)7;4^7?rZo-@.20C!U;69wzO_\u36N<Tșgz?YSB  :-$<(&ԘꅉO|ѱo= IFuv|w?Y /:M%0ynGXͨ~0ꍌtN0jW\BJ)~ngYxʑ^GؐiEf W.?];heʥ 3WK(TR5 xZrYZuBH!.y2љN,ز"dYw~m'yكϖ=%"Q KK I  9vZ,5vujv% wXʴ"W+ ,"g 0 J {D $"\W?V~CVKERg3O>_x>WQz"'B\ND@Ym.Nj/$,-Gh")34%1t@,LZ~笖*Į["}IDf&۷o*e%3XOCwݷ @Y Ӳ4u,dVNiqkL0ML>7~OU4گ-/~_-XE#E۷o_FQ츕V8r?~047]I+62Ki6Lѯ=OL,@5 Qz,|P/bM_#1,ˤgZ6QBRg&>?W^sI~Ûo>m}[Zܾ0!bgkJ)d  <"EshO>zG޻ooc*mf32km)u*0 ťE!0Bƹ(5[ }K˔e!ct˝9֒2e+s*Dk|okˮcSٱk;q@Ogkrq`Y_(D7|/x(c#K<$uX;|kq/K.s$glV cTmT+/UY?h:S>칱l{zKKKq{/kv;[@ E@oR"c\QCťjҥ`ms&k/+^Z Zk!tx'Jch(Ӟ.$,cyY:"Bivk!|N]>Lw>+N[>/Mi8^EYJRw FXcb^G6?O>/;:1:95eꪡ^*ykר@z*,y9?xsgΜ36nVڱyX rg79[RHRJtkt/;V"ͯXy%/[vJuVI'z (pdtI` Ϋ`GzFE43XH%^݉vBR''N(L۵7&ŸJZEm&^?+ /r>K;CUWxa@Z%E+lcg_& ;\vܹ\ ð^m,.,ؾVCk "j!1s.Nve=y!QLxU 1@DiU3jUo=\F2qD}pUQ\icbi˦-{?}mWo6!<'kW)ꗌ ║ΐ;" 4A D " &֞ocl&b Ҭ}]ᾫveZ ٸaXJ^V"o,Jrf9kgʼnIS*/.-V*%!I J)ccS8)"kP\Y7LJ$ #xZ{ZZcȱRIYf ,QX 0# S<9uaaqqd`p̅ JSجm!!@D4M-"J%r7رɔ(ם~Q~(D$A(@̬gI`,5K WPr. E,IJъq Oə|ɧNc=ֺm#/7KhuLJq8I²A>#;656_ol߸̡ǚ5ݹ~5s~s~6VַŅp8eVY)j3brR @30# a*"; }+Fqq(=cg! >έ7ꔻ{r)LQJdք* /K> -0HD=: ;?zaYH}^7UR̳V)P _*Y!;F xM_)PqَB@O>sχ"e$/󘰱+NJ:ZJ.bfD| MVD *pe96eRص49݅;7u{=uNfk mjΕ\ffz+QXZqR*K 'q\^Tf 3˾,[SB ,1H[Y'P.WT11ώM;?=aubVm&v_\^@DgΜ t2 R։+DTh˃.9 S({7ݴGů|GS#-*!dg?5?\D;o% 7vatvnfJ4-s|ְjk1T"A ")B眵!4ʹNgk 1 evFc# 2?\3gΌ>7?7Ei_#2[n~;xǛn!q3m;G_"HMQ7H؆$AIةs|MRc:;nj4'A1PݨU/LN4ZyOL \70U/vWzVՊA(!:*_BHDni*URccΤ=9Ծo`<k cS==cΥ)A0(``)$;BTTI&JiGR#տ ;\#RTU=,R!"X{E)iV*-5hS; =HT"CХiud}g}OJ#ۮ޳~ۖj5qV?s δOy !:kZӎZZ;tޚmlPQ5@5>H|vםtWR w)uN<ٮ(R5ls mN+87B,Eٺr$@w*xU.P7bJ`M% }U.{*= fɶL琑81ܴAÃ7/{G0 zd\~xh] FQ(BW=e`,(ȳSi(0WB*MNU-l]lL->{G~iSn|[7oھ붃b$BJd|+$e(!VEXyl=|FIOOo׵{0TitFf{Hc -[صpE6 qF)d:O6BCl ZqA@;If[lk Vgp`P)o~~ bReUg1R!|bnvܮt}KSs9b۶rY ,X&[EHl2Jk!8f BAiRtjDeBN7j(D@B7\l%S]daZ/G@:xBb2,K㰒P!bfjs{۾xӟm&翸c:M7,P*|UwDf@~_eϖv-o`a4fs:ԏ%{0;|ϖroϵزP0IkLWWi4Y\Ráؘg9S28znس'Ɍ]b2> O{q(sAwyy[>,/ȇ2J5}1tRм*$O[7;Lq[v8@y嫹|'1k )*t\eږY() .Ue Wr J8[9೦d%%2 IBRteMۮ<{du;bnj~.q$զf~ c\A{"4[,M(mxav*$q&TY۴/r64ʗF6V,3@Ʃ3ǏG+bԩO<5~3ΤZ 0MGp4uts y1T H%@-_OХ&~l,y'׾BT.04՚X-g*b)"r33fm\tJBR66RtqRoUP03bZ,Pqf ڟB 0Wf.BzLh.5({~R#v$ҌLFBJjvnI"\5J%uYB HJ X2P^޲.г ۩\j饼>WĿ!6=9Bt#/eIv;s玞=y_/7r4^|"u/zw~mcן?ֳfS"U$W*ѹqOBy/?JI~XVRqiՅ$jud(ڭuV+:sx V9NPOl4ۋo8lj!@:r}4Mz/ם^a bsB.0LLL^^1lS[z˞,"Q3i+̯k*|BuB@vC?x1sB¤Yd6T^WAlF,;e=Fp/PJRu'4 vni7|^8n仍}a6jzT*z3>cJmdh] 笳7o`F~a|oppf:&"b6֐! WNhqS.[s5,Pʇ2GI IA\aazle |odzL>r&˒$Aā>SszQhZ5F+/ЁKluf.DfKD sz!MZ"pNk!P :뜧<`*W_R>*壾;k /BRvDOlJ"7>ȒwU05- %8*HY@&9)26RjChbf@9JyGN+BK+% `t {>C+z>gOdW€T64h+ (:1:_(m [tvzlGaP;=R1s󣣻vBUʽSdyv6~bC(pAu ΍G~ridך؅~GZc|g[u Çwc?́Ƿm߱cAQ COw^A@Z^$W-1 t}_̗Ϟm,b𚋋ㆾ/_w]i.s=&u "n42`@T-n&n¼HrP.9댵l y99Rlqj9}L J z˕qOm۶P[X鉢J<}j\I/Z=3;VӁHmàD:rbF5t֯FN|Φ(_dU _gm3.8~1?8vk7 @',5Ip̥BI)~o̟KK@w}Q[X`,E9/)/t;*z~84~w 5Ӭ90hL|I<͵HDع:879WRfi ց@\g 4P(m]P<Zv ` rw-wiϞݏ=?;R$@ _$]0 /p]i !lB?_t~4> =z_i]-۶lEad!"/]@|vY#dG4|+ < Dc Ub2aL+TҸ ]}%ÛcӋ7-V@r3Y+=^9=::>qӆu+]]@+ƍ[Ņupj㺝IZo4X y Q/N80z>VO? :r&g98 s_"S c'߿~G׏{G oqdiskGi 2J(WK@j`Ff%!5Mp{_x' 1ct`-Fs}g;(l srܢsT(ȀdX,R+##zmfj1R;B/zt4-[WSX͜O{ݹ("v nن^M&I& bF&mh6eH$"fk #H!R8I% !SR}^b^s6+GR7+Xwղy[sF߄+8Gd3tŐP$Q*Q hV!wH}ؽ_W#~{Z&kԀnhґ  ex8x ~W+d]X:t8BhT*spxdcF:mC`( :g3 dR))֙% JR"#ꤨ @E,N0 yǻZq<{wۣJ8Vs% W\B+1΁̂XH؀o=?L\o28SUٵ=}ic: VIaM+;IZ oĵ{2Py) t < P <ɲftIAwMVJr BuB`u{6 gFJũ|s:ʲlse(J333֤RnBpjS6 rCI @ [ql!g"EJL;L{gM~C;k5f!aGp'eOMc[SPړ9l⾭R32sg/f6ne #9AޓJFlf.+gi+!BP4\yS@ Vt ;^vqѪNfrwqȽoO5{eZrͅ TdB0Ԩ- Qg ,Vt7N2;3mwF,8Ǣwp}C9;C瞞CH0FD,dbk/)EaX(Ӧi1z[ק&i>6 )7\w睋J@o-$y$8(! TiDv7F,A|;o 7kW@*3d_ܗʅbw؝AS(e(W*E vjF;nǩ>9lvvJ*]q &ZKKժI%}sT Cx&3 K( KHҴZ;ގad0YIsD`ㅎE#R"214RwP)}@M {Y <Htl-^{ot,@d`Ό5#j#rW@O=ɱ)›oUܖ+ˊ nœ{AѾ%VkL騧/M?~bzf&]v-(Š\M[o}ܑs~f2-e#<BiL\uɓ']w͑|#,tB앹"h, uqxez!@o?|Ƶ&eV Pwȵ?~ou1Y~]!~,U@ k|Vj-H8ر%^M!Ptj~g _|ؖ`}aXOԙ/O*%\;R}5;5}99_x~n.V6֢=zunKE~Pqrdn) ұ3'm(ț$4>w_<$Yk7|CJ) N.HA6dĈ9A#ݨ+E% ml qSs$7|桧2V/=Y,7Z!ϛ9 RP9NRi{Uoڷu[w1R@zӏM̲ ‚1XRɑ)O+kYR Q*w,mLZ E9fR =tK)%@Le9pbMM̂ @\wEo,ܾi$?ٲsO|'_X*FX9H0(4W(xoRRG=?VWۿnaBX$I OMN^+??ioff >wn2TvkI@ىӝh ֽZ兕D\|OѓcչE B\w\upCzM֑qdTivcNS T]kIp yG ĦiK@Zf;ud[6"xII;" ֤Θs'7Lkm~(i P8tuATb==}B;^݋Nhsڙ3i6z|Y._xCΟ?("'1$N ±ٶam7.,TfKXgki|f!1$IJ,T̴Mf5#:H=tkxhyOWrЁ]{v ذy.2)k7޺?c JĞg١j"#+Ϧ_a1 $Jng2TʗN(@,#%TN{|7̧F$J!_^yjt8`OD XB {[;ox{{shT(}3ڰ~ݛr7$Ivկ}P(|~/OLTMO, ouH RH?U5TvnۙٱxT_OؘDA\w#GE$,8`$(/|Zx:C{"s"1.lj!5/V(J1aEL`Vk_ *XFV^WZZ5>+vlv{#]0}nlqiiWh Q= 78v?4WFkljS&k#d0kմ`C6M DG ܻ}W9g xf3Sj~'kcBud c64*28(I &i&Kͧz{+oF/eNǴA0S/F@ԓK8B3cN?|4'/O m޹/kV e@JM5N3c, AÀIjsȌB$֊xIM PM傏/Y3C6K 7j@-|kA-eHa1B@T88x{ @=TH!ivHWhI@W-P^+QjY40vzs'^{+K5oܿ=pmr碩k6&u Pʲ0pQ @)@ #$uE+K_b&LyI֒*ڱ{G-wWJ 3s'?x; :􁸺TUDiBPJhg3#CCe[ V#^j--έIlkvv…xaZyCe%t+t7\/\t>B`vfQOK4qUkٹщ x^+NYJ3*,j[bA4s"ɰXs 4D厔w|pS<1˙*]Yc',uoݼnF<) #:Bi 4t#R*3$ 1/ih 舸cO0K6yMb:wvD(4!ڤXd,` *ju:=$N<հxI~DZ_,'T3vmBʍZHN8)ep?ݷs>wɱgΞJ<J@Ǐ={\wuW0Kb ihVzf&:'?TĹS'&ΞMMt#sBB- B"p B-#_s..h)KKLr (]a_\!J`yF+)t>;z؉J#G&<陝'VXfv[JJ-W*Qumm]?@2OȖ;,Z A*ZDqoi Q2J6Az-=OK1aE/)? ;qQ3,RdKw\8\ ( H-?5K (t } ~!pm7t-M/&s=280cbwE},.7ɗ5/FjDq_KL^RH;<@`) \8D2e[P/V5Ls˞-?3?m>uv|TJL䩓{v]zbȪ W>_$(]gDŹbNO}Z+A$f{:6n,e[ᆬk}5cv`^cԀ/}A.f^W)%rCxiu͗kvڭq׳~-?=vAB! )[pXuazfsg26oںtw{I#cccQ՗'N|A$+J*lkBf% ⥥8y;{zɴ%Δ-2k/VlBclۥ|yJ T.Ƥ+qعh8}gyZaf[w<:vRupXPaВA%A F$J)^߲sTWT5ef1&r,kvT;EL2"&PR̶vX sYKxfhd7 ٔ+MTZPQ|]kL:  ad0X7mۻeaf~[=vY"GǏԽ4޼~dݖ\^1 "pe^X˲sF*i?zd݁'0mUAPR\C5Rnd5tGLh }r bettM:e&mO՟k,# F?i\* Sv;R2$Fbrl;~Lġ5BD)~]?o޸ȳgD{qr+MK^vkK}ѧgg0ۼy#d]ׯ;sz *n K닾I\X.-[ }Rm'ٌ_5 $فqL w>'<E0?=y/jV]8wӓ^u{?Q8R$ !:B*b R_õ%"2" $ bڶj= 'I 49*|E8(xes9z|_Ӱ$:(]ed U'zO<3O,-,*zٿ3ݕFo} JWk Wp챲"VY@B}>ͯ ?If>(!ܴ}ˇN[6A- ȯp\@Oz! yB MMos1?rIM{ZcI ғ^Qu`8vZ_ Dk@EJ0FJ .KIX_ !7G޿c i\D 8D 0 (:C Fs51W?t҂:tsSi;c?>7=y\s]55"[ O!v("$0h- Bܼ/5ZfvmR=!{65–݃[.SOP>}8Z/2P]!ȽC?“3AbD$ILb|x\}WRe4D*;m!`Kg:q1v`cQaZSgN]C&OO>SOJ!o͵|߱k_,&h]ϵZU f#/&Kb1?;^dx}ܙ K5C %ʙţJ(Bk=y=l*6 0_޾u.A$AZC}zݒYHro{ҼsQEKvL1R0674HYQ5L;{%IZk0 sx&%"vd̬F EH i2ˎ2YG1s1_ImV[X:yDwowVG!C8uݹbn)E+e9|W/;hS-pVKkom5~Gӿ,qˆFc#I%7/|ٳ7 _Cn'YT$kjEwA)LjTJjTۏ=M[N8[q̹|.U}OedPA?{wžb,M#Z E#ASSO6Y" Q݊PH%qe34@0 Q3mܸx=DueKSZL&Mj~@X,Vnv"'=DyvŲy@ " `˕(e$$d"d)jǎ>~Tmზ B@[G1i )1@+)%^:g(غ~8MJ7֫ծJy=RT.*!k.w3 !AfJ3s޼+'=zbΒ㴍5J>d#KftJn` <+J֘Ԥ^kTI)T+vK%rR(O)J(tcr01)}7nKidO}z԰t.??^ncSq`sKD,[~~tV; "@pz"g Gڭɓi,.Y =A"uuP7-RI\r^XRN"@&Nt~~P(b&TѤFJ)P#CM&9"VUklqwZj&ptX @Zj&r䈬Fr|n6UWxχ/p=ōF`[RA1 3Bk )cgO84?zrmZzahmx*1H @ݴKg\gi7 g+=" PT)i-[ I ƭ##CJf_/8֑=EHPEh;rc3A15J)7oFQe#kB*%ph*%Ґ]2d+>ѳNv  x&/L}7zXK6H'i# @#h"BH; ׬LiwۿRw.+߷u ΄q_˔.vNP@ZSq5}|TSB P9!o}{|?|zNw?W]m|'(^,UWY Ე^$o^inf~M_e&VX^7XO{?7]}rЎuS9ȌSY\mU+1b^duF0[c:nvD/KSgMX @GjKt@DLӬP(y*鹩!c똔2#^ ^ e-oq6K~;>x'%m b#! kqqBBkRJ&rJ+)0Ro^7ؗܶ"<VJlb!A Ԅ`ZU.5م\cʅbn7 \/!^@(_E^d%iQ*߇[l_~8iL$0 ~'?hݨ66mGѓrQm6̉3ƚ۶Ehiq36m={qd~p8HX 3OK!ȋb Rf2/R]su=׮1#cyoЊy|5Ed|u]۵ȵ=e (oΝ{KKs宮5^!vRh,8Y!dX**@ׅr.7q$iGS9+A*IzJȲ,<۷-ccgQ~wM ?pwU@vXP(P8 (W4j4ZZ+-eHCvwrpOP9R >s uAxsSS(Fb{XpVhlhOoƟjUwm޼o^}֭;}O>%AUJ]ܩSژn]woyx;~ g>v|[z 5чk*=]FonZSbITB! #8^@G~B eAD!˙J]4*fY&PZ/ݲ`[G?7_*}G?aæZ>:1١H)ifu!Ėn ˥w޼mBQPS2~y뇬˜ʽ4{ywreZm\.RY0RXJ )$iZ]]%kh9b"FBBǎmu޴ G<]Xp#GDAr1_] If CkMѾ]Du^i6Do_t̳K q.|h@J !R8)X Haj'cD3SYcBpgZ25;3==>>>~beYVE!%!k@ ؈Qo2PdARy] SB Bdf-:Ë1W 37Atk6f9> ,OZ 5F>ʭx~ W@ =>?ى_7z;w0qanf3s4yi|駟}X,wıcJZ۹}3g?KD c}VHOH46%$]j ʹ̦-;2H)dRy%uzQnN[wMN49z^a?53=(09%q4"|OveR2sF;J;w=mX⬻Kn뺬@` (̀P@*Б ؈Y@α;z~:117S&MNX عmAnㆫ(bT7/.*v3Kf;IlُL+A.3j (D"$b6i[kmK ק~4{]a(&Z9* d %"mIkńm"/ߘFz΁]ҟ>֓^B)m j (r={߽]n aPG9?JB="tB"ⲘkOM(lDzf/A DuC쀾!P)A\]?,#fDQKrO͹O}O'#TcPw2g3/7o&q6~g?x;yGϜ=󶷿=Y/W*A1J #aRMqNϲef'*`8iq48N,EJlYCMqbn-֓^Y̞`}?F(WDY֨'#뇞1V|,ǪmL^?8\|^eۅF RK4ZPZ3#IDL؁yh ~}$vl}ؙ?.F PRi?`J3|m{J513&vP$ $ lٹ Hw,tw*^Z8N$B\ڿymvK\&q*Zc qRxR!qf(/xݲe'*d%` ^\JNiBQ!2%!V1P8{C؏]}?߭.R0f`]"{pgz蹉o}͢\?v~ǮG}oם>qa*:n PG?Ö 0 5GO ׯo{㖭[|Zo>":x`Fw}/w5M&ƧfV7 +)bLM+g@(P 㜱 ]wW9tnSa;m9o1 V~xE c[hQ}){ ;P(<)dМfIT ,jR<%Yָم7 l%4 "="ւ5(LW~v+##O?u [fk,dXvpCs∸$ t" P>WݼBWBvw}OZFB85$Wxq3{759\:-]ȋ>=?ؕeV ~p lCB@y3%gV <+b X+=l'm%?q;޷g߃?4.V"z`f3`(XfT'Bk'Bhg9x37yn0ӧR ~aV5O?OhPs3 )3Ry!K-8OK29f>xHew,կqSCFD@*= rUNƋڻ}Z=/ T'x;~fh`pv~:1P.52Ƥi6۲ek^2377',Iqe+޳ML_O׺nՉ0*(tN)JLUF`ko{ Q)$bf,0Iҋ4_:ie !,eBJ,z{xBh)8n{GZ[8Wysc }Ӊw|P]#r `覫 9CTH.JĩPJiR);@t,ɂmF@(O ?}5ZewnȑSv߸i?/֛Fc>qM71e`@BA,P 2#3i v筃  V 9g3)ĥxeŪ ҿֈ- ºuȑ@ 䄒8Gۋ Ka*^D%vҿT) FzE_"2_еr.=TPt$': hEi1^v5QbA{I=om]s:,~Ol`ppC\PBͥ&GQd5IAf)PtUZx̌@fc2 !Ď;v659Ty^?X[j@]ҁ?S?10<#f[zR}֪sJIH% H)匃3?EnEa:^, _$}dl)W{]oE&R\Kڠ|KlK +alڌ"%=rn%a~XkX\Z*Q{aНe<6>QQj0 &׀,k?c&$騧{\ڽ |)3ps>}ԩ8myӍ<;9yfz5'vڽ{rQܬ+)\[i Vl'@Ee@",SlSvBߖmyU&?t7:O'=s76l9zboES  ;g,piʆr!XA*\^ Dk_P"Z g,.,uw * g3'ћ\']tGٻo?)-T~ ʣh2EkuHcƙ)A $z ℆ۂ@fL1PZвrK,e?/M{ѥ‹ILkZ F7[;۹1RB~ ?RimWYkfr痞~)gݮ;~#=[vWgΜIthh8Ldf,39֞=g-"J!;ioPR>wRYkdk;/mڴIJ-^#[m=tu7@Wy%Xݽ~ʆ֎YzzIIYM._ ^4IqNW"fWDJ)uJңti`!ປoL糣ϞƜ 4kK|XrQbX59Ig|+YJA 1T*'&&6o54!'ss رa{^}smT)3gfsyvp~w-gΜ9y[oB.%L$Y&=g-1(ٱH >%!-XʽLˋ\/9(XJhD1bh3,>c{+?qpKۺ0ض{tt\\7?:(Q8P,A\p{џթ.bO!_8KV1[ 9/_f3cAMfbF;2FOwog||?O\}U7t].!Db`ZZT /,;4y=h7I+}<(˴b^|RldXVPȗNc|QI{^MEWj)#k!b) {JAǼw?z*[֫sQյ k=䓈b׮]|pbb[or_Wj[t]47VOWW7to}>w7ƭ[K2 mͤᇁ25Lix ExM]KZkJLk%Uge0I⸙9yꉇLXցG(8K\R);"G84F,7-Wv2kD'iV01-715UM[v̞`xh>Gyw9# Y :Fe[Lz: ʃR{H%/ ȱ1$gmrIbzzlջrl:~J驹ΌOjѥ1?'?HMZ'gV#Ų<]fifzE;˝DL"$/y_T6@mnv&Ph)ojO=9Oɳ>w(e+Eʜ˕ˎ{oܺikrjik0X  AǞAI*ѥ?_*>c ̄,:ayv8ʹ:Gu[-s-'m2;10lQR+{n8qVueÚZZJsIȄ@ ;ՂydWjM-I W8-Blq.f8儀&~@aZL eS@@7@Ԅ61x!;qް-N"[(*oIqRjܰRNdu%YC*f$8RXZ/֔NHHgd!S1ΐbBJedx͞'#0Ez8 :h!8J(EȪ)^j?l|#,MLJp)A:߈֖e\br<|ptU#C'oڷM/q`o`?w#P !:14-eXӂ t%& 92~e%-"Q 2,`;I޺jUOEˑcGO-щZ#&&GfyOSm&.ё( ]eg4YsyZ\jOsЖI :Dpb(.s9Zlxd?ÇSp|5,[mƫ׬Wf|r/9_Ji(L&כrqק׬Vg³|]=+BS &\yBH[E6jEGsRuA 5K6hQV.gqy[Z3F:Pi ),LHZli8!RKЦbtc])z8ܱ@kD93ΘTʪ*Ƹ(Lx-JҪVЙ^+v+,,$ZJՙ/KUӌ5]n/P KCsjrdqKqZ9FDhq:;8zĭ 1ζz= xZfKB[PfsX-/dzlbj?Qk4f+X9 ]3pdQ)}fVfNYEl"4(@-ڛL0Gz:\a5ڳā#5 Vx\yF deM8"dFf߆1jhScLIqFRȶRG8qx諻Ϝ86Ltt 5zKo_245,H3ps" ӈX9rTo>&ɘRT`|G"r0`9sEKtkd9R){G@!uF)2 PŋWn PmmtvYTK SSNk 0.ҍDִ]W3ckF#4XG8 0ƍVMu:]JRkM8g6S3.9IMl1=(726-!̂USJY(Ut2Ѽb@A3岱^ Vztb\ga43=c$֨6}  b#I*M>֒ܤAuw\7\]_G ;y˶9(JFɹ(_Q t)'8&M\u]7Fq5%RʤaI H4^& ۷mD nJ={f+bK˩S#azIA ͥ[S7w?YE' W= &Q>#,tl eFJ%E<El~Z&=hgȌXVpDelLSrKi *MCJMJh#\DԝA ĻEm fj|爗_'7/s_nFqD4 qdCO]BJH8Af iZ0T]|!9"RZ(y&ZEF;Wlp=\~f#oJ鑙FOO|5(WYpQkGfr9 )[J|-%S  tO=RmZ.Yu$8[|pߞUiZ,GGGo!w{U꾟T!J-&w;8c|jRB:;2=GVͷ싓F%b/*/=|L4 2&۝hq.E4F ^9R@btrZT[<, \_R/\uk}5Eեg7]q9rjx9r,.E.j-;9?QY [pP߹^>'>ݼusw_ "goܮ o,ٹl0<|6m^τk,YzUȚ&dmuQe+L.-rT/[(Tƚ\6[( d"Imtbx=̌L5c9uh&/ NV  n&Sn{$61>k瞙{۹kO'}} M\! JRdEDCc+pNO?|5I2Fk׮JR猐MƑfl&2eLkPiMR֔J)NI[_Uyj!4qȪX)S; pFZY RSBa2d@ĎqΌ6FɁ28By43l̅.{˻xχEX5`$KIj{\ Erj6hsDdI.GZKQreF%A(a-lFI?bؿ}C'Ƅ3S޷߳媍UJFMRJc |~K-;ρ-r4^K"=FL$dQ%(ɉ,5gLK "cpb|CWnyL4MV=sX˘@J\6]GGgTcN>es  C^]+sR M4f!8 liqЍ@Aȵx@Ff%g翻b#  Uje/s/Tz/_UGGGwwϋ/EaK[kHh C&=43F@)75d-7 MWX698yEwo\e  i@ \JGh ֒e2G)_Ixb~蹡I- zÆ5{m۷_U f(@0IS!8?Z]xmJ*頂yr`}Ũz!)!х#]Fy%׫D&#ԫ!s j8^Ĥ!KZI"/OU$(ŔaLƛoT'*S1f 2_@?+c,[9Ɋ88I6{^V2IaZճ,قt\y>ͻHNS7vt(eԌ~03D.O{ nF%i)EL(O:R0d$ERFD! KYk͒1{?έWoQZk\7M̃;>@ =aBTEݝ=zkv<'ff 5D[=ttt)(uS>r|,:R1-dFyvБhznd 3p$&ML/ d9Y:'` ID H  ˺oC![s\> m7n?pG{G3]|yPx,:q(xF*%F'Lk=t%_,dƻfeZ#/gloE)teh :M2?+dX Tp!`F e.RD4YJ HBʦO#N8:5s7$cM椬IAٺ,nE!M)]=IsqRT}?뻙F.\W wF^ajCq’U3td4OO@GOoc=_4nb͊RK)թ6FK^&x`Ep|2X1SJGy!e6ڵ7n}YI*Jk&]C\`+8BzܛMjZi$0`R C#d[8D"&1 &k g{Ro+6Fhk)u>zh!NS'sĠ:0W^ys!Wg! B΅9T =ω2:lFlܞmj˕];wv7z-Pp9@Zsw Z/p&Iu\:^>Iˌsn[Ckׯ\>3>ojr;_w}rq?Dk+elj f&γLJ3{OL qOڵsG&]{ִ%\\"B(80u᮸J Frs]@SYc9HGgGOOWzhhٹڳOHjCp),2dІ:7,LIxӮH`r!՚祉j*m@ƪg64s1kZJiA.—$N9\ulOO1ƥpL!A+g+A>_Y+kDqz*'G Hl4:3ܶғYe0 +{]mo ZdLG/6,.Ⱦ*+ & cLpaAd*M];J~5ςJ䵙ڟ_>ge"CJN}'?|J$d+m`A|. Bhb7t 7{^d#q?$RIJ+H NVFOM4+q$R}ω2m,2Vk}g`Mݓ4 Pl6g\.YVgjyF7QhldD|]OkI1|UWүou8֤Ms]{Rf8w]IV514z `Vw}9(7cmmi\92gҷ}_>w v`21%[*A<:Yyژā֢k]N 9BLF+ss3Il۴RJ`֬[rJ@RQ۔+Hi\ޖ]'\ h*!6sA”?hJiq!&s#Ƶґ$,_ttz$Hdsy֯3Z{'XJiF7 P2- XDB(QyV^op!1ƘV:lgVqJhrf*3vnreW^ْ[j&]hYcR֒d?Gju]!$g\ ]nDԮ.ьq 2o b|namZ+9q2s-, 7 !!wCPDQhQNAEdBy~Eqkτ`D7V 2`L, n7^;Jʂ1x}1d΢Vy;r J'Wmu\?jg ظexK491=19s0aSY\4棛5;5<7>] FGQ0l*^fŪ]Y\{Wm8s7 kH4smk pvL<s8t(Tx' m@N c(Ts&ؾe=⎁q1&Q$Ǒ*J[k"6&C@4IP%gRHkM,JWjBJxb(LF:" JI+ueHQpݢB314Ə6~g~;dqGXDI("fK#ffvvve>=ץd"^Bb3bM~W6 =׀@AnQr'.'ɦpEa~*IzRS$!ٹ#?{mW4D__7&,X{9ߺcMYKi2-Z6oܨU]R.[b0jc-l.GVTYKҪR #FV p@K2 FQd*1$"Xe-)HJ!"das--m͛7k|H7^d Z .lsųB/4:4)Xlgۚ&s+aB$3=`ZahcRlia daj0t=+7f%}fIsW4"?77Š*y)#ASurA=AC(9uzb;!fի֬dɱeQIGKbBƤpK|߾'O};޺|ŠVVqI9묗&2lRgkE#Zl6j{v_Xv5bXwFI}mGi!HуxOeTkFY~G];ET9 DbI3^VbJ3ۯ:WE/ _aH×tE kq4YDTZ)߶y8~źMGrmҁF#|?$B9;u깎'_Ա#NZ+HqFX$MRŭe4-X$ g6F6MϺBJy-?nn|gi`s sDžlS^jKI#XC-00 zW/{@#Abղz~T[;j-N&89!9 jXຎ1hŌ=nFBF ȦL_Bj9CZ1hıç|bgA`Jzm<7ֿz,ݼֲokq;OZdV*B"x,afQk^I Cem>ts|mLm~/&*86QPu]m)/ӘozcFC҉c L:s6t3 T4DT]+c.iP%pS*Ƽn  IHyY;k<] ~ĝyl8v^k_ʒRC~_xɉɉ_=ot[ؔJcuOcɢh\=/yHiMD֐&$$8~_{`zzk7Lt@0%kr6^nttgclE:e $^n[ʄӾIRGhDg}q* !bhx4 |ޏ1fRgkI}W"V:"8hL8%G擤^5<_" "<<97_שˊ֬h?u|zܡCja8c#433M $G3 $|rHD8#RDą?1S!B P]{䷾7Ji%@$I7Ɠ ~˸6[<BKp|dWa=NﻙL1N\W2K=/9 T)ki)Q8>>Y( M \gwWyrP+ kP~j(bĀ1bi:k+Z2thZzOӽ~$-ykJRGHBTQ | AT ]tgjgHUZr{ZM}#{~ a '&&f 84U /\`!q\etyp7]hQ"Rm6g0ף:dYPNMLj9c2 pq͙j 2q@V%A3mWl,+e ܺz+u!(u/;@sU/{7/}F|ւ5 %OYl8~Vg>;1>KY(d8Q&N )9$U c Fă~DۄX{ g _R/AK6ALA;o>?S<6!F4B47S~">WGsT˗99ep$L[[iRT+ƙl6@ w]nu8JSm1[;Wk \uGr+צ[JݱeJyf3$Z5N&Q&wC]~-WoXOK:;fjոQ&mR>j\֩) {y^5կ|ӟ S,V'n|uRHD@#fmB @ '.@=1邟( RAa,i#]cMoFTG%K$f82NV,JN|gzNd qΛk5P(ou8J8rMo=pPDPj)qgg 慟qlHÎ6eب_yu}u%8ux`A MQDS P`4Y !@ lq`y~k^ΒNwkζ^D֠<9k r-_ڂ/K# Co}{}K_RZq̥#$AK ɒ@*u#Bir)-Y Eyđ}3FJxe#Ңi. Z<9Dᇞ(ϕo$VH9YmUW=h"B!rdu\?296U=G l!$$DJݯ#-@ d L#O:uno|}$IT&r .G7l퍷 D#ڟyDțޞFX2j &7'PN['ij-[Yk) BkK"im"ZWdE>W Qm>rulllvv aY ˱9ݱ>q{O+7599562&q?uolʽ;_XU7ly\)߰[oY<^9 53MCMP :uY@ṫ~GZ[ݼf$I$mkm;t$c/K*M8 b9X%5ggH@# n'?ζ?ӿq[1S)[b\JQg:yRKVR[~~V; #;~jZJٽ2USSq&IxB>+7(c[W=Ed,GqaD q+TƦln4Q|$BTڨUla>hk-PzXk 2D=~ziWNc9 eW<$,TLsK`xm R cJ)8AVTz ,H71뒥WÛ#.51ZO."R ,q˗#ؔBcG4yRK3F@1JHn{OTi%:2fH%&rzxS ß459ߏh8bV*ڡçE1}ͪ-m6/JjA#sd1qk{0:J]cܿ)ZKd,"4;.uqS&NT*Ўg׿}G>㹁ή#9~kil{ǧgfr=w308h RG@fjQ8.gόU/Nig09( `ơVj4_~^ :/ӑ/}@\ N`ZLʐ )*=ݷc='g%mi֪sc_=?__o)uk9G C6L:@ON t qxСN Dww5굃G2u[ۻ<$K-/LE'k٬(1*;3]Q@ 7$vOI8rIJW%g-_١m& Θ%K}?1i1ZmVDIl;*a8>2ԣ߾zՊ$ }3^z\!S. ͣSc-ܩ/u2΋DžVizV=}2"m2+I[)9FNON}ݳkgIPj~tæ/3l\verdc `BQZsFf\1)I #7+rW:y7PuMߗUY_t'M$MϿhh3Dk#r]40%JBZ=ʵ=7f3zPh,Tk WLf9< @тykoOtÖ|K_p*ؼat3nXf?}C{1i\%"F@ܳwOP~wC #(M: 'nR,DsMJl{#)H1 @]yd {~/#.3IJ)r>i9!0#2}[9_>1QI wRkezfa|oCֽ[>3K5,TuMGgQWVS1``bf.I]/i  SH'+Zsβ}JZ'6DzGgQ `Y6W8z;v$`ɠ+60% MiA2SXۤP }?R;HzsrNO'GG/q-=~>ͅt/}JILLRuRB&C13JR="ΊarloW3k֬]i90$1Qk-_/''m a7ݺmۯ<94tKhci{kٹ4PjR5 ś)Ӓ' d2`L5itܐ±NNwx`-I0acs|y;Xl.Gd<]Xhl'B8tz1ٞ/d3B,o##87Ґ8(;K-o떛n--yT4!  =lw}/2$l[$ںm7~E_x5~>yׯ_o:4F ^C2Wpku~Wk]]J7Tʠ` jiooB`R\XiFDh-\&pw>܁"K`?ɐbYM\ 8gqԄ>19! Lx\0gUSlƷYF$N\/ pr绾*ٹ@agW{{rkmqϔ(U*V3OjM\8F;ƨ$,GFԒE9XHր@@ۯuoiuv/m&aNvpoZ0/ܕ+OO4<׿0Ժxu^yJ>7[k4jG{.-MB u2l}0)~eܫ= ;n޴qu_xjwvtU}(H)Nd_"Ż27Wl[kޕrmma2!sH}5ZQ1 L+Ǐ(.vt-o$=ޒ1Ht us~؉f6qAw8}?YwJ*#G'!Ga`( qĉRKgL!WjkyO ocG~G?S_NXt$ hժUWmџɎXԩEU³'y( ],z6@M#m$lL*.CD 0pƹȀ#sŤϘ`3!oF[{Ԭ0|;}t!Ok$LF|VV?30&MqkQ ,jD^Nh)4_Wfdb=F8z:n\bK[QXc5+㻢Q %yuNb&[g+ ecH_0.R\040sXh(j8g+(]P-"# r!"g=S}Y674|tpEEws((.If2ѩw=g붵Ec k;q.wkF5#lzo,p.0IpO. _X*_H2dL `$ۮo}۳O6ͫU"@j2^r|2W'tJ㻎ȗ<@ƚZM цqcu|ajEGw=o@֕txu<鰦P$F3) ךw<~z* m68l1Bֻ}]fGa] AO?c#FȺ8,L ya 3 d![eAi]HHS#L .FgWyKֳ-fehٝ)}Ak-RC'͕|Vk# `@l"[׿FOJmJ}&iϩGѓSC'3z0>;vNh ø;I$&d C&еZu8V ԫJ(\K]sMu3Mg\ҵzmqڂV<˗Jˌ7Гd$Ⓗv8 .y ZmLR.OUʙ$P@%֔=lpYqFjQtpV='-%t(j8.r1DWM_j|7VVIf5X+TJ5ܫ# Io|Rn[2LGTםd`׽\.O %HJ~>'IZkMJJAGW̰ko񟜒HTDw!6.8`  3rgӖ cCl֠UL= 1E.%-$eC ,tbk WhئE4LP:IǵAfsyB-^n3$"a(mZyԫ:jA65dZ `?y⪭=c˻[ێ<ñRSaY3n񖵛6~o?3tzdX}kη/V[>xOumbgwS%g/IOo102 s fnS=+Wwo *dnKl٪YASC'S=ii ˛ٓk/u-)xqsuMԢ-" kimXJWkBXJIZ1ƤB)e`M1UERO[ ZH-hm-Koh۲m(b~E0h8x)"c.V5fg+l5hC}\1.:aZKDJ-G-k9x -MoXaq8j_Zl B4+V 0 g}$ Nm(?92#Aƚ&RZҕR+jN Ր/|}YtI3CZ1CNLjZC }-Vٷ}mo[3NdwAЪ֕, !8\1$" qBrº^ַwR#$s/ bYƥ@s3`ysG:Q XrҮT YI8=57=9jnkko+vq]2%ZX2@g`ԉS_7ݶ^gl~Rmz=wMOO%͏GGW^Xqԑ!ս;ߐ3V5#~n /9JY~Z "dQ'*EΤX  %:Nz ' +:oܰ d5+?{OMĚRcaf3 lB^{=7zܡ_7.0ty#, &g&3~FJit~6˥S.$El2{B>yѺ:_s2+|,q$%Y^*OXyL Xep} [#iTL/MW.92i!cҌxhȐ(b,sݬMɉJufjf5nT ZKEI WʾDRE)(2.lc ;K}] ![Q7J%,Ĭd\hDZ ]J炟/'QMX(n}Ӎ:sS 5u!DHjE`mUή|1_lmU[V5TNM0cc p\tR4d@@!h"ؤn3JnkT5(*Q:Iٖᓓص{_yB.T뼴"""cIZR.:;JJ8716z]{%a2dqQ(M֨իְΎ֖RP>?03'XW`|&Um]rR+*{fg0NLk#tY ]o_LL~Omo֞ Xh|ʩ\~WV01H@"Jo~[x#*OOFE/ȏ 7ǎg?sGy)VO>HGGSO=)lkmEoxltMrȍJ k. K /$c rά53` tI',eB=?r>36YO a\Ӛs XevT;r𑣩Fql[XKWGs9R*Ep J+$)!y;VQ)IȒ 8JHpC#'Y"i)cX̔Hw Q OljRٜ afsZ9/gL$zzz '$zltRo``Ȅ|9j1X2WnxK?<lucQ=V*݃  Ρ|oGR& ծ]-KS8?GiC [|su4*?o}4Ubȕ֩Ih)7BGM 2\9KDKKP@ /YNM$ `Aޯ9v|cƘl6ӈk1 9DK:%q'ѣ9EZVXpXjw>T}ֶ{<#Nvtvw M܉#GoyM>hB!(sk]#B+N`^X>\yv< y]om|u7^k)`_n;?#%:k(|e~ժU> 1D5֦QɔZl/F `ac@t9ewX|gM[刌Thُ|g3;RO.G:IC8N&Nù4=si}vSGloL1_k6np]7#%@qVc'5K.:q,LW&\\!Kha<2tG2ڨ83wD󖶶lSK4d3$|Z[ZDZDMTEGZ)E?BXŦ&cBJ-0ǧK=;[Cw}A h`2H$}gwv_özP ZX=x!5|%:pt_odx.dD+8ZZו]5>\u]6MfǏ7WnwбN}os^!ʀxS6UyKŧg@\;Kذ/C ~Mcj%)Ftxu7obú(Egvff`Yܬv̕4MSzK E&(c#2:[>~bW_oGGG" CXz:*όOOϦAcD-)20]`tm~>U驑LQٵ]XP\ ~׃rLwVxJL{笍%s{~^{׋7nY|WޝΖz#fbzJXPo4vwvx'ȓ?=yCY W~N?~ءZvw=aʚ 8di˿Z"Z8 A2_57Z3p$:bB` H}|@t<Ήٲ `-y_`\&f-^xrסG;;;gffLF+eٻfJ6VO@0JsdH 4TNdScȒ&D⸭cK@cRkY2B8d-ƚ\6JW$I8vz\GGbrjJ)ʹuGa:7SA-O޿脱VYPtSUs3}eX~)" Q WP{|{nΎz9~21&TfB{K|mƀTW3ΆIZRzZƴ‹"/KMoJDO獍}b;0M[OI3tLS|3T볕Fg-=zCWy@c,u˺DŽ`lokIHJYupƸVw, ܱĔMҸ:_p٩;{ږ)5=oݎz6R `]:_. `X×giJi0GήT 9\sωHM/6A(u?4|xϱ\mٲe3'}=] /_OoyQte]x#@HC T(11@",Ju0MH25,H=YXHMJKej.^¹G:1%p:[.̑'Ib\kZĮ\.6_SۏEQFNƞG]mA!c#3cި z`!lǝ\k"3z䩓.Rц,[rUVA U|bx*[6o3cGOݻs/F U h,Xc ~g@Wt,U\ ( WmnL?:4k׿f guJ8;zzٟIs~? XOcGO_=w}'~7[^#zx T+V!jF>?cGYe lr_ؼm^{u޽ п˷n}?'x}dd`ͪ5s3-w|4&ޜptkK`sXR)\hZ4Pˑ5. FjX0YHzF``4X.Z[[=`Oڶ'y׮ݭc3MLI$2X[$h[h hɑj9In0@ YXc!m8 7БҶޑcGy;;;*zZE-dlFzCiPhk0dTEQ2=3ÅFJ.}幹2c%LʣG}݃%ڒ+'f5شseY?[,hf|LG)ecx>8~x |o0_*O,񱾾޶VD"@?S O7uHBsRK D gR^'ltyNs%#(j=9MKpK(wlq8kH/M+x!1ZJ\9I|>'4oF-VGOp]k$.HщηsFj8ぁ䜔eW(ELD csIVI"qm<73yfg*/T*c'&286|Ƀ˳9ޔͳ@x]wyq#R XگB!t};Ͽwrv&ͫ  d!WjQaj塧@}/ BʪW#caQ z߯. .;::^8v_'&F*ڈj@޲[JUtn?׿15_EX,` G?9v~so|ڗu8E)AqZwɸ"C@&@i4e3^2<{7S5CQ3ED8g&Ku-k,J64߰@ExW-ЃO?.e9s3XC`HsjZ,*a 8j"C&X3d)2$M$dEk"B` K,W(:;S.w˝|&5ZpYeA'iLH@nEdiI*,YklE0lool5 qrjvӦ2=gzmavv1ݭ-= *#'".Dj(9#v֗n,Os~cmq- FD0NTސѾb0 j 1]fgbw?;hcZlN{q?X $Q-$Iee=6R~'w=k7NPf rL4ISƅS:>4݃X6B J/Dо6:ع,G]VVgc@3  `ן?$D Dh,?cS< =|F{bFHGěDV#!Q:X~Kq= ׍a߼テՅj+p!5?yr~dx&v߷oi_=#?-Wo uj:_~OKk?>TN&h/}޾F2ȟz'~|db~؉Ṭ( [~zST2r+Egw'J޴`Hg"젆&cq -'8 X?4j$mdYH[zWR@7_e/sÅTMQ3n RdHXE@{!2ֆc.VMKn/ü+p׏,c[] 8lDI.85L(` =rNKkkPXcKǙH+ON7k{#{W+ǎW2`Va ZcdbS2ȅ$Q.֔U,Nt# $}?l.g\.Tsr^݅R*Iw3u+W+''9ٗλ%&5oTs^p~--J%-LE/,nvAå&6G$IR8]?3otZ?ȓGF|?xsy\/S곳e9W(:J+dZ ~ڽhii[*{==2 Fs0%R.3m5MMUf3t}⌂&̂WLGgs38TiWo߲|y/wȂ}$M,AL cGPLqL|s׀ss7mn4ɩ]@ 1~O-]L FXMBчz~||p ??d@h0)2 z?F. ojo3`U[׬\/~$Mw켭n+am]Wm~緺W(!JqΖg.W@""2 a`uA 01i5u9^I !/C 3h'RJ)lvwoL7w-cS~}{4&g&F9 ҒIr+"9:B(H>r\%H3S:JBкl;Ʋy+Ε˹lvx|$$+[|)hkim$ B@a)cJ!d3Ngsb8Ngf;ڻyob|z߾=]{K#j_֛?~UQ2пz+2lsqWje63uGQ]&:M BNV_:%?yza)3yԉQw;} ͷ^=P*h#)9.ʼn+ذ9uQY?{,9~}k ZΔJrMپ隿x'3AXj$PJek>S?QDC#X9=Rn-^ @ HM0$Pi4;zxfOK" ; ΘZ Z`&#o~澁*Wm[B55S~~nƘ3. "zj-XGqFs\ Jjq2/LLW^?5=|ϻرlXcHDty~0Qjlbk'Z$Q\8QZZfm @M%IHZ$Zѣ'fNx莍 _no\{NNLrMRht8+gWނ(NZMiR37YwN6oo?>rÔg' ieHqwmVt /~򓟴L&@ ZY` P)x-5 X2M*&L[?=;و_Ӗ˅OxvBqͺu##!Ѿ{>2lW4N>wyp+2|'̉ӧ9dIJcU<=&xaF򛏄)vu0_bb2*/f9G` %ڽV $mwݷC>3v+O_Zw~=8|g~c@#z{./}`18h hV .mSV ٸ`gF8dCK`bp]Է(Ekz +W3O_\800PЁ9U8 ħꁁA)DdcZ0 jP(d Hb&K}_Xflk0v|C}lB6Sرūw]}{E_@-[YuFX$AB8LyGVr>?U12Q .΃( +WJ5dQ .,gj]ZC\zzS:j(g0gPͧA3$J.R6,HhɞM><ϷҌBx 4O;e™ɴ'S9jV?w+?k7U$r~*ܺ{I Y7-ki(jj]5m7/܎#cCs jBmq ]r׬]aW>ϔWotŶ/_~ӑWu̖Xl#Wm G/ÿ=24Qk(,&b|o]wE(3E-P33Sypʣ3~__~_dan:ƥ(m%.(()ˠa|W1P 8qZl1`` I5H0iM9;C@on4)qM=k>p; mWvV.XpWm|G.g s7!N[k956핸!"gLX,aQ&Wjmmm\2Pqe+WaX"3~K60憍dt#}+:Yf%3'Nf# " _HP(E VEqkxͷ_Ђ$ug[Ss]]]C3 3M+V- y]޽ԉ\#zgTX+exkuݚ\[)獝>o?vwoދ;җR]7)тށm6`эD+iX}WQ ps΢wMG:~ !&0ݾRGoS'~ÇY0|q\昚4%R: x,6opq!pF^W!{$lb- F7=PJ u G-@@AԛUѬNdhճL8O5 %: a0Bs}e8I[@h24{ch;'/4Bz-}ub"3NJ$V6_]_d2ia`@ ǘ0F8""Z0 xt &n宇4uq0Pf"Z6峍Zcue-O$i 1Ba0ʂWJQB %svN:=kn7?W1m7b:ܞvJ BD*aY&()#.1zޕ8 Ĕ ~JBpgc)ooOp )`-UEe8}wKՊdҊ .m'o!@&X*)2m'9T﹧)ro8 lZ148l;iRz Nێ،1]sן+ }᤺=qL+KVUW3^5i[ك3zBl;qW5? 3)pq/ںBIt.|# 8%L) `5ELH8QH$蝷l4xṩ^ )5]!@3W~~U\7JF]sn KE@uL>a7'"Z{ius3K HP8$Fw<BHJӀѓ=_.Nz~ cԠ؋{LBjyyo+g/,gwYvri:Yַ7?wMWe7qx^b<7Oրn鶟{j%@+2|[SOia^cFGt]^z٧7J*!Ag>ƶ^kd&wjx͠O*[|}G7D?E_ nyqA!$3ot;dݗ1/R"-{b䜁i Zla.  NY04cHh%Rkw[v61-+cBPVmu{r5UrɤvRnL]_0(1 S+PIe1 lvN2E ;0մƝTa4ݵtrȍOLdCHRִͮ B_Fkj?p EĹR[ 4n'\{81EJt21TJmjx0MDy q<=5W~WzA@BG ƠKf2J-wT7zo{׾͆[ `}B^zFmoH@qDJbGΜ`94mwcnG;Ա#رL v V:XՋ~KK+_~~n?>(O?qҥ7F@(0 Zޙ{^MD&5뻋Zi#_}Vߴ TQ,,gфAq6ko'DQ {pb`~e?x\>748R">7uKfK#YpǍ;*Z =ǟn4 aPsVV⋋Kt"L'I˲n2M1"m% f!DTec6\MAn77w@pᆡa96+LkRRm|E +3(hlaDU:#qIɌ ݞKH9Zah7tοk5b1FR)0 ;i4,/-+_߻{-ޑH%DJqP*Z 3!8 b4ڝ f [[fJ@ILdͮ,}ST2SvoYiW<ȴ5~X[YS:3q;Ǯ֗~_ƹT?,T˹a``1ģvo7h K)mwɶ k}![`]_{5 X3&3 e:ub5G}6Wfm% ^̈8GwRez:i7%< ~:Sr(/֚?~{CʦM58=mB ɢߎAEs)cCq$8N008XDB0씝ΜsKP1Uo`jB\6EVחl4'!Dz88 sjSƌd(% eiq=xIɭ:bX*c_]87m$Uw"0<7Z2UL+VkLЦ . rMZZ LM|m",CC^g'l6w*۩u2aBC !%(@AT47b!^u xA@QJE@0hGFI€qD!v8?!rrrR^7v]Nm9v'u4) }QύBJ)BmBn/˜HMGw^] 4V ^* 'Pz~M%d6 I{hf6W^[oNxlth\6D!Y8/x3k 0QbԱ-c=7˿/3CCŠc"daԏ00Qkk_iݓn/lns-o0٬?xiaF3VlRrxjrq™Jvݷ;c#^(K~C]\\]pJ:rt.LPL8 : *ՓN+ "rsÍp Ѫ r5N>]fB Q5 1+$Ӟϵ67?W|Oq=<<v+Ro0kvyb~x|=|qhlua_<3+It&IMV*an&ȑ1"}}xf00%Zk6 ?uGU7I:ԫc ZRK/^4 Noynnownh"JXc;T511l4)ak1@l'I.͞;~13:nVi@vmb )bA5mVZ+--p_@ݮk NPF=0 4[Nݵ+$25!`v\ʉ^mmQ܋.ܑIpbA$P@9T*RR) v@ .@CI!|?{Q!9`QM_ZZ zǁJ*H%CqTbq!DSɌTs#fSBta%p!0ƔLB\noRZ(SJ~irrMݎo"\B\*E0Rb0J&A7zV6rt'B*z*bmDҀb~6] 2#Hn\)7;{cûNvW(qLӃCzVu~aqR^3`gNlTvM%.,/s߻vÕn-HIAIYIq|_1Ju*/[ zmC)Gda'+K5B*ҩxҤr[@z, d{Mѯ|'Ukȕ vq}p|n 0!#[ҹI)h0$l;a5&VF}{FW(^^޽PvV]'%QGcxp!Vڴ%/7kCsS3*ˍGq؏~4X5J91={Y5 ` sˍ7}_Zà">TqqVFQ+Fe< b4 {srgkT6vgxѾ0=s~jvڈig>_{[r7*eEJ%7v(^ϏBb?qJE0’973,^}Ambڮ  έV ryv"e˳k+/sχ(>}%DBwl|~kI 4c_-/,fK}Cャz!.(ZH&jRs=v00&Bj[| EJ&0f…K7䮭/w1t 6L<4V˛kqq^?s܋OIfGvVUlZ* J\}Mv32d*ˇ_Ɔw#DNp䅙sfuky*"1VX*$J1Z*} \MDo \ޙt\Q22>ŕ}kU5cHe!Z=:spӡQbh._.@b⠷RÙWNO_~݇j?Ю]% %HYÓ]-/gr~gB6/~/R] +[0k5ۍNıWn<cjcHvj>OBe`aijHk4A+Lڿaׯɇ^)i$Na_Q)KS `W$a3 b$J\@;ulŹ;JqM]{یחj9>!3jjmeSg-LODzԣ fX\1@r[[u,Vmz/v2mt]n[/ۿ/Fgyugg s3ccҶ0U/m9a wQ\?n7l/fH9V" Օurp0aL0LR8!fǂs0cZjYVX,/.G*0@3V痏>rephg?&@.em;q2Vk4&3 h<>>"`}?da2}ݵAfj녑Zkr±rɄ`esc \6Y,mlV}#T?za| `Zb"/Ohya>gѝ^=-;ao0&#0Ưob+Ak4 mpM$->ķo½Z]!?عOC8%l.M?H%yZ!;xt*?l.+8yo2}^]Y8׬,a)XCWT j^WCSE׍(BQƁa.n) Vz^ޒ143sã\ndhQI˛^098vKN#N%G7GvL`TzL.G(B-/d"\MvQ .rbPVk0F@V  0hq; ! Εefۭ^ݘ,&|t}AX"RJ{TV d4%J s f1AvTfίuݞHdVև&Jl&- 584" PccҾ1"R8b(f0!n7{l&•闧Dfbx.D|!kZ,i%FiG\ ɅAqd; Ei9lV\ϋyHkYU>[q`h(I4zYo9 #D.UoTc/0cbPԲ4*hjvJtٙxzuyKct),6[Hdb(Asb_>wͮ,/cb\ }`8`hZZF⤒"b^ b۬f& `_&,PJݵC/$0@ X=vo>[' P tb?ѱW>¿_\H8stae  (D :D[sskr׺-.=szz5:23?o:8_V|[ߖ?ſx.,l#qmGmw\" c]>q6)eO's:Rk/R@CzA}CB׿q `"V)0>A.~@x^9T̥ ɂj$)֚ƼFu] ok@`|B`ȞN҃lYvx f`ìJ: λ/^篻 g/ݹ/['G.AX6BknR@((#8FHiZV[֛Tyumvt L^TsZeɐӧN"M@k)}uy4ku1jID:N2%T nuRDA-dh3Vlrf ǂK2}O00,;v c(fex8NťbA-ZH؄XB0JJypi6[B.(WVV0%tvtdU]\Z_j^0!ôuc~-/dR\> uU 24DH-B;҄QӴ8瀐R`XR"Bh0'!<4^hoT=7&f3>SkX1Diשn :sNڤX!Aik5(-d$ d6ӘaBHPJmc s߳k{'@Wk{R4RivMD:akO?̜tY7s'5G>}G(%Kx7 a!j\@^C7 X}_pkWΞ:}i˴v-fq@ Kgg_9z j\珽,IFL&MM:0<80$HAERBf:LAG5WMݜ*fw^s pG/T.#y_|%mXqϚ-G`{mnw3ALx%_1){s׶w]wH1S+!RYN5P q̵,Lb֨ &:qqjffvAB|X=9{__wr}/pO;,4,rBv w܊{2NONAx |?J $` awׁ??1R٘\4V>D@JTl!_8y`c{;s1DB"qF c X#ZbP?y4zA+G2- ęS/.uZ%8eXZXp +?IkGk4h 'M8R 8] 1 ̓@o 4%@1&W-arsJ2O&b r۽^g1'̭o ƭ8+_|їH0EB5hi3ᅭhuznSv,JfS/z]٪^899(mjL0PlT6V͚Y^U[秤 -@w%(؍:T/X9w턝qp30rUD9R!PX*5f& ],,/.ٱ_xQQ (@b Pě*K NgnmWN-/)JICv)m#:)DH:Nt+ c^}?DY87x+şYJ)ha~kN1EDz?s?cm"+"Rb hqc+P?yg_h+NB1B}J&J㓠bhvk[4igF)6.:\0#ڽ=00PɁ"}M7ؽkvq9I1B"Cv{=ss>xԍl'qJ v6i(ߘϐTRJc5p}Մi __$]Z(]}7UoӃx.< "`SRF lfׄsgsLG]tT&4MG?;knyǦ"J^ֹ0qص7Ssп󶻴T ss7xߍyD2vt_kNޘQA`2:i@Q, &ya\)`ߗ}l3(ou{ sc5yqޮU7L"X6dw驥ؗ{'aJ}4u(T5O(7ZLZb;f߇0V7З-d=D=_*RhsCBsjwt&peys>7s3's79@;F0 IRJH)4an bD-j$ Kqt1.\Q z$F˰i Äv"ƢA088E,P l6H`)@Vza@csa/V* md/8W߬ǡH%{Z PRt*mUڭt:/ =%8U2Ǧm_R"CIVZ,kWRqE~I1h&Z:`'L'fs{cHǯ/zsf2w 'ܫ]2$6WDr~`hc_ֲU.@%|~euiQ ~-Rs|/bs52"R**1dܲƔDFS}8P/wi]/]lJ$ljAg [^#ec!  K ; -,pDJ ;w;60FARSg#x`l`±2c+֭J%ȍ7rcԩS&A WK+YoZ Ņpr^7펃F.澽ƾDZTnl ߏ=/ |!gx^hI%5"E0 FzűAkv 28)zQl}qTBc4Jc0(WjlĖbBBrvB)R~𲷶r$I+J_ب՚A6SL+kJ;ڝ^Z5111Eѥ˳Z;v WVʀ( u!+! 掱]y_Y]^}hk d%*\ll.e'Biu$iP ?0j4r9MbN_Ҋ|A{""LaunZ wUo痖KӔJܮ.4JREZCi ТS?cʏkk)04I 5Jۙ EQsA$fQBt:JŌ2!fa-pn+e ✝M &FL\G d#&fBB()A I'!Q 7^8}j7Or^BWE!J=o6+AV+FyŇnNjF a~x߁w[փGmeѠopxp^^]Y" K@ Pb^},-|_O=¹D9X I X~tyVR'zԶN]4zq!47_B@Q ܮto||&.^j'vLz{唓1<{ia_j7PS3fJn+4 JBH!-hU(o_Ĩ-ZkFss1<:Φa( j' l>ɤ'zݮeRb@B ! 4&ڶ۩43#ze2Z,SSv/0űhRhZu@iٱ[[(L.֪յt:Wpyu5̍ C{GyY#ãB1& ߀ z\:dn[Tq$~0@AI"Vq(LY..nҹbi!Z)FvhOrnn,̂WoڔىDvN*ꆽnJ&^SJMaBkͦyf|_a0 a@DY]2m2kVZ2"VHr ^3C<I[ZccCؓCh @5H lnc-d dJH T$GF&w]}Uo-lޛ|Ξ< c־z}P :T=bЋcbDJ`; JAIpLp((,.MM=ob/ B>o^}H@,xhP)#P276SsXT0?77n'|򥗏7L^ %#SgZ%΃V#׸՘ Еkf&-7|w;z;nu Tb}')P0PGШVFڢ6w }7]Б0䶺'{~ka)"J) m4Zע(Xg/,xs>v!''3uW#G̡A2.Z buyRm cDJCR{=7Ѷaϻ}cB~=1=sCN&b7)B4-7AY @A(-WM0Mى4!tѱ-+TFƌbPB!go4^ϳ,(ܲm' R¸,ߗ5B9&<U'|k\6{ͷL$f=͵pW nJdtt]#/R6 ϽZ/OM\&wͷ\s-kξK=ߟph,#PʯGV#+S L-:Ha)1 ?lsaP "ioR$Ā`TAblSYߵk7_뚃6jRXkB$o>{V PT&߷wc`iutrK/owۭ2,ܪa 0 1&+ >~NfapC)"?gFGڣ^~oڿ0Z@ Bakb_ \-FF[I]oU>v`_}؇d:AB>Zy&$*ZXZ*bGc@zUk5XndV2|=Xy%9H>9x `T̔06Rܪ3>ڿ_RLMzG |z66K:yjT*_/o@6(&w;]6I1o/;\~C HmǴ\1 +y@w嫈@iq#hI b_$3O?[GGǟz LVR_M ޻etzf4l T$|,/r*CkDTq7!ljLcyfUN_<00wۚޱV bZ͋/\H(^&@-)V@ Q텽F/esq/_X=oxdpN' 6Ls\*B+5à Xؒ;/.@ KG/]~GD੅XqE)b,p7>%Q"tL3R48ПL0t:dv R)4tnٶL -ӎ+kdd"5П*o6dtzkkqGFzQ$z&@k+uT6O&^6mfLzLw X8=;{fZP`)sbZ-ɧKC} zS|ω^ύyhj|l ## <ąL\__ce2+t<^YMزR ;WJM@X67!$!cG_xqݮzHI۶=Ä|4! ĒJFZp42@60 mw_w*"ۗ{ (0 cPDs/?gV#PQSk '~Bb?o5,-=7FGFWfg iRPכlư)A(Rѹf}zy\+ظ҃J( g Z{yfyO;}ft`?8w"omPk oo[ T@$f1w0¡  sNAO0;y{&W"A^/t~XlA =P]ud4 RUWW/\uh>;6*oys?{kAkow`v5&PZvIIHJ7.:xhqaVkf,(A7j∾]w?:]`! /x+ǎs! `K @1[nootbFS>G_z;¯8щ<9Tq'gvW&[vV+\s[SKĈ`P-HW{;zݷiK+3G(F@, `ْPo~s_h y`NX{(k]P_  )I01' E(V蠄r+ b.̯l*] aW*R%\6KYkƝnZCv۵Z FWH`P ͭ?ꆛY6;o'5RBM(@.NR*96 +LE(H錓/nۖczxij#Z5:T:[{~!p)*JRʍ^1BC#Bf{SkH&, X ЌP]/dL9?{NVmsUX|ߵݜ9dgRn7_pR )0lFQ)aUPߋjX,9PHg_x꩕ٹt(BJͨݽod\4TpCj% Q--noTk81\E`UBX'$D }#cǘ6v 44x~nⳁ 6s3sSt箝bzORJ/_\\؈4ɉ]wx@#BkJJ(44>}{A[7*c@C,NOjyPhzv. 4bTZ!6)SRhd  ߠXkkE-l2kzy3Gᄋ=wʏ|o$rFz=T=VԱ1aGqxhǑ_vZE$y_z鄱֊" 6JJsΕXN?rwskvkbf KqeXWW4(si'm畧}G۝]g_^ f.w}4d֚sLTz]!Bra0@2+P TQ63iHBVW|7z #@`H:c۷#5WFyG[m@@Ş.^*JC#;Ŷ9c~^sIר?ē‹&v5Sڎ'GW܆a(q6-vo78F$U#o:#(F f RbRiX*(611ξ|akr덷N]Z?ݧONZ.0Ɔ&K_`"V^3/[#t®uZ/=x"YNZqOWdGcjoHkk36*#_yneq#?ןcpd 07c;JT2!R %IOL0 @+)c aΥ3G(0"2v[ "%kSxOEk0)%A 31uFfTm\xJ>c]sLF.We;ZqJ&҉DnűCR"jѱ7S}1h&P LB ީܻop444<6*^\\I眱RL'E,*N~$`\AG!FB‰0*W+ FRÒ#H$^qҔR* F=48|?[V2#QJ(R#),>yvmfѩהc4‘\{P+psY S %WZPvn'0EM/!:=}z7mFlD(Z A1 ӵzjy2V$)A 05L1"# @M+kkcL e(P|qGRr1hI FkE P_c:W80M0NXKve4 U"&A8(xk M\cTz>5MJG]p &ۣI}C$|ϛxX@XGP!]L%Sz#z 'Usmw>v7K0?XĻ<}!T+B &*A(1" <+ozӑZU^NLL(x9B$<LBgbrb.%U$miٗ孆i&Rfdpݵ[x X޹|yRRLi|BG> NGp?xi+|kfiq09Y BL Nrͷ6תO>r$ \̔ߑP0"]ﶄq4WmW UB@c4H7FWr'*La /T,R0%Kd&x?V@ Zsll:50L:tksc-bic(AR c.-"Hh,`a`bWw?ks0/T 0`x0JiB`vǞy!Ҙ[I++u0 3ƱQ ,BK a[.3x,H~&2b 4W`DRj{}lq\?"TRbBRR#RaVRcB80L(JD<޽wog봌Qtaf)7^(e8;58x8BD2jlƜ8sZ[}my{nE%,B<{vӜ v}oX&25 D+i:Z25-U7ڮkԱNBJϓ!AWO-5`uZ:7F">FwI0BC <sn+ '@1Z;fOcdXc,E!#cbZDpNVoGƊyx@ 9g(^\YY)oBb? #/}iU{'tϖ E"NpAȿ+sX۟\8uifZ%`fGP,ʥwٻӞ {YO<73 ܅ }Ã:Rk)cbjОxDG}f\淾 I Z_'4sA2G0%Cm>_jƆGu˛k??D C`VzlG~_?C&H %!nD~a/< Z"(k% R啵z5 2Zn| 5 ҿA]y`%PH ò-j0@! X {oFvۡj{tzu_7RҠFH@̸_{/Jml}# LBUw/x~|֠#7\$Rʆ-sGNh|RbB%̧~rs N0kvLbRFwNDTH\w7&ưAG q(SqF*Gn?\+P^}ɉ\?~ѓc?B](Ӕ=K/=*@&j31'Ne[{×Ϝ߱ki'U7 FI?K5(D$xasRDjvoÁ?GyknG8ktT&i^51K9vZ^[=y勷|ӑ&y`?‑TuZa_[^'9߮! +ɯ襤 O|wW6) hHZ^]B{,oIߕ8r* PZhґX_ZZŅRɦ ̠e`Fdbb4 "V(V.[zїj[މcuު;//!4*|s亝u6*2mcVjtbY饗=k@Go݃~m Y-cecdwءC5W_G~C~_~ `mmpc@Ftխwgxm%)+eQ \Ltın_(脑Ƅ Lƹ0q&5\ i03Q_uǞ}@ H`ۭ&D) DHK%Oa"Lnsd*_*ߍbVZp,US4̔mK@pQ˧-#mXJTW1\S0y,j[j@xe~ܩBan#"F\ p\ѤYnןyt'wIza[ݎ˵2m;gf@AOJO-&l3\ <k'aX"pK&\ mPXDȮ2M4 !06b>ϤsD: JLCc̦z] Y5"v[]rZL5gVe9q,cQ T\%0$@!C X*F@:t{UJdw8o٫KV=Ti{j5jzy瞵Mkr`[o0VⶻI&L{eyw⹧PLXOb.wMt1wq"0q]).x~"Y rbA f6 ˌqP_%#_zSXfJ5;] &\rV,RT( ay; A$/\xk2W :s PY*w׻vG) ?o=s,?-r?Q:vɱ٩՞dib/8siV EljʍfvvN$|:cZKl@@z;8qmw][F4?>uvQ(e@E\J0%P,A .n'a:±ضvM C#RQju%uIX;uۨG^wsm$xZj-+b> b#,h`4R@(qɅ e*F䲷{udg5aQKIf X^7mǟR+>1L%Ҵ(Z*`@S[l_AWsbW1p!(Ips>igno{I+[-[pCH@KBdfiݽ3Oc9׾n9}ޥh1.@ 0Qި>O49`Kݔciu4PhB܆$Z so &D@"GF/tB _2ʖrvK@ _ے{ B7]^.oٶ8Wnܺۯ;Af\l0ΗkX) Rr(Ͱ)7V_2~۬LS\/* rTܰz`b\:`-X:]zO /M.hr36uiۖPmr?ӠٳO}?^T&gf,8@7XGy޼0qGpΟ*}[kq\D'/ju>ڄ H?o7 {zZ7_Ŋx =qy<\zn򲝘r u?731zw|m(/kc\nFqb<)ׯ ?_ʸ% l=ʕ6ڼiv΀^jhcίYO>ܒMzX Ooj֕ }O ln-/! BcT_q}ww&rf@¼"QL RJA@y\23Wzb (kZ%C< Jwu@)t u[/"AZ[0#Db观zeyQk'>3<>XÙu 㳵Fc`hhPWt+I r&ڪeɽ7=SpEvF:Hz)f3)bˁҾѱǾըFdl$]䮄"Em(6HH1Hkc[j ٢%`vv~ff>՚kŋ碨ATHWH@*%&|.`b.]誗Y&_X kӕ9Gqv\H2bf-eᑊQq$VIӁ'7t9xEU2/ ܿ"uv͂M  p,G7ƀtM;BްYJ$`XRF Qpz9 \)o-,yLxL&Zd=vMWdlG0?7ߨ7(V/ yxV[\jVYOir=ݥo;5jp {(EQjhFU"-d{ *R;W¯v5TqMW9DLUF\W # V3Js(2 Г򎽷[=PZ@xiҞ(N? zE?X:lC܂BYa.H˦vO~k 'ӬWtiOXۅ驙ГzǝX,,߸q 6ZˈV+0P(ܱS}ӭ(ٰ+Vi./#X4(d)i5q!DX.|@L:ssRwߦ-[xR .?f f! bw)UY\ЪL<70W2Ɗ]Zhggτ\>{\Yrݬ d8"XFIJ3IւK@H6roTDsČm%Z'F"`9!Z6K,T[;z^(7jnK[uVl^s e@TGu]#mQ6ݑwM?Xkr˭W*3g''8&`$sD6p:X˗F/m]nZ\&!Rjl4[mW-JTb'ȨO^ &4Y.E>nzYm4lscN*(,=m45I|o u~^dVq߰zK+څەeĵ @x% }{W>Z^}.oEt&y fZZch* wu[0DR_:/~񧞻8=@ 9<8˖Ůo|矚]r'&4zfYچk- DDJ[FqKj'yXcRb A@l3OZ˕B9g%k06`@PE k5g\ȑd\hE`>~aMYGjۮ5CkJk;)"[]b2s]&J:(π2]:vđ3l1*.o6kf~ Mzbݰ*K]q^!:"4(`H& #곍P\7cs=wgzT H,3Ǐ^x^+-8CB"Z#:i.>y虧r^z^Z\eShf@)tޝܼ)7RFRMx_G]KI/@dhMF2w$V%0)@Oδwp}ott4H\mGL*@U;P+W';\5s]:ġUturh ,1KdI, W)7Nj`  t`FIq$(N(NtlH3XRhۗRTngV.kKͱ˓N4Xf,q.1Wy{J ,Nhk 8B_ ]!6u7Y52~;x~h5j'O|Qku RӧO_t9.0&!ifj#Xf@GJHdzUZ,X匵Y(1B\)JJ] @1~vb"(i1aM{AC~ڛ |F&\2!2vU"NUKM``ޛ@oPj* cQCdT.A)h7=ױ{~j {6\z͚UA&86UE)@7e|=}kFPs_9ݺo_w裏&qȥ344OH1htDͮ3nE$$7:f({qd@xBZ Q&jH$ θ%ɢڭ6e%Jkт 6Y/*f %cDkH $H,b(nٱv][{:Xi N@vl[zcf6 I$d0jQM2 t!{~O'q`{{oj{V#/_8}3Z fI5HR:8x&A GzY78d۴Psˋff.#@ `hZ6ڐ2u\05cf(TgwsuN՗7n$W>pr)厃wܳ;kq-΅a`5*(aqYpnD_x==zn/8{ڪ6ڑi_:짿ǟ}f|jBr_*fz.^83nah1d\Rp F)TBőqrJ7?PXw#c|/].u[Q+$tl*mDbZ{7om.73zެ+1@5'/7sb:FQ8Rވcq֪#j3."Q_՛ʑ5۶Z~Ӗ'汣gZ1ւ@ i !-ZWr[dDh;Vd>0vsED 0:zDkH,dy˾^ }bh780 m6ZR]֑4E`H$b`!A0`xFV.)>juq.\x R `wyC"cB 4$xR!Hg ?Oϖ~Hdli;Lkoفݻ[^1&8Rrc xƀ _vuL 400h97a:\D0rv妵z9c@v+=яHɠ K,\U"=R*DW~ D2A 3?s='N|Yydqr|Cz[woOZ?rQX}˦,􂪖O4T'ٹD'=nŠtP В/VZܚ[nЙb\EoPF`Iba1 ☆W/\34[ns> = 57o Ehc6WL yme}-z, V)@"Ϳy2C`j԰` cdm!֬<}}￵֨w|]a䂣hju+SSsKLU*2k-hB=tH]U|{Ę3|!.c Ka+*{14#Ĩ h8$F̐@=sG&Bnp m:iyn(t"c%/ ®J.\[BM!CHl蠫M`mj(\ KV $045F[`fFFuY]eo^{_2F%2J)I$JD1BDIS%qΐ,YܗVʂf`r9c,V"À $&XIc~:XaÚ[nڔJs4* OŪUm\땉3O,M.0B"aX7`4 s0'Wg=oNmD|h OL-֮YcͅO3̵Tk׽w3ErtL"!' ˮ  Q.zL MKJ˜O<^x1!YFD~g/\\ Zy' rRiԛ*4Z@=`s3kݙ,jD+qXV(I@pXKH ׿C;׀T pUrau }𻴿D|BTz]P50d iַO$K p9cWƮtwu.>s3 4GGQP$,-ΖIHqzGz'N]XZϦr׬ݼa{c3/\x>Ki /|rRFƕMҎߝ+޳;Mss7:} Gfgj<p|b!c4}n o|jtU su2q i9t;N'Y"eO|c_ ^ Jφ_~?g'g^X!ԛUm@CܱcȎ[bwRyyŮ۽I%~'|G~4 Clg^8 /H` qq^zŽvKr#ZnЎ];oh`a<4P)`6 9he=j Le8gD~$㵯~9 f1 XוHF#f]3E ȏV_&fgi2 5.rj'aO}ݵ$ըk4mvmMfrezb3̧3tq"2Z2Od,Ed ,X`AcX #Ւ YEhb[ h. 1"o57o?-!'2@bn^骬zֱ#^>5yR:Gϡ -xၷ2+QcRW=qX*MNN= |=rmzf@6UbL(0";߱-5YlW9 Xa c#Z\>0HP[lSA. Ƹ2V(!]XUD@`-. Yc4B`HOtN|5a[g~*s⑋@q!ã tZ50[>{ lݚ5bu l50&2ƈqov2sL ٵs۟_]b 8)Iy!οM/yzT㤍9#f3 U_'?جկ\3?S2$Y SkFVؼ &㱶_XD,2CHKfjE EH(YVϽ|rd9W幹o|KW|}=q|Dq_oW](""gL) _1͹.W?cP36ާNlboƚVXȏ[f&nڳcϾ<`b|ō_RY@< Ҁz- I8pࡗGd ü| Ç^:p=]ݺnz\5{ [ 4FjF8a#"0fUXD Bk庛 '13]"G %$L aEu-l6${73vKR.-Eq(,!1ǔH`}#C61\$|񒝙o?FHjN_Ih}N;g&&֣FP(Eeܢk6 G!NOX0 NHqX,04p!X)MWۺiӋO?(A>j̪^w#G'U@lEf$uj(WY=i886&dJnY'߻oӖҶ9z<~\ScRx*6c3#D w{)\Oc:;f,GH3 $Ne X e:H4;5Zjd9p%pCN W4;fr\VY/`ܝӳ/^8:=>ebR.WM]9GFy ''ں;<0Dk-^lCxءo}S3wu2_/gÈ!pY3֎'h#[O{Mvir_toXڸi۞܅ @C^|qb|+Uwazfq3հ"bMK8$Oh6"hj'ؓOVV7q+Գ3Ņ 8|ۻ{g7}w@_IW/ؓ5!uLV}KDF׆nJ\S2P/N>>x9WQ^n:~??/V Ā8l5Jb+#dxٴ`p7TGJ_%nEsg(-bXSkL(/tvo}UYg S qk@[k4YH"FHpX- e")]@3"1XҍHf2ëD]3Ǒ,-B$D[}ttzqvзGmf''8 cC4;&" (6h,%ٹ'۵C"!~*[zBh^^ʾejuS#uCJ=39/37]a]!?}l\q\u;Ʊ+carSǏ|o]gϞ%t:}5k /f[7n=u&VJ%I*3л=W>~رv8%Җl}~)YtlBjk'U^>x̕4fkZK' ĥdIKc @tQE C0#[߹P삍Y&"qn<*@bh:~;e$dyk1"̓_^*uݴs;!s\iS2`*άLN!=70Ƨ_x湗}HG>\-׌f//Y  Oұ.dn)˦{JzA;6 DNJiZ_o= Ν/_ٸO[QHe;!K ({sF"dGg{\޸ycG<܈c0 XA4f:'qvTSj`{Ν֘M'ضa@3O= t*a \*@z|b+s3s#ébkR:CTZI)i-CED!IU @<ÿ_{Zul("Hzzu[b`0֔UwZګůAVx2 G]QRs_ҥ'q@1h/jD#L nj4 H@NWrmDZ bek-g ,b,CKx5$!,h@7Ƭxx𡳇/{llpx}xb\(`h$3:V幗Zw|{@Jn}GVkgNf PV5nJϤ2٦QI ޾NJ fY2nٻmopOicsDOO-] ;h%ϒyٴPv}~?c$d1b g"y6" 9 +bYj-r~l?tYd=flq6l^c.'큊̮]{##ku,tM8xB {E!b6B~ I+(b_#_ړ./Yub$ ý{o[/>/y]v͜aB0$t rd%ChҦVq82p6y׿np&2Dc{ rV5@[M8c`,1!-AKvڽfMUb|;gfb(sK H6fyb, Ta޾c56cȐ`,g %VZ DuT>^)G ԵzjXctt̅h."3TH F.d'ODB7ߓtη=$@Il=>=:MO }}-0F0`ҫ/ocFQ q>J?o( l@&J"X ԀK ܁Roo"fު7!WhRdTJ3hYfұ>5ѮLΎ5(Co޶}T -2ICsR pXR_Ej j?ԧkƦMwyRT^<{v3'73oy  0rԭHk9}FG;Ï|[b+/rc8 s!փ}x;7غy60W}~G-C7{E#37޸a-c'I(0` k7 B<uµ trmvqFBm 8Ckmo[Ξ?WF0(o,Z=0cjP+%ߣ0iU1IųZwͷټ $pܾ-Z vϾo! 0`om`KZ PLN,-,-E়fo4~}C{ocmzhܘIu?So8TLqz$aW:sف[T__}V)L !RJkqgGoA36A_Op釨I:#t\n!CnØi̓tX)GE4:pي npnp]ŘFa7X:Y Y"lZMv ږ8'S)ogOxb.ݹkWZY^^^fն*sj, S,Xnxt<W;΄f!!P#"[al#2u}K'qV2( 0$c-i,g'sFdLKnn^}4X$d8B0/](zNY85z* -2(/m%kׯJeŲ5<71s، 23ź;&õUR_Z?kZ,.M_2l@iחZi452Zi )@28"XОȇ?|wMLNM:(O|63W`fύ>'øU&~g󾾻{ӽP%r} &Tؔ`Iv-`,zÛOk3sKeN !{kWAok@it(ā\jgO6ꍮ|Rn)*}_g7=yϽo|#hsEG??42<}Y2p&-&Xx@u+M%9R^3 6Dl?zc뷯 GN?9Pgnn^(f mxⳖd#ldفt~ubG/x0Cz@vB ٥̗VI?O~۩@Xam+ ʠ%H (;>J% ?OONΡrJ\nh:Mi"AzC-~'qCx.!b ׏R XPJ scV+idKY8e WL7N8FXYӭm!ˑQsG,v;{&`c kYG9HN )3g/_`i.P#H2~"h6|89>GD2sлu#, )ڞnؾs" ̢1Ɓvm?D~HH5' 8~露1DNAFlںUp p3_,Ri\ D f4(2^u^e.(5%J*j ͱ٧}7G''V={@@Tqֽ̀ortЁmS>8Q!gYBd@D x1Wȹ әl<|k[381dAL`ٽH^!E.;wGYzo pt9/Yck~ox`t;llśn~.>_/n߽=2w&} hV;{2&¶|G0Fk.1ƑՃrCzcLS_ڵfZ5Pl)Hxٰ`jPёÇ'7m۴q@H $c58sࡓ|+,W RiW zmn1Xc+)GY,k !6[\@?̳OsH }gOrx}\,\'` @$ͣxxGgqkIh2ozw . `PH m@_wM{vT*\*oݱ}fr&\cj5ajd\m|nljhMl75vN$,# ZRH!pfkX=?W:!pLeX^&l]GkEЊ!J=`Hsnݰqg?c*Z(z6|7HTJ•Z'@ 90V2A)& gZƑl.tMnۅo߿nc=uԧ>׵Zcl|2ѱDZ>}$㋵';GW yǭ7\m۶ԓ2ơ Ts|X3˦JA-ԭޓ,Ak]v5W7kGCaHDA.Z"cp83F#5jص^-?7PGxP?@ΌDQ:d @"cvS #ӨV*+9d^$nhn7֯pȴ1hqa!KKoCyO6~_$!)k j)?Z=_ Ɉ(j%@UHm nqír/;r$C4p`o-R='Nj8ͤ)IFR5 8Th0JC0.O>أ#'>}W-XŞmCѿ]oYK[ :$23 \]_F pV3mw n!*+_뚄QbHTZzm/3 "f }_7t1snظ!ffg֬@?g~g{ŊiǍ ַ2 1\H~EsdL@/z~jhiۗM9_g9zYMD6fN_I0΄uWGuX^| ʕ ]Wg7$a/8q8[KF|&%ʱI8#6t}šUCczCk6l.O|iK??ݑo~MSg/}!C.^zNuMĕ# R^Huņzů_sy'bbkjb< Gt~- CQ}T;;rA3i[W]2d|A2uȘȈqcVr3 NXXw|g[Y wȐ}'w~No]tw.H;yG$cL-n}.]Zeuk(7mCǤAl;8QV*zОM5: ۾zkuyٮ4|qn{8p/E.hqns|.JjBa~*ϝ=(eq~߿{<Z?ܑNQRpɿ|{5qϵl6^n؉+W R&:f#h 9wk[@h9q cߋ4B^g|aY@hhexݎ$ Ϝ:mN@#eJ@+um__ѡ{cO0~?<95۷~?vjIlؙ4@HD]vu %"Xcl~n0On;pʥť&Pq؂@/Mw}oF/kNL#ֲ82ư ~=wa$ _q[=Ut_a ;b8mQ;7@ƈ1N0sr:"EstK${d=Ӝ2^%hab]524P*nTo, ɵc 끐I3IֆG/.tYӈ5CVgzK$14Iaՠ_l, AYI3EY{2նq5M"eZq:G" 0/g?JN?s?gw!_2ׅF6)o~å=}⥱񅉥heK=PwrM]|@ 'իlx]u@x= g3c-!CZš,sg$y޳O4\TM%:F0=Ax؏@ @p\ϧ;vDkKH f4&*-tZ |`Gk sN׫hA%Mk~I596eIIZr~&ۨTuJeK iݷ"p7G#k~'ٙ]ˍewW #ZĎ6;!-=$+wXͻ7#?w~际EUtCg8jsw\837y_fIbC} !ǖ.Qw):.dSh0S8][-jedYq'Ք}vSbu#nFѕO:V]tHX3 |SdF{z5'-X`B5ViUDJ7XGa]mY(%zrvKj60sԪ2ω-i-&H$iX Ʉ4IId-!Jn8Ɛa"Lx g+ˇRHDd&xݭFEcny}ݓ {{zVlݹD#ࡧ{vvi.]c\xtw=?18oAH#ƣ?񱭷Vu9=X8nwa":x+q+Ёv\M,άQ;\H&X+e |Сj3&Wxc۶o软5s&Iv<ЬpjU7`9TmZ^h9H(rɁ<$V+,hՕ E4\[Q, CzpuG>_sRGfZfUpCd #JL Վ*y t>dÛ@{z2#K˵JT33@\+ɔV*J9_|087*!At*UZt>)c`$r^UvvXU,U-mRF@r~gH7;Z[ jn;2Ȭ8ʄ"Ǹ* al4pՌ]8k5č+WK+!h:Ԙ^>=zЉKS"!#ۉntcm{6q\w0&eŐNI*vKBJ ljU3R5׮馎6[( B @"HCb)T 66JMH99\\^05&5|@kۙQ;p<,x`Ro޷# #g!D f"Z$BnXh7y?YN@xv7ޑ [vm/7>ۿρ]~KF $ MQ!"pj!]'5^Dtk|_wk31>EPȵZA@8'$...O^^,p/^%Bf+sαl\׵XKdm7Yyy֮p5q7kywp@. TE"[IxQ]^N8–,!*U7+0 47>q H(!|Դbo_ha$!ґK3n![m֋sڕX%HP#c8ZddIA0Hi5zzP3nD70q'`($"XMVXҭ'mC`/^\sfM&[.@d) 4Q.I c>S$ruk;f㚉+qԺ۳ݹt|-n/_Yx8? D3Dq6_Uر^l|RRhff}oʽӗ -r jʗ>6ݶgo (7-Uk5Ɛ;iFjc,:vc'z|k4Z37߶öϟj7=桑Je2"p!wG!R74Iga\o/}3`(Wv-[Ȇ[#fP׸ !NB\p`& Z*FBo2?8LX* +A4hT6nO[̈́t=IBS@K?3?2~ib͍rszrHy_^>xoȺs LLhW̍+aztݎczԓd"0Oms7?߷ޙ'vl~oˉ",jCDOMA˭۴}kEr=}B#ÍīLERʫN+M0"2'`gv5/CXM %|LNws/_<DYk17ӵd{{IgS=RgO޴v}>[^6ͥمV?v+XH"ʹ`NeZ[jg]9\H`9J΅%Pה沁K_ IђsWHP$U I!*fc$ňsKHdH!3@L@ݻvnߊƚ/|e3%(4`҉Ƕ1gf,^Y4?~ĻFh*׭+l?KW* QB 𠧻 ox0i(DG"t@S2S.vzE@8 Bgfݽ||Nw! nZ\ҮW9!\Q+d*Y\ <bV6j&~/=Rٽoς ( `A*E;_ӟiڌlwl2rl mDM$^#XkN) 8$MǺW:hxԊ\k4jsQT0tF$ |R,#@ҜR !kxU |Vm_jv[ ϟ:wwSS)Wq3V }!A@&U^Gˈ.(X Ƙ rL#[ К>Ͷ~G֯3/ǎG .CD/ڪVt֛ۼ9UAǚXZ 7l.8 `92NƮ&q)NmB"On $kL"$:Itq/,@ YdҠ-h hQNWlN6]HK>KgppF;)3Pzݮ>3֢ц +-?Q8L2yDvvflY]~7hk2Hq-4qg(ɰˈ,8$D .g֬[7B6g=kwr]tڅvGjr|QSYfmHe M x9q7 ?uZYT/^(<1TĭtZ7ǷҚ):V"|O1Nn"V\%w0e.1ve\ݴn}X$q0ZQ% SVRkִQR5>ɋq~zڒmm2viqzxZ?֘mk\D'И#g`+/_påz#Vmݲvn'M1 ؕ}C䜻Z%s@d|WM:5u"J h!q39KckWa @9:%K \ `fmRW۫(Ưű?s_y|bTΛ68i1a^;ɉ >> g@KC @fǺJ~ֻSNM˕6HΜ9sl.OΚrjAeT!e"DFX8zi G=$Ze% ƴN*Vt$Qyqﴉ q]k (r=ZRjj2Ah΅qFXc#$rc$I8ch82ZRTʋ0Z^!7Cé Cjϗƈ6 CPy_-@ LTmt f !q_@68CZMFcr <(8#Lk7`v)8s'hp/3SgH Z$Ѥ$҅K%d 2^BJ) <{[zRAg]"JH3dh8l4jKRbh,uJ٥充'fLeȸf5hMn߹X\pJa5QЌD Hc8G&Οc\3_/l4jE[e=ݻ}MJ3;FH'lf3 ۉBanX9 0e 0P]B`PYZΤszlwzF3fp`W7qb ^qD+jsVI9/>OdWšj2vr:߸aúk5kf=s\L๛6~?Ϧ y(D!Q g 5nlRcL:N(c-c\ͅ}G'JOY;D \`I'ڹkז[lX 1+KګAdq؍1i|qRy.} Ȋ,~> FމCy,'ɮbK/|{>;0D $rv>Di{[ ΍Y~EN!"d,ڳo{*~ybbRk,(QI$;93[.֭ft ;R0l3Gkg\fVQʐ2dupQv+, 9Ͱ^HDH8B T#8!Y}DI3Fv=#@;l a8 Jm) 8Z%RJb_iCf:=]ݞȵ@'$ /T5Q7'GƁfenkU=+^Yc !Uvec+=nc"IܜX"!ZV؎RQ 3d@H$AՊ͎=Ź  ˋs` 0 xCwjc$=.ov9*}d{ 1Jc5e[(vwwO,N,հ]BVY+dh%-),R DKW"%!X}L}|aX}rH:mJ2sbeYuFq. 9nu~IId#Zi.DX x,>j-bҕRa^[ XtHVv4qı"p>e˕ZnaȣHMHo[z㦹śoݓI翑{lx:9?5qfpcB66’%([, ]fّ!H?Atv,bajAMoyhfn|p[$_(/VrnF"߽#o:A*N຾qEoOO\4 4UҪ$rʑk s&xTL3@7 ygJidl+B, 0Zs/`,pQn߲m-J#σ#} 0a'hک#焠N$̀`>0ƀ!vq'I p`bzjjWJ9!Pc=Or"Kd].L,N滺Qzߴ867^݃oٻЁ{Z.3o~KS\(2Y.w@b7חhDZo"ZRC7e;Ɵ?{>J /Pז=@\+2Vv߶LGLE;@݀{3ƦT:Njj6]gfz)z{ZKkma R,6F+c1ͤtW Thvn %?|.4ZMšuzZ/wu]]Nwph5[##EǑ兮\FFv9D)1DΥQI ܍Q}wlG0k'H-8'psԅ3ϟԵ%P$Z1qw,ryyijm-tٱ-F2.NK)<[ZZכJv &F ;?`%Z9!\!6[-v9|nqquv;HJGqh4jJN׎xGvnZwl6.LOONNOO/-jXk,#۽g[Va|W).Z |?IwzfnVmp;ʨ4n _27?ύWJn )N1ZV%\2Kf Y+kjkfמ|hayDbjaE{i5s+$[~`Mv޶Krb;u~ƒps;Z TfmH 6 *1;H ]UP}vVF #D#QNSi#lZdR)`Yy07Ɔ{R///884<_]o7glY L!jc5DuOpܑd |@.o7]Xr 3H Fdi0i_~@; PufFmM;LTuIr<ܨwsqE\T\۰Z(`5MǁblI288X.WftzB>nFwwsG ]Z&CCIkA_HoכyXcs"n҈ Qf)e@1FV0&!`uX//-jU_ )A) ls= Ylؐ_GC4 Ոg.9ۚ,_u0:(6 YkI[U踮:Ŋ{Isn]_\R.O6UdL6ϤZAWߪ\OE+7/]n8mb90@tZd,CmӖ#gƑ3cա=}/XP܅Zi@Q.))a3twgWO=/V"`z.XY;o^= ?ЂQU((s=9)Q,61pC"PN3tjSR1҆33sh%I+N+aؔtq,%gc*j`޶j=XH*ZgZч3gVKgj.]8{<8O?MwYՍWO|9537pk?1cX 鸻We%~{B$w,<1X+/x3ST!\ORo})~ssVٿGȴ]Ռ?ihbӹa QTz4 -nyD?S?z?'g 7P 3cϵcޑWwtveՖ[T] O-,,MO^,L1pK5[ {.R+7eDܳgc?%gϞMV3}[,gmkLJyhѹLF["B7>~c7 !UjPJ.IhLRlF RLKqb " wdH6AZ fOp3KsЊ1LL -X"j6DXӎՅ*dV._9_k g܀;2xP $VqFL0$2QgbDfh"}Y2ZB֖j+XWCpXkkq蹳K}=alhZdc癶qPDa,E$!Z#\B(#Ȉ T-_|'W SnE'ֳk׿^vJ)JK .!I%yGRs1/j ΩWъ1#$nmv8R(0Y$߽~*>0ݹo>ŝ7Os/{)#<@";uW;^0D$K+e #Xu{׃pbVk4|k? H6E&j{Ο9׿<8-r_MTz0h@|cȐ:cՕ&P)JˋDmA,EƠ\I*B! r=8vs'R@NK ƹ*“BJ!JD\ i7^SWҖ-׭[wK80'Z s\O~62X',$Dt]tir'EW~ŸDqRVjT\x|lwT[hZvuSSWga)v\'#bR: 2$ pta-1I,X9(vب:잎5p}MVҊ:IOS4f k;,"Ymts΀0fEॱ/kMV؈큓5Z;{*t %:5>x]eufL̖V;) YE3nn*$SSS*Zu( !"&#WGKS2ݨ$Iv3*QA 8֞hJŌ3"KCC0 ØL0ᖝ}~hafшѶCr' m6u&m&MMplǶ$ɶF430m^{H#[5z9s^p˪ܕI-'ϝ>76tzt?x^>!Ѳ}cey.XBuXZz ?s{s Rr Hչ ($@HKAH0k}W׶UnmfxZ)ԷsϪkR~WN<%'i-i78JZZ&.#,E9Ltg%(<޶'V@j#y2CzgB] t<239pLZBWȩ??sǶRyer5!hBK&*ܰq "6|3ouĸɸB8 "ETZZ1$Ƹ*JqΔR!Z )(A@"ji|zqьe A!WT( 8gD^>aAK(ItEhEX\@L*fꎶB~əIҙP ߒu:+CcC$jszz Кr]eE@4W,MDM&!!1d(Na&*$G$0 cqDM' dpX0n؃?:16U,WKRi!DvA*qŮn@ʽ z"-mYm#?Vc hJp?W|Ǜ<_;TR0Nt 1CNRHd` E@eN?Edlg[Gv[ -nJ ZYE|ZqqS/ja-Hؙii::$ETjDgOkffg*QbufW(NkP) c6IЈK{Z`H\ .6_-ӑFd a8!>'#\bxeDΠe34&)Y" =w~ w< |{ܱ*H/ m\ {_] |KqfKEyw'oGbE`Qpff-Rz :&X@BX@9Lwq;_kԇ"S }s>0}cpfz B[:O=ʵ=fHLK8qRN FRbc?M8hHrbAdؚ8K}K gK7ϚqRC!lKBQ4G8HlM=NL2:+']]&1&P%jjs33g22@i߾Js带XHZT||ڕ&;[ *\FGIN9Rz̀1pCǎttON:t%0 (1)`rM{?^slw;I/EH(Z'Ǧ60O֪;JRrnvf#(dQQ(@I'u~!dv`lMffkne5մa-ÌI<~!=RF2'I-Ϝ9S*qʦƁzޮK}aKanPeY&   J@.~JreFo/eEՖI\ðW˵jI!),sTL;wm{ߺIoog[?W7 <~w'虳SW^ DSa1ڸnV/5M$5!cRR SY+_1RsuKJzH/~_Ro|kr^;X+ZˤAXe ֚f<%>h?2‹Vz)E'P@V&L6Eh (mX@b֒%`$&ũb Qq<5StنQevjܹ,Q ǖt&&eT82:213>WtJԓzJD@ 11&3UqjFسۘr& X) D`@Cb@ S0`:*'ha2'ΞNevo~Ýk7|q!j8t 704COZ&'K遡9"2g \v5[_w]ۉGtkaD4?W亹t{gh"䨴ä+C-mmW]!z>9=1??/h$\.ɽߺ{h`XL֪$@bC2N9N8رu6l\W -DT*Z[Ɗ%GU+}ms+xM+W㭯yO ydY7H \b) "\@$_8fbAb@e0$`fĘEf;thm|ݛozߕ3׬;x{v}˟?tЇ?DSDͺHpP͕?kskpԶmm0Tߺox'XٿCC"Y&\RO&wgkq5L,_RhO2TIe2}MW|,es9^/X%UL\|(,X *Q !@;֚z%.NGsL-όMfg"0XVututvv~3EIV,g8+ybα!,iȒ%@DMd,f 8JGx^V}RX^aG*JH8WdH qX6*' cXȈ-cLȨ\.8pV@G=v T$`f`2֎(|o(ʅ.wc{jV9]p'7_ $ ]ֽ{N$m[W\@ V $ wծt'';~~xO 9[/zR`"1iGl(sRb3;==9)+AdB]8qMg3d oX21w& |r(.u6dM$\Jaetv1oCg:= B@bR7z4R]ݱRlq e;޴qoN59ϽGoÞn!HG޳a ;  l]|Xre]m¦9R7PIX8$?t\_yGSHc "!V0L@#Z\fh])=έX+dN#ԁ'g'%7ӝ$9 Q(@qTm4`b` T*k뮭۷Uw#ђw[/v.6g1I ZYחmDK8J2d);!;W֍$aKgΎ:~}?<==ݽ♧aQ\ڜXȹs=lx ȗw2f"Ŷ !vE^|zi12֬Y~O3cu^Tm87-XB0FhH#͉P0;!0"]ZryyX Ch!Ngul6{kd_akV^q+n*Qy&iۆL&ѡS'&T\Jtrzu]+<'EIm̂:8uwuV*3N;otF/2n%%500 @ġ& Yn Zn9ygd wWd[N]Zp->T3p$֍-ΗKBW.:" 8qGΕ?3|Wzw2eA%DH@i} `0s|qշ {0'lLQb8s^אL>Rt)sӣ3Ɂs)/@ J2 tJ¹;$dqd Y֩D8,6q8K YaЀd7  8ɸFDim-y,s=HgX7rE?>snݶ5;6m}fƫG;篏vW1Ƣr KJKI fdRn{y-LOOjZW@ߝO+ά]38gKXzȎ.\XE@G_591Y,[[Z'&Jܢ4FȈЂjjwʈA11 a6\H!]GB5D;=̤]_nI^a"jt{\8\U+:rԾ0wjߧ*8Tr~^*W CYRqCC'x&I*$"`XwUk1(!|!oMT,I v#lTgRU+vٵik>ֲ[HR`^.KH ~i&c att@_Fq5looS+'K.xWtΞ>~1Wj5ٟM~*׭{^<Z"#PZ/Zm? Lӟuobrbt|i.$EMTҫk%gW5$c'dT Z˭b` f1 ttE_S<\sp zZƗZ""B)J @^9{2fӅT@[ߖW~wﻜTnjIΞ8|T"_G˴?~TڂfB/z Ft ,346e:sPKηtv9~;>XJL:~-B.y[ Bh9+Z.]<\I/~χh j+:{;O@(TG&$ђ!5<h(1F $mjs#çc5 Q&~k-7vSWwlwo<Χ@pHv<7\J42A[ vmX'ke8NE3zի+V˶t2D13O=L#ELFzJ%֬^}5:zUΕV3=;̡GxRVZu9BXfř ~Zζ+ӵqUL57t5i't!>G4ր4913=5d3ށg&']W$Lkη>}åjCGIl&:y$)tu=3q~O|Zrox[!4u  LLRCHr.oz~ݪtv -G'[fUg=]g3nCPHj}',K@t]/ |@!N@ Ȁ%h5-܋g'iClDQ Fpk0 u ՗uZX@ MM wN 8q,5hUT1.2 Riu]+=;J#o|ӟz_o=D%Z!hZLu"Z1DG0`@ٱS׾ A zbpc\͕hmMPUH"KtTp{/zv^3Bw{ X:ICNik{@`!Yk"JI `,p k噱O=%`V}W]׵eφlcrUb.$1vRJ!`05=L>#z[^_ܗHY"R]M9im{uKCgϟ M0$xCڛ{kzm֎#G\]u!G)t5=joVT'SԚMMZ g YZ-Eʄn6?dqHs}Zr)Tjy;;3NO !kA>vw=8|x|\[;1=}̙Ǐ=V[e8ѩrO=HOOgqvޫVܗqӆw p!eWƌLr\ǤRWAֺ4{sߕ{m2Ƹ[V>|(r&>?}H#0V5d~52QXwil(R*Ƌ T/u9wDd"C*)g B[*Wp%" Wnx7vlk_VI_O<PD̝x*BQFi,=^'ۖnIn/=O}vpx0Q8_}\}hf01\> G(`#홮]uUתrfz]4$V0kiT&\1Aa0,L]}Wo\UtP0d8̒A$.EQd u O2)Vtu?Scc30*H!뵺%C*KRJIYɉ#"$"48s20#ax,0Ɖ 秅MJEof wt{0GRqnCo'NAٷm>9x=}؉3G'y߆r51rX gfA d}U{M5l9ũƪJ<7>ѻa56z!m1v}}EklimQڨwu4lGgbРE H`5> #&c"TcS&Rprbԙ#8fDƂBKޖ5b?k }n:` Y6\\t"֕V|rX (fJ3] N0V `g?z[mo}[nNH*`r?TvݤxjxK;;0tr&--?{k~ݽ7!@-E`(&N岉RFu])$Yd__e:u8Ow5Z|ܹ5g3?G``,s2Vk&J\khy_aE185 R*6:#ezUx\kEZ@2nh2MҙhW-:iI߳xqqiv F42D[Hbꦛٲy]Z)$>tj/oɷسSX̾ 'etXWgާ+j|x#YG0`PXag8&2*[7¢;<O;w|4"%Alٲo~]>~{nھU=#i/xia#`l CGdX @b._x=V~J<>11=?ȖR`P180mn!k ;Vk-a԰ h%wcc(1\mKŗӏ@6҉Ztg0;_NdxxEQT==y7ܰo5W^t&j^Jy{쉁#NMy3y7;3\{bzbzvzw}ndvtbwEw``<Nj㘣 CQ~z+\}Wp `g>ٙYԎ\sd&  m8g P#-"" ئ#@c܇Mdib 8r rk LXL"!O B 0>R`c>"wwyme8?7W?HO_׾m,3CvM#`6H)K 4q`3̓owx`-3ItbbLG$:ar[$uMZRK 2u0N\,ORiˡq9CDeTGkjȸ0?3zQ\@ȭ^[;ޙnˬ޼j.kVR#MS" e酯" Cp|HMyOz׿<41[3(B@{Z4-}pkׯ1k.8aAQYgH &v2]9ڻPZ<:6']<{z ga)4GqZ)鸉50 lufG(3WKGlݺ9QtyU+{:A7[ ??+>qpi4r~#CÉ6I>'|=MOpuz;&Νﻪ6382XJ1K Ѱ-A{K_;,C.!]W'UI+~6a !g\c]p1(H7lADI=,O2S"2ST"À ٻbttOL|S=%\.ѭ|PK{}#\X 4k#Lqf6]cEoou߻?O~Sc+ӧwiKcˉ@-Y0Ƞg*ZMN=vvTKm|jj3*Du 4 8XAS<2d 4WwMc7nvUmm{okAέwR&YaHڄ3%b )ʇqlFPt_vI֫(8q2p|xdvz\ H'r[o{omWYͷGF)q)2rY= z3(EۦwS>3ӳ;no)<3s3lgKBQ9#l"^8}Rۏ1;[87V.TP8^U} )$ EbZ@X3֌ϔpM`uk;gg&3i?|ݾuL ;;AuW_sԴ#'ЁWojjzO=R lf͚o]wu7]}+jDI_ڄd1`gh`,X B4d](hA^l !?ЏKF~ŗ*Kd+(]Ԃtfnrs'ٳfPm,0@$\Yy[wm&x4pvpbbPZƦEh%)Ym )z$! #C`\ 6s7UɎ:seR^)gxW]g@WOV/z:VD1-;yzڵCC'hͪ5gO>:#~6=:1p:Ӓo08dAJOH q!ƙt]qLS.%D1H F$j&i1_ڈ4cbX@ )ƫ[pTi{D.OD3;z&lP[$ꆽ#ck=u4_?~o#b21 B`},T-:2C'Kѯ}ڸb}@F iD`il-<1W Ow]}SccNU``dsBm*UfLD-.)'#0s_.Fs!r,Nӎۜ-Ks@@sܦƿٔY,NdjO?ϳb-qڀ4x2Ik'g:rCfFcZgE9CGh;ut%tZ]'ĵ1!c@PN%pIR'ə9#_KXߵmU+{zz:V5m/^'x_ Oяˆc޵״GM\\ E_5d`@"/5mݝzLtN<537>}7Q&l핀,wRs$<VeU|;^_THLWR\mbjle$bG9sHClQ1Fd@x<͵]uw'R{np]ǓnJ%7Ł]Q^k,})T^\~Y^TOy!7*&ccD.ni+JgOZEDW^cϕ{L6hkm"=y^tǏ}I1"VS,\5DMʪ83x3K;N9yT&ֽ=_h\gGe;wkW/ a^gW۶qlxeeߵ׽K`c-$(O˜r.3O% o~m8Op DF@]g;$^3a^"u4 `uuEfBkg=R\_ߙ_Ѳel_$o>W7%\D ҚE<7t:16n^3;}c+ssm-`)K_zΜz#hJu~f0iIr9j cs'Q Gf9A-2pG+e&JsS_#I\v淾R/q{8rB>dϜ9W\y=w?CXH:sADVY}a5`P `M\s^,颻jh4puS\K wC !@: tDI,SK3$Dzw'??wwK2Z(T@tPڼeOH l\ N=stv⨴b-],72* rƵ6'р!]*:\n<` e\<*h ۮعk:<̴>0Qb<d,6 2v1?3:\J(TEGr}̩3a\ |+Q$95tU mmn7&IOCW_ktd7n[%!DKfR펛|ԑCaA&'w[C E˧eӎ#G'36 ==m<>?U2 dE޾swMW^-omV)}HTM0rI0!f ۣW圓Zk@p$Vf-Z󃃤c1VYHϿrru*K-ɱX:㘌-f &ޮko:Njw]W&p乔\qgCB{ǽ~ȾޡD0X{LZȘXH!`ڢ!=stMg:vt^ ұ7mYtj-[7jU_T~M٥MHqkՆI&ΛFI@!􂀆*H#i@dIb.NKUS3DDg_s "( !S8>ẞi{:фp&&O-@I!Z$ ʶ_%Uɼ3*G8,N7_&_(8"#׬ٺ.k&Kg-D T@&.ȌL]~Ū]_tbL3Ϭ_OU iP@FL`_HwBjYF82􃀕J G;l&Xa֭k}Or=X/.ͅndkc(ZKeYe-Yd-J"h'i P`,|k{pɢ"] i|bk>B%Xv<}t˿׿k(35J% rL[MI<31V]+8\7??Gz#}# BMD yI:?< 3KD|%6\0kEO[ ٹin?v陱T-Wgfۯw;w[JuZ6:ڪ.ׇz-$LH)4/}6BD7YI+uDs"P*L1 m\e,tS|+qn߾ydxgo6_,s=7rsOO55S7k+4`48NKP0ؒ;ũX$@K($"kF Tϓ25"/Cq/V,% @QDFqCbi vX0T6J ~9`8c25عzUWʗV]]+[V1Ɛ=oJg`LYq)'`ךъ?}L*[o˶;X}~*ۓpn\0GDUΠk&D?6rWJZ)8CzՖ(qRvǑH}{w1`wumٴ~]'0_ȸUdJŹVmo V b@^ H"8R]k׀GA_KU$*P2cްO}omشnˑg|׵zuϽZoʙX2*2!KH#.Fix5.K!9g AV$Isύ8/eFBB1n, c((|Lz-\moWcGOIիW^J)} 9ql R)?ͤ9O}w&&lRd®Yr>PtR0 l3rBBVc#B N+\z>K~^ &x>/GTa8;5ʵA`)GOCO^Vk%F6B>c7Hה:{f_keSRJ \&jRFʵ՞̲@L*3pjnu5V9/UEy8mhD"H]+kCZBW8EW7H롔 geȦ|oi鮇'4f$椨V*N0TsUWhUe$BOkJ)*ǂRH'zVvtq&ƇgҙtKD I"aԬS`"WO5ڻo>wfhͷ\?_yʯ-L6͟Qf% 30VU=(dcBڐRʨKCj HYc -Ȅpƚ8NdsG?o>`Z;I 5`S$g"LREOIC)Ň?+aX圢8_Nmܸo),$0jhw>_N c1d.lIDp c\݋XVfg2}+6d\ãc;,]$. <Жc'(i[Шy9jt=ZY$м=_TiVX62_+U+]+zD@%1 mY7] ` |d6;SVQ36O|G4E 6{;6ժQ됱 H)E([ q,MSEP)[?%G 0hX:ۼcexӒ=7aNjxT hoeˍ7;3=4m~e=Q7GR}ڨ^ʶwFLb_.,.2ȃ(֪o?y>S 3`i$)&82Y`yNaqE8:I>x5x{⸴c CtED57.@GKAzdQڳ-^ѳZ !1^=7\W2yoЦ] ([D%3JGbc#m&Qh.免`}$qretޛ.V+B5ו&|U" \)':B;d1 GkK-,0Bn~$)`Flh'Ѻwe:UgOx{DHZUKd׎-al2q&T8wQ9nB|5Bq8ayv~šNr="fLkz@3NV<%z7J d-MbÜo.Ei|[nB=ӻ2+;_wxheq[[wGGW͙#Oz^Rjf2geCD$ ljv x|_;ᣟ J4Yk .Bq2nC uAI3&|?';Nd3MǫtuI&k@1&E̤"/瑨,6P!ұr!֮60 =ϋsED 吖V#47-uv~S"$['0ȀQtƿfߕDĀX|& 6c@0CC@32I6tuHWrߕRBpmiB V'$j#!:LZp]8jotE /! T^#|\Z |hg'O ;"% Ѻ}m(*JւV,Rz Kb  bDZ*GIHilnV\3 ,z,pj_nj(᷾QsKz4Woo'dggv퓓+]Z-O3ʥә\&Wd(;lcmןٙG?{#E'p yF5 aɑj ")&M3d2,c s)H2&)L=L'>ޙ^w~toU ,xt 3WܑZc !!Θt$znXQB* hgj Dds!F/bQu"P4TƝZ->wsvlHE7[@c•{oOQb|1F"#ۤ]VSFǚ ֖5`􌥎\=:@(Cd#MT`w+yp(b!yqcŮ>ΝsB8q*}3`qt2n=7\CTZy^@vv!v] 5Ӗ|W0R dL}Z0֎ kT56*q|4%`)@K@`l`<%zugwԝqVní׮7=51;oؾ}ffgHq׽wֳS?LBxh5G?}Hi@Ap0_pFj֬۲e$&η=W>ӧϜrϞή٩uwu;R!1Xy#1wON[c5تӵjuD5jrinSomݳ}]I $Z ҩNE~Is=iАE$YXу}8XxB`52 IY8c<$*qǒ%D\zӧN :|왡i\N w̴6Cg0g6_?u|WGwޚ:ܩjq/5Gi/ԔԱb)m?[^'Փ5niHCxsqq)ʕ aRG8-ߥ,@[B+2?_ 3N0^=^rgX`hY>OvSٓG$ZDvx~u h# `x|q&1E|8G"ka'V1,bBc@Z'$!s!B"* 2`z0+ y`X dd`z- YKU-}?Ȯ s&$ ApO9 ? .^[=qV 8f7mwcugOwv.z6!M @;,b]o --X*ZZ'OtY#)!L{{޽{RrfQHBU*J#J,G8Y C񇺪DN1|=z5']%@QkZ'ƋSB}jBZ˥sӣ)`> @@|v㦍-qRe[]ǕC0I4.4g{i"k'_HDĆP([PDdqx*I6\VGI!эG1J_+Uga9+Mϥ(3ǑJ%-^\,)2>:)&%p"]kzt8RfIĒå5D@#׳bŪm۶}7yfpZ #x`a 4-G4R%3&u8SZ8~(Ǟxhpll"Ƙ`U:H=/r3B5Fh/t{Aѳ7{䑧r/0P-C [mr`εQS[TOƪB2Ai~dꮟnڸʚ1wg>wZ9"q<99A'Nr0C\-$ {%`!1Fp#k,'_Y<˒ϚsrXfjNzL[/ Hׅt'1>1EUOG??QEa0ɩ3gЮC\A t_h$v]87-@Bצ_뿑u1H)%}Oo~=`% 3cm50N/U/lOyPp$ZV|crLv0`VUQ&=Bqiyl>)?"uw8t;>R.[M`z67wrc+W(,s-YfEyEK_9cږMᳳzg ! ݹc5='<$h5sIJWE.<Z" @N*b.TPݬߝ:|howW.9w7'$)+_7[[3f\6WҶZY`3,%,WxSfga' M`QX!%4+ɘFNʒ,!rH#B dRцjZ-ˎfYqHE@d"roǞ|i`@ eH "b77O.ZpB1-5_H\~Z#zGQIȈ}ny8I٘g(5!K]D:jCV63t._f6I>}ݝw\{5-=d,̖k'''+ŨZ* O%Z[_n%{ߔI9MD24rR+s2RJ+X69z oF[kV\:F\B 01`:QdMǩ  ͲY!g]EXk8JWj6R, fʼngWA6jx~ԁb f s|q=v nب-[61FBXc!"u|DhC.h.ju# @M.4 8rjiT0,O< -"f]'=7?(~ƫ\w磌_'e[%C!X$5'6t.[ηy881zHT16`$l5Oץ<195U .9l0ɁscO 7d(ML':&[3PWլь!p&^_{~uCg'?O·U\N!-%QJJDDt\RĚ뺄\iU.UFI%ղ~z(0]n Z(J;40o|sͶ E>!$jBӲTRcZ׮VRմl޼AOǹ ҩju#;6mٽm˰񉱹}}=7 N*ɂaۙq-k|n-ΕʧO*wv "p˷I$-`ՐaQ9j'xo KI) `8&O/JRH0Juvukɘ] ώ k_]]F"ZKZsWV(d|>_nmWuLWxzwSb`pL}o,L-29Ln䩳g3ÓSlzn~&ߒٺuիcSl69%نâ2]Am%Xc -e'¥36"K-~Yد475ˁ!хz#)202.8/}~i5Jh5o劵z{:zz;VbՙXxւ `|Dq9_̛pf&!K+v][yaCAV;_zwC#!c,Cilu2N־+s'Y61S ֡芴D+];?t~kl[nE@Dg-nk35qaZh%"Z<-9Po_֭[\ש*L42<5|݋J&ڀ"6_pYQCK{mHm&M$Iv<ףjuFYttuL׎:RI[SO=l[VkpN&IK:=sgO܌2Jp2e泜+3_ڻ ! RI1nPe ;/l޵>~ w}eW0 t92 =3Q'QFs!kd|u01GJ;;WID@y;߶iݚG ;_iC1qNԠ^S'+\k["r97qYpIDYb_}/†˸k Dյk(9tpf'MEJk8BkefS1L&;7["< VZf`H˦EJH/c6ۜH5i)\z[ C~ƙrҎ i/7*#'$7dYU@ G|c/|iAK 2Z0AX Pbk#C zhhAוqoٴ9(2twO;+h@!̒ee3;&$ #+|>/x_eq^(ttT ($w@efK< @10d_yϼO=9069xa) f8c ^%2tvtbԙC`)ɰԝ5w86><66(o m [_n!צzWg8Rh7=D6MM{zJUgDHDF[dT*>{X>_B"U 9kM֬6̕\s\F+͑k:s7`, Aa|CzyXje's U~048K$ ־#Dz֍S!ĵ_A!"&Z~s) LhcD.gpdȐ€h8xT^\lذʫvidl6W@fHVYC\HQ) .P4˳]q26?'0ς{gΟOwPVK 8`G[ϵ⺾<% 0h@CY:|xeFo3tn[ǟOlhu绌1Ƙ5RGe!\l* o|㝮':81=A6B BH!JT8="d0z{u6a eLK^Cd/چ(3s h\>N(.q=LX$d\@3m6Z?.Ҋ4G|CzkkXgGpuxh6H{zTr(VNjjr~fr.* j'9]/ERC'![Z%ĥb2 ĩlՀ1O|$DvU{^.ٴa]K6]) cHDL`92Q aYc udDDg?n2o< i|KQ0rW_koޗnO&稳`ճ;8 /"Pb69|[?ggYd4DZ Z|mB97uulGwnO>i#1=G!wE9;><AUﶵ۷zssF~>#+ZU7 Qr9z:xnn~vn/ir!#DHT *fH .&d0Y$I؎:qqI{Jd S9 T\*Wd󹎔}}J\XZ~@ק'F'΢0ĀBnb[jF~ Z8qG豱iMJ ?:80>;7+~5iWp;wsYdޖͶ"#CI:哥,H!G\颾/ !5(0"dž Q)[\o|ZCTP. 2][F6r ^rˆsJ&CJ0I~Íy}Z F _s +.g fhRe?, 2΃ lD9 MT=#) WcMԟ|UWoݽ{^WE߷[s.DghBpgT.2R2$ ka*\immw=ϖ^ˍ߳u'_ϡ0%#$eHtcifizgR  O)u{{ǖ+ct%hucı_gji{_\ywγݱHqH-l"DZuI&v:ӯ 4dmҸu4^$eNj$ۤ,RmQ") w}g9\(R$mE?3`ys?FD8L=oPɼ%l͊,#U|%I-/o!SO!3pC5 1H$41gfby ѕwu3#cKd,j3/ſ7,XǕ+ 䁩};"F@c%l Ff_'gOǞVU A`sO/~3R\S\c`aL,]]zNY$*+sEU@'3ќx,R: (xdf`@0.61Gg_xx !7;_J,LBAjQ :|[y h[6xW6x/t×^:7t[cgZǶ*#T6s)8Z=9u ZL(mxb"{sq[h7ACpRV7uo8{%vyZYҺE3:UL(rk׻=bdB|<WL`L:o7핎,gDZ '@aB=oe3xx;_zgϜ`j(2#8ud)Ocn4jB!7*%VLo!٪Wվ5Be9~_}'?>54gۊ|gMچwGNTXvTlL^U4#.,w)!e%E(љ0U n6ڇz%ftTdDȄ{ 1I}qR@O~baaK%eYU^#ڋ'˿إ1DA2( GZ%!x@qB u(J2fXwW!W`t0x v]V4P"T||ўHJV{&JړsYfž=壇~^WL?{g~FZBp@[-Q7[ <-xCqdk~?yr%[{"ӣGDZQJ[M)I\wx DJ/~K_3Ic$"Z7>g["kv1ŝ#rᛲK#?'{W_PB"$.]_^y9qBx#cxOs.%|; xb%b0:EeSaSGZw|ısΜ{hO~#vc %ZW%R1xC@,*QE^e̕ @(P=yIRD1F%ۿ `inS}#2Ɛ\Y, Ui܈ZzJS3LzgsݍTk!+({xChB,Y)DA_DQ?oʼnɲ~gyCD\ɅzR, $@aZ6YPcTќ OIk+P4i+We_Jڛ{{N| j4ss?}my `08}4"jdVgR &&zJ8VE15{ܹR%mi# ) @ql$FB.COTچs>Mc!$>P22Q1x=*@鿱Fw TB YPV.v{nq5Itl} `iū7+FrpF-JwmQU`Jh\it?6~peW ~O )GmDE?;ӟ\JDZH{_|3jGW]:P5h%ǚԺJ@  Lb=83 `&JbSq=ƒh)MӲ,GQd_`8Η"ouޥ八ݮ̄(BRj`bD&6PVruY 3&iR_ȤVUvsc:-\IDU/հYy7xV[ |ӭ~>Cs&ocR>T P 50+Pkk@7SP4 Lzmd;pPx?0wkb`1 `ş~?/qYLH_|ɓr)\IDʊь>3gPޛMFBUFQZ p!^o%Ru |O%#" >Oy^+c``lpR|;)x"=0uxVFb*.1 NNg2x<U5xmck46HBmP!Fwb# tjofd`, XUʘ(Ic!Ҡ;vpՊ"9l5 B%xXخ ٷ)O⹗rۤ?$/Qo&X$a`fl#L/_|…(rRkO7Dɸ,@IEĈ$IH"jbR:cEqA3‘# 3d0m䠪Ԇ PD=*( :#cD(] N)j0)v]00!qpq;WJyER5[-3kz6ʊ~ohmzYJ01X)U׿ꫛk_ʗCFHძ^w^ayRbE~3l6&Wz|\][O_ETe(=@|AR!"vuk_JP#(bZ>[O}h6`hx3M$o{ QY•럘e3QI9uI ,:d,BPyOB*5 ӕJ)[V6wa8 jV+rAdf^]__;#`uLyVfYW%!4>ȀJ*!63iuxw\Z-D `URjE:2W^ @BLNM0E2F)t~:f"^{|r!D_iLs0pʩ#PIci*Txvhj 4/͗8 [T,pϷEw  o v7366B`8RZDѨp}Sǒ$Fe<Z.\|cʂDQԞk&fU]HBHxbz.h?19ŕiԛxR$mҖ teXIY L\Ŕ[iJU9~f,uC &2bJV݅uBRbVFمƓg 24 /[>MLMFP0G"u`Vz7v߭ƺL9fr9f!2N"D 3f:!OkA8P0l۵o ~-&: ؘ|Saӝ}ɉ0 ?xG玮nt7{KKzQ6*QٔL_v@^P[=|/oV|]](V__Z J(ǏG?~g;87U@yY~5 GuW6{v`ADhwIEb;81wo3@(|?_V-N;`ܱF b+mfcko\~c4(VqYU4J-A}3ffQ x'HBfBRL! E%Q;YZH%Ȃ՘pޛʝ?އCWP*/Js.c{x镟,*D.V+#8j BTkz2gUm]vLl.x˩"@UnkzP^<91Z @`q;,F8i4;+kn!ZfpԨVpKmGŒ%@+#TפneoAb!q{ilv"jI?tYD]A+&ʢVF;M" N{n.n:t҂Վ=wCݿ]KU#7.OOKƟSkg A@YfW [.//;v ݕی" D[VRVK(^<.wy պ "`m%@&(F,%q"àŤ(1$UUuk;WuAH)Z^@Qi|eji,\7[k$n{[#Q\$cDJ(+նe+- ^SqSwdn!{f6wz 3v %6zS3eזօP!2e~oEq3YWt*( <ٲviq(of|ޓF_>d)|U(#U)Rvsj3dxQE.M@vVO.y}:5g @*/"tѼ`T(XVWӬ}xq_5Dԩ?>{ (ʲ,KTeQmA 'GcEPR[y4 կ]x1.fn|%$Hj6ʆE>@A0{栴&#@4Q,En7uJ Ԩ2D2RR}FpʨU93;I-;3./.FQ475f]U6VkR-μxj]!{&  @5w5`a&@(C y a@DBzseh/*@jHZG'?~ɩ+o;^Ek$^_ZiHʊ+4]ȷBىNH}W^=~T y+%@H>8gΞ={m H Ѩ;vbffި9W\|i}cu# VO4g!!Л:Pv"-((jI_??Ke^?S>t0YPs.9 & 66FV<jiƑ߫*sȳ2/q)ʪ++vL"`c&oL$,2Z-y+% XEFw~fߤQI 7:B^--%V*'#w~!hmm#Sśrf~fGdP GN<$Lkw7fYBԙ8A7_y\ϿT%e,(cwex"MkWWׂ:ըՒXEjk3<Է{\@"ԏ{>l-E:#. CP䣟ϼ*$"BF._zcQGf #|Ǟγg0 DȮ^[yo(NɊ>}41K6/;7\$H!02E(""CGDZ6cfuxc >ZEemYS_ה<~K}#Vq|vۇ"χz\6vg3+K}3mX*Ȕٰ0"ɆF^k;(0xȈBE$w( QgcLJ;LI.3ONNI.-LNN4 fS'OILy {"x?3Ӱ%2W¥WdEJ+ 뽾'eT/^To4 !ʒZoЏҤ35QoR+>+.;zlp@T16(8'yvX:_3#l<~_{}glƲ1 4!yRKnVZyL{GZ;3OSUySG>'k#֊X].<> P[̌2 }0&#Fv* `4L΃M݈)2)Gg1?&h9_;q;1v kϓdfSjvO@cdv>ih܈vDd2L&d.d2L&\?6L&d2̅ "If|Tb"ܲ+/Cl\gL(o+ox۸5i:ߕsW$m͔ d2L&pț@L&d2ǶhFLl8G8Q]޻qN;7ivi5us}Vˤ&K{XD "|p ;NJN={\@8IiL383|lF&Mf=M8 ^09}cOqrӞ5g~6~G[4fN(N씎7X7')B4vDҤq:<6Ζ)t6i 8rcMfڤzm/m`Z &Cx|##_N6opLpjek9aOqki}~3&/b/n 9mvl<ٔarC?M>'lfK5!d2L&d."\qd2L&d."`8n$ئS~u NJ1e7$d2Lf`jc#D3bY6]W`6tÿw㢬Gg2L&d2Ld2L&dpЈ_n?+ekQ]ɪO)_tg/m: [;Ievyrne;6Wcv@ b)\sK}dZ8`\Ф~ 3~73K3) n|1M0N_|~:Bg[w1fӸK:a5ej:m|ͭAZa; ??vhW-e'ۆ ]i.d6}6ffHo~ulddOknZ)MlRk0j0ppqu5;-Fb*hk&-77qΦ ۟`a:Os2L&d2d2L&d2`S3L&d2md2L&d2d2L&d29(hU¶'MǶli.Wi[Mө)G9puuΪmG~-8ηmuzϴ8 hf]IްM M pޘN}6{'ki)tO´A&oO+f2nN.Vqĺ[d_]6Ι;MۉzLNѴ޶WLlv}3/AmJw, nQ ^oӝ&ЇU6'U7y \6lz?b= >2N i ]ՅbhlilBL&d2L"";L&d2LK?d2L& 7A&d2L&L&d2L&gtG:,4F:ۦ6Mܠ)Vm?"I/ulOYo1l t>ڂ=2{iuv:WiL1lPbKJszlMsxb鑍@s26~lMׅ\= ?LC;؆yvfu|< } Ml)}/?iAO/%lQyv2;t9]ߘ;yi'܏ݒE&-k\fM&=u{pC~11H f d2L&@&d2L&L&d2L&L&d2L&L&d2L&ydAyZɺ6rYיx06~2=﵍>kc$ipz6ڜf8劧2#w.Ӊ !֚EQ`dkn@Cӌc.؞]&܏c2fM$l{.< ]IiKW3P!/ ) "RtjoP.z8!d6Ű8Mld d.p̌E( ͯ9 ;D٭d2d2;3 DpE#P!if2Lv2L&1 䜀D4v)+D? gd2d2h@Xԏ'(A Fs~!"M8os#3L&;L&9SO9N0|d2\8\g}tWL{'~~)7ih/'4Pmyb_jn7uOrr&ɜ0V3S7nHWDӰ;noٴswMH:Ct4I)J_T͔El;b"U8 ĜI354I˳p4U&)<59Ī*oeW`bx}~K&s94gI mNh)y>mE&.=6Ko;e6mXoXz`23KY'Xn͈3?2 002sK0҆MXęj`t].pj*)̓v6߰gf*wtqt&F-~ٺ[foM~d`m4O.ioMPOs+a|>͡7H قsZ:vp3M4d2L&\Dd d2L&@&l 33#fzbH@L&d d2L&d d2L&d`ɺqFoH U6FRSla{jNV! UNi^)6'c0IAbg0b:մZ;pU/F^,aIeųMՅݶF)s:6YLGZ3_K6Zޓ "ndɶ$٨FS0rCE<.2M6tU% z LܺbYfҙ؂7[;1 g>^&/;lx6ھaԀěs55j.lyz׫6x&X'/;9_j.m\d2Y ͟hlf2LfL&9{ *cd2d2̆0 ,v&TI$BJ fd"f92d2d2s |э{wX]:&0ʪ5w!d2d2N`3Iϰc+JI׉WHL&d 9_ژ۔sbi u_'JgهSl` E9;3O90Kii9]DŽVͦ딌ցhTs:YxaѠzQ?ΊLuZiɽ%:49'O[g[giz7 چ&L lfnh ~mҁ&wܯB "0u. f3Ȉפsm3glnu9;0-M߼6.yrd''mUF'oE4Ff(!mΒ<mZ[6^@iZ7_,Z4Fy&y|d2[mRx5LUGYRźoP^P3L&@&ɜ5̘5 K"ڏ2 L&d d.D`,t;~ѺL&d d2XսT#(Q2g(i©R;L&d dv,hckg5d2d2c ]x[``6+SK̮}fK/?ifG jT/~o7khmzj zeǠ vҼ9pOE%ͷm J!i]gqO[L4iKW{0q5?m[x@nD@ 5Q@mcq`oSkkFܻA5&lL>jP$7iw/MΫ'Ξ%Coe 3ުFFn9'ن t,@DlǼ^=37XsLcYpۮ1vāggIƍ$;L&lO$Q-&$5d2Gv2L& k~]QU]:F|dd.jLIY,i8II00"BfPM"We$ۋKߞϦrBV<3Lv2L&`@x0Hb=L&L&d.0ȈU`PR3D%>L&;ۑ:v<*w9mSDFF%2;IlhMw[ɗ:W} IXdn`h-*::ٿ D].1?M< gZ]dNYǷbeޞ3PpuF˹8QoN`cF6w=:*Ul0rAĵ@րh0cQ2粓:V[ey=1els"*d&Ȩ FGdFdܳ@aUf d`c1& }L][:DTUD='O0^o7lc' VS磬Ru^1bkn-lCch3hL.9=3 23"fvR"M7CAhozCh3sloR1핈>@6JS }ߥ_^Ql[(6گ!#2tshChCۂǦLLfO9B R\L@نk&R DJ\ɐ Iqӟdv>dvE)+`!s9_jbE__6*['jILe;'0"Pg2d2".XM9Ȍs) 3Ř}=@!3)3[@?쥡2D͚uFL@RjTR3b8t򳛙M}Fz1AѿMbP3wob"жu힉̒lZD @&*ӢQZDɁ $\J>:ƌS$"ffaM~abj춓*A& d?"j Cg^Sz?lݟ+,[B'<莖 FRru-]k1 \s.㉔:^x&X&Qm/ۜѰam9 f7Qv?1 ,4!F=R &fQfgSl0Ft'kBqK@5:r^|IםM|Z᪎68HFS3oˏLZ@Ns oMmsŘn}˯xق%3VOO֓H ʙbRi+A^pKA+cwsRс^$1Ba߽Mֶ|Z,LuVEnAD",N(Đb"P<ղ,(cJ޻/}L8. C]$*ES9ӆ`)F2H uEbdH<"!B\Q(D`9S#?n A!f*ۭYءI 03=I{7NMG On4a<<39[9OSHrVx֨E2 40@Xbf4@hsi_3He`kqb|9:L>YH \$M5Q2n3 h8\?&|Zz1/uhvBL,z.ȗ>"|Y:bJ`3,S+ۭ"%Y ,٪*B0SS ;),Ƽ" @.^à4 ԚlR*i"jZ]fgf;VR~U";_juE:ffhP u r\ZU83K)E@JQXlttkqum:4i L$%=2C](g,\b(bpN d#*'d|Cײ- *dvJd`E)(zFHH8\w(LEPՔb >Rb!=  /5,$pf,$D,d S$ R"QSX6RB"LSJHQMYSJs1EqfBVK⪳ "K!P30iR-[;A! 1Z)Bpї uח8'"!D&,TQC?!0@F4)P6|4neG8 )(&Pi D"{Ա im2K&@&-FՅL@p^` c $Vb/pq9'mк?yJJfd`ZiTk͔I5jZV˒43dI,jteN _&rރlq#&W󜒲B TWWp!&%jD\Q@XZkf?"RYz*Zm&v,b 1Enϴ,UjV8b7:BMiLAyB:+f&yRJ!‰9)<4:0靖>ٌo n~ @)v9ت- A d lof+Ҵ@2k\9Ɯ"HSgbK #qm&=m߆'jX1B#`f"Sf1PMUU1sBDɒʅ9KK,zu%!A&3n,(w)4Q{a6f1HّIS5P I#@ VZ`Sm%Hv MPaOIAE+`ԑŤ,!'T53X4yձ4ŦƜ(εqi¾(&T5a1kc)Ƙz΄=@g(,6]BXMB^&U3JNC*XrL48{ gfўڳ fJrh,uÌ(f&y~dd6 jU9E\ 8̔B\؆\?P3' $DBs^n.R"NfieE c-YRJ\vhR( e!DXD<9v:DT%J'ucmn5r/2: ըt%H`|Y`f8ei,|q_\B`M5 &N`jԉ4e,,kJ=PI5w4&1Ab42H/8&IP:ŔR󨃥HB58蕿fVCڔ(3M< Fe[ԧwz0H @8HE+uQdd6)FP2_J4K(ƮY5*MqQf M m ) Cl~H1k Ӛ{"93E,I#PҒ0 R(KJTSTw@jvEQcLA*%&P>v{FDdek$qOixm4Z@ zF?{6֗4$NzM9cg"')Nf$^a(AR4q,`ɱ%( K4E##!:5*IL,IJԔs-S 5$dfM{1upQ*MZ)V 5:N j  CZ+j:o&@f Q M@c$ !0[&E+hE R ]}sHV6MWON}NmI I;1&Q1vLZX@R@fRo/9&5%a)=T^UU]R%1TT!{H[m4=1X-F033bEa<]Q}ʬ)`֋ Ta(3k ,yNa bDFДRiRbS31Ʉ( މw)1YӉ"L}ӔzX<9R'Nbh4™\:踭gBľk$.ARy4pV>Mdwe3:3j'9掚hSdܡ6}NtMӪcff8 ghB XOv'lfFZ=7'1cwnjlk޹NـY%6W3Kkv5&m[1"*ʒ`A [m`9orK(ZD&r=M}11 H CmYJ\-?2eɱi/j,Mg?V iQB "&&vԯ\}w(A%fZ][q F5!:ԗjR{*$MYH5qbX#YvU z)$9ꠦpL j0b& qӹzUK=o'b0kP$RVH5 Q-jP~<&)Й>oX\4d ɜhu\]2("mrAٚ$|O$uķʪ꒐V5kAJHj&h¬1RJ5! "C{JbCUg0b =;} o~d4isbDMFi3 tx ՞yDJ05/<"ա5;cL@&vNCB-9B]/@j@$,LbԄ+`tC r?4t*@u)жT1,f2c@&s bU/ "v 0,Yj ʎ:VuhlIJ49.!,NTL89qQjPZbFRlsς\gN!Дnb㝘M:#L%M v$+l =\saFDĄ,/V B+3o8DS$"=1IUM0 d0^&WpZƦZ̈0jY {L&;LfrLm`)IgmKD_61#lLvN )<4pXE] Ks`hL۠d'*0KmJ⼸i Z'cMe*:;Y5Ɲ:u  ,C쐁m 25?tꀡ&zS'Tw dpM"⚶L0Ue8Y]fjTTVhBXU!EQK@3hvs.4h4-h@Rea kKϠ~š״ 9emYo!@&6G%S49o>nbtJ?( BBU睔%^tQ`4 #FPf=̞;0ޠ#Bg}u:OM1ehCvܷ 胙iu{* #Sm!I#fĉ7x=P);iH)+|h*HF2ZtӅ5ha%QT"5R܆7R bwd2d2'[Xja~(LSQ5}:W!V Sajk78rRd 0mj!{6xC!S~K7Õ:c&CIѹmObhtp Ф*) Z0U @NӔd MQyXJuLqfvλn;;7Wub,,z!YRM0![΃(=9IZJbt%"g곖#h0@FN2EmdhH-{(A Z_ քv۠TԳmW ~BD1{Y,֘I!{0ڡc(Z B&|ʹo>b@';e)D6M{U6i}?MӕQ;6N=u95#7~.hMUU;a Ge)%epvqHVR 1x ͚iYcޯK6f/Rl.45y"V瘒p)׳^v&y~n,33۳a֟{Y9hHaCc|)Kvq*%b1+0/~ Ѭ- 鉎Zg!l{:TnE5YR-Ty,PFGZLSJRBEkF cX.-'_x{v,Ldf J#-OHd…&36wcÑPϰOvJ`mLViUH6S' !aV#3EO/D%!'^5ҵ-'3n/Ux&(H9ue,8vLT 5aPSSF"LabAWI mv\h=M_j"!CZcO6^wFtu2 O٪##Wz;@}#cc%6-aP0-Pj^5)m'ZF F6u^DN TU,B€BȨ `ʎILj֘b`RMIllJgYyvwKI?؆Fm>+[ &;IA`a 2Vˢ3{DMQ!gnHġ`׼ft[h-[=hmt5#0h潵1ܡ*ZLl!QE3Ӑs2;hC\85 L0aڿl~$bך'?j*KEN5_91h^,&~|,cVc,6sR `0q7&PMLDUc̅p dаF? mnjJ5I1bJNWYb]wUhw)T-p^<[ح1596XL&EdiU4-qڭ, gZ3kdlbR Ȇ*)1 #E5I-Lkj(5Q:qP5>ZFXyVomgivui=Sm4^;UMUweRbk5mK&К]jfzҠޮ)3Lv2F0 {)TjUW]؅eƖMQ$B(&ǔ0Q]D\>jRSQx5 uM _zyŪ"&*7JMLD$e!#!pj&Worz2|ј \& yOIL8m왁bLS̉6{ig\Y6v|Z^\iZ]ИZBND֚iȪSݷU.13ùrNU%NB!0q_uB{<#2y&k~Mg43w2 E|s4{mu{ Y_culsƕhRj7,U'#DSN)>nӔ45 x󞝘Yg2MD ! .Q0QԷZc+19JQ67%X`Ebfudq#~y.W!G:ЯyYz}M}[6TE`W=!K#*Cl5ڤkPfoo57~ 6c&G2#䨘YafĂT]Q@S$e@DRDf%+7M\P U%Db"嵦AR!uaaLP:jMYun`FCko~P"7ȿPɜx[o.Fg:!ӄok46)fkhD9OJP11h)Ġ& ·30CL'Y$Hn25Fu;]SyZĆF ryz67{t6+t8wM^, b3NYbgfaffشIL0#0)v4\yʑLyuÞ3ʰd2?LˆTFS*gg!KɗޗIJ x̘ݺP9Xbf X ");) |FPZ77F޷WZL .Z%5aCD[5цoiqwRV^&Mv2 qicIbfb&&MkI)&J@+%sRlɗWAYxm1X XUQ3 .f?Ψdˬے%w.R* 2"O{J &F¦TYQLd̜䄒 N@dOg2Lv2 5f}ꤩ)]Rlx}fvNbLf¾(#;b4S@R8A،̸pubt#Ծ,;}b&2&.D~`e"A/۝ J%53vRWUQM~k)<@pRBLj`Wv x_Zb4M g%"0 "&K8Ie|'cx˧?G~؉˯ƛo9t%\Ҟm./n]^-}ǻR{ۿC}?KC-C2p> ">׉_wK-uVMxr L@5W͠l\/4ti?qFyc3 5o/K=fKj+& Ե0X1DmAj7kjk@DF!LӘ3ḒMTl);<9ۘ $4mc޾)+ XQMwn}cw?gg|~C_S;zdf{so-RLVz֭V+VujPh" `"6d+= Hkg#ުP6KOaȆyP&qHA$I,-ۻ{_w޶k={.<|/>[!>xz)eOH[XN&32C< 驧?*}zoGw޹b߮X-ALӈ*__`X}ाgFn ?h; d.4l Ǐ0 %Y rOԫuL{+_:򢗄r D!ZJu؄odc-k"5?/gegؠL/N A",-YꬆKP\|ǻ/_8|س4;\ i '+Z:*\]4;2@tvjفK_}cKZ4bHM;'M8W04֡0Kd2;d2; 2Ӥju^W=3OPPHRz\jͿ+oOo/y?o7URUeBZfo RZܙ3춾oC)0nsUK_xw7cjX^L<_H%|YP3_RRT8cҔToj 5HƢ/4CAԘf[}%/~"@Ձ΋?LSTUHL &V5Uf,bːш~BdľQ8g[v~e[PzєW:GV: |њ.&F kGw fJc(o,3) ңzx)Tҫl5O~fa~Osw/?6EỶnCQLT@ ֩M+mi3!ykJ_jLvdfwqUW]/?Q Q2|4ӞY*Khrß]XX+/]ga#5R @ٖ'dȑֱs&&<33b̪*N,Ԙ&L#=s'f2d2`"P#YLHh^{->,qP2R\Yez[.3헼V])V)%_:gykVՊvm?ϕL?ЗEP"b&!8"AkjU _HadK#s~>:w>^z%.y;^x}n{ U`~\~Kv?կtuwO~y.M?7L$95Ŕ6 Lȁw]r>}ࣚݞHTzx3Q$PS܍`2du JgffVWڻz_iqwhl*^o;y>7~_}-3_~eW^y/7[~ŧv͐IYuH^|]=\wIe]e&3Gscx|-^L=^`b `V `Ȍ,e2d7UahK8æ z1rMx߁NEK fdYH(*Mxi꿯wWzV KÜ #Ħyƫ)rF4Z#o+Y韽~S~Kۻ˵ys%®%.ĩ33Sc']yGmϻkop?k5l_{hx^-/޳{Oծnձ׼|Ғ~5jǽo9-Mwܴg?U7_o-&B֟o?exǍ. 0^a a.ZW dXGuyw66 D|/]E!&533a no.}-ɜם@f'f 6:u/EJY&_n^:LMU#1.}h n ^f/t\zWkkWnwtc7 3jDJ6Cgw=_p˞]V]f1 F !֘:kg+k(flrll^L'1 e&_BZgܗWgg_g#,w-VVYl6; )̗.JŔ#tkJ7zЛ~'onk/ҋo{ʹϝxfᦽ,NBvU7Ui5vBQ9S" Fソz w_y{>}_$(]or%{w]9* s!k5 zdk)(Ùt:6&:`>oY%֕H 3؝tQi"9%ủc kj|#gI7Ԛ(YkFRa VQ̌2?@ljΞ_ ~GfI̼3`K'ڻG!ȹ5\,lx_ʱ~a'"nLy=q87k&V܉CI][]\ >gz[z'M4saJӉgMPD3,t uVY (K(=?2*08ܷ)*9 :9-ֵjvEC:>e_;g%Z+G>y%wadg$6=5Nt.خ;;7btt&T$)RiZ'V`,N߃:54?$XV=\pvLw65 UEg]g9{gr՗cl ߔZS^{{{1%U3SL^cϼ/*s i2oAM-'& fbb@, Ls6H&@&3 qxo=#=SKuUGvG_'7zHզ|OT ix `_ &zUUbLqݎ ջ}OI]1,?O|s)薗݆R \󯚍 0SZlI߂62BY/ه<ޕ%kkzDŽhk< -..7&w]aZi<ܳ[nzpnav%{oQե1cbUe.&&%$2R0m( [1 h0 eV]]PsMDZ\|uE˯E{$GUA՛hfC&SB0/&fdИLwHKKI*쌴9YhJg @&3E "؎ ?2VCMN4RŮ]u{EJKwynVӪjEdDCj!  ؓ&pMcj;8W^z|R=Ps+7_~K~٘S9kO qf~WW];VBuZms H>GmE1\ #ӰLD&Aڀ":hc߿#]ZzB<6řQc& ̔RTD}W\:?:t׼U:=ॗ_WaEekL;xbw 0cR6cRvRBY(fi-DC<Өk3L L LQҽ{^:a^'+y+@ $4I;gMf, @N}Ij#?/áC1 מ@BԜ餐R1Q=TMhBBbaN8dl[q9Vau$ #gj#?{'>pqXR)5ۦ`"&"Phm23DQnT Zbі_~nlU`~'~;t/֟}ϻౣ':ħ?ɽ.fN{&vF$4[&X~'?#޵̽}PJGՄ#1ŤcR( "m;ֽk?W_œO=;^tנp7z# E"NKܜ)v>^-s{GJMpA,YQa4dpouxXb'w-?䋲[wac}5-`pNfJ#ABYva"b23+ fcr"DDL$jԨƚJ5ALr`Lv2APos.xĞJs|eIP}[~ߥ͊U7Kj'$l 8)E`L fI 3[;~gJifn1~6Ń uUW~敯|-[{ZL3sdܱ%^'?puW{<1L{Sj#j@H clytx~k?{NA-(]Ʌ$EMqH047;?#W\qW\߿*S\[]Ä:*vn;^ζٗ:ˮ, OF܄]4>ִlPa8wMϿ5**[2?eRh hswM1h0q2;4jȅ3mLvrɜ5dE;yXmnNzYh`umyK_'?|)i"۷/?˿k/yM?Wx˭W$lФAXj@M"Es@( ((JAhnn՘ +ˏ姿v0}?sK._]Tv}y*BtnQmeI6S"&ւ+_~ _ٿCÏ^ !%hd.755]W|Uv-gy)h%瘅;K\ %etDj6Lc:U#jkfYrx}^WuMS?zd!66l2l*uE~/j5=GS|?#"( ىsD  ba5c'PdjԒ8T0fUF!;TP#Yɚ{ofB%1_·>}++JLG9ܴjb*Q v [㏞xr.(xϖ.҈q :VⒿ7~>Oʗ>D'2(hPuD$Y!M L`Eu,gbi"Tuc("*^xǝ/}ۮ{GNt.z)uk !.fۉL {#ҦR:= 3V+m _NNB f<֛ݭg)u\ػ݈[.p̌ f!ILDDHt- ~>L&;f*'̜Dl֐4j U + Vr˩7Mv~w=_{udNLe$+Ru*RyϽW߮ZyFjĜPRR*;/FGXHIJih L&;űr#ҏiL,Sr]oy׾TV٪ժkM =?jU v-/PO?ute9R?WA m{FLl5\.>ODwվjœT˅d ϐRZDX)JW[ Ͽ,ؚӁ#WYZ!uz_N=8N/IU/g}ݻ?s_袳|/+Tfv|˞wNLk*kƭVNHžΡcGO]zeՇ?7]/[09RO&\/±{{yُ쳞]w#/u+BEOrkd&im֛|ö1b_4So<}e8BQ'Uk8%,]?wuWBWo :zp`VU / #pwҞlƯI?6{[/kw@P,$au%V^<`=X 5ZCȵ4/UU[s3J?c!0H9Gs~'E&}GO8!u,C'T'XWjlejϿ\\2▓Bv{_sUW\{.K`l "\S%bWڳ/뮏|ⳍO<}??jJ:򑍉뭸'4V#PAVᇞ;?sO~õ8BAR^Q( \vknKڴ]߁/}yeW\vPX٤,%x;zS6ff++t?_vÞZzӄ3VcNL 6:zm b^9S@IA->^ 9U23Ƙĉ@H8TuS/wcM딛ГyobrC<8dd0mv1%MjZ[fc qTRtʍ$ʶGzvIɜ&{x0CΓR+n{5O~Btcnr{9rÇ_җ oGɜT:+16(Caֳɘ *rUF9wͽU Aw~]/G>^Yc‰?}[~ VD%{ K5];cO(ZEkeyUzmF=kߞ{ww_{¡Cz[_0Rq_Spru]w߬ffPhLfV&GHgeO"P 6!S6X uL`V"sO|/#LwZ\eh&,BFSDASE޾^?7/RqTrT[WL9ERHx+`~u:)N8MjvB嬰K]wSbB3>b}ԳiR‰Gtgw徯~髷y<+|ﴫQ5 -,' 19]]ڭy 8uwW^  s0ajHQSm,t;rei!CG3]yu<ؑc-\D1M`*PEۧӧQ&оrZ4)4 3umugfD1:Slj)Aa<$d s0йZ?AwM)df)j*c]2XHf>whee؈K/W#=//(|qıEQ\5<7`D `oؿh3Y/Z??o~ btԔr'AgG3'±zEo/<S-LZBP;_$>uTe[2tZ,&44UD%R 띿w=Ǐ$FiJ"]LA5F Z>~-'o `1PZ1E,jPiNa?Nx΢s{GX=My/US—Up\Z+++aO<|KP>[E,ł[(-X~ġ{>} 7_~uֱ2!S. USJx%{(e;R+8Cg# 59b)bHJTze{.=vÇVnʲ\]]9x>[+UNg5pM<ϻqVG.J` )B hZh@N8h|34tg"9ɯ}}{IqKD#4ZJP'0]wK3ۭ>g YOjxo<#>z%.C8ԁga(< J-=}:z8@w31vUYuԪʢ*--clm?#a.R0ܕ@ PX-ρ K{Yd_׾xáJF sAUP9q%{>j^?_my7G~чo}ݫWVWw ׭.-̬0 ld1X'$éYrv7bT'0ZC@ DuU[&EF @L0'[)Hh&Lf:Y찲ZSI$53bn"PKu->yc,ZEq-issJ97{|e醛oc_~TJulj ƒ8UDc1v}{"[C|7B>%pD p?g讗}Ń]473M*RLɾ%X{;FvROm~-C55=ҭ+,/}zO~ݳ|͹4*jι@={V%{/S_O{Eb<#r4:X GūW?tV≣>=xpۚ01gQ[K+kCO< wWܻgO?ze\}KvV>ڭ7n{=sϾ ߵkeP3H R (4b(HoqFF?L4̬6ޜLqBd s[&p`Uw:Y Sm&2hJf!Hj&މxPK_yDqn"W[^?nF82̮ˮb׾i"M {zٽWY $`gǾןٻ+JR;]}%Xfm{bs^}5{/w5%v{AlP~~NG捯L afMsm ZE ԍǏox >1BrϮժ]yū%w/}ogWjW/an}T)7gTv†Xqxe4==}GQVEQmp9Au&dpQ8Ca /|/MC@))%HrYЏc.۽Hބ/!x˼\8` |_j^ ?^u".יD=50ٙYXNՌj MXx;@KS'ME"IXJ 9w1!#hmB?C4 ʹex*t8_~ͩlϊ_9@ES_Wz\Dd\tP:]9;9Ad8"A!|\_˯9;q(PKt͚ѱ++DYNIkgypU[W6D#]v36y/ "hlվk7ļ|(ڬeH'c*H9B?卛Ǧ>8D<ˤ4lW ̴GK 2n ̙;^`@v1v:fZfA#)f sX˕=맟xB8Ѝ8n4D#;x\ixo\[>5|f݇Oy367=˺{I)H!k_^36'@LLL=?r"g]5"}S|قL؉-溗Yj#9+d珞ݽ5)|g}Qj$қO9 RN}+:Z:QJ|>+y֘4Ms d`= IqVbf9fDI=8nO7 gtlr,7VoƱ+p}wXljzSW`͘bqbaڨ+_i*1^jZ+dk3ggٌ݂BV$\* \mƋ!3xFvJ"sL̎β͒[xF<c,>m{x1r#/1=lm9R"[G%)"r#3;G$qBJ@Rs3shmDw|߂RLL[L(:X>p?{`JܳݍU~)"#G-@DP d ,vzWƨ mR=XaFHM˳Ħޡ?79YjrC}ooF桥=06ՈYr—7?c_B wD /?g~1\>h#NHD2NjZVm̭rW\lRH(vȓމe~{[>]B bD^O?tu>Y>1?|lߞ7jC':C옐s DOK皁K; 2(a)_ZRj]ڱS)S4H MO4Zxq# |XO% 5]S*=]K$+ЌDJ( !jqk*9`q $$ b~DƮwkfn>W(XGָ_j-<~mI[KW7߼}~n7L4"?>>322<<\*`fzƗE1iP:f}N i<ϘTAP"X㜳`zgϦ:heL>wjG_qcbKihP ˭sJ@|/6R3Ƞ gM*N:qNU st(?[TaE/y2!}~w[y_(,3"fIHBkIP M4D!^rZrpy\1.\#B) !$P P [(`^zuj2Xqz/lufjS  ;n{呗Ξ8ZYT0ߘNRVp*_4o<6ZⴞV,8O~/CssĔm)%:yH7wzW:ѕCH=$oI}kՒu+WzܐFג6#'X W[B9JFv]\K |}۸tʛϧ੼w)Hi:3#^xonݺ0 !@` ΁!8@0\$F[r!Xj[Vw~?R9$F$ D` D) 4Rxq9<x)pvM[,):ScK{Zʋ/$N Ủwepe-ϺFun~f"qC9sm񳧟Gpy?;;{Eݴ0f? f'fo^ho>z)SmۛU! (%2#Oɟ~}c'^~iNu1?_Gp B,%s6mr? GǼV`wdM!vm-% ௹z 8h,]ggggFGFwշ{+?- GIٳgl|\i[{{uSGqVcPbjR_z}# {1 n~HZ[cJ"9?g=?^fJʭٹuU*+{W$^9088%1E+?OkػEDwYg&k3@@v(gӊ.K}^{5$+k.M$nGTQJ |i2EP0R}n~�R,T[x"\~`[7`NO ڣRI+˜fl4̗_lϟ;tpȫ[=?3ʖ SA nj !%/Rg`gɲp@ "*ϓpE \.rEZH 6 )r` VJ=BC|f˖ÓAw:xOл{S9:%Tnrtx7RK(89K=7owsXaIVgQc@ ;)9N@6ܱgǾu+6>#G I""$Uuڵ}[7omϞ9}zbtk tDv~α0Dю^>;uZ~z5ȋם ?sw]t0h*HG I+PLi=^,_Uh#A$D&c|S̜;Z9'l" aNJy^ƽ}O~o8v Zj l}X@Z6 WYB{9WnZ_,s07֞oq?q3gWw:srꫮy䑇O8zcǎ :׹TftdH/}@4$cu# ISyx27s rĉcAhMoٲeppѨ>tX~--{vmY{xRh2D5ȇB`:s`vMӌJA26PI)&,<}?/nc|s@ g4Tl=< `s\>%/rpy\,D$bYkPHIhYJ$q̀af?:ʟ{v2&IɡeK;A CT֬c]_bޗ֩'B⩧wݱ{$Rg,9o Vךآc@&#_I)@poXw-7;(DĀWHȀn%uF2 kAe7T1EL@|;7`b8mRϓj]z[r-mV6:v1!0.b 3Jhv:.)R2-9u۶:s*zGa.юedkX(Z%Cݽ-\\_wѧ :ZsW8M[JuT5M<'az,AEnIM{Gd[k80)TPnfB!wzϕd>qJBfNGs=vw:pdzԁ!"Tv@[nFFO>_TiIc|jD}xEqa"/tzKQܹr)$!,Z:df纖viz4CC+?gzzz>>6D"zRXkS D90"k@4@s=vd=Np8[)Tl{y/>Zߒ%gΜ{Z}k`2E`9fxظI6$߮_i%9>#*梅֋-Eރ&%]?1y/;#2;)^zwឳmb$JI!s@$ (}%=i7"OyAc|GTCSF1GQeeO~? L=@8cev (\}L%5$(d=J梺+=!kպԃx W]wӵWr82PSڔQmvXZs9v"y3qLٹJR,B)z*e}WWCHX ˕zC^(}D`]1SGH{RLڐ҃ \t/N b$Dѝ;Ϛ} ]@@Y)kNܶ}[Z7c:Eǎ3b^K7aKEbfdJGrȄh3c#G  `cT B tv,Yd銾ލkVA2Ȑ$CW[{^ CYȇ3O\b7v̓4~hwu۩"dY@mSBtJz(G-Y[Im]>ڢrB!Da9y-W\s@'z֫#|he+Q~q-xD`f" r8l?X@GJ 0D5ר"0C|RL4$l'lܲ\>ͷ8y",KˆŠef?P M{ a(.n38%i8% &ν>,_0.Ñy\{m=_{ a"^ RnP.={߮syuNd't% 1D"B(%XͨIHk6D~ 8MP f #g?\uL@a!,DA, /|cChQ]ٷt@A$a䰔\AёW۱奏Xҷex @k x= T HϧgGf6B"@{&_hM JzW&fgg8>51T֨4[b9˭l\o/=W\y-`, qQ,lEZ92"%0â, MTV=?g:unԈz,^uUȰnڨA/~qǎ33Ǐ $5h؆*HDBFr|ϏL`@Lli5yۯx7'!?=--'O/gGG_zё5Wt7nZuіrch kAJ tiL";fTB Ȝ8:fNTJ옝}`uFQGXņi mףkiMoAfS:޷˅}=> c&xrqHE0%藎%^{KVwTFG:8@y"[ȣDiYB @_`IQ7v<а]!wlXg_ʀV91"B_e6Wn驩Z nz2ͩj`b[Z/aGg:rkA РiVd]uDEs.hatbeJNEqC/47nؔ˵34?y*ir}Tg;$ <\[vR*S:Co:}lrNj B&FX7_}=&bEJS5{u87KR*pkGccS88BFV(uv[Z|[V^Wn-ĥPR@0'K-PH&Ahh8xCxnY\ty)%M kT% 2 rmhPW㣆' ϾҚ)73^NRmA.5Fɴq'*__[3l /t v/o5 ;-TROeDJBgQb t~3˗/vڵz$I:5;vze(N4 mplS}Aڔ-y*U-Ơ榴0%#'}V`1|]+-W_384\}Bw=;;@) :D@@!R$qJJ`Z tJ"svFDR!f%y{y| GZ^N{yK"^g,ch^x]l ,Tˌbw^Rd&(%HgX@$$`dDHHs- "H~N|gjI(B!$jqH|O=<Ǟ7]垂OԢyuZ[iu~j5#'( ,Xʴb~D0$fG"IA^Z8 yqō18{_}kKwX9q{K|#Â;\.58~lb%PB%Is$@y"쬳I<ERCajf})tQ{KRV){+w|b7߼bSç4^v `b(*BT~.Vg@P gI)r x~EcK |0鼼jf-(N H@im2**IҨ>p_|DN^;:a[[Wڲuىb{fcs.0WD.1$F}?\ <&UBst AxCKow]+㺮[s)v紳Υ@h$ 9MSF{5ZNr TRTݼݹ/|#*ߦT!/'XvŪ+\0ƈX#Xv^bW(0zFd"NR /<\gGf$ bJ ޥ _wn/ts#FOqn[s!9n+/+o ~Κ(1uÉ@vZvŠS3q=uWl/4ap$kq#|f[*\qb˯흜>}`eۑYZ[gs~n3\Mdn\d!l9s߳O\g/?탇2( .$X1X+"Ax?jrwr{h'h@}@;>DHH hlLMDcP,8 $O} d !0`׽q $\*Cry034̓gm˅ҟVjCM\V-0D,$m} V5 1\K4 5#wg_MIxK_v[<7;e7n%W B" l- ;D^DzwQ8o2 ϙa>s˼ TKpyε((l$ ů8+lwe'NkA'矋<99hCιZ A5'9yfdDz=(TGNiha,x>1kLhkXveoC :>_!CdD x wB 8.ΰM+jIej~|rȌ(jMv" D!ȯGU"'QCyB*1P(n]7R罊/)!XSWvLA޹)b3cB#n-Hi$2JGmmM;||3 :=(gڨVE˗/[w#'Z~7~q M4iAHpBy}1/f{ @,C "w,)^%|)5/F%,stu ֶttu}ؓJyX>+?On;=֪_W8={v2כkDid HI&BAZ@sҫj PźI?.AX0gW%]/}- r0jTBwv1S_&|K{jsGGRb`` y` T!0 p紉]RGh)8gŬ%(lb&1,b H ؈F$I\$I-KcR\>~ӧ|LT\l(I0̕JGPzD|\J6 s~!BgӤ+A H:uJ9p mۣ{Ԕ/q}k*r Id{3*BDq"qRfLg9_vCȑ $Z3C!1ёR9G?\j+ݺQCa!PZ6 8gbjl=Xǀ $glrdUo@G.q΢BsFb zjRH I"% FHAZVkp|M3W䕽o6t /$I3,|?6zh|fk|cz~耔;rІe-tCdND6VyW:R~e!~_wtu[TyIJQXeuMϞ_H &08vϙFl(La (K*la[߲n]K>t& p`I{)ڊRhcF71 x{99إq;:[ǫBd5v9:gFB7]e|)k퓯M:7x'$`KP(%1a3Ws6ݼe#vWSǥ 9gSm~Pij.nT{P(,, &! %@B5/cA6_,~ Yy{رoپj*g3O=땝wu'N4ec`3P,<g4"k,qPkTѹGL)٥_8yd^B⭷E鹩֮5+B!ZZʅ{E Ja1{7R%]Wȵ@kWnk?}fg֪zkX|_t_ݽGvJk `akADwW#Gͮ^fe쟟]jխ\q|ٲ0P}}] WmYđ+ 02!O$EDRYf"\HHg C\hY~W{)hʂ e"ZQ(7uT8Cm2mz$&tBoܟ}՝u@ f`l@@RgÖ-+֯|, R61e$I49d #gܷ2 IcG>9aЂr cm]F %9rV!#6'HCXK,Xp|H:3qF'ML)'@:k93hK!!3ZKmom#u+l- / O/?G)1*5ȃxm٩(Jd%JRkP Q%}k^Soic+,t|;٧;GNwj" 3 RYyCUkܴڶ}bxġց)F BVܢRr͚dg[/yrvrT,œP \,ŲŸhHtWmZwC7]sM8ҥ0LV$.3"6]e?G~s;BѤ-wukPWgkow]iA,apAݤP N(ϝ#ogn?|xQOz T.^-"{F  ! BvE+6jR׶ΏܻdGfSW|-i<򍇖Xsssr' RH9rlQ0Q 0瘄 kM.f^{Վ" MOO#5|o~ǎT[lh&IkKZZJR[ʥ. xM&jb{J56ۤ(RY<`g--oxD]?┉% >~4cKK} ۏi^i JI.VimzzRn݆q|䣕J+_j;oyӣ3gfX c5˖w<)Yπ܁Fɑg=ssoqY{QkM!暫P&I4<|2իx:ٞ+ܰj*kTg---y)#Y,FM)ebSE3eX:my%| 4#;fi )α{Dhv  3#R/K" 3GqD:_{+V,[rٺ 떭\~&UYj~λ"b#n4lJ ;$HUjÀ8m߰+ }m),ο3#kn6,g$;q^+K j>=:33 H@hҔa>JӅZ 7zmnMuO?_p9ձ5#DȓR$zfƊ96Zc [v}4Kl-9KN| ~?g=`&ЌF Q#i][O>cBO(v涰gmW}M:94ߝ4& r[Xcvs!9aH>]^=N>%ա*sc^߷wuYy lf5P0;O9~r^'>X[|)Z=c?_ߧ7>[n^CU.u׭ 艑; ý699y5o=tp޽+`v'i*Fa RkdJiF@*ct5gݥ] ӵjML O:ԈkڼthpvnZ,[l7Fqde$R.;YJaJJ!ɲUR8g,{kPr'֛>`{Mj<،uRxpk!+p΁O<χ}]{͛77_~<}{qökDlm!@sM̸m3eBI $5aP3`Bs(!ٙ %@M/x?k{;x[ IJEHS ߶~SK[:`nz"!8CHis@ F;P)%D"MRkt3r|*;pwoߖeKl Lf`~};2N(khV!rֶ{nZsh5N}{a :n:ҩd?#H!f'PZZ/~Pdn|~6^.aTtw BX@ssөhIS]+)_zgOqk6VPAƝSJTkB6&g>Uo玚OVf+֭ 0XZm~ĭsd/":r&R*Q `b` @ l8k׮m۶U{vm7l93$s<;߯wə|ߋp$rp =cL>(I!\1aM9Mlz86Nw>{ѱ3BvMWnZDKnRΝ8yS޺u-s"MF;bVJ C޹ٽX4;/r>Drk, I<#笥[~gϜ9kϜc/ĉ "fXleNAPnmURL!v$HIO:#Qj"x3j /ӤRHHREyz~R2b' T4:2g\f:@Eq0IZ::ןŸ>;MM;nYz@ iĎS6;DH{L~AC$ DFfdRdL态$ŌH($HʼnBʄ]/|wLN40_dD. $%%zL3]8ds=m@I١ F"阥jFVO NY<ĤdARL,= yezKB? ј,ݝROY(u"&c3Z@=3?1b+ Ţ#g-;Ў}>92n)#=ĮGy ]S$H [n\?ټC0%g&̀s*\ N\f-0)H9tK#?+ut :$3*Pzao溳GOp#ΌE .UւTT EHZyc4NJB]ܛ{__T,󹆎 3N//^Bֱ/kt@Ƀ[09z2pLYH|(gտ cL I5FH }JL!p7e$mnۺ ^vLgjxԓjͷn[ru?.ڼ/~wu_QEŰSB28KRe).!Z;/0Fg_@Z:x#z6J\^R 9s}U㥃}q]Q+;mp A w ^^x13ڕȌ9"RD_棏Zݵs'B/ E~ؓiij|ш(J z6?W+֡d):g!izu=e8nekG9Me%eDZRȠ9FĦů ~$ٱrK\~ؒTuj˥Wg٥IKz۞^T`ͧS3c,r NƵ )純iya&3 AFa22$|Mfb,䄗5A(%ʃ]ܱJCWܸ 8iD+6R/Ƕm6[v뵯O>߷ԩsE#IDd{ V<"XK@HdByꉹxr9kA#у;^9h*yeǺоÇ\u櫶\991>3=ym7`FƆ{ڻznTqn*!1!d{E3erpk. X/w SoVijCUֱvEuZ A  5R!-3 AިyYzKZg_`8S\&M)5: 5 16D`& dv)(\lj"$kT֚z Bѱǎ=_xR 7n'-ѝ={;=$TҤiP@ !@55ւBY&m7:+I <헔W cuH(!\-GNW-_z=wo(ms:Mׁ쳲 \ lљ0|΍Uf'tap߇#oEvxN1,Dh``_y{靍j,c.WJuB?҃٭w޴tMk_h z{_:>vT3! ng^ء?΃B/Gs]xp {qMc >PpּA Fj|N>H:flCBy]+zZ`EwčbOcj ÜG* B惂>,L5v&DV*))JPRfdRF~}O;fvVJSM`*l $`3Y"vjS "J˗OœsH%E .ݹ32Υ[>z/`oοˇ[#Htw7ꍇ==?mmJJeHy@*08"tΑPYTII1:f6ڧ쫄|q!$Kw}Kߚvu޶zR欐䁃$)c AN w[)h<1q9;5:똤‘'>/VzU‹gABtY Kz13=޶ӧ\WZ?3:M~P(|_?ru]v7xcϞ7púukwu6|_zo|ׯ_vٳg_{mnU'NF ;–JmFF 2$6TNkm |it:J|cytn\J /xE>6sf$JjZe{ώl,@gᵳ?T2"I&G2 (09/dLn+O?+ՆQ`P *ov[ߴ&4) 3M!bE6I$D`9fl! 9=5ۨ'QZҪuk83&>y"E|h @QN,g:筄V^uWoۭ>ɕ[QT|E9w&"RfMlʟO __p%z1QtŒܼr5W!8L`?ZxݙYd"\u|tK{~zfjvf fFŢ*0XdhҒvuwSHL!U$ A "JnAdC +OBԈF#:a9FI:FO8xIXk*' sWHku@ɇ$9o!5rW;YJSED}Ǣ oo۷/JPmK{@ 0s㲭Wu(6tvv[ZVn]w 4Cu[y `ѓ@Pű  )H1sPd0 ɩI&TeF@mN&n_~rz@1{w+-ki)wuKB!hm-"N(jm2S}DYty\N.}R4M$a AǞ%}Om UOc%bSRj``ظ5vhh;$^x{{{|={_޻rsOď|weoq]wY_7{ݶrÆۏ|#y牽o|'P~ۭJOLr--w511wdy:#ֱ˄v Рvs۷/CA`~=>JZ=3< 8&8C~B,Ԍ/h{O "fD+L|M)fI wﳟV67*B=C}]=}}\mk7w\8R3:l:b+/&Xp=i$hXRC`vmK#ãDꭧO v.Y]M¼ԩM$W->)2e ;\ܒZ}|2>:53QAKxfAR ֻo:RHuy956yv`piRi\WmW]YN9p-6 QqQk9|뮾l 6lR&i Ղ72]Ɵ;B-p>.Vc\}KMFs`ff$v8<:Zcg8Zuڥ[VIs7}6)EØ4pֵn. U^mJ\ݺ%I|߯be͛~ePKKK+U??ޒ/ R{kkwWMӶI_iSK[c~gon+6s_6 uMLLU* SX9j ab-*Q8L-!z* y_nk+Ӥp4i}׭}}\|P6u{OyE %%@f5eӈ1!;dN#|`Xkm&H `<8/OoJۼRww!tt\nM{Ogh2l~ހ`9Ea l%,0sف&r 1 '&T)Zah`ՒցeSxa-JV)$Y6$J'@O`ɗ_?=WuFLqLRw]--ꛜib!m5T\Wtvx>)ZggQR*'NL??{^^ [%(Uko;~9[OmDA"]Rb_?NeR$I Ax&u3\F9=i9a\*^vö+VGzZRTZ;V8^Ar—(k%5vfY#ęǏ;*W}Bj[RFi 2U=;K3!y.VsU=z_li+V3gOٰiCwWϽ_/f;:@RiB?#;BfH5T# $51 DX`n\0,aYRo^|<Eww}0 Ae* K{#ZZ?˗m[вz~̙zjÆ m-/=cfzX(>3g;::FFF=;v"AFB>?;7 cLtڰU*ZEĠTba :pЈqi C4tP'X^kspdLOoMo:}hWW[7"yJz B9{DqCL38g)0:ZDB] Ԣ}n;lf|˹C =]+^RQ+9[%/t<'#''3B6_%PC$J8vͿԙ}]}֍[lm43K$Z8&iD]3 zR cl5k )3R?՘g\b٫b6))/QB8UۯVBs|S蓹0$P&?jPG{-[^x|3ijɉÇ |eHjciA&G(2Oɀ).5`1i;yjii^(_˗m}˗J4MDD6 &~ZgۤXgmZUcDfD*T~K>SʼcH!\ВR>\uŲ [֭r]kgۺSc$2 ŋƧ4t5uBٌĐrˆOzi㟵)y1p=߽ו/ǝ3|"ʧN]}>ofr֦& 4mX*!5Pն{>r׆+׆yF81 W,]7l99?__ћ BgT_~чo$.TTm4Bu+mqQqb:1a`<FpΑJD>;s))\`(¾mEol[%&:I`sHFHD@>AmnH]8A8f̞_w՘NM mX8J;oen/[6N8g>{]w?p2Wyۏ:zdk׭sZuphh5ڨ۸!Ϗ*W֒Q2' AIef,j;Do{JV/z~q{E  \ 5e}NfГږ[ZBCˎ=kǎFU"Yj8;6GQ#JcLjvJg@2D?Pҁ+!1TՃG۱p Mkq}Y|{㖩M7ؿ+mXSl/8;7;ͅNFo)'ssJ%s:BxX!8Jd9.#Fg \rz29>BfG|?;9 DNKy?y)>Z\ߺڞ˯_-U*af%B$qҰA&"}1g+BJCڱLF] E!N+'i "Y3פWѵ\jCW`Lq5ZG .,Ԍek_4" +zZ-K8vgWj65?u&&'&&$" hRf 6svZ0\2U~3nTf'?K}ScSns% #" LԹiFÓ$"!-1 !|>ᅘ<"(JcOzᑱ/(M<9ELJ-׬+T>4HR$iåGid\ hX b dGO{d~+g!Nsd:gxQ>֖;CngggG`l)_ e |XJbsQBȄH<88qU__WgfacRj)yǽ~uxv:I>mEˠ!}mX#׸*RVCᒶk֮uTwwIbvu8^[GkFCyqئ$Ɋsigglik<sF"t?77~~hO_5zw]Up'OD|ǭϿ©ӹrqK/]:0tgG6]ꫯq&Q|l7DItj -[9`؁$%CpXLR4w"x* Ws2-o^瀡iX\l  uyխW;mAjN:~fkoG' O%{^1;M@D>Qʩ2wutH̑c駞{CIIiWgв}/%#gNMM'm5R\Re^{>#vƲf6+;e߽ 3)8N -]sϜ81&P9̳<77 >/[zZ:;U)q*թ c[IS)R*j$*30M5!YDDLuH *"@LFBFD&ˤgPHTc&a Z$Q+J@27|?t޴0|P@gwm ==rd($eiFl= M@(Gdu>?]ޘ(ovxWoJ88g2c<'^]P*)QJz u6")tfcPߨ0ZnYnmD{Ъ!-H:TkI~3QH5$fk!9٘O6f}GFDi%&!\Kܹw{>+E Uk8lޒKSO>+ ~iApЊ5w6CGL9[3Ϋ|wuVjpL$I=$,ddZŴ\ ihmtl#Kudמ+[Lq9K>K)=? PXk8r %ޞ|Rd'lټM>kVJ!9nFs+Wz@:}bo UWvMTm{NO!G"$&BiH kOx(}]˗ sꪫ.ϫVm"" (S޻*fgj$نd&zY~7$rV*èQ-~Ů",:BJ4 ӊȰ.M]: $gҋ~K{/~_ꨥ[󌌼HmKJ.8ֻ,PXf+ٓc&6RH|r۶_+7fO=8|gʭ-Q5ydb#xL(NCc:?."+ph9c-]Ta?p=X7E%Wtw.~/zfwOp(e^.u.id~~ղXʩ^Rp|+x~y# 3Ң4=-Q1йvlb)y^iU>ϿU+/iR¹كo컡w(Ȭ u]םZ{nE,`M")^-7ɖb;I<):ϙ̼Iϛ$8'UUjIVoNJA^z.qHYr|gU~%dž0 -LpHnOk KVY  fϝ;?=164bŋ<#Gⱆ:pmKդڍ7 ϙ;g鲥-CrKkk42"%,HH ,P5ii N&񻕠O4>DbBTF0i>=ֶ&}0>5F0!nX/7,mWR& kv4r{lv_>|XiPJDŽIq|/VP3Vd`|lΑ4XpLi%~1zq!&2g;@j2@-v\1wK/]j+q!CQh08I|@]~!IA9lv-$\kw֫5v.4#Of" TKG;^````ȑQd8ZSfc)B7J!Uu];>b UP2pc%Tjf'!"cQW@>ۢ9؃Ҍ.'xU׶5ݷ?91[řXc &Ф?c;*Hď+eYr董c#i7h2Z+f qKX]NyX6p;/Ͽ9q@sG\/v\;dUJ6h BB"&V&>; ƀusZ~"iц_y1;ڞH$:8`NPOرchhp֬Y:fy8GF;>s_3wKׁk c,ၪ,f!H @Vu~l2yƅɦ=c9bMzC?'4;ڜ,kj* ȶ5ʲ | %2wشr劮|suk<>䓿xRűΎ(6&uP xs߄;jo?bq$`G`OaDT╝Gyng"?VT@aGB!w]3$"M?tPJPT'[`k7 U<ǥ50<O&-e3͐AAΉ3zcM훣/^ʝMW#@*IDj0dhƴ2h"*KncjQ[ʲ`g/h M|=?=jloxlX)5Є`DQ)7|vp%px|iycc]mŀ@@J\y+?(='rR@P)/EŠW ~]x>!cC !csLCd(5Ad"$iDjU~K`NJ~aŽ[SF)&5z7Ωj8KβS{{J%yMd~څ[o*uD:rm7PA:h&Jc*sC`R946fG<c#C8{Z(Wb*20X6չre%%M_X.8 dJڄRQc:"0FGZ"h`koY{IS&ۖjS(6C.PڎqcLV)\̀໐+N0U1 2J II]vmraset@Jq~sdçc55E'*kVlNշ{6?ɏ^0{"7l Y˪\Y$bn:"AX`bZFHY>` W\;?ҜH{O^vDnY헶\)fx#w0 ]H맳AӑF%#CrO+ FA%˖77𸕏-'%pȀs"hLE ccBsY^NBdW#R̶J6FT*UKX P ˦hof~h~TVwmswSD7*nAdd(A7z؋s2_OĬ-퍖%UMQ"'og*ETwQsvAw~OY޵,$Pjl>$- :z'e 7G#D"8z(Q %fpIDTżLbop) D:{ęKi7 }ChI;:"Dlj! >;]8'xdԊ?ۿ\>#21= ,/ D}gYfCd1f @LV P#IQ++VXn 0 OFc -Lܪ> xL*$*Uw,%fIO$Ƈ{wߣ;{ub0>6n Iؒ%5q劺D46Tp 9ckI2NJGH8vcw ?UTQ4c튕JbTж2x2Xa]I>q#]!?8i]37a7[- Llh=Մg{?%^`R6nl=g~C\{s/xK;.֑7cd U&QDZM':Zε j[ٶl!a4r?yL8zÖek*WGݾё˲zN:S;}_%[7X"ҙtLx.‚G"Q@R~'G,/AVdf(!!c 8q$PU "k&\ASaM;w=LP֯T Ak[}ߛnjÏ<\ˋ,ֿgQ:C榖gNМRTXs#*V/( 霠 ۑ}z oŦ9}h.1$CY&ӔB%;;s;PylَR,VJ1TYz:lmkLSoRI}F5󛌓H3KMa(2 J0Gh2#1a@r/k? UEʠQ,Ot+.\9֧fJFJ*C(ƼX1/ea ΙzR<[!c)0xCkbYoVW[Q8nlp$gӶx#JE\ekW/^ޔ`tp̲Ìcrɲt&LA } G9/宭^ǺFsQ@ hQF!V- @{xYIS- ~Y $M/Ҟ޾Hʉ|Tw1WJd2a+/?8Cg^۹;g_u[ܽ|ùmWlV55u1 hQgLQqC`@ mDC! A ߴ@hJ~sܦCMkjfl)˕}w r5uߺ[uSsvG 8v&7\(SWqPhqҊ a:3]8sbDD1SL űVqD; p!z8ن{c;gM6i%,Mʲd31pۭV.yߕՃ6nIߺ~Kg*#|/b]o~%7>-,d-XeP^ -e\@doU3VY0  h3gFSMz6zg[4K?ڷw8AضE*{f bnv`l8&t]زM M+"_*cB>OX0Xta邳ͽIj> SYd (k~k6g L9Ov˪ekxLq`G9LM_{/R=7 M@3Bù~;6oZ_D2lZkBf +3"m@Q ݥ\Z?2NX yˮl8^H2ҡ #D)% p#Ƥ:1U駷i:Ri%kі#"1A4J~^$o_Ș"e;bt -^X,hG<~ T| $II8>1Z,]J8CnJ׎~ۏ|~+ )A pzNCjk??}b[zzR5I?@M) 5XT`pm”$KoFU~')vOԜۮ˴@bQ]T.wBPʄI`ͅ0C=-TܵhδEL:ej7;.F3FX.񡾾\xX5&":C"Ɛ,c'$".0+ؐ_ ƀ1Wׂd`<(0mێ" /w3e {h~+?sO=z{kۼ> -?S^ t3r2%&b uNni- i x:ID6uG? j l:&:{^۳wΝJ)d~x<o}c/8y>m\{ESs[[ b\Ne-sf hq DU4G11D0IY :xOoU{Ko[vox2^ޅmiIj q"r`kr(x}t5[۾8vg>G`w>8>2 < LD Ȯ}{M[V54%e+0D3?]@W?^0Yc |w]@k 1V%.2܈XL3OwA0Ό6L&#J۶d G ސ3aM"n1/N0@ X 1tPar ޶hd飫.YpBOg m\7Y 0WX c}CÕ((t=3ܐM\puM4@0$dq!b&=K_  H$ϱ{Ξqd:1(b1В֌"2V?Q wwnlhp='H([AKLؖ_`쮉 7-7X!, f[rUK{[ڨR3%=x3lm@nfCpr`~ {eѸ|]cc׮>[xxn_ȏ^3HRFb*3t~gY sC߮;JD$U)CcLX͍/]ͽ;v1LO0Мj}͍Tӌt6OY H#i5ٙĩܖ1.ppṪaT?vhTBDnҟ{*ݏh8-ؼy40@06HR ,HD}6AT;TDڲjAgPTW9~PჇzkle-vgζbcu/Xélnȏ?^Sj[8gJLZضT:ґp,Z`׺9]7y-W<;6a򎃯9'S_J}GTRoe ,ӈ_n,JCKsTk복5T⌃<"[f=0;?$]?ʧ&@&Z`9. _<癧Ͽ:?˿˟dz(xN;߸/JTvQllW|`o/{JkSUf qiC$q$"y^w> :1 n^*ñ^,r]h\141n>!cd)Yj .LibX )ܶmcY0e[k]< Cw|X#N;z3w0/ZP"БA6:ϗb?x O &Г ]BP pRfI'_x;PDgB~]c%ק볆c2&U4Bf۶RU0`y9*%L*$񸧴BIJ*g>vzpт[R.lyΪm(2 rJ`}}eYdd%i|yPw'.YqɱNLb 9q2GĉC-/ 'ӉP\hod2}o8?0o8,Z8l;dBCTWNq߫͹@g8%H3)(;J1?3ؽOǚNS1nK_ |~47/_X2_'_ٱlqG){n^!rDשJ1TZ cB@U"sxˍo᭷~P_˕l^96ݱKj/nrneo?d[G=;ql@ 'k|:7w߸Gᛅ@x/Y iVf Mm0ǿsÏ|oyQםltc+!p] \@%ӟ>?-XR-Ẋ>|e c"`T}F˿-M%Y9jҩ@D &&.3R^N*?y1n  fq$ # ȁfL0AF ,"*^_d޶bKy,nyB*)`` `gKڄKh,o.][F92aG h"Ȁ}7S#únjsFĭ(PGc AD5 g:<HimwT%8okkmkkm(IUOdH~x*۶gvk[G+kYh[W% u/-BF|b MlPڦD]v<^.J}&"Y5-$"c_?q"De<C0gt 5n{޷xْrP*g wŃ(JH`@1Ĥ(N#ΒsRF (OIR-mQAgWT}Dm͙K C" `ȸ  !p @ȧ19S܇]cxzb̟;zKS7^6o޼j?4X²8SOjXv(MT`Ul& )opnYHJ  $$ETd(D8kϭk^۶p]w3wT"߳/H|遒}v%+W?rE T:|(S^1 4 CJJctqKh[Ҵmց&,5eK;rt˯bX_ ߸4a**WJ1#>sӍ7 6e2BDE%P56o訐x\VDӆ?s M7bgNp3p,a& RddP@ -e`If,e~KSGcm]Âm5ٺ/޽\̖ͅF#X ѱ G p笹 AL cLL!2gOʺyc8(mC7=W`J\p!8/ ߋyW|CE~  B6{鉗<X#Fa.lc~@:~h=0s\"l$(Y5I.!A=q c! 8. qhjkۖo."=kUCTlc6Ё΃>v6՞9up]}{{bllm,7&8t>7a`{d7!_.'lǵ@j$QYBlj),aV[G@莶˯664&jkL¦6ld{9}dק> /ѿd"۷~eKv. k׭]%mmJZ) őIv WlشY;/﹫~vSGXo^! ~}O>s3 .?/.oyvn<:1%O {W6zf˲74߻K~) g_<|l2&Pr/3&b]㢍Bt.{oNB~ *~fEi `8±P)Se EF)bHl.Colo;{HpS{}L^رw 8؆׻7֦iM-̶)`PWN 2ıC=吴-Gke@t}6Yۛ>Y eXP'm& *oߋ"t)CX=D0!bX*m1FsOI7nY{íۤU M1L+0 ؞/bq2wt`pKm"+!0ҜFj2#p;rXYSxт{Ae|/\X9hX(} C 0І@dQ܍l[ЄA1:302thQn"-kSq*[5͂0} :)"RО=R;h lel{c-ɲ%XF</m 8I!ɒûX]<'8ž];R#2yc8̐ L}}!c&#*|ȲM rOш0 =nj+ V1JKTBj ²kۜ[Fiƙ,tv- P]y3V:3}x[gF3qʅRw"\I<};w`n"s39?];gb^ Zpb1 |ѮFprإ2@(@e/'8Vl^ G_^|T*T*ۿst)Cy봃ٳjigd?ɅI>9g]dR)k##{vܴiCN[+Rr5䙡/mo>u3+.5\ ߷>|?_oR\剃74$at!X N0+8~PIU4EDAX„2gm;t=, %X,aD&Zj]Ks8!,KUݽg gz衇*QnRxM/[d=#͗ٽ/OKRZwm/BE`DjkX 0dg  D04i dch[`heVN͝sS }YY,񙧃rqάt]n]'ύm\wWe?zW̙tWcn*peۓ8R  @X=y@&Xo.>?L͟L-RB/1Um1UQ;"PոS9" @ 2P+WpAANv}^ħW79c`s]p C2?8{|˕AY yw{=~lXk9mIן֬[u0-@CtщF] YV"܏<)M gd['p±ۮ;,,̕RXkkK21ǔep:WL4 !-6& !ʸPum_e"us3IXb愦wsZ{8z0rʻ!1d|Dd^Tn媺t2ʅ#o<_뤢($2#gE]8Jo\AG`eyHש^MI ChM (;!7:n R r y=[,cH!ls7 K'Jdx~PiH[Q[Lŀb|J;2`~T /jJ.崄qߣ<5|NiHm] w~+n=}{ .[1tMƯfŗo/vn#w^ᇿE_ -M(y&"9,;Dki!g wo z{nN2rӭ764Ii(Qʆ0-Fjgf 2 C`Pqs\!LAQ4Jd hULln?N`1C*1׋H%m4r~ӄc@Jڶ}ڵ/YXm]")9Z+6mۍMTb8N^O">oRlmm1!-[j ƍ1ƀTR*{ UFhњhspz֤Hf545L J+&g 9tOvY՞H 0@i?Dcٌ$R&Ǽ7\+* E,FCۚhGx϶gw_m RQ>1X9v *"i;`Dcq &8BXF9s(#Ntav&65DV&$2\80m6ük6Tp>U]՗zUۿٽ+\* ]s{w9}֬_]GI҅sZ{i[N׎-~oYe naF+7&dPb6 GڏX)pxJ>UɁ.\-XܩPƖ,2-Km `(Īƹ !dHcVʍJKDfw]B_>&ܖR@ZsB09hj m-MYF [6gq˵9JU,"2Tcc֭[ya_Tٲ6 &=M 0LM6ڃ~h:)ʧOZDJ42Te!Y@8  `-if%[g4 i`p+uύKs|[Z)'Ioҕ.]#zk%ыWxɩ>]w`U.Ps5 M`!@*nI3(&y Ȥ:۷-cW}+vgu`={;:N;.^te~c膆[n'ҩ _l ƲtG+(IQ L·yW4ˊokohQu%8D`"qDu B'>:=zͷ=xu7]?kU,A+Y&H+p '|-^9͚zP(BnWl1kNg|xKc׿@!B!,"p<*=&O`f4D"}{O>T̓(+z8#? bXV &7 c j*:voWj3k׭9uRy˖ֶapMwkLf#c#l15EzjB,chW#K @IP8C }AbVqN+5M;ޡ{+;6TU,`c#y>KY~z5'wmሔ@X`mRh2&XEFG3u6˕ hCڻǗBƸq⎭FFNJBCcy͙RpW)DRbOƲ@o^⧷mxu˗/䒕MMߐ%"bq;Jq7E+))n -[IlU{obWdbhGؿozǟ{9fW2U PKD.Ԟ9-,K ЄmoLgk `LvE$Hd*;"2D3%u("m\.'SYWӵ$Ηv HH((`n[?+ -?.APr8=q'N؎0tq>77N?^TBUDJ#;#AÔB1b1 VC?jPNT&d(h8#Cd2Mfrk@` &DBEe {*4rev__Bt9 ,Fs2V>ӕe{OyB~g`x䅧o u>tq>[\)ڳs˖MPA/' E!:1AOZ`CF!UM lٰp")[XHAvbW͛bʠ?۟{Ap1wN&W~Сlm VYu왾z%CÑ:;;Lq+ӝ3{K2^3 ~令O"fՂ " !p;wxO=E/|kL~7`G@drEK#ů4B155ް0($LJ$ԑ"Uq 2VXzϽ~E{Kv"AWH1lq%s'#3JGI_|11;cX\g #Cb!B8c" C2 DR ,-&P"L&j,*Š<\9Dɞxq8/J%(s-JF# b$MO:M͵p"5dR  !ql2FFb# q2/xm4pp'aYmmmaKq339}o^SjU*yU)1o;! 8 gRba4I$ `0iT, 1dA6߽#wؙSgR:=vĩS׏\}|U6mȐΜg^@̵D|ּD{-/Y8.( a(CF+ReRcnеM͉DIACǂmfϮdwo^ >Ww:rzÅRa[n:up`pya>?841we@)pĔ1PR Kc: Zi.8!MD,S8j ԥ߮->}XW5V 'yw}zW_my[6ݺy9+h8 <{-7zc| ZnH7n (4k j63?(g25Ed*dh*F*iۿhϥnoV*!=)::4ڐA|dxoXha@ /z5Z{dfk:Ҟ & eQ56O>“/+lYbi"ЀR%5:~q(1-7<:ᛄ&ٳ۵` 0 gL+. =ϚfT|&>XdL!$qa61fƫ/p R;4+8٭W6![S蛗w9qj!ّ)IqKm5u [t^S{s*,aGbZ,QFJ+$`R?T'CWPq"J"bi:+ޕ+BjjjS[!'#MkM\nOtuu'ֶ&&rɌSM> BQF$K~vfE8CPo r&nyDI9Uv qgDDVdzAT\q+kk2T+/^mMG'ʬNB-8 HA .8Az~b6U_O|e׎2ₕ2 XA<NjQ ,aΤGzs#Y;2gtx "B0%Uv鲦l*e#,_Д@+~ƪ-ϣ4Ԑ\p~IbUt $J#IEH8 ؼeU˖$@`g;sԩ]1k]'O"W t\w`xp \o476kG~7:|twmm%XPkPўD?l媿ܻbh_Ё(:e~O | ^5E}u7״҄vy%KVp3EJe.ݙ`cG_?;?u ס -Jpb"*0@~tg^bYCKkdm1[djL chړ:mZf˄uqitQ*x&?'3gMfoog^ʗ 0+!ڊxEQ{ C\<0`t<]_24r3wrTFm[RQL$+1Fpnn]jN( C˶̞@omTƙVu-Ĉ. 9qI6+ 1ǩOk/Y1laYdQH!"PZF12)u!G4oٶEd6U%"J2Z w[JRF0#E5- #]ޜ%Mut*=oM ؿ?8>,}keã e4Ih *yU {tqg`t0cgV9CC",aAȑR)t1_~gǻ_?sb6w).цBa!4R^X1T5ޠ}^v0 aZ1FnHµlܻϞ]Z'Nb}RL i4C+ -;o5+W/>y|_^Dܱc :;q'K""sJ*uV|Ǟ׹o`xdtܐB@N) ?uq/DeK_'N_d%CjIiF 5660 5Ãw}e/fYE\)"ڳ1ʏ@9]ɏ\;l]ژ #T2\܁e8Eͷ;B~MaTR}=Ct%T0MK444p%)URZHJӭ: ޠ5>0E\t/wו7Cعf`>!@)VB"EDQ LIon}K}A)`w?}`g9{hoL&=ߵoϾ~qApM̓#-N,>=wPHAf l'i˲ A$\.=ޜ@(km{ H@)83ƘU P)}W,8Xq܅z-z]{]&~W:;;-]6j@ 6Yax3͜V#B::Ǚa8:[y?WH넗 OL,\C?{lk{ g졇kngJ@JVR#}}gNt_yٶ* YWRYu( C4lr\\E k#PRKe2daUy G4g TSM"/Ow5|g;c. oJ;ϋ+k2Eۊw}d PZgFJ Ms-<#ᅥ %,KqYRB߱$Ox*121b&qIJ!\| #R2J%8{~w 547u44564\yrTvv̵ (\mL*Ǔ]h'? ٔuh!W%#cTkَp>Ha!@мm[kߑF! d$hgvsKŻ ]׳ I~ ۊ]GJ=IСW8 " `d1r!W2Ae`-k,K{SJr.qI1IU6R|7OȰj332fiG oˁ硈MGd ۚ̈$A!#uu bHk!B2 \@ C@n @@HG`ŭ|3p@xP\mRT.)=e:Nx~{u6̚չwO>f7`dh>z6_~Բ1&@tLX DJَleX1vsC޲s7OyHש!?xbq޼S\>En‹o߾}GvvR>䓵;[(bX,ςkQQPSqĪЈU5$C0jpR69Nx.FLJ%2 ewYNGb9߻[wyM7];2җ&,INlM$VULUfy5$$K3<4smݿ̉A)󞛅X壒%" 91*E0Cdjƌ⏲~.t0]A1\OJ3R !RD۬9Mm(9-u)ሐt.0 9X]ݯ><3Βɴpm΀g*hK7{326jwܸ$)0Dec I0Dah[SGas<\,?u;I%I&\b22kkG?E] pfT;g  T*-D S19{[)߾CodIF@!cLm(P M]]]MxL ޾>75^++Z߯H)}7DrcƘ*H)%8PJ Bp΅JJ#R0~x!lOR,-1Erxh!Z66q 7ōxAlfʷXw7y@JACRYqOH(DPADf98:fBkZj+6,uGm׏Әm*Ja0`DsnIkz lix0V)qTqm#*{`׎}CgG}}3 X`$:͵-m(EeqT"ZB$ں91.!jbh)C !2TMk֬93Di₤Ȑ@+u a9}Z+5IP_[*ebW\9/w4kR%Ȑ1)BplU.  s4B6U5MA/QmVϝDoqx JEJJ%xihL,NFýT$`I$Z"Df;ou;MYsjjP\z۷&f;rd>08:jŚ~sCCC{- &r8?yX]i@q 1QEbj_Qh6eYݸ Yfœ֎lX30<8N\9sLXWP(Qx@r)I_a鲥 3ښOgϝ Ɓ ^''=G@ !@ؤV$6 Hwq*u_ɍ5v/Z+/AF` `@e;??cY ֬[M_Xx7dozk,T\S@N0eĬ7B&ͼ7N7MT$Jdkw}?wǮ={z

n\LpөDJi&gTs3.8_`W\R {yW_ݑwxJ0XpAC}L::|-m}}x, 0?:VZ…n eNR|g9`Fo`օ1~J̔4>14R*(Lc R`p5],,/Bu -MMӧ{֮s}s޳Dt=/=J^oᦛoc'ګM[PH% p*3Z(1[y=mYWicjkk[=\oټ% H֚&s~iq> K;zWV_uP-'!T D2 @ ďDRM:b@D G:rWWmrᒕApǧ?-uut@plȀ ~%PXl;n5Ҳ\ m[RAWt&mۖ"E )[l*?>~vsZcsUU߻o0jnAW 62qfyl 8Sˆԩ.jI<.9AvkcnB+mbT(5KW4jlzEmͅB^,\dwLLLJFWw>rbPȁ#=_CK͐ҕ0&=UȜx=_wU} :t=P#f gma$m!@BX(L%u'>g86>ZH,26lmaXB8k R_zWd='Q*m.cuݱ1֖d2Iq1~6J9Ą?AՕ Y LX" Q i\Vd(\ۍy VdmM|WD=}njnl'#,aʪ;621kǼ?6:˷n=u'֥K]/KCC u.l9QM-ckq[p۱q=i!Q%lR?йb3PBoHM꤬ӥrIH+=:Lyjm9bζV۲08"R*&*b7~Tj+ 6lůZjᄑm흝.}g,eN#'`@Ȁp9UvyJQۏ ę'p1@44W& B32DDc,Am[aY^"rxC[r`xq`h<6~ :0ȓO|s?/Xd]Mu .Iq)$ `DDž0EcFzyaҵ ʌrs_ ! M,X̂PJ^wT}sgX¼}yX9*2"p]v)WQE"lm~zW4R^e<[12"xz/qϙn1~~p)UFq.vtM:@($1cq.1cH.G'؞p~X9oۥ3N,9{zO|uǚի7g.\$bq:ϬP.V;;;K̲Q%ҁ ĕp_oשӑ4BO  mPBr8Vj0R b/'"Θ1ѓb C bvҶtV$<c+" uGO:ulxnf56dr971ǀ06uY2#%(N3D|KKmaJ%DZRٲcgeJ ( 4Z;hKgB繞*}1D $z* ?2D"SlZ`w쨪83 !;a$$ ͐giз7k82?z"W6Ҡ9,a+%-L;e9pC[|  کFwe[p:/G?yO>{ؾRcFî_ԋژ 74v+׬&B2dTB!-[$Ҡ 6h)8cB%c\p3HŝtkS-'~A7Nr~txdeç#nჷܽwu+':BcFsM.]LmvȅVW* b}G>]-_/9yfݯj˿>1{<- ,h8w(@\? 񖭳gww5u45Yfϟ? Z̀!` F6 f$.Y+֦믿?ӿ???X{dʘS]wf݂:#y&}7n4H!DyYY̜F+VvhF hAڻjSU]ޤ|\7`H)9AgUe|y/>?w^s.+$E-Y{? :q9$@8YE@( ^l)uZt]*K6}ar|J+ $7v}iMaw[zǖQ2(v._*x;Cmg|7Wl3niqzoxza7`J2P)}m ?yV|s_Ï|}>p+R:*BY@dS)_kcޘ5$K$#>h#w?l=0&/O?^nFŶt&j6[T* |!aS L:a,Ddu^, 0AHM0µp+\ݰqzEαuv5$XGRj*&H/CBNȈ2YXZ:Ddړ ?sıa#l0m&#"{zrlX8|pO}jbG! b=[ɤ[JܨRX3~ow<ZHmt/(L&u8r}m2e-pA+ } _H|:8L.뺙LFk"QRr!'?w\b`].$[~wa\uf?aHE E'O__b6m6˥v'vҽoyKZGݾc+!Q$$r.'H/0 L׾T p{ ,g`6N..j<;wv>(qoEwʍA)l?&SzzN|;o;h[dC< LʑWN"}K^'GT"w0H\ !4rk//" ڒVʜ8۶nw?/3sg/۷޶w~W~K+&3\+bHq~:5רZ c!S:#*hxı^g}/{-=ml˥:S\P;ClcZbHStMVSc[v`/9qݦ Z #i@07[3SH2~`px14oQ٬)-gll)D!#!$֐`c0- ] gz|p;fOMfʣ =vta`{N@SN^oE򳹜l:B1כt&#"b{{8z]-t]5M)lU+uc G'%F*4&bܺ 2bȁ7kq!x&fL7`p&JHH) ZQ9ւx=ߑt6kC=z)HDkcH2!nիKϴm^z…Kg D` ٜ>tpȌYUQ {ζb1&2Srcs.'F(*Bcsǧ&'gϷZb-A rw9x4B=LZ0#CHJ gǎO^<CÇk s{Q\:wbsV3@XMhY% A1`M jU?{>֨NZt7V/?7ݴטs=[7m^XXLS6n<•֪> [;JL5wf,\ #-5?AJiBXd5d[H!:hoȫvCC ^zyi[SalTed7Jebbbhpk]wיް୷`.c:wXbI4 6 Iu(oTGlЁNG>Rm]߿;x{!3rL:9?9ԣOT&8X ]{xׇ?x۽@uMW@ dC_9sQ6zvˡ?dق&f[kU d8YdrWIA |#C/YK֒ ch`P95Z3ɹ>7Q7V@/@Gyyz϶&c]}F.NFwj6Ck$!"D|coכQG 6{=Ʌ h@KްvR&0Hө0 ds`•3ȅmfu7tu>08}7} ck(1"" =JZ% JG6+F,  K_fz1d-Tjb[Q2)qq^/盩ThHBk +)EfSahoy}Ņّs_DXk5@4?3-lZa߮|cnfxՆds6s L6A ""2foXp{% WLcg.]ӹ8rZXh0s wk~~kG\=XZV0j+R~Iҷ RYG+HeUk˖-۶mD)k@G< }=%猅aT7.O\6~T xHްJ1ܑ Jd x_uZzOQ÷ܷoϕ+WN<ַ5c"q i4CKsN&(C"lEkK{UZV2p]7Wv+0|J)9 C5^-.O#XsX25!dM9cDa:eQ1Ƅq e}ۻB/_K5'ǹ뾃۶LtwёGhoxh/}6/}\wWzNa00eۖbW;pV,p@.'mf:=B!9w&[vo-{o! v3]Q&\lB{í\j;~Wȁ`XV;:\ij5Ϗ\8sfan=3/\|߻߃J9ۺz<"8uv~=O:%G/yn__G?80jut/gП oݿO#w1tu y@\z7RFk׽z5RuMV!u|^trI/xhɩaʺҕȈ#!iN!N@Z1YZ33agXڵvlZPX$%hr$@БC8eZk,YCtD\1z˵&޸a`R;w+ό^s#}oGu+M`F[@jsz%֭f.\ Z—JiȒEBư;/ B< Lh&5b 1'|GoۊϮKc8rXZBFF'7r^x~nvX,Rt# ,@Lk."Jkt+~(jAБɟ)-.,JG޳svvV-w]o4?6zС7oЗ|>s[R\ZIe3H4#mȨ'IDpì1@&V)`R.xuB3@@4R394y:1 _v;HKYjtr\Y!(X W>*@~? 80œ'XV腱0 y/R2gΜ駫o{QZMLLٽCi:ڳmMQJ H` -X4 =`,l Hc;Z ˼\Ր<-đ^LNfs~jjl¥'7x]ۻc[PSb|Ht׍aȁMwB\Ml%0# ?[׿ws33Wtd$wO?rK!#:ʶDyqע%׳jG~/;ŹZG7 V]7ޘjq.[ʒeB*W|Jq\@gGr9D MNNa.ۥʮٔD4::zqc 8!y+0@ s)C,gmJq1|ϑ@n{HA)yNϕ/{N|g^[/_kZ.\ܙ <3qat+W>L!C73O=m|833yy{;O?v[֥Ҍ9})ܐ!΅q8d @ kA"t$Km,`vP[opMbKhzL/rLϻh@]\Ipp&޼" 08fs m|oШR~9RNS npכOw[lib[q߁C_{h`x薻nPC %@[0r0FK& ɵYNOL~3=umnݾy:"4I!,,%*!ahLJqh!g\paY| Uk"$cUTk:X;f58OA ,g7Tɇ%_y ͛뮑2@ӹ fP#.{T*_$\Xc%Xcl.ҝu Lnp 1% VpULDFQmr1|˗JhA)&HMFƭ1_2)wz})3Ӈ8|wW7H apѷ"`iXD T4TeEic9ylvWgϦǟ8X X6TT[]xˏ*}o0LZˣzM{`L3LWWqd +ύr(e~橽{okr=1qOo[o Q{֞ڳds+@py<8BKd;"vz?/o6(*wX3IX M<_+?y\uwPn޸yˉS'GΝ{G}ZlVmٲ9.ڊ8 O1!*¥ɩR~ kFH*D1VNl<R:@D(#7'_ȳ6rk0䎷)ϏGq=llYcYX'|GR:F'_^[k\)RƱ6n8#]߼Ci]*ۊm GFbسgG2ٌgϜ!l6[*W.^ceX#9zO+0Dcg.qL&U]7ٳG^8mϘ蹾￿gjzq~~n]8 xPblsdߚE}\KD{zJq5F^txwuί7sj~$.gU ]Ycy!\6P84#鍻C~3V՞zٙɼB9Ӷ>?T g~9ܱ÷3ŏ{)#?Ξə o:ppjT&hP m֪s&\0*Xz'?OwvmڲipӐ34ĺǙTbjI-Mm{x'ggfڶm3'Ƨ1([M5=9ӟu=@IoJ9m||tx ul 6ص;d,Rmeb]ŞĖԺ"r ,P޿[! Rrd-uf.y$322\*;kP!_pQZjm|6a3#sҧg=}a`!䢫6opO>8=>uԑ7lC gd "ODRK5`oMBHD( Pzq]]_=|D)Rzţ?v;2ou8 c\!E}}\sŝvGJa,hD ͑󗇇 }]lWW|.]Ԍd-7&8%c"DZ%RFz\䋞oncOi",%TdC0.@=rz~n|vYBs#gN@[+h4 <а{?TLJ_yH5 21"d$q XD&^ekl|ŀwvv9KDإK)(ؽ{#_z"p*nڸ`cȐ!imfRHRVQǾ5[M!x^Re1Z)G mZ@3桾wӞLnع1VTϝXT*r+䱓' Y"x(" Ucj?jURy3 J3}ܣ}W&3[rGG/?;wx1N.qo.a@ʢKiq>8+:/i I 4n7]@cp " ZzDX+щM,LҬT&npR`%h|}?"eJFGg˥X#O<\TzG*_b3o{r_ڑ{⦅DAx.u??2N_>uĦ;;?>{6 ͛7m۾5B`H: ERsL-fCiŹزuK{{;ѷ;nk4[1leʕ+;OOMMy sϝ43æ>hoF7 m}..mc\B/PIFHprYu+24,@d_g-ن c@: A@ "(޾f7ضo()dg.^giv]QSSn2=93~ԅ"#gxRYRzܪu3O<\>.vw4PrRh^ArY[wP)nǮ p.b\p$l^n}Z%XKQ2z=yǑ+Wkn;?O= xtm"ʕwobgTOxo[:sSsЖ9b0ͥ}sVYn3<,Fwu3Pd ]ݹ., /xgw.gK-z0;}s@niV'Ks- D3nD{&G ksf! ) foo}h)$KHNo t}/W&S33ӳ%tkՉًۺYWg[mŞ_7ZZZQQ"kN= g;m׮M۶m޺u-[tYx[ssG Ft&R*D0K3_Ȁi(t@$Cڦ:#~3%Ķ~bUBbKs\ҸC~&[@г~@ A*_|ؗ={>*[ȧZ^?oY]72]ۣ˶D=ZZK/ K98%EQcǎ-?v+ a2g"$j'_8q;82,) fXc2L&6V`d(+ttηJV"pҙ\OY- S6nj+%֍r@o1LfW^h@6_Z dS9|TBj:BOsL+w69=;w%`wbmDM )DKZ10`< EJs32]m}o|&w[o;?:O z-+TF8q`/2˥\ցYQ< FQtE aVs.δK?E͙yj^OwL*GYZ7+־<]Sc!DPGBPpgv4DsečbRыnwc~~…#v8q{'L(!8gaB )oerxo{U|!Zhxҳ,M"! DTJՌ0Nj | ӵ|<!mԳ_ݽ{Tynz~flO,6+,$5 R6 eT\"r\oYd$ZMٰrfqq dQ61`W/+asܰQ|-+7u/;~<Bc?@EghAHv<Hy1ΥZg׮bV&_$' Bu[[b:R x1SS6 6[&f(*}xӑ^Xi!!0nU\5"e׿8Yo̼tǞOg;# 4 o7'k2 D<,s7<;4Իcr@m071ԼF^losR^& C乙Xŕp[WoZG֑}?őiW̫gB>*!%gJ=DҞTtH;{GAYg;~ر?S'ܗ&]R*c@Z% 0Zdw|&[֪}\yV֭RcRC, \0CmnW/KipgfO>S$l82\)m}b CQB:SjKt`a\zBҚs kRƒd@nVqHhʦ]eSVr9߳{l add/cqpĉ~:777web" ŁFY0eg>tI>|dC872? jz!,21c~;mǁٷ[n=G0lp-#Ƥ..' ~ˋ \۫~u:+x!.)տBKI7[u6 Ge[c# F-6`j DU?wݗ/S#Co=ۮ€T|V1d67Ms=o$2{%3F}۶?#8{x{~~}tlLHlBtL%&=b\VWwYz-sGFLS-m2͸"DQYKS 1LJFp '3jOf2|>OdʂP@!%i褳`Wj\l3g GKF;Cd-C9Al9Rual+`NFjow/oh7p9KWM=Njl0J7a_~?nA܆ȅ7T:ښx6C/W6?.![{&b2 0wg)ZWGoek-gA/}[o*SZ Ry 2yH3|+glIْp|'x52ƀ5JwK >;#ņl=ߥnhz.7鼪sH$MHYXU1*޷3b~)\xA+xROwOVk__\,YTڛ2ɬ {zĘ=x`*>k'2]w8,HHH6 h7N*eU!RqX`L1`8{J_fZ+@襾k2kV [F Q)^ht@42 Y P!(r %h rݲ':{ؕ?'\%@j `3>drQQZ3-F=GӲ^/ _P(x7Fq{ Co-תJ[KSO>;0yL%, Q6 R@d2äbB$y? q(""+e 'AbBzjp΄(Rm[QpsLlY0..%^r#0D4޾n9waxǽ\ؚ).rNkeI؛Xd\iEBP:+Kl&-Q1i5I/޳ekOgg'\|"1 cm@UJ%u9fD&,Fa|ŧyv=z+iI k~Cۆ^rAĸ%DDZKo֫?؏|g\b<U qt \tE:S)Luu v9$rEځ|kOߺ_=Wס\}ryJW'&!Tpk&sξNl^ #/\xˡ[Ξ=/gfgw]7n6==oaBVvv~l&޹s{ bTi0֪TkbLZ/~ǟaD@1-O` xfgh,y1_ 3ƔVkpc l+h)IT:Nj5@Wn$+M%X%=LҪ?UQ\)Ėrv[hގjTھiîm1pjf\"ؕəHlܼ!ZA0 !VcM>G\yLЕ\JJg\oT*G0lȁ/^X6n[tf3]jqёi4c cGy:굙l69tsʮ9CU=2Yw~2 qxP6{mTמ:vM cHO=#}=??9?76o8bnn&\k$)VW ]gTl.H/ ?_7dz:mu!F<_֚a,ח|?n_ʟ=qXmVZ/-6ߴcd÷rOްo-7mژNgYڱs򸉬ֈH;> ɴ pݞ2&7= (yqv3mq#yը!cȚ\Bı9I|Q?|c3o߶8d+J,Q%i9݊օtJEq*A 7 I|s[LFl  23=1/1/]:ph_GW{Y|'mZDLz4U3g3a@k>3:qƕN/WssSdӾ391=yW6tBGgqr-@\R;nx~25[VHX-8ZK[Ge;ocFњJbWv1 ;RsNpط\ɂQv#v6cHπE 幅{ᢡs4?/|rԓg żfzfn>xp]gz7|9Iۮbl7jҹje2C Jd߈(i3D)$ 8 Fın-Jg2 WՐ7m3 x:r@'a )Z> rOWؖD 9C$ %<")*M\z'1LJM`,1Bm!SnZXXk4)*vTHDCp A -qoQ#2N H\]xrF@KUl n!\b*/И%Q:lf8lK"#@baDT0)iy;w*h!\5r~xrTXXョCׂ+?d`LSkmR%0 BHa/M$’EhdDZ5Zc<i{-;/aӵXZ<}fs6aܓ<-WNm(Z,<'f=ʣ' k=uا5zuE+h~S]]|n[lm{ [7Z-wtu8 $0JG 1B8h Ҝ "l:1.U]>a1{~˾y VKpϏM-.,0d H! ڥ_B%iG֐X,V+c|s)gs ݞ<ЗB  hYw@ja.gh܉C B1]^&ǧ(f#W: 3Bͧ؂NLl8|}^xh~o{[ϝ=Ao_GP-DF1&9bLk؃Xkb?`i^佅"A'!^nI2,k \SQ%K#B,+|[6RKOrnԉӋJ+ b3977U,yFƑDf۶휱zN ! DV)qf=$KJ)8)J+eƹRkW[4= Zゼ@r,ɗA"VE/ t`oGwǭD৲Łv? rezXhk4ZT.-#m3X8RѨ/r47W@.V rSF}O.>YFn \:1u}OI7ߵZٕMj "dX(Qea [;|2+._YX<r :{?4V},, `MoLUBߨfA[ Ą16NUo6$ܑ{sΆa֚33r?b4f+vհWSS5x;wʴ?31={6 6j%0N5j (F[kl>RDMѨPL2faNN]RɉKӀvtl*??䉁{'ҙ"g2^94,{; )#<$!o.T1dJFhKkX;/,_pA@"B!OlH5^k19ƵoR0"  ~wvBրVqGPf/ݸc];+O|3JG9>q@0__.jAy`]T:;.w]IZkVtu^$No,kC/پ䡉g m) ~W/nhV -W,i (CHD+Wz8ZG$/N} `ϒ$¿Z{2tQ u_-.K Ɛs`qHH nDk 䁲"l1^ o W\Zq@j@H \#2 ^5^ 8^xlf 9\)( +}"j5a?ܼut:krƿHDL~ĮRv_UafQOdȮmZy߾t)P"d%_dpX\H"r= #L:"e0h(eFa %t,)dIJ7 fKZϸqtlR[\SoO?ޮVjI C0e+W6}ӟ}٬k4)߽i }}O=D@C/^hkmm|>Wȣh&\&kάk5ޏYN멋K}.0tp]qDM,"Wu#븉61oƒc^i9eR*;sV.;4 U}0 }6ǟxppsǎ7VDQ$rb F[+08U RVDVy}o>Τj6?zqǷmVBOwҏ{H@w3F,^+g+O?vq`޳ aZ!%V 1k,K{'xKdmzU8 -M/u21^Pjuڏ]X \Ll>\nJư\dsٙJߛ-.^FFfJeBTd5X8}W}% {.=t6#O###}}G477ȥV_k 8cZ)4D!0:Z6Z -Ujͯ=~ gsϜ|S|ff?3?:ͥmT慄Zǰ,D~6K dY֚!5eȀjFqljz*']OZB+9nnOe]ۊWELDE-VjL8ZZ 1*^=k$]ϯ *^( \cC erj5V+R]vZ eTO{syS=c֣FQHG';%;dyЊL6V}WE+ɭL̬iDu+fmiowSGf:Bѷ:VVs!,Kşץ/YCX9 !8c\7+kkrr62dH cŠ4-˲ى?/ҫ+U}J~9\K(9l)\5uuZ Lkȓl\H.^<9^8Bw?%PoGDZ+eJl5cSlG{GR!!%fBrGtTU DdQRe-?џ֕+\8l&QJhm\p$$"$F#CW( u}(eXm/Y"O+fa< LMMaiT.Y"u=sl&s6 JVl6yF+DQ8BJn:;^1ƊVRJ)Jk箛'NĆ!Gh"Y\S-53,%dZWW! 1 !*nY/uY KD6  YuýRHq68q@΁sEǮhr`bbknnnff:"D L#c2:nZjK~ˤf$\=5txB[>-xⱾ تVj+n8 HA`l)l_3d4F+yl)[;*T-Ђ]Y2^y'q rL+u2`K|[r-%NӪ#t7zȳK?񗮗(P@ %vo}?`-0oT7gSHɩoܑ::J sjơ3fNh AA0Vsч~gw Uf?qD\9}zaqs&5;ryabjEr]wm|`ʗ aVJeMMmmfinrr`˖MLR]$Ȃ%t5 Eq+/1N֖kmsRF*7yh +FADlq>3;u=G:Ahc5 .8g, eU.3:]'kEp'j Hr)C}ѯ= Q<_𞽛R(ߝL &u-[:::E cAY溞RlCե(!"VK2(Bjް˟!)8!.k=eR+20B2KumFu NFEP!rrJ6 bPřqFsNdu,ȉ_ܗKAw\L܁7DqA cPp7r_.^8sӡmKJzx$$,G9RJ9>#* fń9r)47X)T6VU.%daUd 2QJ[kiԊB2Xc81kJaVeۆ{(13D5 b`7DKy.baqqzzq s6:%ldN@xtl Z#Arՠ=U};~FPi+pU@3Ƙ`LpX遼0jZAG.OLhkaiKh `E)%W0;Htr4h!-B m Ԋ;Q&wXKt%IYGN~> e >88UΎ}{;;5EaPLgQX.(9$ Z\++d W1%]KPK8DDvpBHAMC+͸m-""<D!9DiGH!(NuƝ{L,b-/_++f`UGәbZւ 8ҡ9EYP.L#_{&}}OK)F.^VK?/D6W V@>QB\:!WO;HJr"kfa#oՔ;QqS )>0;Wr{7oߺc/s~чWm9" V~> o`Í;6l{{Tk֮ˍ{KϜ>}y8*V3BU~ȹ顇ou<5a/apݖjkKǎ|yq6(hePmS`Ltŝf8c*Vmžl>۪6TBFmm{ 26"GחdbPgWHLʹ!&m6B^H{?nTG1裸p'|vӻo]G;Q6J sF֜Ⲍ#!y#l|[OF )RƘ%oY,y2xp{!@2%z)kf9p"*H[eiiNF^LÝ5ZKVnJk%DdQrYBdIJ1RbM`JL6+%eVЪTLmT-͗ BYփqXjy @|>oIt6[Ah6 A[$H$VWGDǒm6F:stc+Rq+*Z!:z\(HE6 o^2 7z:r$H\. c@!4C՛<[d^(%t6+ZΝ-YcV^9o4h:1D1+ ]!Z+41dޡnҭLkl|#N'W/mZiђX"My.H${76V+@8܉y,LXkKqKZ;>v% L67qe`}s\cKru]OYk] 2+z:gmyDqJ$xԺo;~=DF!BPhko lbwPXTz{9|cbƆg؁ٙ<ޮ Ea41mgrzyt'7(TMUĘ8CGZ#4D[D!8_k~pmZBMD@ `h 6i)%rG)3FQ;#Oɤ0 Z= ؖ|l .NGQDZƐ0V(ә X×gYrV@]T^wY}Vĕֈ^Qrkc15U{s<m4tOgpAx(VL63ۍgΞTʅBި͗R"X0Bj  +-yԥd,ȳg5eԝwV)E󙧏?z+ƍCq NMOAkmwP@Q\Кd@ Eo>qA7t8vvuwcޞ>)`3Gr$%h^<1AQBGS?2e_ggtuvۊwᩉmwqǝ/y)jTZnsRH_lݺ{DU"k-A/,^'0<$$N̕6T6+)e:%3QZA4<8rdc/"X@V,T*cppcGF|?=T{uQ1Rב8~9ljD*!]_#׋h"2B_?m|+#`O(89{r)C@/|^ ܇VtM7Q#Vʞz^ 9oo `oƒ_owrzjwh??ڸazf~玞9v oO~ֿ"1-~3rʹsg-YߑFcj6ZHN_zea/VDdwPO-Be6:˽:EeC0<:!*\|\0( 7ld(Z46"twhcFTpM6I\>53ӻÅ4#ǜn-:oPfU]Hu:5@u]d@`c]?ŹDc--{"[Ka]xqtt٨#zz]wuN>}O<zdrӏ< ȒZbI@X2YIroD4^;Bfp+tmqulm ^}xQK\3hq"aqnUW7@@v ַ}Id8ua.}Y{z[)_cK1 RsAtӍJ Z8n2iBL (v@!^R Kb|v37b!W}V Bh),h9&B8~>FL:8}ayzz<ӛdž(>17?g۸&xJ2_ G)ۯ:?/c4Mjc7 =?7ЕߵobQ[Ÿi#jxHq.F  شX[:8:=1_*CϿqSwyw~??O~=6u4|7t#?NM--f]%g. 53w 7,k_v˼wϞ={tQcMo4#8n yhY#Tf/$@ko< Yk@hw+om޸ ]2D@F)!5yKrmff]*y*Xm%ݕ4N7mڔ[䨻=ޱܨMNN9#eo[“YS]X?!+}ƛ]EXU{KDnDJ.`3z9q&7ꗍ^TFo@H)Y! Wʀ/d2Jb|AC`@@]0_ ߀ӻ7F1$@uhTԱ>bd?|G1 +%IYW.媵zww'ILZ\g|Qzͽ}ǎغuˮ=/NO??ul{?cgONm/5.N drd$ \A0Gõ_W*_tw3vJ; 4Ml2V'en[g%`c Z8?qny~O_yu;l.4Ik5_oKœZс<l8Mc""ild~Fac@h31~)Y!~I_M[ pF RZ&g0^%4Cʂ'(x6M#\x&MSlkzi|tS)O⦡P+-\*t'Q$@J,%J `C`<IMu2̊Aku3M@Bc ;T ;JʮxT68vR&q# ֔ ap{H/=jkN"Q.wھUrҁvyb+i3;,L R0O>|QxũRl_}2jdiÚ=- (1!ǕWzvmĽsHnOKة-xHZCC}|[nF sN j%FZ&cRCΟ8ؾt'|ϗR~_-ʿ-O9| Cπ@`ﺉ5vFЧ?^[O\Fy?T:?~LBtldqǷ;~:tϟ?LlC/*vy242jH)4A@Z1?kq!E)qu{};W\I+wt@u)`o|RPFm`=OhheH+sA7<;?=}s<% R ըVW+|G^8ϕJչ =b7[F>\-6aˌqRAeCo;HK?rd l WPe̱v ՄA߀k7?3f*&b]BZkR3?? 6sSR4mw~WZ K RU:N5+@Aݰ6a pbk@D+ d U 9)%EI(Rɜ﷪+/^\Y^a&DŽICA̋KK |>=[FL>9wvzE.%DY5) < # Ą2Uh# ב i? >Ӻsِ%\/sDV b=RHJ%fV9灜(R^mڻ{Ѩ Э r<4֮*pp{}߬3Qݳo'C/|;w'xraW>v=4z ,җ`XV{jz/}K_ܽ/ޞ[J>${n\LRخi8yp:?;?>_ s?W>!hXnN8S?ySR+RVϟrMD,@EdiTWd},Tн ӈxG@q#4-8`A(bW@@X'|^bn"J"=vر{O>5ZGΜjX+eC9Μ8pcn9~fiηFʛg..ԪmًZ+iE`9}$ @(CMşy~C$Gaڻo=~taa_'>vEZB)_(h!cVZ,JIZ tz6 BWXu  =H _cXiaX/:FbRS1bٱhz1 (#si! +O&&$PZw ]lN97S(v;֕bq|tT<<` .rb9ML킜#B8K5@fSLB|d1&;r# O+"2QJ\B󼾁s++J)_8L̥:rlڳov~Ӿ AUcu,\C|Rȭ?+^q6U@ЧϑЫ5H6TwsWv:Z.~[y/-_DI-"ֶOf=b;~خ}{/FF.WTo<[ 0 M< -,rGՖGv%le/k8VPJ۱W@jC [>{۽{ͣOdc\ ˍJRy> l4.Z)|;i/<~?ϗrO?Poɝcmf39]*BQӔMYGJIG޷hVX}7po?s$Qےa|}a_ uǯ߼曪8u5:VwψN"+?%ޜ@&P> v6d֒"1()}wMe:b`B( Ξ{P=苔:'g7Wc4VJP򼼀oi:kLk}Ŋο=O2C${w8 |(=#p $67~^<ۆlRv-CCiҔRY&lHR@eߍO,4N igXNLB['XnAbys\SB؈9_Fo35zFDb`gjD!cQ i"$lVU#%/g(.];k"gJ@oPJyOz#,1[bT%%^ o~]w95&1B4[kf3RH"#J$i(\bH+;O,4ݕ<1[K;︭V.U[w(*'O ՕمX^hq_&2JIH_W\ls p,./l߶ݿ?>я~S'zSb_)vWd1,(6ԦљS'%28;;5e\*>i:?hW/973yN1$Z*믿l%6:5;@̍ehhs9ї(i6Z $Z rjJKWʀ†zz P'Yxj5{f?w3 hD`b*(Wm %/ȃf,~PiTk6-Rgm Х&NAйgo=.oa~ih>, ],4a" @WпeR'|=ͿPLCʩ|(s:\zӎlx4y“Q79⏧9@}YL8Cܲu( azgOL=9svXZmE1)L0u >twm[V 8N$E`A]ξG+@*iTⅩ*z n"f4 (Z>:#?ӍZC@@.KKKI6CTRڪ;-gtGj7&txU[B;s൩_H #G0&2ɜx#8_ $!D`R0;{?qi&xad P&}GԪ=z˥|!nt}sgvd= sgF)ZqLD &rrD< bZ1T|ٿ2hRWDZ!PsMZBwMLTj+![Ͷ^=z c[-./O\26cgvgAlb`"*[I֛m \5jP[kȓQ |W.,Jcͤ9޻wV@-l] H\.+mϓΥijցR+U? j({*jsϘ$ hxs~8Z_?¡Ο\ ]nN cQ !KF=~L5 "jԁ}lF__N" XK!WkFg8<ށ^nd@ͧ乓j/P*(X F @׊ڒT,狥 tL&Ih$J ^WX/шţG:|HGexT+DRsk?Ja>u8q'>AW` " fNȦ$ɂ# $֛K׍VoV;5Mt]̴8\e"-$H@|/בjCjYʌ`NuěA\O_k ]9ʮfK kT5W|p-@)mi+tܔB°x``R>d`OX#29"ZK0) |(MRK^F!,yhmsᗵpf(SL1Ʋ[Qjv܉vݨ7yc}7?є~֮Kƥv%g׮uŊz:e'zH!絣v1ħ>}k՚[^QZ߿~~~?>?O?ֺ!v)$z.󋍕|!L6r%TRB[Ҙ90 sIob$+@=sՊ@H[!~Dm#ŋ `Rb+e &˵ej)RGK1l>|(A^ AdflP*2vm]4P At )lǖRR!`+NGGRZXUʅݻBOxpȉP Ξ89y|_w3˵{X, (d+[x^!B塯@f hR;PLj3y|#p):ĺ5еUͥ;)l.8oؐdf`d ^ooiBTÂD $( Dp5 BJpv~Yy5J1)I  <r9ֶmX t*}!iv"Ф$@FԦJk 9vyjurίg|R)#awۘU`!$Rn e ` d:)S^9ٛByp$'/)Rf\38FLf 432`4iC01L <ߓr6+ݥ͛pxOQXBIpDBT.M[̲  Y@Gt슻BAZX֪[?x`D; B}GE b\pN :(rDP 1H""CZiBA];'s3K3g'&+=[o-KvG~q" &)X#5'ե'OMN9&''FwW…Vj @2Qe꫈k YB ZͶR ;hAH!V!v^ {C4/5Hg…%}XJLqefdGֱJ"q̜iͣ=զI 9Lo_m[;8?裏ݸ}[yW ^2F#Ê|sJqrjml(0`6+]evveiao홃/M]N4%48m٨G?B6ÛR.tUJ8YL0u\6lf|@Q:K,o- \KxtX̜Y۬WrG#Ө$\K.sA \C=#=7߱~c?ך` bwٴZ6~rKS}> (H F&bZW.Q׉&&,XVV rvz{FLv*wԈ|4Ztzf{9pR@L./-az2$vye#l<@VC+nvm{/,/M:4qSfD; o'[Bhњ(1kuDp' Kڼ2K㹗 w~pW0U4k~/^YM _&`Ku@tl7k A揉ʝqZNPcjBn@3I\-=7o޵s4QspI5&MRfB\:+\\N~tەɱ`vpŌӺUaʼnS»8= w$wu~R9ugYXh ) BA^&)54Y*{:]8ëM]!_w1,lWu^vͥMíbx},(O LEDNjP_:ŽەƗrֹ\[\`^T(X |"d5DL;&chؿe# 烒o9 f)z]l&;w?xjA x~#Kr~@߈uSJ9dljJ˞7oB ƉWx}omkȤ' d^f:2G]g 4)(7Fm,z݄>BB)(Z8kJ{gM N I?{}{wBk[ &GDN  GkT$~R}@@pXeū.yk6A$s:Qk\Sh zѰbK^ h nk^7t6[:vVw䅡"D!b~o͚i4zFO(s&G \ !E$I$&b0QIZyibSH=㶝BJSDtృRظ@VX2QXl5U}emd %""b>iԋ^rfI^ I#hbua9[ aZۼgG-^8sP;GQL aKZ{fnAH[b5Q‘6d Qsʂ_!0gXc6B@̼>Qd.-_!Wb ,*4d)<_/doxMUumr3R,e==1N0[e^] ganA#jf"toweg224Gݬ56_΅0;1=?ICIRK`qD@K@gk"bRHue |钳5e\cݫl_m9fP@`SO?/|ęV3=/IdT,B۔WhVVV*.5nx(40kZnB::e,/VN H\)h²b!:&jp|[ax|>#@!AמJguHT*! 0̃s@7R^n`,RD\![v& Q1Cfݒ)|O|c3g?J#$IoXJοOч;nbד s ԑvufdxT@@%Eqz/RGHk8>sooV1Ưjg&1̅鉂{>?ONN={J+H]bS,§^:V:~Wsϝ/sF8H* `Zo]S)+PH)0ÀkM%$ |+vkFժuݨo|ʼn ;m! مj=M Q d$9hfUϝ;_Q/C;E퇌 )0HP#{г]]}~Oɥݛ6 -_^[SkΞ?{z]+S/H=7]_[] BtT`V-7n2vƯ&Om?$~u4L rT劆FDa@IL $0;Ũs~P*C}]Ŝ[t c6zӍ\PͥI\JRM(#ge>3 USp'Q+NUQ3SI%;U._O^]nͬϧI<<6RQ0?+ebZvֺ5g\NEQ$L,`n+xiExsjKISBH4lO?VٺISI)`XP2+x38uj~a 9 P|%(&oc0[Xq̈B)JZ ݕс񑑝;vE?m\ۡQT?K_Ec\ѴSgu(3 F9k<{C@|+|; 'TJ2L&CTILƥvҕ/ p-_/OrƅC-& RI%QLFvc{ =;S􃰐 @J@!y nD _C*~oV@`)eSO ݛRiVJ33<ο}=Dt;^^]7,dsG/?s_|܍{# UHƥm3$:FJHy3GӉKKT.՛u/\]Am:=@4T(FͥmcjxfP)|Σ*oҧ_]uAZF@I .YWiQkt_f`o)s3JTgkFjZfްi r\8#]R~lcvU,JgPZ"823\dѱRWˌꁍ?@ cr.HvnioޅɩbXz{+##b~T04|"AgY#$;$I.ɑmR%$bk]:$IkVd_p8vR;w'zv±C.;]aJ/B Y8MS&|2}B hZ?~oF` …{X1se,ZBJ?bD/XXXL!-@nvn^FiQ.Wf#Jz"I!xfJ"]E·;np=3K)2#DA X*9!eWEӕ󻄇~R\\Z\~||+9bJZ%خ9j sBfr+=RFZB[16ccQ;~ 0IRJ]S^i|][6++ AhΝ?cxzP(4U/93?F_H NxE @8 c _h3W˾¡ʶp!#JT*ȅ]ߊJ}^ o?~^I=,`!%4?Ӥ@k;"R)rYOZm7)jngWTwuWX$fKV`0Qݞ=@k-O{dXj5(j$*06U"VK'.L5P:McĹ0=xln"V`oUgc`I8ATvjfW7m6[Hu~bq }ߊ ǃ̭&f}Z|vٜnEbffH(e"P"(s#!{_,Z4<Ī׸=t,.)8(d ヹ. 'alѡg"D5YKAj&^m#e"c"3CF@H%δ~@쵦l @P3RZg\oo7ݴk J)_~ԥ!P /mZs0ss [?oֿnHHP/sfU) NPuuN#_P*fqnsVJ0JJfBkPA]s)XaW:zTl5ہ"@oOD67%xRh88sR#d^g&u l?رW/4F$0ў rlnyi$熆WVۭX /-wm#ʼn夆K=&MQJsyu` ,/P*XJQ `@ RVhsFe%(%3}|jH 95 ,.,n>~Mmݾ}o>SSM%ȑ i;s'5+᧟YYOot|] &@gCxr%X;vNOOaMZ~^IW}Gy{Xcc_YnGJ BκFNRh$-B>ۍxuV`f;pTUJ9%E[h |ж!UP_< O-5O?nESq6T9 v=J)p;Ėm<&NrGnlN/e|{CwoR˥1j E@pdAN랊iPm&&(ZkKS1c)O슉 Tr^7^@Bc1aHRĭ;Cds?157\بx;JZj/3I|y<$IIHR;DZk(ŋO=R@DZ }ll󨒸R]*wv!}\:GDkaVW@îeVoØ4MS!BinF%Y"2NW,km@^\ՂzjR ;QZ^Z5ET*F@6W]Yឩ$TJxO:yL~)XB(`Q_v;wMǎEӈ@{{{v9 ғW>5Ԩ ZuM^EZaI1e:1ŲsPP9o S޺sgKgůGFfR XR:|aW)=Dm%wQZ09ҁ&JbA֛ٹ$T&m5[iBA{9%Q7ٙY'g޽a&|Z0DQ 7eӅi./wg<#z ,"8$ZdN^43 P‰'lAxs7aSƁ omyY7ENMVހ4`p`ޚHMֺ"LlB򅗵ϔsu2 @# yqq<( |9r@lh]/K7ScɴDс53w 4O9AZmuiqgyKRd Y-^~rV>jJk''/,t1/j^ǟDZտ2R//H,٥8k֖[Kر@ÃRWڨBT1#az~.nZ)_i3g 2T>=uq|HW&Z ^0"?}j?W 7HHBX6IDLL) HCDlT,G=i`F6:w/|Ӈ^|i`ޝ{K_ڼ0ajeqV=_>ԟ|}]7W>0_.n9rRH Ɨ#(pٯf.2 婮^Խ}21eN y{}(@DDr 4&QJDf:8D)Ț p@ Y _}#?xDz۶P].OdY@$!@(PhM(, 2~5M$29%ؑTR ڦᱲ#`SR~3]}Ghk'gΥְiEHH"n~r~r_ʲtGCw (v P02v/ kn O|uWkܞRҫMk'Q)bv@'2]&\8=*Jv`A&]G °>~g/|#ܱGu3gwww uuf.S2sSBa.Dp7Q NZ&?oZk%eX,7 Cdȼ֝_ Z#_z .omYn\FO1(yͿ&{4[cA޹XT*Eh qή#x)6q䇀qxJWF7M:k8՞wݾ>Ә+U 5^_. !`[ Jm CX$mK!HvJs|l{orvznv΁Ѡ@"Is~85qs{n_ܩ3"v ijRXgَk߄I;wyPU=}b&n\H:"b6Q  `c#)Rf&ccH-^u{$C^?_/ )ȑ P$sv]V$B9~MԖ$vwKijQ6MR&I%&4W(xaжiJX`lR (x}Ǧ9 mTX8gLw[_\>ygf XغW ǡ" *&x |L `$@ V+(9Uafz! kHFA\蜐/ EZ^X\r[c2C)SkA.gLL>;)ٲyb.عmKwWY[e9%UL, łPɓVۈj|t乥'!ZÇ@ky߽w޳ĉCssxem)#aݶAqD)k1uEMހ(_uR'}߷BX,4Zfl#~1ߨI] n; ;DDE  CV`&B fԙFbGbm?Uq=5b(tw/Ï49UCGB? ْcV1HF!\οW k44yL& h՞6|>^NrE W#GAD`E SdBJk)NU*\γ&IigJw%B䶎mYk%Q=mx(n tjkonEij5†xM=ȟwXzrk>;9X!E&Ri4afk(_| lmْhxxW Y9_Q+1Cy=չ (&PX ̝C=ݽwǟ«ڭ$тDSG3jI!JI)Y+9 !T.\VM{Y8*JgR$ڷ9jZP{ #[{KϮqO8 S3=#{fs38 %F:fթAO)!тzdS<[dz|RoWWUJ/S]w-Ks O<}Y-U)WUzG=: TР%fWgV痦M?;sf mczMO? Y6RT' ȅp!9RRZ2`-X`PfZˀDv&̺%K+~(x 7T|`ǬFq9 !YN-/~]or>`(َ=?F1O<;ߞ8ow ]v_u2I[gΜ۷0Z1-DAwrE^OTK))IVLeqoҦC_ֵΚBxMJAMf -u&7 $l5{?7;9"@R&ת= 6%BIABT2_($DXYpIyѝ6pNolJXxf6 [ jlWJ`$EW! 1FK r9\tþ҅Ru:Q (Q[J/<}`aiA?awh{{9vByL֥;x@+ fcOjEEi 4LD+I!o7ߺ_YPpyR?N0wrfj\^0|g{遾_r1)V>;B@drB/8xdl]]3A7#Ԫ|!/j ~f P䈁=B>ae@%dJTmqhM0>m6ZS Sȇ-%$LH2H&`+$@9_[`] A.J7.5(Ԛ̇ЃѓPp,-5(dZ@$1xM(olbL L\UH#wd07t¹{16-7v4Mi4l_8{;rvs7_, tց33g8,Wu b("U.WJ0 !IH Rh)@i N H8j't,D @ *t AoihxS_w` B$C!z*Rll}X)yjf=st iepD  r H 8Zs)}\_OVHzBHv$@>`ɯ~oY 玜sCF'gI J!&os27r}{=ߘ܉S'HPju(@E{n~JzpﻯpO>+G&( J 0CEp@ $4_&^qvj3ars2d],Zƶ斧~3߯ʥR;S8taro7wӞۮ۷wd""cJAF()Te{"J/d%RJ+EFG716;\Pf'[Uz{gߙV]N+ABFnVVV(S#B2BDFYJ!Y=ijֹ (>3>O9zKm[`x}rv$64.׏y`]LȰ4,V˜8?9HBmY~.g>}}haqitl|ǎK++<|5+C/s]C$isEyXs% s K0kVy ņ(? J NR(\Xzg9wr-82] h٨mvW Y2,eP< ~?x^/=w&%!=_-$^|w圈uJkgמ?'7|k_@!kYi_>'[Oz RHD1 9VjA8bB[N *)-C}w]6jDJvݞb+ϳְ#!X !TdC_y1} K{JC}aD+C_$( 9)gNIm; )xû6@!Ē#Čx#1@1Z$xʝ9 2FP̍EVdј/cMF ,Wq]WZ0( Ibҩ s2$mwD+ih\{an6cWsK'&a)RAI< Ð#6~푼2@$BQQ P~C~6HoȠFdPJ^0"u%f L\@J߻usn(nA+0Qgr`] "{(⍁T* )X83sȑ5#\1u!0!@Ƶ[s3SAܻ\o{?+r.N5M'뿰Ү/LVVW z:.UWgRWѰ b^lJo>s߶__.Q5I/{Șx){{C ˡuwWqUl)2"C3i)$L>O7m;yy_gfȭ6(jYAQ?sx{PaOWs3,@ F)ףD(/P~윱]_=|PV| AVJd;5+ _wuwNjd_}#1@Z`ٶoIP#`bun@ zp[@j/6)YoL]mgs?Q;PȊ #wǶ{nzbt@vtڡ p4MO)#@`SV.M6gW>sބzyͥށx*" :ceA<:#+Ks_[tn'NϞ9c8 ;ѯ 6Kg`FGcgŦ1̳+6 CP AbaR+5 WаXYWo3@4uϽtxff||?ߵ"G>U)~H. eM.Yf H; c&M[4y"(ؤio3 B|SO|Kq-Z"0g&!! X}쮌XO# \bFp.\8==-A~SO}}J,`Zve(~H 2jFJKNoF*Ϟ:14fҥp6/<ۭ©#\t`V3dZI,d%F3q6LJ|hb {nUʗrf[+ZoXeq%.W+ 0X3(X %@*֡'O?[n_@X"_rNZ80x^,7A I$m] @@4ƶIҶϿBJHT$kSPf7(22}lz{JS$) @JP(θxn8(BqtplSy;v0no{i+9 D(L֖Vg0݅JBAB 45&=E7 )ǯ3=3m侇>^8|/86F&gr 5L\_><*_+dc(λxsNNc|~ss=Ž֦ߴs<޽3Չ ! _sƝ_՗C\w]:rht#1hJllRNғ(:ŗrTLqw_e k|{\FF ʟ_.{c[G//,78PٳsnvnnqKJ:>s h˪7N=&IbDsa~;RR,#RH+o:({Rk`|Mz}ZFIRI̺T㝉saN{2./3]$ZXtTr }s7J_= ˾Ek/ XRK/~ K{'M_1xENMT)_I6jJlJXXbJ)예 9ZL@NO?Jܕ.1:xӕ<^\Uyaiwj$ĂxWxǃ2F$gv탩T2P ?O0S+vٹ?8|4$BAk9J+|Wib~X,R ˫z|Wj^.B]ZRX@K{( D-BZRA14@Ssg3X 8 eOX QN ""% z,2'z>nNW7ݽ*:^JV  / EhG˵dvMH2x->P>[3/U9ٟ+Ub[\yxz'\ mgug-s+P[ в3N<JBA=ЀJ0@ġKm m|oe"s-8= .92N &sX u;zZ{oٷD7& vm;qdwO> ΀P-[@YqE|[,ĥJ ve&0d *tEgxc--L#qOGt WSa,On\IVJVǁ ^1#HX'LZY6ZLB!(DC ^\+e&u1̅Yf =aZJ@?DtYU>ϯv/޵ךXM/ARO>׾vzkᇥϜ2\tz4Jp)A0poοKh{|>$sT+ekT%zėp &$ 6$@9A`Ox[)pK#bÐ EBlb]l=P(%6 Hh 2L|V+T@M\w.FHI-D9(Z4(N 𚗊 &|[VE9,Z0ȑWL^NZ)%1!JAWb"g3c0,(xp|NO~?uѭgO}L!%i}]~p 7p #2 |:z*"Whh5hqu%-\AvFܹ mxEYָLhsž7޲yl#:,m $HlJвЊI+n٥sςu{D+H!K ԾGE0Z sVJe܀z `PP` ~L811#K0ܻܪ>2}| {&*ooi-G~͋nفe vD+i "@[- H AXj`  D Ǟ;HZ`cSWB㹣PC(7TW1[v \.Y$n\X~[-`󦁽JM i}wLmiHXJgV2:(R4; |d:>ZY*;o:!3% ͬN ĒAjb2D2lH~'*}#xlPș4] !TfFL-"FfZ/WQ01"uY'~q&^'~$CBqX/9];_z,47z_;ᆆI&:+|.}Eƹ'_ƴt{]'k̊W$eszc qSoTbc UR4גmNZ9"PosAѭPL˹BN*7N>+f'L7{i!"p =Z2Brկq!G|2 @o1+[bˤQ&lڴivf^o.o֯+cc}v;;Ξ=;ys>s,dIhn1'!Nȁ} +?m޷y{j֞m Z&oa˼Ls5Wz5FCt Y׎7ncTl:׎ڡǞOLMVZ{~~!,iSQ~ZiRQ*W*]eEMl8!TIsu0;֞8P jw&=fʴجpIƫdwwnDhy@ !#mgm(i9 /pԉ3>ʤ 8#%sCCS_0j?:5 mX9\3 eH8ZRL&mi~:sNvn󮩩߹mX\Z"[mA>7} Wc?xPĽl\x ,2ܶ}úO<_?5a5+w,B݁5Drj 28?whq|rw^IF68= F苶MSЧ!}y+55ziW=dsF @2N+rZo72xęHDԂ\y$szɰt@("D@45$b)K;=kIu84H///sA\J] |T&mRJDȗLII֬g)q&zر#u䇞l4M9O Z2UѬ{{A KЁ ,J}qqeP IqRt^»ǻ;ė[^dk\\55M^Z]b7_V ]-LVI r\UC0_.w:vގPB_^.^Luź{;n.tA)5FHᘭ#k,,2"2eYbדM,TmvV},PhLfi9FHޞc yhA\V P %HS(QkD i !`ggT&D׬6]8uC 欚bpl89[&/0,*p{ Q H{}ɣ&Olm{K'th?W/۷iez5:= D Et$!Ly :69H-$Ǐ'.AKgzq|rr .BR]>!ǟLJMў_:XDkV C nݼ[ I,F{>vSip_c.~"J8P@x&fa% `FH-i1Y"kAk $2#Lj9 wXV0#Pj=g܉SB>}7\'()㔤RN A!wDY%d_,VZjyD1G.˗/[hָb!Po mٲ%ԗsV*  X,i--A%:kHLZ{Ԋ^G;N(]jgS2ujO>5yq:ȗ';I# PJo?/{wugn`ӭfFگ<9o}㾧Ƕ}'ܾw~~y`{q~eV^|S&aֱd:ι #LZ)xxxzu`WWJݽZC#^Q `X].r;"cG1| րtuuR[B qxdŢ}F &J 'H(n eX];( zBP,5*Ã!#0 =lċ>X@87>J;;{zbyh'Xru$ŀApA{9[^ZFBY#$}I{vu1tSVV]#7ҁwɥť玅Cv47ybbunS;@ɲ 4"rI^F 0һ[.:jjbdC X(+H*a

|#)&&@-+J| 2AB1`O1)p,s2;OyC<_z:2]wvsuΜ9&ͭ6CF`iB. Xrsd7y^\Fn}G&4v.%XI'kubo;Mbj$ꪌ# .3--l|iUb-&f 2~'шu|VvV* $ :nS!'BzR<{~w>95TIHM,%g(J=]Oڵcƶ [3N6RPO65<>!YHZ2-CR*d*oIhǎ=)c;6 ]޿Z`"%9vR]M8{RRsN+1=^Z뵺"_sb"")YjzOwOHmaȾc'AfÏΖ0th4[Oc=%{'zolB̌ 2M"*(rO<;3u& 41J5Gx6V Z qSǐÞBIti~]A1,IV}]˲bSVvЖ䱰wO~/_1|; z }L{f,e%#B 1d@)GgL/AA$muȦ0qZ ؜]:u&YjCg8?rӓ`hQ;RqjgO9~ Ee[&{SH LA+Ƕ*R[w悂Y\ڻwC[M,S5fk% A2g),-,m:M`P+걧j.//כ3᱇~S{wDG>}qf # %MĀoiCžAH!ϗ gg@RZiLbzq::_b)S.fE` Z>r0-Ӯ{Jc%8!dX_|x?;G_ߨg?6rº}&/43=û=1F/ 7H?{9(;Ew "*oӐVJkW wwe{4v;q3'HQ[q|7IRJ+4xR}Y?|l#<:-R,+a?Ap0|T"H]. )X~`?GP1;X@Lu9O7ib[|{:s<4($fɏsS_O8t@w+R3gfiVk5a@ +x*z-}D@D$B4, ߭0M|/=XՋW.}핱#efɧm=33C}$~qhsZf%RJgVR./7.\%|ԅV;__{Wg+J8"m=Z9V6Y+ImY塱񾾾x?9I[n*]b1{w)J52[W6qcGXVya%/%WNrX&R$Nzd^y|\pʁɇ>x?Z>g3O~+'_n>?+zW9Yc " A-䞮µ`xxמZcEP# (gHmAЂ B_&mBâkőPt>ӑ}h? .Ҟ{Ƚa5+0C,rT׸}ŕKi{wUځ1@)d R=c>}3/]Z\bѲca'B$ܣh^N :p>_Z hv.oG𱡶ōe2<Z3k/_N]&`b0[H5\6TȞq+{"xB{ǏIfV/uXkCj%}xϫkzsnnarH/0I"[ej6:|DDKem.d~ыًgύfcuuYWh}ZeE,+Zdb%>>Mne/_+7.G`^pgJgZGE`Q K+9dMA Z"h%+^k㘻_~fF4^YUnϜzc菖k.;+n6N;!nhm/?k6M߼<Jj[_<|(*Z8Nwii Њʇ<= 7c$Kɧ?О!IBW_}鳢qI~<Y{xhhil8E( {ZY0֊8狊K5xeg}iY(-^ŋw.E¥ ։qγK|##}Q꩓3 ';vxL}ξg~7v,"c(V4?pxlh|3~3h Q6SϞv#r~|?"BYSo9:Y# 5ܝʻ[!" P+~/5ERAC* #gj @޻86.kkiYRcffo+7&)̉~6|ڻڑVl".+WPHt:HrGqPO‹(P<ĺ{H+t6σ v/W[~*cnZhڦC['w߇KG!@y޿\>j\7A`5JV+_ךwN?ƍ\W^XgSժ5hhJŽ-6 R-j͵hC!33 jeyVK(0:I}xū7=NiM8,#~`Sm] !@H`(g fKBaIDC< i;nz-{rrӸU2a,FG1ytdVJVen{=/ۯ=qڰ8ʥnv?էFjfR9bTwp{?]M3_WGyr%†7O}돾ח6.I;{g̀5]6A9C!&-Dz $Cj˹Q-*";I7B< u8<_/t 'ow|87tҫa5kA_ag- UP-{wv),JulFGWxGxVAwNDRlsI^X*l4)$nkIՖo1hҾ{`@|4`?'.:0dff ( #l 16&Q>ǯvGY>|0Qerv9]/vvvfeux$ZQg :YoFhXYJVPJ "Yfξxpv޽ lKcn쫯^|oz;InBJh h`d'_+:x3O(qInCp)6>m)ec%0fcaډ^f(;>BŨf޶;q)(+CZ*P,a>J2(F٬x aE^㥊  L5~;,4-Jr5 4Rx+F=o2mB!ߊlAQj3 bq -i$G ͮגsF́48w qh UE 4酹#kRD6[޹}cGFjݨZY8saǥ[% LoXW?%|bgz-]AШׯ];Xۉd$m[jQ"Ǟ Psg/:0^ϘoX5@'ZfVI&p+y] RC#ٕ儑V8`GtWg>r9t:[)m䝡]KVrF7/fd9yA Ή "Cݰ^)rdAYU jZI9Xk2ӧ}!oNxvPGjE[XN<+ }zrib+JpF+MfR3\:k|ʥkD_*sL3C;+vRY~|C jvTJ#MSI2ʇGfhW )r a*II=umn "Q?z`=ӫsn~L 34i5 ұj:ع9 DCAh%T@eXtې\ŭ埞Yz ߍuX'5?6߀! A_fVl-baO?2<6?rHƵoW.ETtZ'=&ƪ/& JXGQE]p帯OUoer=dEAr'Nz*ź\tAhf:vޅtǗf/.xZ:5z˟::weJ}gLDqXXNtk$VF9xɹn`dHD<~ʍgi{}}nfnn4hR5rqӅ  80ys{hJC']'/!FDdJ =)m?,#58 (#܆֢%[sKFWV^rᆪı PvMLVAᔑeEGGba `sU^6&Eȋj?/` zW>;ݞ[Yku A--<wQS~GO,V6FkqVDFV-J{{iRGX4Pe4l]?E+ f I 9 ZmfKa8QXR,@AF޼E'&''}V ɒ^|)@nQ)Pu}nu2Zkhс8qԟn˟/_Kʦ,gXȒBZ2[QZʂ[Jt{"Symb=s4F"{uvYK@( D$^;?a :2Hl72Qc p]AA]` ?[$3[k9"Ta:NJdt86117DbL]*tv vȟ_y9X{하kμɉ{לs+7ຕ}{b U4SZDa}5Dq\,Zkg9)-D <<B0}TZo5kӨr8Ѻךo\F3UNXXpVD:D_\.תUP+EJ&ʘ--=Uܢw?ɼc?)x \   {/Iw}lk={n;Q\I~ǣ:rl@͛CcCPo(.rmlmnz֘_t6msl]/&B&!?Npp` c%&m -}}}Ey*jZ˲̮֝UqPt‹?}e6/Gh- \#;8]N@LZ)4T]z¥v+C|k]^ܷ[@V(B6M'߿xl5,w" Z*=xq6hZWmmb.Nk4e9Pi;o\Xx䄅óz~K'+4ڋkٗpijPfʹZ)s3@ i8&{Rs\K7.U8qoo.\C q.O{PEfarHޅ,+"lBOP3|ALm* @oȍ >XHV PD/aV2eN88^;+OrA.@C<7o|گ-瑷l-9ݩx޺Zb> -A`)+\$(i6S!ᕥ MlHg澫U@ٹge8k#lh%[+#T!F[+ BE l-v.LH[~Yǀru;R䙽Q+ L덹]&nk2*oo,Pm8?~/ƆK_ ͙Mkj'/j)r7DQh1:jʘJX+>qAEyV0Jkfβ$EtB "a&iiߵ͕%Ҫ2B!Ber%!&ȑ}0)1zfp('|ȣjϟZzҊV(X.Gn&2BM՚z6Ofd" XLJKs}];¹Q?y~$طIkZBqI.DJD {@Ad(mOoasr+J`B=V@6['EgϹ!i86uP 5aD<R BzY}̢[*\ 73Azu.56 D[ w{Ɛ0 mz+/ {eجDlhJ+ks*4A7ͽycJY0+F{_KFsq/^G/NޏL0mk( Of-d`h^xkGqy0s ͬ}u۝[>eষ 9N-? h{ߗqg#ьqpr& zkjcRe$\@Anyb(-.=g ^؎"q jO;`{u 01;gY' 4ve푼uc[n}o[wdݔ:P 7!eȋ߈@t^{yG#_{65wn1vZVK Jnbv ͚;y{;0ܷkKo]?ȽǏ;>M`jϟzyA9 iOfƍDq36:/VYn+_[Xu!Qy!H< [>\z}7}=xصWYaѲ E/5 P%">>uGp#%˖mE[Π<OmB /;'E%oPY"r9i+yb9ѝNޘf˜-tg,-]j<ӧꍕl K=DP#x&&S1#GV뾬!٘ #D=vPv&WDYGȳ7@¥\"%n[5!/ & -h!R .C IQ0.J'*^.E#唶@,tZ /6GKuBL?T430lncSJB5AS+~j:kmE`n7TV#_|7_]0.,j_S׹b L9  E#,XA֐rCJ D1 L&@Ne`$M+Wă=^;hޤ路FE[flgAMJ1]i=Km 3Գ6E DP ԛ7qsa?KfzmBDa/mcBUthmxw{^-"IO#٩"}4w"J+"Z׹ތmu1 Ru[HԢ.ڝuG߿Bؖ;דּjK<vPAKoITLDhjuj% yKddKg ?1鴒Q?2:+4|j藿kc(2ҥˏ=`{` ഛ)C#]Ҟhw;()@B ]Za/*wRKN|S(!^>G(Z1#|*2NwoourvޯK2{Kf]V^k͵j|c9iv55T8f P H:y%0yמzL)rtk817?٫1=W4qlrI&Ͳ-/??/f1&r.Lg  ruX !*f& յV`XGJ1{QJ`KKDԶ뀇Qkwy7/ rg5 k^~=R9ٟ#vqu\)yza՗^u?J7O4$^:ZGJFvbݮ͟ԧ^V}Rhh8Hq|ܜ~瀙YVQΚkNk4Vk/ZRLQ}ᅥN]\Z"c6*;ߘfx'CFRdҊR#A?gΝ"eTC 8y_e3?j>%sD L$P ; D؋ wBV"34SM#m6l2 :V6Ktj\7\9}EV7=S.{P2WUEś7._-ΡQ?{/| :`9d6#BgxױCŵW#Dxq(,T`TO ^XIHbzK#:8#LE^v ~ɎAndw@l!&=OtTAEXm,'u"F49w`790p ܅gEDHd ؅a9o3.8s"S{cCcMNR<]]*%q$ *^\f8 Z`,!Iia-y ap=A"FsbImQ9R$4:W˕j_Yֹmi4}}q<4::벲-E4WqM;QD Okt9?29yW_z#?;~k]ɫ xDqt5y{ f'|Z>=ʂ"Nc+X8YHw hbY:TAh|`!!Fx;o8TQB콮THPk'Xl=zzu7‰晙敗N'plMGgNr"g-rxRe}|ʙ EɻEêKR89P-O]n/aCT2"" *z$)کOE1:~ퟻl?;h).g.׾?s@9bwmRwz58Fc4AM|x{}Y*쳟y}S{Ko" "c?;vO"OSכP24T1pG*}+*2Uf߲ XMΡ8nW3uzo yPQX&lbF>6IDJ7H hg AJJ hd)/I'zҢ ǠK <6*^lqG?G/_^MqQ\NnQdS bR5,0sJ(8ںin"iMUhՆ}a4 @Q18o pֺ R$tWd5ީ0AN kVXG__?7?aTjd l[Ѿr\k$޷wd:3wSSqU<_3&J("gm; ?nIB\{ve6`Olm,b#h(*g(xB`ûigTf_94fLP&39,DJ"RmYCiZ+w %RڄɑrhBQ&H\Ʃ(`v4G0BjѲ! HQfiUxR13}톏CWR\]+{O?Z{|@vQ-8Q^襄8<ه=×nHV:hHC3G`:!G`L qt8D^=#/&7廬hNO[~e+: Ko9+6޹IftI@Ju-Q-,1I:#YV"R@oZ9Mh=¡fm$&<7F;<_Sɥ;4;}Ba9;ƅ܆vReZk7..Յv>S<_ᓿ+Q8X& {] 0Mӵ5[@yGE"@6+/N>r҅drr =ow 6yRY#VI%©j*C$pwON ?z::;&ȤA)QTF`H^)#˲$"&f02wi{mG{31<Nq@;G>xMd@Vo2%^%ϼb"NJ[gA=RF;Sbn@<30Z_Kp?\;i~s?B 7Ǧ^8ɲ &^FY[nBqeháa?:26KJuU_gbuwp Qk8L` 5ĉJ[\" rQ2ƥH gX])`0p7h,P!"ၛ+cBPnƢLΎ,s Toވ%+pLM^x Y&?Xw=zo?sUg5UV@$8 2Q56L53q5c&h!"40,Hbe=!2 P-As2=EEqfKZś?~K+% xzv+B225#Ufa]0fݚ$xϷO|dT)HCyn0f' ] N"^θNI YoKQAZ^ѩO~on6]$6 uA7&,Ex8 c~AxT__f>X}HN'jrE;QO߼2~.I\N u:JQihx\@*(p*EqXDHm~uƩgߍ_סGgM[?y3=+3_"C݇?'tle4[Zge_ vK-.s`\ B",IR#G'/ ۻ?4֞zo]=g'pg?{]X;:a47pu{оǎbPj䎪-R: n< "o@ l.o{o1,fygQbjejnf{">禄CeE1휈snk[x {ﻉ[Rl>p{\K<Бx`ϒjk7\/I u\R̾i 'lsfnhpe)xXHڅ={W ն I23lzʕgg&y{Չ|{DlVz}/GF 1&Ҕv[yn EIEQ=zC^ڀhڛ%VP֤w+߯[3 .<ɓgE^K%;z4_3 W6iL1}²QzhRud|bܳ% 7Eե{[v {(F꾪tg{mq~dZj" %%_*3b%V0,/UG9SWb~ *͌7* _ne)zU6""=? )c0V5ϛkKA^sͫ׻37@ec52 q]|i2DʄC3'm"}յY' {հ07G:{G[.n,ܜyȡ9NEwj꥗NVGM8$&GQ)l> Zu;k6w&VewO aͽI#Ŀ#NjS'qAn>y?r\P6w;g_^>s#x 'ټXS*;W' Κ}<:~`NT %iV*,8wQhά `47ĥA)fIopk-; ehmO2zIb?';Hϴ뿙5;?+AxϾ}ΟkÃ>@C#Ve,͂3:2-Lߜ36:D 7OU4M(=FH7Z$ +VBM >K3@0=Q$i"A2u@"EcG Tp_o"SSV,ND+/E~'+/AaR;1BH#IYJ)Ni@{a-`o|a!EZ)M QDḄkPh`1:YT Zb4SR?/Mx;aiIRvN.ZC̪Ic 9w=3tRk7phϡ XEG = щ=άٵ>k9ԮEAbW>M #F#*0ˬ81`!foFdxH^6iY[@-s5C;ryյ>a1ڬS!^yhrA?՛;vxK[!h!{6,UJyEw> 6aj :nuoSwХ\g;|wɟ&ҙSi.|D9vEޱheD ,;EF+u-ۺWG*Zz.HDཟYiՔַG|Ⱦrv_󀛜c :v,ݸ~''NǃDS1J):URG6I)#C"E<&''0(""ǧׇo?)Zj .A|xZx3ds˹ q! RR KKG>깳OJgIR:|n@8ZkIkplh~e  ad2FkwmTJV#A=#l۞];2NiT=fŗΡyHή.1ʅ8y߃3u|k>*?z218ss6[imwȆ;J 5JA`x\{a8J1 MPlRG@ CD[wpt!R:g]/+v⿕ًpqoܩ*={, `..@㉦gD[FCL%ɺ>/D,cp..r_5YkqA+&") ^X)5<62w JحsLz*O ĉQ_RM5 IYEMskɺOr'C_t[=\v+T+wQfajԛ{/MRxfhYѢw.Pa0@"|Pb8 ᓼ9B =nsov?URh]C##ÃOg>'gtoɿͭu#t&y>SlmtXyAōnK&AqNMN^[[R/7AR!˓ИՠzS|Z9(*:TPY7#ۗl=%BCCBoO3kLT֖',޿g|\\2hƼg?P>|djtY$)iME|pF/®]O=ӟv:/~{Kkj]=:4|,׃uJ"@k҆4 <;ȋ*!D c*@f[C1MM֣a(wT> [^]C̢h>{? 4e %\rm߹F{Dމnc}G>xV('im[:cAHϥsܜ->ɡ P2H#SWNӀ Y{fP6Jieʳ\ ="R|nw"ZieY 8(HA]ÞgY-q+WZ|jdp(u~f.?~gIdfs+JVpfX>-@wHy珐nˉ T=7O`6m`oDݸr-7ޅr aQ}T1{4 w {3?[j#NQZ41Q>HNw8UUp+_®}_=g--M)_~Z٬Uo~jy|-X[kKg8P5q gm7{JWA^Pk)Ma>i ^·˭$|Ne.P`()*J Pl#w(gYzf3<XmI[Tm ʝNM ~K2Ɗxzy>_?4q?[gسo߹\>;fTqcWt)kr<7o{qnZK\*,̛ma:$l5Lo|q37 G_>Z4;K^[Zrͱ:?0!P&qݹtݴ=gב]jCI/^=ř+נu xRGz3} o.N=q}ʲ_j #R* rާY8&ޘvbn'2J pv9^zfP|qRx%!r5Q){.˨윛 T a4jsn 2|_XYϾ]*?j+Αd0Q)ʯ jJZZ#v^x5WKӎ)BQ⒈,-t:kO$=FvU*% CD/bw^e"CEVo,c8<._:W^H:LHف~KtG\XKRWgg[鋳qKPL5֥it=L`]ht%H({qgg1:8xUIûMT:v`U* N4#P#{3^қ2 ¾!.7rX&Q4wl&^LZS@x⯔QK &&*mJ!H:0Mc$Iej$xx@&)[l +r"]'~B x+e"j+ePa}+GCzR ]RE,muߢl*|7bm:|փm.Mk(tAozN)2N҂VQтBxwpڸ>c?xZwey^Z UTH@> #k7%="*^XHffrHUӟ̾A|}Y%;l` U!ksE07*3ҿ.6ڑ*X[m^t!w|ҵ<+S_zG_^X\^Hך''&,{ƙWwMLLLj(:|uyT2X/켧Ҵ/ɵIF"$>u )s$So HSoYMLաȠEC ԣlG[/Zfq4=w}\:00i'NPkֹܺv659D, 8M @{mYq@P &RF2!-7u9ul1,>O2 T O.\z/,\84"$Dk$P)WFM%n6xa>{MIb;`rtGo/"셽ڠVسk*z{p<ĞŕEL3z|IPAڼ o?} -~(;pbvaη3@8S&nN6yf67¾hSkQVP's{*-EO xz#"[KrLۻĹVX'ZcCq\+C̺-X7k4z|'򣗯LaZIkh2^"r`v'& ’IZyy稑E[NzOdCmp-mt_PoUt6agP%*:w,lw;׻@=B);60_!`ֺ6_OO^kvV6񜹤W3eyֆ(Q{ʳ<EZ?g_FS'x^)Ai-J{ycvk//vKw]ͳ2^ s)DvC:?iwIO=:6ulڿwn=pp CL9v^f콷(~~1I!XgB mܒHlhlpm;JoT5. 0u`bo}{_]k6.- >Oy)Ru}ͥ߼xY K. =zHZQFߖ\uZQRHj[ŇwO}AO2j>nI.a' *C{$N|B7"tK뮓"89M mD p9¨Z Js/1נRܧ" ycSs}XWkY ZtKA}}2:Y8˲3{ 䭱̆M-`,H) p@0æ(PY9W %! eyf% oڻ_;.jZ􄒄+>@pԩ&)I1׿gOώ?1s&:s>X?FJJ+^XJe]+ v 塀uZj lw bERP5j錎 ^ZvjoƉzS. (Jm0T;*_@,ϫ*\n e0(9 ^|ҡ=jeyee!yn ʥN*$$R|{hh#R;ifJ}#ᡡ,PJs@44?=e wN5<88<8xrf&@k?q^7nw' m  ب}8R]k\̙6[њ왩H,n^88􅥡#B,Ҝ^ 2"G`*6at0(6U$:x&"Z[ZYyk*d`Q9,WԏwML /hrk~ƍ9Ӝ O( + p+u̻iz:!]v,y%FCVAwY"E_|W|7NokX&oN_=_9ԻPƐ/w|H/>:ӶOԏn^]DJ F-|5@]9uk{ؾ{nz?,B#4pj}Wյnk0Hvn^nvnMQAJەE>*O(.dRPZkeA)W˲>)`Y][$4Z+py??29bC,d"٬~ }^ukk*s4q40×^q[vQY  L 8*}C# >g>P R f@D@i=]{ ݿ-:궎Mm& [k6ye}N n'I5.;F rƥ\ C}YMxy{kQqE6mybj,r8H]"޹m'W@:6Q)r_X|e͋VnZRLxEy&|}j:p9l&yމ2 17w~/Tes(e+ a3T(h.-w٫p)!kgdMgWoJl& U-3eĬ˕(>͕C]w0[_ngn#M d3-")Fu|4b3kgfdetqO^B7C#p٠@r Q'vdqemkozW~k_zp.\:a7_~e0^8~ kta(ךhwVQI!Pζ|iy;y^+^zkt.(@IA)"޹<ϓ$L (`bt;n37Z9;ixO*ٛ~wR>]x3%#< *n~ˏ<.ͲR+P*i*r윷@ _r,T JcC^r<+O#4x>xoA<y/1=&GbY&, 0Pߎ+W/,8W~G/ WAt@i߮;C%E$ s$2)Dvmclt-\m<& "V9o3򝟬ROzn.#S-li⧷[& émE[SMT .) Ÿ3Kn <=@&R<4z"hX6Xx tx;3ko+(Zw~wμϽ:jypPyP(j5=)um}adVVDDnVv$&9)LLoɗ/ ".X^y;Z4BNg@ac b =/|}GQwC %#ne7oΖJQuO=&~sƏt)2:!Q {f@3_XXZxɇtQ\@6E=ʝo UPUv{r-{IE'JCiHA`lO˯hBA7\L;YsF A9r k0 Vk]L({7E;z`tcO*s?UCÝ.8 79k_B`^OJ{'t 2 (*vrs~@i R٘ ϭbv !{IYO8GT0f=56uې`@n8^[>oCsSthKR//[mP:,;=l D(G1d@^1)1!D JbD2l]m~ 4|cw۳oH3]rq'YlזZ+@0X#{Vفڽ{>\iTc~IP.O6&vSZfLaۇ3 "(@ͨWA$&fVJBޚ~'`>rއ_y洛" {H' H9Bf!0) a;^occyA G}^||+ODJ}0) BD&I:Z9hӉʥf=440] g E1hg>zsu|P?H}XPϹ֤ZVHnnNI;UU!Ե"@@ Һ^rJM3W/HHH'+1QBт(brnh L Z:G*(<@6"S2Ue=r$?{t]͹U$9G?/d>N]<mi'<聛7O~O{Bɽ񅼙)4e0 %ݕv@~+/﷭n~!.!ڞǪ0qΘ{w*bK򛾏:P6fK:o;iw:9\JW>P I6YD$ uEZYHh!MN\rȽG ,Yn` DgdIvkfO -RkQ)*K@+ ѐ(@̂\R.3q.ѪUi]YeDdh w<:Kduڗ_WvF{{8 ƾ;IDWH3N4r |q:k=v),,[ x8t[F6=RH޳wdlą|}{z鏾\M++B MH?IP߱~|ŗCM~WыggxÑKuC^ u;5hεZ8!%E% 1 paa'=Mb7t;v7dz{6lh%Xܷ+vuth-8"HIJ$ $Vt0lP]qͭO=?w~6jmԅFx}h3iiF ?PiaRέ4*SF' 83 6yDC7Nyu_f>s|#hhMzуW"rӲ;,^w ՘&A AtXߢ*!zԍ ߖ.뗯 7Fdo[ϷXm;G|w>TiIH"iw/_^rGq,ts0FDI:ꖭC4UBL]:.х .]A#ŁA6֞؛ t;>, RJ:ŤcQ݊:>?={ĕr xҏmR+Fit~!\rsϖW* ^v"w۷..W+)ԫӧ$"Ig҇Ƚ)Lٙ RԭV35dA3ⅆZjGqgҘ]wgY^JLNR" [Ԙ(`) -9k9!^ YI@.tqݽ} ~9uS R11Cw"h ?2@,,.:=TB:+/x?}.Ol:| QVu;oPJvZ,K !}Yu@on=p\V-zzfYcjL N[8zgۍ}׭{/<܅O_qNR5i47uL4b9yPTPB3m12:la ۼ+WZ:tY)Jqrs剅JCI0 x8u*iߴu0ȹB1+fj!h 0mw]w{}y 側4ENy̟ٳ>YHԣޫmų'kKJۉJ*{3:6 `6'oK)d!!.1 ըZeTd ##ѫ6e“`A>C R>G BiQ$iEmC횝1&AޛO]}vS=6s0Q TJF-};mz`o( jV!1 VK8Ŏ=ۃ=7'kpIHd% $ BJm1YKYul1^/df:Z?wc\^;$5ȴu `h4:Ȁ$ډ<Ѱ+_M#(5 M>s*W^+U?]W]j$Z "ЪB>"HHܺh =K~v@Aq|3+nfKy[P3 ~787/9]}u[o:IM<6:P(|ߑ0DZ|?c?7綇S.{*]F 8BRk;y8t1K؎G?cbܶsئQJ>sɲ}_ ID"sfI1H29  ݻU;՞'NSO>S$!>>6>:yh~ ^] b"&!sƁRQXڑ{g jٱ2(}LKq#҅zՉBsTZqҙK3/56R. .0WOԖ@X^pean^{dAy?PʇRA.7ؽ~>zLϾ0Z+ cr><+;tộEjquIr$3e479߫5"0f->Ԫt- %s @¶R68dƹ DBXt%]}O{CEJ9vf􈞌$6hn2Ot-NMPa&f8纭<~kv&MlH :lR/\qK#!+J%?:kUQj$][xEXl( d?i_JtS=pN"k5}MZo !t w2;j4(AĐy}ﶾ>J}թ:WzhJBn@)H{cuY)?wzcY[ͰD#9ϳ;iޮI$TV'qjR.@1| )aȌ !tʙ\zlec#Sy5:poin\\t9*X*=K/^XnRʾ ˕"Bͳ_ 6؞[H"aK-[ hϼ,J}(ҭ޺/|f=Tj4Zƕ+Nco{  rK;LJoڶ3ݻv/,Z02խ+K&&PkJmDb9ԝōjw{W^C#BZF)R 0~@3CV4ڢVV8G HqّΦ0iN|Z B.t[ZuL^7D 0zDBJՅ]wővr!]Oz=JʜƆ$ =1X.JJkrw30BU++ $ZK@f8vO-DIHIlrAM@Y9:ti#N/~+[9s MM\ZBWAV#@0pS_.i S8 w`#>x4 M53ٙ٧]co/7woߗnVD^jfܚ[BRhGft !F&Ѩw&;\^q:DkR gOL3vm_>w|^xFJ fҮ-r((@lG{`sieO[۳t7J=za'^>#i$ yD:05BHjDH[y⳧y8HQ⾰7mc۳cĕ˓gk@B0$Asry @t]GcػG1]ج7\,KвH 8JD3x2[pUs#;//=?q/(lȑ<3qP.yt1,uGn%z/n0zG*_]uAɞ! fbp1uLg#D fЖMIS혳M')Gd2}""lv';Ll5p"s 7Qs5ޜڃ#o !e0t!9RhoΤ$=_2L޴P,jSD؅aht{2yύ~4ϸY狁PWM]p[mfHs.ue: NgՌstڞ?~螎iL/\=HY, kmRT٥RuQc%Hހv]i÷  ߎLLØ4hyT;jh N=i9!!|vʙCǷkW[U?: 8 _0VC|LbpS3^>B"pN=w?pdG],Jg' ʥsy [GWLkqO<}3^y kr,c@涎mg7ŋzn ryDL hxm[qy3V#$<:ex2_.$y㍥eRJ\:wᴙ*O03vVto³Ͼ6׉u#E߅ol^ɒVdZ3>Kn!D  !%PvT& ܃W~TXo;],ok6AdԦ~[ '~!3nCt#awø'Mu]зx_9LܕM0v"ɵM$2NsCՕ??8c;pUeGD!-T_5/_NJW;m& Xi;~ృ6nCklmݾf?.w !E՟zf;191X{/<;z>/waA9 P&'RX*% 26)3U7RgW){;>}wٽ7.֬*UG2zomaMtw~۵3Hrق qlS(\,1gO4A%UDW*#IesmG p]WUC[@4aI~K@;8BbDǔEirr<Á~(4(:JcCNsn)/0` =C7m[4GwoL)[c9[(J2Wr)<J'?@0R׳eO~xDʊtgy'V NM' N_h.4bF1DoS-i;|8PkoJm%_9}9r9t~Jyaiř/>4ܷwhؖc{Jvvzz>)72slν25f:E_qs &6H%6&x7#/fFܺ K2&'*KgMv- ͇nPӍ {~C7D&rDqԘ9."N)|]c]^{7.uks:5&V5co UD`fcLS .IZVϽG:q=5~K=ؼ];`+0#oN-c#JJ'8ۣ6X "Ӌ&FF8f!X Wrl;?v잭yիFB)݉/NNoݽÛmmVt쁣#^ʃccW,4~ ]w lRdl+ /yV!Eے };F[=zs9f&![VRԢINN @dd, S/h^YɶNygQۧLwQ"8٠xR; Kd~aDtdge#Fʃ2=❹P lI2S|?N_0{byDQlcWIi` Πy ͥ$ڱKVZ/4.L9ݞ_DKJ3Ṥn_ 6_Tԧ+C^_ӱmKF-huuì,lw:qKu |t)z\&XJk=r=N;\J)Oy#/x߯! ,kӡ-#b׈TŽuI۷lʅ::5͇Ui/k#reKd\2Ycc˗*Nc+__-v7A :Q+Nr,KN̉g6>pP:{:=O~ I7sp'ѓO?'/N7˝rz%Br'^Ͼ`wDO HzQ.3K?;o ]bomc>Yo#RZvsj7^Omc瞦훸rSNv;bƚ}Xtfb() ʲ -򑍬"GvFLW.\_Hش;mnu`@ԡsyBk12y [4-'UX$(Q,Q42 + 4VuطmfgRXԒv#b|a밿c DMnE/N WR&Y )4Cp) cE̽)7kT̂q&qgޯ%Yd ۍ؛"n :›@"b8[v㴖...۝B"-l;vH(oRgc!pFIڹc{XDXAhk4MNN93R/8V(JnG[~yrk|!Bz=!6mSCvblRW]oG>3[2nXe]];HlNs|ۊXg!svuwl ~WN56PR5U l{hk|xQ@vNj/Ac} oV}dR C %!Jmq̩ ѕe5sӮUtnT[=2"/n_}~algcv,F٬t Ԡ>-DNMV1ؿn9ULקap8v.ypbpt<_RE(`ӦS{9[tq> F̺9WAp Ipo]Պ],tm!oջ"Ghcy2U03HThuvEWg2 Qt7!DK۲7~竧.\gPc>[_}rCm\YrӄBcdf4uJ:k+*愒fwǦ|asx|k vvO?fjӧ.v/޿Xb$% E6o[C6n0~ }70Nt}w 6%*%'UI?gM"ݷ!孭>[|u03kU5 ?V޹gG!:^>_n6+ӳg^(;8o%k6cJ!=&s8&mJCb F2DM"I=iLiDރ/eG?4<83/~J}b,4LKPw..D`LHSΗniD|_٥ op|Xi>9Oϟ;X$+sشxS~Uk[ Gۛ=F#^NpO,.Wϟ<܌נG[E*!ohqyRmb @AI$),`/8 0qܥ1#[șRF&,뵪{-\y_nQVl:&u߿݆z?tnmzlΘʳyRIK$fHUtdYo f jGܗ3ΙOW4_qٳfP,m!\E;d]ppYuNo?,=v;Fzt:"N;|TW|cUɷnHzoP?tK*] ^Xd =bNYcL欵BBZc-"B 1ihoW_s0'^ܹc3 A0[^&!}@I3eXkR NL,GG?S?d<4V¸9VJ^U}/wxy躺ݨ:U ;;0K|.?qybyibGJ}<4_QXnta{6syv3K.y :l6<u @瘝NAb+3KzaZ:z2ۆ3J'b6.!p)Yn\vlMk3SXhPN;Ԓv ^ }ဋh\[tt I87PJJ>…٩dy{hxNݛ?3z+ߨF'@Ŏ53eAKR|Hg%b98#9דwl p5p`ٻE{iH-\ +MoczhM2L߽&̎7Vk)&v; F9($ AΠ7vwR\wMLuor)LVSښ->;ԗSqtmc'n;hkٚśl+JBy*sF|ˉ~G|_ڇADTny \WZK7>N+W ~徑#=@>{jK4g&]E=?۰pW!W`5VAD ~s}޻<75S.sz@/ "!RI![͖Z0(O@],mټRY<54<E#Gݴi|rj*W>t抡=__>B0 m8g;Xy NE(l͖mL~DJ)WffG/o>;hM` 8UϮ,~v(E;ͷ &SQtf]1w7;q= _SaP\mmݳןyuhD/<gff^4^9[m(E(PoK~)7B/b\|V; *-xԠ/ٹM#T<]GGŗkqT?䋨Kl=Uۋdy0UD96[˭R68iDnЍ0R{7$cͪ4(B.ÒIy#3[笵Q蜚_X@ן |DvΥvl2'z)vz̅Fk̳p׫͸3Ò)70?4<$DWXjz @t?,#$k'}K}WRW;Wγu9XAJZC'sK@"CXA]}i%)=/1.('Ny}GXQ=r=wF!=m(B#"ut*%;uѭÌuhP{s{=];>uzfah\Ƚ+ڼ}:b/K^Ȁ鋯/.C(Th '^ saZ,N|[(<./kHtSIV}2ƹ(NWP!CNڔ5i8Lew5*^uLE-\k?S_ڹ/?Z};98t`kɴŕ{jS#[j_ g t#\ pL3 sĽ7tg3jz *)]W`mDnꫳS#./L R )B!x7U6tSs~^AWZ^{(jE|&\Mi+" ; |a NE-YD'#KfA*ҟӿwfVL{]չa]cHNLvj&}#'f~_NYྃ#CF>%3NwӥS`f:Pv4S«Itb7B Hd! IߙTb lYB;l8N"э<@R;>{rw8wop>'~ByVE bCƐ1@[EZ""!%*z*:|ovT~Ct!hC $T]|iυY81u,J9ۥ}wܫ!S/B &I[ ''E$cWb%$pd:$DL!=qiR:!шN4`@|.W_"5rZJi' ulhxdlvjUW^QظG+SN˓l'- V@LY@Uv)Q$F]kƿ0Ԫܽ?=s@1&T9(Ja \`wR(v$BC?vZ:X$D)3 b"zP Z7C v`ox>)-l3Q椓@KmR* 6`AH )Vڍ芉+Z):\'"ۘ^nM.VN VRF`CbAqKG<3k*B߻u}/ԖE%|1\r&pK#JqZ t$DyAJcsP't^I s 3Wg'^ĥR)_'bMtA18r!A훏-Ձ?|Ã_yi }~蓊?{KO#Ykњf#%!Ԧ26,LZaQ/hN6ZٜusB2bO97uRx=H1hRkn]sԺ[oN="X^ HiM 8'=onS_}֑Ga* ]_ܯ> "j%a (;wj?ʖ9vb9xSNkTxVijs|=rC٥+,`aź~jj]7<3zVJm~or³/`RH{ q&pMk}W9$Ky9nf4΀faU @8DA"B [T>To=^iLNM&ܥ/|^9,%N5 lmwzjA.&Z b$%[Zu D4hW:h9+犥>\3nev E.Ϣ_^ kv-C ngǁx$ח΢j` p}[W&gsKSs (p@'BS>R,;)*mh.'O.jP²olWO&_Lm~WGr+_|RDm}%P,TjfUQ:4`ӽv}iLpʷo/??=!aVl8%T^Z*oQ xG<:7=}qj`Mv$=R;ʅ>[dR]9YwÊ`]1 RcUHFQqZY/v彿1 x6-jֽ@5_s[r:y*C8(Gⳗ&eb;&\DR2}e lr>4M=ғ2TDQ4FFc,ZBkN':n=rJ;gCz*;'ym¢*TM8v`odk mLD w.S `f/uǓn!v%d7ycIJ+V &)c2:Uy/#n W>];w5j@+'-+mVw1$I֚,6X'l '`fvk78IX@+ P+7ll |U8cmrf3nӯ|? - 4 6u$wFߚA}'۶먻 ,9A Mؾk[ZMD)J`_9=Ix\$A0bvL)[#i^`r9@6"B5:ohH#_lpFJiڻosW;CHڦ2,9 q#DV[B4TJa;O]{c <8QKp O 4P۲c&?an-PoPhr }iN@ɜ=j)Z@ۙ&X81 hkbFa SehKqĘO3&95K*+H*/C*%c%4.Νx N?bTi8sPhCBPvw Aw aפm}c~++ <a-L,c %p.`+!cR e%vƈs:W5aR=!`YO\:0;F֙쑃M9'4T 2,/+n l ;S=:L˘.ƽRncb"Pdmn?ꖒ/MM;Đl:/O6yq TZe9cmIڹ뾶Y#*à^ cwn*fg ~=o.,OJ2dgO,I{j c%JTۺW(&_ ay#Cdv !6n\~[*qf㻲k&l]&C$ _ •9ʩ??tTL@D^ry:%چND(0JRG3T[M"bǩc c_!O*ccK̼a ٖe $2Sa_],,Ֆܹw FƆF+4I<_9gqߺ 8ߠ"w`Qw @OOcoO.\֐ť7}ppAI{zeak ,romp±'ieK` HalNvi봴ex=''T ϡgzs)sZIg4R`b!KM[o{$( 0gV+>$5.xrꬪy;N> Z;.֛1+Z[]Y75}Ze\{lm:s%樠9ĎiK6s(濎IH@ 'W {/IDESOU?y-5Qi$E򞵦&$)X L]o!JPz`gTܶoֱiO@B1;x* 6bXk5r Yc\a)$gq Ój 8Zwu@)HRZk_^*gͣţXYһ~᷃vKo^Z]NB%ۚH2g!8V/?Sc۷U[;X2 3KDз}V݉4VڦQڏ~C^;uBeT%]FtQb~̥pd_xW^{ir'iDŽ.w ZoIfβҋqHmT/5?t鋯PyB8)ѭz3sCú4N+fH2(KJScX*CI)o```aiQ@8sF3zW7œ fd~:1$r<}#α~tt;$emBMD]AkE^SS qij̹sC{U닫Y fuA0cxV"[ ! H)<z| & uGA̢wzcedL(hIٶvX9s~S3?zӗj][ PW0e? 9ZW{:_'O@t]>B+SN:gX0;wߴR A^Mg-;drȦXh:wMbK$AK[i 㑘87Ӟ^v- ͈5vQQ  IffC>V8W9}I?#g'.jG_̮ ߦ R֊#R0BphU_uxD6鍣#\-?=ɕ1rWBl-7jK XN$+V6vW L19eث[ם]V(eXifR')s`Vں8|'+30Hbq77q+o F~p [s ~Ow')ؠX(lټyZjk fEv0`rD[ ULhW^BkzMk W#X^̮K.clS;6wq;Qs@-V{֦_[>K_D'aG ,y2葢Tcòk%) VB\ꛞ C^aX]X:tȱok$_/ ?ɏٷĦ$P '+SKͨ:?DsbQ˃+|M A$=oxz 1sB_zX,ՎϡmسϜ~N 涠@p={A'()S "sZ۱Ç4=pCr~'J?P^/r0o~kdddH j6IxRYkl6j+j9uRX~g1}+qm{:ApPjvǙ)H&8OG>ȧ<\*k/P0dɮjQ05Ο=kVBzM/? S/=JIpN#NVK)|=D$9Lq',F:k Xuēsϟs6;'/%'励ڧ0T#rд+47`'!#vG@ݴc>vT, ٮKD軡.myrܓ/ė)6"_t_&4&ok^+ fdkMZw[v߱o' Zѭ:PR ]S9;1@)#1Cb7Z-BgO[|ٸ[p:L6#^'>5$i%TtZY@[>Abg`o-J=rJetl{=k#眔 as[$eQ@G>1g]K묮HHq )wi:Pzfu: |SW&&Ɔر0RYxdJ҉[3+21ȓ!3N[X@9tMԿvL[G-;=xW}e'\6GS퀭GV.N1~?dϼ* 85s/4q_'@rq=|R)VFgYh6X\Fk/U@WY礒0YZ哕94;&Į}ʉ?vR~UL"cK@>cXD1ؾ) Is1T\c-ʯ =v=vdhhhevnfv-sb@ IM!,Ujh[=WW&^ \{z5Ow-quOV`ŘZnp 7(/|@k/ݼovѲ9U u`kנ5@B(889mD9P'_ؘGCXȾ]9rAv??@a>`s\ ɷL^ȥm qcmfkD#8J2טMWq^'筭RXs#ze;w/oHnh \0?:w ՠ $6GU#z[_7߽|.uNpp AVH'#wxKQ T[Wv쒝v[-mڴeGjID$Iٶ-J^oN,Jarf %\PԌZ m}_YPjn.'aT) PNդ?w'W̰u<8hĀؠGphPo4CwRi`p086J4:J;Qfec"fԑev(󱶎$I$Tm}&NȸN8ٵ+D[ZZ:{f箝.\x{T.WhN$>-\ʚ.zD>s?ڴ}|˞-'??&pȦl/۹C6T \.MO+/;k B:&L֨&f7^ESя0ޏsXca5@D.:AC'HHAWV4AT3VZ=Xn+gf1tכJ}NJڟ3^{}K_:(\ 6Q\B)j)RD,elĹ/>_97=9;ejհ<Gu'H)3.ƫӆ:Z290&ބAd-zK i""NHI.H!Xzm+ ߧ'0yd.?4< VjG@.ܾmDwz !磓:k[fÀۿc衃[=qqrg` >LE!HHѕO~d/ݾ"iŭ8stg98nI& {2nSV_o2f;HtQlV0\ݲR ^()<2jda^L )!!%0qdN?I%iA2Gmmϼq&J+O'>\mU!u `Y\/-TM 5P6$H=}ay(<]I֠uosMy;_A&pfDDPP_ƫ_'f/_R UL zmKѣRx<O£_*+j 9 56 ᇞNBz6u PqLDa>B A`fcBA KrьMb,`patSCCy1̩'(l-ULI 2pp]\/4.8q>Q$c(iw tJǓI"YΞܷ\ب-tA!sCoqK)$&꬇{D)X_ݳ~hV0WGZ9dt399+UgBB(tdL̋g&__OۑLSܫs~'J~ &'{@ :&|xOuj~~ V,['X`ar{ H Pm9=8Տ,Chcx}-V 0DN͔2 >u0}Ai}ipb_-U$"0D,D& BHS%HJB_GH`+l{Tz}3zX\]k-7S^>\ZJb`?9ӓ z d [vn]Jf]Iz KS{7ܝchA|-PCw!dAèжS0JH*1'joS:ѡX g~ &sͭ]7LA~v] 5cnD[=| < udu v} "+h>jDɅBXYtvu믜JP@-/$1rer\y}g.|^HZc!!HuUⰻ33i33;&AfDxEo ;6l X#a=w!4]_ )ˋw !-mHPoP_"t^Vw~DunTzC =۝n X RF?_}#џc!y56 Y Z˃ʷ`ԉsbuڒ+cK.]|un?.͢FyJڊ;IMW*M0<44c[/BZg|,;+It]H9 檿+f}ҬԥE/uS HÆ"`@z8,]Xc#[>ܿɲXVuhye/Sؐyڨ\&U . r08:=zadY(vFϐj<}&MV%"AؙhqZ/#'*uQ#ġmrZsWMaH0p_ؗ 9Sp8o#g.>?\93SYN*7S FJ|| bĺA)nB6!"o׽|]*;㺝I״}+_e{BQڐʰ[ jMd -Z5er w!2OW^|]Jy7vMs􋯊OIIVϖuī7%|R1`aB  xw+NjU^wg 1c^Y^H\b&/_ƍ /7mcܪcBprblb ȯڭVcG(Z[hFo_WT4rD'uWjjyxN H)4PZe>v[/':gzݛ{G@ b439ˮ.8W.@}8v.ײ)4 v<|w豭;vI/ۖ 4N ѷo $-PFZs.Ig'&~k/,l'zRoR"(1; [˰Kuy㹗?_?x{؞%"L:74=7+Ƥ:گo$g9(#J ϓHf0t`'Ak]uX)ɱ2R*~,.NJ=}Bq4=8qȤ8療*@q-α"(y8A-ud$ɕoVYW."#mT+Ϸ ƀ#~zq`vӾKMDQI9fS ]dAdzr r)fN; HBHᴆzIRqS0p dL.CEl41ʟ/gfn-ZG$|_ڿ3?gkuEFH![&!r S A&U9fWHu: IS,CCGBa~aTU>>?Ͻ Ag/^>{n" \ʅЗ"j6F˗͡EZ -wxO[ |126ݤ{qH4XJV???S×>fJIk l !KBi G> ,VJCkuDH5֮P{ .e=VIj@q֩۴{ rypO|'>?|싯>zRݢ _I_t^oVs9YԔ8,) pԕW#wݯzH ')ϳ6A\CDUZ80 T|Ih!NปZ (IcA66H-S2"nۨ8,1J[GSp*VmzJmS܎1Pٿ͖3oȐc9&"vsI#EXjBZ0::|if`xkv1V=k9%=P$$BoߵɥY [=EKM15_Gy$IVPJTccerxmKS^"lD$a" "h0`/qXpl,F`S{~0Z*mD)s9nrqHkA6`=;1FkO4P UݭRi~GGw_S/B8X/7z_۱`kV},ҩnuE?Jer~P.>uFvؑb\>lsRm78J;q( ssPC;pͤbi|.B( s!\n)|Wg̰lAX[jR\Z:?ۙ - @NA90 X|_[, &,A L8WSdqwicspVE ںkR.7|jܳؕ/v4$E&vIr±M- capS(Xq VM4ʸl*thl;OA9Gطy8AY{) %|8:6{D02:C|]YQY'piYKb_ov+#O׎$R12%6ϣvȃ'N]rI}~hGK+;c*pD1QyKJ4ֺX˧&_' CB=A‚f]οtb`xœY)'D1KA|4:xM㻷M` AT 9wON;mǟvas-' CDSI!"gmJHLjk32*.砬1sJޟu]Zk}ιc 38,ɢ%Y%rd;vng/^t^Iv88؎Gْ-I)P@Ýϴ^qn $@IWuֹgfZs}c i6轵2u|l pn$1Y#Zk QRdef6D"^fؗ|tµsZs<u  7c_RD@`bj`i2<5{j-OM<އatm5^ &ΓRXI4|_Zmo4M*F$/t-wԇF̾[Ʀ,XLaߍ^dzf :(e8gg<_jҲ5}m;;w_˯s7F`Y<,dZ="c<:sf"Ԣ'>k ".JDoy:PAd(xށuP]F"?".@sr?Q9Ĥ dڭ +;-V;vGA;FȡنUbs y.S+ۧm1 ryY鉁 g 3 B 4O:4gw[A;P'0H-G>w\uhڡb}ҀDQTAwe9Ź.H:m1HEEli f:ʅ$;:+98෼7 WHm&йd_]O: .sRp] Q5b$A"LU*Ȧ"-Drk/ _e_N@*jT۔\EvrMZHăϖg/?j "٦)*fx.! !2c0,t%8 SCӓA<`Ɔ#ͷ?mS;z73=:"e8M}虴Ej+>9Qܷ{th<#(DdhhT;JJ2xIa r bwpjfJP @imH8ZQu^Jkڮ{KƁ֖>ns +OWFaDP((p`p|dd`) Di؃sfkȕ7}lajs/֛i/?HУ'*,heBa,9K:A\{nl2/lsuR ^א ,N/'T pjc0Wok€?/,Wu%d*&(L El B 2qΙbI@; l50@NK*^뀮;w@/-9XU( zyïJVJ hq]`J:hQ] p|?:{lIEFAC!JAsO~'?Qٯ)(^Rp _xT [6 6bvBZ_h:"DJD6rfH EBQEe_&wݠC)fpF90TD3 7떃7?~;/7. &[p 3l *o g|fuI4U@J)A.M* ( R XF*^PV4 LHwFad ;2Nm T+DsY *!ݰKM ֣rrv1^v]N@Z:sl ΁Hr!z7Ӥ+=p͝ճKC%e,0JP,$V(+vE"AWKC5ܬ]9`uS\-mI]iLʕ./(Wx^%!!8Tl FA*{./$0Aϵ}9meBUv*I^#+^eE.eC_` p?EB0"v" B$췬( Vxw]ѠNӿ˵V[Dފp_TдMWd''&o,'' zD6g *;`싷^Hcr8#ÃcS{H6) 6P+5+Icblw64FL{\ ><сa D@%7$L$ۜXD}y7vz_|-S]X\Ϸ+>pvq1,UHF0 oֱffv;1ז-Xi4;<c>sA9jT bͿ mٹoX7O-g@ۻz-σAX)NiGMd9 ",&~qC)݋A/ÿ{A!m<08=twuv;$2`@CI,OGtz jmutT. Zf F~Y{bJDiH6JZMR^D `gMVbP2`::)+Cc: l\:1f/Wnޮ&ˣ;N y}&uHj V!A3$ڪ8T*-`螟?~r`0U¥dY,& }HVjz0FF Wܶ :z[XA̕!Dspǻss҂&)+%jQq^Ȧ={) `~14&Xf*ЫJ6bJg3g޴\*6{CA@:c;TZtH"[^i|]9O,^S@ɉLL2}w>aZ]#}Z>썢ăԉSS/tH,Bj]K :x۝;Ϭ'qUAY_]QdYF?8OlZ4Mqbb2ͳ$((Zkmsa Ku+ޞ ˆ bMh*S:0 7|ч+F.zQmfpo+_?ݒb.#e 1*(.̴M35:s|% GR9邹( )Dv{԰!dJD.` ( oERV`Ã.ȳڎ`mG=Z8}jӛi>,J4XiK3 ϥZv08so؝I~;ϴO^V, 9tɲv\ ¬@Ep*ОQ喏x4;`p|ϻ?}{@ 61hdn %O+g#n7eh%;v|IvjQ\{aur\I^'.Yy%߯|$Z3gYo["oν%/}cn3+>˱0`kgok%H`Z{Eu/EʉK.}o\=}np_3K=*MQw=5G""oμv Y\/>{/:ڒҐxefZBoLWIF6IјZT*w3̭\Tʞ6,Z)f5HDzf84KUu]w3Ok߻v;˲|3G;yM7xIR kvďNM)k- "gmE"ʳlltx!8@AÈN<&@]3z/ͬ-/;ADF9o= F~}SW)쉾+sF:=Zi(<ε!AMЄ.RAK]|cO>8~ޝ:/@1mN9PnU @Q+:.?ul]x9@'YT nnS.'9oʟ\@Y /FJMe\ KADeodVPE/}yfm4{K€m P9*{!Ȳ^0֡Z< /cDdQhFF;RjuxzѣcCÂQ0(UZvV:n9MZۄߒy׮Z'>%4.&%"   ?xoٶ3 VGbzIFA Si H1lTVDS Pݮ fyvdd… 922?7fj ov붞^Ơ޻ƅ<5GN<9t&I⹵[_9zO>jw=~c?gΝ; Ƕizf~LE< Ebht~/'taZ$$`ϖ"{y>SSӱoZ҂OsҎ@ČO0(@cLZiE Q{N}TY\e$BtqIM.=z ک-"(K%| uO.ǧ  4I] A`}]fD%޽yj^cH@5jeۘ&l4*'r9CJ'SH3QlYV[iY.>̡#w=v@Kf3%Y@*gsQ "Cމ^lx<\__`al.޳0l*!wrշ$W2W "m[k\mwܾG:H#:KM%>Uy=RJv%^WQd7=??tǟ:3{XAXTX)|v=zrD)VT~Z, iaf<#Jcҿ3] !T fxP:El߽Wt.qA"or)[,stE+*(eEj~7~}zb9* B< 9x~FM (%Fͯj9ڜ'@~Gz·5 _iw倾tVNWțdz.FfBy XPrZ=p ޾ǟzoca$'R̞@ Є64Bb%WTf@hP6;NA̙C]v` >JcH Z 5BAksHH؟,jE,̥GN&1G5PQGnwf= z"'B`th;0QB8saԹ^ %Y" I}aOc*{?xx|pp# Y/ ˁ lʲ(ҙ'[}ڞ؂vҞ_[:5t8XHD2l nf~}ef|x9ݫNc)Y{W;!~|d`&I. mfb~^wOIp/\BX}EW~}M5z{!0"o{GGWvDsV.گҭmP)$a~ ,#2i{80Ԋdbr/?s?wW27c)1*vܶmvX=qjq`Z tњ6Er7_Q !epy)˷H~1w>І.,\XX>d[yIЃnQB,%]z~ttޱ;:#{TppʥfGf(pE$&KHi,HHN[Y{fa{rbRP;k yd4@ "2}AIAZȕ8uuZJK~?Z`:س@V aM45Qx#{+t}9>h8u:}_ʽ_Ӈh\f΀o?;OXT/>+Qzyvͻ wsuNy(#DPQN:_}w`m DR"upޫ=" T`3spz2|fcTFU#?X{YF@1i5W\Y7 Gp튧BXm㞥ﹰ|?(]fƿ^]d0|^͈H) `)I Ը`'DڠK~ oERዟZ2qcY@QSQz BDL$pdN@hl*\:+>[9r"Z=&z'^DTAH)| ;v*j%mRF~ooAh="",AD 3 :r\i7q,,tK={iڌLL WKV5 7䡾:@"@IzN={l*)6lhH|wBMBb^E+v {oEPҊʥxo9t{o}c6zae6 iR"b$YNE#R $Da"!3g$Ry 8aEʰxT@v:H|,͵?0`U* <d"nBѰʾJ KZ{K 'O\jM%x/P!ry3nOxYUЉlޑŔ8"000Sz>9O{ݤ`jtzcC`i +D{PRv&\sıo?;V"% XaXTvяL.0jnYbˊ( KIASubdxƙ AQ@Lh|yf1O`NfY\ "$`ҚIBCH$F" #-(P~pp>=yx՞P`trd)i֙s = pu9;JS9>ۑ˰F 3 /Nz0[@ H4E^6^meE!DO{YE_cz}Mn4uzY(y]7)vmLTdQm֛ܼ'YHP g?wm1xX^؜DE.C"{w;+ongJaj-}z#w r`~UBv i ]o!'B(s $u iMH}bR }G'և&v]136Aw$K/؈"b/Ly^"3s߹ {Y{ıT8zPX<`)@UC"HEQzqO+*YF X[[^3lAъy0]bBxo5/P1/Y^j[ v}huba/wb;W=9-c/}!мR)0ya E[:ﱩSF/|DًREgyoCj;`Շ:Q(@FHjY+Q Fwa)T|aֈ X^FmtzVP/ Ȗ? B",V;(jckF&8۫M]!OcS 6kPeo|gүږ;S e$ 2"EsJic !{]*Gz>G9J'F\[_mOtJ<:AAwEd^g>z;tع}kvt'mٱe`^g+>(p}[\G,=!q9wCһ4{ce @E\`Vݶe*0l2pg3=Na%D@`=CN&0R[ RIl EAξU:ɁcND49; >䉣@F11l:l*2(G坓zqr/AM&Hf}~K& !;^)(,xe+"H|>yZ7&WAյmQ!"5Ru؋w><8>Xk@>s>$t8BpJx'DFgeT^0[S+: kq3cd=dβA>\y&lS‡ ( =-.dM-XQEBЋrmpν+e\󙪩4Zq_?!W/)N ΠBB.$}ݵ9F+A{v F5ElERdNOOؾg**dq UMA J6L+kw*uj6άc|G&ӅyC]D\ ;P ͠/˕飰sAPe# CC󏝶3-h[hJ$o,{iފKCҌjN+TjO`Lu]krm惟|_eN\yn[OCH0To@GJ@Kz9۵,wЂXk:xz[gCKU;PZ3 yI3\\z̋2h҅|>\dIB/fw^AZr(Њ6>O$+<ۊ  vZkgOUjtrj/ʥ6 4\(zBZs'UBcw?l5Ds;[m8"" CDNΞ>up-FDP}OMm -vl >?"JiYZ#Ư`}yiޱլ˓R ) Phpd˭`ё\@"# 8a\R\D,fDRH#!BE,JhuT #k⬷gxlbXɄЖѱދ),+):"pyV+Mw';vУ.0;_|,DuT !;b#t= - K[wougJcESsji_8d!3ZWO"TTH5 1c&N.A d$d *vl)51@k[v?}R I+TTv#EzAQ6"͉69!a4M0gC>kf mDڝ!bJ6*Wwj$0DF:dރV @A-*⅁7yAX=0 0Rr p30al]Zj̞G8y˚DCXHz=?ieO}r"o".AT}Oyq-@5Ƹ>|cw``_dbzx{'k-x=0߃^/_??%"+ (33=nw; A$1Q13)E,`=tR][z3+ggyLT24#U Ndj׎[TCFJ`=sJ&jqD{wcFP>V[ Gj|Q` i s(@hs FClnE2H p1Պ3s|/!23VĥB`H4.Ÿ5jgaR InE&j;6>СÁp>D>}̉qxݤrmeִ_|Ql$O#^!}u*"\BlzJ"?MoĮoޑcŒk+Ƙ (X.KJ~? H+Bs'Ҝ$+#kiC m%w"g=JH A`Z3^Zyr]..WYڅ@b$7V3ecQPP@H:L?{d`dܑ3ЊgNC u=cM9}7 W\kU祴_o /r%R0WX@7 '}ٟ.#+K}紑8e=VDkA.LP\Ris9O+KgfΟ@0T};HE6̣#w*.͉;ZyZߴڄؐ̑˭ SC"7"H ’PVaS3aaNv, .jԹ @|{w-РB<)R9{0&O>,OkBϞsoq/0?W{xsqɣk=<[k(layv[_}d[+s"%|g/QZ"(D֖rؑ~g ;M-^}|hp`QYAM zG`1ԥ !8^) J ]9vkNM=`shb[\𮻲;|O\AH ՞]Á:>цr.9;n.KvO~Ҫwj5k7wjy2o @I;&$*TO;z>iedQ{PՁ>+nХ#/a{kpfϝ;}YBՉ:D+Ij]JZ}brdǮVej eyF^b]3lpfOYUmȖ-mҙ @| ΕB{qw˫yͻ&'>y:42Z e6$d=3~᧢>s!뫌a[أ EF֟:z_5c-w9T޾mR? {hP^_ r 3]0{wF;8J0(2AsHgtNRrT2Vd^wq8Yҕkw$NLSV1(/"it./\bv މwIISX حۧ6:<͂LLN(Mܩ&p ͮ"m#ZvKQHrRB=w޿e啦hXRD^hey{bT+WVzⱵ=rT!VbT/oْ dje:ܾKŠG~6ae,^B1yG؋|PLBMh^n'gg+{:F|KK˪Z۳o'Ɂ$ ʞZgrx*܌}\Dw :9~X>uٕc+X'zųQzSg>O~?βV .l_",;̤""d&,q_44`ms{H38X7 ApLZS۶a_x?3ǏxIˋjgQYjYV1 ա{Jv ûvUov:T)ɖ)ZQ=B>ܚZNFjn;N\.ZZ° vϴC/kGA.3:dܗuTkJ+#MfѶcI;gQQ䃔c ]rX1i5]caن;^LR =c;oaێkDž_@vo7He-H$.n! "q8iAHD:֥y5ReuJG n㋿xYIEEZnJoE]׮k e4:  \'rJEZ=ʨwLl5u!QDЁu#hO>oGC?{lX#\rw9"ow)"Ri* )\[?#3g/s. 48GPj c n&ne%:4ieEe s@@BAq'=෾t^)2jxx,c<90jTr8Reϗ\!$Hx*"9{5 *d2ΪN\gg:+>KJ^@/YTF+u3Ks3թx|qant? }9B$=>o™__zpc'ZiM֕DgO?s_ kMW{_s$t#$6Zu `/IDmց\p,u|{O՟Z'D%HgæEWcy@0mG@d4|Ȟ+MNԂv-LNN+S$u@EO.C,!u9n.4yױK-$>ICR>cfr b=kBہ`w^gK=oŝ#A[v 3vnqA!eFƫ8 mn*]%d]/; 6m61]2»)9k3 l@ 1>~EdP%" GBHtq~){dTLJ @63iB坷iZgi u&>3K@Qcy9֤=ez™ *|`%2V:aPJa@L4 K#SV _ٺh/%EMb]r.Bsϟ=y<]$5 DݕPPHm.u=G?fWᇋ\%w1+^.U۳|-]pk+k5ZXD)]T}mmjs娢!EaW( uJ; K+r_>CY|(-9qhƳ;~lj?񃣓c=J;1F#ALԌ9~dls i6Sf1"f_ۘw=[&d4HN\꫍oʣUtLH^$Nβ qÕr.଀*׽NRǫc/EjL>А(LpR=t؋֊<姗sjb'_jAf{ #,tӬ3Bvlfji0,a,htY0PoϜ5}婙s[G~{+_ˋ-`ΥnRvH _v^Ekz9k׵@ Pi(}ϟ7{x][iGD!{[),:Zd=.D Z)l$n/.\tR)aAJ}}JAZv#kX@9`q]J /x:5'E6'\E΀,❳zUߚ tON/(4Oܴ{l:>sa"@7.s@ryyDת…Ï=:' Lq %_]l_>ʁ$^*&ڌ8ƪu(,Fc`h ou9>(ݶCOI878KFv9t;@J?uTwk뮽q'wFo;z赙VÊϭ/^xf1QrJ4Zq ޕR}h\;33 +)T0>Ve-EZJ}aReP !XhtViE>Ӌ"hCV`˲T*]l&Loa5Yj@D,K,`T`qS9Lՙsk!ted#S|U{띀/+mf &ȿ/ھN޻7)o5MR5M8۵2֛UW _Mp_>U8ـP3wH&TJnQ?t3)3'͉7tXx!| 4?c< ̫F+9:l,68A<_u|y>+ƊV@Dț=3Zj"f! E(ֹ0D'`ڽnQDe61E'! xD$MdJN94[7YvҊV"$NٱwRχKR-r(Ddcn1s?:^,BgjmTj#0m_E AJ۷Mp\m(>d\pY*Ă/8[ny90Z \4qD0ZJRP*4ͲyKDYyV)&&'o V.,.X<1ga\/-7tM7(M9 M^|5%]_y7n`['VJPfq'6:0AZ H|C7xʺ;{]ؘ/H`)vrzIS'493i@9ՈMebJ8um5n/1qwLFwNlC8yĞڵ}wY:]#Q(oc.;Y|dBJRMr)[Ԛfgr+AYsbV@XH;.WgBh@CKRkiTeIW`b^F_MO*",ؐ `.p6A h m64P6;XiBqWSgZ̑ҨWT&0]fyԧi^m޻,J;`]n;ҮV+:Nf?|g, MS7F{1hR퉮^Pu%=w.G*T T8[[={{,0F/>FImTi46zv3=, \h's 4u"Zxr$O^CvZ/n Db$6XwrtIe$ui46Wy]&F9[fE*c{Tm>HJ:Ei3TCաzF񰸄hRm6fWTg(]v;y{~m^=r2[BwP n[`urר^/ÅEZw.V‹>."$[Љ+}'O{;4D!l)TR̙%Q8V:0vA`vڭR~ӟAk8@GaDկ_ <ؓ<ЏħnT$@J 4&gO])Ο>?ez2ܘthxPDjip:X|,=Gy|,TO( 75ZO~!@'kAY=\5u0lZ%it^|.Eng NL7sU C;-y]a +aDRgPC T) *RV N$œ=65ΛƖ'_Xs\(p *j]5ICd;4}$83KЈ!(zO存ѡՠ "ȵQas%#t""iEW^2;9罃Bn˫~ߑ~%W+Di ށ"y% !"#*Tչ [&mݚNŒ`Qgs# 0;v켰mkcqƊGEjez ]2#  6  52wn @)D"`D̞s"Z3r~3fC!puRB];Kfjk҄&&#UbKeQR7}WڪaӅq&&%R s)fj%(8M{ql²ca!TF$$T zUôk$_m$VjQ';3Oum PapPѐ0 j+ Kr+ae9 q ]7i MXkBN!XaIG#N/o0QL; VЗ vɽ\i zBA`Զ#[^Uq: a&1rɣn =aMƩXGB;iv07gBe8F~q:=hd!8^#.TCǂyP?C6͖sYk"B2F ZDz" <YG1Ƙc~>h_^l rq-RHFRJA ;og>+hLۜ'tgIW*1W=7-eLun"gxر0,ς|m]x̳OD' uU' ="0|>D{Wn\J[\ #Ez5Tx;w}_;i 3hU*զ=h>PuAfF\oXW\? L; 66c|lZ++]X׷n۱l-,/7;=Pyk#A' `hZ$@Sr$itKT'ͥչ\uchZOJiCCo6xvviu"Qy"nQ\ €٣`gԭ7?%k%dq{ow> U|$b+#+~mg??C=4&_~nys2@ag:24nu$hP;9X3˳';T ԃh| rcYw}jZ\_ ȵ|C_FRTY pۯW XI]2X9RPųKjAPKi30@Dze*w*Mh lc(sؘ EvSٹup'>X_^Zixs{hVUYP@}-BYu9Ih7/M-@/o2K Af&itM4#7#֋K}OyjϞ9tvgq% u{- 38o={"Ճz=PFefbR(ژ4AC">@D2ڽ#}wٗy=ѡ{NǺ H9I\nV~nӏX #ɱ銊$Mhq8т"Du"GNmٶZR F g EuR k4ڲ8#b}; A),\mRrw]n*w-W{-eey([%"E$y7xwD8|Ld A,,2{;v_|_v3g+B 0R NֽIfEA.a&}eq.\QΜ9a`\QJ8DNvҿ_s-uAXoe09ع#ڵ[ QʁArRqc| Hxk  hPC*"NǥIB(a=^mRkM27k@aDp߶񩟞)\REU0a8Nk4Pmݳenͽc?vl ƱH)XhP-T*m*j-ȁ'~wA@BaoRj Z]I{XϽ~Uk5[RݬWymԿ<ґ(k :O˟;?hsJ 딃 AjqD fPޱ5Ff&&&KɕKlV~4R4eW<5I1k};WV[&6 I}+: B=2.E1 v,8ש-PEcX罋{gݾ>^8B]w~Z6{D))_zBj""82En@г XJ(p ő9wf? VKh8U,$4?P$9Z;SUIԕщfW;N=(AQ) ӃD. >8PuRjKX B$$E&0< Q䒂ixe]qu% (!?DZI'읷֖"r-n!@zN H$tNQn_X|#P} 7fI=_@^P;c"M0e@ CGB~rzJQ?2ngzdW^x ##YQ)df YYD00$M%둷0:zo{hs ` z(ڑ7y`e0܊:oLd(UWR kp=M A ' EJm-.X $PUȭy'?vwoh2}$ ޅWؗXmD= >){f%\X5nYP,6F}GXZg}@#}}>O}ldr u wΞ"> U4U0 ݳ++TL Rh%It> I?{KYeFs/]}А8.6LJ\KU*[ҕIQ( ;o|'~o%?R9a@N;STpFbP}_{Fh |DT*瘅@+XV[kόΌ63: :ܻwX,-;u:[X%P5T#42q c (rװ\JfT xZIwZJp]r}[`nз tϯ=:jTC}hHtPǵzhB„FEu~'B;1Q6n,A܀wR :74C@EmZw6qf]X>>3/ )QVّԫE=r\deف\RD^teu P|V#"šzzA$I]dXPd٠N'[k덑ߵg?l8<ꕳ4dPQag;Nou[ⱉJy ,ށܵښ^鴲"4{ɧ?QA9,.ыxa҄:qWsv>6Had֊qEQt:MJO$4 X$I3J>;ޏom;I& s7+JY2v 6x~v{ǝwwuVg/\عifrL;Ohu7߹uƧL.--'vi @Bm] (BM]`{6=` <j ǠzϟnЙzRFDfA@+,"֚6BRp!"h}wdeX^s$8qV7@]S._NW Ჭf' 11x*h% +*0Fo(MNQ{&\?\XPۢ V,lH,w <Ո{/گ-Vw~wjA:Fc̴j51&#pT~/kJP)z޺E2Y+OO^^ƹ4\"9OH9)a鶻jB!$ Uܾ}9 @:jѩ91B}Y/6+ޱkf̠v@ZXe+KpR@6+qH>އL޷oǚSp 6T=AT٥F#لF^ 48'R~}@7}[vE J (kE gM|~wqQ[)Ji\lSNqpm{ G0yxZrgΝ+xG>o~n=lݲ+9^ j\R$ g3ֹ GTx/|'WQpIEqhCp+k '< $?O +@j-G۳=l,J@lH@v]AR ~=3{TweFA p42 z( T`L--Zt蜙6` b2>jm|'F8QDx}`FBB. > J1.  4 AUp ZyE9+agJ_LDd~,/,.ڱT1hjM:kHR4Gde}_)&P7 ={Vg:N?71u{ @ĸZ+5WXGAPU g{wn7-̉;w(e+Tj l(Pa g/@&Fp(h%A){;zpzc|1˲Jji6H֖GFad'H6u("YޏL ں}+rF J33x{Z fn2l-)"ɓE=Q<\iq"t(#Ay`G_z)jijf2JXAqK&mq3^jUH+PbU%VWկ[]B=p#Z'FR ut(IZZմ"qi(vέ[i|jrfz:wVY8'L@e1}yQ`/d!T\'r^(A]Hm=o=R>swms\gŠWҬU*p7VV .ҬJxWXbzV:c "#Qm^`}r~C'<8":*04gBk~& hD sO9/jZMԠ^kJjfFgOlH-*H$E@# WзZ[λe=s("κ <flt~̲~km-0 ~rkHԟ lJ< bѢ, ̃?z=~36EehL@J5s~YϋTw+HlD) O6#Q4@!{'~ H c[dqTD! `DqO&pH "$liߞ;|/-/(ZNsЍ*40p]}`m;"bp/[^t\ (ch_|h] y+ N룣={[+k"7얗Ww7NE=E!h~ϯuAz^shTjVW֦7mu޷[1{enkwN?G~f6TCd-A\ʍnnSwϿM0v> H+c Gj݁dhܹO8=ws<ŋ*(,OfB4 Jc猸s^ GY.$sKuVOlV{y) dǮ]SӕȮBafq|ƶ:ٳFޛX`"-/xjAFGq6"( -s)sPXW>OϼAf7PoTտM[[7tGAkMHY%iӦ~v:]RPLV0Ertw/KB= ̌78FzRL聡TR )WUD.8?VB`JCLa60c. ?+?s϶z=&g D 7^ " Io?igsP4ĵCjER$kw:}{oۣ nlK4;jx}e=jaaPN]0q<:QA__*\Xqg}>ZOL|H7?_ hJTM?w?xo~ 0}>]lۿ#U /Iy/Ͼx$J 7[^(@ &*۶ofn*J֧w': kD[>< z FkPo(Ш@ѳB"/9̷V9Q2 T@d'ݤ!4f Lwm?u1OM}~(׌h,fΡ!bVB *)"FrQA!ew' t8'G_8|pӣ{掜]2#.[v&=cjCoXҁ.{xM<n* -DއªrK0*?ιU y QeC(*gJKX HY]~ŃJ§ BgԴc9x{*$qJtܮ}wclJmzz\6Tuv`p#0(4Օ$]ݹg1& <ϴV ?9 >q =?KL `1ʤ6o6&v=*Z kVk u. mAi(;t&A{(2A zyy33=< @۸B"D<4]/>ҡQ8t-/^{q=[B=/`{`{v0CEB@J{UKjuӔ(Y%~lOwVxJykIK$J+EJ62hI(ˆBgjaP_XY\^`yeljb,;z0tsK(d(:q6A_aTE."ˆ\e亊[*NZb|>Z\pI9M&I{=upmv'>H^k/,E?xzYP@"ba!!"kP%!̌Yy*ү;rhrD rI@@5QEc)Zk.>+c|nN-Aѓ!T\z&l ]&c@DM#S{7iS;a ]Jik{6ݳџ{aeԖ?z[-!"(Ej<=5vLTj# "ςXkhvioZ1J¶x[QDT&.<@GmW ;B^ 0 aR/'`R)ŢtUXa(;vnmwVFD `6!쁓: -H0( wޓTkG8W2of6~_#UM 6/Ϸgϴ z#2%J%PN@/;QfTYQwlPS$7p]+g@X#*ؙyd5 `AF[gjJaiKUg">쾻njX\J^aZ>I/ iqո.Z11=cx3|S hp < _~ݴc&uW [Eozy6z@\& C/OOW[ykә~vj뎙kyݕ0+SEX1{0 ),b B@Ej`d ;q .C*FS;Z ¼PhB&RxYpO)s#9PX=.dB@1A`$s98g&fBP1y[Vvo;3Tm[f$b>\ǁ8TbCEA x b,)*$O/ P4ڽgz[8 yYF5k kHUgWs~*EL<э q{wwJ5oL6PX!희o+trP7KkTc^A@ "=?7~?WAՍw/Z seh6%`Q td$P(6wKY"@f+nwR۽N7A-¹?O rkKI[z]TgEd)g" rsq\ }[:Oܷ:5t )CΎ,f g R hGz[}/|k@bF[:;sRO&"m\Z@߁cp9X˜{*^<+3VSl{5]=pX'o韯^踻HW*FO?ctO*a#,V2_˕ژm:?ʟm!]h@~oc]H'O,@3=᷍r'> Br9+y4M z0Dg}(PN-#Չ{ZITbcmʄRWR9,/w/O 7CT`",>;ձ**zT0~Jrr2)!6j;nK[^;mݹVu _ibb< #.zbβg&'̋?B xP-OOٹ'A7Ƌ"'(0#hEys0,Jam[w2a Ui^@ $`QgCp08q(u,/6+vs'$Z :{ .MQPXǨjB5"z !τ@OZgAvG2|- pOw* KEV@ie3ӦYmL4c>B([*ES>z~ϡCG wYm3s/,:&WV>"5X.:'/,kEcb.$+w聧^عsHs9zks_ȣ"'OwWܬ,"^Qj:4hoH _T4ιgN7#e+lN2EaE$˳m `=j=tX\oO͜ܜI`T.޳!D2ffƺ,n߱}ǎ @ UKXKMp;vA M A=R8*YR8q6BzO^lS ȍ@(T_Z{ j⊀X ,b^2FMf7pZ@ˆP0ki6=s-.?}k*s'fGNm@ΐd=`XqQk\ dwo)UNabl-&ZO6*ؒ{rwh2^M7ѽ| R PZyu^隌ҵB^%|o$f=V[f"dE*B TƤ2;n>uID)&:]Z^ɝ t(21ژ?y^{e j뾽[1SɹVd=(`i +KЗM5p+ h4:ZiB r<1#g fGa=eka2RmM:3+StF5.qKMq\r.+<5qRa;aO݆֬n zsk$/,2Lu"Oc ob xy^),%~Q!W4u6I֤uzH/߽x ]ͬ_0ͯf jK4-r|v8K~ɸTޑ"@)Uˊ=[l RèP,b "pdj `^F*CS };9p R fc((K8*B$tey@<@E"uN{/=pB]fyZz^$hDkn6Z#6 (ܨ" bg~n^D3{o _V)?LzosCs5*ittС8KCZEa&>}ѿ?C*q"+mUsێ;~֮gQ3-an]9{Ν۷߾k 09U`kAQTjPdɟw[O> *4F|[}~jbfzˏŮ^H4RpT/O=́~mRv6:0di\O j]wwågjh@ܷPV,\ eEBR\JH~GWW۝^{"*8ZDK x79FM66}xpяP;EN TTe߾1>rlg6d_9X%a.^ :tPv_nkP}HǾ3'N=R̲Yըr9wL\-..iZofEMLMcɲ}ըj$Ixέ~(>z"H<=:ҬK-@ yuMe\T Uԁ>#2+Zl0IH) XnDCȒxٝY>l(|^F+)K,ly(9c)VWz U*Ȳ\ g۶ٿR8e `o(,Uy2=>pus{;Nc[  Zw9=yDgpn=ev #іMvz8WkXT5wTksi*XqR h$6 ZjA{&lS ʍ~ ytc5@TZ~uY/>w>(JQyW갔!SՓ;XIp L19C/,yFӢ-w* 2~uj@)YY]-rG^vpq9ݹG6HJ4`_.y{ԏˬ JR:uvjnL,/_/w?x`YU+U/,eZ)cHs dA,P^/fWVQ}6>&;yw߷ҾMrDz`-Hn9`06@j*y,kgZƚqa\ \vID"ϗ'8n7Mʥ5ebo֛'nxc|:!4+ 7 kbΊ Ja0F D?p'Ϭg 3t~aaOFklEet/Tu<{xㅧ= 0jR"C @SA0w.Hc9QcdY I(cV#ys" onwdWO8{Air~lk)t AR,3t)qIK%0Bw޻GFc O%SU fg}\9Eۧ Ł1ZA͡AcZ9Z<(5'~#>%-bɅjϯ-ùnn/ G)G`` U}l$47O۶ܵ(vݻ}@ke_rZEF i 2 Hpً(⇠zFui dP2Hdq}8b魽Akkh`s1θ7~d@ KS&|_UvMIABm@VdB!ү|?_ BCq@qo}ǽwoyg(28Qlvɐ@)hÒŇ]a3.|D8BM<_=ߐx*NP_ҕՔv=`hU8W hg@{x=u68$X]Xf, |lbRq X 1h' *jm XOmyiH%MM#FXbJ vsٙ#[}  Aze79 "K:1@ ׁȎCjmf͓Ks Rsx8o 7s+E]={w/bck\"6Gpaw+QkGՑX[|M 9tw?<ۋZ!C [9}Ij:^_?HH#ۚ|?㨟&KRybm3` (1 rQ]sPRIn; 0 =3Vhċx/ @d࠹J!epv@xA!Fye==r5q Ο"`;L\n5 KrKWPZ7#p}/PaĶQuDz$dIQ\wI;K{o|Бcf>G$4/$RyR9m4PJV,L餽>܌~SQI9`^x'᦭ov7)G1ޜJ@c*Q J8V$hu|M;ªE|N#]lRVxNZfy KFψHJ " Igywrzb?sۼ}nݴyfO9&IYGq/E\=s.t'>Ch9@YɅvo=O+}s4xȠ:v/zIzt]-}B/]@36vnl;400SyA}PQזO-kjl!Ͼݧy*奃o9|xyrۮ.Dy`Eh !{`'DFZl<}r# 0 jX={ C9tP)$8(xKāOE낀 sp9Ĭf7ԇX(%>.HCr؆*JIwZv9:e7 o_mrUxR@nsS@7o,2:K7a[3u~W Dw(fλ]ijm}n 4QRLmJ\K*xk5b(;"I ̈́d`p板W~es sVyY2>>6 ή,ۢݽwh l̊܉3cwDP0شC&U@ c9N9f.8DA}}_P_QZQe{.oL$RdE\6wΝN0##cTQ6J=Zw߮"S'fðgr̲p FQXRYl͛wQ 7Kㆤ P!!*^LTaE^듿_:nV;pN [(R$`y (a$ ""o2t>|;N9u?WV zC7з ˳f@P@*,u]AFg`+^}~z|9?m:,꿜Eh@1*Ju51v ɠ1rG",80hB@fI@Qx`^(`(rdJ΀msO/Bx>qqfOk[֡QDhm|_hpٔ/dmw <}ήA 1B $0t! F6,L˷ P.TY]AP6L]F>-Fk !zILu ^VeŲ ""6"7j: 2oNq\o Dسs^0 iH~p~Koi)^0c۶UcLIFSGE0[k Є਷.BQx`Nm۾{lbE(X6jwVhUt96Q%-3)_?V[V$ԡYOJlLX\Tz_TK fčW 'ɉ'Vf_Sd !(ԍzeLGvbמEu)txw]jVuVuR<+OAlݺV&L|]L[2+GX y>{~(Ϟ 6AdHrk69JaO9{ +IR( ˡ(P7#7mwˇ. xf0‚Z#^yXOγNգ ƣ .f>9!cM7Dj^<Z$PΡ  I0H.K l(8:A`5pZb(Y9PhgSG΃/h\P9g8k9"#eT(Ƚ_KS K9d4,K~ _]*;Jh~ő2k/5@ecK>Z%kVZ=R]7<%_JHe*{xgz+": = 2Μ4j8cӮ{(5$z)[)LstLL?> ɉ1Tvb wچw~R"w(cQin]i덑w=xiLy650{{?3GN&g;.:BRI`$һ'u{Se gAwd{z 3R4뵢{xw<,!0u;)\@%q! \q2!vN@Z!e\z жѹ^/Ba *-3J];Χ[ QQpocub p DPSM{?HvfW:Zp28" &ApVPy4!yf*y ~DD!¥ G._>V?QE} k{NyZߞ408@JC*x_GIֿ1f׾-] t 8 \iZAVy0{@RLzHC *vmݲcvҁ ‘)eyT銠/^ 6CryQdvuenwȨU0||wx!ѹ@2ZB}OoKnv;KmRj9O;{wmo: .P\࠻",%۶^Y8 `Z-O(I6Ğ6>}Q\H0{9t^4옫 K1:0Ȅp?]ɡeQؼ[97#~6i.!T2D= G1wM7Qg=o3|_}y(1s|hˮl.%As[TVXL, ~8>I2lDQS|jfǾ=MY][{4o}+w{}'N۶m?~@zˋWֱw6m(UV,w]E, e1iƛo|^A,d)( '}{*l ~|B_Z`AФ++ ):2A`_쯨3A#,sR t}{/Zq Qg{93BA?l׵MT, ";8j8p;Qf p/ sn3 sR@pȏ옾/<&*#60K- {K҅lRvR^0tyJ X PX7Pk) y >tl* ygI%uˏ q 0xVpp}e;dʀy FB^KǕ97j"8vwzrZQq%Cž"nz&t%=ٿ9 $z5) Rz;~mo4JhOm q~^^_{ )\;Ms "D:Hi>Ϫ n+fU}^.nH~gg~;a~Vd G0xO8Hhdhґiֶl;aFPgg>; (qd׵k,'wy=kyxv|^oݻv#"rld i*% ueA,MJ w3>zۙy79=ID)"SR_+uü"yO t;6׶\&AA4BWޓkLյ 4IJbT\/銢-Z-ZҡR*K|/7b'zWeDD oBZ<bA,c)hZŭ{}o}AQzHBOAy$3k˽D:rrpu5YI_@EOm޵aV.Y eb SK33TY``GngEwc ~ Ǟ:u..Ϳx(UTXmݻsbƹٴ߫n Fn?9R{E!z'sEPxEsL \*+JticIJ= rHF5p0rE)P&xWͰk|fR/H)WNh!#8봞ILT<`Jm)'$$j74}ᥛ4 'ñ]:MtlxJDBʯsx@)4at-Tb++˳ uM_azH}\7gxxة#_Rzvpّ sgf?WoN~4QZt.1 ʄf.cWr"cweR„(YoaT(,K%Ȟ1)iEQAA7A 4()JV5ŀ lG*P]z@ݻ'(JDkK+8 cJʲgY4H^^$D@\>MnU:u3rEڈV>>>ճE}^FO!e` mV,W AfݮV(!r/%B qj}Q\X /=<@'@ja/y7I҅l[%? !/Zp=ǖ7ݻCW9|pwl{m{7p)gZo;d1`4֐M1^ɳ,zXuqn40j6ϋq{2|Š/p7BV/T@ Ոa$WjeUUπe~#]T)| wv0vX5uC]ǰ#Dq4vl̺Ʌg1\29ᔾlCFHtYkф%:u[Q<*ĥWr򲶙\ 纙!{aoʳ,ϋ 8Z^x @E##zɩ)kWU~,+ EJc]65Q5Bdք*Tc/9}ڐJ>("dfy;Wzm|zfiuMYPcMc#c Dx7ikϾ3 hH-^_2'>(uPk-a,..̞;cZ.MH'yhgq]0{][BqXu,/W.s)f&|{?1sO<3RJY r?Ax1[vZ)!m2+ Mci6G;>(VVuZϊ>3g H 륜 ā+ CbT 0Q؇.0Xu*u@到Dj}q8}ﺷcO,V).RkA~=Poz>bZO,EY/~f&"av>R Py"_Xc_ϝ=?2>=z,,&'ϗW gz޻_W#YʂN5LQrh9vg?ߜK!/&yRxf$|b:YPνG+Daոb7)o URqL\^¸V8@ 2k??/]lsH[g,F,@㕲uHhHq&B]fW*.VU-lLΜ@§Fދ2}q=+|sll鑱 DKU =zX ~O=^? @zΝ1Q畀BMc%-_kBKZdx!iQ#1{":,A <3RiNo1:@9i": >=W=Y#tl4ެ=rrbb^$VkU~je*Ibsݷ=rȦgo'w{RDB r@ifYH$;g*yQzT( +aPe=b\ XrVV*Q., ؿ != +rP`BtROt "`|zrݷ{fZGXhfsmw_<F=P@a-*M4yc~4%{: ٧XI;$_; ]-}Ͽ2>5v%ugO^ YM޳gm[=~?/`mxC)?܁ȹd.>=FnPؒrE 0#ZI?1&j::("R"?Vn—νU8P¥g&D|/QQ]P'}:4^8sʦum+21R APxt1>Tu,w֗*DHolb(d0K˳K=lq,ΐ.l!@@MxĩMSS}Eh"PZ/@R%C +Yhg=|xW LUUrei (LB/).\ d z[lƵ#GɟCzD|t0MyE<uR8~/ v9ÎAP Ta26^ *-WF;63ӛm B\2x<}U/2a$=D| KrKMVͨUZ1Qi~HbAηA`#`8tWן; l!Ko~RW@ugfOml=yLm|>5Z0&mv}]V[Xv$ xvo^=2;WHAȑ$nX7PTآ]AM}yC~+r$J[:S Keޜxw ? )VkE={'raA\k,rJقBm8UEQQ9?M?/~uBA(`T&3$Tɲ4tZ1AebhFQl||;uy P^#B" o3Y{Zj@#rBd;CG.,HepI X\i=qcXL n`ҥJ:7Q%8r]uzo|_|?GF#qO>u?;zݹCҋͳ,-\X}Yo| "T:/\V/ad-JG؂=qwXD¥1 #0l,KJ (L܂H)ʼn QU⥮=3,_f6oL4<~`ᥓiZi3]NMA ꕊ+8h(q(<+D@y(% 9Kr˥@+D犵5jvk=#*u{j\*@`2@JyפC  Y\P\c(@(h4YE"Ņ KC`nUiӖ^: >Go21r6;91ǟgH,N! zDF@d^;yt)Fd~a<|Xe25fc Hh6@ɷaU%?^b"@l2>j<& @×e<d0h=br~x/</BpN So?CrjYkQ{?Pȹj e`+RQ;-ed{K 0cBZrZp3~:ě@`PQLg5Ƨ W_v PJ/< BRYkb4.i0=_ ͌9B456u=?<+;,$0jsfg.}#No=X*xBe"Wp/_JkT$ `&ȃ% AyD+ t{/9+0^9dc̆Vj=&!"HurA:k~uaDUBy؃W@nJ-*HeŅťauHХnyne|h+솚_qaɵ Mz#C1GDt~l6GIth9zwu,5S߱}oj^k04(D!(Xbx3ʐĢ%g)!n 5wsrU8E7Kxyk]klf&Na{d/ s B"CݡKF-aJym}1D6.a? aA@!Ge#<0+P֬DxޡpN@ yqYbuH"`t}-).BW|dCKA_T^i^eԤp IHͶe&a#Jq`KDn^BU(רooV꿖UaV' t<q{y:ln|նnVj @+-CazQ ~?+q}UTsCX$ru>uZ1(6&(L1k~7qXH[?4^]W "Рn1UgpE)PEQu_o]glp=cF/;\@j Vɱe[zrKP Ė(AΥ8ɉMYyڵAjBب)}A y3=}>u&R`/*Y ̱0hEjgș34A*@ِ 9i{P0ʓ4《ρ p||4.\iuXL RZCx?5P 2D*znR{y0kώF3;5=Zo(B@ n#g>_zE1eCr,rW3(S2`!T܍%\k]@`E2/JP +,u5c` /%ZauMFaQR B p|lz78DjbjDGfr=Vf퓟wV"ņxOO?{ؓ x]<[QX5&pFI?+ JZK/3p^@DyO\B:7XO_rK.*]4JnX.P)! B==,hy "GųhEh7FaگX4#9ei ~m& d(E(x aq8WGQ咫$䟋u `7\l0>\yߺ "oy#60o\ʥAJ@1/|y%PaM0mKmji±";*t$ c_8G"$2Y"ųVy'#Hi:uKaH[7gV/t5@HZpbjC׳9sfvm]R0FIJKDx7aV7qq:PD)P BJ|Pـ^\Z&❷w E~wiiERk0ő*cۥɘc^[o=bE|} Rhކ 5,^Ui3RVR{ ./<)(̠R$^G0K d_ZZB&ƧaMϜ>d0k%i0>]kO9"$D 50AaeI,l_~W,i8 8y8a&HDhtxk?NUw5 $f4O+bhA3˔;D>kl6|,`r[bZNÞkX0gf`Kߺ|d;OҪ,HQU׵:y(KwU.%C@t% t=^pM<]I\dre$@h:{=Y(Y![4 Wc ԱBww$Hκ4=iI "f ҨفM-Ǎ>?09!*-uRxAy'>Աb/~NJlD(B]$J![*sЊam(\> _4{6Msh߳Mwi?׆"ԑ9F R[@ ҉oB 0H&NBAR+w=_~Hŝ@?Z "H&ܹo߾G~ke VaHӻr~/!UkG Yfi6!~btAoen{&iha\TeB=zWh&gϞ:uJ!RYIĹNk9ap Z)Oz> 5=qxi\a`"0eµb@<ɀiŬN_WKh`;FKD|?P" v斛|OEr}NR:{5Z"!qK 7sTDMmƕh Yej»2$`[H/.eE4@r9@TV8 E:EKHƩ 6lb+1 qZ.O Y*N_v驒M@*=]g%4췿m:iRCg<ի7>yDlQ) D"Աun6H],v0O Iz+4y/K-(EZ+I)+/ܚi 3" ` Ǘo!'@$ĵoN6P?_s?{Tuz۽[\X&n?V ᧟WǏs&^Zwc'IE{UPiz:6 N:~zmkdqAhnٶq'z+M+ Jk&-T9XZǦN8I|2X)˻īuV64EQD|?#&V+?EnlbRSE؜;;!:}oN=% T*ap[۽wٲaBjciRX3[]ݹXf}<[==sIAlq#_ZQۍo3.ɐ=,B2pɸtKvQf~(/x-xT 6zה..O9S- u|06-sۮP<}ޝy;o9c'h_K~r'nS!$P*aqZEW rWzþގ@Ejʏo<i#[tb?USsG DcPvxA,2qip,"rI)M2IDkQMc|4 ,TF -˾NYz,cԟaRrl~D[*D͞p@hF,(oujBD4Qڤ[U&?kBQXjrEhP^ƾzr=n6" `U}]䙺o=O9RmZshYݟ %H$ Ӣ/$i TTйiӖu6$"mzIDQT )R .ҡhZk83kyArRW=~뵨?mmJ.Ђ*ӳqP+UJ`gW_tW_U=?@eP6Y(L_9qnWkgKIbX04wv&OiZ\= bORQ̓{7>qȾ!{?Б}F^9uDcC @%4&^PڷJ#JvZaǼh/l/ 9q` ̒%3p..(:Ker]Jf`k«/!W4 2d̨"P_}6K# V֏~8eE*y   NOM^xZGzZ1<1۹jw.`ˠaٻyx kV3@M6 rнu뭝]u Vkƺs8wjȼ"X. R)lQK?t-5R]9Q/ٿ馛ؽn^qojzĉ>艑sw>|ϋ~k_ZP̅{Qi0 F#SG@޿ٯˆ' )UƬl+ 蜢ڜ֌ڃWn.v떹sNkGvM_w~2Z<btH,r q4K%8hI.,A6tuw{P*5&rGڹӇr̠XH@VN]?>7 (pgl8ǹ |4|tggql8r\ V{`ևv|U4$WueE%jZY.K`E VD޽_B!338w)]P g[:ijj7+r rM4{O x:R!W~gZDFb腝yxPuXIH ZTov̹S6mA )vw(+]&"2_pCa<;; GF-C,Z SSOgGN  ֬fVΖ@M?O޷ĸ 1;ffYZ q9,.ɪiA^|镗_zS߷m a{c=yg3e|بLVm7CՎ_( ЂZ Gzfj:( g-]AYLbǕ+:v!" ՠ`>{?c?ď]tw&OӁAy[ڔ^մ+Z.kC33I% |£3JVP7طuZ5SQh Vc[y?P~̭ݰq:=JhP;~ @$Y;TgScN-hgV݃}ze7U#S0Œub" @gDD7- Z󙺈030퇾d)ފi|c6 Z|/$yF[c~p @cfctjQ{9cܰǏoqj; %AezVΠ*@$<ϱ!&I[|^*>Bؙz>5$l[DdW܀粘L.K{"{gyP&@;o歵jŮTIorDRu>&f>RЀIeuqXC%zIHgJJ޽SMP#fAT,]YHsi⋯aa!omr96&r)hFY\ $6bPI⌃ݳw;3wŐ` J3IaMJ ^pDz}ܷ_8rT`O=*࢟|Gp'iXDZg,c"kƘ8фI|oUX',n.;g '92)Rֹ(jxCBd\Üx|bʦ9ŮUNOLgo l VBS9yeV;x `.PsP3Aovuə\_~7ŮT<9~flbjdjz#OC譻kˇ ?uٙ˽>BRo3Y@#bP 6 |G@5Fg0xQY΃9!nޑImKߵ 9)M R /{5I[mQ9Ow@&P(B |Lcr"jʱrq* 8|E|G +V`.l  "J%t;w?o8v+7X1؈x4,۔mF`*ιͿ9i!_H#xP43-3hCگuuVkcy# W4'\c ]vjUPS<׋W,q18|fĈ YEq-D̔4A@4a]') h'‚Y \ZIh Za+s:MF 9? ZfWnf҉?.#XTBSƌ8zt Ξ'nxz'3kq|uk Dlo__Jv:ծ;V嘵~o`jV l 4QqwOhH^HDF{O):7n&TB$Ih S Y+r#SGF cTrkg*ށo}s_m %>R:_J5.;HXnX$) 3ۊV)}q7 p*2wd4+2GԶT]JЧ x9BrpK|t`rA+Wv>8DAbBѨ(6A;ysGzb_#I,oBUܸMPĢ&4$$GR^8= NV^u ϝٴi@#@Q=? (!B"-1W+m dC4}خFťAG}ZunWٰ̍:|1`pB`@DH(u5Ɯ|PGA"z-zn=FM  $M-;AW+A>h!1BRD \w'>;ry[ (8]Ћꑯ ҞBb֡?AIsN…hRI뛞\QZ5|$SSS]]AZ1zZvuu)h\Zg(}? B`h<=^\.&T* B;wnӕsgR'wi֮:p6 S&GK3yy0RdjujcW)83{fjtssd,N;-$\()%ZRŰCBTĖ/VG@(^]F\x'^D=iUW}vly *$4~G`gvCɳM jO|O@Z/3Q>LАDH(2ЫFNcwwŮبZ6ET\O1h*R@bӻv%*I<72+\ٓ*(yQwP r a!x_xAH-gYX26\,}t2gj~[Ai:d\pJ!RC>քJD5R:ڨZpA(R*GQɇ~jAتF$u)DȾvnȈXm7]XPy{/_|2!C.CqC4QBL;铓!#h\3,:x>6g@%€ @aV[ ^m/M2 Y׼J`)&ۡ7E> n ;Kp?0e\6]Gחd|e|YkdvȏI{\{~Õ(S~!qikׯ~#HS )D"`F;56939מ9ro^E%Ijah8)ս݅J ;y]9I^2!&FΞY'?'zW9|MF'޶cSO:bǼ"e]e šGz_9fsb@ 5yaIIVrp5c@nI8 ƀcNA ;\JFaL nja$E;D q=rw~W>%L> }crA`QFB,8DVVA޼qHRɡ `l|k;mmER4$}OWw>6:#uV!:Fmwܱc+W~mrbK&@"YF,l<033FWZYT+0 3Zh$8 =Gm*J9+Y88$ D$jԓ4ͅRZFTD B~ sj!_(|t_Oo1Y=ٵ~CwiTkT::c!~Ww9yjI95X < [+-thoJ & +=#2=/S} !hα>R@]9Y*Oϰ8H4O(?7z褚r.P 1<7V Ű{hhMG'jP`A/IV8ocR!͔JE:Bz3cv5`"Pn;F r+g43>6@,(!0# 'iâ}?󩚫?E_MK0W?Dh3c$ mM ;@TVǍ@-ӺˀZVGM&͛lǢyMNlU`ĸHwU$"lI0;_-\HS5NdkC,f~݆[8HD6 -69;`q,(^Ux[Ye>x brD [rsu+LMN7~~OaIldOJgݺ[﹫0]*`SCuruUHH5mwo{8P FqN-o6Q4jbW@4 TbW Ű%f,h'9jA4r2ݐ<œ:խh ]=a.c4(jranft|ٴ{2};I1Osb)l hp@ݽmm?ܲiӂDsi5 )@; -7ܯl -}8JmZ'PWmbb\Rȇ9|$^tTb['+0ı8{aX-/䦧FjZ|f63~M 陙ӧN۲e3;nԣz*tuac'0ܰ&?Ϟ^)r- ]}( \X|/ו}osK80> !BW@=]:  6cչ@ }j9~pR=3` #.qt< 3@#$0lR9G;D+Y[_y|2d if8W}uI"6΅UV[}"Zb$"XPtd\y]V,'EYȩFz׫  e}x#23 r_LM4GOح(o` !]8S.iuCu2 Hje17*ήR3*'ȥ̑C# '#i׻u7r9iE )BD~SGf(V`?}3@)HU(f\dʮB'!h ,\MQ";88x۶{Y9@Zidߖ6nlܸyp`PT*ŎY6ǀ!ZNrqùӪڑR@dЁ\{^(ChI[;+֗n 6Eac~^CȨWCWIʖoݺAz:.M6@_7v 8iت4UOUm5z)ĒEBBB$vn^sgJ}=ǒ$^RLUz(}YNyÆ@)BgT.fb#OLVXuI%q3U+H @m|嘴t.ܲuNiΎ):;:9U~TV JR>wӓbcP3c#MG" u5hw_92M&k44CgXzUj0_xYؼucY?:a{յR, 8u.J"') <@PDC!qBL䑨 &)(iF8ΰ6o3<4d&RY/PIKB&?/N眸hT*FE anwսXmn?pc@6r)^KSTT.BQ 5qn>rT*@1MNNJM,IqO?uGO"} s_ަC~Q1Uw:H|g[.ҶXz7GAiT_~i :zAhCk׮=udkz6laI\vE)Ҝ}{Zb%H ٰ}CyO Ø-zS;! ) VqC l|.<&v$1jhCN*8E(g?a)*m|4d+G, HRir-_ֈ"@AqEQ zϥ%R'P@֑v-@/j8ʕ5*008g^=zy0, \7h *f4)բ4{{sQu KmbŞ:}+VdHjQ,Eeף0y-ZJIR9E05]El4\ vb3dc|Ѥ^853aڷ-8=*Cjgfrt1'SN{EG o0t[x鳧gƧ Ns}?ȣO>29TkhKE vouGq254$.J51dBHJE8@e sI8 ! 3"qK<+Y`IhD\A iZ# =Y,3aOe\vDtBs}^cC,t^Z҉ /'RFR'8  Ib ICc?9X\VB\O_G0vvdCC(q!@b=>tg~Lbԍ@ȱ?*[:$@kg^5m鈣yǾc4o{ y_8&A#O}eHTn^ۣwΪJq0fn/N|ؼfM=^/[T8;U4,A[|慈M8T[-;!=lt̙#Ǐ6 WL{H rk֬1F)?#?sU95rB1GQÜ}Lm1buTıM0=[+I.$A._kZkp֑6ΰXD"+HY&""57W>7rv5}"222r3ck F#zo`X40/d~pOhr:Pʹ]!LU.Ms 4R*&N(d*+95н3\+3Ό?Л;⺭0DiH&]w}[>;{7+[lu,F`|Bh qaTјcY5HPAREWuoAI::"d7Z6Xiϧ2/+r J>'"y$W~pR;OG\T?3S*غYZȑG_?Q.\~[2jcǎ3vݰvU>bZ&zᆫx݆ޞ\*8.ik _\.skbL跙bMXDbg1ߩIMSO&uCLS _+H, }w#3HL>sp-; Te0yʙ #jɪq "r[]*ԉ?FfNݍE8B'bu*<@ 0p_֊X&IJCGP: +W@ܐٱ]gދ/E  %YaՎY *!{ǶlPǎtzvˇz%INL+;q9D%+VHQ8+ju[|H9?0APmӳHuٸؾ8,GTgsa,lR2^9>NP!)dblڂDD1,?8<~M3'L|#S>+#9ol2/ŋD%^n.ivnJl7qҠgsǷ /:rx$Dhca HطaWgQ;^s<7y.J^z0H bGZئ6ͅ}3TqUp5zX́etζ4K% " f@֖ggY8fPw@3NR@% "B˛4%A k?3X%Z9S5K,p?Šo/"1/6QKͲ ETA0=a0g=@1̻uSd =ˏE&MS֯'~g?+R\r@8~W^bHn\r*q(+;`&mSo'h.܀ o 826ΝrmhQKƦMP1KO:^GGQyӺu==Zўޞ|[fG\g=#3ڨ燾WX$J0q7lPiQХ4SEˁ~ 뻊RE 9R )zE38P HHBc@%\Vvط XNwlS1$qf;*o/c*Djm[JNu s㜏6_^=V檍lpco䊋)7UqőFG`Y( "I<A-ztܤVSV27\2Yv_~2Q}a!RYHHAE..-fy坨- ""bPXb L_R,t3Zd.񲎍+'{R8`\>#KW6b Y g::~}?VVlf;*+]ur:D 2[TGaٽ>PX9&dQP+^f[^0FϬZ?셞'COqM+Vr#(N;H8hT׬8+bxFcv;Ӽp]Ůb iڈ#Oyl]+BT( kXdMS@rMm+jHJi\›˼޷{//*]jku}c64(e!`h 9)zD LfgpݽC+V͇ BZ>o떍H4&RZ S&Ki 2G;(LӺ1^jm J a$ٹ&)KQjj%m,GR|sPwJ?} H z=7o=~䩗2( חč(8t @@@`9AiJb9@Y(@- kVM%e{pH[|%}Z̈́)V&&1ՑHH@(HO5a']0"!NZ4UDhLHseZ,Y&!,]G$LPQ pq7RenwJ%TI`#5DRaǎ=c'#Z&Vzm|1"B()'`ʛm];s8Tc,xF>3}($m[oy'<($aE$,uuW0eY$_ 2eۖO}K'ΜH ڷt21 J{ &)[#μrzzBI70HvpN!@*+wl+cv!"y^f"! )90ڋ;q#6ݱ_yq},MƏO?@d +OĦ$x yY^g K֮bXZzDZ`aT8-F`.'UkV~$<99-Rb\JHK[1+uҗtqql~,j޻e_]vӾ}>jx,W3sbzK3:I`J)rI Q'?ɏ=}߉l;}ٟkW93EQ-X=6"@1׭߼ys" $.գ W `%V \1_)W}U$m7Ɂ mVd\ ":X jNkύ+n( b4Q#i3bBh}ݹz6j ^j `xS<6]"@ƛ7h iys5>[;wOOvI(X=Ħq{yo4jfF֮Ro;}rr5o|f6̩s2x\WW \d]-ML&csYh7^F8vl,MBr D5;[;b'Z$5cNCqZrnS'zt;_sk]BPDEȚU/mq|-afRG V v :c@>:~zd yfL׾懶O>'z"T Xٴ,{W{ul_tGƒ*@֗]*HXN*(Et-+QQ!*eCLԞ[(] BbfߣBKk%wSFΖw"ʐ̜$IǙ"pJ<c |> aaTk(.e <!07;ر;xF)l Fiavك@H}n['Nm$ I!ߗFH(ٗr= mKXD4\.7>>Sm4f} ;"@A^ߌQwGw$L L{yc ^JTh&Y9GN(J0K|΃]R-! ߾wxhSaDi_s:a i^?QCX[]WZ) Ft8 i ;H4R3|Φ~2Yv.F\>\tA>D4K\Bizή$Mg0阧g+j%]\6"ISG8 ZԹ>g *Th\\z0Jx|ɱ8.*_ϟfhaKV2xiC"F, `uh#P$ S{SiԒ&sPЫݗ֭Z5qb5egK# (_c0=p=k/1k~x y_$"u ۔do% BK)۠07!E-9y̲Ds?ߒ$Q^s}Z[46Q@ ZIKky0WЌMD&N|,0;uƞ` x: fT@t >G_:(q`|IN'}j |sڡC'\׷5h.}^'VDIBtYKߟ (Tzʘg܋{7 %q,IWi8JbG\TsǎF'ʁ]w|y|ܐKJl N ׁĉ"R Jkfeѥ/DP8#0r N8JoL I@' $fNX&bro1Q(!y134=;_^ 0]) CA5M*$i π=y݇_9F M[C`:B DBYHX4O :ZVdjQ)~V֍KO2Ix5۶܌6M=.h>Т\,GTiܳ0 \a1g|o@Jg"&e LGˉiZ9Ԫ3H T=C??893nvϽ~;4E#ÎI(e.S=vߧVj|Q JB ޒ;DZTT잗^xV fCL ΥC~1J{mWMwq-<i!9^iG> m RňH2|uf =;3ul$sa\nC/|F%/??r$&;ӟ̜ a\GgA)lٺ9ϝ>w߽%qUEr Wp1[Ħ6xFKr icX(IR )T QԨfgF_.y%\*8|䍗^:}萵B=Ɖv:zL:-`$,|KJR5bYP̼< n'M 5iM&38J}tCs/+jaHI#GIZP@47}W~?{N@E(dɼH}[6+U.9{|@ xօ|Z",nH߳*;Gd 5i33ۿ?51A@,`vvu". Sܵ^(V8?7j@Eu[@ * Υ }G>SG Сg4%ED("F2E%F@e3`=4Bb4j2TFDDz^WϪ:6sz<{عF֨zJrBGM̌ϤI=ıY9BDdE\v\63pd<YƇ-5!27BG13"e` H@)&a$0NOtݿ~<_A ™ ػr@"YGPp aag3_RLE#CB2z>:id :̠BVrAk2g-@/(X!ӼV)s|NM\#)HuK.376uP"Hǎ";jU`]@.7CAhZ-=7w?/QNBMN LVxϯ8or^%,@DNı2-$X= ˕45ep yɍֺ]KU|7_γYeyiF9ϯ z~?g~~~ UɹX$QJ!M\>$V1V*c6kd0SXtՆsu F&w|Rq_7?@c qT4iR>?Cm7*<;ɐ'o:_dKdȍ]e2rJY&[}vc_Wo}麧t#Zp ,B”oݶ-MGVݤڂ\}_0u;74D<}[FdBs޾5u6 7١`G{$P)XvzW{eG`p,Rg?kNY J^lz%(XΞ}`ݷ-iw 0ԧbۨ 1ă;WvEQ 5SSٚao&8(`mj !:i_.WkWMbP% ҾV,+ gC8rv`b-!yFkȡ &}uvddZi N5'$iZ}[5ػNݱ(_:ڋ{Kx|6[OZ "#2`),l6Ni/hl:D\J&8Jsn`9$օ*C)bEAPQiyC47"` T4MGX;~ >տ$F]Á8@qBw+|>qtW!Q1$5"P`19$':43Qۤ ! (iwxmg@Km:?9@@iƴA#P˦-kЈHBG2\o niXpLn. ~|bA#Ao @T^:FD ZX(v: FxeirLxeUIv::fgUN'fuԽ~]۔soR'iyFpq9HeEclpsxޓ{8ǨT HzH,l|UK~>R >zm394 jFp,^N,[ !"O.+,1;ByOdy Kk r%M]gggRyc[^-rn 󤴳`Ȥ㥙#gNms$YbI WRcQOp/yEg"̲GߺDN)tO}O=VZ"BS!E~P^%Z,Vʧ=Č>q7˕3&;3{O}[ZBJAbB3P[fF @D)燶oG kujsLG_ycF-iUdLmGcKJY |'٩4MyBR%"gS]A!@\߳oOԃ,'>4ǿʩK7-L R n}ž!l-2D>y0\7\6@8lO%U.Mo$k{|hڲBOhZYpnv_C,GdE<Ké jBD1BOow=Ǐ[$)_~u AgBZ92mrdfb+ @~ k .$hS<$i 4`bwϯKqRΦV)E84:!W-s3ɱғO~3leJz|eĂ"\\r2^k~o ^)4iUFKmpd1O֞ :<'oj̝z N'>sK!Z HJ@ZD^}wnï1m"9o[vu]{!&N'vؑ"H0U5mu+.B/9_Aq {iޜ7 .ElS9Hy!_~9)"!7RMcNs8>EA~_%,@ iOGlDku:.@iO}KN$% =Ri9d?$m>p{^j#ƨ|S᧟~j׮~~ȑ#33<Z7vΓ@GHD !~+Q+$ںA!JS`/|IBm&}Ҍ87δZQʆFB%*ŷq-QXp>8OZDZ_~pMWg@#HZv1F}c?1o>V십'H?~~'}\NK" R!42얻!ĵ7=oe, \+"m$ȝ$ ո~vOh`N!Of0U*p #P"shӶM#'O=AiC{?CgVi j;*UٲLK D"LD%O Q|Ƈ" DaP.@V%+x(;UKK /?Ⱥ m)p}ŶNs]{֓sg>u7Jk{{,ZE}KiJzB  (7<;;ڮc:jJTZ9kxߔe?NEzͦwן( }NJФlĶI(.|.c:ˎd*2HkG%mLX?\@PZ4%95;Oa6|މ FFnQaS_??jA I{bp"A^el*0ZӧO^jC x}> lں/|Z—RkՠvQE%6Mh!%0U?~s̾~ &cI|'>=wm.n G$mо0H]Y.aC)!F;;7++"HiF35gVm]]Fܴth)EfJ!:kR) )r؂XB(HtCĎ2s( s^HzzsoS J;qkxbzpBfq̤T',6lF% QJ֖'N{QtqS)ZPDxYq_On}wnܶs "r#ʮQ"h䇽P u= _c׾l9nD1 Eo4bRZ*a*rp!] N6v`l " I"JD2kh YZʉC*H\i ` (κ,&3O 5@"PN=W LKo~ v J`xW/tSgvrfC0),(OIlX-ω(è&18FC1{q6)8ki&,cz:wp6*I%ӔB?j4XJ2ZgHx/-d4$v4Xe9,>x's!>QU02ƜkE*LWJkBԞ)g_v a@[]%n]<ޠ 7l`-h s?B\w`pyKlɋeC,Y6V+E1A'')E@ٱm-gd+b8HPʑ'wN?6ݱ;WEE5{cI_[BuNe\#wWk?82bpEH:I4Z=Қdhmjڱc>rT{ p*0@Xu>3{e8Mf/~I>1G B\r˯ŏCN 743KUHrj rm~g>V׻|[ 7Zl$˵ZS*|>{7 i* xXz M$2(-d4b v 3;kB1fje(V9xpJΎD6;qbWV֐j=ݍZU)UH*R6M9m(Q#̕Gg&Fڻ7ɥyTS)>)b !Gw߽i*́(blb~Q0cnY1tZRZ1Y)U Ʒ9}fg{8pЦ)30.V< &I<# ȋ{{"zx8&͒5fzI ݡ)yYE Ըqh =DR G0S?!(93{l1PkZB`4uo~p˚'^:#֫B?y}xg&gZp^$$'΁UDA\!(X, Ph٥W{Y1<.sZU2qZo@I ;yZM7k$ٌ!&#R]ݻw5611FB RY fN}c7vw#T^Og2hԈ@j=:q亵n^izj VD1jE1ٺ-{Hq}@Qljyn~ߺmJbI) Dν=\0eZ#q^K: $yjg^yz+?Sg}Y9E̚ GFG*JWwGWWscը. z-T8 ᡡgh\Q'D@02o}r sJKa`gXl$tN=w~Zc?:yl aMJ]I>YT\ddCkkJ]:K.qg 0}++/YڄEz>@_<@EXQ%n4m֮[:s 'N#P+kʠ!2)cbUqYpLP)0S FR"(ktѹaieO=TON(M|x~4WL8{_UmT{ӨTKҙZH}C+˟ٸ}%KkL<@X% )4sMݽ0ԓW)$'3|/g΍w^ssQlet4 ֢&@`k RxT)$ `pʡA̬Nꣽ]G_o4G o6)H8JeLL"M8HoO]w9?qMZYhi#⥌tC?}%x514p"i?=|M<d #ۇ!*M{/|'0t%ʳ==ʿW\ *VYf׿^zD9 Bʅ48qIpl "$Œ֜Hņ"Z=|Soܿ펭۟[kb0Υ48ǰ<^y{.;|g `J 3qyVTQˌ 7޸#Hz/}[yEsI娣3#o|'_LIrLΌOxpeG; \dzM">3ǿg^tv\Ue.IoLd:6+|}lZK%J xN ;,2EI<%(rLc3s^SSjggCKA>."ڇ18ޢxG^Q875w`ׁsc?w\naT4o!lKRh$8털)z¾V9J*v+ky%*Y#荵j'LVd"MZ{)@s^ ڀ} ّg~^={>~щR:VPRy*Gos*b3)m+oy@zo=->|H%L\$feD B eEy$IB;46T 1*- 3XL sg7{f:E kVlqPoM$ľxý]=]oZkn]_vh͊_}sss}gG81Xr`E.={>v]^߿O~?LZ9~"ՙʌ+i@Xm HSc[/yw.S(/-+3(" 5O&(i4`ҿw2YOc^2k Ҧ7ZW}b4O~k:{;~\K" F'i)@;=5?ah`@0)p*նG=<~c#^}?#t z''~_3Ptbh AA Y11a9홗:#@,nP!d\ #b7LBE.C[ڱ w0!@ʺS6lg 2 IfOO›TUmҰ޺c bHԫ<13= n*,qC< X$&D7R;[z Ġ=k'5 bǑUs뺮sνW3h42H99'e e[r-ly4ffqqؒm(Rb` @\]n4@IZZKBuիw9{ngbYI~6tl|+.QLœ~y:Y<̓O䉄P-N2BeݩcVXg|qUJA,@+؁F4 B/aC9D殿|YLIB{cn.lFMO4Hw_G[Agz7wAX&&Rt}sTK6#OONTX|&DXx $Isf048fk-mȣ.!jIh 0(/'"\O,x"mX]-2y!un*&Gm-q< IȯGH0@t, $g&869^E}3+'&K!8u;C`b8?]C\c^ж*q׎<:0{xva\tRdH`7Oތ0rZ$ZQY)c&sApF&pa c]!B^|MH CO:5NYg}fmq`%uEDJST008ޝN{X%rFh q V*aӊΞYcM85mrvX? ñR m3ӓ]zTH_\@m??ݱ" {֭)r\ͧ=U<?r_{}>g{ͦXk7^5<2\ӛ(c ɯntɆOVԷ݆R":s;cD$"D|Ԏ;֖NetO^ecw:ޟ#Q}?̾Co_~5/﷮j j\ݰm~=g;$Ql*R`Fs2W,XwٕZZx`mdAH-W"IgOI:/H$X$`s:6yܬѼ" 6nN}Edbra̿ X3VR~57\K,jF6lrZ˥M2)%UL1 جLs𥱾Xbe\*Wf"0bA ,HDBLנX3HXH39vK;KYhccH*@u9}rK,|Wv m=BUfFQE3#ʣ'*\<=2I!&h:& ٘8mmlߺ>o޵>ڄBFKeS\l9''~9R$SPm߹R3~={qsϏMLlj#ŖA i,9<k=g/ @Ȅ ↜7p#NkiŁ/51[sjÚw卨_+8Sd `h:NKΙ,3 "ǮFHBL&].Sٷ]wG. DƲӫ6|mΖ&ܨ~ Oð!7L`!"b~ -$)k rLbwysMOHb|B>D'1K/0n>r=Գ[nr ﺢ%m>I|.Zƶyy1RR6B9)ZeGe(JI B*4[fr?- B[YyΊWeV~tVvςZr:#k*.^vݥW]{xBW}}7&fWur@=6BJ1Q~s/:/4xu&ql=z;4r5oQ|Z(MDH9Ʈr=S^м.FxsUT3\XD/ƕMͮ_s6i :\9:X~Ptt8k,#QGm֭_uegJM+e$L~ﹷUǟw^3fD:4W fO䀴@\Z'Cj_SS fECQ]7?=>8ҹv](l#/44 \We] ȒH,Ι JqĂF}H,Sx9tޣ;Ͻ7^iBɔrkukWRH?HR@&Rý9k֍W^5l'_Yn~<RRJφIq @ j6_|gaU!.pKx]笵RZۻr||3sNV]mȇG|>ˁJMU;/!&Fd;׵{;53vf˅cP\$I\J%JIcT1YDcIi T2 BmZ2:|zRT- -IGD?n%ڔ_3eE:ItZeB3ᴴ2Ւڸuͣ`fF>o+IyeoL $Mxw}r9v";st;ɖ-ږ9U"M"v=Wfd8aT 1sa|f|ג./!$T=Ȯ]vvݾy!EJ /ey&<`瞇~ށll.Їٽu]9&^-NNr:zOs[JT !Fh*%T e)T& hlP'S 74~*jEok٩cG~ǞQJH'hL$@r.sܞdV_xA{gKOoaYݱ!Ƃq-ua]p$IHHIJ1ƙ=c2C2R)0n/O~"5v\7;[KeZcO<=>5SOZ+8ˈ[@QH86| 3'oR]ښd} mZR^bn-лA$hCFb H *h;S WCnM0J`BC|Ʀ3fWH4k:UW\(!(7{p2a-I_3 :sv 627xژL՘AYC ﻹWcA{{~nhhhgg%۷l nG66M'}$Rzש$NY](k8)|>66NwCmY$Ŏ]%@t2;&'v@`nO|WYg]h5zdxMV-x@^~wf~;2CRDʮqTL69|h>R%  glSY%1ĄM&#$*@)I3X4iR2Iuq.jԱbG>Rs|/6vZ7rh][oz߫qAp 7+U V6l[[tu-?VCDRL;2ؾ5WGYRA (r 9&ąTaK颬5}ՉW]d*e4?@-(2G,0qMi+Jssqc\14Cmt96Da9J#ȫ, k} P kO_qրJo~"ͷ{Π7B瀐HXS-H yV,@s@& x2cd Xh2-Swon$/oU+UfB:0Ӊ1Z[Ien\DG{ƛ)nxRz{N'hݷҫvJA {}8vXRۺyC?:[ZT%Ɨ(u$z0ձ#&hN5*S˭!^Zj9Bp-w⩯<0͊8pub3Z˓(XH@@yHb Q.k.<5FUK")s]5a>lHgL]s |o`l!hz~? PXÅ|+ Q%O=w߾ѱ)rI@ISŭhQU_[#CxFwFDžޕul/և\L<)I<91-ՊUX+8EK"$~! 81;dǎP)NߟX;S,Z:Zqqm{{%QD;w e]c@k`3n0osS_Ys.cSۺMotbfȭi4;l*O=O}뮼hӛ..HľE] 0g!w(vi?xlOzEuk8$ǢnsFH!`&kv=$vV~x:,<3_d@^i9L&kq8}|h_zHZ!e }yι[m<^nO4W<11gKFŏyWGPUV #2N k4w-H88v-k=E5[rtmJ_Zt΍6mG|ȱ'y,tLl/ܼCp|Ío}o226k*Bad9zN~Y1~2/g 1cr&=? 6Ov?8T ²O;B-OZHeE2X2cumC$_Uf^].ʕ^]\36EaEHk R4!8 J dKZhkC `]:QJyRRYR~c8_Y'hĸCG@!'SƹRARuO}ۿZ{zs: 2Ƙ:#O )}uZ HD`X@R#EaOe+ֶ/B rrZJP+rL'@jXclMNך~;A&\ڥtZ{Z 8K@e]$O]SKԕXlqIַ~O?zϻIb=ORu8%g"8S *ڔ>_:_+S}06('!G4ֱ I)]5ޖG?=ozd{nLI8 K3Đ۾e捓Qo敛ġ՚hG"VyNpN8Z0}Iũ?|(fJkO}oy u6JbLRPU39cR K|@iˑt :۽cN^ CMKeN^:KE -#p 3ֶ֖x6n[޻7^#WW5YrLsi&!W_<INR` c, hDJyrmX+"ߑ@1!R]Z`l~쏣ep#ѾqDDvaiE{˦Ntw+Niqg{6D&[JVrN MM󭅮vm" #$18k]њ =p$@')/C_:W#{EBH@F )8q8Iyս9k1qVsf/Eá ?:u^Ԯs=JL[oS@+E PR8@ps&gim>n7PKf£3 {.^S:(9pXsO>BDuyi"kMMTv D.[ߝMWLN\X*x[nWҵﲫ=wzoANU,@ǼQ)vzeX:#p&=<OVk6o~{{[ޚm-ݯD{b+S#~KwyVJ߀vJ JE/k,;O^LAYH3?j,[Bw6gc%ǖ@;6[ /ݻS <%Eo*E/|;=/}y_ǺWt _|s{yK.޵kǁؽs1J&<]c' )6,"<-[pmT#, 8/{o[.Yvg Oz+ `vB@,fWX﫾9Ľ=tзn~xgOeQdR)Iq eA*L9gsHZDR"R]?~9e9"3F/#-yG6pEHs"4P~:hps;zWv6J.T-b~`Qo5;D((-u HΘĂkm:ŋqA}9BDIH'vmMϮlOHBI$Z;AS7j ev >TfjOj(M0`vs`(%cbl~W~& l.0XGUcg9?*  RZ |MW45a܁}>n`[Z5$0'u\ݲec:[R(A2mى ҁT#= .7u3J!$ ֊ dddZNZt5KT7mJ&㙧;| otE XWoϝk6npٵN3cr-;%3O?Di<]"Nt\qչML$Yvj27QRגwVH|?SHO CWSj}sܰaC- GGG7oLD;l x-W[ 9pDOFpnG#{sGf+/tㅼ!?4}{|1x?;i`upzj;_uky߻n7\ձLaP7֭(0T,4у܄/@? Zx/6/2'/zEwsh۽*jco˕ǎ ZKJ8p{?xDK5͖Jt68).bg<)"k5JpZOfKz7H$PZ\sI=Kq)xtˏS8VDhj$ږlrؑNܺwU78 6~Тaŗ­*qnD44, HH|2 ۟D*9$o.9W;Wf # !eb^*As2zM(HÇO*\?؁ c眱Xelɹѣ7xiAZsUf ݂glkoFZݱq*Oduւjv/G/;vy(=C7 f&ZۑcN^ ]ZBJOԡ9@V '섧j1f dªUEZb,|x՚Og]M2.y_yOu >tZA2aW]\pޔe In,q '솗p])ǂ0B3Tnۼ!YfHu!WYYֱkٺQ$Y<3`$\q;ӈ "H}06 ߡ]7ilEQ^_b/ygBVK΂`|Ba)W}8B9ߏ!Ԉ%#MD8WνȂ$EM')cbՖͷN;rbxl( )Ořß]w|C#C}~bt<[qFM:k&G2]tāxm>ƦFvzoNSS- <Yh4<3 r#ʃh8=0*tXfTo6Wȫ+K']]ݏ=ؾgǏyX&&:Zj+%UMQ0P%gK}) Du0suuzRM / :-.63!ZVlMbH`Dg!D5N߻}S/5:3MOgIPGPѨ!\sF:@ۄS{!eǎ8FD@ --#kݟBNeff+Ae_엔2-(O@"A6̺g7Y{ɥO8rUzYlR2 7RhC$ўBc^g~O>!ʦ2DqH{^e4AG }||vW=TrjNoڽ]%eyJi}Fdžo;wmwkjN`bm|Hw{~3{{V G>=䂋ԁd4ޟzye<`޲ej́tSs}닔G$0v )g )Y}mtdIXA sE!awZ;Pj+w CwggL6_(>r(T01հl cqE5 T{$˫\|W.{azh*@Ie֓2Oݝ]5CC]|VjI H ct91#ǧPd ֔/×qp z֙ $H̬F$k---DB$$D"gKeZx/?PGck5&a=o} BJ]di/ҘB ?R?oQ#~VM[Tv}=vO #H1`yXtlT-창O:sLq d e@JZgţI}'\;::Z888Τ{zc2ZKEQ {i8 6~7_o_sswYWâ^]W@f~]O(Q Uq延|EA .qV Ոw!TĄ6I DPTA}p.hb< (?"`1=޳`vI*#>+'p`ZE6 " -^rwlY!.˦g=~` <ַ=0"ߖ_uoi, )&P+6I:+7/#-@et{F MP◿:F=kWtmGϻ;/K',BU~g*q 閬wlޱ.זv>x|K5`$8w 4߰Sw/%]]?a{~Y!?ZҋW?C{y!f|w'!*+;`?$x LAlz]rHusB d4dPVZ^X6WG&l?pLd֎ R- T%6QljaHhY)&!9pb˂$;g)98[}|I)Ʊ$2cRZY)$9BBI!8<ə$@LZ'm6rõkVvy~=#+r_| m@nΧQkÀXMLݔfع&f.QK> a B(JbQ^!58nͪ}kJTmmmCS )$/R|d'Z13g2Ct>/#[cVmZ޽b%H%h`IC`W]1d<_zkBd"T˞hR`U.{NLe!H7]FI]i6S, $FlyFDk02THD'NTkaϊL\""kLlM$q}PK)#ذ#&dF0[碴mݰ:Nj&7^mouΝټ踻}ꕕɰ6}ٽO#o4n 3)?/3`݈w}y%/8mxz0Z;Wc} Hr=S RT*ƘjEW)k8IOO9rdt|D9tv@JBW [o._&o;s]fLB]/V Qo3̌(r7g]]^<ϯ{ ݾgJ_0?{Xҧs٠U*E!HDLtww[&&Ky1)DH8jL6ڰa]&kS Q. l*MBR-6 :<ȩ~`ia+h+QZ2*$`@>\ze /GcmR4:fW׽M}wtvpHld}=qI~0/6B< NMN/w5EYO|?aARNdKuu\ka |L؂{gg l:ٺv:. aՋ' h.x* T(ja±`֡VJzFxyfsFtFfU[[%Աɇ~xB(SlMF?߿zUWx;RL ɥ;.]WZrbl+_ZG[7sms= #o>ڱc{gWGĎmwwضmnMk;RŠYh!6/כss5Is18$8 f,آ\Vn tl %ZgXkcUkPTN Z 6TX ƍu'v!bVjH(&WV"*+Jζv 2D6ڊW\yYV^ѾrU:i)tu7|VtdADkڻ:Fٮ7[~5J(i૟+-Ihk@W $I%Vš$Ճ3Bu`l .– bStSeO 4+otM`þ%0tK*\3ՙYewIL=t#?0e<.vKύRʓĂ*sΛmZkژlK[k8\P9/ӎ0xximmL@%F&Gyf/uM\g ]ݺR;:ڻzy;v*W g|𭹎ۮ:r-;RAGl%&&1θZ%~'8û~gO]>S^z:Rukz\ϹScn1s_åGkM{)UӋ̄߁q!1H@' Ɓ`\710#PRy [T 4v x5 $H@@[@mtPJ'qX STYұnooGֶ6kѣ۷r*/: JR >&4q ցTpT*sƝwz˿6 Wm%09^7䓷Qd @gIs@pOG:g8B9F@LO?} Ācp[jr{ǥ޿oOڶMۺ i`L(ꄐ$* R:Ys=9!ԩR8q N^́gc:Wzߋ科20ͽ?z;>g4g:w#”/uϖʙ^hNLL)hSt:$I'S2M7-LЅWg3l*:*g B[u.bT-99'~ d9&lH~f1j%^ Yxũ;eO3#@$odۭC'޿؍7g)"~MW6@0:[z@y455s|p@ ;Z,O3ߺݷm^ .6l)kƗDtqNXfK^}mFx޾n(-5אkRٙɩg͖ c=ȞGwVs\^Sm<_֡GF'cp=eg(yQ82М|/ *s@ /FPO HqzHyt '%}m櫀2A!5<U$ ;F)+WHl5 :ɬO쁁?^WzZO{ G]ӽ܍va%\ Az7~޿O"7o>59gLN iw.~=$tۻɧ8ѣ_^<Нzd|p HH6ΐ - XuY<RAŇRzmņeg"= djζNsRQϕ<: ࠚ-6%a=n|Pe1 u5˷mio9yo4l58\poR~{[o@F@IXkOp:Xp\0=YOűsLs_itW)hӰ|; ~b1 TYȬO}?7qT uZ19g$VGr׮M |}՛~] $N l@"0xԓB6u$ZBR*ָeJCqYk7X`^-c8SBǎ;(DHHO>H몮Uk{׮Pʹ\ÞO\9(㸭yEaF6 O; ou h!Y#2::=߾//HHR[VEFK'%VȀD*_M">nlN:gq 3ӳHlM8[M[26KOx:때 tXPǭmwrǭ72C&Q#J}P6:3ą#Jr5LM$:9r_.ϭ۽ /Ք\[@l7gVoki{E_ڻ_֞'>g ؉5wQvjo_-oqlYjRIdž$k9RFDV/b<'1g^' J'N_ɋ&ۋzzy\fdЋ\\H]16]ȼ꥔0;(^1(4ǖ\0=t,A\*ٙ]?1T*U+v\|*ASB%XO8;(|E#|+ށ&qֶmsBS3CG|x~*)$A!FI\V|W>MgX'A}yqm6_@v kP{'7s9*`Tda2:~/|{-øֻ{HOZ4<<礒FOaRc YD|O'zzf$2)RBJ/Jce{G848580NY)SJdEr~`u4w"H߀ˠYB[:0BjKqt{+z8 |{auM8}Mi=ɖmܼ8+}qxh+ < kF|^\W\#X|2V~0tMhfFg Iw޶^$$y[$ǹ J:PBԛ}(R;v[)EQhfʭ9FB8N!-X "!dng!9}N4OY(:z}Jԛ(OAX(EúbfLHs,I@ZQA)g㒐0z H^K@*V%SVt?xQd`1aղ7կ%ܵ@9I oay;dnfx-O@h:\F[sTXg} ,- g`\ ! $v^~7|{EElkzptp}6އzZZu޽ehY/`ӓA4 1TN-\-tuT~x}Q&l c9 P#BѦCt39+3 x'B0yܯ|lã2P'сʼnW^><0113j6d3m(e!o-dGFU+W~Q*bT_iKTV&HzϧPK1T9788*<:g9ɱUA6f:S$'^6mˆt=eu Q ѱ'uICf 1cWMBDJl~WW?bksiuu6G҂ޝ?{?8::SbT%SI;8̗wkvx]Zzڥ5DuOiɝѬjH1Jh|fCf|vzhѯFf=H[FB"/}Wv~$Xk/ O?*}˻orY;[59xj]:QHJ86~ƍHlڌRAZ`5qq:i\CVeÞqHH;;/%>/̔iNUZ]:6d: ȂIq,ېeS)Z2m+zH|6[(<gQH@{H5[{֜ /jzRO?nXy^x꯭?1SO=XG?#926Mw%>̓oz%}W:Z[Z; چ?I9/+xtgVtJv,C-ԗS2^shRG_ww~fgfޙ[Z[j!]23]Oyu%7u[x7gD0P*̙l'@X@@ȱsWϖF?~Sj,J(]\xBIl\,bn2HJ$$!DgD-x$$9ڍ=wxWMDE7]V\V,Vi"555^HY@BMO;gZZ|'DOP kJI29}A}\Rwۄ`*La-J{%I2;;NgnD5NXU a٢VdidƎq*F$yI@ox*,3%uw˹A ;>T0sl|GO}<:/}˷zcڮxiu k{,m+'@`4bÇ *w;-ȡxbu6 Yw 4}I?KJЂxg0)P\O3ICS}kz+;  ^/F{%GhaffZx]ۨKɬF(N{o1I=zkp΁M^!:GBfsщLk7R]~@Rl6'4}?egPR(N'*%;v skXd:u|jl_=@O~H|ꑃt|S ]wtv޲bZ+=aOepںR #0T Dg ߩoJ"F ||,j:d:i/ᓨDTJZm)jlU _X]>[7qt _OKBgh+yQe*݂rhԍǍp5#hg8'&'fgcʁ`ϓQ")^{ym}f?ق5E F``TyFp1 3e-xAH Im\ulaX!@po{nzgMtmMP!'ܚ}--9)D*ARHAJXk0"LH^MQzdefclִ6 "bZgnLqtbcTsT*U\- NdW(dбoMXH fhkBX猵Yz X;?~gJ7w3M葳 .H20E^2m9x{WDq#&<#(F#ᡁJ]i4 Nc`W?4;ZXKp>ne0 !l7YfusvF fg0;>[ma-fSA+KH0>19KP(kF}iBI*B$\VC)Z6  -D|!oI8 X33U~e2cvʼnΦ͛ȮB]c^ڌ_#<[bhk5`̖؂0@uF4fo&PExWvo`I2|U\]EԼy7>z}̩[!!~~ޟ^s޳Z*~18N잜)26Ib@(Cb[[j9ӫJB*T*K|WJ !bkcVߒoge)Sj9<E$0IbI$sTNAҔ?sݦ!\Wx)N Ѡm B)}둓L3ywͻ1m۹seocR|.$_T[CwU_W9ҕ%.fZlNLt61d=v+.!JF%a\NV^ Xo<.mƞ?DË쁡 Hl>h .E*+ vnXr(2 Igw#]J>O:(5[Ȓ\PS!$ygO?w[ V&[zZ;s&ITZ81<:m5g\imr?ΤRR<=5}s|/UUhFD%Vu.'ooMk]&֛p-cK" 8B D ڒ`X$$םvm@J>z_|б/|kB.o;r? |nxh"©Īӣ>3{g2U]鵶Q0MS3r~/ӊ:iV. +O㳚jܾtM:ffft-ih¾U=+Ď*Oz{vך^hƓ^R (/n>m2|n~;#B{{ɫ6@%§ߔ2xLٕCm6Ug%iSTu 5<?#cǟ8뿹us^vK/>I_+ܯ@>[oiOn^jG_0w cCIonI WWnX`š:#H7wqr|ߧց D X:P fłjTTW~j H(!Ium~qU"MRF'R bR@䒚\v@l @ ?-޶ ^yX$@bl~t+#;R:XXxt`>s^:G<4TfD +s>97ҝG.!e9I$ Rj4]l-)(a Iw;\9 \5:!Df()VX"x͚uXZ&&әL[{R:hUQR-N#Nb)(ક+W|_i/csO?|6|kT(a߀b+Q\soA _WoJk|&vqlmpKN(uP!D-^&Ӗ{zop6\WoE>/ %\z˕oܷyƞIJZaչ$xp_ec*t!g%FVKI)$0'5 sl@ J ,[%T|rfGE9F>H)1B)AZO"ҺuӮsrH>Z *3O( gHИ{c. RD1:Id@դVIdGbc&2I1[?6ڳf}f:)Vr-N.9wa RV2Y˔wŻ]zN\O`y`bEʧZVm?86:Rȃ?ޮ;|Ir`?o[,N- ^K/0(I N>hU\[z0G XNr{{sTjmkn횎ֶL&UuvviǺ5P LVgeڲI,l IZ@*F"!N8R.(Z R,Nt:ښKXզaoؐ#h^#ST1].AX+j Q ,DcR }Eol dv!;RNRW(eUPOU6 %_*D3w+9 NZ R*6DW |p;nYmkGb&_pͻ.T:F΋GoΖ"q2驑ǧ \73^߰k{w$ n wye0a#EgG N `&F)6̢66'GBr*C( EF{4  ο>SL7_ɋў#dߓφY#\y[.-ClQ H-G6vr{k#,B⑗ Py,$|@ G_{(n3 kZC'ƶ孅J @`9Ͷ1l VLم=D@tBHZc@+WZ-ͦ[FġHjd}Ȯj#G?dH+_!7+*0 :&Bf9m_;9=)I*5/:D+ֳHG2oҷ iIJwl5=Dt- T艶W_eZi&I ?ҁ` V#c$GMSP(x2CJ,I~FZtV*7m Zq7I?uoFi6'8DS֙fČ4 N"=VPR!\v^#h4VO\-s <-JJZq\ݼ]x'{{|G>.z2T:ܼz D\'rcϹv8b(eT̤bDQkX&ᘏ =ؓ?uݛvo۴XLݼf,9DBn*vDžjWlB@c-f%$1cka8[-{zo<]N}xT]̸b"}t]7p}.  ̯Pu'=t}lMk:m,wf{Zj'٘3wkOS-6%i`Ww ,Ʌr 9}Y::B)F.Hs [ג8^* ]JR%-쪕$*|K.ҚoG@=& :AKPRBg EB oxXjlJN{|RXUkW~pg!:$^CDeH^ 5`Z—B!AԶk9st?w}svֹۖ\0==@GqQ+a\khW>]t׎MΖM^6F:@j1]$͎%. +ZXK!f$ F:oO[~x݁{ض}f:_]be1.˵A ) _C Ɔۚyht%#Q4 8|X)J 颋v A{-CϤB6(hvQ yJK8Og;گ{ppYIK*kO=1/𶞿S2 dTds<ȓaÆ(Zۻ*Nk˕{k׭$z*n}_&Ҏj5lk ZONNO7lXd\,NMNUՎ=RzҒKRHjmgZ\%sZ[k8/M6[tbl-Z;IhhxZRuY*EDu`@(S%t\ў5F, b3T%j42hh ҙR&!0|:J\5;շ7_}eRA$痻ED|5RgnBƾgA"RA;LDHrZ:cCIlgK1#dc [W>oz jLJ:pj^gD u$I+O9_F9f$\~?ķ~F6ցCmG~ : [ҹn޼9ɉيFֶ|G{O.vΎU*+ ak\V&BuwaT*eSN\.K$0N@A?99Id*i榦JZ $jAd'%35-gp կ@NLJzY j`l5H<_3l=3{{GKXeX̾fJ*cJmvmpWwg*ulʳӥR9/Z;?#ŶޕggZck\- w'lNcF; D-)r8\<S,ۅ[d@4lW˿烷Tbw+/ܮn ӗ{W\~ ;֮ܵtb`XiK Q8;#; 'v@#g &1JHQʚ$;xI4(De999wt7%j/|K_w, Ŗ$1Zkoht&_b_q )pWD4AVI} L1"K֙rBѣǞ{Xz23?9ElBw부)^冒P9f^%f rJ)`t4riZ~#>YSs1@$8vrR~v ֳ)wB۷]?3=W.saVvJ!&*J]֙}]647ӻ826:=5[Z@ԉ 5ki\?;Lj(Bc bssspH._\QlJR3Nӱ"(1z4:2n u*ZQ*""?yZ vFBzmyOO:d#EFY ooиt-5csPI9/Z}mB8\(<`5%:)Ķ[S/Ư~S-=͇zWغkC$ W&d,\Pb wo7p׾ͽ^l*jS-U,Pʅ2KqqAT`zl`f |oE_un1Şwv<nj"Vt̩|_VX$cSj=9gXD}b-P-M탃#;dDtBKsSk`y)g^/avdp]SkMB?2Repsؑ#[n_ rM7lܴi&EV)hĿRi1v9#TBhE@ *ʎ<`Q}?R޼ijycN=Ǐ,ZJzȑ;rv|fvFj6W|C;vnljܯ~WZs+!P@Yᱵ tNj\4R"mm/~+Z߮7==111֖ɴճg;LPOL!hDVu z0(B [tdMiEmپȱˊdVXxMe?~Rx`n&fC ?Ze䘯q(ؘ* 3py@RI!8(x?ՙ?Y 7}3ʴX|$D!ȦD|;bwq[}5[leŒ5/+ы*9gK"B;x@Ux5ZH9υLȘ-zJ2gyB*$6w]]JL LL ͌L^7HZ!NMLvQhRy ṳ׬ZrmKs3dMęٕVSWnTʅP3S';Hr2@uY@SD)ƷB$6>yfzb&1<};w/Rߊ{Aq]@.h%g2Eh~/KL貥( TY#P@ B'֒'oxvP!Xѷ;NMnܸťS=wߚB_#,ټ_T_8<[O׬^}!{EC/G?c#/VJ#05Ztr%~ޕauY,3Zˏ(Uť.[,`"Zz&&9޽C~`f#?uǎRyYT˳>^_?-cIk&2\E+ز+/wZv/IrXhfZܞy~+W _ )42"~wЇٽrI]S{cWKD Asr=Nۀ+$fdVSU7߼mSϼsF֢O8TWf(ȄR46<<_i/ =OON8gBP> |P36+d]ri|t&̄Bk.S 8M֭Ξ%Dž|{''''=f3b)k5(dRX2Y/ Zqȸժ"u)65!H*EFBH%TQ8K(kABho/, :\hFk \B.LM5y5UҼCiy #):TZBH(b(p ĨE 4i o/?wQ|h{}ݖŞVFH 굚EQ(o>%Q";Mo<^>'B[b!-+zO:Q3@P72~l`VUS<:$A BJdpD ^d(3bŊd0 ;{;jQ#{!2MO3:cZ0=fYl*bK'*K:k'a&Df 04]cwPq\[նn]3х) *PҰzJ,Z0uT)Oh YdF qm׷o_ջq5 Bպ01JJ㵖_׳ͅU?A@gSo4RlfZ;$ٴrɓCݲr2XZ_?==y䑬}=oz=~=o .B @,%M1_jeTD*V P7}_ dC֘N.d-/A` ^ׁ ]) ǒ$`cZcf@(YGh&ؽ?s7ܰ])eeC1;$LL LFx ^2)JY{$Zk2vtbDLNszzvfjw㽽wlݲ:4K#&vKkEəO 'frK‹ff3BH-?mR1<43[֪< tXk{'B y 01!۸L?6.U@(%pd@)Py5? 7X+AX\ܺշrxٖ]Ą:uvnbbfV[ӽz%6I\}?B$*֖+RT!EVlqzRڋ#r/[j8Ie-E(%Kq1+TJ((r$MgfkJyZ91.Z-A4Q~.IԲ ¼1XRR掉y򴲶ƥl\B(D/=ar}Bi@xlI(RK)%@T9pb `̂ @lktݑk͖?f!pMSt{%    K"_u/".UDSA[sG遹 @6ݰ{]o> 8 qq]@ &p!j_z ǩpA /X= a }DTps׀."uϲ3|C('%3©S[;;v;a yZk]l] fh=?\ yNP=u&^Pd #Bs.$ ߚ =ų|Idt .b@pl6鹙ٹDj%S5>F8ŖA*^jR&luu.%ē/9H^:|?|zt[m]ٵjʵWmPCI0ٚ7+TbR ڢܨ?RecՌ`; LD PV Xt.m=ӷ&ofo|[~ ?‘c VɈpbj%kio[JzyA,tgkm;W[5A |_⪾ox]8l|?ؿPgW';_D.MӶf-o !_.[eҲHU[6r`b`OO?940 QP>w}7gJ]ˮJ‚#J6kݰ E#F"vڗ1ԉظ(D(%K  SV"؆ojac`?PZkKsTl!iㆣ>BpRIGTpɢ" ^$|a@~Fi*agpUvZD!JBr:22o#'&@<dP)% Q$~Ֆm[6vw]p;>T/ ŅS1~ߚ4!_}sZ*g-ҚugMk?K^:Tw{wS[KTRZ9 "%8!8pnyIe7fEHOf3\Pwuwwv|?=y^wzՃ !B{^cQ xqO"rkQxmk2bž}/W*׾G7mz(&ϓZ-9- yZ۷\m˖$3:l,_erxF_6SPAβ_8`eұFak\}=p7+3Bk#f\>k)fҩ1:%z̖_y'Ϟ9Q陙\&N@90ilR7SZ\&HMRsM_{=󤘭NZuli޹d>su~kGg>S*3_&øk>b'=6zإ&<d*PjVn@ص&_']qlٱjPTMUGOgS45 GBg0o|W{zڧfF6l^w0&pDZ"iK5z^׵33+mZt1Gn:".lGWlF@&F!/_tB !9k5W^j}Q0A!|Sϝ=83]zc PR^=К2 cS6w\vv)/??400tNCZiʎݚ~:c eKM[ 뮛c#ҥ 1u &X 9/:B>u)1h? @D^Y)uYÈ9ƅW;8YCXZ0RFW> A,;PflQI5rBvEw3SxH 7BXφh9nB#)._1Gjyz)%Iv>ʤ%@1Ʀǎ6]ᖛn=}grznJ (ǟ8H4f@-6&]_r"#";p/`|9( ]9yOHB J"Xb? m/C_2RD!-5idlxrvnlvHKcx2޵w a>A\S=R(뵒!.67uL /tN'Oٷ/ą3ΟUt#sBB- B"pZKGe5ZSN039rZ/4anf(Sg+8v|[Z=?0y^k[20&RJDP2dY"-U3Q ZM~UZM?ċǎ tv?q7ݹ33XF{ƞpu_uQWZ6/`$hlϼ\rZ3h gH*&|o>M"w>9 ׽^x/98|M5hvnvpd$796'퓞zyԜ#G_;>kRlٶXj'3-Epv~&ƉyK %3 $-348ɇD(06>B]{kժu_wc@6OM ؘ'9"8 0sϩE#+J|^n9) ^vTw!fWMď?C=EF1gAtz{{뮣>tH jUV{rxdpvn `eJN~TP-+_cp d(B@r,/ ̻_!zPuu Ϳѷ!/.סF M6Ϟ_}.E7tY995Zlm 2D@(!Dc~! (8i g(%Ӻ鼛#rCQ-\,/vZ(.,L 1@̾M@KAJL(habĒ(|?eLcM)5Ư0 ּ|ԏ0ҥN"`j 2tplx7vu̎f#uP'7m\hm()藙! oJu)&]J}nkqUX0ƫWxEC4eu DC3 `釞x==L/| &5 }-}go|[z[RH BоWh[ե25L  -aNKNEcQYz[~C 淾ǾX3ĸV:{SӓIifuQٷ2*[jXhmnO ͅɓO1 9}`$$HqcJS)_`*bLLSk<@/f 8̆g#,/_l1`3EĚ5%֝ݡm\q׭ݷ!!G˕Hj/˟<}n`xdeNJ(MfRZ f˳hړ^Hl f,bv&|ѱ۫NMg9sOwpuW^A>2FL (%gb 'l &[Ht_,qzYB2#-!A+Ԏ({WZf͚|6sî]+ZGHZnwnUI$v4M}uJ+$kłԊf&F P@A4 . jR XIbkO7oVȏB{n[_w䳏/@Yc {jvv|t40 XɮطBXBT9B fDT#uI Uݥx qt4d5.CE0B!liOsIzZ\R#3tqX8&b: ZטeW|_ra7@ZrOEXFWm}+ښ23>vPHl–ϝ#kLĭ^\;s@&̔gjO>)$+J(l:f%(y7|[i%FJ:2kojotS{)) I}OeC_hkR)m6bgW*uǠg_\~Գ?W*W ZW弗R'IEs QxIs(挱RȜRLR6i'<VYD/ ^(=rmŪ6l淼ƛvOIҸIe[z`S&Y AzL@@LB,-%"|D3ǖ._P*/YCҵb[01xM۽{ob?3Cm6姟޳Kj~k]5|N|#j}w[WyGw_P[s^B-ii e%5 T[WFS'=Z( {睯Ft7r}Zs,Jr>}D 0\#_ΰ^X޾C Ma87W=rPOpŗ^<6;7 RA:2 "2/Zr(<~b"Xch [5ƸŨ-""98RWm~À q=Ҳޞ0L%JJRБM ۼ Ui/| 3:FDi%ے~fpII1JjzT}ARJ"JҚLϊ0gXOdI~Q,heKT#K@bW<0$$@X,tvH7l_7=>[?NvcH-zX.¥.^]A\2K% tC Ǟ;gȺ/4`aRAM-ԥ[wo= UkGkJZr`Y!?okjx@<37wxdhllȵv 9e&mOTf3gJFGz& Z@D\J8J(y|` G_:_E(%j|ߋdd%I1.riœO?81AWwڵ\۷lYݷQjn)J\R}O"gæb<[ BVm[:J?qz8NBTslH% < ,rD( a,)YLhr*?`gc*ܾC/g4=1Yobjwu{Gw|1+s![jaL_" r=./kߚ|;& )Vk 12 sg^v5_>!_˕wGw11M\[Z[Nɿط@bR)$ ,9NKj)6oټinn}/+^WVt{{{vڙeÌ|Gݳ?eˀXW<XUr4v&b4]8B@8 Hm5 Q38J &_,_`MQ[o_z3>QO},QDyA+ʓC.rm}7Di`% D +@!dW~X2ȟZ"2" $ bRr*O4 I 4:W+3 p*Q8kĂM^Rr-RkqCz^~LB`R*זxC_|qͯ~sY֖CeTp,9~~qٿQ2PdP_#<GuF5}pVo\V1 lY@t{mlҿpIW;6|>Bh$;tJ.]qϸ=`ĤQGiIRs`8Gm.J$ Q l4*dVq>Z?M[VZӻ- Ap{ad2Aaf|dɋ<~:| I9cl&2߰}˖ jȴ4[ LDZI`О^vuGb vlY ĎNlݼ*ImlD֑aLc ~3B)B Dfgj|H? 'tDy@oBָh׽M7߸j٩0A-$D10 oH|_5_ 2gV ̌R:rJȆFpJ ֘@y@ `׮n Hvf&f~o>w7vH&491sy-D/}'Jbwك=wQ2>8sͻΞ?ݷDy:+Z H<7Uk*:UV+yf]{߻QJtW4QY#e[ Y~(TJI-+]@4$:)&aXBKS?Q%O_CІH a =+sdo{kKssG~[oV.׾nڏƯ:uҺ覛nT{?|,iV__y'׮_513c[nYgm^Ht6Ty JD,#AB .@2G0i @Ď %c]R3 +&&^{6ߥ OkCV*R!Ssӓp1}֎?l <*PAbDd86}apI.7uV) Z`<ϤyìqBH$@&5q˕ d%2"Z?l,~I6!"60 ʥN.8!^% DL]y ߉l؁(B" 8?9,9 I)6aVm_'>JY9 s0?'^d$z {4Bļm G ՟yk0 g&{ ~WЩ!ҁ `0:%Z48Q 7Fb)>J"ڛoy [yiZ?YUкT,RB Ic!PMLN1{_:#{.-)*}b)QSD@ӕ[M2ITRu=B!bBI29aQzl CG$Vy׭y'_{($[׵v9#< d! H֐1B"(րcyL .W4Y1x_x@C/=%F@(Z=Ru]-[[{FO=U̵>yVOfY) Zv[o; GǺ{vڡ^uR+ͦLwӟ=oM eœ Y@ P(Ѳ !_CNg _ҾFMWl7Wz (ąaz8g11(#@&(cIOP*4Zٽ|gE_G<#rK{k0_ àP( vo2/Lk«[:xjA≑:Coy_]pvgnʞǎj]ZiHAP3 `S`01lð&B$[g]U _z\ V^oenvʹLFzDR0T~4HYlm}7mjio7L);{qZk0 1j&%6\,n(ȑҤecBUl4={ԩֹRkEoϊ\&dH}_gㄲѷ5U5hG\(0o۲jb 40 W\gͿi|)cGJ~޷}U֞ iB2D`"Ikv12R)BrP>}Q 4Z~T TY @SI4#.+f%/څG a[ |w;:UL9tbtdl``P&ON#.5+W}CfCo˕ᑁ;\ /=\3B[R `꾷;> sР3TKe:rSy2A 0ujPl&?3Sgsse|s숉%K%h8ԙ{ySKQ];V%\{[nXvc}ى{}SхB9𔟦6v ֖R%8|}ƣΚbT NLL?Ͽ'r7  P 2Z Rc=fjD6NAD? ڿkPF!$LIjXy[v6e~Ka=Ǿ'ΎRLlYrрB:oaP!]1;<1Gr]k{ly^ShILiZ{ZIDZBI%%SƢ@P )eD sL9|YsKnVufAԬLJRQWGz 1L"@ba.]ԋ 9h- 'a]7&AK\T( neT/Ԣ$2odt6Opql`h_յ P%PA;4 aCx׻^N>$̬$螷Ab6u;n}MeS# إ %Vҏ Ad"ILMM@J H)܏4MlR.W1֪|Gͥ3s\n!c GDSbRזH M[:(\k[a[Hi5V{Z)HHTJu cgOR@mٌGZza8[mx*1)'uÕ@LWj'fϹ4@m3/9WV;'[ 6"C`ڕ̄D_;gL"!؞̊εLZhnBm}fՑG;ʕ}/u;PʡQR&ilp)k8-%9~@g Ȧ( T\ՔM:rRd>_|?M. |A(7+gVv $M&u/9 ~yӫˬMpaXr`^ /0/dF%% h]I5"yluI8gk8\;;pd Ri6lx ￿K_zwoכr79{鳹LSKƁIT*A\JtXhcDD^;˫Xi*TJ |/ˠ07::}šL5!uBHn~=6ԪZ~KHx;Vy #N?|o7p{^<ZBLKN&'&gR@B$NJ $R091GI.kZj&p)@cIt١R=Z~+i-[ qR rVQi;2C όMbI * G>|bd|*(}[Xra&i3/5F,<BBK@4d+Z?}3g[ۺtܩ ##~ؽo^u+|ij&9cl,#!"J"~ +gib|Kb<λ[J?}w F˱Wwd^4k ΛSbѼ-biSsSْJ gϜ~G~o}yl?uIb1kغ%@{|~mߚoBWщ fb^ޕ #yˮӃz6O@fv:₱"|ZȾ&]#*D5\;B$q*)Y1#b^ԃrPФ$狞 B/;68<69ձ'~АJcR{ȌxBl myƵ6qr: I/H!5D})%9̈́Rȭ]ݑnް"ٻwbJər-j6 jBD*tdgL1MLTWp6onjrIHJPaRqyJ&{:+euoݽKj\u]1ᑓO fgfkZZ=6eiL2*`r5"&ߢf՞WMRo?E`3s%hkk:ym9kLaZsY?ѰK`e-} $Gї/|u oI= 1T:k 2c2֖^_Xwy-w욘iot owO>LRqJ=wPͤj{m;vZjQ\uvk`MZW3Ai顐?w ^8m _RkOJ?7׷= f DD&nH N4φ~;v{{oƯe2c=r s^nmRCl&#\fu+9yn@+O *ήm{ad|Z'yoa[#Fv/03i", O=xvxCx__o\iha3\L"􁬵VJ՘KtI Sg ])^ιi@U ԰$#Jltۺ]`ÿ/88up@ ^uzjxzOS{J\jݷBsӍ7cg3 7hTN3lܰ!̞=wn͚5ϟ??,{r{RwWMFO:m*ݸֻnqإQUf:2V(( ilx:5ƂZJn†EbhB^^^rwY)pY|5CظΔQ2c٨a`oݺ{WTz#xfl^F.B;fq\7g1L.G>^=^d[@@L֘2ؐ/LZ״> (%%j;?@e@\ ™{{/?dGR_/p2^&,&2}omnض}qC)c5 |k /p&*t-j*%M,'Y:01 d6n?wpmֈɮ5^Alܥ Z%iZ.W&"+,b 9pu)MVA @,QI\[2i5;vܰqyMo~=iKӪRia  m[7[zZzB!eWWs|0\ '.^0:R ϯĦʑǁ) `Ԧbc4#-hDF 8rzo\fڰqߔv,#Hc r-7΋</Ͼ^Q4*ܱe _m{_h: fOH Hy|&hcMcMXvi_aT$6fjk|}crb|48AWZ\g3CAޕ{_x:v3>]^wmȀa! DV(vֶ:-N:+41:X[C:Јz~"8~׿ɓg H;%(0_*/hoʅa$Z{0-ܐMw^cOwmbbVG>ܺu]Io |@Sr[;^+to?00fSd^xW8̹S}}+vtcT-ξv|W&NF @ih xiӒ^ ؠő dcb LI7ttz7ѧesB*Gdl*`!<p <91OEl_Tj={W׃|?R*yc5wڵrʳgϾ> ج/9c*cdаkmo]w[2b`zP)$2BC`4хXrż ͒YF@Vx %[?-Y=細_]!S#C-};glb{ైeJ}}_}'c[<7^|q;}-s21ujQ,)v"J(U#l$4p5BYcQH K^ŅA i*PZψ:hR ` l-:y'O;{V)CPjZBۺᖻ^xϭ;nY5Ԙ %=%/󿹪(6OMeQ[vWt\baM,fl64f|!/Rq\՚RRsD(Pc|>'OfIp443|#GDB?ݲKg3@,J==Jk뜔KŅ,L\~h0DKhR*XHZ:b@Dre_[Kk\BoLYWv֖v(8OmRJ '4-H߷ԩsΞ9oU}.RQB)TYpJ ]-67=ރnz{UOotu\V!7o[հn*S<;8}a/s;53w;>8Ҡi)3/$a=722T9q|5#FGz\6G DA&%ָ8JR'j=JSk )a\J=招l?kʆ-?ⴵYoݷmw5(|yޑ#N@MeZd2eE tmH 憆&{ښH##L\(Zlް |ǝ7}u~&èvꜚ-6Tfz8 &?3kj1Em P) fXؐR{^&qr==kut:, |asixJ[c9S䘜+BsWܽYB!5+|'S33SSSSQX8IiXs~!h/N!0\w=I\gȌΘ|jZ SwFFlyldPkq]ߴrdt}ͺ5 PV> mH r+eKT10efF! dD vݲGfbbGOwa$1@3)m93JIGHcFLd}v}vRx "ٚ 0&Nc % 8 uB_ڿv͚Z>19|ͷ\Za رsG&(@JuOMMpnEL6S(ෝ6n}﮻\v-1μ{L66pd YiY)"Hvn[k,i٠~#Ę6oCmL\2֨5 D2tr`TN**_wKu{zӴ"&)3[X.ܽ3"6.,M 3J%MR`b!=v_i-^soZJ% % 2fHIcZ1aҌL!pwXp؛YYc{ˠI:=6,3Jg0IS!mnܺ-nUaFd4- a4eK@ 0Ul˼^ki5 DuC~{=9wܛԦB%(djAK+E (L6q-RFQ PJ}__beZ{#tuv4M)]Kj'L&7βd wTJE4U@ud3*l`Z~ E%9ur<[#Ϗ$Q+1W}KtgWwTy@i҄rz-K+L"LSf\TV*u.p @0ZqζDE1 ΗzAVBFY!%%kT׎ 96֟Ax^Ko{Juvh▍;ffؤ8j.:Z[RkYjSsj@>bsC?yalo݆J Zc'F!pAS:7X=;iӦ7S{GKRk[;~LiĉsK%2*PָT F&"v.15fvb`w $1JӳsO,|۪UΝ.C3LF& @)]KRĦ DɓcZ套Ne-{>sD [3 ,VfGj-ȄRk,2BK` 瞭݀>LiS'?%|]B"~BY[@k"fJM.iKD I  $s)"Jݷ}o;I5nm-4x|h $NbMs 䜵(0Uh“ Sk@̶7FpBVB5sutU;-XJRR.(&1PػuU-koz?\` )E=I "x٧pGϗ~bGPbL6OcP~߳g"~]}+O=6;7paO{ݼkj`C6l['ڹsʕ+z|̅33rilloexG[{\Ss'8*4Ml+ Sii_H0] u]8Mf#`.IWPaKg Zbriԫb4]Idݖk''fGGmXfÜffAl=J@)eGGBd<⅁v]f?Bz`i)=PH +H5!`@,*ʬ$Iu =VJ7 isΥT4P/Gv);y—m;;1lX& |>,2괧dO!ɷIݹ\,*ӕkŪ'y~d|gd|zzN/(׽MȀ @IP` 7lԩ'&GpC_OsޗBMLM<`N5jȄZiPR$3֙X0G@!#֔[zk'zl۲>MMrerj\IcY iP $qRq`|բ(ZGRJ "Ȅw<[|bϱB8;;y ϟ<~85;o~©0mܰV3@9k ! aXc &X _#jW%`PEq̱U \zy@׈8y-0T9SJ5W!s~&z0Ӥ}Q!f_-=gh$x&,MR) FBH dg[j`MwzkO?E;︳Z0;<_k>b;`%J"%˒UN8?_Ɖc'vr&۱%YV, @Dmf3eu83hIb*y g|߷^}CX"K&ZQyk<T[njukTvma CCWjv?W\ R\:$98d`RK@L00*%.~^zdF{Btvu{%p'ϝ aPkY&2l:_oş= dvn[ųuRc!Q#-f&߯7ocXm1rA;я!2 p  `ʭ+e/ȝꞗV^a.]j*J&uPc(Xە=> 6ދxEDc^J햕Wo^1I y$lmY߳IUj\.#"ɥ &t]F9 .8r:/djChd7 ܒR@NCk6t%tQ[nqM̚]1Iяq֮!F Xjs@>zC45VŋGN呑}j~+W˵z}ιd~͛6o۶m/++/0~)nz,ԥIlt,pr,7QHC_Q]3􍵅l1Z{w;rd+>iYp={#dqDtF[j Z=OAv0 ~$# 9 J#B՟?>|Sw  aFfk#⨯]_6`1A -`{Ah;2]%〆 T/+3a힅<ϻ41ADlX+87Vjbw<3ʻ C7?@{g/<2wpƜTJ#~&=cg \l|Kƣ$\p v`&Vؕ!K7n~g*tf(`]s`8 rm1m =z(cL2 ^wA"r>B-jӧ|ϖ˵Z4v}iR tj.Ԧ'9NY" |v6-z~߈~הjYiHǐ࠶HM2==VzWK`dmTl451NӴ9_rU^SJpBq*SlXҥ+X0fFj5>@7޺k)(:GD_tE4\g0)MS193N0αգ &98fD%n㄁ MV\'? ssֱs]#oOw~k.g ;D][Qᎍa.+vfL+}O Ok ,[qn2^M8C6F9;z<7ٵ@:~ڌXy$MOU<\! V6o_C4YRR6ך!L-| 7 tZ>lFPъdf|VZ}DtMhy6`` BJ&rρ32`\2J3iR T%.jT ~>0 5NX22| \S ֳܛUHU`[^㉌쓛\.gߛzq#KBc.9u«7/HZ-! l!M%JAiۖmˡ%_y56l(v֬YA)MO<'8>?zKř5kt rzwO6蛘WV+Nr Q c^Z*Ep$D6ei,tWvn9gSd9t\p$66W)g?ӿĄVg_:y^~y}wo}O]Sqql2"w2"JLǦ6m7, BI9Z%7o&9K !DD,YgkO+"B@ DLF9@'9?lsk ;vݺS׏,[{zzV1&ޞ3ߎAylF#F khp`nXWc`HZ8cW ~M~=z#YcXd!g`)8QGsJb^ѹ7cƿ{3B YQ tr':+7C߰ۧePi(#Mw\*_YRMGPs:Pqcyy#2B$wluGV؟ ,[⩧>pP3g g|vf&*ɤRN{(s-BB3j2d|.kdvfrj!^"m4<kő41)z\7)/^'x~AA l;QjYA -6s]K\$\$BpH'&Dhl|Хt@i.a wZn].IZ3Sl՛oظQkDQkǎW/ -__KS/LOL7;*j1 (=SS/<fÛoE[sV/<xko )!#G\ d k#8 C4E0&ș&wUwe+CBaM[wi÷-`L.]ȱ鹞\))TG8d=/HbWieKKO{3W̆ _i6WtuaD!1xz\tȬQwan3?r 7>¹q:ol0]\@ 'JVve8ƙK\A8hu !<g3$"C.%Q)sg8p ]|9jҔ-x/ Bs=Jִ !D?3bqFhY/l]vs={a0ql̙3i)&ikIS3Ύ,\>w5Uir2{7u]_W/_9f'˗~_ի9}Ma=Mew㧤 IHi'ۗXN*h_x+;x*p.C˛>\ 1sn"7v 7b ,ym~ UG^9Y ij\.DG1S 1]pŠUfrLءko>4;y.r2Z vt6''kSO=uwwgΜ9y\em};;:YN*@gR:LzG 8"APdq!yi/d@itmpʡkEwO6W83Pl611y$I0LK2e @-co6ḏǟ}NTڊtMZlqH)$[]sum{4{!8"MLlSF# ^Y'Ig< h檾d4IjPHZy"q8K 1κidT硗,:$IZ-]:ʕ7 P){h+\ӳZM8H@ րiY$@CTb4,Y`کZDE{G̈́v8pa;n/Z52<4499Q,v -[twܺ2C{~$Nv3`d*Н3˚Aď__7\g ZiihFwWJ҉Sx?ݺsͻg"rtVI `C˔2x&kAG fK2疁%7-eзx5>z}Nk;o`^rzaw1D?zTLcm[&&&rAvuO۳g"[~gp­ݖfWkw߷n>F122h4Z{^g?]vuϝgnUV;2fÀ|Rjri^w~;ZhZ'UB:RJ$JJDaL8n&gN g/MԂy`%(YĖgj%G9댶5+6\W"($/Lr]t W>7q+xVkqԚ{&#sǿ򅧒$erQ{\'QήȲa|  8B[ˈV =כ/,9qΥ' 4L<_3"};Dwj +fQk6?Lb!䥙sjheaN!`֡};Y/=S%CzYpsx80/쩓`Q<@ttA<CDD , Àij9B,^V$IM BrΒs ΅")sqh@m9b `:׭]ܓ~Z6$ŋ Z)IG$WD+E4/+-#cB}0gHW,. srOoםwv:rN:K Oc@҃ԀY!klI_S0*u>"|x]Z*e>[t_ha#4CC2r!Ϗ0(McI_Hnyc&6!xBŭfPq۶zV+WoXaxxIZ8fM7\rϞ~/̄ԊgffVYazm-%x .:֬Zs]wןq,('(mYa'U \ʼn%@{ByBνcYBT58f PJX*:sVEDiJkYpv脱 g},U4ZkDd}YxA-RGwo_cZQdVL6gd y&D:׈"(m 8㲩lD^'֒b8c皭F1}Ҩ'?c+_4sy`!AS;W1$A<\\#ث#|]y kL892SO7VΑ'DbR" 3`@S릝彻_޳o;o߱muV_wryo>nmnr뭷Xk |_5ݿ{owo߶cW;naW?qcOLNq1q=D7۱e;KME +d?8"%+9ph4A[24ԻvC&I4X"2cd:rIK\[ zV"4=sfZ1B`^W%@ /k VЍ/Zj^nx+:ǤD|1Tsсcc;vQ|K;`:jҕ7ٰ} Cx:> A_z_Z (IL&;44 rIT* }'~ŗn,l=IZL0=?O ]Sd3z֨EB^[mm-ʠNߙ a'kn8456c7d3g,hFB mc.JgݥN2V3:uӃk<9 Hpi[ַLYZ;f[g6m@9jYx.01srh4|_`]tig11ٳN=տlHu(=/7jcl.KDf+Iuoo1HsޣcLjAwoWZIVpq2- ]De6kζBYÙr5u |x+RY ̓{92vOy[0fЇ?f3JoX/ "+2dm#0ȥ&0@V(Xkswd* YV΁2r3׮ٸvsjF/;qrbr:原:]o6g*d!&{N!g!rq8*FWO8tD}ZtJ a&rqct9"!%BDrNJU \%A$}diO3D 5jՖ1RgtENkVZcR^Rf394 @2`Ǚz\o{tU!7n>yT#ekrrrRafVF*e rD$8ws Vg_?e$j׏]pm^W1q->:Ar㲱rb S4Ovyk4ķ'~cX _6c,@y%H$4X`&BOB R,8H6cE!#Mx6~,vZqV rV 9}Zϴ5`E:<騽L,4gD,Y`\Ņ l>Lz l۾Cq8 0ƭ"`jԘCD9Ҍ3/-e&<)CLp!d@!N]5xy4ETb0+'k%)sFcɒ%Dd -fQ<=5j$HYk /?0AxzLS'V R 9Gp A U*ʜɯ|0^|@ިWw}Jަ-7yTQjLG-D8qJ~f'JSUʴ2VpɤdR1dp7۷m挿\ٱHJ [M/;\ꁬSVZIØZ|tvvuBJ!E{\lJ1p$'0Q[m}*$nXxW.[u?uuρ4xp ۦ W)8]o E赱q+V\S8waȼDF3<|S ;y|9ΙCInd AAJ l\aju>wV*s*I/+??{W^? 4:qXQ;x͛7c񛿱~j5_ꣵ6oz~|S׻ (=gM7n?=癦U-Y=3?L;\d\|C^=C|=o5_ҼR/>=Gv<1k60^19u#Ek#.Eb~-ܴD"JTn%"6cc\7 |G46v!nA +۟'7mC,s~덴up0fZI]ĂR+ E+2g ڌH0-˱mOe{E +yzj5ąi鶥l*4oex[m-B:g1R}B" . ^4U-e :k]Y"dq,1l5fZM#/rڰ T4=@‘cq)c9y jboɯPuc&4rRQKCCF#j2LسgRC3n-JN09e=ڰ$K&j<5;]l6ٹjB !QNMs`dT(J\Jz>!Gd(evĈՌ1h#~( UJ"sNT6n%jjksftt}94VHղsRVGj)-sňkch"2LО@@\H'l3$q1IGg3y^P)7stt.[*JbL8'h4ߞ'Dkq$~p \!flo5umݴhù8?:::6i,W+sJQ̖smJVH @OHeP\+9g_ 2;#}=əKL†ֽwLz{6mZ(0 :ul.zp-qz^Z˹dy¯֚LG\qajr'$LF!Bp}=xuԉSŎ0s ?˗q27[n6KGFF{kj-[73v??:z.>===f3s߽4ˎh<{ؑM 1 fh],$ݶkҥëV/3(y Bd ݮ\" XkRl4I}w>ڴ-1a`[O#NLOW/f;ſe˓7ߺ*@;?y,;vxnrVi,'ڀҾ"bmlW+s6R@^r:M/J Cr`[wl|GfE0Bp{w`9IDQc|&W̖T6j8y꽥R!B!9Fj]`\wi-/^qr2롶/\B+}' 7HOvuV'~`frXʃjZ-tm2{ø`N{ꩧsPꭎ #jڋٴ )uiR~G~6oD;5_۪<_x٧~N` -Z{Cnyt5f?W=7UE,8N|/lԣə^]+9PdsǞ~쩨 ͕CIT:"Uuu#gYdL˧\hQ9R@jMqr9y~Mk %η⋏7&~ӕuެu7-wxHc^W[\s%c*҇lվ7Μ?hMC}(9?ZCȸ`IfsM"gJ3ٶ* .ȹ8R˵6{`r/UgHKYssel.-j^&(45شXoD 6KI[g䥶6)щ绺 wlNU]~?{p&.No{eCCG=@-B2*dED"u'ΌN>?Xzʹvwv2Fk֬JR猐sm+g#:DJ2Df hÌbiRJkm6i$jFtȕ5)Itu\ʥ=BS3ZW*s]].>sKV,ٺe- 8ISblu-J )FEDٕ@eawttA6lPܯV\Ύ0IVec97կ ;rcsCgΟ\fͫ7m^J6-tu,]8*wdƎHk,<"V80jz^h-8bN]h6OT;8՘!]=Q#㤯Vh-8&@  W)w˟ܰ;X'V̩߰ӈr˖ܴsp`s̙^x1?ٟwSSSgϜ{Æ R2Dr2===ӨVu&tȉ)5+^9vPGGU9d|J-ju!u\0?;M`U34?AWK."g#tmZˇ&.L\wcǞqbbkc .v*2NS$|wX{B|<' r:%/h|D ԮX#N0H Ι5j+39 C=~˟{j#9ꚪţgt^1+q R-C^6тyJcR~[r-ڊ-Պ7~n4M3РW)ddVr2mܺcIv{5F[/{gڒ~3?qutuFMl&2`20kmiTOKjR]?u ˗ , {vn ,A @ $Y#'c&Ix%yJRHrIXVJc96Еwnb)K.`xI϶-z:<[ck׷ ˕/ڀ\˸x9N ! 2r<3)PRz(>1F qK9/^ C\wceí8p nG8+bL$IV.\b$*Պ3<zj1'NdjXUa@pNas:R>0,h\z|1N5+׮/$]|8:zvdd` gȹNb!mUum.-:8^E+B-)uv)3A&SL) ZޟWk5Y@J7[&p %dx g[d^Zdɻ\.wܹO|zGG6o޲vfܳ&Q|ZÃ陫Lp7 !EVυsg9roG>2;7R#dVRzM,_*6Zz͙78;Øى\ KD D#GD$=9kIZ0ᆵ:B 1 />8sƳ4QI)gijJkg 281WJ޾l I'_]:/$V%_gqM"5h-`̫ՑXd#B(.tJfAߴ@ ,PG+J4U>Զi O?~o{rB3VU9cSu!Hnvt6aXXbx%RDl;ZѣǧgR.Xk֨DQ5*ִ&$ZK(9sZ Mb=p'Nxz;z;9Wa9늅>͆;oX, !3E Q>W[i-b3qMgfů0<484+:Zg[c ,SnU(+}1 s@ ul7? %@Vc "CE=d)#(`ڑb-0Tܨ7M^nM\l6?Der,0qVKŁAGV5mGwlvrwD&} kcf3\ka sdFl禦jITRMq &NN~㱯kwmtd|QCϗ> Lk 2zsʲ|)29$s??Ҿ7nxW|ԅԙX' GSgϕ'.qD8 .8,`mjBi~!ˁ>sX\Ҿ]]($X+~6ǭ[w9xO,_X(<өKC=lvZ2/䚉ҌZN_JFf`׮O=ё/x];Uo&J9 0daYI_:Icj"b6¬,pƒ5*M6 cNx#//= ֙=\A;r9BO]vÅ #̖ʑ5_`:;*|.Djt+S"ȩJ/;~ĭV{ȓ^K9&Tkc.31>۲eE\T .f3.3s.2& gsV+mL]qR괪 $ _V&{яN_,}]F|ͪ#K8\!z^!~ye׼ cxg?lESB!=?:jϞ=ǹ!`D1F ﬓE #5k=zt||t2sdbȢ3 k~^m kR t[`=I\WwwfC?lFY ^\j)iHR,褵 t=+?K%eV7^&yAfk 8|OGX1SRM{l|ꫠXMU֭xm}s6 yk<ڤ*zn!NB Vk]TGϏCDDp3#H#~6ް !!k#y^ι(N4W,(Moth}>ן|≧rsP%*5"&Z;IimlbͩSء&BgOOoO&tӍk֮(K!{CQp$`88} gH8]KA ZcQ8H%tu#7"- p5i9( vo=Kˣ jgOG/^2!0Vo63O44ًSu;7\B._U˂'YAۭaR?~9tf+KL/9/^?x`Ys@ 1fVsI 䒬ۢfkvzǟ5kamW~{Pvm'0S+jj֭_ml&s~O87\v֭[ݻwo>}>wV?Ǹ[gXB,PRΘ<+}!h0(wۍڜ+mC8iJ!sdUi{ [5k|bGadeC‚$Gn}ʼn ϓ@6ggO[ 7wo֨v ٺ>0 ƁjGjBs.i.2 kb#T:e}zM)d{+}X\$\$0rqVgMjKNiѸ2Kᐑ#.2q`w}:5r i@&]O B4j0 P&"@)enX!g9gRZks.Mg"""Ƹ5TLtrf/MW'gˆ{Da>ܹF H]<>=ڵ"Yd(n _h˹pc*L6dBDڏ"޷T_3mE B)cLpaEdZ)? @ʽ`y}_>B!K%3w߻qmZ$0לqZ>'e܊m~-A<[d^`RH-= Bg' X+ika%fqCWO~ xS/~F AXicf[(d6p3l.Vk5YOgV6f\qiz4T=m>T9oض~>`Y<@ CSWT&8?gc&pZ X:;G`̄bկ~y1 s&y\ԕKM=zWOO4/?v\N[oȻHkXt\N !J[L2cc!Ο)mؗs UqƍYEf ^a;gkϣ`j%gz~޺466Ҷ[/?0BK!:+{=UƄLFK:rr/Dk p.|v;vYyBdC?5u(Woi%G8utdZQ<19559AADs-2c%和`k !J@ְx#b,=/}=˖wMۧ/}ߝnizzzzfX*.[j??;t =I_1 q|+c2_gaLAꁇ]wqWy|_xw<4408v~4fu=w`gWxmݲ~ə??UJ?֬☈ H^ 'ϝW:cB$F3(`|2m%+Y< ˻k8ZkD:%L>^>Y@;otװr^`հm "$XSsjС}O-vpt܉Ga3_ Cd8d'ˠ$37Įk-jWDKCpY}Ye OM\K6[Ij !r/ ƍ6$, 5>5Jkdsvsuk1A)8RZc53JF]ZJ'iGGg>\ !9F 1ƜF$\У /MΔCƸrR2)Kӓj~E xY&Cy&G qޔÌ1g9&fR}Br$7DM;Ƹ"toAN(f S1/Q)i_;84-LᵅG΀{#Gd)f8rhffΜ9/D9.z2J bgX̜>>wˍJ3iKII{tNU* A5ga Aĉ&/{hM3Lژ59lw<D+ w}w#=/^F~ Sל.OeXVmjzbhqd{? "9G*N0Ȗ 6lJ=ē+r\dϹ;xlbqA͝۷!G;0ǭf.\(92DIKkT^AN"@ tiUx㎍+gZ+2uK$ON9) A&Ҟsg.qVK0,fJ<~t9R 2h!E f`k05}=+FFM}ƭ5-TeRhm\u^o ۰qu$Im+yҷ-?v%Ty<>>~H󘰎 h&# 2:0nk?r :grN Ȃ8XB0s@9yy>_pB/+\;'O'Cý};vg{F-(d4h q FVWӸjE pvv'˛@[,J/L\m" ˵ʴBswcCά\" &.]X'Xyɥ;L1?xؙ';-1:u !ޔczòWmmEݯ-['iv;jWU՛v9iZm;Gd@F<̟'*3qe k[Tdox IEDbYI#d͸fw޼=WŔ, d𓯉zY<]]GD$F#]6lSp\{PzlƜS4T| 16-%Wl恛aۧIyXC.^qw߹nӆ%Klؼ1(x@ RӚ;77=mSŀ{(|M1!p0; /utu[63/M vC|J&X#3DN |?Akъ=A#C+jLyzh`5jI-ٴ+װo6I{kΎ::0"Da}/P޴d "Cz%{vl@>;zh=j tV1Op "@Ǚ?6'{Ao=]/J+:rٜ0A( %I$H!g.sֵM"g8/ Ҷ]]N\@./-.":@qEFXwg![!U[VZԜi:}~t]]1cBYr:V`ͅӜ1kl]Z`1J2 Kɸ5H=% K`j<[~聻6m^5a!8QI-_zOLNǩb\XKz#]t%K{˅R+-U檩J2.N3gӻ`Rw[8@+23XHv(1{= k;2Bp8>zx_K\x#kEVhd Αq Hk"BgȈ_9FJ^* \}×gh"M'l^Xd]w2o`Qr5*tt /^vxde5ԅLM/u|12"SZ;GlG3/ erA Zisjz|iPk(QsQo6\0}IdfRieYS&FO*3-]l k63d6+xcBsɢV (|ʑ{|7iL .fi[=_#,k_}};8}'sW_fNr>Ё__Zib>[ ISv^Lڶ-X@@Xh}SW?n4yN'$!XPj+E>lgP:1hstXsuކlqrlbb:ǙPJ3ngy =cg.LϟUn~ͦc8GIqlшfAu691U ӧ g^[@ /&-r:mE*ܸqM\zi+[(֮[7/| AEhxuV^"G^]i,n-:aنnX΂ЊVZvز+=ةS'>C) l&)%*8f`Dg-gE\.#勹?4luwwrM޶@wtXHLD@J b+HXHjm+{uhIS7 \6ACG8aeAٳ"+eJ]agZogksg.;s^|?U)^j:vwtQӜ<5YUns/S[zGGp4 캮}M:9arAIP`(JT+dɟ-dKlY$"LD sꜻ+t~TO8\[ug^+MDPHb}ggƦ%P*i9yJTjc(eQL?qh>iomYzJyzj1xTolfcG zD*5:tP-ZtaT:&`,l% ($)%vƔrKu޾E\v×_~-8ǟz_ړO>yرw:}}߲TȶjZh90Hi|rKyoZsc<)ќ ,a"jmTl ]ʶ[sUWWtvi#)FA4a\HE(# UL(u;z `z^8`'9G!H q4> 7~:2] QDѱS-ex3/ʤ@._f,hN9#p⋺?&Oz!`sݥyp4!1pmAtg*C`Vk}P87S|uϞBB`bU؉SEz4ۙbѲ"PhuA"8+JJ?7 Fmss%ۖmA+ZoACc*|Uեe f;t.Z,՝kqF-U+a\'q$g~n=-".MO_{oʾ3.[ֻI :GlZ 5j/dF8*sAc73 惟稴X`H&_]\qvD@$Ej H RB2Z= n$5[0ioP #x|<$M|;zAL[bS/osԄ12BnZ[55D04<FXIZU mZ1%%fFBNƇf>hee3sG)zi(s5c¯֯_{*5ϩA`a%h:a'` )һ?׀uXSZF_EG"B4p )o6VQom}9pk_zڥRa2@Ff>ńM0HHFpԱU*<5h,./9._d@D׭_6;7H8\&:@ &S@0],xD#^TpM ?B3^GөvMe2K)$g',_9}\m^6Fa#BMR|v#MV~Ǿ͋c3lϽ6ol Y,Jx`Z5T2O^-RIK8jtTZf411Ffg&3Te#Z[Ү륒 3gh620s`: gF/|lW|˭SU2ʪE|Kl5&T: 8,X|mVWCC ˙{=]ittWakiii@LRN"&eO:};wYݸqYo=~ٳ7nܰ[m/W8>z`]$~՝Ońpr~֠$0k/[r9p5W4a *)1g{ % h@`"@LdB/봴K#ht!b)0r]N.bT  fRJٿON~#'w2)@ J gF13ClζDʶ#ۀh\ RJ1gyc<5ʀAiel K8㘁aB4"> "rޒ˗v-;68[rkݽa:3OTOlGٳ3tr--Z#:p4<СDMg;T* ʕ]h ĶmX$c/ Me5?F̴iLBnۉ|-B Z˕zX*6d(q=>sQTZҒ/:۬4˶dܔsH/9%O8 5pF-D/W\yU" A4 '7nPqDro129zoU% !$Ha ?@7gpH@"ѓCgo~-zs?hE2V}ᱱ$pdzQŠz]Vp>7?x1"št__WgO/*m(j!Xőբ8)l6NsBL%JyXΗ*hx*aiԃ|`C~lllvv T#@Ho\ h.Öζϙ)F0 N='a/Y|=jeUn}gU+s?iڗH6F3r`c*J bYz׷F;e-[,|8BpIJ" R\ kRYxҼ~$IPs륒X+WRsũޮN_mxdusPI!y/r!^E؏6[(oyę/i뽜gYm]m<@#̏*+ߐEX(-t كg9ӹJ>] ݰ^ڮW7Mzmk+x"Ubc3scӳ%k-]ԩώO6H$Jbyۜ)C8$Vygnի/l $i%;;mTب48qqy[ y;i+FWo\=9"ɖ [©'0[X1952K;_Ju]mC~֛oyǝػ^x{=ssŹ٩G},+YرçN,[4')Kzi׾} 1zժW~rt^!@ mBRuN[K{!le v 0MF9c3h$vw_cFy>S(ʀa-`u6E~#x7uzdf;W,YHgP(D ,p%VG14f40‹^A$BH\h/.lslWW4g2-aY a`ٽ_I4w۟]R eB0By=^I \;^)EBj~VYE)=uvZֱ8ѩSaGQyN: h*a<ІIP+0JXƦD\ y*\E׫-7 j:1:D=7m*-jQ--m;.3)/b-F53 oɉ RJq0"#j-qtЩ=p(Z1y=hmFߏn۽}yNR !<].p-Zho8Gq{W RZ)%h#@p-N8]Ǿ`8|KN?V.#1=6:: 2)E UZ)L1n #PrfYɤ kCS===2Jh\=zlBd[[: Z,[<+d NUu?J1ZC-9I~Pf۫3>rh(%Am&j3ސlيO~=xHeD5|hOyWzڏ9zԉ˶n)陙{޾> )A)Bm1pr.nnYg $p p An/@K6L:a ȑcCo sjFY$B+QD:JYH2?S5 xOLPLbl.8]h9x]u|>?==}nKXJ9RJ+B@n4@e"CdiJ*aY2)eQB R9ߴuX#h68;=lk. irV~s+Ӌܚ/n!uv:,R7DL%iԈF*~`КkiI ڶ(7h&''Ke˒I1(L23'S+V^*M9ztllbr֞hYtْgΜTtuuqZΎhv۶GvzEpλX|Ν;&&\f2]qfm}ݝm=:o}ڸiMgg'^}e_6^~UW]~W8-Q82;qzLllrk(kF(]duɢ؞N)H @SV\Bs$`P"JGɌ37v| N BMp=a@0D=[ -~޴>-];o k01FÙz"eJ&" ƿPr`gOͧ^sra Jֶ2v G h…%х?ug 2 7R ܏{L(3#to_?3!3[ZZ8V&o~ڳdK5$-6p4?ho r|r/ GvCOwtY{g^9~J+E ul;X hlL<D4_ւJY2 j;43%=Dm9yG[ܶ mnS[[2E D ?cQZ+ MKkwG`ǎmK Dﺌ2bs!؏b ic(ѻñ-ϳ)O8+uwic= u2h4j s-o'y{%#?ӫ֮ܳ}b #98唋FP˿a@--<ۡT0ZNۺJ Y@sQ}ɑɁ8WYR*mTW_7Zu4bsۜ>(7D:xT,B0fnzG`zlOGOTL$Q 0Xzd]S1_7>X(\}Hݶ՚3ZdUoϢc7js drfgrP(GhLCMY:4P?iBJj\ `ot2 øT,K!L+}GEçOV7}9r^hT'Ozy}ܸqc q'fm۾?E'GF/[:d2#GW*Ֆ;òh"V*\>yeJ}k-/_51=O G,n3(bDyKzvlؖϮ\%MJ2cS+!IJ,Fц0j jXk[ABBsM_fကoZ 0 6ܡz];nݾk-[MKp`ԦgbNg)7ќ[@l|PqhJkE?18_mbd*YXP)F9IJR$zjuom_aDqz¨kf|ڦj<>^}G~c3 (U0<:l[%+S9(35Qf;8]m&l Da캞lbc4*UTj)DJZj6`&ϼk2j0J-.DuVPb8eLӔ0't>mʲdё~믻ӭ֦6υ$8i4?m{Q9o̒Q(ƀ1cF`rlGKře˖/]0AP #vXDU-svmm[fho?!DZVQJ <'(UZtUٹ*XE[SLdJ/բ:r4h(Ћ)Xx>nnc&o~xhpxL&" 3Flt+d2-[Mȟ}hg8l=MOuN 4dhpR֒$ ! r Vr` :ln+rˆU\Φ!PoZwJ)Iʫ`o֧>~C~'S'O>rJƲ*Nt |0t=H6K\!\4 nR hkiutbKGTQ*f6ׂ/L{[cGH(as?*)M&T5 +'m/N$ ꞡK=zdE/Zri6hkkIl+S̢g>kRKޱꚚ6@PBx#ٖS#\3-r-XٱIF|k/ OK8" ZZ3y5Wt 4m@+xooCh%Ԉ!(,Kic fwʀZ|@ߒ6Q1h~ܷa.|+ssEIm;mZ ?FC΃d t( #éMЎN/A&`#'SmԂcǏS0x骕ql.ݹK'3O?svh7w<ۿ?QBcLQ K,ٴyG>E(bC\$߾.h<7`05v":XfuhWWl)M .V,_16:O''&_|Eef;VP .SB#C U=Є!(-ҐJ{px#=='\6gnD}jgϮZb˯HgRF"a,&R\=}%@#A A%[::W^WNg1 Kvm^#x-)ʕ $LX4*J-)o]XX&עq[ZNw:2M4 $D@R z*HOMzrM߽=@U;XUs'hILDҾl`bzkH$ҕʼV_-GBX~3N 5í݄wÍ׮߰Вm+sss_~W_^*' #nn_6nЇK$=\iT J|d'޳>;AMJRcI%qjrr`>KV C{+"\i%bL5&-/9ڶqXKAP'\pBhӞB# Ao^-  T*VE\@ A`, " doN>ȃYndqfu|%}kW4|2FLzG潤Eg;ek@j*zN@Ԥ@0l}mc>GPpzTѠc A?`A+(c!Ysz 롩LXkf6hnc'F%(E&F%/{{>[7nREJΧӠ8 j[zC)y"EQ2ŀquTG&&]סAhYv[v\LqFFZa6W}m3V)P#y /.9KB(ej,Ktk, ekXgILJj8GCWwS/9r{hD@ 4(<ݞxT-=9_GQyiAѩ_;:[2hߛґ7i_Zi64G,$pOOg2Eˏ2vP[~B k\Omh-/wruH8bqO};wfyw͚GA22);#)E/򛿸骍u˺mXbޥݩ/lDZk Erqmc jUgO]q}]`X Gp%4.Q>S TGO2jb-׶D* aFMҔ0 (ejS%%1ʀ8Ճ4&zZz2R 8n|4^{ٽO}乘N$Nd'@nݻ_{@;W-H|uh`\q=/+Rryvf[7ǟgvkƦȈi={/~Ekg/ ҉,HRkCA,qN0D/}v))}~BˑډF 4$2}͗޼jRdXz\k"!e)-#8J&aQ Dkbh&Ӏcš'# =~ˏkŮ۷6=r`C/y~úEb[nnii7FikQARhmY%!,2J YXEs."-nپlEŝlfj̩!le(`HMdk/K$I.V.sӆRJn;rzv#:w-m7l٫GV#jjq\ |ORkt"XښAks޿9LE_RYʢDv GON d\©wqF,VwuFmV2RFBp;FTo4fgg=y7-f>ڒ۰~MKkf߾/|2ܺu㚵ˇGNܹ˷mly FǞܗ?uOS? {;o*qhm[w]wOL= HRixtlVDB(UwQQpղoVJ R5W1@BQ1AI)5ZYnp&iht!s@  %daLin0Hp^N9t4D聴C;_>Gw3b/^Zi@JSBfFs"Q۱lC@6(fBhZLМ CB# ʠE;5=[ PʄՔn+Dkc8?bv}?tI#|OX(\d-OJ̩#U_//PKR}@_Ϳܮ~kQOǢ6` *%aǎxg[b?7n\-;\q$3 ؑD~הs`T[~P+Emn+ͧrk_ZKZ/jF7BT3 Rs5qy d|` X㳍g^xu0_9uz|Skݳsa__XTe}JcZDQJ !45i o]P>1&@ٙK"c3˧9s?4BpQRHtdq#(*3;>זul󯔇C9YM ~W:> _o=uؾ&^TnRIw"ԟ_ߙ_ 0Y`M* !+O(ŭ$g}…%mtP4z<_lk g-h2eSJřL2,W\PH^|nnkZ<%v7j{_O$S61FZ h0rږ{wuwLNNh֬]%b8uNhJ̶XP;%` *ӛ8WnY{Վ:}F֪IGX| yT|Ae e23oɌ8S@ЯBp@b$,p-+ eBX(iM瓙{w=W-0EJM~厖,7}Pzn"~xQNvڶTԫDcju Yb5nلY@ 6\#Js(c.@@ş P*ćz`bEa`$݋v9!L~<.# c)c)1 c"Z@)G^~W@sB8X̒J m]wNt.=|}}CÔbxOݳvêᱡu)PH$&L(!?Gҹ3$j\t-Q֬lkm0F#|xdaY(,7B%I"6TV,;:ϲ@$ B"JjXШU*5Te42F-9b j`A0 O9i:K8/|޵YGp|'Rj/GwĠy睿~ ] v|#{ WRM8}pA~CL:zh\{׮|nhpdhp6_[>'B{Qg>ݾv*}w.pY`-=7KG (06hmw Hdz_=;yxpVu,"  DQXQh~ ̲@re.E\ FPb),^Og(L%˗/V+6ۼyeZg2x4jBxG4\| 8ڞӿ^&-?!J"kd mU.ፈm /;6/pڈ53T[ߥxӑ fW5;R`yxkڱr@2ieAͯ*-Mc'Q6FӸ.#" r>(f[SIH9r^TG(  #l#b4lG˥8::;[ mJs髮b˶{>p߯/NṄ?T*Ϗx?_'>񉞾#k֭җh4WqG g>sSg*~A3(3 1IY6׊z Q| \YEY#A"cMZRN!&&Uq/D z[NHSwCk,D}O~L&ߒݷt-n&f?;+k .J+hoiURڎ %m&y"iz!0bs%o=D5ԌdjI)0ԑZM\/x_h4E.0l\'mzuz<!Ke8v(2:!/NߟlɶwcdMBlU!@h.RH`MR*0TR*c1ZKRJ PLLbi(p"IK*YhyѨ (Ł ͷ6l3$ 7iBFݲ%o gmmZΖj+G~;d& LEs_ܧM !Տ9K [Vp=[+QҊiBt}tsAH@Ssmn C#3,B) b)1[%."̠F0 xV8,aTRqH CY%F9#;{]ﻓ(C4R*cqI~q1,5o,&l[]͗0GR#"# f1g_S]l=7/\Ǿh4ʨALVo(f9t>0fq[E( FLZTnnXG1Ph)daM"FJPPh(6/5@~[mݼV%Dصo=(0W S?39:|1VgٿoTxqd|t`w`#Ch{7ܒt=#h9o 3Lpux  "\eCewoK0˚}r|:ڽ{HK뇇ώ'OW!B M(!0#Hj8s9dSaWXeZ?}啃u0شi5ϞnkoY~@/ݜ quljы ThcAY@|.HAԨ7jK!D"%RNdnX)M.[<33WήZ+tbH:9M FѪ#|ypjlcEζLTffd7.#LqוU7 l0b0Ʊ-~yӖ^VǵYJS^֯EBvbr\. XZ[C6" ÔDUL2r@$bI `ZZr-jZX`븜S!podjɒ]Q+W4lrvN޽)R`rujT sbt/ٰzEoW\v hPʍnREu|c䂛r39 <DʛsFFv^z_ܻbW_xuj|nWnl}L[ュoJ04ͱO&BPbPrε1RmLs,eB̂G^8m'ff/=sz뇂rQµ&J6  w}+B`Gg3 `/6D"HƄQZP)e6"=(hZ}ŪeVv |S E5Wo'< &@?wޫ~jfS7v[עd*%6ǁ!H83 !4h"lθH0aU*eń4t*8V2_<<_C "|©#FH S8޶w2FH]f cLDJKLJ) L0BҺ9/RDRQFD֛Aȹ Hj 3RefjfÖU7H7ٌ`@TbrZ5ͼ7YqVPl=}=-v ""~˭~/J3&ah&&f-+ӻQذ,Me\(~B_'I5M[$(_LۮwMs~`q2F)7 RPFF*nkoMeRƫ7-ٰ.Ti*FR PAkApc J@6˚cHʜ;~(DiX62*2ɣNw8_TJ 9B2aЛʹР&hVRi:*f+ӳ%nbl8-aF]֫Ѵ3)ϼ®̜M v0FB ŹbT+VKfgU?t>.O)y]wuOibjߋ?u,~;nW Z/׿7=>?]). !nIcǵ׀AAH5Ȁvi:{w3#2 H;?A}^}Os ~޻0;_xfxxvr! 2s^(Q@mΊMkz.}gw9<4ϝ=uy㍃JF#BXG0F$5#60rA3FQ@5 1x+W,:um\cT@K#d3F~b Æmqa c "X2 ኁt/#"qTCPJV.h4XS*ɌGZߺeq[zJľWK: 狾JwyGm-+.[}ۊ RG)􈵪oѵ6Xx3OUNWWxrC F0u60TZql+O:1+,HڶDB$<׎biY%D鸷-]UW_λo 뇇&zVwΝ*-_h/|ׂ S̋/ʗ}ȇ?4446nO?z(J$nEB s|fIe2T(TpfPƹ6H)BQJ(~Q&Ӟe'ڞ@ K…KboA0;Tk><7{Tlv,p Q_9xHQkrJeشB@p-oݣhPF8q,vj_z 2T$O(mpm՞JvMݨ*)\/:p?RRJ(a).?PJ< wO8==S/MVVbMkfd4OFWxX5U!A!ܤ$ !0R%LG$RARYADbA 6J64{ۯ\urA!ؑS;_~-0]\ugoȤ  Q\~m/2W,N@ Xo{]wcUobG2&;bݯ?ه#غ~?ϗ,YsOQ`B,l8^6gp! (SR PTVi%l^w˵wV֦+.F=׋w~?t㭓=('b\ҷZւ*SVH(@1@t$v P(`5`C>TR 뮽">˦D64l FjAJlT0ZR\1q, C (%j"6ϻW܇Hir<__Ub!e);jkOw=0ʶ 3t =7?ٽӿv wRiW^r/ޣ"h)QQbcs!H4S\im-X'h%0ՠ"3J^fT*_ѱT+e0TKgNJZ lKөd*螽{'+b#PD[Z*Pzu7tˤ~uUmG6B`ЊcM,80ӕ)cL*:32N% (G_xFY(- B]ۉdL DbF4TKx( %re52~()%bӇ|@)m;b\aٹloD7w}@#yF#AX{Oٽe{z;jW3rs}ݴфKGFzAc؈ΘR m0՞{5wa /ƞ7FG֮ZN@op;o?z򈕰v7 ".53vC 5p7&97s)/~E&P&RJy]:b;ncͪ$2Loܬm?sQwuuű J nPc#] :$@L{wWkk纙tFB,ᄡ,Ε;:,ΌOOƍC9/ULAVXgGS^NKV4q Q-K/@X|FƄu8J4W`V2ZfMЀPP7ݼo= 軛rl[<'$`ŏ7m=-m}{zw>gws^̉GQ|-K.}'Ύ˿c'Vջ]yPZ(͕Z EIJQ tLGaǽMoHa (4)B^* 1H}UK!_+WT߰NԾ}8"dDDzO A6-O*  "%l4?u4}/9hԻ;wl2|wsWo˯zՃk֮t-AD0(ghaԶ@, 4  Mx2\ǖZkeNT2]:DuZ*0~CBbjGTJ&!ή%!sI^fۉGp c`ioڕC^*%4Ruw|!g8*\>VJ.03;ͥ ʙ3-vhpjjӷe놮\>w˖mٲef-y׮яoή^%uםJ'O}j57pKv=#7l_'{LϕuPI9FqF-@+mٴn]&t.sƦ!Yh4FPTiiς @%0 F5;S  @sIE=/{gT߿o3}!V*S aV%\"K!9EeyM'hT}צ4&$nͻegޱh L  5\Ħ1JqK0J4G_/kl&xE J=+EaBk}7&fQ&ĠcAP*&SeD+L'GᏴ̸L$=˲8c\~CFD"!*Ζ^R =3>>25=A@KN V(Ƒ md2vgWk:cDzyvmK\_y7^5{zZkqYi}?æH)qp-k7^uյZ6XU<עK?XO@%@cE2Ԉp-W^s58xF:80ݕ$6]AM0%na],@@]0zsbVb]t}_Z:jEUu 5RIϣ\:B )1Բ< ju)Qڱ\*;qfQ2nut,[*F#f U<_.vV+g5$!" 1fV5;{={;v(ep|4Xo|G `O)ꃧO +Zf,:3S 򢸡LPejײzٙZ412ͯ|䑉k:78>˷obAM9M&-}V^#=Q\~'=?ϻv=O~GLr}tsCv۲eˏ8igsHmow޶ " RJ@ 2P*"堑K.T cU۲ |wΎnǶx|\Cla4P'\X kh2J1 ʜ:3``lvng}X,'/}o/Wn7ڧ|.¶v¨њYTXSl0h1asJp 4F`s%ORFl_$WXYb,d>memqZ( 3FǶ\[Jh_jcOj\.M>0NlA Ζ>&[N>sipln-T繩dV&'m[hqHo4Z-y866K.[j}8cَkÇ-_>zŋ~c W|dԋ/ ͎sp KbhQT02Hm^e+.qme42C7f4%P" ̴@m-Kl(F((5\8}$6I`D\3Ctc=~qƭ'OݶmWn)6] *Ym܋!tRx'8Hy @`qe8y'~D@NS㠂(\)D@OYMsD0DXp=̏1[, 0 a 4Adax.mzUu>K]UA 5aJ7` (k,=h?&򇏞-dz2_Bb)h:niv=oHӮcFcժU}kzzQ'IdZ9V&6 10 1 p1 CY[W{GOٹ:dKilۦ(eJ׶(ahh0`Jk,TJL(+S:rH>YX_> XW^ٽ;"9q\>w ]S|]9q,)R*N_ H$GOwdmҊPg8x a[&h::|ld(LNLtw#SIT sa|/x( N؂` @ p{S h 5MJlԂ;`ӂK ӷZS Jċ~.]'g,"c csVV(/sI0_z@KJJFΙe"-6X8gBV ueٓs='&ϞhЁKZeVb&Ɔuk؉|\@~W~}cɒ?~ؙb\aԦVlڎdԈCSJҹ`:Q1whls~TetJy]%vtp ˱~Wn.O~ON8+,7,_S~84K˖,ΫgG>/'lX/ۼi5[ooU9p{/@(~aْes3z]#`Mv'20J͐M\ƅMM C|"g}`AgIԄ2mZ+JyB0Vawm=}-s/]&h?`(!0u4"nYJkJvrm J#c"ZkXQ!BMKso-( iEiD-4"pj[O6BMWZ{#?<6㛈EOYD4u3;d-3%@ PO !z39rZkJ1HŊjlom nB@( L=%,LP\,emmjTJZ9ԦZ]q:.Z58AM0;::!RI*d{ssEJY..,/ʼnt#璙l*-_tڕ-0LJJ&(ZiJ/*ޛHJQRk z<2~ӠX_w_*\c]|>/DRFd||';njb=ʋ.FmhK^Eo!V X "42 !KB?yu7[Èo%z8(!,Mss(RVY׫Q8ubp57|ҥˢ(. *R\R˯džZ- °@?99'DрHrʋ;68㰫5cّ&9lD*8"iZhc.Xm-L+éݻ_]rի:;:ffu6 ,wv7d2{^hx޾-W^R:éG-"(6g2 5H#;seL&\# m4fﬞ' F jͧRn":Zm%qhy B: \8j F$@!(PqJcsA9_;x-.][ٸ}#Hp0up8( R,:] J^v0zs#Mfs^ JqSc'4bc@b7{Qia-Kڭ;֬ڸL 0y?v.z0Jy!0˺9_|?1 &+;n7<’E~j׮JèB slU lB1RZ  -U`*O(6aUʨT1%H 6i^"H(AN d:c9LV,SdXת l2Qo4(ÐrcqQ ܬuoiiUkD;drjvڍ4=Z<;;M(RF:|WgbT VFDy+J`5{o<394kGտGlX"FZ . ,𣆑uBҜ \/r7EO̅~q!߾0p?@iz^&߫ON$d.ɄF/6?{^U- ?;6SH|/c) qIIuwBJ?:') n:6xz ȱ-aX(򕚯մwG*J pQ9?nbvV~7|GŁ{;[?6\W{0$(\xۖ /0ݨHUt%]xݶ_w<p^zᥝϼ021kv޿gɄ(~+_-7ޖoh#c>Gj`P]%(~W7 0 ۿ FF48v-]\o?&J|\7=48L$zɩY@G gOdijK_c޽TP.xQhY{e /Q_V&ʳ/}CgO=uرmΝ/n|MF )bf{<*W2 KB5Lh3H.)$~YJI@@BUJ鑡|^^hyѧmt#SqKc C(B[ٿB F6#YD.۲@h40ț oϻ7ÅI,DSC @ЂԑED\~c:.-R6P!DgN{z0"\EXlI Xqrhى͛>UJ'O͗⺩HZҒL9c;^Q-:nrt:˶JR6^;v;n4:{}#NQ!:ǡeMzmFXenkkǧkE$uQ@''YUL,V le6,Zl:4CvP`LlT72 I0:vprn[GKgmp;vruzcrjrCBH }k na[!Wg{W,W]v@ǀRdž+5Di?Mv@hƀ͛6.[_։c'k{mgySI2_آEoMοDH)J1E/9o$~aXm  `5o0fM>Іl+$4&"ۨٙw֖ɩɱbVC7p\=DkP??[x-n\+M|7ٰҙt"(1|I#QL7:9/]V˕eH$ RjD庞%L̵Lt}]6QJ}|ݖlq@[R·$F=K׭[%bqI:t<y۴wXCA(/Uu-늤ŭr%=j"c2^Q(-a+eױ]1ZSF ~xaT彯QkK-udr Q7jlʲDKa٨y,sOӄPD|B?ǡ45e (g2m}v&bνs^ !dJih4H&iF# upN($[` HTC溷g \|ꇲ.ŎT]i855UFLֲ@rӧN{G0ԒqRPfsV)Vg;qĉ0HQsTVA5y HM[L0cv==a떮!E]l@%)R(_W{cXEjF6'MּHg>O/LL&AȀLjKij*ƁRB(Z1Jyvfı/~**˥}{R*0m<|JUV;f73 I&83r5e䮭$jV?;ө7SӟF'ݪTLxt=[ aBMK9h~:x,:3)A' eB$B[v(%VlbOwE1Cag ?}[=>0\pfp~A{REp#k}~Ja(J R L o}岡-1c"JMvGดEQP %Idb cc\gmm'~csmo[_ȋO7*A'PK+khya\*IJX![XpǜPż('O)Aea tye#YJ8f.߿w_Ggg#s+UJ +K!jmW @kC'9 * _{^36M>yb}du]k2#ɦ RՎP 5D$3/;qɚS-LNkE%Mfx| 'fZaͶQ3kzg.. R"B1ӈO''..#)%X,U$ 0m0Bc !J (=O,H< !E V4FJ4ꡝC5w_=hfo2isϾɤZS'N>ӎwřLl.sӍ7߿u*7cTk+~-'N N"0 {{{Y?8UIny3f؈i[J y*V,IZ c]t*-A;+B铋cdnjqmW2f Vc!DV+ɼN٪@ F!ln6[2dTZƉX\Cߗ` ;eYNmy-Ug 8>0s( Zk-84H (V5(Q;Fssf3y^I&^uq;#דNY,oԪ&-₝ڽ?3?󚝠C az*fRLR(LO̭#o|/BYk%P+aZ۶HG>={v_yՎ?:;?*39Y,+5ۿ  D~3 kmS0*Ng5o}ZJE^^X~g>O'?kZV: DT.o-?˿//n۱>W@ =Rzc]@en=_P @A7LFZu+oBb6ul>nܶX 7`1Hغo>tC&Z-/-[͹9! 0;;+]>G*PT@PmarߗZ)Ϝ^:uGy8rGeNMN^yv7j}Lֵ{$! ZcRG4(ej5;#[|.3>:;͛Qo6()Uo_O6K锛U{c;cr{Q*lQj6[Q6Ϟ;=2265Q+׉9F(|'(PA L(~&Lv+v$Dd˥h@#1Ur#Fx|>u@RG#-}(PE.Yemy+W)R/'&4m=R]y8gNnW$7X6955;e&+gOd:UDˋs0X7~! ЈRa@jim^ؓ _k &Ru\ v) i oky=|njhdAc@u@&Hw?K)1{TaCJ* @(H/f<_qlqK% sUWإ{FAto@Nլ 0c-M( VwceE`Jla:jb:7v!dD?fֱcl^4m0۶rH6MM|n5g[e Zke0+ʻv&E*BTahvT2MrHxA4hM0XT+mH,7u+BaacFEpA AI0FI]uPZ T%Qm,=yʽW Onlb#" &zq̕n`$ z鿷jGw٥ w|@]l=4?X~az@ku#5`D 1X[ q'!(E ja%e" !tWBJ.d'$H@X^m|7/>p{3\8llm$ݓ[,L_V@Qon2ÑjE0iy@`y`FѣϿخU5mQ± P #MR+L BI Ae /_zמc[rezi%ZSs`b[X[c[QΌ?m>~ IIfgWW4aC=ne LcR 0B kվfY)jr۶leY=}a>|xnf^6f ~ w Dlp%-L ]T1Vg?ן|n?=}fkG Wq`J M!uIHБdO]?m;&@FiDLzv;=~jz~jJ-41r3s3>ӧQxqG?Vϝt\uSj7H<8 }VݿO* @\ $ P<޻T`O r~7n[)˶j^Y'W˫O|*-۳{wNfل'KKǓJj48ZF߶Fz^+qIq\8k0G@b  $㗍@kF]R9H$PE2c:;í>`Isv8ylt,"2B~yiCDI0bf|o_kqN imz7824rz`wm&>ZZy΢\Ha,TV"PlfhOk2l6kfmwR.8Ƅ1%1@|?)W6 #Z=NIM7ϝ>1[/ b`昉bY6rJmiq9WpS)4QF!0ea((apo]y N9mSye-6AO_&tGGJ BD*aY&()# җhLSQD(J f<6|뗠ok8SZ$#p^m}D.k2SiRb4hK%R'8v{|b)=ҒeC)FJI)t:J30r덷k~L'ӳ3 @c_yew\;BZYjvKE@էx5צ "Js~܁CG C7{/BHr ݣ'NW:{ ԠO:L#r‹L0njLRN-9뛴Ryzqiqnu_segcAuݱ >i@7_wO~ߛ^g A +2_Ϝ>s˶ZѾk -oi{>VWTB>2u|3~RxADe~e] B"W8LR[WR\EdW0v7 0&2 5Š5f 02# [T>399EF?[[ZJ{o~ua7^{PpSё{w$*W̰˕JY/.,./zcchVy+%'?Fp-(B&B1D*5T!g^xzP̷ۭRt5WHo)aJKl`ؤHAz R>W=ϳmCIGAB1H%g`N3m/W1s嗖gƇ1Aa/-tO}sG1lc$md`5$FFnl&;1>@E qFa!aCWEB0Vq OGƄ$FJR'BFæh @RbB\ Hk9gO,'ը<96˯}c%}5ʹx/]y$0֗LDdp~q 1s\;C}C0(215X D,C)֛ɡյ͍F݌0MTqir("] :åok6!)H[[}~BrE`ti.%B5mCKL\H|cz iYD Zr AhysLq``t`ócB+oJNݨ7s٬iYq7*F1]('l4T:>ήolfR󧗙g$OZB 4#l:zWoյ|1N%R8J\W-x8 Jk zyi=+zeLf̊8}DzV[LDo;:Vj5{rE9O4PpwbitfO=+ZYP"K2q,iL&cD< ){t}@ )2~hr(NBy t]?( ˴RvM[o_}OIDҕR&VR*)JbmQC/,mٱ5[p AaZipFJ*h>WHxQZ沶rRijaG0M/im,KEMLmo/ϴN;x.򍴑+Ll{~S xeyRvʼn9QjkRݮv'f H"8N!0J)! R!`)h JUCa"3 FTT"<5G~*l~__w׭ 7z/U$(XoRکO:Z,t5<(SلrCmv+qPazZ[ݨQdb 1(RRY0p,O7ʆHYRJeWWk햓:Ւ=ݽwכzal$O~ر^7u\}g4hMvjrrnZ#D BJ# $rxffbW/TORCؽܔt001c}_yV; LF:T/oDwF#B1OBe56Pa|gԶZY DkFŘ/ B+@)A .BF%VZufz)C[fpԶ-#۷M Y :v'B)%yH(eA)رZ^MYCXSOrgfV VcT*=Ϧm/B(J!%8 ĀoEP @ F&|ӐӸʽ_me(]38w͢Aq.g{^`l78AX[R^qT'Q87pԩ3gb5yO=;P9[ܨpSGy9cڂ ,J0;H|nn>qөJYnki!8 Dl+e0 !-k%  A6$<k6jR(0Ps3svJ%j So7VR2If0"R*"4rB^no2.EA{CG|ЗS)lN4bQ)QI8N`&\4 UW ym߶zYeyP)`*,#"yZ"85(V;{dyH 5h Z+Z B)gǑf̖AtrlPiBR4@,e^n*Q^Y-NO=Qܲmbx+$pC>s yx(O9?ϨQl)[A>o6>`ıY 0Iκס#' aH*V"oB6 "A ) &~k}e}ueP$GJ`0DWl>#txWuquz?[lÚ؍_| 6Hw:8JWf; d} ۳{ώt&sمE`P$L|'F]?kvDXuB 7o__pGCzVFH)ϞoIJ76jL-}aZ??wu=wz-Ց*$f&?>|H57_//ҁs=~QI8!'3Dʥ'hUk珌\{- h5qEҝJ6?6wɣׂulY(!HD}~e}cL@JetfLMN޻k2J"P%<ƈ&1f$|t%Nnj{ 'O%RPFUbA P0@zc1?@!P-n:P{5Jaf2;A'dҊKŘm۵۴R" #YɍNolRRPLf|ԧ4H%!L+ҁcgkP&`$c9L.vzmc}E5)ݥJXW8$RJ-HTOOԟOeNA&@Bpw_Ef~A4FAXmPlrm<ӫ唙韬up7(*H [fѨc?b"ݷI߯_ʟYG7@bI026B1O}sJBpJ)R2J8Fr{NQ?z$!ۭV^U28[XZKj?~fݹ\q}Vo4h24 i.IҩlDt_{H1FXѱ-4 F'Zfó 8eT?؃͊ IX~@g7~zz=\.qj-HP;P*iORfaJ$%-[rhͮ|衯4fnٳgȵ_u=w յ>Ͼ]o;O(Ze%hB:elGO?y$T_kojnaę\Ж۾#M_2muUF@@^~{? t?kn,T{QбS``1fi)Sm|l#/|h;1r]{8gO?[==_/Cx?h&Џ&շ d7fRJB r|h.eK`Ƕ7jPve ML Y&Eq &AkW0 b驱3{vO.,V7Puێ(ND#! uan͚Js0W*i6cwo򣣃LAki9c!QwDP:IHY.v- k qSfJC?ԱtH<31&,ulz{rW]G!!e@e\hRZK! 3'G`)  Z&ӗ~w;qB`B䂏0?~fNL? o|j htqX@v66NQmävU_u_+@ ,2;}nfctW*F$ĘRJ0.Ro{{b&lOy{Z@oj70=l0$׿DSy Z.E-eIJ<5 Xn6*0He<+.72kj+U#@`\&}=2*!Ͱ)YKjZ)fbP.o,Ԫ뵍J}Gz hJY &I|q& 1A9e#Ŝ$aX(S#'ֳv>6c*I65eci$ q\n[Y6@sB{&1\S2f\$e,lY(0)z}I=c경[W*(V+lЦɒ . rMZ] Lijw'.#lO .خmF73Z+%С@& QRr!~1J*Pxs]L;R2'!X* 9@&#Ta8cm;qaF懤ҝvy0r2VG(eR@({#R6"N1l"PwyTrQVkW:O|řSn6A[mD_S#477+++xttI$jϽf-c(Ũc[&#*;~?f=3A'Š-᷿m kM O~1ek;q;o$YOΞU j[dݱ{ff`S{foz͝զ[ʗf,u&7?ݔKu\: X+) "s͵W/fLOhl..Λ]}63͌81( $׀'Kv~0X۽=}~+ťVE\p;<4S҃_ -o|{“O={9HNel&l5mZi׌tY7M#D;vzٳ*Y+u-k+r#'&\~õǔё^|_\kjlx 5JC/>c/w=w~-+uN8tBovM@))9}u15f#xai0w~KXV5 *dS\6V\@͖FdR񣸈fS0=/[ w\$  뮸rĖ=ih.ŇHH 4]uŝ$1JX6- fRYN[N,cB #)5FhsO?|\IDM! KRtףf3|!/ {k`c0B"j33ŕȁC'=fi돂9,ײ+Mw|k͜;u W{m?񅅅mJEjb9%B(c e[н0Ĕ$BhM60[.o|F%FFk>CG>vJ*9?=sj۵uezӞ.,.\}7{H* DM0-529c=?b.s]wZuwU7?/fSX LZL;^/g,3j$e9\P"p!8*IPڠZqA0فޔ<Նu#++ˍfFмͽyNh^kNPJcet|hubN^T:6p_ooy@Rj ,lzvO̩sq+,,p?89³󎉄 ncۦذL3 \;0ٗ\V5)uP 1ǘV*U7 Vj`La.,,ol2lJ'>~Rmd:SPШ7?yM|efߵ{jh 0Рa 4өi1֎혦)t:zqq\zVݬ@}WiAu,:ikF͹\[q^nzzzr!BZkEbBHp  Rb%3F\hhiZm54_YZH RJ$|@I) L [Ədiqe\(z)b"ԠeKͥ].VZ Dar|D JkL(g?}?/Tj$@$%BE-'^8.3-N6cem.-6 þ([\]/kB|h`{ JIl|?<~*`XYެ/ڽ;WOM RS'@H|Ox 1Á , 0re}m= 5z}W^}׽؏9ٗ}YgXї/> =r?R:j5667f@'#Bɫ'?p @aO9z̹wяD'!QM=AjPVjrIłZrݯ O"RI,///.-A̘3;=!C`SdT2T8g-2lVKm!,n:c=>}33Ϝ#0ZZ(LC'ntueJ/Z?g}eZʣhr;s040MZެV[m:. 0H<+9%&ioo>ss)%&R@ }]{nllٙ={Lϖ}r}_S Q$ 5vB G2MS#@Z Z?ꂴ Yn?f`쩓FH( XƆM-e" G8RJ#dec2N/q9,GgϜ6 Z(a°&0bdwh--mV֗ΨrsvCCf;wry \{޹eccc|gGG'30U'c9Q "pQ il;frH;E90THj0ae2)Up3X9B`1AeYrmu~san!V!4/l n$ϻNQfڶJ(5A㱱aHY 1Ad PJqRɨ 9ߗǕJ).UmN'qIb;Ea"H%hV#?Ҧ D#v\y(%kF8|#biǶtѰ̒fN̟9vvv|b$IGa{F'Ba`h㇛Fy7 [[T>bʧrZ\DA˜<|/~F@6:mbyܨ7B?TIe{[8uL*nıR #+ 46(ƶo̬ή~=#1( UWElV%!&0cr˸c[:aڛ9xM R=յr2I#S/7;K1 `EZ'x/Oxav{Y$}#[J`:Z0IԮ]{?{rV*! T^Y[Z _?ԊjkS7|x Nָ㵷o_[xG=۝>"N~Oav* ۓP.d[;\?._<ϺbD/.6O<]y_wҨ;_ m2S#%"iPXnx<^'I$ 4Blr+lˢJI. jhk Z\.;nY^[^<z˧zK半cP,V#1dfc muW lX]Z :ب0qtR}ϝ~}vlG]Hw%C/~K$τi$FF]B__NAkVn7ҥ|E#u}AQW۩'~x}/.,4@6{F:u٨0 Τ3}QC;N!Xm?k k'fܜ=Zߜ3)ˆSƱRiP A($EQ^u+RjT﷫ zH1ffS`i]o؉20zx#qIƆ''N#A B1ffV3cE>S(q\# znwK(l&IT[k+@seflƷ rR uSR*X)"p?N!J NVSh1P(c0g{f\Y[n{nus+%7-JBs 8I8M(H)!tib$"H$a8N5LfHB8zhqnsEwb|j,_(tO"A3-'Bд\aq"9WBr!a0m@Iێ+X6-0 µ͍L>:mB'<"s)Oyšnp0ukr^֛ZႛOWk bLa ZAE66T)_;r[ZQ480H6SʧzoIwle2JD>C)r+cO];wcNkP##??|u'%dzS >yJ v,[noCfj `%2QΩm/MR}Ԏ|Lˡ~_O?7NQ`>rŜG H!)U@>A@xK>|Ȗ|*MB胏~-4Yܘ4v HG>rD2CF#_,ݟ&P~xtq"Q9N̘+t 7~~ryai~ymy쩳JgǔUɉ8_u $2-P7 軞$/h@ bO|~՜.Z(?n:ٶiPJ@k FAx7h2QI,8WQĽ"iyk%BuA!_4 cjV*jujxÕFбa&XzH}+a+sv!c1* =ىq˪f֐B/.50F]E PXIx1Pp% :;QLր h):&cÓ[LJ'YfZKJ 2RB43LMc)8 X̴(1%\@H]tP罉&@@wa@^[0+++zm@S'UW^;7|ȩ[w2K/<AD1Bkх}55j`\ Ob8FHiZn,/elouKKkw>lIdS} ɤ 篬T6 PAeV[.;{!ՠHZ <( Jbh`TkᤕJxH B5큁tM0eQc |>vr XpiYf SzpD63MIH ǙL0,.Ujw_@W*5׵ 1JabL›*BP,f^oB."Y\\Ĕd2F5}b~e~Lz !Bq<0Mg3,Vf3!TJE0!҄QӴ8瀐R`XR"Bh0-Y)6˾_wC3;\~ڎqض-dsJ^)mc H()^#0fFgRN:#66ԃaM {oK_Da5;]ھkkhooTkb7['`d|ui+6Qe2<1z ks`ZYi47864y>gN&C;Ji8 ۍkUuU$H \B")f39jf!TcG?s=}NHRR!h (Mĺǟ&F{ 5i@`)+ 'MFI\i5))wW\={}'}o{_6_n{߾ >0:h7=yt 肓Px1{r؁C[;vMjk 9` %RR)FJs%b-BX!P$0A ( 9I8LxQSn:uGmOIdx`$"?AZi륁DZ\D3Jm˰B>#Rn߳wZɹVCL@0$ȴ@nMya XcLBR'ibS\wWaP̓N@#Ji!!sv\#m1vnuR&Š  D+bWM2K.4c /lfy߽}-( zSmm) %k—8&J)?J 0§?'?s 4lc%H&oƍR:m#,RZsg˛v37;JN`vTQ,ۨ7|JqE翏WWUxDZ(% JoMt!B pro';H)J 5Ĉ+m5L.qlu8Z]bP2NDd0X#$1:N\g V)j ݁dSG+ d݂c6+5'S1)Vr BS/kVڎaDʱ][Du+D .)T66O9~ פcP3NLǢmR-atl65vDqOO1)p.9R$t*JL@aojkV[qӬO Z=s΅E7<4(X[_7+bGZ0̈́'NLeEqUJ+.G\Ar_ *PAN{( :'8ɤhTǎ`JII8JPFeHFq ; /- [G'Wk'^:}iMpiob6'ky~NI'q^TJ:8cvaJ qp. i0NsôR#=-dYϞ=O=v Xz/5ֈ{zŹ+2CY?N !mne\ѓ*,͝9yxc\ش1)f\yqiǦՇt`S<-Ƞ(]GZDbI+0v1J@ c:Bŀ9`P2Bj@s+3 tPol>#2lƊkAδow7?'|o#'=96($mZ~jy}a='ZI$h aܛ9q 0(BN=eY@ tzZ5I 7PƉgϞ@.,E"B!B;|Ԏѱ|O?"![(PH֞zWB {aH޾mΝ΅a,toۛ%D]S??znnqqs"`% ( ?]ʥ3z͋RgeM00mLwN^]I "@kEr59=@,-x#O.7!Drı?6y7=X!A(\=Օ~Kem1_z[ޜ)iZ-!u:u +nv`eW_ 16g9mo8?2 ʮ  飧_˭Vkd+vfFg>6)qEPB@kP 2Rs/_̺|:l5S`XEН<\Ca^#QT *_}\qk)׿w5[w^ @r$'02(esRAJ)@!D0hKt@ tQJX룧OSϿĈ*Dj-4H zߞc#GW4ym@i޳Qcaq! #=OP"DR Be(-!`h-1B@(` Z]}(eZ"ws3'N̷>%8mXZDp Eo>{دՔh'M8R ŏi b< [[o+9}8HiJbL4ΜlkF_[N߱L? R) Nc1MW֪Iw`y 26Qlۏc01if~xCNyi<>ڿs6${{1uvLð(־Zr)zm0 1I4F ykT&Cdm!$QĀ)3bĉ\jB Ni2cFӮ5R_J+ӶzzKI$1-cb&k# ϔqrmMGFmtzKllAH xemͶ쾾>BrmlXfNXT  㣌FҺHP.Vqqfԉә[KB.B2dDf $IFS*=08B\jS4lb}QPd 3)7掝2P2Q~ЖLb6WJ٣V:ql!!}#OcP)BZ@Zw_مm[~<5JT$B1Dlr<~rozyvRi#e.eA A,7:ւF+Ջ_v 閑+]i R?Tk4$hYI[Ĕcl`EcHD"teago޾N[:|| 0) bA'rKc[@%PoՖiQl8O7{촽ؓ l-op@$F@k]kjB,8c0E A)vMy禧x䡸ێ II3ϝQ>i8NtS)y;ޱm~f'?wPVtOM\;m0y X7`q_JQSn @Rs3Q2bQRN:[*mBAjR3S'}*ؿx5?=0h&?3OOMlٞtBW][(v Ӷ,!aa`L`t/G0hVR)N0c1%qz/뷣#b?+eeHm6e*Vl>5LQF q,/lˊP ٿ=̿:(J#@k0zFoa_،@V a}MnzW~ltI;)6@2J@p h `3#&˰/7d77ׂNTh؄W' ~~=wa=4T:hvZ[v^y;m8z([oMK5;3=}nko= ^KwshaV̩fPH FrmiF8_Jel+!8d@OWu 5ڝ%1'&|?VZ.u]DzY,9=rޢe:Q *g8?8}0NoomwD._LӠuNjxe2|.Ņpi 2BTܺm`4ڮz+^:pzsttԞbPV=d뮿1mZ֚} WN+DMӤA0?L)$q)mbFN;7wĵŵJPkw&e-g fn&JD p``0Xp&\0Ӏf+00X3C~/oZ'plztuD"پ{op lFZv3afld2Bq(a5w{Ξ) T؊X, (V:q@ZibͅMTig(aPSDL`%z""BGخ \>uTwgPx_#1`ZI#a&IBG,B Z[Q׎cVD_O*^HvӭZx/l{C?pwNL$PxSOtH)B2Q_{mWv һel~<я|bvq!%Zc @$Nc/<1YYhTO֘`2LSN48ެm-;;\smg^8rQ3o)Wlt+'ONKՍ{m>`h@ֲV>*G@%.Kjall|mi)[|)zc.|O -R)PL0 Ġ@X~[R)uJknnO7oƧ>z\dW7XR3vomo|o+CcY;tDZΦS%@DD ~-. W^8l;?cZF0ϛtX)BT@HP$;F@|'lz _BcrgH8J qWϿ P@G J",i bmFb?Xx;\m6= :VsOfb㧞y6S[FM`AC>?_Pװ$yVǜ ȥ]ȸh/B0 }oڞ篭1ƘaRB %K==\6SJhJG^fJ{خSfp!w BO˥8 J@ bG3@!J$X3Ph4PWt@w~zbkd{(mAKHԨЏ٣'b/da  y3B0%Vbb1a H#(<_,ku_Jzt`hU` \&ki( :60XAHoyyIOo9X, )PH]QJ .T@C!_}0)!1Dk0y_% @Q\ӈn!&"Hk- H!1&j 8N?OrUW89iF#r@L18]gkw^{sc,B P=9TW G ɀ:3us(;rʥJ7.&΍*|c?8>~xU@`L_zݧ!ZhYQM,ǎU}ADWw)MO.&UJBm|yةKsyaѩQ2KO4Mlf<:tp޵7_H}?c͹[&|?n -?rk@:nhAwބ!`HXMoغkmخ`4j7#EAK5F3d44R&+vwaX\xjCK)O鉉oO$NiwHU*U(l+2f6hV՛KKKFGG(:v4\juo289q>}}8)ohn(16t' .Ye˶_şL퍂1Wn_9vuSKM ,R15GN:y:WXKmRn~Z.>v[nλbܘbc'}D5L4e=DvzZKAB͝8xQ޲Fײ/wܼ03m50f~߮p(LcЎ>aVߺi~=Yq72YVƄKt̑BX$m@xuoX0<[t؛k7T:=8o}u7mF-c>#ffR\1WJʛNG42S^'fK#2JJ[*DY5Կf[7_476܇&D۶4[Qx\Dk6k'%\ -Ўų1RJ, (@3W.x(qwo;}hf 5RI 9X1m"Fl P` @޾}롃N/v^+;+z&nE7ϏeEM(cDADHZ;u䚭3誱vu\50vlCZ\z+_ %`1D@ Lg M !6fyvᛏ|}޴c(Gƨ &gyV޼i}oVC}lJXPZa2ppl,ұ  PvmfVۙبy[b ?qⴛ{ qVj2bwvX(L+UZ*OMϥөbOoO秦RPHs8l gp`P @hv͎k%Z֩Sgҩ`&n4jJ!!dqq9t6cYV(8K&C7ZtG_\lo3gɮҘ;5^\D^z\6f5QH9-A/U*ﻮgYv.Wf;ww2Q #eԼ\[fkduaQW[7a *YMpۮ-q,v5%^T^_ƙGkKDc c kmC$a:u@("jSF eV Jx=o>L13f< FB!\̜Di 4jD: ԢKbtm"a(FH6!́S8F+ #}0 -0n:Zj pQ8qnĉ'X56!ОA~i[Jg 2sÃ?[ۯ 9'thh^#՟}:vi@P@GQ^V(T2P/U_~ie=~Mw쟛Y7:4~|P}JFeߥDPōN?dR('|ԮZKe @XpƈcvfZ%V~Kf"dsD([@6SdW˾,W7v=^"l6;c1.,q8FJRKʉN{\S#H8""%N0N-Sv WW=+?H`ARK@ۯ/-W1ض* AB:gth^1S BDθC3=|}_lRqjbdL)u-KЪJnWeS_$X)lz'b.dRBڶD&&a,xa w\|q7f]ް:E]r;GF)h+lz[ݼemW8sv.W9uBwOq u>?C}}~%?n6ʍU׼7z )twAٙ ٷ!'R`;ȞBя4qr{J#]9 oIlevj90OkUa~tEE ұqh)H<21UtdQ28Wv+3۶EA܎u縞K}?lbfP z!t!?P-!X&o4});L2.rBx6jܰ," TjK)dh6D1s蓃zyX;JBi5 9}m5malWEsr},.RiVy6 j2n0vJ3(U.F׬ ;&JT-?wJGSSsb1dJ??78_f$K垞AWhV83%vD"eTΟ8 RRFs܎o~ ݽ}Ξnبg% ")9jVQ#<11~䌍-*(Ex5i-1A %Tk08. V;vXf_ffH[&Fd[Mp_ƁMg.&mIoX~Ԭy ŝ/2?=ӨUnrj#Lh@!lLK-Çt2S VFE O:~npdkڵ'Ɩ{ C Fd|e40yDŽvVX.B%ê`nXX--?W}򉧉[lv㶅݇uG?'4  ũS'4m`B͐&&FJR 8_/4jdTez{ڞ5dUK n;=y~bb걑b+S68nr~$~`ƍZSn[+nZ W}Ovlg=w=0zʋywoW^;7[8 \̔x:`@TZ)l^x-7wEL:D'fbxlH* h0A#MZ[utY^ 6[˃;~eRfo M\H D~8;9sp}S&Sncv±I'/ZƐN;^bOwfBX!fon[&82NmH!tyw=wjj%#F8\(##=]Eov 4BCP`v%Uh .,Kp86HM @(c!R#-.VA%Ӟ 8w[ x фPrC&D^zd`$UHS? c[\Z/Fskp @,[m'ufihK*FB LCt+\$@r tıN%3a&&!|ŝ<5A 1nt#p7lzŞ;kեނpZ.rY*":az{Iwu 5c$e&J1^6!NtZ@xia˔lk S΅ke3Cz=F_484 ‡4@ H~0*@nfM쫄z[-FֳvNgJeU.1Fq%g疻n=~ض:`"/$0\j[ ,DP8@ZRՒe:̦+6%{:9?aۈ A^Zk(V6480<8WrB䉓OlZ@W_l8u˒VdT'˧Bat Z&_za E2 `;8Un?uzW\n:Po/O~/ ,:;=b~oP640ala PڡJAO޷m` iѹ LNEŇXo#c kv\?aCG^<] sO,-WjHBRaTޮ[7A6 firz|b/>W_qx>;*/{sggg7l4^?<\aUۭfZhQ@Z?W&aDqLyl1qow̗>Z" ;wfoWnQIBÏPCHۼi#rEŰY@(_ȏuZua_ $b6~W~~MKyםw`z[-t:$!Dò^hc gdMeCt^{d31i\'$; V  ض@XT+JYv([9_0B(H j˜`Bք`.j+B#@!DQgjDk0 ΁FQ UhY6%4ά۰* E5uR@8p1 /dw/JtPW? J"6ZhZ+W|[n}`,7^7 ƀ:0rKj9YQiT`y$,[{zwyru~[?zju[ѹ7D"+B#61m;t_׹6A>QupC)4HR¶3R~W7|Y~\*/ϵ@H4y<}w;ǽ-ejfR]5TIɤs>sLH}S''_9`!EmhO=h uH(:]T$cusK{hGKT[ijhZI80"]Z_UhZe=iUcIϥ )Ri&B?PJ07eh'< 0 `P9~ةڍmNWox |٨R)/I7(\'c}mYT)*cv\k htYd<*nXiKTg'n{=]~ԖPjL (S'Ida~ҙ\q]/*?~T:Bml+[Bo>MJt.YTPBJ̢AIw(J1!D0+dR--49@ϳѵ̔k痧Յy@9u VJ~lFmn{CbK PEFP"&A z Ɗ}׬F4B,;JZΕoԃu^f7܄j5b{ AA0yh`}GWq! {lh[n?8f.qz˺-J$ݽW]miӻk}kz}u }''5%,QkשAE`O-J1M+#Џ qv(Z- Cl21>U*U2^ àUhG7td 0`,#Վ:s@-B=˻kF}?;_/Zݹwo=jkit~8p(q?}MeFݯ;HĄQqHjW~xۭ߼Mcϝ|h3dĸ~!Og?{٣W]i׬DB-}}=@œҒMpӍ\3קΞ}"%(6fZiD />>kWԑ}>{FK?`1F!RL>q5^uaqrNz[o劣/%J659[wBL:a4FE` 68]bgq?F ]a8K@%/N[|~2&DC(! @!hAŠB'GH^7S졮.x0 ZRI%̒4ѳzֳ(& Cc1p\͸~_~ Aː۫)ZQ|:ՕMݮҞW^j*lF-Õ ȋ%%eZ-f TwfsqM2e֥r 7@Jך_+zTabz  dWg>E6۷a5RSSBM(`1 /RAB-I n*uib0e굠\s Z{܉)JxT7Srӯ "5BQk=;;l6r<%`P)S!b"pBb  aE4ڡtq\͗榌4(滯~77* -k5ڜ"g±goJťFWWKTB:}yfyjCwSCkwCVHՄJg&ffgfsٳqSR 'QPF2K!g 4DX#}~[SNVR՛r}Ϝ^~gN=s3^U(dF žǏ:svdtlb|6R 5\8Thhaf##}Pڽ~FKMyY03XX-અ$&2ah AZc (].7~OU j gy ZZ}]Z~lܼG݃Gfn*ؽ𢡊>5~~zv~a=aGqeն__^cM2l{>?\k{ah8j04(4$^džF7OϏX!}۱ l@0vLv>>^7V٘Nf^9 _70< qMϦ!@}/?7'j%=Fs]]?C?}U#k6o޾ʿ܋;w;rXalaPNQqv; Ruؘ> G#Qq %*ʹ) :j6'Oݺae {SOR,ϗ3'ϠԫFW}>| P&&LDM^ 7%ڰ"+ej!v5882O ;!ɄK5Fp PfcSԫƶ\q$X:Y3@ !J+!FiLߝHZ e2ڌK=GKlRB`^m.xgaۼǏ8F3DŽR"U^QܕqB0`owGTEs񕉄!{d%$vſm/C&F%b]opՕr"U7bhR7WJfV clZKW "JRFP:SR#6H @R±$qv& 0T-x'~?l06X3;yφ Y{pwr+3=^=]¥j]bvtMOj%d ;"ApW^xqO#/tZ^q!ʶ=ǮBL\ C e8ϹE1XFTtl4@l/aMUo+)ѝ!`fmq+6JRQR,tL69Tjt6kQr%۔e+ X)PFaO̢bI7[f2?Tf5W0. ιHfh4Zv݄2L Alqd2bU+W@sLlT#ϔ_|pqyyzjI%[\k[03!ZJBɶ\!,ƈҲm;I0Fen/<& Ҋ2a4a凇G^_-?=175[)jt:PB5p]jWo615}DPX;:f Z9^"mgx )kKY[\- FIDKWREӠ hqW*Uk帞ItqKLD|=뮹 Q[ܲ:&${q A$#*΀Rxlϲモ"]]_Z[P1u#7g,ha0 AXjm&ؓLq0 ;n^?g,VVe湽'#iB 7_{͏~[Vo^b:s:~dca1uPc:7+v1Vf0{n._rL>o#8jm }J&i33uJW1ɦ&6Z{{XG ܱF %KbI$I-` Z-PD>b۶m=:CD£@`Vpf }Q\9[/ 9ǵ73D"tT[6F ya%8wQ.U@3SSOUی4q=ɮ|= \_1L8J͆%Iڡioi[ /ATV5éNO, 0: jJ0* rG^9;9Y^ZPEQjkL'܈ -4tlpV) mmqq~|b0(c(`P (߶ncW8>9sRuY>D Did4́ӽKn+ xo:X*PQGv3ڬtVB A DaĔ 0įT_ڵ];[bY-ÀUͶVp;:rD>-333-`EƷ d$\J P}{ dg&uyRiQзzVsFE2Y(?ACxm3Gb)< qmOͶk!`EɤouuW]{~3 Xb m)a2sM$\k ]{=x)pl5܉q =o>߶v\7lZ $paӫ׮8r^8q `@x{ FQ{N'LF|QZ3NAX ۟w _o۲ᩧnRXx<u܌Z+m~:TKKϞ:={wdxq7DہhנF @YtriɳYOW45yT4 ƞ@s0':@RB;eNŠq~YfΛ݋K"&ةb[WڌӤJvV 6MA0`aJD 8RG8SwD۶ec80-? fls&gW"`1%Ĺ]UCV4mWlN' l4P닅n`T}JS3pSɴz$^u+O!||vB^1%GZ3tL9J!A-i&@^*/_'"v厴tj{D@E11 ER33`.!h("2 cT6k gJ3)&v[>EASErˣJ8Bn lY`rnzfnFe,Fi~6_$đ֒R(a9`Wltbaz:@4B!lM;u~ LVFC%$hHY "c_ZWZ5>ms# tEd&50u%!@T 禧=cTJVж(52 tW**/37ޜR|5ns]OӱlXs'(e"[*`!(2`JϿvyR\_5Jj uc=CmCmʹWm'SWq4>z7/<Ѓ_~a۔ssy$F)| (CMJx8v+~Թ(T!S-:N`SplDŽoL/= Kf??A0iB  8v# P` ;5Woߴ~m>kVe¸ٔQьc_'!QTH5d3Lt TOMN8s giF8zw nXF.$0^W>:6}EFK`6焀AAo:q컉}-A4-F&S`_zr#W1 Zƭf3 Pk_|\7BI@YhLgh.(NAHB7Ȱ%|45wKa L1{|ڻ+ηήZ? (Ao&fF5jK*]Mz)0@ j 2sq!n""*'z^ޥ[$;F#umZ|`@ov8 =2::8mM@V7Jc]]%1:2&@:~S3Ck.{+(eAM_ׁ LpW\RD$w|d\Vnh)^zi+Ϟ_\t /|}6 , @K#%uřgT2h86:<KQpFn2R;TDBcG_?mVG96(Q˸( ch( Ͼ!|I庺v+e3aQD""j ad[’6.a*eKDYєuH@9wtϹ]}u}n6RZ~(Uh)5EPF(BA& Ù6:Hkׯ1a:)dqñ(2bCCI$\,ewwOӴ0rnYZW6~ۭZEP‘޴vЖ#[am~l|˱%e[фRRC mMcq۱ljVSs彄6D@ň:q0nèM OIΧ9 2(@C-$ QĴM,e8Q .g1Rjق"ZEA*T6^/-Wk=]ye RlR %NLNN.2bB b? zy69v*ھ/v4W4,@ /m c,5>ycN-J /AAY/G4*tub?KX~큃~{:4:\-Û7n,/zoO=cOX}ȑwNF %2m|꙽qlمEI~ݭۑF`ٳO?uƣuWmf}݌r)HJ3gkztI/UJUAM-ʘ$5AN4a#凿 hIԄ&:uCTkPs =ȑi sT wzh4 ͨk8/MΕq(wkK3,Ģ3˿p&^&UQvwԙF$%*QZKRcyנT8:]Zl@R̦uo㎻$\_ W,at_J3 ˸iƻhFrD(RF3`?d⌽X(Q$Je_ĹxD >sVo(>-'9K&Pcs'ImĕwP~`rBBRsJ%mOVܳ7"]NPD9Tuudߙ.N/]8սI\pA raeS5epˆF~'jn8T)+O{~zn2/?ן#Ռ06RaPiӦ5W,ͽ޷p J& O={jlln9{v"_H y7,Q])kh;Pke.1оw$GV:%p 7ʽYFR*,u%@`NY&c.Zb4,.IΨ.h0;nzw矧ݳs˾ ,6ꊭkbp$?2V سWp^"U웝kmVɐ9z.21@-M m "ˢgrMWzU4μ:ǟt(3B0VN:38<0~h)GBVAij2% Zt A#j BRn2(J8Qu4@ 8T0F sXw1ӿjإ"e%b%)zzVQJRԢ~41J< y1Bh,%ls&&F#Zr=?3h@bHٶM qD/Q 'e35#8DlWʁ?~艽2QRHCD|'餗׭ꖉK 78i'm0B)6Hp-K9e$ s 3sQqqc_-1Q`-BX[5H@3 U##j\ g2scq!eZ֚Ie$P8xX>kː6$Vz9j=K;eƬ[m[R6E4 Nc `'\B Q(-J""ù@,%OO0Ս <̓`/D"i^G'\4G@îx'{vJ2*fz69Ã#ָN"_,$\wn㦍ۮr7UajFy\rm7o-tMvjG~e׾-؏@ PJs3/kғgѓg)u;]t AKN0a#A$pb~SgO}Ui|I:& 6مdA`f&qhhZNL]lq*,n;L!e@A!ؐV 8~ܓ\o}|yYVkWD(l>vn!-7ob< c4fht8sMܲ}mC_־#5821q)%:H{b˖HԕWo`f(>ڊ!4beWx(z$gJ5&us3QkyT4\wmw )[HixKBc J-ҖW b "U+,P!EQI!H(#N[6J4^2/vߊb% TRd8P& scnۖrݔ{jı'p>e0u#%k9ˋŮӹen )5CMinT<;qh^0!Ȱ8cm@s$(JhQUP[~0 .v%Yl3jYӨ$usvviyQ[FXťzfkVY-tP*D*DXqL.#H3P p,@uL:M̶d6431~2*7J~#$:[Q`9Du䙑EFRǚ. AjvT;x P )Dqa(50ʪՆLV36`̎ [;unb\-7KK\`N\|ǽoƛ(xҭ ۝3ԙӵr)]̹'}ЀB:bNQ6+P$@)R !1hƅVQ@ڑuDxp˺,n[Q*U?3?l&D8kzGs[^ߺRdv89A}vr G@:mV:} %ZR_[Q/u) E1N.@{~_pYfMW]qc;n["0:ٜ8rdq~> B[XR˲$Ҋ3m]+GC/hv@3h#Rzg^¾˭ǎ6'sMP@a Ŭ¶c̑* 7tÙ=' ]޾\__1ctM&]ϳ14!h4^8ۗ.e~>xYk+ʗ/jM@"֚s{s?} <n*8ugcja =|OCM KA%w qfz<7~?L6zo{l>x[>rҝwAJC?{nfy~3BҜc֒1c6m[u]r d3\i6BntSY1 knAlJm#; e/>q&M$8@ 15nM:4Z9p/7ıc4ӀRW^wmתm9#:n9!` fkGGKYW$Tg{RJD!E(L1B. , 9𸣤"ʰ:?ݖV;rtݦMpbeytX_oQ\Y՚ TԱAKVՅrjԳnM$VU"joC"Z688n-Jr¶^ݳġ!j%$I81ƩTRWZڳϋwQ^3  ]入RihLz GC5Q17= ZeM\ XS-XK `jF `hb^.{]w\}un&Ip[0 Vlv3k8 O(,(7 p?&j'N@8|K hFkK%c֚3N8aCKh6"%<e$$v2x 00ra408ԨAatyP΋}u'~j@>!poƛnPL2X,{R@@xe"-sBH$+])ͺM~~Èt~l^ڭ&e\#) RLMS庮jTTrL,$ _ȯퟚ8L: FQ0 ]}ٝڻ~e%Nn65L9y0H_$e (³/oKܜ)C`#V_hD*`D!'h:GN<HʀDQ>T:w>󳪭!GlB* oۖ(dClsǎl*+vB"?42vl||Z? c0-Z ONj-P=|ڛ([|@HЄ畧^鷎>7xͣ~ŲX\ea2DAx$ɣ h҉ZWZS 59z)[@>H֎y]s@'tU}jEH2ޱWgzdPj ,M2qf6tI/wػ?[オwog֡|._.;\XhU*\V~׼;'l%y,FE  ;|闉cM(Cma ۢtm7_q8 S2JZɤgY|ѣG}ߴi 8e!ųDKFH.`EB? 7T~kmpqԗ!Drf!$C0szS̋fj܉uX~anjw}~"WO?f7c)mFvX36r)ו ` F07ؕ)lٰ~d S %@4GOo=viar#M Ӓ%[6oRG`\O2' iƠ|daB@$&zWu[qld\K.VEn4C#12BpJ~Jrnn!7ЕvFi݆XLt/(, c/Hvp@JvT$5%7nrS]v$(FjB Q-/6~,ssɩ`:J^7}եjH$N&76ܗ͍M_}KO?  BoPydHZt*!e/t207q>>Z-/WM- PF A R81hmrS'sA0q]o_bɱѱFz0l"HDYH/D!]6 xLjTh*[Q[jB|juU.b2 8q6[v?^:|I[Bg_7`b(4 0%iA'g'-.=DkE`@Zr˒F>tɶ*b%+wZMdX\Xl6ɉ3׿X.H%Ref3b~{"ZL$p(fA!6R ^兮`ɥ{r2Tѭ=4=P dB2Z")eV&veLѐQaRQTlpqy˵^ݕKg1@$K:RFգϿs?,S"MbHF,J-JHr^~eȕF?⊘xqLZ(Ѝ{go@ 4bV-b}מv_]mX׷zotyTk(Pm7w=e}'UFon]E|xtM`7:Zu!Me6*ukӮ秕Zn C] Ft謉"E)#ȵc>b%+p+"HԸa[?s* Hpu~ѣs3&%6lZeipv͖d1 lxݽɷMv嶍B`Pd:^9ēOwRz޻RL^eR @N?C}z3[#x"۶=zu^u7 A-:. )T l / Z#3 h+mVW. ANnZu,m(hը(T*Ԏ2K3ssRw&{ͷ4#9TZn]6[k641"#J%A\.+<[mADNk̞;'VLXC#6`nQ,dR9{W*e BwƍvjCCST.]מ~3].>߸qC+{ׂk}/>|36sXOP{hZ׈5!MO/?swedIIJ baEFWB[?ȗIeF}ê|!WfWa5-4 R@fFC ̀!I[ /cMwDBZ #N2X~Ӻ5k0x'}Mckz{|N@P@.8ɿH"J `8U({P.<}_{8R0RckU_j-/c h8}o!饓W"a9JyıW}*LK# nIvzŴmΛo̤RNLN"+j()֖`ѡ[oqhRc Dhakpi<*8J@DPEWF0kby .ŗ~?i H-M" d(7F1ʔ -ak_#$7j4IDkYܖ}+6MmtƶuWI/L\7RvxS4l`۬ժ??,ۮW_pڑT&1>n՚Q'"фS  5P+|ım/To`n߲=Sl%I7+z)%'(AbXj-_\h -tBV:"j4 O"$$UZ} g}' :*R\ۍ#Ieht3GpBKSGa-[]gbbK$lb*a\-TjL*fgQ0V^\(K+یb R r[Jeq;ch( !m @,bp*hY!F1F4J:6-?' fҦMCÃTrX_WoԨ/.OO*V`6Ș@JY~]/1PҠqHU Z" DӴ=~Mht]7}{Ъj·'>٬CDرcgdzɜ Bc$@tx:Ԫ9kVU$T[@c88LFc:Ϡ1( P{=7ҤB3]A7$/P̯Yʫ$@]""EFd7fr)^ҿm lכ5 2Zr 2 e7†%rlh! ui/T7?]廮yH{/PB:zN+2 _,? @*5JX NRid:aFDqWԡݯvs!y #(Ksssdvo,f }#C(4׮+fi.WfϦh$w~zvSL&w=o-<43>^pHv,nt vQjCm pN]4#(r{& D6v4]_^Z VcLAeXb\5:xW# QfC2<"&(FSm X*%12MPuh b) o  :@EӓOwuoǵWZ5)滢,<=j‘! d{lsGrmODu˄H5"2J9ogz2YD'Qq=o7;;r`ىZ}K_/.e+8=g #TN dP. 1F)F)ͅm٠5;NG lzշݰzێ$8̝ B^'} #N&a̲ͷ_?~]R% h0 H0@Ţ8D4r`̲VE\wwͪߚ_\:|t]kIWX~]|%ZO̔i395\᭸SO9}~g۸(pc'>=q-&m7n$[c@ h`h:{wPۭvfx;+o>m믈ch_%5EC:+Ll3F ( Y^xO[+vҋDi-k7'$Q&1J \!ꁬ+4Loo157Qږ˄5s|={0o-|H<}homQvҶ y|a!&ª6KKD+ɀ[K0R+ˬ6`((Mzg@y$VfMoh4j Ku@Wn^SLx }f ]9|8`h<7}EGlaU[F=J-A8{\eq.n5v$Ri˲T@)/+&N@)}=9 rAd t8|==ֆE vQ ްfÆuke+Fڤcj* U;_Zfq%,2=Ǒ1`. {6+k$?;γ-_9}ziԻ.^L!B 7!MBnHH $!!tc6U%2>-ǙdcH$Gc|u^0u`U81JBtTp4; ֟+}/{UH2LTi%6o^j k.YT)0R`٪5K?FVBC,@lUUu\RS1@LX6.i/GTZ&7&He@_SGGv1 * 7)&D_Gv5O+Z1jhjJ@}ZKdʈ"TH,[xn-Om7{X.$+RMLM Ӄ%c>Ja~X,E #2.BR/ʑ5W-XdǾ}gB1cւtZ]2U!XKJu !`Qp0ZjʌƠX-OHZю# Nd sLG˺ZZ4AZ (\gUjU,sDL&&0% 3iRD/ Mw9v\!/:qrz@@ 4yoiMp41Fbaajj8 uT: C]=xXjmjm5v5["Wo7ŊWQ*q9ʾ"<c@YOL5 Zy:!J4:9121h97a*\ Ùb޲LEQ)"W6Ny޵__$e>iY8CȄIz|!"ʌ)x|ܞc=ZDHsMZ;nymM-ͅ|q݃$j5^&bqT'T~@KfMYbկM:nkivgKaJA*R޾١iP.Yh}ɑjI'0׾WnPF`#a1JDuv,wOlϽoT״Y]5jHXAˉA8"{8OQ-ٺ- -*YJc2>sdfT0#w~cLa "Y"v%# 0/W :|&VZni8'I/[V:"᜖ A Zp<޽$X>g1Q_W`B,FRltk20c&नLO /Xx|b%OL۲m}.Ww:vO31'+ ݜkʀSdzU 9B PDGtL_;qÖo;p|;>|NJ+Wt |\.UgfNe0GjS!wqg1j."KJ/Yt3 GMznBNjo|m)@fRk֬8*NʥoӦ uKCB 8v~DBHF9 g 8^sZƘAB"C!_[|G S ScP[E\$yfӝ`|X` cdm!ʋ{v9>۷Jm/_>n\pJW;sfhhplr*]JF[cMhcm{1{鷿-tjh鷿h)gScɠVf=,jl\jCy%R$d R֚%0` LpO>wlA`(&.ի/' ctRdrfzKu uoF<5<2698ZO!ڢxm"@l]k#mY;DdP 1m:*a'Kr1 ׯ%zՕA75;>n"9%?"WĒq"" !¸,PX8gH,hKG+eA3 91PavbFO%-]ڳnUESlFzF[%\a@HUdqv`讃Sm  Ƃa\`I&Ӝgmm7\uq6l.\*;Gg,J$3/# !`mvݚk홑\8k,1`m5N@xjf-uSHGxo~B 9on|llq`k6hHHv3;8?Md/^hqgkA 3)xuN+ GnjY-\Qy䉧~zL!&ysSSwgp F6)p ǟxG{[4=5K/_uλ9tx: #sY &;O?~b!ъ^uWl^ٸxUZk]BFOfm9G롂o|fҞeuƖ͗n_}Dq1%F.q#h_ș>]=4I.ped{C0Ѐ匣Т6?t|xd;~*c%$ZW'׵6NOE' 4[~l||zj+Y_9xh_}cn͗^qQzZs#z1t^?ez8zPkjٔ!אK'C |y Kh 1l w>{nx:@&4ẵ?w]RoT!ny^}c[|`77Q\qy}@MLL#KQرCc@mZgf^,VeZ~]v.n y["CZYc5_@"?Q&25U#ZDl᳅oٳc < yؚɨܝh[nu-c@2ɖպ\.XPio^w_U{ŕrP#08"d4h91ik$1h](XU.ZĺR*k4j4e> ;wgf8\*MJA '"XuB戌L}K឵,hc ˁ!`*\"MLr t;q\K%ElB!2FWlٲPT8@XÖEمwÃR#h}JԹ:yޢ[oQfD),Mn슶mohzG'' #uִ4GdYE.& Fm^d5IP cc2A3q E\&YJHf'& | X˥ @ D`5X"2 z|bյk>Xep~w:GK.;lO]-Mǎ-IP@lJ9"":[\td_v\h9 zU`$='遆DSR,ػbXऍY#FǙ3 XK:k3"ǧ'&˅SG!VMe IP1c= ׮XupHO}VCCP͆%R\n C@z)V_;_8ZpElzlwݳaafZqgNl7nL|P+M0X.YaU|kϒEww M;}ǟ.NN%i,ٿl~Xt< `y}-y9X: ["T$0a,L=y6&SԔɱo5+oLJ<<3 ?+'&o5F0T– + þHGB]HEKApHTq:!-PU_|֎FH 8ڒsO`9+k_x^mM2/iULđ$aD5?Z'„>N}7n7ldWZ2{__p}HIsgg?s@l<;PՕnW{{hc ;4X#m_ͷ{ڡ…/ _?2N=~-Xch#;6&j >9r_TK#XZ|mlt(B<K/Vh OT#KQdCֶY<gɉ uUYݵxi{уTVbx#p?m'P̭ТLb<|j7O188=:b.96 "!92&9g羗3ɒ#@qj6z]8>6?<>3Iqtl,j=.t|+e)FL+JR|gڵ+ReB2&(^ 㠅3rP^$?8BvԲDDsϢ…{X[Sk9( ' _ҷ:ɬ+}0NGf?ߺ\&gA^x)l>Y V7uw U JԔ:~lo5 SҭOM΄prW@ z~/ʣ~ګ(dg S qk@[k4YH"FHpX- eB)]@3"1>9#d,"I.kj #-Y.xC!FkmZq涆KRѡ>ڌQ@h@(0ڠc_+ dY?*bBhސyV熺;K^x1\l%KrGu\unLuncǎQ*:p@OOOS\vm ܪe+Rq4,ߺ1hz8RFڄQ m󢆕틶zSؔL`mĸ ӻv9ta|P1qz,0i u$].JBH66Ak7`#b EH[s`#0);F2yKS9k1"cόmmoolhhݺkAe -,V$LNm8BrU[ Mgw<|UVx;3cg+#g{,}k Xer/(ͤpH;VJU/]ԾWEfRq96bPhmkik8>|Ô']_'ܳlϏnf l`m25(%' D497ÏL,[b}.E,F qFbI:trёkڸn5f箝gsr^=Yԓ?tf@cU0+-9ڟJ7L@ݔf, \淼azjDאԀ& Bgc>}-ՉD8Ulq|o<;32hrt&l|Y,,B߹m[6'~mNMOBh< RAh* F2*Kł'&әQI ZZZ;v9I>8}cU (3M7%.{ 6:<'DMuɥ1.|:zD^&g6lCܼv޿gAB&j;@L6 R\QdAlBX]N}^kͅ3U=}0ӓL~;ύrϥgZ,IRYgҥ̚kWL.2*j3ꮮBEzT2rn,YP(ck50U!9'ZC?`3 H1ZKCp|"{;6BJ+(dw{ pX1`Ӧm]ԑ?W\|ٱClؼ)b# p\P!AF<JTijn,Y|0?Oץ}'Tՠ臥@HY*ZC @]O'a>0Dbۺx,UM02DAkeɔ)r8J`@ ܷHUO qBfh5m9 9" 3rb-g&VZpdBZR|iܖGFv=\\e&ƑlO3ӳRsׂLT׬]bneh12$j-M@pp V5}!!qawÂ.c5v-4pɅȦF"Ye\\s7ޜdTnhcb82';DeOZ[+``)i9st` 42GgҲ7u>UȃXiQ@5`) p;ZZG'yXgbb;3"%A w\OšlDh_«x]bX[DeP@GVCf$ o:;>9>>Vga9 HL/}P.-_׽˷/lg&? {3:2[_x !00TDR:z k=x~{3u< cgqL0 h}߰bWUp~1?a妦eKm`8^zǿ 8!rf A1;UpP+'+ctm޼}r̯:ssznL/g~Z;Zc:`}/_]qGTaLdd#c8Hh-"#z dp`2;ȝ ʬCGO #DfIq {6lhƍ-M |{O޲qÆkJG--~2֚HSZEW.jimOgRy֑T-,9!8Hzz90"Q cאY_Z!pdaB@hCe380TH 7Kh}o{[};vGK/[|uX-*:M>80{^EdeQq,\9gT )ٖiKכmݘ3⻟{_RG@6 ₥?o®.^6hm57mݾ mZDam9`m1y"GE4޹\L(M%]&Mຊ1h_bwh.ݑ%|>%B sY9-Mm]ArFGR>rP2]ءNf3֯g{z^~ljvl& HY.1C:2EpA Gm2din*9 ؜'"S%E r :j4XCFX`ѳY ##mmic~:%Jh\ö,q`dk!ےsRĩCy^iMhiFE~Cjպ VBC{cck% 5051=60rxB 23Ģɢ-:&͵T<3>5|avrjoL_쳳GOHJJSL ]`kbhd`lldthAyca&;942|ɇ:)DF*COl\ʖTKD33S繛^T0,gSD"-ϥqgm1;"++zs,6˽7_}㍠Mo):[v>ܾ}/0K&d*0X/"&f< ٮ%=3cӓ3 ٺс?<<(q>$DyOShCʝrr||iӂM6twN g&$E=sz#7\z_y~rZ?}^N6 W]~IkʫSS#$]ODRG`4Jj_UO稒$p$mmjezv<_)eͯ) '{ Ħ=S۷mte"*kмy" >XזyG\??+|.Nn;GFŲ k2&s!8bM%o)ec B&>0=sADßԗy`"Dʈ_o lɚr}]a¥߽w~+lok53i_ n(nA:!5ƀyʐ$$dkv0ʴr ;zf'g>}]T*  ,VcRόN}+(i޳Ʉ=d OXP}-Al@i)R*VP?}ypp EKW*LfKkPѰ97Jd K?A\k0Dy nxN5L:Zp3MMMtTDPJ'=qcca4hv>y:͋,-` d-Ѽ ;PmaVCjBfJRzQk%:QXD婩anMD #6$L&I߷`+yKs`ZHcfuWxCB]Laiњ+tfLDCR [ֶW*Awuƛ6]'?ԩ!cdV|VpUc#thn ZZ|$A 5*Qy#E豓{w:dbqL>(\~~/Td. FWGŋ#, )ƋYƹ ch ZfQp|?S)o?Yx !!WK9%b-k~W80KhPH{q5Ls!E!9am75r=?&dUHfM7jokoqK fTr]߽6V`(3ɬ 4 "͈XDN< >e mi<~T*){K̳d3ڛn!_ҐL ˅Y7I4' ɿ b\Ѻ헸\2eLP@4R=x;"O>-Cxx;6_]&):7;w57䮸d]}?\lTl%Supji̙ ~g՟O$?{rrؙ 4IeZ;JefzTeLC+oXؚZB~ױ5~u~% RefҮ٬w-;=ډgΌN+T!ӻv<~Ps]+7-}?Q?ډy;{{+tvv64~ҵ 0GMOL MME5,0#$aN)9R rb܂bRfKw끽z J Dˆڛo{k/]\O4?vdӏnܴLBytԱ+VerO[vʕK@ 87@f4A%S⒯6CDc-3D: &t5!q <ME;?̓'97Xc .t_>ӈ")WV<'ۚdślX+.4s;C8@‘aP|3Ghͅ*48^c".|Tcsn 'Is舵v_HZ*(=׻",p esq\epVjzjPWM%_y L]Q"c$=wE@-ZԶfŚb9~'`. °Z- 4G7A-h;v}}ƶP(LKAM]|e`( '5~$_aFJ$  +#ͭ8-\P4mK/,H:D3s b] *7~@XZӾfIt#"ϳ|Lhdcb~GМ@`TD,0~'d6܂078 G)k溋۽:ULM(U~+_u흁p|f6B헭г@uΖGN>kT0Z `DGOO\M> "1 "YVsdL#SJIP\[U'Ne˖ Vk}0vD+Vh,"C/;rd)+ru]7htPNd=Ͻ@;wkwqzJr% 43 X12h˄R;gw^'v?LPH m'}ɄyK}Cs]yOd^{s5+ ٦FQ¾} th˗)MeI^zsu{\e}|Ҙ.暈D?4ќ8WK}'{ON  hFze#T±ř0P. Ra54da?@ozSz)oph1yvtiz5[IUB $K8ǐ!&fsLG@|lC/mjj3NNjG,>]]Nw}?zuC, 1,L]r 퍥 նk"CyÚ|McasB Qk`D0L)E$$ rI6tup֖x)U6S8j8WX|ѣq%@ws t1_p}KKejhNO?y2E+_s57|M@4EŽWpgN;oǶ&0WL+ b!8@\,?;7~glb7bd'5{DƂ00}|/f/~%]6=3nˢoY 01(]g|8(Cɸ<\ehFbp+QdNѣ''w1`ɔL %Pd&e f+%S[jr>ghL&.YqoožRС6j߲umC*!|??!3s^%;W֚ާC#>0{{`TĹ`řʮGy ђvO!MwL\,'Ƨ %7L8OgJyWp|ʭ+?OjXᳫpkW_|z4Nۂ.DhmMV|\cx~a`pVlXwyoozO<Ȥ-VtIg8'' Ϝ9526'rLĆ[ڳM9tY5PKM$DF j=.5x>H8m z0 h8N 1C4'{nHT[Eg[]axЉûώN:$m >)2C}͍q1 Z`J)UDJHa:c:( QJ]߭oo^elpRadb,vg+0҆[K]S iƹړhښ4Ό3]p.!ek 3SB"x%ی No۲k岐J- W[cBőkO?5:5Ν{R/Zo~2ះ'-xxVښ?ڶ!G_(B܉p=#'uXt3)3dj5{/ @{(4QXP33\.є d0pkဃ& gVٰH.$,5d:/oNw.ر{wUUs: 5.WYKT]{>ȓ;v *,8![Z[L)p .r^@g'Gźڳ~W^s~ɀ, IZ`@Ā"GƷ;FZs/ZHıR8K1ȠF"D%K_ޱ{.IU-`р`\#@UFv֋Y]b0fZÏ?ϭ#[C.*(bْznEڻ[Г-9W-ƒ'8`!_`&[w嗬[>oܧCd<ܚ5R c{"o?Uы:W:vTO/~|c &NLA9~jjb3Nhk Q/Cmc,0M07bǦ^]]SJPȘLZKz: Vէ=cUe Dwue6nZ_ߐz_<>75?8;6ys9W ˀ`PUizj `BqSS"ȓNLhrȂV) YD0=εMn" -w\u+.zgIzzJnYг[n@2F[ H5KK6HaaX)i dR+%#=H `ksT2V*\NS2)7]@@?"Vz;&yCD @,vEŸ1J/؈zEF;XdZsesc\*jY肵 R9Wj4* O;!XWH:U8|<;PH`X1dNX H*jKBJ E U3bR풞e]zLJ9 &P6[G 갢FjEg''' )O8L].sE=x@ ZYd+C PB8ǎ q2p||i>;\)՘0| .GS}Ώ>=s}- &V85a.asK$bQtd M''Ϝu! QP]tqﱣ3N*BJr0k765fΌuk>gboaꕲ$1OuGH 2ƭZ",JpTDG[[F=;XmYOx56)kK-{th_|S<+ +Q84:ڒlXАR$Dāޓaqk/쪋Hp9d"&A,jblӏ|=ف GAh@8fʳA4PN PV¡OC!8> TQIr,WBpd-]yeP(2䱵<' l,p2lƵ|[}7 J8#Op,Jm7~u7_ҙimĘJvlpȁ#CC D¢97踓] V-Zx՗~a*gFlݲx`VY bTh 2rX*_Ə׮K+qgMÞ, &NJ-HDsPKZ5%ЖǏB$ss~GR8JS)-؄ _w /Z*gZ76]tvv\Vqvvv64եi@uui("맯&4V3@.8֜,s҅Yx.YOu0WJǶo(8 Bj@0ZHaue[6 R`3HĀ(*,E"Ɗj83K1ϿR'X~Uq4֐j#.}Ɵo8c41#^ t^X@Dq]D{Ow'wVJBD@eJƢ8GNNNNNp/ѵhI.!.B~;4l5ָkZ/8wzBMK ܸ"FI>2k#~dpdl jk'qMqE|, [ۆ:w/o87fB;1ļo6 5#n  $!ҡ#n]&_.gsN֩Fu,A)"C$CL ҆ &GN\p4&&uI:wi d+d*j{t%(Fq3,޾SZIgccSi1eA9B"].Gt~gY(\u奙l*5״u3gF&ie>?9>AH9Cg% k(,̞}DQ) hbZYҒt>p^B\=wݵj7nV*Afҹr|rG{DU0s|vJ𖋷/]å[nt8,9gZd:ө40_s7g ̀Al]˶ JK;nL̠lMWfS2-7%W}`RtL`LКkK.;z@CzW]uEsg3y7o҂Eh9@h#w~0&Ɔ\RJ\аiMq'O|w=;2015(j[Y#-$hH&׭[m$߱y QA|˭}[nY܂2  V A-Y.KȱQppjuk7 (SS#1Nr1Gz&Scznixɥ/xKO=B^sݑSv-i~e HcBHFzŒ&1`*F3-tY &.bPHcq`"̉$Wijl+VKԎOd WwPR)pkVL8LX* J#r[55hѪlT zLyu9"LRN2 akVfÃ@K]_՟w/:w\I5h.\ȹ yx?ZHd'і/u49zk;W7:;+21h6Dy/&WUlk_8mnMq4'2~!RdKP)%ÚʾV36gbJd2ΘqI~GAJ` 4&Ԗ,1p:~ θ'Zc֠=b;5:ؖ2\Cq>|ђ\:;=)?)SA}gi٩|aj(u&i r#(9@QS:$(;u27c2Zr T )Dlqn 8R 66|^r5XNJǝ=v ,R:Vֵ50X5egF>HxScIoFhon_}EKǟ?314O477p5KZlWSj"`qR\S/6ڑAbD@PG}SK:n6p]';L&nV*XqZsDC$(xjs)zO7f R>Nvquvۭ/[ּa&'mA Ps]8H뗿RTw8e5[6v.mDK`m/ |s_9uomǏf@Ut/rSz "Lo^^߾'01f\[mGBw}=*)/Zk[|p4x5$t>78:92>YN tʥKa9'y-yu@"Z$5a"cv3 6Z.F FC!,rPcSA5F} *bRDVcN 3[7۵sOXBCF4Q' 7@I&v$ڛ ,ME=or|߸쎟ؙU,?Ќ9aIc`5us+v/47WѩuYsɀ e=> qplZ .kil:~konj̎ $2b@dV^H$Cw\|Ŋ/벟\XD wf~z}~@R(,1lCS4$rdgO+rC%W<ݹI2dqѪ ܝ_u+VTw,\08w8gXs`92Nq2 S h1WBBBb—uZT9+x,]emԶ D~N Vi(ڋrNЦR u kYӝMpR5*x(_~ة}>wha[[1 ?a8L2yDvtdPXr ͝-]mn7ZmeB`+Pa#DDh.Zxq7(тW_+ g 'OQCOi&ݪ XPٸFH{YBq G=4:0=w7Qx bA *۹JkY?8Q @JSZЃB>:sf͔5>Dt~% uǎF+ #D@RtBuԢjX=קPY UΎ@ DdA{Lg~>f<3ֺS,r {wSu/Xjw[CVuv+@sFc =Q.X,Rĉ4 FR̞+OؾpIx;6NqIi}Ȑm)gv–:ώ Erm$[\;.'`R?S6?>‚y1Nd/Z<=sW_}e>=h]Xp'ts9\ g)/ _3T-[J?-Vo]t몥JF'&F2a5sh9QLu<7Je#d)0Z rj@ |Oy{O>p#&]}E᱓s mt3&lͅֆ@ 9"ha`HQ]p") O?x;pbfA*r@*0G:V(WgGLP)ʵud684pkMG5-|kh U:Hґ\ayPe*bJ3Du% Ƹ(GvJ|[eM6߰墆J3kۊ?dt-à +d Ʀ󥩂S ՘kk:fDsbR6643rf,\b̔Ԓe+'&*a;V0L{"ݝӽTrll|ҥd2==E9 |Sg^:˗-y{qMrT]Ɗ(VhTlx~z/յ4:/Y;s,iMJ5҆{f)MĠ2 ohhlS[K8 xdwם3V,:N!"bUD(M`,J9Kfԩ/}rX BZ7`[+?w.lc~>=Z*)L(2]Ə|nI4H2RrV#AƘXck,b`\ - `#A߫ s#̹ZșUP(7u,^X_56XL=Տ>2'XXӵ#HV[kiH9s%)m"={{0Q2T5Q[щɑɩt^:Hp=\/S!լu9xȕFHt y3C~dbi#QXRQPgJu ɗGk*++b%M-ɫө8*ZKT#ྸ 伒 3+dSDj>#j4ؾseNW!ejc+#j+!ryJqIB\!8֨7f'<!B $ $F\j\lʆ3K3` 0 xwwmJV*Uwl\,G'O>A[GGNMMzL' qo\/ B:q=U)Cw嵐iYxw "m"q-"KKs9ko/D&_tJ!AN<! ZTYwڮ۳b%q:)׫3sB4A:֪pΝ>9|, 9JO="q?_*]ηMmY@Iu<8I9C` y_?Շ3<}`B[wH`#342$cFT(pt'loxǻu˫nZ7pA:,Iud11bmoƇΜ>O)jl3ĬD8F09v.V|t|ᑧN4-Nd!\29ejڷ=/uvz͓VS̥=i[n.e5;:wG.dN_Z q ڬWGLŢҴ+" k9( ܀9)|i|r6!+d|򩻮)4s[ KkaFUĭ,-2Gbۚ;z埢cYڒȸt KszLڸ\8139Zh+k1p\:*L3gO; ,pd2 )y&pxᇏS|UVp:ڻE\Uq591Jd4"7&@"ߡ Eo7 GaA}_-$kuj{V aT*iZzƍ}3ucoe!!-0xxqfFXaS[w/pqȈ7Ax%tknmc_B֞1b \ N/_Y[Z(ѱL-- Orb6LⱱB >rijJ5^w[v팭I d\:" ͖KJ%RDž !RZ-ADž(\ I"<%I*Hyt*Z+ 0a2c#)J"HI AB01K$=]mT!sds]1*ٓFΎ~@Z+D1n <'l616?TV/n udz$#Fi Ep"VIIrDH16R93U>?Ro"V٩uܱF.b:FKZq. 78_]$-N5.DX x,>r%bҕRaѲH+$WJOdn&u'I .]]}ݝ`)QfqTf3 yi q:w7o?wWGGg&|ދFSىSc%6DL(@bۚ ܿ;ReD0B!bnbA=53h]7$H8=;4_ʹ|߮@zI:] 30@FP/׵J8-8gGto1ݙ|8>R) fV/I9d2l.ʝvlټRey05w&d]<>4zjxgθ^n;ʧ]T9/ՙ/NTY5>\T (x F'ˀLЕ+#/3է}R.u彷mI^oeu3f؉lOAK`5XsI䲚`\@!6Ap,X$֯^[̋Lvywoqog 1LW]]?z!^"#ڃR]-U`P0"BDžDQf&GJ8sΗ.:(ˢdD5-\mff&KF4/%O L[[HHp)D$._Moݹin7:|_w3xh2pIs RgВ+eN8R !c_f|.wcbfm/NB)SƂdnut> v4w]C:ٵk;"ڵEI={];v7B>h8byz;2?0.aȐ!"~N9'u$l!0 WA'8I)kz|N;]fas}ϓY)Scslc~AR_sSߺ~';EFx*73h%¶1[?j$U刣F$VH BE.᦬|[_/|C*+?15}Jpӏ<`x.WlOgGq}wl"N` 7s7(㮞)(J|י\d|Ri%@ZfjT:mn2)ǕB-Q4=3l=w6ff& bjT.NGǚj֨7֮-:\\ko%**DMGe:j:RbK,"֪U)d3n\"!sUB(ؖA.}R8z9{ܩGF Wk F(Kt8eʎF%F騴`6ruvߵ)F2'f-,,rZ)ItZ]r`R:FN9n1WdFC0]Ar|6qݰ Rqju k=# ?ck6UCS㓓33 !kj0Z"}޾k{@wI+EG:hU]OTUrGS-5k.ȳ3ܘ5=ݽ]J \! AYj.% V3,I҂ǥ\JgPDK48<1ca5Z1rq"󽴎ښKH6q}__=9|%O98;'Bh5xpUH3ڀtQL@QFkuwmn 9!`Ȑ_̗,,m?w #jr\ˉKûeݍ`,Xb%]Y/Vk׳*j/fS_scGr)|?|ovlOg>TdAe ]{l{L zp.{%&0,n$gO !\}94~Jh#ttdtljbrhH ɥŅt]%e,ҋRqu8ɥ\-yDn{/_Yep{ @GK:y\e2!JiDkafqe1Ddr3L2ٳg>OU44<[Ȑ1Ŧ h+v=c##Sl&%DWO|p7g2i;>>vmov׮:"8JUbW5[w/]]{ڳƒm!3z.V*:!r R ̠)#.7X'p͸Ʉ$2uhT `"˴]k,6 /_=m3#kz~Bsg׬T{m)DMV3bqR$8HA2M DNwmvm ^i8X#A2lԚWH"=?;/{^w1qiOP2Vۋ<* m5 .![1A=wqƸ 80ƵRq,= D+ӿ:/m9ܱtf M Y9ojt6jaAi<埐^u?G$n|d(vGO}z->Z1(ťAY5q:`Zp(L Wt jH2\9}jdLӨgoYur`@{g{WZLrkc@cj9BsPqb[1w 03yZ֦}?B"߿^zE |YB!;Zm1* csYD 2OXtYBHք:4F$6\ 4T'-k.ұ dc=S 3Ј1LRO 4-X"jDXӌ幒h/dܖgg. ԗ g܀ѵxP k$uf;th.ÜO%1 $vn5:MdV #vKhK\1`7'FDI"YFsاrűCİ#ڿ9*n:(e^{ d9B"@!q꣟#|Z?yOoܵglN^47Mt,hcT!Z˗R8RJcmE*:QdEEܱY(U(f(e- 3Ԗ:Qզ {z{z˥R\ٽsO*卌_͘*( ?{)$K_'?(/%%B\v?/ᾕPnWe}bc\Ge=1I\o6&%q4rd >/"_>8{??:}/%<AYݚ8K͋]r[8kwZ`4x`#X/dEFg?xIѤTn8s~᳧Ntv,f \-*c|;L2922VJ loO3gY֖W4OX4Rd ɥ/ R)il8q"d`[b!<)!$AD"Q\k68_6jj۶dž-The4Nb5T4bp5]]"0<$N\ 'R#LKek4ejtǣ##ͤB0mtcU^㚥qXk *BPH4,k1(t;tlJa$d8$ `ʸʕ4|K8=p}MRҊ,!Xl=[3n "9 RJGG՚cC'=kQGo{MXh/@J`}w0cBd-hƣ#=mBmnZ 袛XN%ĄtU' JBH8HUDŔLJmmX)?JsAH'e8(R1Ȓj aqɘL06޳7J[^85:4:Tdzff 1\m6IMOYXVkq93A.Η z_wO&  v%Yk5Hr#O;u̅b:q-c熦7nR̷;xK?]eaRzI =vK 7ԑT{s k禧Un\QfbWwZΛ|G=3=H@Ivv܆m.@h Wg[ؼk{miMN6.UJgfE"#| JOzvm]/[:vm 09g{>ԍ@Hת gOT '=wVK`.a<`B1 IGn޲Y{ٕ:Yb\sPG"Ijb8\ @qDbK7ZS)ΙR 1D $Ce#@"jҊ\]Q9nb[GqFA!WUT( 8gZE^>aAK(ItE֍+3j,c. f&ݰq}g{g[!?,e/X`Bÿů{wj,tdF@SG['6#!!#I҄旿roFn486 P 襋v1ƶMkwnۼ07O~շݰgML$W_7ݑN>ؑJ|CwmqƖ` b *b"ؖ/B ƙ'gAWK<-z 0.p_C>L̀]ޑ)vUmS\ 6y)܀䇃/\šXu(0k:9޾5oz]n^sz!p@Pf…q9N6pLV~ySCu8&sI$t]7f#$@!T]{Rs'>L ,@FԐ+ri5}+ľ]IQ5wؖDgx ss3펓'/BXŜI^/Mbw4:7|/T;_~iFW@W15C!$ `!$aR@+W< xU[q;?K4kHa?l9°V6 ^Af- 8Ccr%K#`N kUaMzYhϴzٻo^_[ʥʯ,V> |η_FZDjBp bB("ȹ`b~_;23#}t6d}٥E10? Bgr`\"1bⴜB zZ$!ET#r=k#)5_M8hHrv%f[+ę9ȶÁ.^(ð2 ,09"qD䲘iƉI\u礫$V0&UՄQD@U6?1400|a^ixN,#Ĝ5v_:4_ୁӈ'>͹x)θshz܌ꌑ&eH #Hcaqlo˛#fQJa -|y1CQ37TK bW0?=8$Mx[!XEKB#$I\'t\:?l :bg#ՒN LX פ'^7zms=eKD%xH([g60c45;JRr`w{sL#JK#S3L8ʒ6Jbߟ$h1 "$n [l5bC7'|f0l8̘s]]흒K#e$cq4ju욝f \3_MX*AfG>\r%UO{ k2hH;(08x<=?[=f&G$*rr` -&l"8ՆbN>_|h^C4pP (]9_f=wl[ϟ}.Bg5 K=\ \64KRD%/`Ѿ_<\󐖅k[M-KU}ә\ܞRFe`:/aI[k7l(- lu%.g_ o.iv %j*dQ#@Kd@i,#4-ϕ(0RXJLUFŹg'fX At&V,vwK(O,L/U"]c2ͤ J # Db Fk-e " hU:T} Xmx9؄t"0 ƀa˪8~X KgϞ-ۗ0A3fbp~mg=w9y绖 ѨISȼѩy6;3_ևG疖D!2k " dpW;"Bʼ+0CkhqQi,us鎮ΩDEQíI#nf&GNjٝrnٜ)JB8֑N8rI|_- sz=((42X-g)Ka,N8пyj\(fS& !hk*KgQw/UʮYs[gZ0ȦnJKzh& \scr4Eh{Q nyN\65AD[?~L;]H,xHFI&+ijRFy2!Vo&l~nX=az S Z EC*0Y/Ͷe{wqF77n};ܳo;;<5Nj4<<566DžRb>qJe@Wd|ŨG@`A(J"& 3.lc-+F$r|~ztjfta ]wpizڮ,ta]w@wtMܑsgF_n<Bdlim*٩Ʌ9!s,(BB3-!kIpqߑK>ʵ0p42j D06of<'m۶ErMfð13*y2Nq:.7F0^3]-Ҁ^Ѭ+CKQ_.>*ޡ J1D22 2J=wh-Gn$`41fkŎn!9Z_w׽.y#-6=O=΍|}~3|kmju%@ KKo媈q `58Yj9 "տqyuk6}yI$˄\JjD3,<l#sb9dRhZb'^-w/Qr\Y|AzAvŏ###333rY0 YT"k "Cw5Z\ԥ+]O ,EKB#.5f* zKf ]m]]ݾ;BDQlF*#Jeo@lk7X0pj0fiT8m!q!129'c9بZ+Xǰ%ə̗OfB\#޾jzXf-"?q䩳L8(" @- M`f`2(|TBG "C߽h h.ft+5KMZd;vI$Zs m`5X@r"kkڕܩS'Ɵy*rhVk &Bv/1 -69'%<SO0@K& C7|6 N XPO7kJCO=W6EP gV|4ٽcǿ:p0 hhP0H:{N< ??q7LO]\J<$lڼ fXMh]¢cSS^qV2@9dCm-39zgQZrRMw S+&aBwۖnf~7 Ь_٪Wx E4P^*5 ɤ1RuΩ,aY~=50cA4 "p׵Wu/LHZ`@ZK7BƤ)NW|egk/d8 k5ZHᅙNb)F+l'vyWL ĸdL2ތb8J c D} X6`#Xq`߁۶ ζ|YOsj<33fmOWgGOow[[> Oo4{d͚^mCj7(K"PCpd gWH~^*@BHC&I.`0Lq46_֐"j>!`_bE'@ +jMG+~*+`@ X[vfPR{徾k;?aoljxvnޱe󞝠4H,Ce{%N|p Hqp}6/~s_:}TZ=u蔟.t~ZN_u_YsC0(b.\cZcq]yEWt6}adXH<:zu;`68J%$w!2H!kA'O>\F۷o{ǻvׂQ J沦c:qs>>⬿#w`k:zݬrlfb#r!cԚ}S(nh>s i+d=[ٷqӚbبmyN}:'3|Ҫl4{Ǿ㍄#HXk4-B,-KꗒD$\ނApN-1VK ʏRÓO;سf @gnv z AwGVSk  Tk2\]_q _!$ˁ? R[_W6 ڰ\.-Y'FeVȗe2L͋|PȄ22d% jH e!ː!5zf_}ն-[TqQ|{{֮]ݱ}}}aqlFa:U8x'JG;zD^*2FD\x-I"^!2P8RD-'%A[ee5C@ƨ`5Vx\ԃT&Sʀb8)ۼ.q?y?{ڹK_`/^&fy;A20X%\Lǎ+u((u ~MGz^|k,7M̀UJf]UJgn[sͻI\ecˈZ".K{^.^"ǯK֨\uַ- O"JBkZ\FraYɢe Z] F0$i?oIwoa ϳZRN?c$NEzG8Kb{~-Sntvq47\s$ޕ5/N/L#|,Fam;ڻ.Vi.e~IV{/-M? B2*64JR)!bfրix9Q(D-yI{BطMm{ge#s0-% ߯ekh|a@Ξ9}4keLe?0iٻ{hKowgxl\:t B&Չp$JGE Ђ`!;}F:wZmtuً'3hfE˦rTk65q" (=֮\3PȺZl֪e&k 26 *'t'O\8ԩ*I5SH[okb(jup`-gyV]zݯٟaQCg.M0˺cƉƴ!m/k2z٣s3#`slKFXWSSN=Doݻgۛ|c{Wn|zrS.H\!0ku-  DJƆϧ<7rE'A,.-ja\5 m 1`+2ZTʖf&HB P%cDH 4񡳣,pI굥FS,H'leۀZ0JDO|Q'c|_߹cώJ}iöpk54!o+m_YkHe&\} `ছ0 r1>;?25R 㥰d[Lv߽y+zpj [R[_x XK6`mذa3cuYժm87-XBv \AМ, * (R*uU.F$B q8ct;ټy=<69>y-֭o(tIDܜɤ8:vlҜ0pSNΟ9lVwսsR9,+8ltwj 熎-,Mdիa7Ėw؂D P/gS1E y@&2LbZaZ/.O.Tg/-*7UKJqb#/L:YJ įܸu dV Ba|ȃ_yǞ1QÅ$pM}9;0$R)A+s(rQz[GG?}plA'p 1B۔qЮX }QcoMcS[Jӌ X')!-N ϟ)/6; - Lⰾh@ pٷ&2yam 8X .LMJ u9d_*$uҜڽ?OX<\nmKEs`{;$eK?dIWu9P)!ǟbkox[ޜ+^ڛ]D όNVKF R z:kxip88NtZRJK@qhm pL&?5|өLЅ%(븉=mPIͱᎎ"]fM*qs^y3ݵk[t:hV͙U_s٢k~:ekeđd "-#|.+b[.le4yyhw 7h(mܼt~/e f}@OOwnؽuc>0]̬=Ĺ8yO|?e/,i l<2Z>Zu'Cr0p2{44FHPFgO/9%9̜R^2UX+.Rw5;3[.ۊm33sA7zZ-*YdD`YUZX3ùJiCȅ,:ed::*ݵf}t1ݷgBp=[QqpBvf:vFըcma5Cg# a!4uP>Ti./!""C3,=y$9y`@@[ԑcPBBޚ\)W9@ Á֜llfR}nݳ}dnۋ[{~!@UnU\ mQ\;:~Jtd oӚ܅G?}itZO?c?7sf\M=\,1˓5H$F%˦6\iߏ`,h:5_? x33s%4QK\oTj֒_ TZ4td1JGB#Ǟ^h,8m޴?M;6EZV @dVjPSҮ~xgf3^AIڢD(-}x~yJ- dL MfΥ_꛽[n.8Tsb_bb# Yk8r#??bk*ܸQ&V5(]a9βz&1xY"ґdA`US3 Q2s"eܴ-bLqF:yjgOA·rW\幘/we׬)v" Z' Sb`ܣ?\gl _ǀg`t%-Vz=lɚ@:2MDHWh!c[41[pK=y?@h8-~ ĒoRf}O{"2LѨvww_{aDv…-[X@g2H#ls4VQ \u zhXk5@ ,k!"c i<_7EoDV*bR4/֫ W"Kh K)2 g-n6]JeNڂ4vcrv:r\1QZ\}kkSh em^´<[~J+. ghlot堑-8q}?XJL:~#яP!޶g X\枬.u>[ 缮3g@#2: z=ՙt%.+},#j˽S$ #Za5 pDI7FPJ eB;:7o~7u.e{ү֩ىt>GBARpŸh.ڰ6q.E ͅf5ko "΀YЉ@}Oz>JHR _wlbUk@; ;y i'% nڸv`…qFՈ3AjӆAW2Sͧ@`&#k@˒rj'o ZF\sӁf#g/,κH t% yxJ$d6͝=s?#a;]oپiזsw ρJZ(  L+$c9mޟ5 zSg'oUW}5-y Ro.>ElviM( M wȏs5`S'>Z@{g~ vOۑjlt( K&A 9B6幕J9ߖ3qrpOuț|q>6=?ӧ~鑑12XwOoo g}<ո}ӟڲuW *Bvո&8I_uSi0\zn^g?{L:˙>qzF`@Gk ^7*l6N-tlݾ7ܵh9u+#(B,1_-NOM}ͅ銩 vvd8' td(xg&>ŧ}Hd5rJq$N}n8|`~~Қp]6Hgg[5qg)aYC5dZdRF+ÀG&q-bZh!c@,đr`A Vb: fs_>=25[4T`S}M;T+ȥ=kC|Z9q%T[Jlg6zIظ']Wx 9lVnGt\B \pȬ6Zn .[Q78 j~șO|޹E%0$-k_W/\W.ǁuU2癧t'󾈒;F@j^HQFVo+ !מ.A ^Oã<~|dl0QXj4{>xڷt6 G:ѥzy5K`#MUo6rz׬q$Z=\•ajZ&t1 b`XmIy.ddxhbRc,2 ۷ᦛ6Oӱg̕f;B*)R|wEA>{ bB1;:3# rd 秲)`9UT%-g[^svJ/,lޭj- Ww =Ƚb )!ԴdT6S՚<؉qcGon}מ|͚u׽|p Y2Hĕ(,"T!?B6*g~vjjfvv~fzvzqR)dZ U7z{֬fI'ĜYDD3&M'DJ֩N>6֏ !\k$Jka"m/Yl܅Zuv0pm3{,F%jaRmDs5DxYiX" nn4 e͵Yd"" X29N͞_hx;HPX_"yϦ—2p IbH[  aiLyrGu&''<}rtll߾}!Sm$=ij[,ب,C(["iJmlf3|was炔hN<']6Z`[[eSxx 7~0 f|M]9 F%~A ʒR`Vrȸ#^ߑh7de 8ri^8dR |g p$6wWpR⍯}#Cٮ}GO9=0;yr溛ټcXܵ&F~ \tfA dcг]?57UXWGC{kHc]i|ǣ-hF} PlCWf&]I,C9#4VRFLrD&&L\W%ع'ʕiD͈Ӆb{j+l:dJCs;D6 #p^RK3D'iMt]y_kKYbt:gr&d5es "urnüREb?[[T~}n:` Y6\$=+Ֆ¦ҊΖku\^+wusd #llval۶vtu~_閛ݯn:JC[UύM>S. ?\ڨ'ͥ٥|3O=~c9\{Տ> ][0 %ĩ\6QZJ(ָ+$L3??BNԱ#ǟy9:hy{|I;|;zthfM3恵H1@_>sBg_[v'-,S[ڀn$DFT_pqiv.A*nt2[踖{LzG{+ r`PHKV~m77Վ.l|78sC.vu:IffGFw؞J$q\ނ1Ⱦkh^dk,1g~& Xd LJ/U^T~]U!CgcF)׫2Q.<_zn" E 8h?qjT!ɎNq屛~gbm~*jǕwj/e4D[Hbꦛo߶CԱZ)$>znO'wwأTDrZ0ڨ*Get {z55=6đ'z#2B:|r@f maQr6##C/KD p۷랻zk{í: 㥅e.:|tppւ1 wێVUgfKKl%l %6%#cȍ1Xp ;w}Xd`jt980@WN-3'".˃@2 JD"T6FFM[,})?$Z d#H.͆!Jqj&cc3(g}g=ϻkmp}3lwpv׺>P*)WnVO?ã^Hy؅y7:??373?38{nڷ[5,xqsd( OBppocdqa,JǕ Iu$KLp-(˘ _~P[:n6΅͆tf GJL%F:^!rs/LOO M`4gdNEL[Ui-gΏml.\ 1GRW|.Ǿ\^,my 2(ީ ]nX4xiMZɹ}&6'֑jkzq\m߰eS`X,t5GF"0q/'?~(ԕjM2}u0D%[fQQ\Z\,` iھY Wu %HE,Cٷs[{X}3m֭R\JL-pH>lݶ F k ܶ&M$Oo+}ʾߍ\Αq$w!"b1k9 bW8-" Zv`-bՇ˰w_zDV/w#"WIx̄$*Binl Lg 9q{3Orii}kvhΏrm,FmBQőR*Hg/`|3GqI=SD7,Gq%ƄPpD_ .͆&z#&E'Hf~Res`qQٖčDZa5d\E(-L^<ztB$r+F͎]3 ,rx 1H?%­VWnK9{>@{]w4W:/|y>ibaQ#0 6i;m|ӛ5`kM 8PW+V˳ߓρэo4ɩ=sG> Ci?[+ =rUJIMnLUB N?Sg(so|wؖHc.PGSO>}hg,V'?16:h43?w{: \ַyfn𵯾i5 #S~,td-AjD1(w*&icUbFbGz ]a}G#7]j IS A~b<;<747<:^7k&I0@ DDlߎ;6n㺻#=G0;~߾ݛvx3iYD25 { 3W x۶moݸ}# dXd_T/0K?0Ņtl>m|nn*DM 4 8X@sː1Dvrvafvvut;z֐r\v^8H)mٲCZ;o)tyޟDj~v|jBi?/rXG#?{RŶũ b@: FkC:Q+2cր 9_I[˶J3k‰K3/܏vT##CsK4uRIMLΝ>3qœO-U Z!%0:pMfman-sx/\}Ϯ]l,<};]k{o *z䕚g3̎Ɵ-o9EiwQ^QbkpYAH)כɞb@ {=n ]JB&r3nrڒH,3g!1}ϯ[~mv]3ݑ$L]I*^[MS-%BxYb̽]"Cրp^k ã~{Cj I㻾5]f-{8y mݖJ[n :KKaTo6mN*AђfKtFDJMw[.tpQʼn5 bd+0s~ Ů]WfsV^î dI!i.,N*x6ߦ ɁY^{./;>w^׽:y,1{y~~'غpzr.+ &npGp2M_4WtX|c}[\ץoKM[Fe| L*ƱaCsj|%V*,lQp.83fe &8t<0%O:vwJm}ɹqDʦ3y\=UAk.%[W @g;wU,х[^#U @2 З%VeP/oTJ|_SժJe(q6i)2RH"T 1A#@kYc\`X3P63'2M7s 7n>t8#/Ȯ.ro; wO;r]Nшwxw/fHJZ >Eh5CiC#88ӕfc ,4rWhx)]nIIөBȱg*`(ڎ=fGg;lRXb7K ɒ~3'0ɦ3wXiN!уЅayKgܜ+ ZfWLRH 3p>wRx\c}pD[.JwKΓ0JB`ȕcLJG'?u!#XC'չndB me_\_[I/[Ij== : ΅ac=L{ X@V rGM=}lna@  f{ۺsa3fiv~*͆zM2 /O\{q ]bXD(8Z/u;$Uͨѵ+<YB@._x_}ם.4d}_U*0o<0233 -CcSuD-.T6O %$`#C`\ 6s7UN;eR^)W^^}`pwoozxݽ]sԏ./088:<f#g캮еv9֙ z`浪-Ye[N8q\RyyʼnKcyD.,[Eb; z sSvY{g0؅#iw=gַo՚N|wWi}=ŵ6|C?sԱlWsqICt3 RV_&O|~cJ~6ZzuMLL1g {>"Qn"0 H k B=ۄYLTZު 6NPJ`&]\$3/g,NeZ] Hcp(m=?c C+-5 %7/mb:R:O;AhGj5պXX3ˊY ҭT*`(@d?1_=a߹3S[o[n͆7 c},NOkVkJ=# ,! x^f%C d.uWr=߸Eq7d]jf\G}M7]yRw#c?rٍoߺިךMS LLkzJ|!dm& Jw^t/uH*y S%65|?~kvhlY 1Τ낐cK.D1 d~`).y^ W/?GD D@FCJ ;e uW-^Yߖt\*p9#cΝ%@i;W*E;qr){C5?X>{|7~?#2 B`R},T]ӿTܙ{6]qt@FmiĎDk^GaG 6B?7<5yh ƑM*8 z]!_b 1Zw<;??Ghͤp X-z_itNб`9A8n(Vt" R77eW K"^I[ۿ8_>1@pݗ̀Wfnf/79i綠ˋTMx.P<EExAZt&-fnڳg[hR uDԊ9mfM6p~|a{k[cD8c Rxr|L9Hd!MƑ\y' 0dٌ5:SAlt@'D?' x-; XMD5[Z}_Km{ry <`VB `<@ s׾(5=39`F!e7'ޣfԵg''O-7nذiӺROA,sǎ;{wܖqؖ-tIJW=`U. )r\F7KXUz0RIk qﲡpb/Hwm+0=4*5\r 9j\)fn_"Ed?\oI.}˶~bT/.ĚU@B$ܼ2q# '~[yM$u% В X@( efZ 9|y#GoZLH-V2ܱtAx"jjXu'ju\ 1]Oɱ5H\%:#ssyXCbQ1Fd}a.=vthn٥ x quC <&x!uఉ?p\Xia!IB;W7Zj8vt<%Akk-\}=l."=yk'3IGFV:x♧K'dD85Y&9k k:]Jl -̫/L ~&t ҕa30:U*K٫w_Uce?V+*^K_^Ȥ} fݶ3#c#Şnfe8`9p WjE{덻*f9DL̔׭Y̱S_#J>7=ufb=<<:?X?308d.rFe`gժ!@81f,]ۿBd)Ehr\Ʀ /~8!ەX_fG\YZi  PF \"g8 |ȁy;O4mŚWwM\z;7+lX22$V2"H0ڵ͹_RǕ?֟K:L `@~g߿0ӵFŭŨ8d+, 4CfzX./o1mZT9l Z0EнغcEAX5j-dr>H u|~*|o Ћ< Y,Wˋ8&B[%J[ioWO5mZ@F$ݥ-ՅHGV{b4.kh;]2wb-Dh85>?J̹۞{RmJ-ڴsi4UYH5{V1L0?p ?o]̻~@p;ߺcMRؚ#VR:~+lueq EԪZeg"χ]6H\YT9wBzskڢ :qxǶuǎfwlHE7#A"XSKz|)!Z"?j9ZQ|2ů'ne0 FZȱ@d9} %"ct~pmR`ήڴjսAR  uf}7q;jdJjҕ37O:T[pd4[cYtLPyC\. XT78 /<^=F[$$m\nx7i֯.?|8ccm[7 9pq&q2Am*6EQ#zLh !/)9Nb4]Z2$@v,Jc\mZr+p6}_yM'meJUZAtJKǑ)Cܔ%6չÙBBQxyn/b??Aޑ{11Lm+_Ԃ˼T':ay? x%Rh&$fhu<]nҗ>/!GcBE!C h3[f-G MY;]4}Qi@QOZ|=cz {aqfd^_ofn ggNO{ZeA3$DnHk"ca5 䙁vh\KITگ}8Epĩoת\P7dzٮL1ϘSo)A08wRr)i 4cCwRVi2ZIӒjǶ[U.p!_ЪEAP* w'xrllo# y@*BnZT=oK 6ΕO/T˙0fߩl%ҚK `8}C]qm+dˌzh6߷r* +#! 9;D!_n]XhZ+m؉|4͖ 1ƸtcƘ(N#Ƹ);jYmbM[ eg=|EHJR.ܲ[ww3%C:fSf-phf@#Lx7] o}};߶q~v` $AːC95~683ge2qk,qh8p 8Wn8w0oxVGiduuԪ- y V]a_%V+ӳ3Bz/]]B;~ԙU6m^Kؔ։־$+˫EA.z-gE/폶0t$炈d uG̀,"9n41-n. Wz܇zj{s]2"e']vE`!DC J:4©;UI^&cؓO_O}?o_Ҁ%mč-j%".0+}CIMѩ'<=Z"J+&-JV/9[nڸv3yi4`HꋉJ9פ ! Xˀâow࡙lOwCJ+*AsAb]EDĘPđ8%2l-GI#g?~VgB?ԤǮim^?f u(m$8t;ϝJv I'&[:~]{Y70dZi 3nTJmZI3J1Fgq}۾nZ3[TTM;^Sʄi|]=w?~~C__8poFx[nyQO>{5+{&R dB֏bVUf6 fL]|+U*Ȝp֞AB ! Y>.I H#U-Z?oܱk`wmz{x6Mm0JY$56`f!~O<pARjm?Ѭ7]7C@&'g7o߷-*ՑA3Hn&G(LZƓTqlj㘨c"%$Vi-7c!E=ē '~vZwkQ feftPwWWWW~b!__\e]{v5\TU_f]__WUClV[ЀV8hVP.tXERE-'#k SFcK O- 08϶% 1ɸ3||[ѿurq%|_pm:t>ne:Ujdd2,JϊqKըTI?9qrfݶaK Bi;`^Wޣ/9d3{?l5I{x0eG{lL$&F\rDX0HYFo`]M;{ׂ.E)}HUS0rI1%f +hקhykX\(7J%mJmٳ}VYHsݮZc6ψޞ]dj,^$IBFc֪ UZcoۮGKnG)=r27_ټw$"*zg}.*^>Q*6&+khiylٓI3kQZ=xئ-딎W۲uSxV ǭ#\?Mm[73gmJ'ɩc;GzzVoL-n=9zv/9RMH59pl@iaL^ޒμp .tNO#Eǀ 0, w2姏 ;^x i:nOܺmc>kBa :$s6Lk{9c\77}?3{n ~XwȒ4u,)5[eYaLuĄ Y 5GW_5c|q''#Z&%%dȄFʴ蔵F$6Uɩi]b7-)+!àН=t`T{|Y{"8eVn-obL.Jan&4-ΕwIJH#czim-kq3) řVAhř E.MĖ&C&=3Fb>#}`5r`U[=wzlxdBwqЬ;w]c7BiۊVly,8*sGzNnW qaRK{ tJenXGr}!P\iHh`vbG6[?dGQ%?y%0qbnē/{xPo4ùkwj 3}}=By1|uMRY=ݾuSk\9ӓ}Qb!~/˓s?FڢE逵h0UMN$͈G`gEFA[XFp/ZJ!LX4ϞQ~'{y INZ˅9loB6`7nXԩfw.|W:Z%S֘kVmܴlHu-WzIכV=,|d!^)Dgkc8ZKeYe-Yd-ђjEмh P`,i$bwG8ϗV0_}Ip-.y?.:5|9WňKy)zEk.5J g3p4Ǖ4F~7թӿ;#Olom+wkjإ^ v30Ug'΁ =EmH:hsì#= +3'ʻn](L9y >9?7 % Pڱq;[^ 2 ;xș3lٜ@Y*(E0U/o͗5y]X(/,ƕv] ~{FqڹmuGMwCVG)sre4،9K4er]\d&&hPp86i=႓1ZȚѦs}$qB.?~HOOWFkׯ-tjua[/t~`" dMjU7mh'O:5~J nP sZ;Z=̄tqA6B|6b z.R21t8) DWΕ:&xȹV|(K&JF6i,0aqW?$o|fbn߶mRַuXczz3`bb<̄7rCp(`5qcdE[d|*"0 "JJ,~+V^Jbݲօ$v89%2 R}`NO7ԬT'xXwFQlB{q~7 0)ptD'k6=o5WU#*aX[S 1IdIN5] nBZUq uz;XuJ#8M97j.#\"q_5\"HGPFR J" ĉNҪtzK#@5||c!x_ϖM׍ ŬZŹlZ)F,zA0H]Ђ{!Dp$}}k׀hpWC*P2k޲O9]ojӺ-/}^Ggo3oWk6GS'96A,!9`As&)TY5I4=y21NRsaK#ǑВ~XIj3ͅLJ8|åuv‘غ-7톼!`^W8=k%igs/RV*.9MbP_-~W٦"5M2"ﻙVSYRR '-̕Ͽ0mٹf9?ܪ :ZϘvr!p2.؁|fHiώ۶ l^u}S__;:&?.w_c7\(J!)5:ӧ'?f6_|5Hk.ӊv#Lͺ!DƅA0ԉgεbÏ/6ce@#dXmzg2iZ?6z5&.]k+ xnk_GZk_;n&WRJ9r;ZkN=s$e3I1a vͺѱaPtR0 DJK-6`>x _³@/ MbZcQGN=LI MR "# dtl 匸+p֏ne˽[wteuGO9\@qUVҪKFiOϝ=u<3AT{f g@rZd!sQx'ڒ@-ÁvlݶyJynΫJ݅s2)N)R)IMk{9/9R$tV Ti*m}W7WMWK+:Zc -Ȅpƚ$Ijdƿo< t>3fL188 g"KGj]M֌IC)>ɯFQs~/ƍnJKNr c 1 ݼ%tqatUHzygO:Br膬p2p_~{F,gȖmd 2&b\kYmջGFD@ mY✷Wr Ng& ͡^kEq2oS쾧}0H#ZB kxoLf#Nu2)%Se !1ՙjeRkCbpPf<<@`HWt =yCXxӕ;w̆v85"&7]1-rz;/|Յx=zO':NzA*jV5W(B;cEDCl#9Cd(W B}\N}ѫ^?'MZvg$2 !9yu280AFST KmͿ׎]{~wߗl䉽-H <‰RUOꀐXFTSFIxaoW_.pK"sm82[ 1?M־ݼm۶kw@h6ffO>tpvr* piBNc@h %F#|wی3`$))82Y`[NaI]E8:I>?х7\q[(ᗜK'>*߱d%I,|R%Z\.1 iB0cTժU{o:cGM6lfgۥ@R_njzRBJ6m(4E2`3MJƙ7;Wibw?lb-}~4 !2%mȟC~e{SR5JٙBh'zxh,ٹGHZn̄-Qf2q&T8wq;nGBb##q8ama9L"7b ]"alhz@dNV<CjgsbhD(fvL)z-~; gn˒}rWikgem.Ä( {C'{~ @ |-T !5D*-_\ر=M=e27T^NO 3sfw`wOwuESS~ Q+86d !oǏTpX="TnnH㶷}{p_06RJk(R1,Lb@0, RLD@. cCX?!"[<^3x./LWޒu$_zBuXJRDR۞ ND v] GQy^ǜ *"j "UHn缠G>?m1md@Lֿ=ǦĀXB6oj}-f/h@&ͅ/Q ,fW,dX"`u@0i 8uLx u"BFkPI=ٹJ_UM< R& 4ƆK=8TUl Ką,DQD#H,aR4%IjqZoF&VhzJBv@ze1`!E! Q:a/i6D33?W ;IxR6o,z3˾]gf0רǧNOl"2 Ν;8r]ZٹB>زyU/telX$ ,HGz泟s?s?{"T'`XNr9X2mT *cT-5lAh*_\?9sJ5|׿L'Pˬ8~_llb:ZJW%"krcCBHc3&I:"(N6BV 8٬sADf{wYWa,ժ \yfUt=ȓ}ݷnG)C~g@qrDȂ} e&&X IXͷ#8d1D.-62JpL?!yI榧+Ü;'OM$ס^I>AaȸF7n\CTZy^@vcG-t,`HQiɔ|ܨM8Jg`HJe$/`i"XYHspα'D`<Cr~IVknnÝ׮=Nw߲}·O<3җ "9vrغu[%SSӈu!hthXx%vHO;Ϥ̓y:@"5D.M+=X'`f Vezs8p-#=ss{ƁG{"jE֮U<(U[?^LӴJdL<638һjXqz@fԊm$9?]6ٵyƍ&(F\ZPq:{J)Rg,!Z@F-X{^&U-O?75]^4 Am: T y՞;Gj7mpq|Sޮ=5Mu8p`7[6od(^5I'MZnڼcBsbfP !u%kg'aPUKd9΃?A X4k~ÆMVI&\4HW,ncMVo:ۇ .3q CǓA1s1A4?? %5BL۫{=w-eoR;0gՠ4Rp֨ƿdMC{~ tH;8]'#+Wz e1?8ojeweY9cqtO'X,aLKGi Xp@{#c;6ߏ,s%is wk9Akj;[ҥ. f%W BDMS:vlf Ņ/}s2&GR]5LS3!q/rP*PP$f=wd鞞,zZЈ0 #ܶ3!0MuPZ˾\yٰ}}+A/X\d *, ȕ{KY;6~䖀s O}|G< P(@p CCLCmfvjrO>itfܪիK[KwwyU0Y91xo  ?Ecܱ(@\OZ:2dI4Qt`#LGCk{W$d ѤvK \ }2%]LRq,Y"Hn7~n葉ӧgfqe&s T zN?1)9 '?}g{|ÝVHG\KJbEZD&DЮ9wvvbg>;޵;̂J\h"XӶ`$Rk 2V~57 Hm΋G V%dXV%kH` pY'K`VSRݕ?srfEjѲՊ\s}j톼#ZgH-+u;q~Ø8VBH@ Kk!g Gs$&xj"dQH0J!HdD0qxWL$oF@Xho{@­|?HSL`͖cOӚJYəkaϋ5ď?wi١#vXwy$ˇ!(K-X:MC\2q6 0xwwVcг^ DV fh ]ߗ'K_ /کN YdKG+5 "` su֪(lO__^ZݍF\/?p7f .Cwy-t\]Muw|?T6hk9g+MbR͉+Q%'XdZ\*·8h傻kmVp|<$g덚qU(䥾^^ȁ~`"CKǹ$J#@udkl5}u]l.۝y|ըgurX~w+]2x$>.?W"I,q "؂&Vyg{o/]$ дxiF qѪ7w=GSg4~JںR%qy6p'(WS{<Г1nw qUW#熞s]6XqhH7 ^%!Q3}:R׿S'MWB8n  x!S_BWQz:k«7 Q@K?@`-#ƐMl7=J9B~[o>h$̢6@/0R&oYۖ\޿z⵸.r$Q"0:xSO;~fIR6H# fu{ffqpc2_g}jhUƀLy_$HNLui@+`\"̑`9DZVGYqd0:aq%J <,Zt58=U5A Q)Sr1hժgN; 쌉P۸icWwwVnqL%"?ĥ i.#"%bl9df9OD& 0)6af87ߊZYuf2qHd#J RsC>:)@9 |6j""shc4\`$1c\N-"WJwW_:`D!  h~YRJ#z!|Z>}կ}upt.pb08kS3Ǟ?-Pe"p3"JU pŒSJe s^l~ {Ǟ=zBCsdFi`9ȬHT[mhS5Wʇ 3gJ nPrq=zdÆ5[68Nah0=aLHBO$HюQ\ cu X2V J<es̑f%KkDo~pttնm ݰ@!G8'E%fM_ ijΔC-8I>2J81&g-Nf\wH굥<_E={?G}@ E1& ڱ֒>{)iKZdCH5(Mr[7m\eMn6/58W8EqILkЭVwm !.V6U0΁#ÑK^_פ~:ťA+5|0`ڂ֠5ba ]2&pHD,.TT8ǿ[C*e'N1]FFE|!o#XмA4=vg1:Vsd($k1kNXʨ\< garAkwzf* ϥc$񅃈}i9PbI0- C wIW {NȴVo-}k8 !"#\ѩUH1~Ҋg&??~lvw $#H")x63q&b$uDq_/K-O`pP:>tSO>ZZqpKiulپs ckv0"ctpA d Bx33tR&IRu1ZYkdRzڤ\J|r\)^42dmgA.'>ŊF-D!'h 0ܻj`x7Sd2c ډy&87#@BOS??''*!b9RJ{n;>VH83vvDZ殽uԊـ"\ѣ<ů>x8 U kAt k],Ǿl2̞<~weđ$Ԣb<ª֗l6Y;eذ;ӕčZfiɒц19G<+Wi@=xv m5VH u2'v%O t!t T3Dr9@FV9ƚV]CZXBP@T;sI|<21FXdL"2"{^~0d QujlXj,V#fax{j6W1ZgZzSIUJ"D+)R˛-"sc6 +b5:yl=pCO',}W:ՄM 9ժW8h5N=ќ!tZ>r`lxtͥV\H_lqy%q㶃:W 7lo#9_O<L" IYCD[SgO9\{ZpW" &K2- f'1azA)O|<* bst֙syO~]`4M| ";J u2<_AƀaDjdd1mB!wƛ, #\2[WFzfbndI(tw>lt*]K^Zpc,  wFGF:٭5J+.N !ׁHB+8c NY&Is!!z[b 7`QG֚V3 LiD+ŢznQ>||``U!2z&'9kT+?<Pb!El) 2\.V쁃{{{ΠP~v}3jԂQj496~̤)llo 8{՛<'S''](`1(nP1d|㭷0=3K+wg nZCLĉw>1fa1J JͯbF/UJvo+:_K:rUZ?st+mv]ׯWًdt!Pcr9jEƞ>u|cͶ=!$~xZ)"r]gdNtLؽv]d zZid` *?>80Q !"`G31yfBM;zؐ٠5O0:s#D ~ZB ]lRN'batہA,dE>dXvE@΀Q|%T*es0A$, X Dめ@&6 mDqHvPk$+YC;ϝ,.4ؾ?5zh;F6:h98qࠪ$qQ#P6ȡ ׉"vkpޟ|v~n3EQo||+w;Q0[>r* qoZk(^e =S{®565laV^(Gz;X7M"ƚ*ǑZ4)WW@dW|4e"C c.=gWÌ+Ti B 뽜" @id@x#-Ӣ%k@ " }?|K_ٙ3D`dq\'b~Mk u_ .H S_ַN>ȄF#WmڲkawOrMG&Meq`te] Ƈ}ԙYZ,Wkǎ"pH*roR L$cfSOIB){*8DÇժ*ʄa_뺖^Iwz#aZH:uWo fŹ+4qivjjP(uwrJL?@k{oG$, wgBdM`H0!GO?1N`]2BWvͫWf~ \.9ȶ90mLŋwN۵]9@x~~hIy>O:i!Bd@["09%*n6+GzȦ'ʞNhc0Aɻ-Ym5 .Ȁ0!.hM$  a)KU- 8`/%~\&\Jm_F,' 2iU+{׾{ݿ~ 8HJB47I:z\-F9/~SH W&ZP&%'Zڂ!eQ$x&2N<}|BB|h4ZՌׯ_uY&dhR9pMW9^f`h4Ȇxĸ#Ė֭͛]54P$qM r$k7UjXy#Z20+{|盼NVR+x/M+s=_[-ܢ 002./A6Jh5olt|CCc\c>^u!)^;^Bn! ˄w2w"4FFw­ܷ3v)/,~_7?Z^t 2MuNqj뻾R we|B *@X=WjC>t8 a%ӧ㙙ëVUW DTp]?O_/>g琻f;_{JWIR+b2c9õ\SǧS *ȍ•F+!HWs܊N oݺuz$E"+]F^畒1xY9`j*=a_":JZljf3MSjxjōL+s6j||٦ogy GkiIbaEmΧ8Y)WZ}xO K߾i\>Q0r&ЎiJD.B8m[2B$)1Vw1ZI_jascqiۻ2|/@c1e,XmitiK[Q9:`d\x&|\`Pu\0ˀdS``tJ 0H%fٯS [4Y@Ze:\9ufp~ni!1cV\JJ;iعcۿ^r=. M=ԓjԛ"0FCц+bqn%VŞ bɧj=pQPג٦etk/ׂӓgz1SSJPF/<.&n=[, jmΉx'|66q|""vmhyK)^.h}7= %3@h??OSWF ]F|we3y_P@P] '_'zS14^ټs}ܿ|+_҆@#ӭ~J#./F8ɀњcRZ ](~?zԌEOHtF?ߴn͡ZK[5\i7Zt:$:?&@D~@fdbTqVUU6$,vZU:Hf%f0A L#"HLcF`4#,EXKX$a~w-eRͅc[);djJ$M3;5_Vw.$Afcd!''L38+kN5BHq""ka,$B~&(5xjS?#g&g>P $^qxW Y5Ee&mP.`nnn\[=jcV?|`wOWKA{pp% !*'y׷xE Q?_zY"o$-Adq.%2{fG`NAn{[0PY@y :%=訾m!}Gr@5 n[Fi$(Q9t 82J1UIڎʮDQI g( 86_ذ~}:xҌe;WhFbuHnUdq=臈\H(h5VlN*ͮb>Ƃ~˭ M͔ϜC ڇ;گ!9箝}zqוIlٴ9*`M4Dz@!̒e0 vh- NHD3 d0qd.+/,h)j%ujٴBOOip9XUkijrv$X@ w06`!b 4FPRQ;{ -vO ;Ż!"t21WY=o?w_ۄ}C;l!dQӏV3F)bRʮPn/7 'bqlI/VZOѩsahӌ*W :w Jqd%g\ŽbOBnzj2j56m &ƭ`1CLI_+HW? xm 5X"v +GǾ x_ҍXy۝.P,iXp@HkV#CQ_bg>5y r URpA:"1r-%2L)Rey;~\PzY[jseH[VsСsq2@.m`sg5& lmp0TFmjrq`BQ VbB/֚(jZm+V#s]!C0oMB|g~ 5`0O:8v/Nq~n,狮˂89 <9>Nz_?WmxpIAҬ{WfΕ&  0MՊˈ?ΡX"LhcD.g3Low玶ZMņ \H# tGr|Ye#Ye q!ve.hZ+!2gepgd` 4\ [jv*'K )gjPF! pGJJ0t:yR rYuby8]/5K!Eq(h A@8&Ȅ\谀ձ1#]k2+fn:{Āe&5@x@t# i8*#^w*] >8jPB=>37+$eH E?"[B(<׍Z-r2qbcR"zͪ.ٴa]W.SU_|Ò%*E"UkU9έ[l޾y 8=0M(e@30܃ Fo-r!d<ןtF0(nF__2BsfL}CEcWz]zu+:b 8uyO?8@̶m;DZ Zdph-ϾpHN (awg8k#1nOEj{H 3 *ݞ`U>{T9(VztdDQ|.Qr8SR./.,9AT%\^ "qY-M|Brur뭪1 AԂ٤Zk 򽡟CJCܨTB>{`3[sgO0Ā> 颙ӥ16J0Vqѓs@^^<0~jj 3HW!R'FFL蓥l" ӊ"Ĺ&+Kdaښ|I^E rۿJP.B5sgN_k*|Umxi/nE5 & @BqEDʂV%j(p}/pfA 7Қ~? Ӫ#XEMO* 4UҖ0D0 _!IRc2qܳ'gO8vʀD j'Ļw5qx/{Dpvv oIJRya8#Ŝ\UJk0Fc²6L{1k+Gx  U"C' R:e/|wuleut+ Ԁ: 's|k- 2R24Q)\.F[e$S=s |Ywe 2iPp>`T+^@)\O>SRwֱZ-޷`6Xwl>x_@3#q Yֆ/D^^\E(4D G+D'܊22f {C >3dް~ݮ];Zq3ã7lf!q8Zt< MD,c:tq߂.& _TR@,{@Wh0`A_Wz x5?|z[ $ڌz$HΎ"6&vvvcc1k"fc&F!iFնԒZjvdnz$TBUlVz{?^fVQ$@ u&˗~|;|,1(H0f4!+GEReQ46>Kj`hP@QH+87yA8b`Ƚwݽcw__h ƀ Z_m((5<`%^/3T0DxRt|ig/(#@XO>dwyhue̹$=?:<6FƘ |"֥!4 P'G. B7Cٷٸȼ ~p-vpl3<͕ DaW tPH ^FK}(`ӤT6YE*P\v '<_L `"EMgǍaxշpɠ /ڗG>$'(P22[ skq%{^Xo~{7~Qfjz=^_ڹ6 =ӥRA($gzfy+%VLdX$2yf&`fL@,qaJn lT $* KMy+k%?nvLҤ0{@RT/3auR@€2@"&&'-=}F""0(P K[@AB(cY’u(T?|/Ĝ0G2HkiBB (c}橇{qq&7=xX.Akopva S?:Ddy(.1,j$}lO|wUL2*M1c4Fo0nˀT#P[?W]^nj]0)T9+=THez 'Cc fٻBw=3@In91z76g͈3Kur@iI,5yul_~v;#.ϜcFcH>ىM=<\ZXZ3!YrDO?əJ"v$BxB(Ed7`km?Mf9A VsbED $!%*!PtwkG~~)9#$Zg! D\a{@P+L2#DD:qؤezSVǻW Μ^ȳ^{쎓nڵ3 U%,mJeko`8R4>6;[˯Z t5cf`}}yA_}c̘׏]?38$+""(O/N|uP*Rfu@dmoB)$I[hQ)> `Ldž|y84 !Ыvyfaa旻b0ZRӍF^o4mNrydxpffj/"sVs~'ٙT^[H=2 22Lvi7I3Z{@؉cKNjIIn ;_S*NYj4N/DR( vhJ*tm Xyq+FogQ8,= L.6W׳vlWֺRBTȲ|}m}Z֗BV|~X86XVfm l>!ib_o?N_5BK)XI,P2`@)TX_iήK W|}1I: q=g(xΉ0[zZf&JbZKr9甖L1bتb{4[' jz/DQJ@r@ TYkf4c`$P;p` %t%-1zֹ ,Z^Kݗ,7bB!3zщɡSJS\^kt)]Y]ܹkdEF^{ҖA޻SBaK/j]6L-|I|u7e@z!%&THk(C3: =3(]$B;o^9r 38P#+~ex_׈(KKKF+s۬])FV?6w֠Ri3DޔīogzDfY6Ӄ=m `7Hz@&6:綧%p+zMt[öb=#}@EpqvK͓ KZ268zS;Ɔ޿cZXʷ'ľ;v7ӷOؽ{VsW01жSv} ~y[V+{NB/=xE!CGJ#wM1幰A7 z.($TS=c1;$|#~eh(x,nF0 r}.\p2RkK d_1"bD Бs$51HQ2f!!TF|xڼ liv1,R{L("Bja`"},Ξf%3[g}ǡ.tZZ=/ͲV<ꍑXkR:eՖTJEv;nʕ(*IKFT'+d!R53'OW\;֙LYFo Z/(9W.KO 8~l7 T6+|@nh_V_L@f3no0^MrOwʀ 0 ` 6 %*:<}ųϽ|"$1֯~ep|| Oo>58Rz^"ɪ34EK㕷Sdxs|E@{ f׾./!@/#O>kXPYʾAG'̨ I/L[[i:RG{$n@n^ `dRj]u9GB 0I<{/Bo'9X^[G#Y WnL̨=wƆX4lt(J*VkJRz\>s/-O(O 2xOwo@]Znuލ+c^X#J++JU>; ̘f&&s?~ܜ9{~DQ,4$ NR:+ŕ ^z-.ru+9s{K822ĝ]S;D!@JTBnaeU鈰Q\j LNHqLj_vY6k0ۼ oul?D D,T:%$oW_aqq;^9zEAD +APIсpR"!1{qR:5YZ\888wN&EF;qɁmmS:ұI9ܐ3P;>b};R8 |&j[:+4V(;(Ցn<4?b9|/Ge^- iULK* <+mg]zN; 2 _3?_M3BAj[#Õ%2tp{2 :@vȁs >1H͚F'Q! ;rm$ISR(0B1M=c:FM; W:^aߞ}w,  bqtli\.˞Y{(f@pk~%Mû_` E8H&@ 1 B! :nݮuk8n/iZJ^1KQ }Ne)$B@tIp~MEX?z=_,Fg @)b effvnnn}w]kF5;;6ОAfP R*rAvs~,(h?ew~Mks_I)f@(@Ia10T5BH9@J{[e .m#HE3# YKڻnR|fx0pwq|moJ 끽+Oon2{3wK mt)'^#4)4#f"7M V:h,}wcͤ5>1>P(@`{7SzŻb(V;v\Y/7'=@ԥjMy8At|;ٻ{~8g#<'_:,,O乹|umҞ=tAlaq\;| 4Q}(&GA6eL"2C`r(yD! 4~>/ogqw{/8։eUi:_b}G~gVSVF -O62 k6IewL ,T\NJq{>.ͳ\qVޙgVu-w#;< QF"uT'(XXf+Y+z012$}9  I4MRAIA77*9`;Wϝ=vMLHJY{yemKZSZ5?هHD(TpU3B%jTRy󥯤 xa`VZYZ aeRKѮk>|;;Fmv괛3#Q^E`meuO)Id<^!m<1n|3sn6uVIYrx @vlD\괅+kJ&'v'-+n\o436+y1YZ1)Po?S,N\޷{׾=;{qzb|R~FPDBpa(N.2,¿xi /TkS29R{f/<__vZb^P 剥噙ɉJ͏ (R֒]n3vnЗP%݀=|+O(^8Whk$gp_mf4mTH}[.CC KgH)[^nzƒ 26{ڛ=f<{>ԣG8E Fqw[BBj2yRLii]㩬jTҤsⅅfA җZyˋN6{={wY!_S{(߱jwΒ#*:y=a_&DĞQD9g(e]Q__/J_7X@beR0uR^:3y:Vֳ,ݵkR3%B%ͦaQ٨)sf}?˲ s'q"7VR+AD)֚Q0 ]<,˄Z4IBh3444ZPz 0aaVWֺL'eZ7PR4n}A+ AX 3Ì A%傤HWx[ՕŹrQ![& p @8g[?o3kėׄc9!{uL r̎ŸcC!VCrY*B˜y(0Drjb<97;ggg=ϫΝ;?}q:;eﭖJ+n) x|V0^n!FF@+m P$;{bCŌ~mqBV948W+# B uvvvvϞ=RI묳FkQ(%Rd Ήqk?wȑ9@DTؽ{@T0&p6\"‡yneYfo~kȐqn};%r%%˳K=z10Y\γ4q| ARj2)|DHsZe AQ:yO=~|dtgf عsgɤNu&yB/?G~L$H!Б&'Ʋ,cێᆪǵtgvI p|@yсi 9xBӓ~\ZZqX jVyiuW{,uv HvW֊BZCZ_f=KDJWbw㮳<_5  kkufh^_XX+KDS8(1lލlgP)O5bd Μpb8hZقN !s犥ؤs:dWb%095Ryb3gwy/ A+( hSW!G?v RJ T+G#>x`dxNs,,,(%vIY)<'րLb/3Sv2Py {ڏSO>г]~ׇ # Ky8[h4Z hL.@31@nlo2=S;IJqǝ+ý{!  Qi ]J{y-~Z1<?Wv0CeA(ɥA<7ʙCyܮVN{;၁ Ξ=rP*N 6?ަo`w]ر/呑ѱsgP >nyЁCw "@yE~y|HW;͖ N)WB񆞟$Gs^+ vaq#Ÿ#sy^UP(xB <_4晧t#ZZj['NZ]]/JnӉg(e((I:p21ޅ%C14CDyVg|b٬Xʕ !0myeM7}E݂(2kGNyk~;%#1HVO܌uyڶ[wך?}vq|M|--~3㛱HtvgX^?8XE gF%?7^faS;5>::3?ӫy믶ťύ@A5SD%YW ,&VR#!9U 2#Hִ[͗_zqppPQJ3Os_}܅噅q.{ʘaQ.9rݎz `:˱ՆC!7{7f6{g9#2HfDPI!̬lwu04?u^c X-U;KDǕŽ~d1*|212OBfg^%dqqVafj5Ϟ=;k}Ry:XZ_]?zpp gO2SZm5\dQ]]j%ZY쮮Tk={j3+Nw<{vNYʲ9? n$mւhjA\mF+¢5,&iX(q0Sp'Ɠ3cJz_y)}cY}?:mD=c $ 3oZ-ֽu?`sfYXXʭR(*JDzsU^[KoXklZyOj={v>|0˲8I QX.U_bt8b`ym xZ `Ɔ+O1'iřNy,˃Jjw6z@q"sq猒=D{}k+łwоn)x6 $i1B BJyRi`a-T+}v76釞RD͖1&sKbd7?=/0H$' 1  fV$B7w1& MRzpw7! ȧ~>{D&MVV_|EJM =]{'ލc)0@JvelǗ8ڰցnqR,7M\RBAsK'N59&<:q"pjb_m?v%*~P`H,±щnS_o+-/wAX,qHKX]ijo4kIjIBX, >}:M0Vӓ'fGe.*h4 K Z;jݎӵjǸ`\e۝'O;KCcc]jZrդ@_!ewZp%k84{G'ϝ%j7+/\I۾;5Q:qk0,=z,1d JI,P<511\+yXN9KkvP Py&VB)#ɝr{B ZW!oͱe!)4›l-X*y5bwS4HP #O.,ͮWePL :A58R-,.8Nu :67MgnrPjwAzeJn5hQ; xȉzAj4Olh[ǝ$ "5qz~ydvfiA9ۙg>2:8=}197o}>04\ 0ӽ'|?h:v;v=;ګq2<4J.Zg31i)PL(B'u/7rnE"тj ҅bQkU0Y,/NϤY[}Y#] XpHOb7L گgΜڻ{Z) t$&q_.0Juϓwݽ/[ڋ|_gyqWC  Bw"xCD;o@JiDEH[A~O=xa`A{E~@*sBi`˫.^$@H) %8v鹁Ra_\ ǩX TݸcQI)؎V7Lq<tά8TYeY#P< 0M[[ (Wa NL }EDU;mTʹ|fA\zu(cwbm@SW6;_}񕰨qgs?x0Z{osPRi ߜ8O>w]{v/.->΃>w^f]"RJH%ZOy^042t[b/SD}$I&'' r66HRc 9I.dmcsb>[cTaeYI&7zZ*WEbA,EaAIJِ=8P%&w=wk/~rx33CҸ%zr,6z@90R2R J)|89P韚7;;~qiT(W,80=HfYZ\u@R(K@Ka H=f`X'`-"K q$j=X_Yw9uXX{) {jGϾp~>\o,<Ϲ,[VH26RcD׆c< ?!>kSҤ4Y(6~ }9)zya_8Ťs `Qv]`pǎ0! pruy2% @! $<$F]ѫ}vBT@`O"SO{=q g6@D4&߬Ω^Pą4cD$&d`Rꃇ$g!9AfZ55Y: )YH v;\YYq`C]Mvycf ,a2ڪs?axt0S*C^|pl~3 /q_8wRg?0824P.Fbqrbt DgN RqB!C80b1kkRVX.L_xW{%WZ1OZKbytx$IfS)7[W^y=ϲnۅw΋/{j5kOJo0 mn(  r0X'J#nsqj?PSR8GRzٛf+`pxoƙ #%)__xlM_p݌UFƒcj~+%l,?y? Ϭ;2Z-j$8eacT,mD/@APԞWi7[q5N !Zcݻ&^y}vvvē7~#jZy_-ԞbA{Wѹ O|(nCzrܲ#.&'"!jCl%yX*Xs39Fr..-S?z+% $prٓoyMoy啵cGJQLGFS'O\8n{}l^oDRY|`TXl6FYQ0:Vs8;Ёvbl:8TSJagj4cBߵsjuujٳ w߽?:u[G4y핗S@ Aw @%)3F MF$`?px5ڛmRV)6vb!{Axm^AɆ\A(9Qv$%"uy-"k"eb1@yns}wZ IZ /^V3`dm} /v#<4kʚ|]c#Yjri))"Vv31}f۝|ﮝ\>^ Uʥ^3s绝C;8TMvrRDYɃ^]=;iGO x}v>AH[D?g!UʒzltfV_>;1998T.MstJ!7xŗbPƱ][ecv?g__~wݍ3gY28ٴ±H;99>M|j˾W)- _:$YJY̑C%@JB)"*PIW*zh6D {/O8pC 43nrbW~ONNdq2" =s66^2(Bt "@t =uv{icT 3mz]g^]|$g-^ۊg68Ji79=bՕ7 w:402L}_ިk)a1MR&wv@>zk+ ?Z^cϿɧ>9<4կ~fԧ~f_EFhtt,[+Kvvvvpppz}W(ZZZ贖8zR-N~X,n;(s?9R12q…_Eq7u&﫪 QL rVK37,0rDκr4sTrf~iArR FO>~Н{Z^Y°V0==_۷o|j"6AY |-nۤ/=*qPxϨ }/qåՃ(?=lڥmW dD@DMr}=⼉J o|H\BTF ²UnxxDXꦧɶ/^^QiřiC.X3Ν_8f36b%-`yqqme-.~;Қy;@{1Y:"|r(X6Jb{AZ@(8DR]gZB|];h !<')C!"\:s^3ۅO施#7ML5?a(c`]~q/<4V~;8|7v0wk4l/w< t=2qp׾{Ls+% ~Ұ/@;e V{܅sO>V.fcD9BDv 7T\nGtJ)+ͅCwq(8*֤uƤQ! !üYQ}W2 לj\q ViqБ6R+?rCF{…Ω)Q Lb+}|W~ęɉcSViwRՀ+iT`sz|9w@Kk?l%- ų0P8}U`tWYs=YJ%.o<9+$K% 2H^DQhOI!عPHFŵ-^ x!Y]JiHu|`we@kC;,[\XˌVj_~'>ߨ///]8k׮Ņѱс$ڵXr)΃ye9*PluAJELDdURf5c u{KKf}|tV\)qȝwܹcx_M*'hh9&74=l03k)ΑťeE}?`mmlhhO TGFFA"8EkppPPJk%DZJ@3l3z+]űեj#w[Z$0TZwKۧgӟ2y.яNq⊢^׏#s2./{'{j&bF&g=$9 D$+%}8IO1[r`^0-' F)90TZ4[Ju6I( GUWAW˿w6&iԋ3sK( GZȸVRiZk@-TJ_k 9"GN.DBeY&͋:H+K]OBǝs%:3:0l'%ke&vwh-B!@dJC 4D(DQ$ig{5\.RF֥})' ,@F&Bb%JIڑv^/a!Kc(+$- ws׫oTR4M`yy%M;suumv" w^k8nob*5hٌAyjiSny&|)]+]TAj?=w/FEinCCVTfʊR,64Wy*%|A ,K;r;1M띓wWgKcd<*g;.%JW^RBTJ[_zD1/Zlvz1<+C!iI!YOy(Ic&J_,P>#,I%hsREA$$DD.. TlIr_}#KUXMS 9LD"d[`h Q0)%H`kwӾ\[[GݬJuZkYɀ{: aj{$`fXH NR!i8GB*R{:rDtƁ%q02<6sP@k^P9zͷOw' iiM_X ;5N'HvMM9b j} $@ΐVNRsV*R&[n @ZZQ^X}֟s(3Fͣo0JZ%3tKyZ:;B1[Oy\\u;"u "`|ڝ&(_o$pt{G nx!8 ]_BCTȢLfv,5:f`DdfpGɢ(̲|OD\S, r[0M{n{/k)POܳ};ӳ{^ž7)RF(JSv5~T(<.hg F& ~{ixAGY=\9A,ʑC llO/b$1&]+Br% PP;souie+ffɑ#<wzISvsdd0n]VȱcEr9Q%SoWk^vJtlv"#!$DkQ? +_z6?=G$$`G`("V]K2@U "1(9`TȄ li2jswMMV8Nq60V_'ꚐW$78V*Gyh`jNLԢ.#ou!coA!Z2f0,ߏF* 1&>O;v67~aaO{ʝcݤwFQyJAԪ5cR6=n9[u,Yrfpx';rO@heILJb#)8bfk_&z?VK)۬Z-G?~9,KUȻ=M71yzdIsIUPl+ iwע y2ݖ@b^?\{B L$rLnկ? <>G; ˮ3?cy.˗TeUfP0G4d[mV5靉"fcb'V]ŮfbbcmI#R[{`Bʻ?ˬBa|{9;~_xhnnŋgΜj˫ sJl[+dozf⥙0j&Hd .GAȑ5FH5:KIk߳.i4kMXhx>ONkN:9_o6qwoy=O~'QRI!%Y bW$7vލq]&>" ,]kab_6Yt$5==So}{O y>A$vX ^b^ .Wșա6PJ*Q&1/-T_dC#QJ[otԀQQ(F2 !OIčL6 }[ܣv岙W_y_Ѐ7JLۛFyGawxȶTuMYr<j΀G Ď'nXט J-og@0oL zJ 0y, tPv- TeHBqgmS_sֺfٮ/>}ɹ :vzffMXm2Ǐھ}&bk 9B -@k/? rBqv,J3{XB/'v(d$ UKKGVaV}`X/^\ ;RF:;\(^180op} }n{?{{n@Osc#F}`%Y?11$ ;,8+e!D_ ;L1<|m+qͻ?sH3z)p7_;֎  n2+]':pZ"洣2b,8Wר`,&~'Z'ԗH@ `cސ-H:n=C$P fp8/; fEz\6I BHϤ fde@Ih#s`kZZέM ֚ \.VEDiwЍƸ1n?lă;q A9A,/2===B!RcG?- {!w>Xq&Nbl.i!T3zNK'3 67Iժv*B(8 q KIky?%%" r"kM6 Íb_tkhdxPI3Fk0 "|"݉bb^%Bclz!|[w[/\Os!xRJ(VM3w%wz]vP\wX~x}ydo/_a;"" 23S"&JCV"PV(rE}ߓR 6Z``myG4::yJ^eR1V^B)MY ^'Q9O(:Rccml6 N[)% 00!!NZ, &`bNm6}''o=;^~|< N\6vC,ắ|~g!d`iwtleѡ'?(0P ]EcѰi !'Ԕ"ָy?Srw~7Ʒc?wyfS(db[g1:I4 [$GֱR1L6hwZ`|P ڭ$"!Df=영\ӪY3tb)7yss ?F}O‡kg#q ^u:^uaˇ9@aAΈמvPIۂuM@)DZ4έ;ŹK}B9B̏ i WJ9}K|fR<20[`0V:"qHزM(6jϾruY݉V*Ug .όN n*ѻm.zv%xzY-J.~- r;^JP*#||ߞ}sкn8 PB+Rҁ p.-3w7:~(AdRAI `qk5Fk k $1^ƻzs%]T~W :mLkm4rÇ@&R0BbśƯu11,A"%G(:Q#1& \ݖR# H+jIs햄ҿ~S ՅG~=J g}W›I2YκT;6o54ҏ&b %ϿoVW VQ{' { #W/mPu!6IW. "v;ܷo*]w$DINddue !GINRQkEMDJ ~,h;r9IG DFB&w| w#1ɷl?\‡|÷!}(5&2K(]]]:yB;q_ʧ>]ov$Ě)ZQhc6lGƁK9iRN[=y O\U Bj!fJWJ;6][W+ Z*z,A8uٙ ϓncD,8g)[: Li$PQZ@'GFAԪb1sn- b$*]5`k0 D2C}2ƬƟR~=yJ%RH%i=FԞ9"TV39Gd-(K4d~wbGͣ<<!]v)NaZ4g5iXJGwt: _3Op׽v6,v^.ZX _׮ZmYbJPHժ!(HJRR )3P͎1 gfZ(NleUklZ!"[o@oJ H.Pb!l7=L!]؎ Ѳk[Y)DIk!ܾ}Gff|_JՉH)XT lf=v;PIڀ l81lHS1+&:4:8Xmdf=_ 6F7*2|%ӴB)kEo33hGvf=,@=u̙s`\*z _֪=)Zp]ԻfXCC7bBP7DbrTv\^_&fYrWΉ@7ƏTzժenGw,ڤ@cI N~St:I;ؒOW@L@\T0_RI/ڽ B)LՕv%5H) nuOj_]$Id KT]GJ*î? 'z ]SSoqt} !*-بZմPNʕZuueam}NGBRMbD)ʀᇶL<[aёr6Bh2Laarc-6RNLN*O/%OI 'hdykhΰLqڝ?lݻg#"m^)K#q? P;hfZ 6I|m_}g_Ƒx{%Hmr6HJJ#RR$JJTcb%%3q)﭅4 Hܮ|c=S$:v-xnso6_|PT0 ư{ &I ҚI'#%@T/#@{%xS#{NwM R~nm8-`bjV;Ϝ#t$K!{LFF;k,%m%VjĹGZBj/g _bzvCp 9 +/ON6jMΝ; Z{Il1AL_ҧ J}vnʡV~z.rӫvU-x/i;0OL# _ޱcI@+تU[*GٳZ[ l-uQOqJB@JeJ jI9kA*j#:p($$q;ITl)J\*:?ʈWⓝ_nkJe[< @?ݽ{m6q|mqi-;z6}O {_ v/ЁcDb+wāK(b۔߹nx8Ȋ iiv"9yqѬ]ld}^ 0{{;I[km)/ )-AzW@X$( }ٱF.W^u0 {{ 4흶 &׶(gݱ88~|FcvV{1G~:FfLVSaONpΙo./ Pb[A`Lؕ %untZ/3| S$ k3)7`IpNOr}gܷrƼm[DFdUg !5J+K D:6?21s;v#}O Y͹"xoo2~ӓfΝ?i Y􅵶z u]?&¶,"2A&}*oloy`o/_tTR}W< L_M=b^8}Ef=fڝv[W^= /_2kjwRFsq~ax |x})L'Ex(apB)g #"Dk13 ĐX ,{Eyc3y"R^}q  '[h_;>z|,z*68G Te,B_?r?_Ņ˳gytfzY)N@2 A*Ԛ7N\G/yRJ!%[Z\;/S`M@D4;7Ӊ:kyZG TءH 4s GOdt7aNhq1QxS >Voow~J|ߖ˅ёҎýrg`nv^i6Wņ*k`]ewl A!X#P23EDAHZnvogݳmE_?uNe3?^~~2$! ~XQ;Ye fdZZwŅw@M>D-%~@DN z/4l$1#oIQ{A/72 '˅UXSDDe0uxy},dža䘒Iu E]:`=v@ $1 opY+,8vA`D% hԗv=}GUͦKȕ E؈Pa&a@:仉,96 iSn 'bsLމ&H@fPīblk` 1ɀ@V_3gf/4*gmӗAB9F/ rg5q׏yv 38PcZKF!$ska1HZz ]^}.s TR*uڞQ.={>FIKv`癅Kj-5.ߖ#' ?>s1"Fdi+a!P`K_s+qDpM ,InELIJ@e0ϗE_{A;T +,Nwg"dv42B-Y.(T= /zlV,sŞB6kF۳@ ǷO]x .\d4 X,/WDq$0\ne ^] I kn֑@ B )%H1AZFs sIč.I~~k_xyݒqwE{3ۿm̈́=!7%`d)rٌv#`#3---=#Nr_R;zmlhʱnwbַ-crHJA4Sϛq؁%re(:[MɧףD$&-1dLz  F6d@a\T-s/  tYۢJ LRHJK \:ҋI3KQ;Ƕ| CǴ1D^_L O̯,$"*O;U[ߵc.tj.Z]Gd 7f,/2;'%<wܷӪ6B&T3lFl,1*| fĈ${w~٭ĴzFY>AT(R3n#CR14 O %!miC`!j6J(-JFewgog.\x^i4:'oCñ@3߳gh_w /sυ`C\>/JyCiE6 wwK{˿̒#m,٥l6ՕCn>lT)`fkC}(9vj}+/Ntx{(ÌGg}[fc?G묵ցu+],E‹/0==;:TXg1n :-} 憏"lN$H:5W.Rw{-;//[, =Ty,PB62$QjvlENb&LBP Fr,ؔB@PBL{ٲ3`Q rS"Z@ ֓L;"G4dPZ$D—#ǽ@L\6sxe9@Z:{].@ =i8sE+1&J:l-~KWODBEO͚[G62(S##g8_(#b~f|ڭv>'A(= isy)ֽzd.쬵$X/2C LQ@Ȗ@J۶ !#aF`5( I;jp,P@ e:)9j"0(@Ѭ6guonzPۋ+KQ[PP&qk*FȀ-3O8o"ڒQBy JX׮aVZ:뜵6Irp@ Z"@륅(eG HireRSFRK!>sUO>|`CZet"H%9D<:KVuE9Bk^Pw^}ld]@e)!@FXy+>Gx(b)}rDXf3P=䊥* V3D"aZ|霟$3N\z٪ۨ ctPB s7qOfȑeP]﫷8pZI%QVo/J7x %rDh!mb` bfd8@%Nx)IIEԚT¯пgY :JLd).{mۣ_:I |kjQ3(R337,T*s1bd 9l__3v!L"DNUscVBJyZi 'U+$=K GRRJ~չan ZkQW" (%S;wig^YX7 h |⭷.=׬ ` Y.˖գC%ɳ)ݳ/l&mkL Z}SFp+i(e(͸ ƙ%UD\dc OJڮt8`QޤjIJ+rMK8lv^z|lSma[kH$(/n&ѳ~󼞞]bfj$xLVK-Rw;cUkSżZV+t~HQ8 /_'.(sZhBcQKe@ LHZfTk/77y奆-'3\҉IBiG=1Hʗ"#eavLQNCC׿_[ -VcB @$L;O!I|$dZ*SsR))$TQY`-:rNZ^6i#kcZ,5Kـ@dKRV_̮ݹ8>xѧziiq]zSw(V։JzϞ3co'N\T~Y!eȹ|[?NbSD9k5UT[]Mc1nyy{k 5oԡ>'cm,qhX˯=yhN7R=!LV+333SSSL"LD!HC?s;ΐ @ǔ6$g %PtR.:Kb { TxQI B>X9@'( @b" ZxWśon7N.JE~:f"gOn(DFDNjŞ=#7)0x趃;vn?}lm?l6sҥ7f Ba~eqA>Cg9ߵqu/s >9M֛9y]WF fK k'O@l^ۿyϽgOk)O=msJREjz U:Qf vO4dKՈ@4LsϞ۳k>%[)q($8,'V$%ICd7miy/,TjK#c{[H{P\KIyW^zW_KtllVZ֞҂%+tʉၾ/AFFsyIntV`JP7KӤ&I<ؑsr kX6`u6>g}^zXkKןߏGq^.-o@j Q uJԢ浏y\k-ޕ֜ \;mW!ajeltP(|o@9[3#{{G@B*ŠꍸY_% N^oq['Yb:u|C lu[Z٘7ͭDFso{iJȮy l3UbS(d(8OZ+ٱs֬{;}b$M.K`]C`@"xR{j S~\@bВ wyϯ:GqGv&̌  A[m4Po4m| П_.*5&=N\ まNC|~ܰjcy>-ԏ;ͷ/:qT)}޼<dp7& ĀBIњwNo[^jD|)JaMˍoiOwjN.54P, ?iVVfݷ'NK\x_O{-C}!b^Ͽl~L0-s6ȄTkL|sϼ)g \/ƙNVŝNɉ_F\}in-yﭻkZ,:J|*dGLprMـ]Q1Dkl=n4Z)[O2Gs)vbo#OՓ}J@7::vADQ) &I̎nnfS M$+$c1q֘rqmU+qA0^}ch[o5>\Ԩ~ -÷:91>^"B!V f-{ m'U3rBm۠m;iZ GR~{ XhN>K:q$T1ė~3{r9~ۿҿ}plPv0\٨2%b@)/j5 h4Fޥ{W$I(bkgBuqChl'@Ɔ~kb N ZN/,]8v|vlq-叛TҐ @!k&KljpZK>urVr_w v)z60_@ 2y{zrLtl֘$IR+j˰Qt/./K!,9!鵉"#"`g{]$,b_6g7xثGD1 hKȖ gO;\>;M1lS̅s X 8 $HLJTE a+0:]Q&qGi/뗊#S;v4:E8Bg2)Lb2y4DJi$-#""'^'^~XJ0m$"`q+@/Й"_>2?xPi !+AI!=TbhݻsdTR6-rܬ7ffffzK>s3/'&;>uキv[V.LWZ/,.DH,nrIC$+&r,B]Ϊ2-DCC#AZZX\:[KǟxfyL/^&2O @!QI!i-~'Mw}Jt%C}AICjILIX+[ͦԪUAF(1.;{r^./P2 }>/ Kv*JsU' L50#J]dB`v_IuDZ)Yc1`)H:@ສM6)_xG?PqVY|qo?)%k-J'R^g^'9? uN*#'0c ظXR d -t)cފy['go[ξ~򙗞E+vvc.8}A2u_/``h nok3)殕GR3 LScB!u'@(Wr>j)$@sK.P I b-DLV4Է}ׁ^^\zzFr+@;F$Pd11] (0 @P Fo۹@,`lj2,c^h Fk ##Z+=yo8JՕB!#H'qms1"tS r\-+ߜ?zܩ۶;o~)zYr8BJ!GS(H)ē?zݷcDŽ|A⭷zv}MLz]i=44eUl4Nujjs@@j)}S'@HVC9 i5ErnT=e\._{SO<[펧W9&Z "lr3z"W^<=88lOQ\;&vjB#M(J<)Rl>A/{ѐLʉuI!":HlL:=CJS q "dB`&SDPHɖf}G^'sO: :zZ'<䳯v$ߓJ.,.dGFF`c6a=4( vXءBDkjOS z-Aom:ӋEt( aA2kQ)Ij7ߛRjK:.4fN־nA"c#jI 2ᆵߟkklDVe~['ժ6W5H" TjIJPjx=ܱm3C46r[3KBH`J\@QJJ! Y@B&d=(?03@ٷm쳿]F+dl25,~ڸcVVJ$Y$fD(YkB"cKB8l9YysηXې~Z1F zZ 38nЮ)DTJMOO/͵۶qIgl&=lj\.W׫S';wcVVV;Qnv뽝i 30]!EIs,!hH,ȠAXyQDq (r״ 07;zgg*U{~iqeltg kPIa --V/93V_,y~e h9I ˍFCi JA.a sY_)Қ3YFY S]J V)(9_J'Mxu+0Z7_ d)P (|2VynΈR1_Z" kZD0l.V^~8n_pabb$4nEy ܦ~0#$6Q+i$b͏@rvL)=*5[/[%F>hs0MM3GQS:mR<385?|'_lT[11+g_}w7sr6<)nyS ʑ? 5K)Qv=+Ɵ70=:`% ,S'꘨%f̒ C}qd|DdL҉*0_tBua]1Hʥ fhP0/~atО]<)? X2O<+/X;vuJVE ApAd![drӳs—b?yzס/ds8[6 [s2FV>ƆΜx\d!Q)(@"vȯtx >5@\&X`ӴW&&/M v?0ĜވȑPrdl|yziiiݓΝ{C]j RBG NҵK,ٓQYCr|B:?iT%2j /yWDF⢩= /Zxwi6rKG_|}{]<^SZ,\bم[lr{je;v([.<%@fr)紫_ln\?jg1$,K%(!7Ԕ!V0-a\"71Zn.G5 ߷k n`cG9?\{w|o BeJL)鋮e:nc* -ҧ߾X >v^3 :Ev055_ \DEtE lPP={ s#*u$g}Tΐ-~.$E%EѬ[*ԁ2 p@W"[nԻ?g .6#c|S/-Ggf<]FչȽ\,xC@R"@r`A 'fCz :"fb"\{9KyBsVyJ' ;ab?tK.}݉ڝV!!Ps1sJa೿@ ?@)#g]S^__g}/z;vX^ٷo_$۷mCRSQDs&Zʦ$ncJJ1l)vPH3Y2Y\xhh󼵐3?{Ϲ]|ӀQb!0厾$ƷuzLbRoX"Cv3?7VU-ӟfv̏YTڭw}0߱su__gsw377ԙNLln~xs{3:BmZMigbl&/Ԫp{<=\CCV* 3VkhhRj8}8DO@E,VVUZITG:Kyc@JSZC NMM5ͷ:Qu#W^9';_'M횸٬{uDJyǮmuGd}!Qoy" zff JOE "jԹٔ{<#Rp[|]ܾ}4mb"k# (ET Ɩf"ƱYR``bBbFR'IJ= gugH&Q}_sl!wݺ|2I(f~}+lN$ECBZ]//6k8j//oۨD 8IbN TZ`kZZ"=ݲҞ3ƒ@,u;"v I"|Eq_9›Y`/H5?K͘!2Ql"ܥss YYw5]U_@r3gN4?P,WR>(PTorpBgaa<|СCZ#G^ko:~Wh;s0q8w;$1]+R*0XB1RIFHk EvPf\/w}biǷVNlϞ46>\yGO윚_X8}괳Q9 oJ3wy[gyem7;5596R6WILE)a=gҔ s&xuZqe6Ԥn 4} H0jﺮ\7xfµ- 2XL_΋o\AԖkˍJ.v IZc97*zO|g-9111L;ܮ;i`Ykf%0ʓ~g<_3BYk#V*ob%FD>R ?JcP&'AU90$.19cPZvc33-nt,AbHBHϖ9Q_4I岕KvfB"gkZץx# 5J6JddJ#ۆS'8}LAJ&5|a*PӞ޵k׳>}K_ڷgjyiyyiܹOg?72r"r34oxTo7N:SjY/3R?f8x3_={VTv)*rdMIš!nάM ,5BoTnkxBb<h'J$sJݨ|dbFm n,^F+jRZ;q]L0Io⾃WqDKcA9JN&e@ baJi#vv%ˬPۀ3sGR4}k@):Z`pڨ͗e!Bہ4fy뭷=EDQ%I4Hki^8zl ۘLadΑPh =RmϮ_ۿ!}ml3I:f"uw{qhָ+QҟfbN߁>q#X" bel4rj5Wb>_֥K{{KŞgYzY{clϋ%6eo8RxntlFLdF?2<Ʉ Ko699AZ;~HXڹsgooӧGGGrG8g~\-[m(=ϛSr+.dd.]" N+|?_dľ&& ˙#wuܩ 'A k8?ո Au͸1")$ VXsťzU~L,/ڭ~XZZY[[():w \jf!pHi !NW3XCq"VJ`W qfXM15{[ݳZ[J 2R ',K͖2===b N-PRj?~x'޳BOO c-: O<7h$=&&xnui@ݽ{Ą@+ *.8syfhph``l...>v~ ©ӧ_}?o.%:}}{:#GIRZݽ{ }@ݭ6s쬇k1%:ld8sl/?_΄lQ+:B`S߮UIkm-πߗ{|| av%}%A[ħ/@`R ťjS[iw›)3p5n;o۶oVW+߿o_ `7QwӧZڧ?-7Tko=nuGSApٙى##C{j4QY^ZZ~r(RU? A6I8IP ,etJPJ_}&I+cJ : ٺ;ɞķO<ɝ{OuZgM:6ⱗrgxt0 ;oڕ;qԶ?{du޾U^R@ R)R&)ۄe{,kt8&z;f#˄U -KD vTj˷/3+ $v@ 2{w9ߩc,J)IT' PdZl"cO}O|OYM;^9~v/j=vE]/Z{Ib,cM F֚=Ch zҸQeۇVM>"`n,3Fkc8"C`\ǑrpfW1cw>DhA3ƷȨ!t>&\_\Y]}Pjɩcw;V=n]doYt vA]P<bҸog"P?䧞V&77^ܗKWgg7ׯܸ^'u7Na.4]/~Ɵl6;ۛf6^M X" hΐ1Ǒ]!Ҭ8r08$aT?ǁ [6fFaJ~7ҍrj hf0fCG2c GNw'Ƹc'*#D\)EAK #K@1$KȘ5$ ֆy,KnP ࠌՈZk5c<Z'i-/\NEu va$P,jrLEq)bhwz7ff&'}? oװ J)6Ѱ0U\͛ +҉ɹ_'iv~ d\GDX3Ppq+`Dk Y k $p$q RZ )`njfva3h_?3;Jȃ3L͏M g^Y|D`ncT_kO jN!jY:Xv1_s iƈ8 ={n1H!;77wxq{D~erR)3gggBV{nvy0r'Ohk{0-9sfzzz8>>>.s]W^{ggg'&(4MyÇ7nl=vco|`:|o_~i8U++OL+7W>9:ʥrRJFiƑVE0Y$s&3K3yy.d4w- YW\^FQ87?:1mb} Kt+ֺ7e8"2*d4 X2˘8?$ʇ={VHQrj Tet3nd ԘxG?2A C4'l}zjbrrYԋt?'˗/;[}K!?{i~Wʕ{Ǧo,-5zmηM<#? ܸ#ˎtG/|G?0y֦V:uȑ#~/}4}'}1u/j뫫koqqcYj^ >#?v\zb'zA| [A Y$k#`gu]pDPqkv#@Rkx#Rv0za?\YYi6/\F_7P=;/m_5C6|n+GV9TyYnEhB`ຮ'v45p&8w8{9,oӟİr/8i9gBoiLgjXL5A6c[u9;rev3!C0iƅpP <ȵd 1IF' pd5k[;[`ZR!%hT4hӲs}ϗa4BxH (i:daHv\^kh)Fb}ݚx  ֒N/ !)=S-;,:ٙ?ï_f/^|C>ׯ] [$Hr%cb(r?MMO|_ysC;qG(҉N-ڮ<% DBC %_3'Fi ąps#KϣafJkx1Aomo.//=Y#/\/2; #y!Rlսz p I}wE"c9/'@Y ϝ;Dt1>]4+JgvzZ)E%bV=7pHP,w/}R'羐e*M֎z'pu=)D.{㍋~'~?QY4RZ8gg5.\xWy晧zT*Jv''fgfgg9s}\׻zr\fY C0Ҏ@4MGBP!- <(6r`8V Ms`%"uNȦdr*ȲX'>~Kk]#87{oh0y/fqq!˲?'>;19~}6Y\A4."sz\*d^[BQ؝dB <\r!$|G::OvGʴOH4MpHT2b;c c'˥ƍJ&kz7gfN>?=[8=;+W.}s619w>>5o>pڥ=tFdiݼ&:#@ fm1ŠbD c 2Cl61[ipէ@)2W M$UdJ ff'n5RtB2-{{c`B~J R#`\&:891Y&'9E奕ÇW._=vG~&oqP(8~|x(/|&IllomWcGvDLZm0| _xRkkk_Z\sY,}l[Y^Y]{ɧ*1qKqw̡C l68> ,Yw;y5R4Mǵv0tBqJY$,ս&i _AR_8t7-+.h;;l}^ ݹ?17?555 VV>k'$i~!_0Zk!Qȴ< @?N{4t/7>vrWAJX.Rff~wW_ϴt8B^XPZc30s`c샾?:+i \#*21ZX}/fZ 8i-us'ӄBqX,UK'If* Fv}fT#9+8I <3:q7Uɇ(-eA"#- N,TaOLXgIxjb#ޗ#[O=yp"(Ν~`rJ. ă~⁡  DJ[pd3OrҖ1aaԭ$XƈYWcNW @߽)Dt=,KV'Yml!ɚN3^"0J6")goX0;\'m>幯}?.1rA @pqȑͭK7(q{h~1kkJٹ|.'LZ} _n6 ` /=3>L6sⅵGytnnp>qfk;|cT%/#'^>ONˑ33Aqj761FAnUޑ}i95$Cp9Cc1!/l-icC0f3`Ae}Z6$SkQ7vus(RW]~cm$wbC*Ka/域o+ 8V'8)(&"4,c=RdDd ;mA{Ţ3'*isOdϟ;SY611h4$)J@ )g((AF4"c ,1 ~Wd {.= ,+?sĩkMڂ밟w\Ջx\}j"b2ҙvT5+4]wl|Tf[:6Z r>c"D P4jyp!@hC`9g0VD.o;H0!J%ވ6N9ٟ3K%(QtÞ,õNS2Z8$FdRJRI2䐌jy" \ig+S2ju:*߷2?rHo"fbISSÌRɛkIGqTȿ~twnԾ->Xw oz<%B WkT!767GYuY@ˀ\,@R8F+9|\8BL2ڭozzGyDJl6>}R)=ѣGʕrsA?R)SO=Yν9?r;55^\<أZ=MST׭V*GOV+uUăsVkuUJo^#Cmpop[/{{J2_Ȃ^.Xf#%i蹎Q C?TD"ԓgys#&ְyzz׾K z]mK%[CfOT2m)J(ZaI !YDDUFٛR *n mi ;>36sIzu4U+2#WJI<)|Pz[N`%nsٝ[JE)j\3"2.YʔbBc [pE.T]bzKw'p7j>Dq=Q'TmM$b8"#ƙʀfț͛7}1DB`L359yÇ~\Y^\LOM/K/EaxzH(^tjI)U*o\6b)Q%BN, M`3AOowF). b!pHYY;h bR-7McTT;s $ }) ^4=0}8 6U6ۣߋy񑴸Ҥ4hqĀaŅ(9,c!q筥׷I s%B 9eLƉz7BÁd^SڎOy)8L|!Cks6%I@_ aG@#=HJL0HSB9csp-ow/޾54޺`n ff'L0A*F 4fv],Yf9Иؒ(rJZ.8AU0ehi \>qT;2i?=eAwt\dz?4.}h)08jNgK1@2@癅Zԉӳss~!? $m50B;~ I)ζgϞ}z޵k66G{X,ϟ;_*Ǐ(n7fjjP(ʛ\?SLE`kj_\paȲL+8?vph4vVU*j%K3Xǝn@^w֮ݲctқ [84r_0qBLJz^k|[a ?Re,ik>5 :=ˁ`i2)cdTJR?Tǫ`@-qurGO:]ѿ׿|䉇\~cV'ƷjEW.`˅"J3Rf nqh/>{Z(L*)ܳF_|)>'w2J(,sHz]udP̬,զr||>>IMzpyka7KAi_~Zk#Onyy˭ol5zCύO:yJxc 驩ZRf}c= Çzg$^[[pǏ~ދ/GԸzN2&3Jii)a;p0 ww=9 ofdD75ę .3Frܗjmɜ|cO'6WsfjO_q捩G=+b߭{9j}qzAEAARHUVgeez>S٩GGJsSh5IaT{/6J"4({X@8o`>i$gl7~yxa9M:UD~c˿۲;a#bM&@)=a⎗_hk'͍-)GΎjQo| rȠ9q=raqƍ5X^ɀ`ɌJ hDqCzt;NNo}QY3h y\-\i1&i;XZ t\,KS:W\R8 [*Kfg)M z(  `9Q:o ¨b!e0ƉH)%)c!%M FkB֘?GE)Y 1$"H_\*30d1qu"jZumfRkrD.HiKD RqbѶ@We'Ӈ7]3ּ??Of1>VOӴZ- |+l nwZp]oʷٹGy?~a>?SjkZqWzI_V(D\y2_i>g;55[tر#G=XzWn9H\3&9-0;${X?CbkPi ޣd8cZ)!dҵ+3z'A,SX4;+k(Q8Z_Rk?wv1356FD?rOqgz46|fV-(K'@ T5B%zriu|jN|n7~/]*|=>RE"YeY]!A5?bR'QL ]>+8;8rx RHbˍF^vp4ٹIk±,5RC]P АSІ]1;lj Př˗ǽr.:ׯ/]?jMYʒ0&^@h8#tgFk;Ra8 cpc}-?"1^[[kz28Xk-YD[Ӈ۩BОwܱ%- $irijQsdY$@ 3 Q!1m\0{}w6,YDeObB53W r,5Q4A4ZD^8B~v ( Y*^mBj;',ev9\ZkP)Q9c`1ZGq DFn|hlPm5dk;S d?/b~ARyz1_?]e᠇33c"ۭV.!AV|'N8}z vuY!RTk'NL^zt諅Bُ=+ͥ4=sSN ˗/rt7V&LOUj}܅f?>>9贕R #՟;O%% V 挧6]! 2g>~XfR0'= \0 7nsn!77[(V7;+H !X7O~~H &Y`GF5@k.'0$`f#x!;H7c$x޸7~.=wdKq΅tKh pɥ WZrٞ+K٩JS*d Y!Zo|JR`,!;xvGǵ̿sZ/l F”ի'Iur!s43 Jr!|>U;7_ռҩ1&IryCTF#"dYӢ{aFH<!wyo؇߃rZ$V*_Ij0R%C#0Ǝ?f*S`bQ<̅~Ds 9_p'=WQgk/h1}DDZƘRiRJι1FmB9*+~`:z/1ڏ%ƀ%I=Eg7gDZ6Fp8g)q"kqǑҙ8 u8RJ{'n; 2,殯,6..z=Kds9ϕAn yGϦiz=Z*,k67'ggggfr //\|̙3ǎ.\ ɥK[ s3YnSO=1کV ?q$|>Iw,V+t6B6˫bqzvꓟx\KlL1'ƋLr%KTJ2`nM%C AdY}k8g1XX^]ˬv$4=`bKY&SwS? ?d:^{C]7=59sҵs_p`痖CnEaf D hFTp$@"`;V|7"Du"@sFw{@ jY@. Xq90}U/C>(skOLMN*<W+RZ>0Dž|•^|izz~jW_{{Y4HjDKo] qw[ʔ:\4-l w%ZՊ1 1: EtTgL'>MF_C?|~W\Qnu~չ?ӟFvYK6l B YCJeHAoĮ@omWro|hm%d  "q'Og1B}Oώ.B3)ʸ =`atj{Z,4Mg e&Va8.ur+/{XX)Wrbr'@$ %p)rN+|/dѡwewxWjVo}X"~g%h29B'^{m{ V3GL/;xx>鍋22:=arJy9(7K@GJƘ]bkfx8%"9u!$Y x9h0q8c6w)~?aK@W;0vO ѭw~}*@MΗ\`0H\Y5&ܓA`\ҥ :S~lpq־5 >[g=c A ycMtbu#u= ۛ3ӓJʸyd0 9cUQFd*%oy-3ƚn%"'(FQb^Xk ,Zb EqFNϐU*UDnV^;Nכ25d)qNPt49(֞jo8:\b͵OdW6dq2hsyc2~ޒMm,0"$3ZsəpeTF$\Klx> "Db#1Kta>WO~KңϜVۛW\([s,q =J!c#_j2&Fw]8Rީb kixRd~8"Bc@R)}VHigi357] 1 HØ IdޗJEkDpHq N)q6 $tғ'N~%cc֠2++,( T*sGLz"yK[3]BFYI)CLϘC- Z3?tTiʹ3F\pe u=CvyAAErSpII.n/ $ nO!B^)v;pya \A S3)P_~>Ҋ."O9Rh|@`kk7gggʥN&+RZq k( -;.$I83ƹ%077 `gg+\qA|ua#"c\Ufkc3asciouxKY%85z=Dr"XܚZO~Сs\rzi{?9VWW(=8RJM8}޸7~`1pKlQd C^W^ZRy>VzDaߟdRd-偻G3r8"; УnXKڒVyWsla7%adJܹ7JZ5/LNM/~ƿs/Xgn܈f;n}G`aT%*HȲڄhЯ&w7fZ,sq4NӮe*Zj3ϻ}ؽLh jeZ(V ,ӌ 2}RDd@>ll,-?g< "g|t%SKUHJ^RfmΐK;8(\zdIlmAR.MN )Z^AFSB^V3# e:RGiJv-ۃ[0Z2ڂ]MC ] ',˔օ|7Z'I: ]ߑ6[]q||ggKkSʮ)JG0qZ\Ζ0$,K2! rSA.wsuc}c\)FcqZhZڲ| $nvBʤ&3f4Mbl \QnƜQU}dig9|?rFa(#%p!ZǔC_y ,=Vn,Y=R0R.syi1VV"D<㫯rg}PZywҥ'Oj0޼sرO{իWG>}S[[[FZ'3.]祗_n$Ib fzzb֎/s '1A5W*+74Z=[\w!b^^,"[?rE# c8¿nLđ ݖ=ɚ\O ;XOz^a8z7tpte  (dޜ-Vp.8g2Ƈ˯1fccxں rc36ixr%شe,! P9GY8O6^1G,I1*8`2C,sO+R Ω.]#=thX.'%,rCҠP” + GY<|gύ/4K~YJF3Q\4 q,j8֤̠B8y 0ҥN` QZ*VwVX( 5Z~&3! )I{:oY^?8qv:dnTc[ ɹ#8QVgYy)' k\.k2,. 9cTڥrQ # JՁ:UC_rU!Zc9oӛKsa?>ǟ~ڵ_3O?_W9>W(DhB Øct…@d`-_տ={ŵ .%qNFe9di{Pwi7s"~k,Ԩ7E+,xQJ j8_KҨCJp )|}koMJA.~qG'E&@:,eP{E+BѰh!Angش?DAG=ju6Jkȏ,p  0z;H1KKY\r57>Sd`]zQc‰6}AG]A nX68ݶ4GD"q! D)7ZʅÌ^8v''u;]pgYe֒HVkM\xOU{"k83* $+7+$QGeI?D]:FJ)WLK? 9;80WWidq+Fӻ 61a fʸ:~TXͥͭrƱ#eX^YY)+pqۭn/+B!+phlQ80y)EDZprYz9o6^ovfu]c)N;119cm5Bvpll\p (lnl@^av{Wku/(cVWוsss5a8qpX+- S 2m-LfDG!f(6I<ܑ*(Hp$/]qa ɀ>Rl}猏}Wl7BN Q\9?7T^opJemmccm:3dM\>{"o~[qm+,c`s{/~ϵƗ]=2u\ttϿd:`v$cniэ >^a@VAqp!u)y`/tňn=*{%ɟ<_<7yMĬ!0acg*X}^*B2`RQ)nS1Z)yq3ṎABh]z?`T0DFhG,w5LBі"YͲR$ٳ6Q>}߾z%W0'SX\yߺzm:7=6?f5j@$Yazhd@R,@+yqv*Iv%dʘsxmqqk,YkRvY8 88^ Nyf4Fk5*y0˴"SIbePk#j: 3k䜴N;=ӋNm743'O>tZ1$K HtDň璈eGq34&鶶k4uf=/' !KهҘ;FR^*㍏O2AR,Ӝrd1 4Ms{/JǍ8 Cjq cJic 2.ZYm f2=0)0I RɺhscZZg^:kv37?W;;;Yf{c!0HY\ (6rІT! k@iB1&S9D Q eQjp8ժ["&~_(NN3Ê)CV_eIRrA= "%r0 21?8z P(`skKJu}KN\\ ]+EXp]Wrn'V{I>DaoE䡅~OdWʒ$ ]T*vŹ( fYf Ƙ|? Z 0a/I)zD~!#JBl:~8PIhBVv;^w qΌ1߳ 7{ `q{ޭUgɿtᅗ.Ĺ,7r~"d.]WoJg>C!HsXv^$&BƸ Z3{?/ٿ ۡ<+W4L͕n#־[zb"\pw"2;+ƏC~aѺtq&:+]T';uȓ!%ydyO#:)V5wqemn&$q~(~[DxoKł Ӗ.i-b47l|W '/..,,YRc%X`%C)whnDP*cGzI*K/^S'OiIaMNL}ĉƘ4M]>77p4m4kkz0Z_tc~~arbr||^2fYv;l0̴1Ѻ:vxs!^[w^$/9xNMMz74ݭzBm'S)&D4Z /oު-`Z*cwڟo} u{aopk3 beqoε+}>G}T)y @*\# @'EϷ^.+Wq!-oiN>|/ _ނ,*"c{be"J<БFtqd"U%AFYzG:Z+{zfD Ҝ dGIM , $v8G`46(r$VqWw3Mo/깕tc.90 FcD@LXmtFtnmll//m+͍Kg;qhqjPqV[%!Acr[( +gT 5 kG kM1tLI.#*M3p#{a!BF~f l7$B˯8 y LJ\A:DYlF!VqDPZ1β,ΊS껎n1nZY|K1FaqP0*W(r V qԩcN4جDDc c{>t*B| ׌N鸤mRLi"c\p@yw*y: F(JDMyN1ErHfv(W_<55^Vӄ~u0H7U&^+b^8c|gg 2"0/C&8R()lloюƴ# V ۵{%٥4$HTvg8L(唊SoQX`mmmj!cR2tPBhKV+=51ffkӞYL*e\VL[x[k3d-j8ySh.X^w RjhiY\}s'Ii4# znQJw:]D rSSru(\ $I9JqWHtWWW_|ĉǎ|m+ZSS˔ij0[j@\CĝǏ( K# Q=C̈"J֐Ȁqp8&|;2)T oQ[3k`2㳁_[_}nqk7"BccJ܅snM/A؃&3Xm1!oA[+Yu4@ ]"qcS! K0~W,;'9h~Vuɉɩ4MWVn<#33jϟ?w¡ǎ4Kwvlk{[)XiZ8bxrF܈./ \h~~ɓR)IֺVZpQ(G{]\puˑ{N1sG# ݴۜEκq!B(V*@z?#GK/ſ?3foFqګ:;5=ƕ7Lե/ wv~_ʕׯ-3?o~~#Ġ*]@3 htRJ̤)M饽g7鸯9D`bڗ7[o `PJkޗWqr!<"s&.ǟd͙iZ;4jF+%^QZkFե\0: ۳r?1*Ɛ` 8ηWW7:jRyyԡc3OtIhr2 -񓧝Je3F^!gA2%~~hN`1a-2V(mȧ&K,3WɠZu\,Q;t{j0H,aDzuccz%qZ//MNNH9wẐ䉓ooאָoÇ7k?pwUB]W7Wwģʟ\~):C(Zk 1,d|˧}liB-uDH1Nq\Y \O[[=T,滽0tN8Wn̖ W(I?zU2[t8AΗRLMNr\J7nlm>}T*,=WHTzR:¦Fx}zzX,FQfi9BDV+SY.b._ 4޸xq8aFZC ґj%RN1X*9\b(60ɩ4ssƨVk;dbpAF{ 7-. vKf^C(ٞU{rwAs_괓/i9 VƹMh|<'>O}#i U@ Y #DjPX7XRpIZYK:K# ,"RB& X$hc{{ݔRF4JRtCto{p}}{0{s/>#)thFv4 {Q= 6@ءk/ze565zA[lDqT(ev]lx'(8wק&kE4!- ,L7 1?Y}lq:*K5#y_M=b9"rig$DXc 4N,ݼhwRM?MD!" `a 4`qTJllcbƍAo}$U#"玜tG?1̹.ϗJ!x: ;#>q1N28C&DN)p&V&5,Ll9|,n: =S:j -(rݞbe\D8kg퇤R&cTefK"gq49aDad;\eIJHW$t4K32Z#r)XF.2 o%RU˾W(o缙ΘYƘ skhg*) CZ X UXvZ+樂[W>ϔU!LYdn=RV\zȑ#r槎:F^? , rV*W^) s0VVG=Ӌ+~|||qqa~~&vw4HXc( n7.G}ѣQYClQu{]52KjRr0H!j iWW+Z>_qc`=vԱGs6zN?>5=e VDm3S |X Jٽ&w;3L*Kx|kN??/k3V]9w>5:'¥_~9ȋ/MNLM̾/w۝W]ׂU% O}jbbrym9qO~IQ;鞯o%؞vczKD U||azO|g׋E'KBRfZaX(HJ2 Aemu9DpڨL a!_2Fl(c-1Lr 8H.,_ bu|89y1/7x hn3khm2ooP)-9٠{'?}MmM/sʍk,}S}1+Fjgp{KFQ% eaB)HSFNw@4DRc(#ժf"< # 8ir cO 0Gyv!6`n<c莬$c s1Eܼ|}0H' bN9c&%WEFr7/`?xoAi XY=<_r(^{UEZ8ΨqFm߄\/WQf K``<3+k#\l ,gBTi DW~͎E\72s&X}']H43\Yopm` ry;CK,g1XQn.K:)@ ښ[n 6 tMou(+}݄cb@J)T6JlDLz)3f{gd *A ^ml 텮cfgUFt\4&*\.L1Z((KLV4V}i5jђ䓥q"J5MSϕ4ެʌ\#B߿zj}l|vvvq6ss}Mpphq=/$$˲^B\nәz 0GK%kM5 899! *y `@ AJ !cGEoΞ7e٭6" ve./OQj8s@skH~/;BF  kkk+7PDp7vSs'#ol7{q,@D w]v'Kg i-D|e@J֨j98@Q'I\93A\ iA#:Q!iz !4~h=DΘvHGZcڐ|!]il|T)377 dFQy))!ziFhG6=%:zs#VgK/-f5w"2""cT,7^׃Z;ȲܽyޔU 6pMԐ3 GYicb!F]q1&}wy?u9efeUW5 !}Q|}~93ǂba{A0#(@Ȑ1>M>xoW D!0934Te\ۯfojpX*u|@L-DƑ tXaN80@s2"bD۴]dVKUz݄&o%8Wmn: G`q LC^Y o4Pz=k=rr/]T*#c[[.法3loJr(@J8ƌ9sJ'\/# r rDRVʵZ5nmMud&ÇTRյU=?JI)\>Nյ k lmmEQHSJT,TizA8'OYc6[Ͷ3jΠ>xؑ4MVWW֖G*J&뵭k׮@6w{cca~z8` 8gu K5òȁ"\  "drpSV|鷿uf QĽ+lOLX s5jns.֓3|5Zl!Vϕ^7wcayeimm}bn %M`67D=HYTt6$ёW_;k.Ah8Ԇ,9458~@66F+A.3@ \Jkl8q#6(\]NgLJL@:k` sQ,3D:N~bXn0ib| XސL--n.,7.ZoM$DsSݺMc;V|OBC&R@gֻN롧>? #dxQmW8 9N3876 48JA|49 vbX<9im"h]i e 8v<)EǠ$h7_bIDd!E"r?زj<37޸vcf>笳`E64;*au^Krnt %9"14C&MGRpH mGD笵 pwX3ilU`➏oR!zmL+/w:d|@1MY;[3$`7$BeJA֎'IhFFq:HG(\\^=%<  1U*܂q"X5y* ?ϲ\X(UJ"֑+ R$s Mƙu:Wu~otJ6rٙKW<{NJ R pyyRJ%T[׮](2nD[ o3{3w@C>fI ),TJ 5R)ҼH 8Gc\=ԩى}"޸|j;tyޥv'N{oJKׯ[ɩViwlckXm\rqe@![[Yx쾙~(J; ) .kPIoN43#GOoJ!AS͍7VLqyeBX`D@Dر9} m)/\<}lhyٌv}QU(Kh'_:9Tv'{'qXK@19?@!pJR*\9PJ:pЉ#Alzj!H*8234nLĶ 090 _ҟ^]mD@dɐйFidˏNkh=0 EMaH`0g T(kx~q)UPch #GiłVJ)ϓRu R2)}@J!g !cp[L7 ܰ0BB&ZS.n&zlYlPRe١Op+OE\JHF'Nl2~UH\>jcrm2]wۗv?jɸ"hnlmϞ[t@ik.X]"4:i_wWfJEo) 8b>Y܎r ov${;Zق>8ĵ=}uucLv;WvZ@+"@8gh̶ۙ6 chp1Dv;?ԧ;2a>t'C}w#а Y:p=b=Bj$mqP^0N;,8{\RǴ{t}+󍥵R ;`}soϝ;;;=Y, BR,.-]xX,OLDl5fZV+A8PJq$&4M1LVHСÎ IRgi}cKk04wcȝ:rksL.ʳ3F}GJwWVW_}5 0j6ӧ2AoGiJ{m۟`7/p3%n[^j_[zm@ :ΒuCʥ|x!܀Y2.#ٱS<~ju|:ph߳uaJO-.- =tP7Vj#'~ 7zA%Z\X-"f` SgNק_ӯ6*t~D!vɸ2G?h,-K6z'o5w߽vmy@ 8G$r l4tCLMUNѠͅ+sW/^KG+No߽pZ8u@Wu{wL=qV*aR2:M~睱ǙMSR .@ $ L|neNg̺B>:dL(iGs8Y !8Á1s)x>gLyw~nz}^7:,K셋`zjY{1kzյ0ZЁvb#ǏSb!/ta(8vn, K/eɓv'(EDd=25"r"\1W_ѧ~R.J)@" B7p.\ngs9TS!Pz9]&BvSn/{ݭF#NN+Vz+NMsϙl&o}?;q/Z*/bV+{1ؓ^ ?\5l 9G6ֱ"}S;amEڵv{&/b7ΝIrlH1moșLeeG]hDZâ#dT2E6JuSO>pFY* RP*QHdi}K$r)yykvਵ!2FF !.šdF} #$놌ltmuh9S ?%b?H} z?k sW:'|~; ;#ld2@?۹eN;DDN'_xޯ/sq^I,)ϗ2F͹:5)q.˛*+n7 tWuCst1iz=$\tӕɩ-qd{(Iɵ |Dj)$q2%1Dl.Bx!S=c)bR# oZ,I[; oc*waCU+gX)˫ EI){ZR,GgQWRDd[ͨ#!"cIለ'(Is~s uhoPJ%: |n̹nZZ¦%x$E !0Do?ճF<~jf|\X3h6j}4+D0@F8CMaɼK|ޱ;D7r>Bw2ߎB H)sw@ CB8뀀ku\ZX+yJi®^1ީd!{K5\'w6b;3G" ZGibfs0J*SG)r+`&M @b&zׯ_ 2g3٣G:Kvw>tHVhƸZ ZTRc`jwz IVhl&;555>>x6]Z^X8*\|sv;;kffgO<++ki\rX,sbVy>scG[آ[O!80y {{7_;iΡTΏOTّb%k 9:!T5E$&簶B$ dr'֖Nb;~s7Rҫ /\ k WKrX2 rul2RcJ6=wb|.k7l+$,ٮgè;8Ҷwp~+ߧ h7 ]~-e *Fsf‹אָ5"G0-qgi93OTəj%1Y1/3Y7ʅB)UUfme!#ߛ {IAYr~LMKu$$L^2жAHti6@JIo'xxetg,LRnُ?yub$B~gzdd?l}jCBv݋7 w9n2,ˣXqG1ɯXNL?y[60(s\(RNjuG`,FIlu61(BFLH"&FHAZ5"m[DdΒ9FQǙLF:mR;)hM@O!j'YZN 922QǍf.WbSjX\p%ԍE:+Ct'E gDFy[:k  kԲ6ȀY # ׮] aVЋ#'OWG'RK"Cp۰?17SO 7 {#@Cvp[[m]4k҂ "g#"{~c5~ĥ$`یWox/owUq{]!I@(BCgyKt+ Nc>=>61>ZOu2Ʀ('I1yd.! DtgA&q/^~&'8Y391eV{C)P.ƊŢuDa5MDvȑjd|j6W'xLJtz-ĉDdkl5''K\_Y!$?y2 s7ZA&0hgBZV::MU%oms3:M{׿}ia?LL>2sO<"X__LIlZq9I67:~,xfb|k峫s>y^ן'tG&8MU xo=·kɩ{&؅Ro|YbQ2iOޏ P![Y{#1GǏ3Ag-hygD< %W/_Unj$(UϜ9K Ƙ8=R2JxV!=r숯@<14Y ^&z:MFGGlp6I 0|B%9q@92@o6NӐu42]H'SD9@O}._[hG[ХBV[_sv¹v5HFfg~ݣ'+O%i:~9ީ@$< N'IӤT rbۿ~c3 l>6V JRw:|>YX]!_(1&nSJy&T () ւ#' qa$2R)<ȅ@Dmk[nf(ML&̮:#J b &\ȍfӯG˕2 {=|(dc3,䲸 vFREh #"GΥ F .pawIt774`j7u YEv-[$2"*8r콗^\v-W'c|$2ZɚjM?;eB#lF  ih  ~O2.\8owϽ|[Ril|T*5!e\.I}bL[g ek>$RяPM@')CqDukù } #Z܂"og>G_vJ[0s\6MS"+Lf|bdZͭ0*r6\+hXAӾre~8[=|m_{1bnnu =55Őqά՜L&TkL FF;B%"luGs '%>}g~_C`BՕN-wK٩019QW6V03;a-YBl] d ZT*c{Jz޻n+„6a.;q|zc܋R]o{B^q+flL!,/|?z3@ƕ Q\1Ƅ&qwxdWrx7GGǏ^ݖ D,꯿ޯ__hd2m()båbȑ'5_֜v4l *Y;hbZ2.B pQlQΫo/ Fswc+&N]L63LӔ 1\< ˿0%2.(O;mHFn n)V8Dk'њ!L8c2A?j0c#f&R.1Jέb#"drlX\Dq¹ <:rJ5IuhwΝSJ:tonn4],˧NP': MD%BB.791d|Fkk3S\vzjblt!¾jtlq,--MLLxlVW׃LP.W}ߋps$1P۝^KJisƆ۬Kcd/}s_xYݸ6[6Z K@Rd2$~; G#cѩ:kUG2R0߯Gi4ھBO͍+xLfGmL?H9{?П}!/s# ibelV*GgXQj# H$MS!\0 $)BˡjzoP!λBia?RJc6 j_|Ȃk7V^_#$gI h㏏T+i)fdCv}cݙ,>C :n.zm4+\zT J%{>7?9>NyR |tbH8Kś[KȐs!spB?=to=@I}}RM` ~!V;ai$qҸ|94'n5֟{M jҋ:f&Ƴޘv퉧%!GnGYv$w{Vn*Zu{1Cɘ jZ-x:3z_**^;Lt*jPd nsP^gP'5 y._Ij“9Bd,I1aRB>Ϲhw cSSSiv#=j#3 9ˤL4 r4fkX*y~~l,6$IQvh@*_bk3d/o`VYaYK-)|o=rji2y"=!4#N"k؎lz#j`΁@~w͕FV}3 W.JB$DGdw0h7ν!.ŇM88Ɔ?(dM@"O4%Fkm>zʷ,V+.;H$Y_|ɑͩLQ4NNd{]Z9Zɑ2v!w}ui|/_:7G)ϣD3!ڞόe7/_:_ 3T^Ct&k/e2Adr]&Z'|ߓYmҋvb!;=3笱nڵklbt } c#)F{שּׂ5 'xJ0TJI =Fǎ>(c; 4b8\(mn;Z+@Q*ܻv}A ˋ|?q :Q`,H IuhgRqDۮt+`"G6@6<L!HcmJz?tJ@0VL4gN/V[/wi觖5R[nZkKrrd,Dya=Ni޹CeRZrBiJBv4+S6q][[A6k[j\(yZebZ油`G6gr1pmmMp>:2Rf=K$~~P*&fods'O\u{zstttfC!Ġ8 p!FƧեvg#@ 2ս~zzk|pZG Y?8Wvh:mҨN?\.eCƄ4.tMR.xw=ѐ۞_o뽷}/Ƹ- ,߱! z~Ws-#'NsϑHma\֤=cccSSSZDl5n`\>7VWy~& 8tTtjrj'B?:~\Cӕb4J00@(R8B2FE}<4{p=9+%w{-rt3(b6w7 0je[~HJ!Ӄ!XSg> B3g:W1Ky.RJqlCŔ#! s(q# 8 PJ!3:ZtQCA~%]\[h\rJ07bkװLC8E:'^˓#/\7eɏ?1#yRn'Mj*rȴ5Z W^$I_}Փ'O '|1VVR!9`yJhJ4!q9 I 20q1Jr^|~Zߘ[LfY kuƟ. wnCJh5r)AW2P3tQ8 5rHDc~_gd2"4҇a:}C=ܡ=-(>ob9omȄ _ LΑzLZ h2)4AQusL00ƄQIA)M ![{9q)1Von:>p`ZrdPqv&MC̠5xޑBwal;Y$!mR54"MNk;o+vIҥ+k7E.{}l0g YcgOfNSO9 ȇ(p9H dYF6$&I"d!/k4ۃ~?2X6㧚 ؐ2圓\PYV7_gb6u~33~a tȢV7l4$v3${]ۈS q 1k]Xfm b(QЧo-h%;$e4(!L k)IajD o\ fH|(@82L\*P:EL0t{rvÀq@4`x@ g`5}T@EyքFb+BngͧT[~Ο;}?s_F~ 8bBiy#""ВuYhP}xqwEnvH޶ӭҝr`ݍ`{Oζ?"):G!ŰK\:cZȌHy\JK/Y_*#1,puR@Jgv0`<cֹj9gI5 3I|˯Q׏O,SBDC(v=R$UJ1MBW#0c~/Eps0F*Zid>]$rYXjd#d֑rt|A?_(4MO{Wy^XVk֘>Ip2AtN,/mcðj8WBȑ#RNQ(dj"nmֵZ5URr0tFGǐaRQJ!2NąB\)Yc8~MOϜ8vr!~l6\\Jl(TL2ruu'q7d2." 1ǤMc8c|^:#J`P4 ;0Y,(Oz7n,<āg\pimucb|뫫rS?Vc}}\3v/+7;;[潇9(Uc5v߾imJh0b &DW(rӆ݄ᝧ߄pkom7ezHۑ?q!s⵴Kt[jL%zzaxaJ{S zA&,zcie _{Fۑ'2pd,Ml w[k&30YgP}#C&R)_Jv۽b.Cg#c3gNpds"$Yy:_#" 6;0} Н7~B#~0 #2a8(|xYr#{zʕ- @X@ c쇱nv֗O~S;)dwSݮ~ xyZ|{dXBwcn͘XYG_$ڝtЏ7ַ&'&Krv|>{ť-c׋bTk=Ƙu\7IN WTBoV֕T\>I:bH)' WJ66F p(P:~xsU !xrhq%cБυȯ<3AmZ2b 8&cӤ:ujј_}\YYW~Q:b|hXIJ(.%=qkr`wJ]^[^~ݳg/_:}A_e8c!8.,rd\hMai .9ÝdPݷ/Pmw~;{qo~\x$۹BA9Otuy%B!ڼvrc}/j[R[Ε:@2aȈ]o?GĂ! 8r"rZkt)1qzx:wS0M9Gq|W>)bb8t1i~P9eSGw7~sq}.mF$v8ƀ zGFJ9ι88yD1&iujW*}FZujz >777p I*y[o}wу3 }38Cdl֛7RCf)% BDdKR #TƘ+D^o~~~|lR*j@twHM r.][kZ:33]fg;d-QM…B虩{>n7$:~fбJ~ A,/ί7nX:Xv$2ݳJh vZqVmwZ-9گG{{mĥjR+F&-Κ$ |@*S!6QJ_; x> C쎣=wkjϿY_G=&"@WsΞov}qIJGD1@B<~o33izl.oR0ƭӴ#ǒRtgG"Ly,9vq: z^U, g`&MS3Ļ vmc+!E F'ljCQ~"s B bwƞI@"0q?칫}s-! 0S9~ v/mw񶬪D)m4X&9g.ƃh+Cm|Ns9ڵ5DP`ngaaS 2ZR666n.Q7[})2 q03A1zw@08ciKZ2cw We\S>gXȐ1 bCiw/^ݥ#*Ή۩on[dQ)_ex<VJrj5fgfz+ |4G? }=ȹJ!$"Z 1fFH˹ +ֹ sn/מ$tĉv /?@4\D$3ś(}3l= wkM~ey"NE >85d˯>CJUʕ?W.?_)OL` f|O!u1ƙ '!)7o۰ Q 9iz %czLsJR)\/ i8]H84`m93ǏgiSfƧ'MM"G)uw=OF^4HJ^&n'X_[#m[zRZc"p (Jpྏ=pOR&0@.+ J8I]h*Xף8Y\^jڵzX*i::6699Q777x\x* rϴ[݅fGRX,sd3QrF|1I+kz15ccLL&;;;kV///W,ff#R)2z- Sc f, C2ٲ ryݭn1V˧NayIJ\?L\NZL^1YR\~h큏dfeu J(nJmX5 \[Ybm% ޗw{͵Nr/I3[a2ۥ eM@7i{Gp1L;#۟^77 aw enuw;E$U)U-kR(Ζv[9>T5>|CМXƄ+m7:ADc Y˜Tk0`llrIU6}rhXI Ŝ5Ƌch Ҕz8DjL@Z֢VqÉ1"\#GlJ#id8 NsӞ IDs@&:% Ivo6$%juඹ0΋ⳟ7=p}J)ůZb|Oo\MIΩ\=O^zeiiSǎ;l|7'':yҕphs;D@CJJے܋LLns3ydƟDK<՜_~{w\;nbjjbj26՚98Wn ΌlGAwrc;HnN{Tn[iuqi@Tl4ss~?rm`lZ]iu s#w@bm%! W-UT dI!=$`h\___V\~h,nrwcV'd$b 9SKW/Lδ:R {3SfT^1Bfi_x^:25zFP":8Y=83u 4f GD6\*V֒$B.-,+++ǎ\I4VֹaNE\kZXX.*++kz&~!ZVY. (H \l#nwx^ A݊ 춎Vs UTuֺ8J8c|>W*#g(^__ w|c9"v;\esgMCdtҺxnφz8_?lU*/~zjf|6]81B $88k /(fzȉ}L{Jz3?/]> E\v/\@`9ŗ4x+;19=&gFH!uoS>4v>#o)Vuo(cXw{GqjAQJk ֑݇w按gN:4 m\oMujw2*%:M84 _w@qF׮\VK3Sccu%X gv%$ &NLN3 R^`,l7}a 0t G@`Ӕ Ae.\qQ r[͙HЫe N:lrʤtj ۃ̑>jڝFLJ1܉K{f0&LoGvt 9?P2?I! z~V[}뷹RDrnD}c(d-s44 V#>;dZ ݩ3ݜztBVֹԤ3Pjaa[Fgj,0ڈax⒡]&ȔKYˈօ\^D٩pQ3.ey%_\lljFsrrzlthΛSđN?=_]YY^YѤ Җ#cK3*kq2ōM+|g>ŵ}dL4ȦСC.\eL.8K.^dllC\nzjX, a S.VW֢0KRB;xhzkkkЏ677ʥh&;tֹ( 0n+Z1M }5ƤqLDZ9Xgq8d݌ِ&f{ri(koh0.[>ph y>{㏎Zͱ0*|Vet$g0LNj\!8\6؉Z\K[?˓Ӿۍko-"7/3Q)--lu\Ujy/r(D mK$B 9GD .ݻ&=̊?+}Bñ_޽2#^aZ8&uAgj)(e~/Jd*us;͌OUkj3_\&PnUpLL g֚ s^` YqW*!vz\FNU'랧X!˂jL6v[[b&Sp$Zk%$ is]c@PƱv~չå:Yk\ mh0d1tD@jҰ?%*Jy qPđfI\GIWf:wϿ.Ad*t08d]xN?"{1@HЦB|3s޻mv=8-!etv]pdgt+뀈l[w@0 $)x)\Z|bok516:+/ګG7;j+'ͭƙӧ̫/++&-[c/]1\`%pHbnss9\[K6z^ZaudjDlq=:vʥ7b:439ovkk+? ./ngsl'?{_|F ]ʀi_0ŀ!@a6VZ\Z=}9˽ka~sz 4ֳ~=ܠʕk&M 2 #@q[?jIK~̗Jʄ`"6ѭv(T/-/z|0oC5~WIի  !4?7|?tz[[l.''GFFt{0+43@ ss\Iɓ$esa\z1599dabX'Uٞ1rZshm( ;NZa6eB aI$jni>̧HM\>{F0y?yΙC{̉.RVKfH~&Pl٬B39Ǚ(U!.e/؄wc3ˋ{Q&Iۜsmk˿A+Ȉ{8U'xĉãc#L8`.IIY䜳ky@g5b1KQ?:~@'"D^w^j¹ZAۥ)" M|<)xc{xؕJԗB۬=5Zܘce2Vyyz 1Dㄈ2LB8ffD4M^~C;vdssY(lmE˄%~P@FKQ0șrbo1-'-$m6rD 8Y@^Y_msSNxq/:JRk$lƏ4Ljz\Jm婑\4n^I![sδC{C'@wpk;\=ZL !stXjۙDwLvv{Cw4պ Owi7uVטaSr.u|D1 웜 |sn8|dVe3sWMOL{ƯOL#<s6_Pämb^?|/;q$ln~wlmm.<_v}ܛ#{ +ϿbN rӍC4YKpGOjiF@0ZQ ;7v^ΗcKg]{ xfff7_,kkO>OO77xԩS/y[Ǐw:smmm-<911~ҥ.ī덩ɍ.7K܂vv}9@8.6 (a@$l@vO1"@bА 8$!J`c>dm}?>;vVӁىGy7vRL3uC٬q#^$##R/kKs+]p= r4+ W BP (aIr0ɅSJv$pHN GOO9t4W(?YijUo )Q/z4Ik=gqD3 ˤijmb6rcsEd3NwsyER\ ZX(9WVV}r)+˙}zZZkn!_,+t:L&I8L&IRm 9i:Ԧi72 Q u!C!8cT+QC](;i8}=XlŜP'246 y6W\{s 77:WU8%e.Fq'JE|1m4V&O>!y t031?;Y?z|DGgb+|ϼ‹I zK[ rsx'\(K_:xh\?9Kǎ3b @=S():vݵրQx{7=kB|tM Rt uJzIvC{}ssK2.<ΤqQДwprrs*gX^3( [eDRb BnAU #H9\\YY-E".A,^֫'P!,8 L)9prnװfK9Eac+ Ce^ɏ? do uf. Vǜ;m$ aųNzstoK/|zfvgg& {FFy5O?3xzG_|$:pHPzn(KîA pv:pls,amR΍ўLK%"vm稘)6n`ɭpDB򹷯{/(TVW֖W/ °鼺^*ڝ5t4L\}Ázz{uU'0ލV<2S}+5Z 9ûowRXNNgRJɅFv:$jG?ܻxyR)EmA;;^?Ź+o kFN֫JG}ե(g_ӽ^O0F1&4bXkXoVJ8N?󂩯}O Vh6R /ccuƁqw|)Չ5HNPI1_vPS>'kroT}./}΅?22vĦiScVܷ:x؁j6q|r,H. ܶ!Nnɪ^'rH8Z\RG?޹6W6(v$Sʂ%6 i\:qSOkdt4MI !$ggf4ED$IIqK#8FʙǤpv睷$gJǦ'k٬s |pu&vjB`h )o6_z+gVW*8ow~a?ӧg?R s>ӏ=ə˰ Ę j INOMLO !N@z~% 8T&*٩щͩ>o1bqǃv b   "C@!&`0Mb$G~9C1@@pN]к=%^s*"Vk156 lZɅj.DtUGi^Fji_ bŗ(@@ }rj@|~kyc }я]tysku2`~anck}lrGD.ߛ~7WW7ffoϟ:ok7RS'=SO*_x>fD'}ϓRIb\ bx^70Utj>-hrD 6.Kg$ B%%5 )D3R(0cl&O8vJ0Z4v^ZZc(˝^j.\R\,˅R9kr|e-9eR˘p\+1 Jj666ݮu>ʜ0VWcR< |^+%յUklZsTf{mur֑QJ˗/MMNW*$ c0q}}|.xZ^Y. RI l4&1fkpDTJm];):EФYF!9H#1d) 6}?oaƥReu%BxM)16>/|>I=/sXf*z9/R/Y*tqѠ+*cvgN?3?Mͧ>+\paS#qlt̓Ҧ6SSRjZA&ZKy0 ]oTfFFZ)6[R[ ;m-FFrM1ݳmCR"AHXtq!CXd^9pXK\ JB9?%jt ⇭aX};FuwOe P 77A ++KNt>d3 { hP^P-z:7Ϲ٢l1 ТiB^zq7۽O?)<0$vh{@hx pwo7.%2}`]0"2q"SISKo^ y֯_9`(}ā1G?WS?g>TV9q:2z)Iz8ai'NMX)qQf9zdbr|.8O9"+dX6"2SuU8[g92Z?::a]\Xaju[~Η_\ZMvTKRIb &mPX~vq=?xir&wYMd e\qr"g$m rnKSK0G 4Nx28 ܁fg0;6aSwZ%<ϼkׯ]+?x{Oށ~g?(An.hT.c„;y )2QҕN'yA36R/sRn Rjtd_*BH]^] ^_Un{rvr^_&erIJf\.g"߳a*!&0&^sJ1"&ݥ6X 2 h@k~OrBul߁ ?FСc'(r؃N{Ou "2gGFf8R-&`G f3|6kN}<>58\uYc D:AKҝPQBk  M"Ƥj1wu=rǎժ%χz`]% ajCrce4{{w+a0n6\idW_>^s߁qlT 4:"iADB "MSSho}+_֫琵Wb c}a8zWT'xz`NA C/N?ƽCpy|/nyg"y!B `t۫ ##$'|l-._v{o¼,Aqs`ghO!A* P!~(X]kEH]qowj+j駟Jdeul9sϙ{l64M!c1RVREYDBqY(reo}Z[MzƦ`L///{Ʀq/ވ5Α`L>SRDmAGp3P &?CJJv!12z\k2m6#:7g \ = MT-Ht{MnRSR2W^yձB^2JtH%{lV8sr||6;j {Ü}D3XU X/y!w/p8R`LG 4:ݷof|bT*A& ,r-9c %.O4H ]Y=8n_D.r{2 % D P!Jr$ Fb|w;m0( Rxq?HD4$ $i4*rZkl F0G=%Y&p.9FKtmunظv~JAX߷of^X$aѠۭR RH!zZDFqZ Y΄nkRzrnlm5X..@R)j4naIq`̹4 9G:nz0.yzCgGI^ཟ{&|dwmWD5@E(;:2ghgg9=9+3ҎfD")$а6h]gUeό ?^dVVu5 蓝{wN7@G %-}˜KgZ|3z^f+76ӧεVpeƘnT]Z\z?444226 '?%0aijaZwU'n9g6Fk 3S{2D ~gKAgǾo^1_tWuk`%"r7[ _~1կ~o_T2'AhƐ1#rR98N864\E֠ cXh4:gO_9wvG llaq-!̮gy:╥ō\nǟX_\\* X[X_b}{nA9n)txnu|>͆it4%#"gl;tKS@ ,ƵŗZ03H+@ 5Q.G'ڃę"!zuVnw}N| c!]92dZcIsUɠ\/|]pWc $Iad{q3__\ ˆ=xd2aԋ]nIaY|{ywyw讒`gYkmq c)y(N_9Ͽ˯}LkEN,8RTҩ4,@81:ZN<)?޻9]q=CZ3<6l(3(+L6Q+ a.dikfrR'uFp.$I8IJΥ Es9kSn9>ok'Q׮nd*i6RR!7:l Qz=Sc$ln!^q=['4ZG,щY9gtOku q`H8 "Lg!6B@ gƌ\^m?x 2xɧҵqM dwG>ڨڵM67W&fQؓ:jxz!7:+rcI6We|x}G989=Z* XY.6q7qA ALXG)Vߟl?9$H:}Z \rF.#pN B 6Pe2tkؑ=pB0Z&Cd !Rj^z RofL{dqW nVJ~P[^_uZy3ae2\*T76^&hZjIbNB>#cu;NR.[2avb|粹FŁBnAJ%=Ovpp |O9 GFL6cf2GneeE09\B$ !"mmKh˧˘N$c\[V hϝ1fGq6Zsni&G] d=!q}fl vN'҉m[_7kJfUk_=wsz/hm֟s?w衳Nۍc8y|&mwbD #L/J@9kw39%%O}O̓_X\XcmP*+Ib̕ pt$$ k09Bd(dG6ID)rre188\]//V뭌zC=P =e/])[)!Gt #gGrH>g 7ڕkuz!Y#<սV&˹Ko^:WLDB;'Bkc:w%3ng8:,}pL=->B4;;~j;1N6Fц1@䪫܋93ZgcVlNuZ8F`drC9{Ǟz_}假OWJkaιy&'{ry1Z20zN&u|mXG\>W+^ܙ7֯ط𡃞TgOi,KёNw£t^[[[3;+q̮\.{\\ TY76:fX_[Y.}?rqt\j6ۍf\zܹӿ#{mr9si,lL T6kde<ܨ'T{Wnk+ͨ斯]]s}N9zq\YY&t9#Hg%1Pc0MlN#mZV'BHFZK`$m|#OW?{tyᅯ?o:|p.9?l6=VX~'G=ZNS\=/Tkř0ӍE{}{rHێ{Wq댶:+RI[fVo~@.v&NMM6[z^X=t 8Pe}}ʕˣ#cSSScc#f}}}MJf< 8^$,岾6PIyc.xӒ0RGQL8l82Z*mT:ћzG#ã5u['F*/NK1;Hsi4AbØTJBc;s Rjd'ݾC5|G>z1N3Xjw:{s`JZ9pjGW7*|ā n%IxUP 38BdZ)ҟthOX~ͽ׈q8YXK/Zo6q.ǎ:rpt6ՠPEa #耬rlz`lr8n.2omorCmKVu;L2Xz(nU8jg-ɇ|;ZuszvV0dLik# $8;;LwhX[[][x;gu_r *,%>#4a#0%&ȉpyQ˅N0Ʈ\g'z܂Y\]ڻo<<{wݸ|0|>?ƜuAl=E"^_K<ϳcsׯ5VY5=M%_,9ǫ{ >uzcmrF+<~Hӝzλ; 39Q 3(WK`<>\2.v5|/[>x¿uƍkxͳϓ$oBPz'N *NڟwUaB[N.$+OπxfB8Apst=\·xL&ۨnRnOO=3}gcV3b";K hllVz.Vnkrr2_MyR:gqd}b9"dF7|~vvv4a`LyTe3N#f$0[8GQ*8M,el_DXVfv͌ @ٺuV&ٵk<%u{ׯW*ٌGYYE䖖21"kIfO8Du9 N0ADpNwc pNYt:-3Y5ZK|Kw:pjDdNyw|escV9r^yZzS?t/|)7;o;CbSgEC!XK,Žg? hv>(VkM_{oT(dBPP28AdOgP\%U. c0R$ԙK_Q "/{dn =zRuڭg?~kW{Q4:6:kW^Z33\nrbbNFۏ qmm@Y nu׎]xdžЧsxR8wzzB) `[k)ԯQ0ER,S!\^zsjQWFG* p= os.vP 9,Mg dSП@Hii^Ȁ^˵ݶqR!Ύ=<\\U> qݮѦie OuݽkNgL߻xS'>b@pE?u;NruehPY? ̞=BsƐYgɥk{8G?zz 52DBt yΞ?|xч};ET'/sF=E`:px( F]d >I/YX?_}kA1_!f~wTƚ$IwMy0ٳoI5|ߕF2#2\6_r)[F0+V7MOM9Ѫ5z80˱@=4PWxLVC>yb=6*dfWm47"ˣA TAmv+婽Ξ;gdhgvt}].\w=}عs磲, Fcz"o_~VO#WJsv ;Z,lC0_W9Ç:^,&v P*gePƃ3^xpzxLyP9Y" =s^εk7O<s11cYDg?gfm}G~ Y_%cxIC) xn+3#g|&}̒2qZ0sm`fbj}F}lp$c!sN'Ii<ıV\AڐSk7ca(268HXזn8ܻo&cMƄRd?<\VC.#%cP3d:JIb)M@ cJZh*KD`u:g9im n̄9:vۋ&5Z̗W_[SgZN ѷc$o޼䓏WW܍g~=Ÿ凈cY@DBÛ5뉭br&[O|칏rc#^ b)9G EO.3Nŵ__%8"DK "!:ˈ, Scc>LmWv~7kQԻ5ǎY^Zr>ӛҒK(pSX_:W'kC_=cѡMR\2:\!nbHY{<}R2c $vQΝx.~Ld=c"JZjay" 6d@7'kYsELtG/-:{!%Vk{B.|O 繡mE@p0 {t[y_l Px3dGo:7O]WΡ!)UT9Z& \3of;69{^<21;Dnۿ#R|*%¾&,m%|f˄nn}NXE.R?Vq@Řp+|>#;ym+ :sAS"X3G` 'ɳ3ۿ?X녅j&đDVۼsR.;~lϞrȰfpxkXk2lӡ? 9+TbislbѱbqliQt\ biB;0 r@p2**ՠ\~ 5zO>"‚yjz`cϝdLLklL<7;ٳwXcA1ƇG0nQ'I'J\^WՍ5A{Jt4P)Q}?=*<ϷDQF/zs;pcDhŐ=>(/Y#"Pk eL3(cO0:j/辑}^F oJq&q/}Օv+j4KK랥N}yLyxV;t]'}B136Q) p3gSNr@I3n"Ł\exC:@(%ubSd͹OqƸ1F$E?Q;m둭  RdΜ=/}CQ K.58Osssou@YdeeSS0RZYسgVJj7ox> G-A2:TXYjG'oJS{xnn.9\:N izB`2 LS׿5X3} 'F` \lm!cX$R U:iv #mAdzB,_vw8z?#c!zuFˋ/]44<<55*X2 8:,:rN .q }T?͹)Q릿 Y"KA`HH$f+,W7ky]_y[Nϐv$pNF\.cLRzݼ6w MMpVtr!m\d$;$h{^}1J)GdVJkҠGHѲtinqnCX6Yr H_9'F[g pJu/(Zڭ`v˟;XRVip I 6Q;RCG:p`ߞ!UKJٓes.V寃C# oB!ɉ}RDdfwA9cbu\~jj*"XLfeZko?ۛƖa~2DdAp O] l`"W3\"CBN@I6)` [!V@C @ӀO33vS~&Ym/"{I(}53+d ZѷsRH!S|HŠ\~LB C%:I<@XD's4 yl)os8N^}/(QLRD\ۈcs$4cF'&8X359Yu>219kEQud8G<`-+ݞ5y_._d^:C'@Q穧g7-By6o-T TYdK8Dvm"lZ*8gZcw\X*U('/k.37Yn[,;L0@m10,w6C9D$a7hdf8!628%HLy=}\ƳOUa \}Iơne2.q)wR5mqΛèn;d>w%^cj׍l]h8xzʔ 6Rv&2a"c2[v !Jzmt]y%x'q/ <5:48gRHhu,S,1xZ^^eLnlT恃G{{3@bKHR Fu͚R\.9cppp0IՕ8gggRzm ǝJ);ݎR*F5<<A)I;/I3=󥐹\9 dd 83uIqsf[lHwnYO]o#qLg޻;v7g>?{zbrxpuʑ:֐ 2DXp ٲ׉ǎwVdPyka_o1NvCua֜#_L._;kf@A@qrdDG4D&C߸\?6/rlH~jcpU.1W~wޙ>s9LqoտGх ^ַ| Xp ˙0l6|~Ѩ7zhc}uϞB>S(|k6gNe2A9~WNrHuoQf,d2dn;ۅ2i Lx1af|Zk+SYTj UU֝%s0G`48C!Y#dH,'3Zh֔AW\p] N''>ɨ-*D2=TD sqOOBE'N/z*?z򛕑ѥjli ID.qƐ,sݸ3%?-7# I^ա4D78p]|G[\\af2@a`6Ғs'E4gH.nM98+=e\8甒BHm'"񜊺+C59uZ_}KW.`Gׯ\f-&CBH!@r@'(#f]B ˁU'>6{W.Fv.!o4ׅD@gs`9c '<'"T7;{_ KsWΞ*%L.>C6;\yG'@m4Ҷ;Mam%B.I+JB f|?P @!r!CvD.%';6MPmB ,!@kxc5u:(A<_eC*f@) ׫H(j+O ` $$Pc"X ` `!` y(J4aAj͍WתGÕ^Ԯ7=rG?Sz ƐKJ,pa;q2$Gs[SKpFI.Wc@i9Θֱ F889C #ܓqlͥZ6111[,JZ fvNWmi;fsmm}bb| DT 0_6U嫱Q`X@1f3xZeW*a&3::$ywmzBJ.8<%UtQ137SSnwVW׊b>l6{Tjhh(uh!E6Kmy;O m'Bn\sz1V,e&;>Sd5ddϸb}& -t(O`#w8w:Pd2[o\ҕsW}'}*J*P&P:vR1<jg}۵{cS_3>f]0{dM$qH1<_djOG.־7#͛N_1J^[HqFȱ<`yy֭[SCCä]4@0GS D$IΆ>:9@DL/6QohE=k`jNo>c;ouҥ|&<`Lt!jkB",!L0u8X,!g7Bz1!$W8B1QpƂ@kPtB  3 G9`s{:sݸKJf˽љFmJg?3C#wKs}NQEQ"KF1J햩;ݦ" wnxԸ ʨkΜ_m@ʃ3Z)՜#gw),8(J΍ayKkuT*ll8X3`vvv`kdLaNL`DXc <XER4=5 R&nKDʀ<ݔ_*9ziI!(~I2TJjm pimsQc Ô4T\S>A$Jz!P[ Yk0ѵ۵$N2a4=725:9 GΟ|[/\ijkOs/J*U'8/fLe8"?Ac$`Ry|\Yw(q" >: GY OS=3zÇ_@#0??1111ZzuV|k?4I d6ka@;ۺҀ!"0]p3K}nmm{Z7Odƺ N͂s0qQH0p'g#oF:`_~'KƸ5e +AⅥFoqӟځC'922rJ#>؍c1w)@ 9 lt]π &?ԉ+WN~_ʷ_ʕjߵ: O4z 3 ixcc)RvՋQLOm6n޼yzheeƍA6{ŋǟyD'rhrSc=e[~dii3@nT,*]L,\[[Ds>EhR޴  V2=`8\,m~ 5꛹\fztPz)?tA(2Ik ,I4Lu IS >|gǐy?7i^z \C񇋋\b~С׮]dC[6av[k˻wDJ9%q$8w8|!lXEdS)κ$IYަ@:_669 PD`1A֪4,dr !ҭ]_JB>b8M @D P#"ej%0٭6l_~ߜ}GnWs ȓrjlT,t;f>n[X!%hL_X$m1V{sNN]V9_;6$ƙs'#vK7N:¥vK磴d3ݻo:Ibv'rIAP(ւseQ򅅥8֭V6|~hTX rZqd In[(}gR %GFJ(JsyjfB:}kwڕJEa+._SO\ric}T.W;N.˄*֖+ srnn^26z<ۿh @8ߊ!H〄 e9NB?dX(`c I2qOK{} J΀@"Jv$A=͘ 8ш .^IF'uGެ\>ûXGC 1 DS?apX.gzH%g7o-ePr}p1,YMeH} mp"Y%'Wg({; Gv{hi6B+gJ]vM)5[DtywM_zܹ[| (ڼyȵa,Z1=k WR s޲)} 9C \f\>쟝O=[]Y?;kt! a`F!8ڶpI]o?tx ]9'6-,-\Η_~mm}idtHphZca1 9-,cK9/ P`@#0))T 7}`2`]S +ޭ({u[~ygW#XpC.BGG}F;tsnYv}or׀ť_DVz!w&qNYϘPN*zaVtJ\f.3Ểh%GNHZ j㭷\p9N`>" V*;:{بn)%s\JAX%C+JRެn]mn(%djɩl6ǘ^~J9 3a"zvJrnTW{( C!)EF zJ-zBrJAy i-H[]sAժ'(Dt:I!ctzSODX*eim2\epee댉(6W * <ca `&봇 "c5ε{|b~ÿ́'8w;<;Cmsƽ\ۨMM\;ʻ8K9:΍+WjkO?h)9rxpݭ3&'FJ DdN,hcO/<}4wˇ?RVWwBpl d 2 H5;w>s}QhW,i{W|}!tI.%Y0BH`$ 1د}닷*~o~;~}~nÓD+~ǗnN KhRovZԷ^9lkhðs筡A.[7K7|BюǸ0x8!F+CCdXsC$&+a)sfy$Y[ٷo(wM6q6DQWky~ DŽn;IFFMݝN;P*rĥPQ9 qq|6[kX . DDcMlLFJ9#"mT9l6{nPf3\pk 9SJcit@J)(Ki`6isD sGDFfLF yiM1͵$]|wNs&'rY?ώM W:9(s}.;~n];h->$2GDd1&8F{F`w%٩ 8 ,+W|7µ9ފj]`8`p"^ݿvٕ0?'|\> m8c{:Џ0*8P,jU1?P7>>:5yzs™3;z.90`'Qs`k-,W]koxw%DkKدp[ C?W~/8~󭷾+GXalw:NFu"Ʉ 'O~9˘NURzuuz|xzrnVxkRvhdБٜ"0_Sciˊ \[!gy%c7 zeOdR+?-^@:@܁@ex7s⵫7V2a~ty~s)RԟDA$=&%8n' cGw3O?|ӧ/^>{ZF 0Z):[n@žitZ m Z- .H$rёH@Fq$PȥHX%p:ldAj!rd'y聓B}IE+ sʑY.C#`ȩ}#Fp r߶x!Du&4gGMFK2IOs箞=sql|;~Db!׿4 knݓ>h5.qhfcޫ7{^Npxlq !UC0 ƙGt?oT_ v{Ԭqno\ _}ˋ=`y|,O.i7ruD@Hi.RS c=v@qohG m֓w9c~Qx&cq&>arf2Aմd$RJykRbIV׬O|!76:20X L𛭦ֺaq# $g@RT:ryD6;ojn{`E*g|hx9RJNM .IbVWWyG)#g+=@RJGNp@Ib۷4cPN+dG"KK)PkȄT}F )燍z+Nh% PY R1O1;BmEr`" bTBpm='rO}䙏'A@!NdRꨓ}g>Gm0}™K{|? u&׎|.SܸEm.tPCL"2 9g@$Z) :H4"ݶim&RHU +9SRK4jZ[7r-c>Ο\?jMs$Jy^ϯ6寿s ?61q\ɀ#=ps^ _[]=%0a]n/  lln3o^%8gĽschݨ׎9بolV˅MtKY>3Y\qZYVFFʹ5"}*d4!1d@A/OPZ3zF5u>_50; $D i|~qgBJ`5\{~ @0D.7!!#Lqm{Z-8:J! h/;b. :5qC6A )qvwwVI`ԏNߖ>Cϐ'%J1@tW К])ߚި>TΝ.]z}c $@VpDG \v{]r$FNj sDьqӄgɥ#B(ĝ:&  5 $%C9Xg) SR &%(1O"c̡p,Cb;0d:D@$^O `EnI^)/ B@n8_?{⭅[NnQڑ9Bp 2٪'\!N nݚբ(N.]$zݻFrLt^Y|@Kv|iY>(W2/RqkP{L$:rNw;l6y>"pA tlht*Mls.Uj֋z2Yk5'Z* @iT:FJV VHK0/"\$$t:tb}ؒs2ًkZ/8d-P;CrF`xKO|cZ}޽#S׮^{ݻg&nV!sy|U,K3gJ[qԉ-)UaԱ7-M&L6 8 74o/ fv 5㊑3R> Xn-!rfe2}"Guk~e}}m`899:2R*^DF^[*}­?|k'{pY \JOPн3V|'#gj ,G #@)$1b.[n6MNO8x}ѱ#] ,G=(:*C߃_-M4j9:N+.l>HV=0519u7nyju8 d F@N"Es )hpgYGH aဌq CI osÙArBp"H cYkƆG6813!o{T(X[]t&|P0}]c%B@ :KY@jf/ LN0ƞأǏ:yN'rBŜIo+8ٟ6+:j|.Nv&Niz=R/Dpv g\# \p#ᤚn>ӿ[W[I~?rR%~gLcc#֙N6[ [H'x=w 㟷uv<2A _1;W^zӉlS3`96I~jzk{V )766Z8ALP)ș6(Ibp. HIj7 DDRႥ*[$I¬(~Ɇ'=tn[,"2t:f#\.Ap^( !\qM$g؈Ҿ7n-nù`\0⎘ֆjF_;qXG|g@qF~ p#8$1NwN⌳^GǑvT!l{ ‘]38!'OC.n#iWb+Y/'FRXۛs{6Յ|%›K+ƫ?q|h#Ɛ Ƙs11 `-cȴ1Z,퇖?~7,&p .~ye "dxūkF#r1>{6ju&yrqVZ-25nyfz˷' [ks349DF梸-0\s+Fⅹ|.31/dx6%\bvq~>e-N>Ѧ{~&os18J[Rͭʥe'@Ƚ[k/_ywGFoԛ#hEp&J8k3vN< :ȣn/QzWfw}c&FWVvmT, ||ݻL~hڂQI 'Q2dA4YK`) S+r\@I/Q RȍT*EG6IbSI:"|}=FHl;?yGsǜ:| )yKzQ'_FKp P ,c9n= x(2#3ӟ䌬MEZY2i47o:gMLK\63~RȬщFGtVWL={fJEDNJRxqͺ X19(O [8aV R*H)11L];1ρKSc)cG Aϊl @!&$1I_tZurŠX.WJe c8g>1Թ- ޹xR h6# J{ ;I_>keKf[Mnp:$:d"q_[ /z[+c#G.봌qC%H{ݒ:gNxک?xA nTk/|v.=Q0 >(~<8ڬV^Wb`p,-/0àMNNJťՕUi|b WVt942ՆcaBѭY+qԖFheyeiyax6R/tADZs Ld.ε[qڌ#;;;\|GG4qbeAD@pDK-dʉuЧ+!0bm@igWVjK7ҩ.NMʹ;-O|\;w @^$6UP;%!\rkV믽n/vcSϟ_^˹ŬD%X1,70&F~kgQ!=SEV՚_ʕ!^t4PJ>~qyyA&|/TRE("r#!R_xFˁC'NNP.Q%\>@^dcGw^Z^\W ,9g6w\;s#?091fLz}1 ">>*piID#2c=|l3+DĢȼQ[o]_^ wZNN2aVHC̴G[ ȚnZ}SL՗$87 @p8x`qhϏ ]|>.d6ZnyFfRE=G6AߓsD{ר4m6vO.1w_|3`PH܁@΅N8ǜa[ wX&1tB04\IOHDI՝h Nؑ|NH{RLtDdd30xF,-\rYFV( o\>O@ip YȐ #!ENucd{ܳ:kSU&I-\2z]TEg`+kk{SU\控΍V[hYr Y^{GP6ཨuH7-Q ,@q%ݴ8"O 挱܍"L|ᕬػkG@IkE:~8Ӿh ߐ!DGATsI$e$Jz*kjy%Z>B0ܑwnVРY:1w\wW]?~L H۟rfsbfiűbޝz;q8CzBC/gvZu=FbGfkL]6Jhisea-5}`l%֗o--߻=<28{"[(8g9B~+@J ѩwjׯ/\rcxxP(prytvҖyʟݳoj׌ȆN'Dž@w;H\0\2g]dPCK怄(c zZ\#Ν'?p`_zT%qȦX.kmck%esB @k;*o=Zpj޶D_W+]ԋC/J]n<^C 04 9r9m.NFOk;GhDHe~r'rʯo˗ꍶ'€?vcǣyvll<ݩ RΦtteyl^hkvZsv啅Ņ%44g8ΆaR(!uȔ`؋:Cv;Z KVw}mEypI뮧[&9$q%<&%Nj{ݘ1熆+\}ΘyF%x(*OEqoueuf Ct[rI4^;44~*ݶ״u=9" /S">X΍I|߿pkShǹp0{'jsׯMrrj0N:rD|/ѝ0α9tƂIP((Al7"$i8i ;;:A-@2a1.;yX8ƔpM!q&Q:0Gs=Ϸd) %LzKk/r:y=6t?}ma08 (N{>gD?ӣw} 0mR^Kyd/]޼Ӊ(Grd8(?Jbypp17_Rw@@+ىhaai>|dxtlV[[[󔧍n4@]rrarL&qHevr/HTuZ[9 /2u$G\)FN͝n/YYP\{S**SE Q_eBpJrmL!C)%#?F=]Gl i:rFϔa8*^ҿtzhcwL.F Ȍ5-.$fl'+sǝ5ӆw޿F0F!9D7n.vZ7 }>0{c] fu,%"u0O3ۭEhh,Ɛ*g_r)Sc M3_$@O>NW7kR=t8t 9 Zr?A$g $M gWW%dq$4%w:&%rf\,:1T Lt]SjXf1m·4U!vP<.{R 6a!8҈Sֶو޼6kks7nXcpDt+HR~xСgxug%@8 fjLr@r1;R T.!9`[n5ͨW770hJơv])rN GN)$1"7 s++/[˫+g_4(Xm,ԫDGlڧ{"u:Vob_N˗/޸yX, Byuݛ7O<9:6MlռM,٬jԒ32;k55Ź#4+g]_t}ǏNM[#a, g,~MpIVWP#={9dۚ2@@ dmxOW@;H=¤ \b*FڮW{3IVVOś# ZK 92֎.Y|7 ȘӚ ̯WW7K79Y,=̣H p}$%d4-Ru A{)0)22=;JӔX"J1*HIbsHY ғQmtb"`븧6v9 #k|FHO:g R<xO? kf#pHu)"vw n[J?8{2ڝv6Vt;ږٕB[aR?z_/:sތוwZ8LG?;H&n`h<.k9d U|[/?9yssW"BR1<<@9;118DQHK ku َpގ*JSP0IH+#wՅ[ڱ/~[ +k"` s3G`c}fT,I#%u%A)RFE$qJ\sXU6[}ntb#JB,zScc#DT.뛌\0j(J1f.1){Є~rOs39cN8Zk_{#`Do5`+J<̫z߿uclԛ cm X( 7Ayjdt4VA6k1vMOWy9v*3@a@LZXykin|b ŀI4[ /"mPi <&8?{,陝 bsz_UnyL6ЍvC6ds4̮Qh#v$bF0vBUh5+#EljR3$l * M[@9QT͛+OqUۨ s궟d@nQ\a\v[޾'nϽ oMF"+\ɜVlNGQ!aOLLy8NOWW6uwZ΁ V9 zknvd!g~o_OwWG^UWðͺ I8Jf$I\5cZ]=P'I=+ۓ%dC7{;&w})ee@.jUˋZ5usBJqđ.nrk84N ^akS+X\Z5bm](Rӆ-nB{g ]pquGd3дɋ?~3Y!zU8[::O/7;?+ښ)2l|dY{erDBs-j(ZŅٔuρu -ڏAt {WP2!s$˿K/P )#ZMncw/)}ovјeauKɩ=#{FF::ֈh=tc*w߽UçXyCGkVg&n72]*VWVV֦vJ.weLp"4C` V+Nfs9cc1~{捉bнucG{;@*G$2}x>FBwcC߻.=d@?ЋRR8&IBfLaff+Z]E=s{f]!,Q0!Rnw7%䶥tyv/C{m[ - 4dz9d`-0jM@{?~;7 َrq}?9DgW~t̘:X!E]qviK|K_mf*p1EhtbZFRq P!q)F):pƹ`ZlF$%FG-+ Y!צk+{;?|aʙ7.+ Di IB5U_[gle o7=}D6}OY($$DEȸ{M(aEls'Ms Ч~׷jqTb9sp.lɊΎ\~n4|?Y /$u7 u65}cdOϿfJwp",#3qd9o7ַc1b1g6tbe.zzKo?ѩ-/U~? I&ܾN?xAގ.έqƘTHDAm(Vb܈<Km?4l)c9 1|zr!l|sie75]j;2 bm-]MKĸ=)$g 90зh$CnN6bin.^xjj}D°˕K9m!gL i0F!ךӓz`Qʸ]ו\f=#6{fkfިer{LVH\*֚Z1؀`k{ H:;{Rt,PJicƀx [^_ H+=<<44ؗ/d:GF*`mLvi6oJnm_Dc JW2AJc8i%)7pDrOΦ~F?}Urod`4Z] ę ]⻭Vj/m湷ʥҡμZ>߿?&Sh`@μw@2K6c.8DFhkayZxбcGVFXt;ǂMOx780xaYUxrO9Uvj/ƞ'F3n;/M-̖w^"K @!Z&З1Jic8Ib[XYve|m65598::JݣcC=E/pmRo0\pĘ@`6iuq2h}a{]BLCܯ~f\p əWN-.WVn8^HzzʾގcGL`d`-ͱiynNvv}t7sǛ3S$M#Wj.^XYk.,Tw|!NfeuGO'RR5t6U)DI$!3pS8s [,n-W+y ^zeru?l'J O 1R!υ8]tYn 0l#1f8S[_Nޞs:9>7/1Ih$w{=ƌzBOks)m9(%Bn:E㜡O~zY`B3N,'-l% @j!X[b^w@dܯ|KC1, &:V:!RQV;:ʥ~)L5V3[͐9.Y&\_ |ԅ W]f.уBVI3`q.R!MFCJm6h]@Gg'CDdƚFY*%k1yA~Fkؑery p?pQqfr%VG۾ۮ؀iғ%rμAbSN s'lɶK߹p1W+ VayjΙs_kfW^}ٿ\w' C^jwDҲLԫuhmmkX; ֺYokqĔm1ٵ?L1.0:mr%`,Od˹?ǿw{p@K_~奥ᑡ'zw/}Nx~a#ɵrWЃG= PF9't潞rkW'9`iM>)XS[/`fUL"^Jp/߬Mze8 _r|ת(BB"vI]0WƓwH|2sEާOۉ^ W.kg9 @ Epc|I+/<^'jP)ΙщHex{i`5a 3C62&sMl]T+ _#=L_yjeR[j%o'Ar9 ksS'b9"hPZlæO-Q}#93ڒȱ Ů|xJJ06jXϾ0We{N4&[{DhÁ 7=v.ڌfCHzKm<w~~ٌzzeYYven-R\-N<9<2cd@J WeȌik2"2P:n]\jGfH ' l($ׯߺ,-W^xzKd"˹J £d3/8^cw28\QS rGJ'dJܑ#f3j&Yq;; `dPzR8N f O*hk,5GHI-4gjCfS4I6cު7'[`HoL+8|CVYmT'dޫFD]Y^X;=XEBWȃB n.xX][J88S ƭGǎx׮_9zķVo1^sټIdEɱ})YM >_ڪ2Ɲtc# t2J!0!Xۭm}# ]p`ȾrD{]xyeeyxdhvv1#(%r.KRID/奵^M3=5QVOW,۵C#}==G߿IDem;69m<ȑn[$kss 'f}cqa;2Yr-/U9G~t```bVys=xhm! zڱG"J)fͶ{Mm; H?mHsŅ\X5Ox+s 5kps@%b]%}{ʅb>[xzK4^&3At<:=5k-sl&l6NrVs\./.-D>+wEDyRJcsr.M$1bsG:Ddі;GУ"C[uɅ.g[o^'޳w;jyKdJ%q[н(-uaߝ/2;ڻlh 9d Ͽ\m̙W $QHDZIh/c=Ǐ%CcG ܌0O … O=;l5:˯^rcǎ>'jjz:~Օ۵U*{&Y.fF~0{T#RVYfGGmHiw%q]d^uoO ݷop7w Q@-+\ heKp)3\x.9 W-c 1ƕ6ZC jߚ 2L<ȃQ6ő~@1@R0ZrANOt>՚؆P+!\pc$HRr$v%S vod VfA{P_{ݾ1fcB s2@q@ TZ+ugi⤧~3|. #@ALp[k pY 2S5Ff,3:*'asx3͉KWn I+S7-SK}=Jk]3xd\,R2͈[ Dz?~_ү"/}٠bPH94B0*vwua7zøaLg&@j6 he hc[t{s.ces.&D.z.WR&'߾r\5ƂZȄdsf#Z\t LG78̑ݝ9Dwd~7Ag~3ɬ֚.wEhQ=ivw|&zWW "X8sP820l6759 ry0Oި(5e~шsfiTr|GWG:i˾Ҋ1WdHRx;nЎFh {FQ"TZI)9 Vj./,.-,,vtry_wtY`㒥(nv.N|z2R@hc2($C64w|$I$!";#azqK$VGz&xBnѭ8zyjF|RGG뭚bHq8[fFF^]VWWZ幙")zȥﭭMb]cul+U7(j^{cm1scSG)G:D PZ_.p??O1pIqi #rcH⥥r c:"!+̀<ؑÃ{W׌Ç$tvOLOf`ttt=G-//;\.^T{G8P`j2(.6B'}ܥn`&`nue?|rvzQ3=]ӧv m ¶.'mfE&"`N[q4m{G!mٶ@:VcHd2C$#x'8-S0% P }6դ%x٬7\("|H݅Xlv,Q1R۴ n,:/]˯Q.wťUlve,7;}=]w|.{H])򮅃`!??=s*|?dcL''HpRjumllg?[gL6[ҹBADR(~6wTTGI#P ,/Ǒ^X\y¥ ^);jpvn!T:2kq-㺂jl(@8.3$ c{:ۿ`jp'{NdzZrjmz~yg97I r1 cr.8D)MQuR./9rxee#?"hcmjf6F . c"[cZh)7o$`>6vùx-KX(:; r~pq 1`{#w?܅s^e%Bku&o~[ko#Lp,H! 9Ν0LxW Z]N6fp&hi SUl%G@h;=5;@.-[zR)fo_'PmITgO&qP6q]r7#2 1ULۀ(e'omZb]h D9[sV=nsŇOp\2 [2J[c#aé(ub$Z{~9c !Tm [yI3j4[/ϲ7^{M Oւ(d l_HenD b/~qw~a U+2-bq7}{ {R4)"+#:p`d3/~zsqiϿT,w_r^4EO 3# Oo67n.H䱣GF - oFWgZXk2ٌ<#e1Fƶ5$N>Zq9 `vfQ(/KkffAKbzo3/gf/8Q:P8]!b+Тiː]Z_oMl&uĵY dmc+6kn5-!V,utGJMTRk+dvjLXa+Le|a r|+l*@?z+6~h*o8ٙzZ-z(}2+i8D6Lx6g)4S8sָ7nL.6s8JJJ=FxuוRj";^Z.^aG=pߕ$jbpެVt-声eY~(ii=RZtD/CCv0mcDohi`` T Ecq}gG/s^Rvρ={k`^xÇco}kC;:ff&>uog3ٳgWz{zJÜѕ{ýQTȎOMό?ԉbg @9w-;f\ٳW_988lV }* \.;͸ h+BOpan XĮH6 UQ3ȑ٪D?u#tgo0j^ /\%uu`Ib8 q|RSX`vu[ہJd%c=N3l:z?D+0)[eʼn \ 0>R.VWTkkK!s!iP@d.TI e^]\ɿ`}ꉇ|`dhpi^ c[d]- S`S>~:Mh *,. FFzNt_OypSN//Zq493F2F),Y`tVWGWGGyqaqS''j#NN[Aè( 3#΅F%U;wwYtǎ|еWc3󋋫ְ|ŅΝյ,`,0V R$q Z#$8 {upuu~p9T` N 46 ]?[2JF|>_T)c6I)E.b]3B8 Tk\.9Թ1nu]xG SO_@H&Qly}0?ޑ>/@d2J\ś73A'׶gD&ˣ(" L:vkj'6˒,ݭڼ鞶ݒMwT . wTpܓ=֊ r|ŗ[)˓M{+Nj\p: }?_03˗lΑqIHw]'Twre}drIx?Cpnjmol?6~i3w=0Eeu"˿Z_WyUplѬl5bqlh\jkWѧ]eH?Pk{O:]^Z8yCz HWk&r.9)`~nQ_>y|={:VW7Hf*a.-/0lhtzhtJ' ven%ژr4666>~^= Q8.LI@8ADhƤZۍzuC9CDڒXc%Y*k1+W w޾jiWqy=eBEw8SANWTμ׿FE>ȹ%D C L6{\בR={_SO?wϞNͭUVTTWz{˧zPp\\\*Dksӏ?|^ˁ' TVW]:iuY 8ߛO˭Vk$>t=:@ZK@fY|Hdɨvs3w򹙵Fm}zjW,,>ǎ16.‰\Wn334at;i'L5QmE:Ż{ƒ>   6݋Dp>@L"=خKFzxza5M z >Q,@:.%@ڈ3 K#~;#~Jwfűen}xX%ښL>742|3C պ,w޺| Lk}p}1ɹI2Ab^٨RiˣdTD`9h-!%==53=W;9?gN=pԩcʪu2g`mk6L=Lx3 XpY,{]Y"( g\rXkmrn&Ib}ޮFẼTsReAJ8Jt }M]Z2D0~1tsޏ( mA:ud$*&1 TB0}~\^77IBp'vn)zɦ~iQ0gA-,-V榧+hmQJߎr>uȥ ,!ȒQ&Ijt6B8iF纞1D3Gjm)4K0Yܚ[O1k3g~,KEsN=p3' C2 b[ 19:KH5mfE N %VtãʴzJXTz0+I(SFら Xԉ]_ /yk޽Ï=ήBgWwjK r6}Xx4fȥHV]N!Z >yz9 `tq&7F$t%0φͷ={JOGG .::F0ȀsEO4~=C0VI•}۶:\VlԨNMWRR{7TI3^J XHcuЖz{zO=pb.D'a#j6[+qr$c@Fɣ~uo޼1;[ޚbcVBJcAWo@ť7߸EO4㦪N!#қ9#<2w]JJt nEj!!@hvA֜# *H(NRZJId7lC AeBۄ5Ux@SAw+?}nO%]\Z Ce,0J0\ ,A4G2,=Cc@m{Ncֶ1 `H `ydd|rfL1gsT,J)UƈXs"04G&ctubL=gsYVis&**I6?楿3&@ڐ &sdvj#{{J-cbkI UPUm`qDm }a'Vs!%nԓJV B~#J|pqYGf|braaJu@=Q jK [9s`bLxgXkdmV8g5"JA~)'wtH 8˘/TSs.^j,FxGSq qkD17prGFkk=ԓooa{n./WZ`\][7]vt MM~՟.;uK֍ FB =tTw;)d{ب'/ޞ 豃C'Oq|audж zCK GO}XKAZdR!4j .̮s-Z{\NǏTRG@'hlJL$HaЎzdM)#iXR$=w1X=?啹ŅZ^_Zc۲GFF=68kIDHgjrvrr^]J%%}}_T/^Do~wP Gn1 ؍Rp!6g(R/cŠF;"dm"cTj10ep)w|Xj+ !!kFƘf'єޠS&Ȕ2EP[^\;:{ k3&R1ɐc;MhK }O?,&E)% J҂HfyJ%T _~PhXJZc( X>gj%TYHm(#4zGgR }Mn4V w.g3J遁C=ZV֪+c +rGG`T;Xo4Eҫk1ݽ{g-8XOY[^]4{UYwg3^>2T:uh~~˯[N,-,+-  b|:h55]Ṇ8.S!J}qնy)G[[JJ[wDҕ'SSQnߞb( D䀎`纹LT8GOϜ={rGp* ö`">ŔfH(w%h/},nw>(w0ђQ2;kg2G?ϕVhtD;&f"mڧ@0>DQqSR h42aexMy!2JҨ?t/^4k/޸M8E6[D};0gϾn)I")57o`h5:Α>7@W \{F+g-:nO_=2P#Ak0y9t;]>yy( P8qx+jRju׆d K@#vAiBiߵ >t͆NQ;З[vMKːo8i[dMAθAd!uʚ6>Ro&ŀ!p LM0" f{{ `G/.IaU[QW0C<-5}uA Bd:@s/Zf 0L(yY( Zw88Ibk@ 6 [Ęr㜁21"zZ͖ڑ"SpƊxW׳,If }[ͧyG Fz̙}{~_}O?)t۳gh+?ywD>|`VxkE)idXvV0YIDL0'ӳ/\\XdH@(@(=g֮f)?8NXI!*c,shC1ٴD;ڄK@qqC :1fK?}#{y7204aaiwU\p<_,۷ ⚬;`Ygw{7:-W!ƆگzɼKLaȍ%e 8quH??r`UR./.7{\rfLh3ۍz}iania. V}mu~Nvw9on0S!S"X)96(i#~(oӭX`(etDdһ%ckMή2ȶ%S;t>LW0dن'Or# JɴC` X ;6n%ǁ ?W|Gk"Ņg+=O,e[u01.l#LR77>N4 <mZzU=fpĚJ:37k3rh"\,i-3:ohT,"jV,-,+r`(:}HMI#ەwÖ[g+z\`Q?2%$Bb.! zQNvwg|K/ŮryΩ׮#{ZCF vݠ 3!d@FFukVklpĉq2:@:!zW8%rشvT>J#4)5Ic>{-= Z{`9F6jj$P1U IfVK+#OKT\tnz&Qm3 w& -A;<pڞV1ѳo`qZeMLqemiJC83h겐2(ί ^}j) oBуr)xc(6JYZ\qƵ˗e(@10J0CG}-'!^;jska3Y A/fM9e R2[8+}#ΊOQkW_zj5ުkB O$eɂz޽gSQ 䄭P3;:EZR&%1))q. Jl֬U[qDƐR "DaBȹQ< SC ﮜyGfH{nt}Dڪ 񕥵yD՞'z=W(Pr7c?6Dz Iim Yb,ı}7$bI:07zq-ڍH &99Tx75CΚ-b kWn/iR4G\(k鵥RX-*+z K |ܙ: bjj(JT\F$&oVeV݈܉Ugpo'b! d9'mAxvW0=S]Z}嗯M' QjS 33Ĕ*RQfZj>bll,V}rY"k(RjVB7 vL5)0Ϳ ѭ@m7ޫtF2"0S˪4dDZ։e*, 7mL 1#~=I#mn"WX@ḾjW3 FZY_'@ bb5&F[0O8| @$zAL;.zG54 Z;(Ը1js>u]Z9# ӓ˕(VZZwlgnvn5n.w5Zikhe @j jƁ ?bii\.y^V猫$I=$<ǩUKK ?~ZA`8ĈV5ѱG?کc6Zzjkbuf2b F@眊XdhwZf`#2؛sO^9^~CƑ1R/==-,jr Df-H鷚qY^VK֛Km2ր0oE^80`=a^oGϟp.SO>De"àe&R\iy5,w-EFI T][Y3?]}kys tGqPI74VLt xg0r{-.D&ł5V)h6z]Z CA:111&emt׭\鱾4-6YfWaW.Z\Xٿ=lo_QVq͒9dLo\a2^a"!Ҏ#Zs/fjD;|K_zVJ8dcJł8JM9d+NnKlޝ,2y֑HsFkkak9YXGָĭfx1/EDc5&_ +m厭p1""s~c;9}6̾=~TӿLR"0?aGgO ($IZ%)ucM=鸮"5Dd–8sե^~eZE[a  Gӧ#_IA+BZ"s?~" p-)Kts Z8Ed(XMw\0q8I VtK sZ=}1!s&Iȷw9A:vV7CCAK7wFs^mګ13='&f""2J[7ˎ,kX&2d@B. r j8cϟ<6V.'tB=37gB_Y=QZ_E:J̳.ˀv$ lBVDV3aZqBjhHQEƅ`h %%jGv<"kL#=* 3GNۗ$Mz.d<'S$6&aL4h&i^ z."d258s^$j^`Kkё/DMF`_ q{|7>AF]G:B`[%!gG8uu ~#{_x́s.,.sЁfgglĉ y8N#LU=}\fVY]j:Kʚȩ{Gz\7TqL6+ Ȣ[oy>l%qd~Oo؁}#ҕZjtm54$9GD ]rfU[#S}n|vONh;l3\Bjܡ|-[qBA91[mJV#pVE2@]+40Ƅ?1>{,C7-/z^vtp3<1ӗVPIj% Z628Y* I"2֦F;iC#XkGZQDDa+M\0}zkJfhV9SiCJiPC $@c .'<~;9ogwf]Z^%|viyqvZIz'Mis2YQjkmfwi2YkC m5FzHJG)[F$QW"I8NꍺR!Rd3A:: 1=a!?[q'8{#)%?`]Ck/<$fnޜb)bY>u?aزZ$VI|ܹBX[4VQ[nǎtɗ,x+|/`}6t` yhxS"`R$&@[@Ɯ5 WObGO3P7WL&Y KiF7U? 7B=Z[V<̬;w^oJ{МCuP]<0;ԛY^R}5I,{79/r % URI$&5pu}?>nߧOkQ++Kad-87nN&CO?xT0HTNiL$u!G܊#"$"sb`G7ED0j߼'hoitؐ@ULcЊFOJ:㜬2Z3oc _"ZC0$8pMn5(j_R`xX&BXhkZf+S33Jua@9kuuȃyxqDl6らcD)Q ]' Bo_aLvu8?3y}f\q@Lͮ۵`~~'^ܜ훗{k\ro 7z[;4<`ݸ/<}Zv[.[tV֨35kO;QqՌ}C(;(O_q㦎Hg߾J)U纎<dy]]]\&Q&kZkXq%4s& hops|Z;|xW%)Y*sJhCB=g GxwS8 kJԍF(O_%`Cn XOv01k&?d={_9l4z{zLVu]O+uU" Jш0 #cm|Xr=}>J9{bbay2" Ypbz. %kƍPD-{Y4--҅bK:2(=#Rqqe>)>`8[t hew[eLhA' 2xv"*@Qj ævѤF[|A%mJ`W]K`3Wg3.<7X][sk+篆Fbi`39re]=cF<'u;7n6kw/]x^=i5ƚ޾.ϕa?g?,˒@9WӤwX\>HD{{ 0nw}+OaQ,32MD(2"g3+Ykfw7[nr iCYжRaA86nΉvS//{ k?V@9N,RHAOo0lJ6m*1t Kw!p$Tjq. iJ*.J$1Ԥu "kO`9⽇PRDam~=%CąW]Q(j=z?y{*w1;3SU+N#cS! p&MS̆2+4 3mT|;5JX@ 08fzSuV]wSF̕g$e|[~9\te 3s AtS}爁2#^m:9Y,\rg\zqsSq:jsu1g.]tzadTp$a݋ B`p7Yl'a T /_YYi᎝[`lXB`!f[)cj_ySB}/XIMvِ $B[ #-ӳKקVkPزwxPκJصA{uㅼw/7pk}2"c1owߩl"J 1nRW[mgY LNN&F&'kDfwrwUʛ=@#rL9VOõZ{||/II>O<_b:DBܬ4H>I>Uae-8o8ጬu!5ij)%B$f`qA p&\ |g>tۺe((Ilye3H/Z2>RmL A[_s[ )pa]gJ3Ν={Ν;GG b\X(ؿ p^rZ6LKTl봊Z7} 6rLL}Ywܩٍm_mVVG>lwΜ9}剉-Zsliaڥ ƚ}cd' N16dGz||pbb NaϜv-gHF1p}1;A\ ,3/\<\enLnl:7'_޵Hl1M;Q255ut՛v+[)U 8Igݜ>ǐ3o&Xsrucd~,:?u:ܹ299e|}; `bHD ?&w%7c:g-L ɳѵzGwNZ[]:zgw\)< 9$@ sm 0~x?/|r׎;kf{pleւeNݿo{9mܵsl=w17d/ sB"Bqę/͵PZIڱo}ˀTh ۤ/ ;Z8L'6'Μ;z4!lݶuaqepԉ\8ׯ DR)mͱcDI$uD( F1cZk!\[W;s=>dZ6 ]fneH(˫/+++zfl$IrW^~5M~$ P "lQ6]$cR]Z?O!cccHj|#G^gesiiz@F[{'N}#BQhu:IwUZ]iID ǯ'1R$q+W^N"H,J4:U10 A/__;f8}aZhH!T׌֌umJp¥J)W,ܸhݍĎ5~T J7a#K|#(Ȭ"O$ڛO4:8ã>t1[Xk˄[)Apܽ>+Rj7rc$eB xL;_`ٱh"V;d$BHg:$ܡ&B=xp~N;xJӳ sX#@r z9 _x_yuu|n4֎}NnT*HE~dY@I oQܦ\7 XH8yf\ !6}f,7Wc. 7Zĉ3 BXfb[{B޵ ssׯ;{frd'j&}G3v|WZC.\wb]Ο=s﫾޾Fя~ƍgνڑ'y4`mka^(A[LJ+U[^>uT߿؂0I&% uӛfcs eH~2wܵ庠`m9)N+_PwoAL*Fpne~]yh0._Z8?ݥ3@sI;I1I ‡+03KrƺsÒ(X'w(BzIl|x۫ c<4w`]&$iW'pۈ@\qPxus_ LJ{k rT*V˾Ga34P<lbm ή2]Bz[m! ƆWVjb>Vp Ih'ٺT;@R>'9o}g|$"VzRXg'8Ois0h;_zȩ%p¹o߶e˫okpB $Z':T]}kFJXI7foܘ@RieO=E11V*-܀WcX%.KuuS[t `xs#*.έ~K_n>rn`% `JcLYkWokNV"`k\֜C1c~ȆZ%zsK# 1B}k7qXD¡EYD( p65N "cRjd$7D['( Y1`nVR6@P18ڼ{kaNB3 rR斗j׮]X[m~K]oPHTOnrb_j~΁(jα}=)1?42>K ^9ro׻w|ra}kׯ_~Z)rg>N([]^hϞ=SOT*ŒkXkϓΛ$o_ M*L8:{ª /(nm>,A-m70ol0N ?3|c#})0NcR` 22~xёca1$jn+ 䇭uDDۂi'"4+3/|%))ᩧjk G\NBצtS(w?hY'<#64R";$?W[Zyf$e@$R 5>>`$0R.w)@)w{ ]]80PĎ}ְ~Wf\.]|mJ0SyA<#}#=eX!1#wh1@TH>HS>i5ggxJik}jXʅRБ_㝣 Xu"ˎ7`Pn[ F 0tynuE)e߲uHXHJ VzwMݘαN%&s)[5m4拗gxR<0Gq\,zB{ٕՕbASqF ^Abڲa 3 dlXkFG}!TSSS/\HdfnvV_Z_7ƬKAh@! vEÁ6I7,1lkڎmsʟ_Z"rFgkJQv;jwZ[rROQI_H脳k2SY$$ 1YynA#)Ej%T$I ܘP qFX, :}3BҖ-Bami sA\EQ9&KI IBdfP   CpڹǏkR̓O9[-!X$ [@D_"^!PZQ!+B/I:e3n`qҵfYM9<6&2`DܨƘuBBl#V9e)89F[IϤeiXЮ7o|s?t,{]\Z2#o;zBIE$ݸ>77޸pKWyLguuh|s?jeucչ);>>>qM nw 5 )MH D@`A$֣o6@72g6& q(iu?ƛo:~G/\K/>}JrxW._];@=}ZLVٸ7T}DVϞ9~T' t*p 28}N53ljH8 <,_NVW.//%UEQ$1C%M#50`TA.M*ȁV3vmV8K[l%}=\8W޾exrY Tq]f'uŻsw}&7V$6Y\]=R!_+B?UB33ss/\HR+DiH(CEdrcGHz-Z]@5%(<46":Z[mtv6{tQ3}GWFGۭkoVnEq܉V"nc ۰{qA0 s<[ʥ/fM(dO8.>cãDJ Ļ#6= )f',g"I: `1[D1i+b`poi0sn \+Tj;V@o&ŲiԄy`L܉|`)ymuuu 7;|SRC ;vN5ʱܲtV*(o`0NVV*JYc,0:p0q ζv6^bܥ]x[ou:mngrUܳw=/yोFFk_W|JDH_}hxNGپ}܍\>|'z*m['uX"pYDS"/q%|IsfPw]PyB,e.˵;7z=|ex޴-9 Θqurbxn~ĉwF[BX\\Tڭξsm&sO>dU|֭=‚MSǏ_>icc[_lVVWR.P)dԗVm0; ٟ";ǖ3 R$qpR>0FduF.bSru?(j֥'It'/NHJs| Ox7 R2<9ex=|Kk%: 0SOw޿bdP(}!>ǝEf ÏNܺX(,Z*%#A)[Ybm[$:@,˃I+AL1;S_\XZXNZ[^jkJM:@$6|KMwu)B)PkiעVE1߷o֭6Ձ**zp`hd(Igͧ#`%xB ,p"(J(7d "B$ֲc V93XnPK?Жqb lSS/\tj,'A:ܲ(|MB瀅 oQYk6ɾ lҭcq X H7 2F۠q,PJI!7[ΡEC52SBPcE.5N;5b4vl體RX_ʕb%~QCzRo_~ %k7#% i,/iy䑟T{ʈsloo&+!2;FL_*W"l4ζ:QaN4gL>[Xkz} 79/D66&i6Ɓ4ՀNSIP*xmݷ- qb|.,X_7_fKW^旒f-8` ꯾bI#>>MMVd+* v6N[KIb.I+VE͇w@`2I`7&Dʥk_ԪJ_RX,06 C6߻sgRo_;j:X(G_0;7cSOOD_F׎c$*{wZz }VfΝ;+E{ϖǟy8TP$&-3G l-@kn\-<$ū'O\ Bq{&$]f z7o;XIZo^:,Vե^=AOjJymڮ%"ZDG(<FfB1F)+RfgyJHD2`)HFIH23A+NnKN^;uX:9u!JOiA!煁KkfZj2p]>,؜A`s.IH aQ9X:yի##i֛joRKurcldH Al/ g㘛:i~~4  2hl6٘ 61̎fbqrb":ΩǮ\DDQ/,)@B;yA@6s`B eBc]PzlaV+ bqEܶ}m4GƘZNHx:604Ry5+ܙgN__{JoOT)c=RMM7Ip 06M<)6(qI{GgWVjZnjd$n+C{6W+M<ú)#*k5ʞ7ͷ0lԝ>%h]b4FA¤9"b<`t ~~?c-*s.N]lv.^rT!_JS{8I aEFkQW_M҈|pٹj' sNV\X@h 9uxX7 .ɇ7ts|*YqАH9v/}kv @~i DSI)R]G{B "jѓ:RoSO>XŅ%É21mSA@r|M[Q'Z] 11u.YRq_@!"NY,ߙZm}k"!A C6+ 0ud.$%:U2MիפR@i| [? sorݻW-/JkK=19[ Mt&EX3fg-"f`!ClBQ'jwfSwbDqjmDq aWtjn 5Y}_-!:yz~n!„HZ`OjQÎRdWH!iĺ)2uRym̒ &]2%eO_xr7]m-VJs-#fFy PRxw5dDR$F^j)?sLR:! ɋ.,5;; ;fkSO=4W~ 5ٸ `A^]zǬq}{\cJkS+pG47l52M. IuR<}m䱋/,&:T*cvlyЍN_<޼v=E+LSijF *m(;V[@ $Ty- ['z==cCc\ۿc1ŋJ qʕN}sQBJ߾\(4:RF{̛7㛖Cx$I~ND]\XiJ~w aATRu>r̷pp֍c& ,$PW_=wbTZJ3O:0+ҧJow{/$mN꬧=B{3[h|gΦ?ADL!0Jcyaiwm˿`ee񹹅c~;$xMm?Ƙ gF}8o}녗_x`Zo) /}%$G'Iq~i>cB"h벤36 ݓ}]#![4:A J,D,~˟2"9F!r\ 9GB|ٚq#M _|XWjw: DO7!Ɉ`B(RHXص#v;MR4RxDRrLea!H"HѨjٙ]s) <%d]R[!Ie-:\Z>vTBnhp'c==R%3i@y[k?b#\;݁6QT(*14{ޞJq=L߮Y -v] ,lǪzkfkn~ē)qQiap$0n=o%JP`Ѥ<@gt+Ν8֛J8J"e25 67=͓v$]cy8{ci<}R N ʀ7iQu;Dy̜Tʊ3Z )[o=37j}g~w/}+v[^^<}}V}pRԵvuڝݸ|\>?@DeclV_][p[d`r9qo\) 1K%\;w\Q]/rR._ e}ϾJ@Xm6}}UPuzt|HԀL'$ ZX\}\$I),P> B9/ˬXpu+.e); H@m>VNّ(4rְ)F$T͹7|`~xwwֽ)2¬QdfA\,`cR&H:. x^:7޹|ZKvaX~9we;o^O6xJt230JuRJHy/}k׮j@/QYfCDJ~CΙ_1_//~r'<G Nwݝ ;|I>g~7f^.['|9t McDQܿogiI{t ʁ0;d03pp3Ǖ}* ۬{;֌poL/o߄ۤNgٜJO ­Ha$cѨ% Z-[|izr|4nvl۵cG$D+OܒtU+B#BګHp}nfjn&jFB~Ϟ#R8$I t2Sѿj!0E ,HX5;lF'V!vOz  e ng+Hƻw^h5D)$.$D6']ZZmFFBZ֍K:H0Q ?:Uvy_={ŵo? gO?G~~_7?D?gnĖ-vl'):QCU>c#}7>1_|S*KVXLBNǤƆ*=pq~KaϮm[' uʥj>NMRd>Zk(QyQnW.X^y{h'PJa5XK6fJ0d+ R 6oqkc[r=χlά:51*&F @O"JrxjJ34GByS]i4VR[uCg%!NB*NZL/q"=Dt4mh@Pݳlxt$<2=pc{#>r©} ""F#Wȃ1Ԓ񉵹T K2!:g5:M8e\fD?V*u)BYoeI%AbS5}𫯽_vq/,-X|z{zѹ$ j+%uցu9*כ;\. i:844>>~ b'4ilz)HJ:guzT*{T*);r~ƒ\k#?n%$P6뱒>[G|vSt(Pjs.\靜|q sKs7fX^ b~rR n0 n7+ݥfY"|!|Ol6; 2$VdRtҔBT>6amZjE׮_MGٵ(%0|LDfF=BcDvH 3lzAڏ|ťڋ}!fj+(N]o֬7;ɟڽ#nض} )EV4 AF~(pcf "`ƌeϗF[ǖLDxӶ`H"h 3ͬu٤x30[)dqĭV\[k}k â~4eLS ^1Y眓y ! Z_(p{ܿ/~/Rؽg;'N\~\VxɁfuw^yؾSWg05ѮjqF ѱmi!Hmbzuy7G`2SRzV[T˅K lYfC׽S}҉xsK^͑z<>%[zo6|p&6RHo*ewk!"Py$;U6 )~45=Gk1X'-<'MDf7'7-~ؼ]`r $D}|WjǶ9iti0J*ae ~Z'Kv3?VC6ú{Uz &'ګG&P Y/K@4[k+kB෗Nkii'!M5dv 89.( 6xӁ{zzJRA$Q|V.KNqy㭷,p Ei,|w\>տ:} s^x?:x߯os|.3O??ZooMNL·~?ɉRO}:k0!f\.زmY+Y][5j5N'MSc50܃.ZxQ!_ qZ &5i)i7-!]L$7PoDRJjӧN )s|_oxOOXAGd3)?IKQ&q. #E%}Gs|8<2 sZ{iО~l5I-͵[C?Iꁌxφn-3`dKDlYF]B $1Qt:J 4E wZ(@4sssgΜ1P*WwܹuF^*6Vi"2Z45ڮ08Mڻwo}S7_۾}׿.\Ev;y)kـ]Y\ 6 PGWqfJ6::tB.OH଎5:/fcR7C.;7޻;!8NӘrN4:(i9߁lV,_{qPnUˎ.,{!3~@F}|rd-);$$fGN+mVT#/}qan+GΟ?C,,̿k_F577f'~mˋkyD< ZY];پų{-=VG 'l ĖBqZJy3"ߺ[ 'MMe5zddСIP D 2*dPr]VDFte׏7r!\(hLI17RPK r9@Ǜrk[ ]m;$ddN 2-I:giST"|?G;{{[var ّ̖P86^?ֺ|ywXyq}ؙ+bD{M3iuuw}BejnuANx@Y&yӔ35 l-2C|~8N+RҜ]veZvxh~Vzs~hܿvNRQL77s׶ݾmbvvT* ܷחiGQ"'=66M5J_Z+ȴ(f6Nx=P20.o9J[ IRBzeí&(c!8`gR|k"̀DYK*ppokSW3OIb@Yw*%}^[YzWk]vjm.^P*CC}Ӊ'& }_/e\|GFƮ\˯Ωᑡ5IޜKM4;<(vU) _l7pb5YUJN$ ^p;GOb%VHɻwOH&'FFGA $m;f)2 q+Nw ]"wueipA!Di,-78=;;'jkfޝoy% $חZ@)0lCXo Vq{&G_L@}~}3Fvl R 9v>?LGΆ,oA,km!0EQU%3/1CZٿ޾b D}zK^:l;uˢBpY&/f~8=Q$#`!'I+˫ǎu=9 =ha֦FOJRckZz^2^t:1 E s[_VܥKJOIf` 0nx]o g |R((ʅaoOo\6֔K\._TrF#kK :5BR}/+g޼ٹ~{֭CBJm 3ߘ<䓃_/|hlt싿ŏ}v?ZoVZ?g}w?K/p3@V>!5Mb4ZRJ("TTJ !Zv{sR [<* rLV+e3즖,ccQbKKRiii-,/ vAy t)V00mкUf`m4qrٙ([f^۹s=󅂳VH2F}呠~: p:gR|o6(V+BNq>j')b_O36E⹅˯gGA(ce `!m 22g? Z\\t+u,IO8i'9y*NZ?- /_tý8Vس0_P6TRGZ;Dr6@wmxe73v&mmђʸrP %II!F" l l'=vwxݳj֐^掼⥷>&[!;vQfz #/WˋsO>v/g>h44˕ r퉧ڵkw\>8s'#׮NKa y91\ =uܹ٩kSho_o!ke!Ȃsd]uurIb@RoYk--ͤXdߗCfH4lG-vY=yg?޼ [c)qŋS.Mö\.ٹ5̇]$pMo܅$D@BHEJIR\oiajf!Pc` 3KqkRn7ˑ Z[@);-Nnr82{HRZp9sSn-MU{'([oR )- " &mڞCyrc~nh//Jekd{;v9:BFX[s߷}6V|߷g_OJBsB !RZb+!$ A䬕R*OcMX 0̤iZkԯ]V.\tQ[ lxtysw3/X֙z~}nJ2dJ4apH*mAQ1@IAh3_AH7Ix,wu!Y,҄SNI% R\*պ|6kkz#G^=x}(6ʓN'$ w9ר;,s4[ɦf NL j=V^V^RTP#`tضeeR*Wz&*&.#βӜe1 5Zxh88rg/WV/𝨓ΜpEǸV^( pt|vzsSWvoٵ}T)4,j4(_̽m0ySDc"ue]6tՀ61nwZq ZR͆Y_;vo S_d("ro:Obhh;GSV||>ٯF>GN>wwܹMGn}nC&i5@OPO.TJ&K#"T^9鼾$fVĎ(|aey&ynddpέ~j/*(>R]Vk$˭Vjk-DF$71#*:&ur=5$1! B%W6?c?n:x 3!*̸yؑRy$٥驅VE탇~h<'kl&6w+} ͂(uQy6ZnaKs~j֚b dBzsu+Z&vyyuϞO>8 o00`V Nc,{Ӆ+s3K?}RڵZK_tqv~3ЌhQ'bƙZ3dl%޼<@1,( @*sN$P@>%:M5kw`#8vjwϾg}X(,/>|ĉƘ??_4M֯TO:$ɣ?2eB iFIPFQ [-)p̭z!"%iJDNG*e)J|gJaA(:>)FW{GGG=ߟ__ZZJD7n4w}-8HH$s}XpA4.]7}B8wxTf$IlWLf5"B6)g s<;p5gf(kRTX$qlV[]Y\*s<0&i[J$-M:+JTJ o,eqXg#y'zldбs]Bf Y\l Yo-s=1`9\x={r`M]ľbb!a5خN~9~$/XTFFG.^h4~8IFF|󭞞W/9V_ Cs,.ͬ_߽mPʕpanFO{ll].X(DxFusȼinZ^rv+#=jvzw,kإ}i `&FaXދo,.?@O rDiŎb4|J,J0ωG&@6( \8p{/|1$y~?scbZgy`w[}0@ `#@$p|+wk%L$PI$D"""k t @4IAJQBi3u6cZR&ZkSVaA&Kko}j-[ՠ +Q|ϡZX[\.Rp.=}alc'4MSzC&v_6Vw^=wԜp9:HH;dg3~"I(`b822R㨿?Qpi T'S%ٹhAb$DȜ4ޞtrBB.^xAD?g_}#O|W~WO<[3}{ܛoqz˿+c }K_z啗}sI"; 8-@~0>>rC1)z:"# c9\ t;w"b }oZ)ܶMy"y:yץRFv֤@a0uPX8F D`p-^9`=%Oŕ45S3/]~#_ZtAz~M|O_zуS/-{]fVHmvN'I"AKSHB& gqƭ,5JQWd*LZP\hN 3F~hq 7qY&c:u SWfB߲~M))۶BԊ}]vEQ{fW?F .knw^{}:tԩS;w}QҲ=vb|l][Ν956-E7R UC2ۆbbK58i1Q&toB8&X(l6n\_[m A9;^xRsMD)+6"Ç;%3 l IOkx OXLS$z۶޽sI'ud-s, !"!K"!9*SNꎕm:fD$,iF3݇ڀ%e'¤Nd! ZVEQMy@ym 68f-ٿk̤$")*`]fD):&՞đuVIEA*BNҔ]כjX۝8z{\7&1V.Lb1!$h'Um;KZxn.]џ/ro7Ⱦۥ|0h]okK%(PtiH$9 Sphx\PH4ѱ1g-CNYKHYs7{"Aj48n5}}} p)Q`-2;9kbopʕFxΞ?'Z֙sgZEKKKvgaaO>kǏj&DN ! :Q{$IuBHpPRN sh$|NHɤmΝ?iv'5Ȕ)Ӂcu1N͉ 6ڱyׯ]{衇}'o~񱉾TDŽr}Ɏacm0 ! #`׶v_h //CYeZ}өo:6<܏&Z4(rIlɌB(B*H:m٠"x=Z? A_|ȑ°'fyyeaa&鳗 Ok>vܩO|? v}{Y=$Ŗ5gl"rβCTD ),POP!!#/Y[- /_>}+C+SS{zz._ٳwO@+>Сr1vLnVc?6w$- J-2Y,u΂0 '=;g_[]i n۶dؐ+QwZ]>q"c5J}rp\*yƴYۥຆ$;!`s @ÖgGHg !,ݔ^\I pd%m 4BHjej&D+F9gώw+s_X:RJntkuNa&3kUM$I0DA$k R)~XD M1F)2iRx(|A\k 'LY9i,Nƃ~h!|? fgfR A)GE[QaiF*+̐e:\-Z^믽wߙD;ӳWxr|l>lnn1K˯Fsą7|91?=;_k[f0^_'gv Ak{%*RP.ش?t~ V?|<Ћ$:vv[loZ"+On4֦~[cTHOr9vu?RҜ 9{wwe n^<8[85"v.Y\PKda$҂ @y Ef~D/Q ~ ?Vu?\a5&Bӆ>='l_G|B.yKB΂]]ٜH%`jyIDxԤ)=f3*C#?O=n9$IJ " =c%H*i(2KMgm\ɴVJy#5AwgX$i]!gOF!6x][Zy*m7w|=u;~hN)߸qӉB1|)VY(BOz&M3tZp^0 ff>[;;;W* Nx5}桰5$9vI)(rR,˄pRljngXNM3@1Pf`IC8cv<0 |̩3r9 C)  "x:f0˲hď6|(e16MSAnB!x3ڄa~mcc}s#3Y?  P@dY9`Dt :I֍N ufkX,|UL{5#1CApq.JՍͷ|Zg-Q4R)$2/{ .o]\}pk6F:)6CPDj˜N1vUZ9fz r:&_+bV~{j}{{{mcݴnir/YZ]]߻pLzJx:Ky.(ˇCbN؃>64vI c)qSGοᵙ=p>woZ_z B̎]Ѣs<;;;oܸq|eO>yeb|lk{ѽ{Z!s=/޹s+_y!Vk`zzXM)\rI+]T**n˗O4kI$.&? \^P/;:\lYi}m3!噳 ϟt9ZBL;4<# hM|(>}WCCsB `@ưnB̀3ΟNNת3}L;a֏Gt x~4:IS8Im-[ o GG fxs^_*D1'ƿ/ QVS[Hu{g O %Z vf! kb ܬ6Jb1 6Jsϝ?{n|W799zͩR$,[y;9:#ōyB] i:gA(A'!Z(tm7nH%6,dAvdFe+XODƚR4::֨7͛!T̀ OL 9k22"B1;ޞ 1ucL<Z`HH@ {]`-0ۑrif277>6!UND`'J` Pݸ뇅ue^*>Ss';׮iǙMb FMmVP-nn` R*Azon}'&.\<_)l#8 bvHCEna9Ip8# XP71[Ry_wݻ(=$޹}t'ff^{nzono_yɓsH::URZ1JtlfVX"L:Xt1;:r0H?V!:ZFҰ` ?ӗ~v `h$ߜ^_;g#b/7ϟ;οO?[o;oOO;{Ng/77o\8UkBkogeuWӤ#8#koHёJ0W*$D/3Te` 2Fv~GBzIl 46+kׯltZSSEe@:epRInjy𣀱O 9GKΏvtȌ.ghbۭ҄duuHAȬOLMOV #8vCW#և=|:LO %elQ, #P\?b = cF* 9| б^ő,DpYW~J'=X>ݑ+),u7HSs; ř k9E>Ӈ>zaҹ,̙BxX 9Dڨ0'"&D)*Jܤiow JyD* H}?~Y(JA]X޹+W/O64&A o ;_ݽv9ُ@o/X_y18,XvZ WH-; sdsbz\a"b\vN{$֞y#j1ڼMq?/0z^Qst99sp! ^ecY:ˏ&I)4Z~GD!"{}PJR\Z\]Z\e SP\O=;33OM;X~ 4:s1%6$ABgǓCg>n%;3BIq*#VH&PWW{A JYCB8Dz橅^T(L?yIwt{{{|JX|JoEnVn{q\/wjϟ}angsm~Fjs3slJ>2jG-z;9a0Gbq޵wߑkFǚ?R$=C!ȁϖDdQy~8`RA@_]۽ousoK$7Oϝ$aRQ|7i)"Hz Z`^8W<6G0'1O3 Ȳ%O q2,Ey-ck҃uF _*/ޗ2@β$I>H!8f!M",P.W5ڼPJ9vc)H%IA(N(~&jx8)AZ`\(z=AaXRJ>u  pteWRkׯm2Mu֬t22ZAdzVJ^pb|b9]jei%βmc Dj<=ۨTJIڗf" ,.-.--ahV<\ ETYMs6cލ6i?~=~kOSc6/^݅ kKشe9HZfѝbKIHGo((! ObiC|s!l`;K+KʈNj$Ol @kg6uDZD DP[mցCDsNŠ~w=Tb4-5/ld2 nz/~9 l{݃n潱ŋ /|9vwvt066nF`h1;;7á=?@bsk G|Om0 eky+K{w ai© 媪(1[dv?g1 B?ұ~ncNX LX-!J cCC^<^̄VgA> I@R v_@\% rqQ50'WVEJwo޸~'H |WCM; %6?EhJy'$"fVk!$rAP!8``{{K`,묵Z*R)̴RŃKR5x+37?s+/Zsbdw7f*Ne/l'IvQ@kITL^Ԩ6N]&m5N AT 1Eg쥩BUEevyH K QA쬱Z%ag^I+1&38njyKt0|EyH5vJUBJAi\FœÖ, MS5$|l򰽵 ⽽VeYXVkU5 CH㐼W,=:hyأzh0@H?=T&e $uJ$IkO S' o0d9kuqYf$XfMg.@ew~w] O;{vwwossR\z)=ǶX28kTvvkڅbavnlr,QZcYAXB)I^XJ?,No;$W^ @ih?Ms wQX8P ȤG5J56*$YjmA[1i^pv|9=;9;7޻ʎkUWJ`LTPQm x v:h- o;O g}Kۍzwݺ姟yɹB$8 쌄GZq6K5BJ= T7r'FQ姳K/[mkFg?h,~w3<R%R,C>rN "VP>`'}9vDEܹsvqNq,o@\R*DQ!*T(RR#5O% r*OYg1Zd$R*)6%K,ŏg.Т1HIWRw -[N~w;Vx#^ k#5ʳc/|>[w}* Đ8pZ s0׺ɹr3'6H@DYy R$F7nR8==:Q.'g𵯾,K ZDhp0"Z [k0Q' [('_~ᅯ=Kٌ+?~iv:q ,Qv^S?ڭ gOw7~O<[E>XYg 0mux2t)P$OL-O  @0|_yð3vl@ƙ)o(Gj#NWx~;;/^~g677ܾ]*{{SŠI<3oo׿{z}v SVkw WV+J~geVT.J\ X2I Eށ! b2oaiOZ'yR+5%)CPG1k D hs mIxZʅ#! xB:fI)"H]OkP u58A@3!駩ed}fv||<6Q#ɀ O+;?6I#DBH$ %wnvRͱsݽͭɩѱ O褠Orecc+j/\26>!8vi7c'i R U &3 ji 79>A//A,qr8z`7Nu&ҙNS,;ȁaGmc> =DzDY< BZ ӹ 1OA$i%i: |e[R)u$"Dxz`,mw:vss',yO^;a$!3=4cfd@ VWW]e!a^dX2*0*Q!RRFP, Q$ $HKكJH2䴇<90$ZG2|ly|G>Γ6B!j3SuASNϟ߿7 V7w3:cDF$)6:齭;[/8G Ù)$ 4676T*yX(̝%Ax HX|ߗBR @Daƃ AY!$NNn93An\q0 Ð Cĩ3z\}uCGgb}84 !2/$~2I3W=yDզ$  %pn@CO ;z:KA8NьՌF@R1RIpgʫ:I-pc;;lZ`&FT3c, !chz1~ qgtCQ¹ĕKWpٜ\A(8z~@gV Oa^4Fo  _[oom ^}uxvjShuox@J̏߹qz^_D FQa9#!7v)-/o{k[ko&E Md`c9h~gXcZccv~7W_p'_~s333a/壼ŧUݸ0}zo[=m6c\~9aVG< ADh7vwwN9uyh}j ꜱ6&q=qٔ m¢λtF{?BQ5ꥉ5tǰaU-;fR!r |/fo@̋BC`]c%dB :;ov%|S /]>Jzst,Cn+)}f}y0ƶr !'}$Q.UP{{QT,D):$T*βGF^TA%l,]ys=%=غ7ЕxTyחR rM 1HWr^kV\>R+U#cPuƽ6~H!xBD'ǩu.r26ODXg\θx⇤ٱ6r՚#b3p"AlǝNg0IuCQt̙˶v7&;'p떏YRCe<i+! Q kkޖk)8d#54B15$6}ۍ"DJbP(j9 rFaoTDd>Hq:笵HP0جӣ@mAp3:V^G*mnHArҳ( DgZ==?_Rff~׾V/ݾSdӋ;xǀX;eVL꘤NR_xNR(Ro_ <]9=~V$xesS@`ť8:"J-Ρu IyɂOO@HL/|kO9ZXw#`9zRQygvG30kB`pn$;9>m?Ü*D^n{wd O۬T*m`=*ɲ/}b^v+W??ZXo6̎~?&vfNK_r!?Ns3g&I$1Ƥ깓Hl\Okˣ㵳NY`Yg d&`D,ONLUcSZxT~vRvH#6ActrkiN:K33gNOfj͚6K9>ljǶ <~# B"'\p3~4k,Z/{WBK/_$GGPc}$M9 `yqqiiqmH"u8.cnDCգ>~I ds&TOQZ3X 8 a7cXS;6M2No>RY͍͍ѱJ`Qk! d( ~uu:])d\~h`B:8IlP5ζZ;ݶ=jQu*+Iٯ͓3$ˋs٨͝8Qff&:B&6Ct=<_BѣLu;zS\ OPI30i/!! 8ccn(#s-Z.C:yLh|gvp9әT.Tyo|Kf'[&mayucem5IͭAW@ ͜BH_x:(LRdAW,_yR1 tV/Qj%L) 7(9b`pQ b`GDlbO ~7AV׶~W۝}̼mX$ֽ$.ʀ~?#gNK f9dK3Z+_qΝ+7% ߲J!:kQx7{MD/^z_th4&&͛NҥK+++DBӿ^T|嗞}Skk뫫cNdi|izрLoyTNLګUٙQ \@(: 0˻!A{̒{j f~ O>sraJnYyĎ 4㣛k@vBulYkn=*j}<,]vivv=$Y+owgy%1f:ܶd{8=+P.+D&ˀ`gw7hsbff4 DVJԀ&@C͎#?+?2QFSbtΑ籵(É3AAHJpY^] BY=qTVNi@ l>4a0ȹWc$l6t:ADȈ M☈ w:a)!M Y]]gΨieRi-WƟv;*U`*|;?N4 *[BV{/(է.NN6wV4ABX+{ڨ ]/T&Iyf~X)WKr|bR)TG! msH3-Y,*fݍS_~ v7[Dѹb  IKZcH:V˲sNgooS:p0AkKCOQ|-c3g[7޻z_.11Vٓ%J08(RaBvWw{{7]9Z))! w83 \*V0 QA &It%q FXrit:$pl ;ڂ %BJac~e:n|NǞ ܺv}Qig9z( ޗ bIdtttrr9T*w4VHAyY)%:=1 ҂"p Yx!*6 I)4Qi&"e$=B,.//w*Dv DGò,G6piI8((AL(TjFiAnWAX&Z)/w=ϫWbP)x(FgNt{I_|;{ v˫%lnq3,s?V_k7:z9G?h~O^y j,` ]`t:sišHiLJcU`d^ꕯ~,a_x϶:}epaJ9C>xv,#3&>ΓY!x ߹eZkp=F7D`΅pDbx\y]ĕ'>pڵgϜs37+Ws`0,AYHU,nlً;f秦[o]nNTǪpnaT ҃n6ڈ_D;Gpn~60Bo{7{֮R.I'Ϝ0[gHƟ:0q8T|*GbcVcf}R9p)q9(l":`B@Ă+]|}O ǩ3e 9\vp1@aK@`TNA6n'/:waѬdzPxt) IqhBq=]0ι,^}l "űBVưyf\,Nz=)P" ҥx~$mwʵf۷`aWKd٬ %_)i˵Q0pT%u}՝.BƠn_x\ jըTl̪Rve$Z)OP-$T3bg{֭k w>Xe4LBBH!f:s5.4 4M[_2M Xk;p0'ğ۾'nYjn*BTK3q[>yԉKߦo d9ٿvldhTsMR2-$B$4hciCdbp.gs2$j%,J˝͝mt;=^u:{vBfc%X,DX JZHt `$1}MHoUJgmk+NIdklo 3Sk˵U+R125&0av fUkuJ:紳ڲB y>4 0hJvkWTOϏU"eDP붕OY)Z +ȴ[+qD?~镽VKJO;woyfY&HT!qn  3L} 0[إ$w߼4W_ gsSsr%IP-g 2V 9 qGHLwXuT7 qKB⹋EχBp!xIk!*@RB*W<- p0Ji "0_DoOԝ;w_j9:o/]x~㽽o}[_W#_>15Z*7n3=?~qnzzʍw;urzk,LG;?P] s9mhx1c> @@CuK rfo`&z%GC࢏.ϼ{}$8Z "R`A3g R !V{މA+%*rn~V.W^\>@IsbiiիO>ƚ &~@B0"z^,XT+)Õv9% so9)<3NwSδvg'nVF~2g_Nzi7P?R|nJŴ֡CB|BJ% ZN zɠw{x0`nwqA#<@@rH΁E3$e tJj3`I"XkT e:Jhc3-!@P oĀ̜fPF7Խ#77"ru8Ik'ժnAӾWRT*Cw*vg4AȪB9&aI#PƃVT˅(*UH2FxyamRI֏(OL8̌. h!==J9\4MZw9O͛lmiF Μ> WWOMΏAmu/<XdpMwsnA055y` $.Kj𡺿@N)$_{R*~+_8yQ(٦RqHO[@s`%AC/GdRβ``iFP eJD*I|F󠓎nJR^\sDuk({ =3_~J_}=Nzqpp(Tg4I#PZ& r(b 2~`9Xkk-Axr`l̳Ojc)J2g,8!.(&BffB%V>)0D?L0Ȝphx gd}`m{ǁ)dNİ/JBT,O̝X,FAf6I̝; fT.LL"LU&&k.?ȻqK gNwFt=덢KIXC!:woyww _dϝ"kOs, EJ\:-r R wZbl6ƚryMccb2S#Z = P5$4*DGR$BuIbK@Eӽ,ZnpoVkKj-5'!e$3  W^n'Zez?2X} LB!i IMX,*mllJb ^\L΂;Xo68}u,}di4csJ>^xC A3!QDu4WԽ 0}< v(3uҡ'YPar&@gC@L7C%9K[km~? !>XDz( `||4 RE>"x@^9:lTT^( .gO*0,MMGZ>n ǐ^7*pc{ۗtJEkC(HEtc B),3iۭRҸ\N[:5cAkgg4/Cg${aϥilKHBD>,%Up=$>T/gN˕`rrd{ku߁fP`LgXD9ǖ*21/ͮo[Yd> àZ-5+O\V ;r3֘~W*$&n|1sI _[# Fk7ov$V뀄@dQ^`N xX@ Dcn?5 I+OtQp}H(aiA 'K%ZꃕwOOM:w9P0Vl )t{> <40R> *UZ7~}d,Ml/^/#X Bpsy>y _sת[rww칅HiB$D,N-\hGGH9H%I)C74w{$C==Lj fvRxխNBֻvIDQ[LX,t~pz##Q6NeznvVNZ%Iu^}jHfN9g2s[ۄK?ɩщΒsĉZ^Hl 2 "p$5qws}N+WOY8w~.DTժT A Q=k&gI ?έ;ֶW9hTϞj4ˍzd)Cyqy8gj5L tNI]~_OJBa|DG" t~HL^cqw_Rlr5@Ph=͟F)XB!*Zsҏ M<}_I4M+8(UK%Kz~p&"^x^ֶ֖׶66[5;3yB⏍~+I3 Tab|!wDQTGcOJ/LBݸyKOb8R []]}eY^6+b)YV=hwA:жu[~c)@e|xQftp\xBF$zP!pXpqPAL=xI>>>;6EG <&b8ttjf̀ Ytd WKMR≙ӧNlR/ȬHh Eh#A?sLdDH kЏTZ|Is9c2̝wYJ$8 \N4RT*Dw:)Iujq 33^wk'mF&q2 OYBbcDD@ 9YBj|" vccojr>Zg>©WJ,jBgݣg?5geRbB5*@@/RX*+^COHt<=Z: RA#:B[ɻ7l9;R|WCٙ_HwQf,3`xB\ lHsZ B)0n7oJ)=sK##~t]@ g9& Cýcϝ;751q2iv…FS^I{oozwi̳ό-/--.>8sO<119!+/,//~jskw:m%ƚ;/<{eQ]V f1>z h ۷>2ިO^&3`l ȘY # -E`OgvYC@Zػv{;T њKhr` `ϲs@qɑ:_O|$3ďXXI L ܼy1|fh >LRtMQ&Q)Y aE"L}ɧ.MٙP̜&HF16KPHN\p-˸ [^96OݑZevv<(N,fS^jھ :>hq_#Hh˃~*Q*B@Β6n2n7QR!Fc3ru_ M2pH8CIb.q&Idh;>j0ӄX䟍h/٨H2=pT`T?奎R Wo3_ yZscg8D&SiYFJ(AR2d*eQSB,R:HH-3umJф QHPh,ӈ;(cGq^Zk )!]sB-P[Z8T ;߫WG/^̓M?K&j`Yc6~00;N @%BQZkd`3sE8r)˕ #o/KőJqғz4??:5ِ^Z$ ȣ3/N'c#t:gHl;=}fQw:=J8 4=;1Qoj֎;7bc8:3bt"!8gGj= +Νy'b!Ӭa$1;/,Kb@E/'&ͭťecmI0E C a,LoWozTY]Y}مZ3Mjdm HkׂNgi 8TBdvm1Ew+k`O~0`':( sWaQ/_s Q!|'NGQHhc67[;{;KK''Ƙu.IAiZ.t~_ENfqwH8~NS#\#8Cb6w0&`dt]AnivՅ'O fffH| #Vsͪ>$aBFWn?;{o3&(O?sM{/$ϟ?$)PJ`DŽ G5?j`ZVYc#bX/T,CjFAa!I?R[anzxx-G23 "c6ZHB%56RYv;j,<1RHQ ܩg3Í7ᙳ礒V,6{7o;hw+K'ƀGn8lnN=__nf3HimӴgTDJk| @'mT$qzN8Fr@ RR)ǾCp:̻9+S Aa!2unfCӇc넏FO<*8NBFZ BsZ AG}"(%87v9)ɴinnmMLNg{~ w?X磋&,lS$`!kM7XC.1$r.CBIl Fja23"IΟT;lfI*yx262rd0sg&v؋dA^tXvL qgVglY b!(T,<`v;jd@I8yBV<75}wƍ$EY\\qs@h * $Nb-}@ip~${o<'{wJ__xq.zpfa, VJlQ d4f6B8ЩրYXh<霑RcQBҋ^{' 6Ia$bCU^2(HKyklw~7Ĕ勗/RS.W $Q|TuX #z(0Hs4 nǦv3í!c G[DVg0.avY )>?'wS{cN'(ECy5?vX}B$45VB )Ӄv{kkQAZ6(r8|5$$ʬH\*gbr/}β33:ˤNgwݽ]uZzTTmsٰ\bmh=>>y2:SJ($"o(9uDs*E21fڛLY7 iZ,fsN{d$IjhىX  ,[ L L?,eƮmliJxUB:;S ڤ铕J7R,A~*`M)*mxBEIZgf&NT`G V#OA$ I2&&%RZ/n7 ;pRo֦'N+WIֿpKeQ{(%pN*Jʏ#0(~htA8`TDLߺw;H-6 D@Θ,P9`z|u7_ `owl^toܸ˿~vyi5")͑ & (VVVvͺfQ7*`ougsK\ymRq"ZkHSr!e G'*=Q!rΥi9'Ws6HRHJA`3J cw]!

6^QL$o^=Ҩn^ZK?}pSH~JR)]3+*g gM!gA)&g qJ߿/k^F/-,M) #)P3q{Ov kMn2,Gfkkv:zGVF3ssB)(JŅ`pa-[[RTJ8#!r ջkwɍX.;wRˎ<|?pDyA2hxGBQT+iJ4|ɍ8_v3^i_}̤Șv9q&sqa%<0΃`GH(:,(Ͽ{}_T6HBWױRY~xةW+hq#K4Ϭhd_wR"g  8c9( <d<̳;;oN:uݝ$gff9l4zɩk5011yݫ橙yS+ ?ؽXv&#S $IEIb'.f$`w&yK~<}X!c 9_}R0q\_|?[rS*R)L|TOՒ弐0gL ca;$G___ux%pG" c!q ,9d8x*HꓺuZcÁLHqCƐ(2Bn62.8pzaA9Yx0uITj+88i$}|@q_x=zxw:P4FIfR$ G]1֘9RRI)@rioOslY21XDxD6s\rd MnAF>ynmnmy:9u dȕ98l./DeaiM5ioF;Wӣ,'`a"͓QbG6em-]AS)ha&Q0.X9Gi*>>!8:◯ލ}/,I (g)GƐYk4u`c_|We{c =r΍֞RXXXҹ朇Q9?<{oZZיtAcsS^y񻂛ug\=9rQTH$Z$$8L;;GdԞ,\R)``DfW39z<HufG؍[\fzj6ӓ>i䌣c $`~=ue BnYVF>9ieB5E5Aqc#pq^:ꍆRg~gH˕J}QgM D'IF b/ K1nVӟ*˭|ٙ3KaOM5pvJOv4oAf ^Y#bˆ1!%IQR1Ɣ獒DJv=Ĥ(&$+% =LGk֟O Cy4YJzy/%jV qhqL8)p VSVapgUā }OIN3Ur䧣a;[[XƑUL5ɛ6,/Jѹչj9B<,JrY]xZ"lg KE1!`0*,%t82hkk{Q\3>9YΟ? ϟ9}f !Qz "rDd`q$Gjӓ79Dzݷ^r]o6,?l" t<ϑ r9B=OT`gT+ %~fgJ5>w\{m1RRsj2g :gWNOpt/hT)b駟~:,=+J73oONf ߹VO],c)qkٹsg,}^^Q>1^kVd;r""!+̋4u!rxr^?Mlh7Aϝ=2%11ӣyT˹Y vmmkt%<={njq^ ͏ ߛcA :>V?b'G\0c;mDf-T(}vu8RBLKgZ}z~ {;QUJU@zԴ6ؼ>RD䀜1Byt$s7oߺsV9(W+BaYmUJel.+ċ=T$Wu{HF)bB n{ kBs/<} S\0 wtt{zfjzbb v;%yf$yx6NP3p "$N9c=bssgsc h P+6 w5 b9hmomlv:홙_xalrz0xʫ~֘-θfZkgi/y+)_y񅙩SϟO,._}>Lsc*~< 0JFvEz zZ+ S~:MB)K~QbR #g pY%`UO,KQ;۟mnl{^xar|bccR.-.U*_W+++F\.˥n}ΝsgτQ3WɕyѠ=91 ; ŋ?QNLġJ -|#4qI@!d2:~p\xs/rib&7e. "W<1Bq!u:{Re4LHZً/_~ᅧ}O1nrC yc&Έ!ȘP:Їyu.vG(:B/9.!2.00#GD́N` 3!+ s@1\67U!TծPq[q8cȘs/ Z* Pr~GR^U),?^H_ӞB%"TJ_O*=b}8L#PC PR@$J 94Qޣ0(EQkGdO{CrayeVl6ƌr=m+Q%R [oەY:/IVTR{ cpNfox>A7om3*xRIG>rH2 BF`SԳ'*]Ā֨* mo< xb|̛*0fdR5^K3@7֔Q1^3sx>88 ci,T(Ʉ.XeQ]A ιS+ S\l%kNoac8r1)&,%7U) Oouׯutx(\\Zkr8EX*cXDYM@9F@0539JJ<;5Vj5WޛwƍHdȐv綎[G-.qAt; t48t{s.32-Ŕ]JED%s6++4 }|tij: {)!uJ0 oߺ%7 > c *(@DQ'a;k? | :G\c1<Zc6 d9u,(t~,=::C1d yR $RX{~Ri4j‹Zqs!T3 k3/_%ic|Zzjbrو.\X}F]s7yFS :[[_*W 9Gt9c:ޘ$M5T*M$0s:yP\V p CcO5 ֬ǹ' ~"Z:f?V{4e*tAk%2G?ư֏xZF+S3D._z|cPR)JBA d8phrKd=W+G &'(DkR~x$#kƳ/pƍ[7oa0;7/xq [-.,OOaF$b\s.TnFRU=˪Gyr&*p8"c'3 (]Y :޿`}d]\Xj4s߬5J~}s@sxy"w*5Xo> `RQzG>>0Z.Q{!3_3ֈG?ݺ8ueD@11!E.uQ_ '[|!>K - D`Id`,hSp> E2ΐqq0N'ߏ Vk&I%UPb MZJa Ös9ޘJ2inܼu8XRb:qr 4G qmiUVS W'[=,=:N|!G@Ƙ4K =c8)r0 CG9y8??>34zݵ;ŋgj4@iDR0p8yF`6n-/ EX8yR2r.c2{\'f3P*3N 2b/R.] {f86B&y*~֫j4='g⸄3se*@Lk \ hPGqx1AW..`\ソ덱{|5Ii=,G`FdU'J85]Ad\7zۧΜKҬU)lŊ'(D<㡣xR I$!$Yqlb4I{2=9mMN1I!DgQ0 ʠ::.)yDFIe2,9;[zufat뷔/q9F X"c#Bh0LӼZݽٹŧ̄kc! s6GzXbA*f੊/3' L AR 7޸38ϻ!NW.."ӵ!cH ]/s`s] GvxyR0_ð;XLk<΅bzwPa=X{~}=ڭQ \ OH,9 I^7fzຽd0a+˲r~ZY%J46V'Q YsDɸӶsrZF#‰Fcnrjnfkk,>|}ct). ;d])SL.w@*O\9ǑEa윛Z|~Y fvnsԩ/1ȥ޹rD\*i~vŏL!~^0>"0F! ";hюdpXifQƇV,sŏ\H 9dH$ϤLI@`1!*x7?񲾦33iez<[jעw RZU*e}XLgY1!I!0 27(8A s!1 Zm80NS}_YCK֜ Izktt eᇀ D`[Oh"s* B_9u~ll rĂE(uc" cV+,Jѩfis=Ú!1.MGj=I4(6 d:ި_QKkܺsk`0F "\#8&g`kC.Xfj?Yt瞽RVG0,u<ׅ3"}OkMl1ZJÿ^O*ۭZ!dh,V %c=! LG7_xɓjwwsL5*^Yu\篽jٜ /N~i::55>osWNM LLU PyW:YHtqKHE}O:͍dhYB?|? OE]4 d _J ,v#⃡iz;FfjBK@1CNzJdZ3^@tdmx}]åC;kFZs pHy@(Dq"bnl7M(%;Jeaa65Y+y2ΕaVz{kk-.-Y7KHicdJQ9Yk,z95*0<׎,˄%IBtEc,M~ <˲I!3)I6M3klT.;R\z~Ã[oY~~ wC";i?ϐ )K7}=@Lh cc3SS(N#z}Ν5srdΞ=$Ãv`0_8=Tq%Z)75kLDRrYJ*@@&&BpeY9g1 rXdnލ,Sq)7_R/Euv!2,qP|E M2̔Ÿkkf3SgwWͱX.BAg~\Opq09s:G Ba<N2P`9tT鰇LZBƹIMNv%KQ7:adYч\Wm`2DAq+!1f{{;A2wvwCm$y*R8a+ i"T5aT:< sVo|ًӟۇG(^K^Wcc)CV0N 3q^*Fx3 [Kcϯ@2| #Bdcq$SI!()?ٯ{g_KFyz93WmLd M/8`9^&Þ)9]DB:w{{{[B mYk6Fh˲,YE1QAq<&T:Bi5 #|ιF)E&{ۭA_)?lno{UN%_kKvI'P4IhpGcrK r3nWrPQ9p LMWyZsss/gZ}l0? W._o4)J:mooqƚR x"73=P vcwtBgsKTLU}"6O9!X zN1( ~g?{ y睹9)幹<vO:u֭t}W8 G*D.ϵ+Pzr3,W 7lP"X69rDPT/oqoZ*YoG;^˂*R |[8k(H~rhL鴦gWN2Z0&g8m>:>Q+,6Py\y! p#p)dAA*Fou5'1˸gcS7`c& u:EƏ#0d Hj*wۦV /ÅJ[s\!8&Bbԋs4+1@qD l ky*S*k(%9B )sH*˹1i>qeYG"U-b(Ip||Z J,O37|qo~R= K(9Od][x"9gTV+v̗0`u .tHFAL^z-$I&&ϝ=s5Ycr {-SΟ7&?88H~8wjuZQTO wq;KY:3t%ys:/uI ɕY80,IxJn%l_pnܸ9ZY&k^ݶp3g.,.&I(.]vWypowqQ~Ee8\[Ibz> CBdI$# `{gkeiU81v([cKR(*rZ~saGQ  L&ף./u:kniW*FZdž5Xւ }/ (BHTQ  4K2X<߫4\t:zM ͵ݣF}vn矉K~Q< t,ٰpO爅&C/Mh]p嗞Mt#UCzr1ȰǃcD2@r0HVK `0rr,Q)@g'2񱱉 Mx0{xSO-Nz,p9!`$C9|go*pЯ7B ;۳39\~8kKAtxx 0J$J/=#yAW9bŏ{i0ZZ>}Իo_ YUۻ;w`Q^p.cmt\%~UKWʱL߿\ĽD7\]:[yrzjbr̹CtUxp(ʼnGptV™ɅW,@@atO./od0.?aw9>V<^^v.%2'm(/pғMk$"gSqN)D c.2G} +N8![)KŜT`I iZ0@]}hlz\+a·:VLc4<'G:*}xs-I&WkNJ)@p8Vkc1X5q<78wR)AeYa9j)'kM6a6_κ~#b`{?/ҳN4nȈZW`Wbq9y[KBy;;Kr"p g9p(\n+0?O)K.oۻ{κ/n{ʕ_xojRׂ0o6R:kjsj%<9=;jw_f(Niw%zu,<ϗ %i:ri2AX8htww9Z]^%Ij7^~ׯ-÷~'a|rVѻgϞ7`Ꞟ[̀b1,X}{Q|O-NO\y0p~L /fꬱ@#CbGmk]J)\zv[$Y, vcF;{|x۫]Ь6J?=?omBrv67%FtS\zZQݗB(< 1رu62"R*Z)W1`1Z8\dYt0RR~^ty013zuY&(UCd <:ϋa`uD 9ݱm]}A8py0jZ-9$ӹ>rݭAK[[[7oވ4My ЬW9VͳQVW/,sδKިlֺOHN:ot]Qk͖޻tsgK}18*" >_,ɲLxѨJQ 0~4I1Fvhjr9^U J'̒( (o6s㓓'IټplOLN^:ǻ{/]+N+ff%rY[JGc+DιrsN/=ڽV{0|hPEzT*u߽f饽@eRc8kqSщ5o  SFe ./8kugz,%ip( s Ixl^8YDJ~w0H{ƚ/o<S#f^HӐ,-$,:u~s2ܷr10!tsݗ瑺x2|9gNG`ia?eD8R%Jn N9JQ:BJ%¹zGp0Jr677v4@ƒAf"wtԀawfy\sJBO?T\s:rE>NF&bʓ=wܩ3[9u&Mݽgϒ3{;i::{K`?O^{~~vV &h »-tT%64h.MR^%; Sz·q ?2KDC[loo޻5징s΍͗NR }c3}Ѵ>C> qO~RqǫJ|x=OJqp)FY9q~N,Nc4]"X\0I*tw8Ծ~v蓻N'Ϗc{Ssgce fB:m4J4QJq'߯7:;*Uj=E|njswHo4WA?6mmͪձƬuЙ<#rDke;ەrZ-KD;Re1DFrAs|gg*~p0H]8=tF06杷ow\ϝ?9`U?x?3P~r堔d~O @Ig\Cdh6&vv?xW^y9 ggSB*5 ]lllyigϮ(GnߙG׮]dzgf~ι fݿ9mݻrj.J^~܋/`Td:Fp0t?>]óG(ٹ{>k4KKZ8]@cGp#CD6t 99Ƹ$ǭo{S%IV.(TeentƁ# ,>}tzK=@<?tqtAdblzv2a{]~R)l'R@UDXwHIhy)+ЋSM!Ig:_f%ɍ;w=x͐fZl;?Ap>{YIfV&Rnkk|ye2DGN"kݱ~u!!>x ?&'dC=,?$WLM |xbڈ$Gv\8BOm NkpަsnoЗT2 g3xT'ơBB@șq:+ 9PHxO$|b}A/ѓtص!Y01.<[؉昔+M4fD4ҔJU GִAfGB"dyFd 2B34MQ*(98s5mahm4!"<6YkR`l6TYeY'&(gWG}!ڭwnܾfd).e/Ϥ\knMMwG!`ƸF0DP 5Nz4zqUkϞ<ڤ 3K3Yyܫ qgY6917J333a繵LjH*Pݛ^3cGAz0W*5к،1\h#n޸exV匷ZjSIO %(rDZODh jI@@KDhD r$i :srQRi$k=)3<1Ȳ1Ylk/8OFaݸVfgѠqo2"ORjuv7_|ṹx~ѨX; v%KQfYƑ ! utݾRr2rD f3 1D4sY1.N?s c{~otB"BpW"HEܬUƶ6w1Թ~1Ӝ;eC .L-A$"笅tdar͛79{.LO? PR6yd%P2V&}cA w6ʥ\_ʔ$O/=X?lwZ>ropkoW O0ǹrq.F&K޼;eYFjy><0I4ј,K`s(!9L7~r-R֛Ν?:?^x^~>M. r2=Q_]Oރz07!+4 b yuElL02r vv;޿ts?tG=DccG?hC?W|)'6# 8c,Wߺ~sܩ3qI"3(c>U[>H7 p`TJȀ#!x|o¿{W:L<`wwۥ)W.+Ѱۚ e-g 6&ɭ[\8Bqr$IHy )=S;88p !1ǜfD&t32G#DVI2DmteBJ" j:G!c� Ӏr"ܼ}t¤d d snlXg mRcN‹nHΤn+Ν;kkjwv-|g*^;]kTN\.5u!e/JiQa$s{kr86֌0.PJ`~aѨwէ(wq(G~m!Ȁ7չiv)p$"hk WX9J腹ߓa;gu\#c ^޿3;73Pqy~pppe1c3+++B,ˎ::xW/^ۿ}SSӓ(7/l8Lo߹ Lw{=<ڪVJ͛7Zdf! NA"#3JFCw3.\ A 89k LH:;[R /^ZZ=3 "#c?d5)̴d å !:zȁY: CÃִq:jسVf&f2B$`H_H|$N522o vgKsWO(0^@D)E1=6sL]s!B"RnmmMNΚ~'orVOiŹRg֚P`v&pfsݻx㝩>918`2=im@ygYEa(udd:`mZ]ps?Lln>Rix3c3%LR ?џxꬿ}߹QPJc d1Md%pA8 'yΩNY ܽuQZ)n4xů$1B\z$[_yApdwd.,L//6hsy^(OON#2d9R@pt 2} ]xch>NDMF/Qk~7禗O/=X=>SeD~;#Εqkkz`HQ8u,@9'rؑr΄18+tk<QpQ 29.ZyR)sQNIJ3Gj}p`9̴ΕID"#9Bf- rs^p94> =pD:̕g{vN+ñfinKHP"ZZ\-b!D?ZcQKDcccR Ņ`ժU!1pJȅӹ><<@I3SSvvvFX}brrr_*M x~^[\wN[,CF:u\@E8FDj?2rS͉,OuEQX:k̙JziZ)fF>gƀ1a wwǛcc GeYW_{Ks3?_h$j }t&y%.UAuNmuDamMMa0=33;9$|S*y:`8zll8D@^EXcs(dbZhjo }ez73 F{woV,l]xR15))A䜀Jc<<Vۛw6wzs_;+ LX@ 9"D_G&k^AVL"[X==[G&B0sΊE'lV"rez~LOӟ?̳ +W2-TˀΙ>33Us0(Lfw Jei{WvvqxxtڃpPk4+M{Ӳh̋O;8[4Zg Fi=f@d`ؾ{}\Ѡ @PF+ƜK#K^~(76woܼ6 kONM,-7 ux(xvf>vwŅ t֥YNb:8c'XEHxRUk󧟾z)?fnjXbgq1ZGp};FyyS+>2NΤtYƤ c?xDrVFAspL) 7"/|uO1g3v'[!JBJG 255E\\Zc?dvTAmm (ADB<1J@YT*P24I2:wEQ,\8gƚp$?}+v+i}BI6t|9,  YL0$_z xfP."R1DV9nnQ811{q)HaA\jQ?B/qs&.EF\.iQVqd+%Wj"BƐTPXTyԴRYf#̵g5)Adcā}h- K@3p8:w\})wptx̙wn_TG^0wߙ_\(f^սvTQ#rr9\Z)ut4IZUH̳O5G:Ϭ ng42nowW0GIG0;0Mt=L\p13QB5&&f/Ͽ77jw 8<tp770?@i=RUfggͭŅJgwZd!p 8 @bCd%qunccC'\H)\: Rnsppx:YZZvEǙJTX:^e3gʥs/<8Vsoww{\N]TVVzRiJI1oʥ1 G9`ht nsSg 5^o4wn;::"rhhkf܀L bU&k\/]y[7=160T K>Q:hfz^+;؏$=<c&n۽tZ@L̐IM:XQpLdcoxq?xM Xuwݸ?.?og_zLd;ϭMX$@'W8/V/ KNkcsLzȀSַ6d[NJd2[_#pO>dbG w=GOG.X\-u<=zD8CKfLrD+gu23h3uA,YGơZpƍ5^O)R RɢC81dHDYdAoE Du1Ty^ZェI:$SJJnQMOMm<0ga6|/ Ҫ#v7}srbjO43 ZD/%Ts ?::pȀq><:_#R@[#RR g1]D659SV"2!\^r3&S@$cPDP0dsTT68WAJLD`9gioAԂcϕʍU^y$Dѝ;SGV#(\ixa"4K4d\jf"Ktqii~q1#Ν $5v |ꩧ1`9Gc2dZ1Ƭ8ؠ߯Uϟ-c! HQ냃cbkk;I4=ffRnd|J)WH:U)J!Jx g/iav}8vFV ( =ZT)WZw(/gξuϟpksD_tǽ86f`$ XH;΄<;Aw-!/Dsqirę>c:'<'X'uR`o=5=}̹ ^GH*"3#?|}1"{V^xyr|0Kϔ1 GF;G/ eɌ 7@yy8paqye\L0'$7:=L!˕r?Fg/߽{QoazvnQ(ad+DJGq/]K3ZI4Qfo*g㓥)\(%N.Q!CH޹{^׶kz|Sg''+Lo9gŜͣIN~Ov;-خ'J(>5GS)wm Ї}[/nd ,9po9L8(b Zl<\7T.0Μ~?K Z0A׳6jND4<+`0(ZJ) -Q )#BHF|G |42t3CYDfXEAd,Q$ľH/~MOϤJ0v7gϜ=[&&&Γ_yQ^{{{495 Mxc㓈^-w;ͦRr4J޽$sRZkR "8k=N|D Fx@8\0V 4-.Rx٬UL+QV*J{\T?l&&'yi@ḧ́GQGy?|ƩS>&+W~n߹λoG?dyO?so36|arX~Q F4LZ=}r)*\1у"(<1Z[m!/Xsv3|cs"hg)qB`?$} ՖK%[{Bx{{QXʄo}J\Ra,, 6< 'b| 3n~f<\1 D k[Gw>PB՚f)ɢM9X,#G=kM~NDOQQIF% qil0f4Fkj>mιK/>EDnl; s͍V8^!~f1R˕Ӌcu!փ,M|aBo,pY&BoM  @z1@N8n~cy^^Zi4z|#", ?-O'>59hI"J Ygyno\{;QT%ǧ'GiVqv 1Fx,?L`+6`rkUd(80Vأ#} ?eu&=C8J9BO-_gqdI ("wQUS`X w:[xx,YYY]==0O2nyhMrG ?1NΔ=*:ބa=2/:ӛQ;Ij*  GA!Ly'@A$":3I5 瞼DDBjI% \eft8IJv"^Jۃ_wn4O/K%=;ZLQQoԜ8kŒHX0@ŹΨ{]+UaXs|\iZ_x?uy9s{kn+Z9eZ]7?8+[ϟ\ikwo]xbVˊ kieQJAPHk RA"|8()\Cki8Ho^RJ߻~ ֱ':N f8UJ$OFHc8Tug>~N $|"eoQ2i'ܻqJLAx:<r\ݝ|gq1)-lt",`f9p YguZkc, +,vvvIٹ(X䩓޻JɅ*ժfV-+)keU\62\ Ihb=3xzP rj(75Xݽs/ 9sm?TV;ޫIs3PZ/cdƞ-MRKq'www{~rc4\V,eM7_]"g͇&XI짠ρw~<Sx5tu K!]:RT&Gq^kK=ƽ2d\Ň0("$c2b!Rey )DGIafw9Yc@*+8'T2HI֙Όq޹{_w?Ο\9JQT*%rc2rߋB;!yAH 쭅"_A )٩aaX*El$ C!u.IX*HlD)sJ@BcRRH5M=( ύR1J 4Ě1J&cfU+ѩ;V0¨zf'I<_)WL>l/ uN?i A3R{/vw>s+m#lκd;@9)Ld4wenWo Q% *]t&a)BD:@dT*UVNZ{f$9'%$B wg+Bh8>odq| +ZS RkgW!"8F9q,&Xmuoݺ[X8q[‹Yj\LֱG~GX $ @X ŀ~Ԝw Da )uܔVfN0@++-i&t{› Bk}XE#` ^/2z)Zr⹹N}vZ5}Zj@Sv͞*Q"H0?ƭ[wB Y_yR'KRb2 *eHGr2>  <5 B%A 6ֱg?3S_=}banAdY(DbA(1X'"%U=9/`ݭI\ˠ$3M0\]{͙NøqR{s֨X}T6ܾ B8M'f[];ښ<W8F` ,m$"ۅ^oшwvMB%٧D Lpתj%L,ժ\\j\w`v֨0+RV Mz[s3g.&ᵏ?ZZ\n4?@XZtnϴ@38DTRN --Zy[I8DPܚԍ,˽WI2~>7@*pT[l3!D) Q6>U}s'O֘B9 O Kݵ˽{kA~p Վg!x2dD Dz;(Lgj#>\y+$pQ QI"X‡(IwA{! M/6-mb(#ZO>'+'ZXʹ+zp1Cu~8 ㅯ[g&H@R~^|) V*ABX. rA@@‡DwmEna&`BO X4 GKx9<x>a*zE 0\؞vFG%\i?$*q%j,D9kNgv̕dؼQM $ P"y'jHXc~f|ΤV?zuB u gH  : qe(̲"yİc,+(/ _J˅T5kR1$$"ZZae,ZJ)1Ms!C=I(*} RR! R =9ZJv[H3K" #1p UX(+3Íjݒr9z嫕j<  ZMc¢Tݽݻ RXu!wI)H=2~o?KLwg>Rؙr%eZe0Z aɍw_/W7n\LgϞGH!Db$uV'K$dY:7yKX IAaʹBݨTʳ&OwwF~l5y?ujTs:vcFqI ;ͺo;U@y;K)^ RݺysssQk,\i7+F 4O# D((,?~vN:|nҥZ"VC@3#3?ClW4&g"?yNw?`Ņ/\h"!l];0sǕ={/h)LPg@qwm8HvvJ^oMٿ1_KY~ffl/#i/ތuN }?jH@JdL;Rt&Oݻ;QhkrV/1dJw UJ>9CFvIB*wv'~woדDJQ./[Xk猝C\#D@TXRDA,Àqy|17cw%2{ Z($܎no=43Yj ~)dnrc-H-Tyc. #)3XgP$(N_V>%"*2/xM֍{AKkktgI ׾P0H!sqϸz6 bfIku|k ƩK2J D!i- ZBD aBX/_~zV>lc^#pQ"x9߳C~BD=Ǥ < d޻Uu8OFW_|A)_+"rցD#!" NA?뽰굸RW\Ёȓ$TnuzD)$ O(pD#I0|>jȀ>%_e?D35Ɂ di*$ֱm$twWs M`ڷ B ZÈP9wF AB =xB$IPzs !q읷lo [ʩ/XN-/0SVIK[x2m!EV\kVJ|BJ$H5JOJ2z$rΓ@B@JaRaTxfV`z$O5< {.ܑH8؃5YQ 7Ie9QIneOB`;RX :>3c˳x&D䝟$ ,K6IC%w߻S0:Jw?#ʰT֗HHm,{cғ$-g5ނEO ) (?p[{{gάj0o㬅wu{{Tq':BކA9!:A2w6MLwo/dzdQı>' rj,Mvʩz°$$f֊_R5716rJ޼UfvκѠiXF:I%(T* J䲜B#ՁqZtm"` `DtfO<8 K# >"@y(01Oqv>½isy5 H7&$ I $*1+tfK <ǀFOosϏA?i]Ӳs8Ц= :(p*XZ AFHà6 &$: L>V$0`̕JrkGR*!Iy%!p4r\acDRIƍ[7n"zZKr\z嗂P|"w9k"xדּ1ҁgHZX_T̢3d`򉦭D3 Eu3qC"hg&D$$$ol3YJMaX%QT(Ǔ$( Y+%MujsFo$!)`-9{W@HYW`L`zx@H(wT1@RĈYA"*4%,0 ?xٙ\TV%uo/ۿ?v/|eD'HWʑH\M '(ݝvwJZ\U i=7d<ҽ;m]yv=pkfd%*)u$٩ת]n\?1TV כe]H^a:L0t$z3 pjbs`PR1,*HU<'$$%Z ݍYs'?tsRYFJ{XCcՏiqj#,=ҪloǙ3c RX白gKDj)}8˕8 KrZsޖK.DQH3ij-IP2Iݻ;?7[ I@8`D+R22Ks(`HC%Uf7aAifQfVN<&Q5YGZ $'$!PR@)AG0y|j>x>< "1LA`[2 J(*Gyo/5mgƙdH rK( H)2ƥyp854J  S6$w85, {ffg R ~Xmʎ R*2Y*U$ffk][ܽgm~XTx_(U yiAr!=a$f`!Y D{pee<˲k׮ǓNg6ݭwu#4$KQ,>/ Xޣ c`{=!|G'0 ~%U%]%J``I DJAHT\*T@R$p|vDh9$>l|Gk8={ &(ֹR)a0G,; ]\a;UO葿A>n,HDQ%4J9$DRy{HAKqg8HW+,5M`0h[Zѫ\qzݰƵk3s8}pԯL,ܸ1?{nxDBܾ}#+\XZ{޿zj^˿K$[@:UZ֗ރSNFrNaārQs>CV ޻ @F^ B268kPp?C(]p8{>xRT(dv1ufB7C2jI`" ǦS:_H07.#`ʳĤif{'^zu/'Z$D@-HyQf|a_}{oX 2A[{X `ccͻru4Jzu>BBORB u0j7Zuf]W͒l\/;窵3)w=xo KG& |>mDm~'zkg{occG/¥KPJY>H$%A P` HB <(@Q}JGN߻yl_ҧ+ OnO?s'|Nޣ?GJuz?Fi ZH9.,؜\m^^mEwThn4R<O& r{̓@{0Y!#{ceSi}>ǂch2@Eq'A ugʝ+,ke2Ṅ0&&fS2`` k>^0'/BȢ؆A\ DT1"  &I߾7&sKzJH`T*qtÊ ;kS"ѓO]MR (JteHOĂE/{&&9&c HTeaG׮]@sBK)dɾPIy/٬^}EUB;%̹_}r\mwj-/rA&IRZh+§uVJBP+|6 G3w^HtO:wݻw֐EKZW7I1({# m !#"do 5f(s˫gNf"%x3.WJ~3031ރs (°$[ݭͭN^yW*xcʁݱ/ݫƑh|,\cDFJ ػ$;O?;;je,wk{;X?l~67o=v;ǡV (ChJ$GONWȅ|%F(f)5$׮߼qY6yeiGsss/t1S} ]3Ρ B((h^PSg [X2Tǭ=)ԣ#ǃ`@_xx=)$B+?xHJ5˿: `uğsxVG2dj;ynݻroKc?_}DA3(~Uy? @%qY@<[cTL1U 1Mp4vUk~M^5FA騰$e$`|vx<R H 3H)I`Ey)AYfsW CcR$M(بW[IrpP=?dYz8kolnݺulnXZzصu)\&֤Yk:scKo׆(5xkg\E}Cc هAAAsF֖^yo߹ PjQf(&A*[[[ׯݸ兹,=sf)},;$|ݙ$gv$@HQ$^R|X*yg e% } <؀4X&6wZiowRFS3`?.1B|L78A[RǥSWkF1kAxeB ^Q Y jq7 ^ngϮ?$֫܌A7R~Ǐ΢b(Ta3:d>x#˹?An9*s(͟(IWpv2R#U\G U#=2{affZ4MzE2sΆk]&TͳR{z{A(Ϝ^1ݕX fv5Ӟmn\>C>в33;Ioq|{\Đ$8}PVN>-d{Pgffkڑ#U@P 6yB`b{l7@L[ 9~ hIxf/UaNzkz AyBz M~V; "י+Y:1C£DM`,dDj;RrϞ@ou?v;򉗯^6f27\.Hh-J7~nVlnF OYz= /l$t\Z\XTwk^pO~{I 3@hh6R]@FU,),֞c=?Ç')@>s7~g~wOR͝9wvfY8 g  g@)BPyx|<gr#_0p LxND|dr=x}ms2j)3f4{6.&"TTR̃T4qLONQՔRD䬛d\Vux4=th4:yr٬J & 0S{w6;weYzRY,,w-Ԁ6^z(.1@nM\*YvxH!um;B9BTYkHPT7v6o ZpSgF]A>C $$%8 nOP}TK33uqii!s@X3P$xP Qɯ(3PFr^!Ke&A{rI΋03F\][JX[տw[L+Vdlh2B@Kgr BSG5Ӫ(mUa?Z[cwALYdwΩPfaCoYMVku}:@;=n͑ibGmZ>O}gZ 3sTʲߛ͵OzV_¼&T.<Y) D ʭuc2D#)Kκd<)ժ2'^Z_6ֺ;APf>T4jZ&r@ gJI2w.ufͻww(+H)ƖK=_h/\С'{('Ïӟ\iݝͽݏ3 T NUV@)0y/~Viy֐Rg0 \?!?@ 1,AP n\쓏o=gd25|xbJZT̮w^*]"<ܲ;v { Oܸ~' 8k-X^{4*E|6W/#5HyZx~FٓW.,@ rA-`KGL>7[llyX @0T2dO7n?Ox4WKs3$ h(%˭jR0&U{E]$O_txb /^+T+9t=0"L;=!z##jx> 󴉟'COß" 1#_e>4jLyph;P4R5[@\gRRO&P6Q.76vI(ꂊSXHB3zǹLa^?o_ 3fYΚ4ovܨ]|~2^H͓@Jy*$`$iߟlovKQyyyժ7=bk2I>L o~37;ߨ7a^]R>fquM&ou'],-M+6S߿qY;?HdV*3lw go;3fiUTk 'aRjbj, i @HG0B:%EB>|GZ>ڭ[ϬkkNv:. >xD!m_3p^?JP@Kr߿aww$+gϭD Lk,L;"<T1S î(V yT(n2=aN&Va;[ql2f& #)H $fpK-10MS${in7Z)yXJ*"ynQJAQ BaowpmcR) `f<ʥ0`RՄPinJ=9!eZ0 SY>$]|i}z/~[Y,"~?Bj5.J))UH IH?|dVfe`"ޘ`orZrLý(Lp<JU!5iߦ.ˋ8lDh-݉`+IjsWVoݼtLqy&i*,U*yxT:)*D}A'v޹er+$kV[h|rQJ3ƒg<cw-Am>>l;Aނ!޸~g{kw;K;qYpD>VBY>KNd OS:|Hrtoo%v4u}`+W."ECQO(X X <`X/b"/!/XkPHaB /~_N`aq?M䲋g+PJTWky*y@vHE+LIePP7o\Un\7fM+'~g&\Z3'};GW^z__y课t{fpHx/@jUc,\QE%#x@" JI P c0&OmY;73|JHEy)\{&nH(՚ĦZm#0,ocv&YaB3=I9gG!sgYn k\Z$7*Z9 xGD#;D(31Q>v9nwڵZ^i*V&ZFZi6kU@D:ϝzܹx{ yrC !`<GE)0KP>H 0͍pQGZ fV…XM;M ܋V34i>@ՖRf劌Kzc0xL;3x~PCD*X,Eݻw[g[[&ħϴZStv,}z)}v'#7+E 'Z.^^ \Cဝg'@'717KPQ" K~;cuX-da3V]Ie֤B >YʳF9#v* Ù!ɭx9|ݛפgW^}/w ^gR$ {j|buՍ%PىD7O7`C0 F dTDtAq* DD@>|.|xβB= "67){$RL$s1O( YCf{;zIcR)2y= bkaQPњ%tDXchONxzx4By0r,m2I7ַ èR*+R4:FۭV⋗]LF)\rnWj?Kͭx8rrIJKW=8q BB΃l!W/]r~{Va~˿WWm6 d$(ő&ھKps/_jW\)Z0GNyg "i+ `wFd7RgϞY۸u\k&`{9w" {||jmvn&R|]axY: UO򷾽H@>q[h[_^KY= o[\&@bov }6頺#w}ZJ^=8\Zh<F״vwAI# J `0̲E{?I&kkD=qbifV*RHIHyhcAسRJo'Y'oϟ=s\Y8Vˡnݽ{/]{{J$h-/ N?y7yml8)E)gzBnb!y"H!RJf = O&)R FvJws󵗮٩ݍ{߻}ŗﭯfR PjRODHZa;/ E{. @?g qHn|zx'1"δRL~ {3 :R"xa:(UǢ|9^{_#p/ouv!yxZKGv? Gp($R |'捷GCRr~D:Ǒ:Ԩ*$^ 5%cX*pNܽf[[ۻskطFK\v?JyiOBgҥp5?ڏ~ J8/$PCpi&b.A.B*ĄU&M(! q *Hb* ?>Rq*$sL|< ,xt?y<psrp~JcRX%*,Rcn:3R_J4{{}Lj`PE0GMwg㢨0p xs E$Mv qfYDAD Xqe<ꮯ4g AcgIfVݹf%;k帲0 noW+ݫ?wk2ƺ))&Dl;x"i> c'AH(>P[ٕM,1$>x{eeSDfN.TAd~O3ͥ`ov굚qՋ.h1yE tkcP XC%c^…N=_{j\V H +Ia9 C`8wt{n(*9}^Z 23 s~0:(§v3unN UJf쬖3eΐ,C*6~8 ޾|b)N O/胍ks3͹B(>I]>VAEqi^{gKA\ A~NŰAu9ʤ +Dֽ~8*wfssMid bEDO}(%XN%4Offϯv:mN@ŕe6" 8j(}Q,SŇU{62smw>:sfԩ%;??s|H`  rl@;wB$?tK%rT5YB M#F_X R` }N|2M-= "&be5!/~#mas|G>$dR2Sf&A  !<:h3Z'HZ#]ZG(V[wvny+ ' BJ)2kx4ɲ w[VqjsrL{d&?r"K^oL;;( IYbQ  Lb)i4Jiojv8T_{Zu/yjeW_~w$N\+~i$.^l۟}Ϟ}RufN,]Q$d\O.OvF-T%Tqn s ~%r^:_,8GRIr ZCj@h_I?Z @{AdBmX.u}@]Z\YY,ɕ\CNe憙=?+/}s 1rG>|Rwq|嗄A'fM`/垾u.IbG bPko^?&o4$ŗ^V"B]nijSJ,$cS9¡y=JGM1@v۟|z0{wkR$ KN'n5+>7:RD%u$"vx28f2;y)-|~̄ůG>F""tT-;x6ωO{ 1!w|"oVq}\ ~?ȍ:M|$Lܳc{Rz1 ;!y{t!!L1Zm"VKWsBAdr4{=k<q3A(\ig70 p{d? Xٹ̳33 4M6n߮뫋'wwvom6Z{7Ξ?=w7[ٹG 3* kFөpw..\'?$p__<[o}/khnvhnonJД׶׷7nJ\:}R%iWXՉIZ+WM`rf0T@J@nvC<3|zȒ@6H^0 4^w0JQP^xl` gSJ@"'TcG'')5g,@yoFYJY-l]yu Ngvurchz$@d)A> ;"7# ?x.@ @py@AcJPlwYi5(6qδO'Kk{ )KJqiroGl2ƾraʙv(ŕ^\\@j$R6#ӊB8+(gk3` &"2 BϬRg>v&B7vc" A$g0H'oOvkvg?~E^om]'>jz;>ZH2xzoF{[؎pby(7[ٹVr?Q\;>zD="Ef|dq~(ً󥀤Fpx+<~Db9ægF (rg7oUwQI?[@=W"4N8+7־|M /`",hyOHXra!k$)!G`q.wѼ?#1eMA =q$2lMPa`)6d^%g g*x4IvɵstN|~tlgIdkL9ֈh󉷮joK$X)|?ifY.IjVGL_}q4It$Zz):h0EyHaEN](go\^ &}=v*T3 ,CD!RkPh- %ɾQ0|Ar u:'>ɻw֝Z N^NjE6!·cPw$ 7oFQ^z||!sv2x8}#7)w6AԽ޸ި]k+/,̀r 'A~XO'?ܛtRRtmIbn{~A=lfkǧOՂMfRl-?p}j7PA;X@ȅ6B@!Lzg=3<羟y|<X)$g6E̎YfKX0| Rhhժ,lgՎ^).Ŵw(ܹ&SKJ뽽$+'$QEfMH&0RZS5J*]oOvwt\T!KGAZ {ㅅtqq)gܾl@x'cdo R]|0.c1A As&4-ת ?CCY:n  y}gwz{Oz< qC43 M%7N7Aϝaj %%BCUPF:T`ccәͽpRc)ݬ^|V-Ͻ[\'iIw842 ]Yw67 *q9!_ՅZ&3.`% "3# ~D@ABJ8ϸ@a!D@g9g˥vW-)$CP8¡lˡ5uV^k3pW *{f "ւ*wO_8Ґ!@pQGG9C̃Q s~'%in|u9}C T9^(06^! *&Qs\ H)DUJF8aT/ Q%uY,-^wR f A.(+0IdhGgǔ< :#ohy<쭷>xt'[$4 +[ Ví6{ӨPN^7oΥ>m͕D=4+.Q)aGC! saOC$@+4% NGַke{|Z Isjtm4mu L#ٟ~6݉T[f5yM3h*5AMue2V.}41RWzRUQ%"pL$ !pMeQ%"I]|̞$ Z~_u!/ixPG9ad Q*EԠM*6\/AI'򐶗XO;|w ?߽qs9;@/:09RiUc(I찴K':P>|D'}vOvg砕?X^w޹A9.AħJcG':DZkܥ-@%dH~Ϋ=wnwyזo;ґ\~>$??y}sX ` Q|3ÿUK~ mb*, *HQI$~xxrWr5Ҭ97Dekc8ܽt8lzxzC]ttS؟4:h8T~49_?9+v)?3c[R7>M6H) uZ@<,RL:ZHw*:f*:O33"xu^=V7߯ZO|z6B^K ڰ(?*|Pg3 uӼ߃?;;[7o߾{ocog}} IM xTIv%tMAQHD]YpiiͿ?/~I[o޻GoP!M‡Q=A7ՂY!rbRK|wۯK m7߸sc5%t0ËZv;N{< fҨ#%tSC4nXӨB"QGFKg$4;\}Sdia!f"ҁQgFaJx Aʙ)IHr4H9}DխKe:qX+D"`o?ov7o>~rȈ=#P4CJ RW%,$fNRۇ%޹JfY`c֍  fH3u/flnhsK?v8|7?/ҴWrӓ<~{ki9T;P]|n} r7~nY_S^[{om]x6a".9t{Ox`S2չ#*B0UOsvFja4k1}==a+YT#"Aϸ109X(0ػ@g܈[:usi@A@l?7f٥ir?_^nA8'@A%{iO0"EQL  nnz=Ȟ=&m.[ʁ%af?&KV0{!?_vݝ[|뵍[kKI2ԭa Wd'?E^Zoܻ{j=%~&AYoQQ̸.Y"3yhkEbDe00D351sMk[HMq-TR pBaQ0:ϳ|`K'*mzHS"dtd"Ilo}w?͗oO~7S ]N2<Zz в 8oV{~ z{~[vC诮Eo<@䀰Wvp3™; *"g[AO'G߽<~OV; YC8:Ǫ!\<pXi7|8Y}>;O޽KSmv>a npBw ŭ'q URF*rqwbjH%C Ø5z| Te% <9a$TEJ yA T:cJttWsq%N,>տw/^mmw:o=~G{Mn&1PRJM^KV+ʧ|o>U} nu^gshBTn23|IV_坍[Վg%t5˜s#@dR'/+ۼsG5,.{) A0?ѨIPY/QXȧG-$00 xfN-'7 K1\jTRzNQ2P9|qF֩)Cz϶wZiW[ޣG؅NɓKRRHӹD.2*Hᒥ|OoϿeY?I(I{?|M>dt4)TKY8F~[7\{[/XX\Xj> dA(u nW;2\'S&mP3R nq`W@y!i@"@@Dhj>'bw9||_j3G$ i%A\SIFukc&y)p=,k%"%'aeq]2<|p~A|/errZMWsrnTndJOmv??n?ɏzK) 3HȴCdG;QD'85;x\ JLq㊈6Vv]^wtDH U;YUF:=#+b_zS:=Xs.{.47-@N17mhOE'- С }^IY6Lt1ǃ3FT&A⡥K- m[ %G ˌvxt4.Ie+&K[U+MZ놥 x2|>>7]?ȸTN Z 9;u~ϻ|Ρ9L([ucͮU߫jd,BF[zŮlolW8?LΉwɍd1&z+`H9WXf爈T‰3*1#m!C9= "ac b P1zb$|_||w~{ؼ$W$eva28QFWvw%IʈJy1;>t$TA{+D-N"Du.ǽK![?id npPlj8FQj6yqY䑰3aq&"r$B^[ڀc2͙yŌW1F\$QGU-! ABpq-,&)2)W &&rDLU(Di3f{u<gϞ}G;_^ܾ}~y u$LJ1WiBMjfOv I` OrD}W_|%7o.ni2Q!GY!68 䒦i'h9Շ>ޑ*Sxʑib"&f"ct-x$yȑРq=b̎L'Aj ETɧBzᇿ^__ ZH9a-";)`eu.E1 YpGo{Ϸ_Ņv+,o;Yu[N8\^Rqj9Hyy$VP"$y>YX4g! $J\tkaa\ \;Ic bl`,EQ!f2p1 2X &Qѹ4}*/Dz;R[Z(a͓v )| H8@ I<$MhOA%@n}Fseg[1'S} %fB.UB>:_eY=&I\D?> ܊ϯ*-HFNc6Ǜot:[X //,**ISJZb '>mvCpΓw:CnH NV@BL@Pj' p+;)2vNJ;Ӌg=_X%aLIܦ e-=e JFyUa "YG-nUޑwD SB18i:Υ-R@8'p\ a((t 4. 3%4IHEDp9{@sB b(G]9FL ~`\Dvp0X yzE;!J4$.rN 2&L3db$$ "Wsf*"(6/B*aT@Pah4=I4 ׳A4TGe14STN,TJP730+ҾuUU8 P0 ø^@=мuf):ζ5heo֚#  ϰLJ]JqD)4.$x9Z.Ras;gP]DDDA1_+EI@(ʶЮ=YŅ@#2*F%@`8:$()+ BEśq#hg\B(-xyϳ>j6%ƅ1C(>T")s*@e`$wh6|45^Q}s2 :^w(?u13 &[W(`g{i_ݟMțk:7=>!X_|9O^ !:\2\Mys34 $8OI>-vhvKT|s 㱊Uu7V{Ae!e!+ U*F q]"x񞡘T#fkP& 6W(@u0 㼝aa s 0 0 0p L۴XSus+{ը/8rnT\_뼞}00 b 0 00 0 Jjtʍs˗'&ά=4lY7x\]i^Gs{Z-ټ &gAj/ SO_Vٽb*-/ux$T"閞SѱcN{&36jY3\5y'jIL:qn7ׯS&%Q9}{z44Ѱ ia bU5&>5nM$>ٌW,53mڴ4-ȸ q=r\_#|KSW D0uQL<@3ٴ GgkT2,Y(JA60 0 0aa9aqiXvaaaaaaaaVG|TB3sI g3SYji%4-i ۹q7e'NPFu̖"O_FE=3~fԏ>@1#;MgLE:z{3h3oڮtYgim~:Bt$Zg9>/4۝{\50 8LPJ#t9bںyÌ_gŨoU5SU~9U>,?9baaa`aa9aq*0 00 0 0 s 0 0 00 TyVsOuFfeϫ΍Ш=9޿art cjk9^-pٵI&Byga]蜯otݷ6Q’F+53BJ' /20iWF`Kŵp멓5v, =r5g&00 0 0aa9a=0 00 0 0 0 0 00i3o0ojg5\ۧa絾0 fUݿJ"I|y}勒 m*kfN}9hoX9h4_aƩ&zqƌa̩`aa s 0 0 8W,~0 0 0 0 00 0 0 :aq 8%,*)$0f߿_בs83Q;`̃rMNs`,.>bAjo$$Wv baXͼαaƥ{ti#i.oԸ5*i'Ѥ0 0 øN`aa|hmg-_k<$da gs+u/fހSG4aa\4fk=ϯ}Ozj`aaU0 0 00daa`aa9aaa0 JJКvhиW/hk4Y AF/9}0 Ճ6c(P10U`o\MRUsIgpZO3*ZS><7L]Oj=fGg5ԌU֫ϱ^ntKٞ*_zKkK>OP0 0 0 aaurTs,Əas8fa`i& 0 0 *8haawx|Y@ 2T}|M}\β|POzVy^7,O5_q}f ?/yߧ]\g4>:f]}6Ƌ4]ÚdsiʁS ҋfN:fcSQܪF+[tjwVkDhٲi\gP\KwsϤJzILsކQkRE傋6{˓}\~/c+S}lոWs+`6s'c:OJSTq ْ[)lhu;;jEsd2L_=aFQb1i4_9/0 le@sCǶ,q`wR*ص_HbkcK;(|7*s9ILz$ț= @7_>ƴIո1hs:a4NnxFe+T( E$&{Su⛍]1pIʙKScgنeu:SgN>>wG*U!:]kVe2u9L_= 2da~Wc =Ҥ]{Wڳa  1f<7`pȸnj$Xd5tgt6ung#JA'j50;C&e'NW u܀pc\jjCG֯W|C8sX^A9AK'; 0 ?،_IENDB`BrianPugh-cyclopts-921b1fa/assets/logo_512w.png000066400000000000000000001336611517576204000214050ustar00rootroot00000000000000PNG  IHDRt4gAMA a cHRMz&u0`:pQ< pHYs  iTXtXML:com.adobe.xmp 72 72 1 1751 1 447 Q2IDATx]|Sؘ 0dclcƆ 6]-B]K6uw;ŭyަO~/4MsCDxa܆Pjsp3x0^z׈~*+OY?k~ʯ ]c 7hkF MB,ZMhxMCы~>>*rz7^; 䇛\X//z!I#mwϤG+|e~fWp9HOG7Oj ȓȯ )~k+й!hn0(L %^/ϩGK\{ % (0``8_5O\9O. x"I\?R9 =< ."KD V7ižnC)iXOR t~Dz3Jχy"R0p;y^_8`*kxx!LOypo_>hL=qÆ{A){B<1xכ  O攭<2zR(@+{x i_`=TğYxx*`8SjϑC nh +/^hxxE>W2L^>Yu^BEkEqPO9Cf^ޠ`S)L|Ex׿z #~)[#b9utxDxij{ܺhwz-xx'&o ދ3ɩ OKD/p(S+[QGsԴY {.io /b =/֐w7n2>I y78W,lS %^Km EDɦpN? F -+b$'qjzH ݊=Nџ^7Y=wU L?>b/7yǯj^<XN}m 4=L?HnSq8-CCkذp=pb/ĞI1e>-I|N"3({/ӊ:1ߏ}.{`#/zC u gxֳBQn}`PR1Tp3JR<#516(<@*"Hw;w cKsrݠJgnG8W,j&j+]eJlC/i7-_}^]1iJys<)\L󊩺4K5=֜URt-Ӟ 0$@D|mOI+6'ܳ^PU%`wUtXZ6$ |raH69]ok,/IM ںk(j^`$/}!^6=PbےGFpz ϞP1xכ`-OrU5Y,NU5Q[O4uzWW󸲸!'>5C7e8*ޭDVAJ}yʬLo'w7s*;ʹntY՚%14T`*ZS*jh/9t|0AB !H}vr2˽uT?q:n/ v߻u( 1&1W[VYiK- WoȢj}၁Wxx}OYZPv ˸ŏ3Zs Uk=簊:J!9ï 2S Lw Cnܵc-|\** 6?W֒(ON^+f^VqՉD5`g.^ҪSNx?>\E$FqL;;'NWLtnu݈WVFo̧W13ӃbKrXYu즪ڶGu?q!3"@=AֱʊBâ]/]c ١""0!nrqz/_6gLE#ABzGP&,~Xr޸v@6Q9lne .i"j 2 t}:ǥi9J*˹5%yʊ :w P-KL*UU^{&5!%BGOJͿS*~m%^H"+H#QDR !Iz݂sWΧ?llC<etA2N#'wDݯ o*dTgTDG&D&1Rrع%E;-7޽1%p;?l-̊ rrwLt2tPpECɲ0Cp떂@>0 ^ "a+F8Zd{6 ?"?> /wkZ=C|(MH 20sIBVQ,:HkPK~yESB)[Q]*.$Z'd*;S_QV$N*_ɫ`s >~U ])gs+yi#j'˞?+iaAn/"9EC5-KZՍ;-^ѐR M ,Lq-UF,aѝMsA$8D{^b獘%! v8.$/207L1"3 80)=7Yna,eamcju~HK^<"|(Mb)3}xM&ƄAwʼox6TW5*7;jI6cVy'.a3ox7[[hh.pmuJ>tzAxB$q7BRiKFV]%Aa#('jfRfY~٩EJx `PPũgw9 auJ&ÎOV&qLj^cֆQ@d&! 笤{#㚪R]yiQcKjW]Uc }oUA  Y/f#:Uz"(Kws5K0aixj*gۦzZyZ{[eX9ihj]rn+p}5@%B>w{;ӿoYam%*1ޣ!(CЅ:*ZϿQY 7~ԁ㜔T[ҥUNHv117 01 45d[GXZDZZ3}k:+Y(Ș]l$f䢕H(Jv+ A.IJ@s2/0 qTMHBmb~;+Yj9KXOtq:M F>fvW}JxF~2Lo 8wt=EhE"\'6/.SGm 7 *7X ֳJebfȏAb$y?$4F'ΙQRE!z ws[ kUݬJ߇/7r;|/«wSY !.]%)M9^[%ϻ\Ld&HN**iM箝~;3dmvC1eYKo%#qR%5wp3H pS#WeurguIKK٨˹+]"-xwbc| 9Av:~emk㠤ln&'.%'?jJg[ӧl/Lj>;꣏>xy `pxs'O}طE}T;ͭa< s^ RmeVp<< O}ImD1"ۿΚTUai:$D`@Rv+(0Z34,FA|;]jnee'x„^\d~?eî"C"*y\,=}-‘"#jv**I/H/_}`G6}zlCkFW3hOh_k7K=arM[W'Nmf  #IhSCL:^Zv: ٽ<܊"rB}*4l-Ѐ/ `AE cZnuګSwEwuĻ|{?D?{L `ƈw€|7)nF]HΘ #r,u` zC 6 J8UqѪDjD")aD^D[PHN?Y nW"̷݅5Gy11I-ۼPy \,igeGh^r LIAd8`u]//.Jrs/}r'HL6ώmmSo~VL۵q_&}̌9i_Ic߱jEcXGGy5+ʎrʅI`BʹXC58/V隟@$WDg o 5g)0za/qFؓ1˱#{ڝ>Q_9iOx|b c?oM|$U P@l/B4틱$|ʗ >D~q]S[Flwk!Gmt`# &1ΐ5*_ ?BךWtWf > 8A@EWy%,ɯ+k64߽ci-וizq^=Vz?>a'j#&9EgWL85^;&_;:ȶ N`7?BœIs/߯rr`SѝBtUX!%1 UCK R=.!.ef=*OU*n B;ukmk8wEWN!Hz}MŸǝɪ%$'(JL;6b7:y$6;y{SKScuxG7;vtʑ N]_B;JM5ME R@!az~:jVY~x9h(xA}* ZNڗ  YRzLNk25qı Ñ^! Vqu@gm@r`f ԁ|@HDND~( ʰ|?t=el?(O@ՋʓvܳU%9UZ/w5bAe*\h_R_ëhA*ij/e=.Uqؕ$O );ە+"ѓY(Y\uّ˥ۙ9hC:ue]ᥱǶOSd/̥?Z|͟^JeM1wވ?FH}< ?acf a:yBGn\la_j륩6B*t/+OMi t_kjhx1#? ~_P{,_T% pe"(⧣v,G/+4 NE 𛅱8$6b~j_N|̯?L>6Wj@QǍ%e\3醗G@7.C"nVe<./WTP;X)yu,a#Jj95\ED2XUd" t CNO֎樂/x{O7.ϣ` ҇'_GJl}rMcw-dײ4hNF{:Sio 7)E).>B K]omeE" 9l{Y:@e`P"rdC/ P}AHWHQ";\җ)pECP0X`p7Aی),A&j- &.ucዜ:5W PAgqqy vV@#ȄH/qYf$1sKLfd峺XBs k-UC‡+S]| Yj欝6]/Ǽ6וh?O/@z;ߝ~`ٟ0z/nXkhrt5g4f:bʜwhogۛkB 1-Ԓi =5 @MG콵Ԝ/a@znCa5ZQ 'CeVizҝi,W @ wcxto%)^V2b  ],JxrEP @T>m2eM%ɭjס jH'9|yk ḧ́ -[aUxFjrWG%Hs1^QnfϘDeɇiuQUԿ EшD{b~T 3) {Y\ }DD[_wewYkwF]`Q]ё~߭h#ςEe~|+sI U^ =* eRhAXeeD8W]ei~᳾qb}f$5OlO7+9wrEϢ})a_އhoO))0w_n_MvnnYFPHwv0MC Y@t1P4кsDՈ8MBR[ ?x 4I̼(=V,?$vySժ rp9{eΑ.%ֶR@gIt/c bk꿺+AD}bwj Dm5ATe(`Af4-9/ 7ZIw_%{I2Ԓ{x×'&+s M297Ҟ{hæ0mt2o5=]A:(Hn!h{h(S} 8*!U7؈!D8VLü~LDŽ돪"_fo Q!1?‹S ~_ToG4ck  ^*סփւggiH]*鯫bǖxpˮ.ظ^7P L(UTg < `sڈs#S+ 6 Lё':cU7.t.t'>Zb$C3ϏZbڱ3l͚:nO.>{o4?N`- eA񪯮h ujݵt*Wl_7\<&ٟ%7&B6K bDQ @Y|ysWhҋ b f]Qfo*dE9}`@wE?$ۼ/-~k8I۝9ꇃ(=jankEBY NY+ђ Ci:yQǷ|&SMwxݒvfׄ[FX=Vjg6O1{'ǂ:v,̡f0iRڏj:$!h@Hex!))bPD0Ęn߯ -^b'tnDڣb_kYj~IGJ[ڲ8_hʶ)5톣,})+"5&nt 3"Z=n8/!rD^ WoY=SG6IG6M|`_6~s`d%v|{=;hΠ5L}A{w*#@#3?_zj>p­o "|^",NkF84j 6N^>k3@ : F.4Eƙ&,7ehG,˯\]M=8_A`80D7.R>wcTX%jzmSawQm~~m|mW YMQSC.ҐIyhc혰w';K<ƯOn1F6jưqhcfFτ0O(o Jfq}J1R@ ZkPvPꩥ/xa<\$1l/xR>0\ԚCΦ 51U8Qrr2~?eUw/C!pp5=M̈[l+K-O[&I[}ǿoYu F]@DshT75 4Paev =sAe>ڶj g=}܉^9//xpĝ'_;Qq?5^Sif g=k4 7P [+dnSp @44 .'-v_`h TշQz?pkۣW$קgx! <0P]ݬVh2 hs% H_:Hua7B7lM[yvcyϔ'xMUq]NiqAQ̘[ښ)=$@8biP05DžϏ׹ɺ1vgل G>{䯿ޙ oӆMfؤo5lѓ?U `n"PT5@aj$*]M]HoP򧏪bm¿#k𝆋H m R gO#0q_Q8SݻAuh5+@\uoft7s$ZztuD\߷yJ>94:2.x :AUJM h5}=DU]h 9Ѥmhl2vXWZOjf2%(3xAGl_^~U'l]pnw޺fo+uvNg|دMei'FqcJ)j!(RiHNQSaCO @zp@Xc>s;>7JQ 8ŚCe.a b|5@lX{_[akf953NGA-كzG/CcMjx>S8 C3`MC ]-4N HPazf3ǻBS XH|xqz#<j\T(r  4KAK_f3l5R@I 9sՅ)f@c:G@T$K'qώs$  x&:0n6&A5i0o]BB9bޭ!qUI~݋<؝2kX+S ht'UI\pY9mAh_97h JFx1ݙ񓿘5G]{F[=qN:e񗷓m02lg+ٳy>1l71A1=6j樉ܴ\`) C=q5 ȽM&m}AJ 7ޯ|r3%Lb/0G 2>쮝9Y}g'm сޚ[Æ}:c@ҽNa20r{E7-Ep)RuR~XqoP kltBLnڼ+{ql b Ϝ  ,3A~' sQ{߆ $hkXm&m~v$ n|} 70^mkS9a㧡==e}&SQVt ("jeoA)V XXQ3M{aM#߄G'l+O̱ЏD{ '@XUBː2 )[,W5TXu=ÏJrkps]Et·FWW9ܳ_w=~=./jyzS@Py BoؾC7]Yꐘz\g}ca#-?H;T+]/r>i7:y/.ێS.c=zќ<(إ` h2|& .NFrb~qhXHi1;lzS'$D_^;Q{׎ܼr-c~FY1j/iYNܽmUd~c4EiAfa&0Kk#M B18@) `ҹ*6s%WBU3)avǍBzAHH/W^ 0lq9[U3O ,z?3a:m ?] `;¡xaE;=}M_Z2B -^q, /"G+[0d-ƿpq'8L~yPEB&l~^v}8l9V;w05J|ߖŨ60k?|Wt$P|s{?ՎTf1`X#ZQqR/ ~5) C:] F4W̝G/4_3$++ Tz!i0T@w q$ξxCumO47mɉ 2$?9@92 Ӡ"* X|vQS~X$h(3L8 F^5qφWO38$@߼-Z֬-_:aۆ9SlbZڌ߂M4 SLL%;,N\$GK$0y:d&E~_{p+Tcֵ@^b(Naہ2O(¿y.wk*`()R| i71|4M!y'# XG [ !n !`^qXC}49n$. 1Շ_YFQ2 <;4Rvx?j0Ajv5 .xf CQOE~|da`0.T,"W`3gpUa]M鰞C' La|T0!@@֍do޾Y^ʱv 2i M5PkO|cжܣF$hF0dbU)P,Tܯ6#chw&xчV >X@Ph:mT6e)}@a:jE)Ɂa:! R>W1@@}߃\4Х()$ *co '&'| c 9^4V̓tٓMXGvjvьq s24MQR6 AOÀWhwP>a*$m;sxʚÑ&3!h2ޣ_x5O1~@ŧa0bH~J9:NZ<~wf(4x0c|$%Y W~~*(hA6/8݇@?Ԟ<33 | <}8{9-H}vTލ*~%vT<6WɆͩ*yT,)>p\3Տ4:ɖc,1MpB*TQ$g,5l&jl;̤E>8$hu<+(_R R@.PQ^+}ZE330sȮe];%FFbh*] J`PF%_x@X (My.pPPXǞ;0a罛 1!la>=j>e?!41P&I~=9P gJA'GX(SRK(x *|(*xPӪLsz~mrojk]2O}.R:YO's "ZJ TO9 >+܈:Mc(̻(!Cj WP\K)KsWrz| Kř h}^4"XkB (m+mvZ;˫F}Ea, z>n.dn@AFM⪬ ߟ;S'Ə>n]UlE@aN}[b23jU`̨ʂ+2Z, 1Srq%LӐ@=];jՒ,iN3 hN$6#7>oi58{#-vDy@=L= 093 zW[Y,8-7qV雇L?fx{vpl}p jL1TT/dD}a=Ԣ&eB%9Xw?sx* @>d1Y1vRB۞G0"<njv]f?ɵ}q(gP?R\-|4TLZ XYtLT2Cɀ)29cܡyìze/ucoWRHQ\<&L*y @6TKԓz21&n9%OWl!m '|BP Θ(&=a'l0Gq|]Djv⽆Ȍr7 IA:4ƽ6%yh +jΌ,tO\E܎ $GNiScO,6O/kaKD r#vt7Y-qS~<  /fe?[xy'iǝwdn_-m/*^,GT\2 PpO )PH[}b0H[-37z;iwuƢ?W,rŲ/vh܉[֎[ݯ^I?N({֮(NqH7 1*D޽pT1af>%Ru ?t |]&.#SFcvIKEQyadT671̐嚵bpI2&< QEI. h.d$t%.#] V7 $cKf.*bGcY!#9DWÖ,`O#m vn6;r/_c֡cfM+O/rP)NuH @@uE<r٠?A\s@4lH;ck_\;c!T\,!s>Sfb%p!+}trr281+ jvۘ  HSy@{{G6eFNYe{6/_DݹɃ夑z/sc.;yXb 3n-$R"ƌ% wY>HPπσ]wVw6a!ʮ,;3}3[jklaJq\J4`x~V8kD9cf^4mo S͢ d nq]>ߦlH@앯꫇Y]{uFWMd@돎Kg u jv7'wpjHR91H(crgDĕT}ƛLJ9OڌiB]?bh%C/R'5T8J.aӓ2lCa=Q+0`D-E@ hd [xk*N3\1[/V߶b`dnyx9PLI@GX\c*Vv4s!90 "v d)Ka^%eYIh%2h/ؾb8s=]MG2XAZOIhW} LP6@ks'i]:$ekyNcqqEvSEϝT"'nՄ5rJ3K)АaX@P PSJ/zo |\46|B0-!ă(:qz]3EeԒ:Y^2QS%^zvP!Qvc0( in(BO=9_ǤOQ1žؗFnYWP}p 3sZPJ'Gn88b/l,l,h Q|*IK~Mn[E5= buBG2>R" WXklMijKQN(b*a̘ѬZ>"BBwf<*N܍nÈst4h҇WfqQB h^؂?vFc: b DR'-"&=*6FE%ʔC F[.뼑<'x90(%ngcLOt) S}Lp2A6A0dLAp҈[~'%& 4/.R@j~6$'*YNo7?$DBϽyV 'E| J(M 9QQ;SBImx%P,ӗ|Qk"4҅s^$8 Q]EMok!^xxge?R$@)ؓ˓Ƶ 0DxHƋgq)ח~Vfu4[κjQ^^/)qNi<+% D\RoGnePAkrI_~[g mSr&Y‰/ ;qwٞ9v^Qަ)tw4At9)i$D^]%[d ){ sFݷ/= ]ѼX]GAb@U_":N04#U E~|=- Y$jU43s// bvIDMvp3`>ШNE$^0]JFD9yu.ƀ/9CY0$,Υ}BA2|%(b@=9LeUuDBb""l- vV5UlKWC iID\,`ƗFi1c޺K<~|.zʸna Q]UՐq"Ms uW usuS{e;Nm-w7XRߎDG2B+s+v*+iom`TZi%q8.EzAFڮ*rebnpNcSˏ^޿AOnJ!nE0'`kjW7 5zwF়c& $GmL7< # HyۺC ߜ`pA8 W e,'Y-fbPx\a=ML󯴁тzq3>>&$/`:}E68L/Q,At|G-6NWWNtyeW OY2 +Fnd!OP}.>n6UX@%* H44q a6MLky8]Fŕ*>^yҮG%NW;~¾Z7k۶[o- 젮DG* S:nD5'[k*h(HE5f*溒vJ#w\L 7[q2v:W3BL a4x , Q ŰS]Qlchb$}R("}x5^W&lc˚)ӏ2HLB󃫩%|\M/=3EF$^9VQ=ʌGBDfQ .'84`Qg:3J`M/[Y{Ɯ>B!φ& fAt"VB1 Tu q j;*yRxn@6<6'JGDKKﺞY@zK5Y`+EnҜ"ߜ;v)/r6pavnQ}*7NjWc=]㝬|tN˞Y}r7~f^ f^8T 87ÉXӼ@';e,G|.԰59ZY%Z榻_9t:͉h61K |]*Jzӄ>ukџ}<(yG%_,1[5T0үLȑ)n/$0rիB l0p:lOnU_܊u~(8 T T w{=Qx \=q ~t(>)iN#fL}2[rZzfw-7prJ _B(ٵ7 j|Wr*`uVqaHdUB5vUdSn90G 7 ZJ!5H5 HPɯJiμ]R1<ʎ*B@Oߘh+C E+{"4Ms11usא6U<.~\AQ\ߺh{]7{#Wc+%upLfQ-V-J칝;ަ]K[P>#Gjˢ'6osRVql- %δҰHosW]),Ɛ"l Zbkh7 l { xlC("c"zc> ',I נ}#A1jKm ak4= 5h/3ypP8ՎmD_|@g)10)7Ѧ@ceex 4h gDYsu6\]K+d>(̿Wؔ`B+rU, 隴ּaZod ؤh3 ֿ^aVr:Klh7[ۊ$K\tr]8XFA7ٺ`]nZ*zDn3׺rYa'fkyvyU!NuP'۟_Lj3[pvW/-X\V ڢ 6'ۖb)'-91g7ZSw:*$7]) oN7;-@ARHHl@ Hڀ!JOgAh]Ä[B(p{ iDu 1*~E;*P9*^eۥ^S5P 6–';&mX%Z ^0I{XHE,uGMAYHT*5 Jt=< 禑ӛ[è~OL#^sv9'zؤ}̨: ^|MRMwzT5Q" V~i{)m.|T\YRE~*@ n\IDKEKYCOhͲ FK+l3?+4p/ .+Z{XyEdTY=8T1qSc]zZ'g]:|Bj6գ핎G*[ftCęYwqWH+h$!eTSHɍ)t+ Ku7۽p-EUc`N?ݛ"VQ~@9 5Ə`*`,E<[)!T܍(@7u$L^;-I:po@דNkn+Ql쀘wU8Xd [%°T_D|3}2>}@\Hi^F? 2A YIŤ,7.pA5Ɉ5>iݮٲzxdɟksٝ%yT̜PW- aὬX`ǿ\tyiM6i_$Kj錨uJ)dGUn{Ɂ [\uUԡ#glm wyA[mmk=G8^_ne[[ ||.^߿о8oOFQ.my%zz )LO:ढࡡnfo~Y+~_~a>OUTh;tGZޙ>!j-4Y ^(2F9b6d0PK@cL A}l("{m+& ڦ`X@5p,Ҁw_S (H39O|?+v+!bP?U0鞚''V4A͏\pPQ䎵]_ O+C&-j]yS]p9[9Mqʃa%%af&@#3H͵297u=] K+z_N)HC ?P]`?q pK:ء{+fgOx &]bG#}'55;'N^稫I?{'KM f8?Q/޿YZ|Q4,7r^R! J7t1T1P:vdeR;xjg:פo!Oώ *.;xض{Ugvm C֟3Poۥ7~}@}P2/oNbDIpIzf}Os?&P4Dn=U z)QǞX'@U|ĐiF|] ;LR;Chr+Hpc^B38tP'^8j$&_  n@3$ X@Ϝ۶uZ璹k+!BېeEif`\X^X2~Y#WeE@ \Iώ)(j/4f;GE[FE-rfkV6 z,9zYd$NhH0Qp*R.8|93gTK)kkkY'Ghi)"5 goj5+=dd`<})쁓Zg%:~o=䫡ژI:7=^R̊p+[^:tlüFR׼uO}3m;g'`I79teb]of3V$6wa!fk^4?e7{4=NYЫbDr~wJ>|V8ڰ4rJH=י!# ĭ!bdAW;n2L(3R9n|au^cF/݁8KqK(qM@+kRRάȏk(6v5 OlfEU杸m o(-S]cquϿXdl6FHSgĚ:WϞ2TU=vm dOtV8AիgԒiʄP.4FSSSƑU%. 螬t+dz0,-ܮ)\,wߏ"wޏgf2EtV{ICUR;~nlllv ?ہ a3a+J&^?;$Q9'<(A5J Ieb(c)7)D6?#) |lޤ X80 7\; s,5 _?nJ85+2Jp n`?ߕTVRgw98?A'>(eob}#K~Y>m2^ځKV@rf>QQ/uҤ }t(ۇ7|Jmmto ]y课:hݱ/MxW2=jJYͿmvpe$DFvLу78_8We?tac ݹnWVX6:[qlfjQx詆J^L)y)hSX/s/F̣ζ~{,$A(((#Q *Ow4nኋOz;Xyd&Xx\#[['w&Ab[8ܠӖ%$yB  './#-wߚE`E`/\ ,\*w,1*N壋7Kd>Js;:+Xi|]GWR;yJW沕S:ߺMS^ZDsSg?--JwuB%Ho=(H@}q n!Hu&61LFYWYM[NQ;:A&&8[W OJQ7h񫝱O}|BF = ^pۉC}[`([ $o[YZ 4GEh$Ikpr?n cyaOW3&倶SW&")/H3l`p1)Yi,"nZR@{ wB#]1~A%鉷[*:;Az2 m!g#kQd9B\}ziDEL~@YUMݵ RtǃfƿqDIO?ئpϪm:lU8x증J$U^3kMY9%KAnj]OfyUA~qtXi]`-+:gvK Ϣ;U)>G!%*7!0'^\ZgC7>k_P[$8٤zl rD 9c_5*ba+.9 G0 pt?Q~=Ώ r~c <7 Pi<(v1C l mtp`3,, =n`@yz.)8zLJDۛjb0 |`nI5R@R1d % cl xPlfHy^ݶZNG۝(jfAA) ͅy)-ٷ ,_=u2eoV}A Y78-K֊3]<4Ux2mZQv9cׯZ_J#kMBg>;}#:h SӒ;;&$[{t2mC7a>LAfd8l1ߊ)J d%"\ OIJcA fKm+InkԞ9}Ja WY"^N5kwn%E{ZZ:v<{]L\ #+*2!mwş`+ k 0K5WA#kg][uکmwZY ^VK?-5Hw?P@n XgRg^pq)-zL0ᙊ'S@A2t=k`&A v'+Ȇ(,}y& -Qb6E@So4jLi<6 "ľ06# t`W}LC9LeJF9U/۳qp3^ 0 "g"J:anLphLd{e'V0{)W/|95  6+_+]׹/F, N"./#!M_I +;ݝݽJ?}?~' bӾZ:{kqAa鱑M-!:>ڊ[{lᚼP3-P:JKi/ThSo` #nVFFx`,=fNuUXRS!)/8{">>) FCV(`@q#3 LP1;L1#uYp.nJH1_ɜ_?B!:$ o>q*xjOC`I1(5za`pnZd3;XL3T-!G <=6d[GMĶw(O׾Ñ@"g[UIUw[Q!)vuRМm-,8$ǪH*e]uSScO^KLAt #@%/F:J`8yx/gn?o[9RR,.a:ۆi+=lAiKJʛ%Ni[mTA@&@]M楃Hث*]޵hEliG>MOu5;fyU-: iɴ0,/L?xg;sLVj"ɌbBZ 'ED Qy!玮RءY`ϛDRPe20Կ~Tźn p{:B FANց-/ $@͓z>! z2-Wζn&HveY4&:F;ni];>zPڞ!͉N6Zk`eDwOVWYe&(^lPO`s& Xcf}飿hSs/?z1sV 43ܭr %LxHς< [/niL?5ھlzp ?$W8y-y#Aukt5 @L<>@ 'mxEx ȷEro#:낡-cu6iCeed(CIc^^{rv\vD:v->ѯ܊eYH]'eayV? ='KC3Ioc '9vF;&T̰y-:hk ŗ-Qs+b'TupzKf|C2#@2}0}޹]1dPg )ֿ|?90oq* \t g5-V^qP &jC>g GC7`'\Wj˯;?abFZ+iʹw0J̏.fWܽyǼvnW'!N}]gA#fTY=HףfNDk _8L[D9\$qQKO]RIR rz# >0Nw3q0U:uj+/ʄ[$ۥX2(s &>l`H&`/[pC̫ QG-G'!N*C}-1> 2MU2+~G([l^Z f$` 5`,t#:D<c* }1q09^P 6O`ŹZ@0cb[mĀma'qm pGBBQYf<;[ >2m8-qX#|xI5r,WNo\)HM9D=GF"4yf[ 'e`yfʅ`veh݃'I_ )tCG)|Dh Cְ{,_&w-̂' SfӶ_&nA[3[wpڌI3|2mҰL5+`3HLOX;;WLe3YyWK^֗楯tLEu$<$ @ ,$^ P;n?oز@9\Ȁ`ńhny*_u/Q.?g m&!{v[[V5d+"l+Nʡv^W$9A1FEV #*K=S,B<V>.66ƶzG L߿&LMYfO ѽv!!GWYcY]h魯jc椡na`%/imi9QsNAܐ[@H>] th4P>F6p"mFar~;sR:|~?" ~)vxVrR毄לi[#"iۯk|xxz8P;C,,oȺtX3'cBl;>cH[)xJpBq Ncn&*OSS !DLrLsvH25?VeZd6SG;,1~Ê_сz8J&Xa\|g7 ZR޹*ʾ19P)EI.ؾpu1Ur6fF`0ǮuJ⨤5sP8VO6_L6q#I8ZfMs*|72MX+89D8[W!|۾D:l}@ǶIV=`vq @bpuM$#1e<WRd^&p6|Cpv;~#7>;EGۋ |@`r=mT(i llp`;ak!ƂMPa(* C SV l,x{2\zBPWPe*o&_ ]Au w໸@j]pͰm(,T2^Qƭw{ϥ\ Dph=s#jCcRCsg%䝞Ajs20J ϢdqEvYށZqx-/G 4rPp} 7U;y5RwTFڨh)+gxk럖ו5t> &j>zY6۳l3}D~szim#%w{PW]dW0L Hߌ ҩEDE qLp_xs's> ٱ~("Pe v4ra&:Qtg!"ҍ4W!,-kwCG˛/)1H*#N\~g(=b*X^\`5Ԟݡ:6t>ԇ F%yTj=@ /9:.nҝShx$jfAI#ۤYm8m6W/PdF{e߽7EQʀ$2(/Q$;:WRYɩ*.gf*x\MTצ޽t;] ^wfj!mKZfhxۅ(;%[DFPɭ^ (8"B[ܨIKڸlA5[yAS؍|7]h;`@}E2d}4eIz~q)v>aTQRIA@QN:]8 ^ PnL>p cz Vx(Ik_ǯoa?[Ee4?>հ~k #2 `ABK EPow`>kHS)*I톳 ~Ͻ=}&p-o2D ݋P~֮] a0*'cR^p ۘH0r;onԮш4rNĄC<4WZϞ3oFmuςLM9*'vׅ,L7\.y7p. `.}X*&sVC{Ϟ8%QjT3.)5Ydg,0̏fwcj|JU^QU3ViwYQEX3߻hu)U58a|Zzr'jSS49~73lRT-LfJ K=u&Y '/r )a.+"σSmDFN @6&0벝yŚ(\#8_?}7e?ܰ;]l Imk2Ęi\ 52@lD^M<> 0d53Cr;MfGQUI.bj9 !/ 4!bPu xX 9w;,r_MU@?/3x6 B+'­5;+  b⓻{Ao&r*0t8M_(1I2~y58)ڃ+0G=? 6vBG }$`܏(eYi\~PAvcvͭ Ymx 2⚎>uDe2|<#fqXIxid138"<&/,7^qTxO~a/p+ PPC<[^EVywyQEAx'4$,8^5sb(cy@EWDMm&*Wx#)&%Da 3+33Pwu`0} nhmoU{FA"@N+azX^Á]paʗqܸAZܮnms3{WkYoC3I";}v*s@(lArQ稇j~ :y'50WQKJNzߍo4+pbB|%,Hp+\6r 6c;[ȽWf[`VS!#|L °̶҇NWrTz;KKf$F vAlm>|AYE*W&Q%Q%G.k/Z׀k[fUlY*&-?/QRVZZR\Q`oS`]l[fWjWf]lQZT_ygԬ|T-4l=-d.IQ5;QbBx`oŴevr9q[]Qbep_ b-eL)&!PcOt'y|7N]<}ޫ&R5_l^B_8À-a` |ǿ ' vINVy'ZJ״"BR8oʦk*^)=ST,Bk=o羫K?mvM3g=:F\]%q'O׃m9{@(`en q2sykjҾ0@g UB5Q72ݻ؁qFrIn>(P3鷅!wr0S>-m c0s =2 gЭ#A+}d/NBb"f3sV0X@~!#@92x?Ht5ndKlM۲CV;8""484m]a]iSe]a@ϻj*6b"8vtVVdZ;*\=qtnp.x1mQ_\.3ÖrGPs… C7[hߦ_Lv!kZ zNZt=0p#pXs'#ٓ5ivX~Q 5SӺv9@O:RT[/x~ronS :9k?n9sC2P$V?יD71|2c1] <. @{Bp7ƞcZhўhGdJWiHn2⍒+NP`ȸfs6N.3:p*ګ*٘V'( jod Dw4c, xPxĦzEXXkW'ú^L pM1V^*F f }(#8sSx x+sچri:P?( 1WђU;4魙0E[:"w@a 0 psԒ:9+fV;`l1dlTk|/֖ 9/+;j)/Zx=3.+lq)D+Yԇ(i,(Db얕ڲ)|Ap2W󣇆v[QGkұVRQDw"ѕ&Tu'<-EIKSG-,?ȹ<#7Еnf{QjǢeW2vW1pRҴ0MuHE5̽Ԍj*NuJ(*8zZr; uenvy||cD7Lߡa20 tJra )p77Jdߊ05e'Y _m ?BcJ:a GOkxM&n@Nu" Z٨0@x=NqfTu$=Ҝ+o|젼#K>ng.{3\FƗdL/EU湵TVחzeXY+[)\qQV5 p. w 17V?{a_*u3eoCY-26`,ܔ ̒tLL֤8;YڧO]~\O?+9K"ҏ_Ye$6HBSlTyڏ7bl#ke0Jd:܊3ea? s=8e`,~ t؊ߊP{q|/"Jp$?S+,ؾw5 l7^637~'otꨢʑVjGW^guQWKv/{1=|teԮ9;{h:X" B\L]ܴ׎jvgڴ';:Z&GGEG';AM8@<>.[D\mS.ׅ%i_<`Y4-˴QVqپN^fi4O  m렭qٴw^j`F3/p1jO!rnG[V`/ :|ONW D[8+ن_Xuk?k>T t~m0}R`r?Nq b܋"2anBsV՞ = i?ugi#@gqknGz¸ɿu*N{kxz2 Yncl$}]Kq$ׯ`NTxcmZBs\xp- /U`X+[Gxls@Иn u2zau}EMY84]M@d{O!f R*;.IGuDzh/m=i*iu0H"a<$|YT߈A§ЋK, {\)WRM@ {R Dd5t?Xd`y vߕE&1QF!FwlOU ]ܷ+PD ;aٛXiu??josUS Azn:gO_ ?C~y+]>`.}2"4զ>̩-nK{*`+Qv7)4,&>)?$\|Lդ<*3Kbڪ)GYe{V2S\+A>Pʿ`3} 06Sr IU'%m,[ӻ1Puz`Wlnt! =q on{ Ӗer7مSOnz>~4Sʿ0~/1W- e; ?dAL˃"MsI[EDd SlZd[;?g|7|Dhg~8_??oԟ'~6 =sѴhGxw_njkcgna4tHsJwIai?Lf9v;ߌqpLs2E``ml)nr13@ȗSL)GnHdwmP 6V?)@.Y$~;h7>ug59a'kaler+ L$ Pn@"$d9-vl_6|Ȱ5be? FF| jx>{YsR L]d8nlт5~d׺@30(,_.uC+ Qc}Y}Qk$n6̏9sZĚ\pdݱs;/^~Erߵh۾Ż6f?M363ڧ~]짣4kKc~AiUEݴ3CD+7ץ=͙`O3n⢨w~H=nvV?=( 9h`*iF~,?,KIy|!#IUgEK7U+6/~eMI3î;݅9l'VC -?5xrD/0@ ݞA҂Q$oKf8p$`hуu|`zu`0d8ѥ߼a"~my9`&941- =sۥƃ2Ψ~TV㨼!Y=g7Z%3.jGAKWW>|S9té[٩xH纡aUmmhv8E<1Afcl9| HZIW&Q+,af`aUdz+#zu{YdXe9Z]9xfmu19;S'9Ӷ(+~$Z&eg j 5L@f=Pa\o~yy it M*Sxp1RʮX0+%o|Q1P-M侥-v23tPMYX\ۢ+ nGaj98.wcRl ]9 {i! >Jzy!fEAz@WP/a`z췴XqLFFY<ļ9܆c(٪ߨ!آ ~#Q`>FtKn  UDI?_9`@z,ߡKAr\buH'˯%¦3gx r;v:Oph*2su󽮱mr{蟽,qnɴySߛC96l8wߣ;]4=@bnv3f?ѠE/ r\Y:[9( gU ۪-ԊbMi C ml0.D3?ڢ]:;MO;yjc+$}Ԧ$,YgԤXY_05˗1$>o<T0]K@^_w{S+к.&*:&܎o o^?X6-{5%.]Tp祦/j{bVιm[ Ϫ*Ji[m+ImfmN^CDNhPsĹ$S<'_y܉-#&w9'l`KaKۏ|\zU'Lie=:5~.P4] ,@m ,֒sAMa }WBݳt=qN࿺`aa9 (sM N- T@!5\ҝl 6tUkg7%=7XE堄5s8+xg qF[ M/ߖdQS੗+n~uh2uW2v3 2Pױ>k \Hz{='n%2a(C~D!{vF 2p']upm5i6hO&\f =ƭL7p=eg+Q:E^MN,4 Ӛ@GH(;ǝ Ҥ0F$Z v*^er.p ۀG:I!hp"9h^ä/<W|Lhh @dH$W&k[pMwv շR1Q7f$ڧ;;':&X,D1[d 2[j9{V|VFSAVC(H2̦Ūau/־+LCAǽ8?XfNȸp1V@9Ϥ1Ĭ+fx=Y{u93,jăw T.v#J2zrܺ$Ǵ6a縓HN$:# >@.tiw }go7iֿIЪZ~ [d`ciemkgo + }@m ۵V?;̊V[\Tn04ΰn8;ݑ E ƽR:t 1c z>,0ՀԅۑRO7cF? Pםx J?cV횇u釕 pG@ $P>v讣(y1w K덓gEzt$@G5E/~sOEWn{sScs $v< M@ئkz8p~IՇJJ}TUD.n`@hRZ⚆UM %^33kc`Gwgg|39sεO kЍ}1pćw7B}XWZr2"CNFr`cHni %Kop[Y=wb?vYltି..#Ցy^VW^MWBY)1@ \$s_UaG[UqFAd6 dep}px<ɰ;9k񁮡�uo~7?z/,\% tV¬?1ڹ+/,=<ayFp3כNUA􋰜wےs % aM!s7>|;oπbtk^Q}ݯ%T@42 ڲڜݷc@@ \>dm\be7XQGC@#R760P҃`&;C Aυ2HAvN gpjeٞM#+w+Y=HJ]O41<^t V/}L&WFk7}z2AfV( (d>8DJF`b]na k[CCPjQNd4A[>c}T_b9bn?;ҫֿqgUؕ9& & dwFW^xĚvcrpva>-m (` Q]ܦ2/=D=ݯ @hkqiNdO HC˅<_ͰbP`a;P3V2z/%:lJ|06Ƹ[=R¯on=l<8|Fұ 3-VM!͐'yp{w& Fsff"<ʤժ zVRf C#KZL2oVa^ 3.SfTX;M9CX-]@ \[5w-ÕbrJĎx`e5e 'Ijop2qQ$u)BZN!j#ǩjίma_T 5nʬg4a +7D$$h3\1 tz ^@ |lpҳ4L7<L@ d#$"۝ Eӏ6(QecIa^mKsܬxn.?.eٰ&9ۋX^3g?(uS|#<+[n3k:ehiI]_$a.q&G т}C.JQIENDB`BrianPugh-cyclopts-921b1fa/assets/logo_text.png000066400000000000000000007572211517576204000216770ustar00rootroot00000000000000PNG  IHDRz"sRGBYiTXtXML:com.adobe.xmp 1 ^IDATxw$usnDwڻi;[`0ћ($Zi+hD( o3`w&?"3+vUwuOp~ʌq#2c|`B`DѯDkg*gwͻrp>5.M[3,{ _JZ vc";g42e"ٙ%_N/yZ2M*M.ZޅDž3 źB!B!XJX aKX,M8o 3ofU]8 !B!B%!N2*[U2ԮnT {e<}+gќe^=tE~&/m3W4B Wy>BWŚB!Bq,f~rnF5%.=m9%GQ89y7qWzC!K-Keòqn .0.%:esj繥W^sN.r)gD>y/@|=tsmsOoy>+[; \ ٚתxY1c!B!B4YZ2XN(1O g%j/_J rgZU̳@ /4W\Krwk.o8g Kiftӽ#f7 H"B!B!2,)c`oY%_.yOaKI(\<{̻ޙuNSbB!B!5B.1M !B!B!ZSYڷZ|*gW;#.>s7fGfԮ.}<]yS~h-qK~MSɊ 9#Ay y.|MϵL_K b;B!B!Bg%cP7n5ח–W/{8N7Wiױy\ާlF-K9B!BTmf}' f.3<+V:ybm>s#7JM8nAt-Լ 7mJ]y;f/}wΈ-Y<1ąvq@dsI5t%b JgesEB!B!X%B{7xwH.oͿ*B!B!>KB"Κ-?1$ )B!B\/=y5qA?_|E <+\j50IMY8f:)4^5Y,P ;R7?[zpp*[.cj5V->F!B!6֏y<\(78i!.XZgdq։KXY*^>sIIW>` C !B!ׂd B!B!7s*x7Iȷ%}Mq'.Ik+wZ5k$KV!B!Y,:Np%"_nD,єp|S7C@RfEZw7wjkξe^S'oP4.N~W?K| "B!BD*ߛ%#Qx/o1)!8.xyEZj9߽:B!B!%?ٰ?.e8[!B!BqMBɾuc7Fte]'# Iȟ{m~7o?B!B!VތZ up[j3/58_oOZV$20"3h\]7 =wc+I7DDDX5ysyxޝ2i-ʿB!B!M*@1? ۪6af`x߬rM!B! $in"^#+ [jϫkX B!B!{D!gsUꬱi<!zC~|mg"#VI F͙QzYkoAB@qE8&pí^k(.[ bS_R|-B!BȞKkϣ:2jxk 1^U96 `$AeA4.@3ep !B!?$R@*9]-05~IUcB!B!b$ yC8w Hh\`uf/"M'݈n=ɌLll}KxCހ9IB!B!OZ]LwT^*QU6WLxҏ\^N @(` |qHGoDF$0"1/~.~"|V,n[h\{;ݲh?KH!B!bY/@\ qVb]\5h8r혊us iP݌%woo%R!B!6Rrʻ,\ϹæY{%=+cp5&`!B!X~\fE[דPlhhǼF@\Y16Cn>b{ ܄ڮ|*VJXT]vzvK^7Ĉ@vZFHq9ڵ 5B^e `pSqW- ˫}V9o+R > !B!ɂF@\Ӻ?&2 <0kȶt\Z^g2.`mQꏉ X ג4n"nZG7fzI앁w< NWWnP}xtųE$ B!B![#С@L!.g])lByqn^-~F ,"3CSnwo-!M!W,&D(ٖحHK!B!?6,zf%1"<+ǼFֵވbyd b;ecxƳKY#.'.тG@ZCdz`c`D$ Th#""RGafUV1'Y2 鞙zF" 5$sZlѠE E*8wXS$ߔ2<[!B!XZ.$^bݍ"joq.ZHO?G-ie &.35H*gz329MىP 12"ԣJ12!1{3"3!dQ$"E`X`#6L%I!"1Zd/%B,@_=377rŵT<݅ LPZ69%3߈kqz΍=/{얍{YʴYB!Bk!ٷ>D*(źQ ޴.{LӦa?UL4^E" ٠ lP1 LDR $mT *4\C A!a-Z 3D@lʌx/b=񦇘vP8$";dr\6הv{ $P!B!#Q[#1'ދvĔ' ?L hn`DkDd&~" 1Y[m0J)c "0he 1 M6QR@h8 "$B]o9p= @DZ{D!d: bpYJ\F !B!n%[UZU-=/LAԘ*suC5ZC lR iHB 4?D2!RWm;$?H@E` T1`P5N-j 0 +t ml E`̦v3A@F2>ȺqBNYjh WN"*+8\!B! bQ(nus - }9o 2 Ua5I֢e]G\kN@ miEYBDFH͸H8=O0+OmA˥u!B!BfMa/9f(l!|ܔ=d+섩-kX&̎{.u`[2rsh91HH1Ƹd+VDBe[L`F T=р4W4#(_LbDG$6p늵P#H{ưad@ Űx2^s04 ŵުB!B!XȾ97DHJ 3BDBRlAafVJkCeY37XDCѪ\]uQ\@lR+Tk/AD M,Bl+D !B!'D!X 0"XK2[IW9<8Ȼy9#N_\w "ư%\T̄,?Ɔ1;B =[lCxlgHLn̸1~F 2[Ȇb<4tyK"EَQ& YݹgK$Dۭ^.Ȧ1;ݬf0` ̌5OF&̒o/~)EB!B!D!g'0jq{ &Or>.ٯfT.xCo8|ĉg. &˥dž p O>w߷ Q{O{(uAFTRe0k}W~>mii'b"GS׎Mًg_Mв X3h<7WKiC)B!B!D!gDx20@{_3B}{bQDOB`0P/$R!@jcl9/z6\C_0RfDb-Dcsbd([.imx<ӱׯ޸cKko[(`Ab\6-'|3@KqM%}- ^l)&&,љ`33F$USKs=AHqJq實B!B!D7[`^?SdW_?S/m:Hy +fDjTh)[GH@x ;wz_~+/sx>@$N3RY6%0K>zeq$+Vvo_9|Xd+d+D"b  B`m'mk.zȑsvڸegaIzUr!gg*I_}srl,VQko\5-=-V&5{КRL%0| &0s-q [.8qre]9#Œv@FZmm` mMT^rN$›( !B!ǒ\_]#Ə{K:addD;ёp*wq\Rp@|??/ൗ]6m[lxwٲ{]k0 \..@:&1-XzdCRښ^zjtstH$Q?Z9]3g~:r:)6iDH@0`dhOϞ7'a"P*_B!B!Td/ӵQ7FTd㆞/RɍV:tS#C-m?+$RX/F ^|ɩM+`掭_rٯn߱@ amkY?fؾs?w@[EOk$ՒJwl]݊6PkD~8BA=ԂVlkȻ2>Z.e.bga$АڀgP0hZrkVEf !B!BP\*?} y9tX{13jT{^xHvrg?Wm/ M-sCT@ #CS/|PS]u5aX7pnt23 h']֖@ Z:OoHcU GOf2?M-8;lǁ?h}H\Q(Z,e# lПͦj!3[-n/]NI+B!B!/ 󽊯cU,`LG3hX"lo…#;3z͛HP3ڵ;>=5U'}}D<4йfwyCOGҝl [@ =K;FKv _3_. U+RO~ɏ=ѱY^+oT#X~<#4 Mُ51?{ؕ|{N)rZM=G?kZfQ-zK/=klooeuR4f4h*+|\tx A)DX-./nGDPH33DDi06 {4x?6rɱ+g_[4U$B!B&R}]pޢ^40Dhvl \{O{oo >cǎ*K/޵-mz}gClٲ)X.1`Ղ>3O~كύ^SarUl\U#xg1M _~/w:͸AqnOd˝m_$Ur5h@=~mDi@!7Ex+xמy빧^OWe$~ޗ~(֞U7}DvT-{W c53Clr 9k?{b~@L&ҭD2:{Z#@{gK*oI'h( YBkE]X6Z%2# 1#B6 my{H!B!BJofG˚f/|Z!3013 Ts_}ҥa6ƲU')?ģ/M:6Dl; '\{}sm79k46]{cx =|oКi&/~|펵'YS[>3P8clٵ቏>ֹXXFF j@ܔ>7xDD>Z!;^W~;]QƄn [ml)8ٻlj޸!䆧;6ܿsU"j㡁0Adb&` ˦~+*j$T6`ЈmYJ9;(ґXkk*Dz:xgW[%퀅 I ̵:YCwRq֝bm_%D[C36ܨ.z=rΒ$)B!BxţHB5,$Z^= D@bYi;l֋'?1vj/f_Z?[o֮OƑt];>ɟ:weX)fm"B@VʶUgg: Fⱎm-mɞڻZmG9lQ&H@ IX~03#hF@63km"،vKj|y!_J2j\!B!X>^ -hlea,Lh9>Sg`έwsg0`2 O'+ ꁗ>ٿ{{riԯ~棿,tM<AV2.+^T9}o/7>'_ktEu(3Q~S{uxiͫVO~eU^Ddp\P\>"ۨw^?wl{CJٜ&;ܵyk"nM%-6:"Cju'ΟڶvӆpUȺdRE=̑im$Bӓs>iGkL_{qeλү7um%LW_Ȕ|<~iKկ^8ҫ‰{okφ-[ә*Uj2e SmT{8n+ 6)OYcʌdOʉ3GN8?|J#O>ݷںfx*e)DND fa6pJZhOLd&}/^:9;J/OO3DӕN^" ,D[d4괶ۚH%H$ `0`; a$$`?RDD |B!B!Ļ֊Bb=8:!c7tfd9ZMKy[z F& eiF6_>zlRУX(6h4 d6ڼnӯܗNdAhJj@decx2q^ifJ߄qQdA~p$O^& VK_z啣U4=;{7wZUD׀1X_RS@vi8=8 s.OtC#cWVݛ6ڴ.c;:!G=ҁ7~εں*Dӗ3TìrybjǏuloh۸}}=t}wߺ!۲iz13X@HK;hM?:ׯ2zis+U6f+X1NdO<511199Q(Jv2K"v- x8NSH8֖ܰqu{gk-ݑvBX 4q`A4yTpZ.%̼AN\d=B!B!2QZ ԃ}LVM#m<σ3F$BPZ̊FDQ_7!_':NLLjEk?95T}8 X6)4YPĒ!@2 hWOLea6^f/? e 6|.:u1, R_? !:UҖB{O>0e;\H6vf ?o_zus=wٲ5tu%Ie5iPyvnrC?N"DNJN]<7O<0Q){o۶m?;a; kExim$ (Dʹ0k]F*S`05+Cr,Dff" g򭯼poZqkI%BFy fxZa ~~X{8d1\jEW]h4Df`6l]4P.q`to|QkڲkÝu9UGy#;Ʉyo=+9OݟO>ӏĻ:џ0"?dI~1@ }؝~:Rhtu㉀`$UmB B;ؚ/dm" t23?eL9_-۸e;wlܾ)ݙ$BʶkajU+[Y;]}σmC `+\{ET3̃SG@ܲ]V]Ocg~VmYGW/^(f]yw.|NLRO L~<53\9CVml!v4#uVjJNH(}ko?ssgyMҙ7M _oh$JNB!Bq}nv4~oy?xR nR6FҞ1l1_IH`X cDtR@d,[_5#w-ޱcs(d'@?WtȦ`&&G!($ $&U͛翳/\V88O` * -bM=syg'F'V;yUm@"LmP"539 @)+}.Kt ł̆7oOooع "2Tt8_OLrBqoVRp:Lwv҉D"ݚJ$mmD"JC~> ^o-1 !B!7Mf@?.1l;w?z6E4FB@ÊP x{ Y|A±ӥ‰'.]+\2g/={=]wlh*F{3C:fݾ"T AzeTF245V2vp葧?w~s BRL%˿ְAw l%Q󻤨 7'a׃jV}o|wh? =푘 :E-e8ٰe@;h``|ZL@P}0jIG`*ݻve[.J851'G'jubtJ%?=lNMY.֒LөP8G-!F[qs#m ޺1%GV8 RεT}']UrX-GΜ8595U(ƆG/_☰[%  3rs-8V$l۶{F=XhB!B;G!W,Ջsϼ #?+;'^eÄwA->kP lP#c400Xv jC;w<~WFG??rexdP(ݑ'U?~>p՝ qhB`,  ϏR|g^jVH%Z1 OFl޻sK !x|AV6nx7Oʧc[oJ^q"?a쪫.x2_W=#I4EF~ ``fsAz85}ll슆![Gv0B߃jW+EwNXuύMMN}Ʀc&bɶ֮U݉x$dV^FUsMa*[f˹hvburVD2hI&Xvugow0F#q\X][3s3[6v~[d-`6f;޷5j:92114V-TK… gOL\>Zrytl̸PtСCNuO}w߳ŲyO_+B!B 7kujonaKJA!NG Ҭ%!7:@&pX/V8@`{w}~Hnw̥Co}RKCL~;7z"K&"ȑGO&l>|kcmf jrUx%70sV+B!B%I pŧp0c aMi˲LPaz~tk*fZ9)uZn}pWn,^>O?WyG>9GX`hm_v{?ޕiBffdCF5?ma` dK,p̛LɎy^iM'vK4q]킢bQ|āWMg#|/|y!VݽnXyM-JT< i&1.еa$zn{'jiϖWnYQF #Ek!PF@DBnIEm rhl!% RT\SuZv#t6ў-ml3[-UM/3?| 2Fd2ep+Չɮ©xz*O{~jWu˹Wq+JflR%S›StD--mw#`,K&Ue[~X6aF-הՖB˟ӍY|9382p¹3w? Gcv|{;vwr{Gzn d[IB!Bc&E!Rp`7}wEa0`㘱ɸ$5MIc#_,;tLpʉ?3N NgO]|g/?3pwy~q׽621jxzg*tmۺvӊ6O˜GW @3sfG io+Ȟwn3_};K%׀!D@Su+ʕ5֞Tk[Z{6vb Ȧ3[<='pܑS^}{vohI Ń I3SP\z.(wBfժ@ H&ѥ̀Tk؈Pٌl9``Fnwnvvuŭʓ#Bޛxlၡp ‰;wCh<Mɲ[::׭|DP-=z;40H@Wܪw`D&'&1tGH2Em Z]e{Yv \<~`]ox:ʥ?x#zͦ'r}~8" FfB!B!neD!)G yJv,rAg1  "6"6-53PTBA  Q6XH53{˙/|w\:|Mf+eR޽yۦpȶ,Uñ0Z?F/-Zw;󎵩TĶ #3xMkىw<ܝ9-j S)WX}.f& #/ eLN2lRd3BUؼMwojiO @Ffd *0z%sGv[ 10DxkΟ>〾cm֬iOFCu˜j`c;ږPA\5lZjџu/k9o h8ҕFc\x#Eϭb˔xv duKUukCW_Dm6nE"V^}+2HTb%_O&&&.2O{eP v;`%O&,ǶB)3tZ8]\}ܫA5ϋA|ni3;9, B!B!Wdz. & Vб,R)#F+QY]dr ?r;rGF&xDR8w<>7l[i$n+A+CG'ϼdq";w_'*fstRP@k \ Z#e5ªWіeѵWΔh|"#h@ 0e\\ٶ" )չ{7??ͯ>C>y{tH z&G]) M0PPY) ` -hrdD`M*j!W.FbؖRDDղ9popJ$L' :::pwgkGk|ժt:HD(9 @SVؘ@bEg" Oޏm*ff`h 0:d4nbT-zlqr<9y *+Gp{WO%վ+N"ToXʰўg\^P/7+ SccB17h} Lf2ccc B|bR='d* V{{KK:qjtKK: H4l;lrg ^hs_9̎]2`-,`DONچ0k4uKj'3cN 8a[٥j%ޒh_qc,ɶRԺXîUU ?|)79UN[i j}߶) +g83WؑH  if@/|A1p4pP0uۖ|cl~nf="M DӄB!B|4uyٯU,^ -;1i@ .-Pw@nR9bl۷eR6d jiV]ST7M_{gW}ۗG2=?:s/%Z~i;+T -E M=7Z" 1S\))^շmض?ϞxszK V\ױDubplar3n(ţJrL6[(q'ܸɧ77*ܕHI[޻g_xCnYutl=>/zU?6 *nLotR}Y+CUmm0tqlB!B Pu G6ћlQs8zX.k *n[G(e=gk751Ƴ Jxxσ{ydSy^ [j}o9jڵT$0P-ڭ<4xelPvsbn-6zq M'1"ժH$H" BlL 3iMns|R*U*ԩS3,kF۶H 9 'Zbmmd< 'Zݭm-EJ)Rkɥ7`UOpm15w$B md6 gذ2.e\RώN;rR `ST(p2 k{7)>Qh'hURڸWQJYʚaySTږ*޳wc8uVKϮgI$2΍2 ^ǧk**G._︭;gɻǡ>HSg|'/9w b;5ϯzZoYu;vyۚWۖ!B!+nQL,UGrn  [b ~aB5=]zH0gUP} mc/ZD" bỶ NNsCʼnXC={6m[ZX] Y6դ3챩h/`m{W_}|kx Domvjr|PuR-A"DFfl$[췚}xյ9CnC{6? a`*CUG;شuݥ3W}˗GʥRPd̚B ΎMl۵yۺ?=& G#A>[sIsu6+Kr018N($"\\<}7֎|YJ) 2A^ G']WWsJi*SʏONf2پcbQ)" A۲X, zս==D%v XИ XƦ,I*j}YlDF``b8ؠ֭3l3cm{cLx iBx#B6̺^)sjӚe]:WΞH%B0z|f3zp;tpd@`JuᳯuwNl]YFX/p;wnomܽ=[ RB[\\=p@Ogzvn9B!,/r+G@"dT,G6\vģNwHdB4"&&4b'dcDN' =G;rq|`o?}lߡ{߻1LE\iTU%=k_:v̱w6u\Ƭ_iEÎttdn~4Rfܓ[^ rR6fʨ-(ٲzպR-V"2?79V\( vvvv5J'pP1V߉+/bp(`${!`(,7H)F0ƸHhkrψ]6GQPmC5`k r5[T67\L /_<]V,h$L&cTr5T#ޖJ@EJ)e)hƲU#Y3I16MŁZ>i( 0MȮGN9~bCo: ԲkejvlDμ- t/ X\η{l_D[:HF;mmh4A;RU2f"zog/Go}o;Y*U}'w[pUzlCAG.rϾV(g*t>{~mdBq}!qxx\X\Ϙ@0@(SS]hqHLȄg0sx2 нjMG3/Vbߩ cCK#7`fZ􊹱&ΚJ$l{ݿw? y:` 6;@bG/ZOc`0{N %޸ff`&qSpa$[TCdžFp%vj2s+.ٶGH)ņ0(KiSKCf Խ#YHSƨhj I'=꺮1ժKBqt|zSd6ʝ?7U,]yƶQHD"P[[KK*ўiFx8,,hꣵQb};捻y mΓJFl-u@<yBAvG$crM.cO(__/7M!Bw XT&dz(pdh,֒n?7UDgVbWAMm0ק[JG*A-mX;)Y z[;^?zPR州{f{޵n*;Hfe8 hW F[[O6xy,֞޸3" Kb\k=hccpv<۠ lb2, :Tk\ʔ*B}˓Qc5zu6gTr=yZW515U*xbbT23GZlFspB3r}Jm7JW {h!)r,'tРacZY ڸW*^Pȗ Jyrb"2b:0^s̠Ec1$**861C۶ ͝;{qꞶ~fd $RR*e3R8R`ERжDz@+@`  2!BRcA-=iO4hfUJnX\o*r٩ܾjmq{k* ֎d%ڑNX(lR j܈(460{?""u /nd#0͖Z0FWܛ ݽ<߲q}/_߱.Rx%;eFӹLfl8ԟ9_yqlGYO+L&d&r. sAWkײTx$juV(Rv&hgl'_áB g_q%ՙ9פ򾪽/2ܾ̂V?VnHBB 1Bx3=}U2]*}޼.nfVVw!ԝ7nܸO=<~<|׭תT( B:r]*%Q E,G0ⱘahIf+\cE\jE ]+kF&nؑP 6[~K{v=չbM_3>λr;$0y_0C[ +&_XΫ)~+[`(xa5d!֒d*' BeKXلbRYKVMeE"nQƐyqv]_JMӑt{[Ϟ:x,#;{\j绮K1De˖Bcg#siNN;9JMfe_R }dldacLv"J넉H쁇@S5<4cіΦ LDv?'>98v~S+?>ww4P(篾.eC/j BP\ yQ}1Hee">x_w ⚓/Zz+{/OZj BP^\ Y,cA6 =(fK7%p7pLd864M94V*Ζ:0?ʅ!+6"vA$$4I$Ry$)l˖ w?>E’†, :VˮUV"(N Tѩ$Xi$YFBbe9q4,aTgm-D2hnjmG@k8,PәY-q!|_H"ߓ'Ke\!LMs3g>|h|n] "k\@Ghx{ҊxҺkO ֭~'l~q|%[nZkFBMR$'$B!&0 y{ uLfKpU} \ h6ĥϦ欲xSpz߳ЁzHMLrږ$+~/[#v ƴ(0̂?pТMƳDuCH{[{SS[;牝Ӟ5`OL-[DwI# !Kev} 2,F# 4hh\=Qs"z*ζXt6r /*fv6GGO!#@B =8v%?.TR+Od-DD$l ƈu,kp9ؐ 1\$>:2h?Ab gCML.]ڙ|r]/X;քOZ;M'D.hܒdrr;o lx"R8l>38zr|!\#yR"GL Ӣ)<߶@\i?ϏyG'W?yq& *MsZCBP(#B.tvv p8mF`P""NLvtzvLWR@kux)`"V ]0Dk-ꏗ#mjlNwbQ]GP Z=_"s"E!(~ե 4Qs'eIOH>L=a$B/_*#0mܴlҎ7ݱ}ߎ=O|N2Wn[[n\;r1 eguE@ |i嶶dcq YEG 逨H|K4f>eo]xf=5nK PBwr%r|`yE0C8B@9 Zo |vE$-(UA@ 5İ00=gp9u1m{[t,hiLB@XlXazB8>d8jyۍ!ٛ$$$# A{p~@Xɷn]wRZZ7(z8}Gǧ=;3ٳbbf<=2j!D@(*0ّq#d,x|+?QTy x;;?QKqYwoMjA j^zZCBP(#׻"$%fS@s]@J1\/^pi55e1*Yctv7Ǎ3_7vro۲f|q/ZDxVA;0rksI w_q/ȳmͽ3=N=1($b+v-2 S.>G 9Tr {t-XgMxŗϜw ֯]w]-_ک1B ,B8 DQ]ׅYzneh$t.rgKFi%$"z!IܾN.k lYK"\-ҩ=3⅜%HЗMf^31^*yTvgҞީǨa\Ɉ, ˞@ [R[+4(QYPsRlx8LD:6\zyo[s2lj?KHΥ ̥[7onii'NAOVdyy r>Ҧ:@(FH$ tґ"XI).2/$`NVWId__㣮ոJ~IBP( BaTyyrJUAfx@d.;+\' ~cR\O檍+"/*?SPU D$$;¾㩒lˢ7ŗ9v\.>|G'oio`B<;S82vFѳ/]!h# I1FSlnkYrɭwl>uf}/Oa7;6*d_vzJ Q n]{<[OFۖb)hL-Oˢ|IB C]\r^AjvuwJYMeZ|Æ[ׯioN=993uzhXuW.li#~<3f!ge 542$Vgqȩ=jKCrez+z:|gh45ϐρ z04WtA,lƖvMZxնEѵXQkDŎ,+^hHqږx"NrnDR JytUǚշZ\"g`p9x$c1&B&DB}+=1j8 щY׏l~_J#jA BP( Q!/Kӱɜ0K0RZ3Qv eDBk5#3cTj2@Pu!3zB[vtwv&9zu^q^U$@1C ,i4:S'F'>⳻h3֖ek:ό;ϵL XbT.)g\tő oq24:tLM^id:R4#D9xزZ+2Έt0{o^R>W8=bnrKapzv7\4̐ߚ*  (2WIl6BPwfƧEw^.f)Qeq{"zjR(.\}x4dl]nΖpECdEJywE2DpJ]Ȁ1YA/ih6RW- |gCij BP(?(82{|_h_vxfuGErNjEi5MAT$ٱĺ@G);O@ I }}K{ ! dycAH%FMF^ؿ33S 0=Ofά^jvfne$ř鹨[Hv7_6[?|7rEC|RQܮQu#m=60GY9xW WW I ȒUKo)ٽ_~$;2*C>ȩb9Eb#3Uk0!BLjmmy3,!iڶJ Gyc$A {_(y;@(EBbbp95;^3p!S/# j^FLjȻ;]}r> 5ƴ^:M9MpwG|Eإb>~Y3FL=ʼ'⓿~/G<VQP( ?? V[G_|ަVL8R ׸kk,tsdDG[ܴeCg{H4Ţ Rr([m[ 1C 9_#3\<Df<%4BICгH(;NdR/tF|RTzsfCL un$<@dU^)OeOYK_DS}+^ɀ҇n,G)$OKBS yg҅OA" :^sEtnl閅$g*$Q M̞mn]m1^lHg(d7q{OYܺy /p=9c<IRJy֥+O<ԓ##vŤ۞T2%W~?_Z}-eÝ7_~_M׾]O_{j[* BP|{_gև~EE*gq9?y'@;zr9N! "VF4jl.K6KF)A>=o=\a7߾tEY,1`iz`" @&h[4ZM\ #%Q}]VؗB0]ce5Dt%KΣջ؂Ѷm۶}O2B17ytWN ֭qXNΦe25Btj6ƍD4u@D e?p2s%>} %kQ3V<3nwxÚ^3}8сchYGxXGsqhL#rv.RI ۱̌7]"- NǨlm'wl 5bXsnkueZƉz3"c!"")kw  8Cr3$k__*kmsssc}wlo}twv;|5@`O~gVFQ( B~q_]n?R(*\ldW._k $F=!1H_ ‰(C/%X׉\ִ7Vgy[" e5v#+,ĊӪ^U@BB ' M YcT]fDf!$-UDTQHamე=,=#O̝\;nsʆc<w%IvgFƭ|4DC| vGC px 9+37Es7]c|I@PHv,N$hg˻~HRbD4^rÊ.w<ӹ, "!@Hy:: ! >&}"Sއ! J[ |R%:k*T$"02R$W^:u-~O̦FG'W5W0d 'T| ].լ;y \|Շ_l|=\kݗ?KW{}}H|u'x߱g>oO#ϥrqi%D* BP|^>>[THvQ52T&<[ jr{SSA$9㞇+cX8 EDZO M('M:@#\jE58&9g"k*DMKƵT_@=T湞Ix.y\o?^N\oUW÷ܶqwl*l^ZD} ;vn ^4HXv= Qcx$CR"}= MOMNy\n߾M< NL>}ʖD$ǶJ٬=Re}͑w6GBDDsZC,k d5pv߰-I)x+mH_@ @$ WH@D@u٦jH5JGg AdYe;(ʠc&V'ηC[mɐ!֪eu^nJJτP>Wϼ6[xG7ݷn֍ ޟ篣g( BPLΤ <-Lf[J*ڨx,tâm,&*OvF]!BhumƐ1FDuXV,7T?+ŏOXKz~|G{crÚoʑ_N5ڳ|6_jHDU( BxHg W?R!S!뒠HJM֎–lePaX83_z1?i? wߺq׷/Mds}/BP(ϿQ /e}>z֮]rݫQL BD.%!CllJtu X\ه~zDinfzwD%7JlsMOxW.$c Ԥk-n'71 Di-e&"1{$xEdQ_!0X,*:/yj Q$z7ra zz\FؑTkiC/Jły Z dts DP„tF:#5VPK4~-$ 25 xZ#x$=$|I [FOJy&ڛNѸ(٠{FSU!^.3E|RD.84瑐 }I9A'ɺ>šwcM ڡ<_.nпt>$n ݍՌd"ic2:;ifq :%@n, ֎DXE= ~F4M:o?~hm?Eğx|-ǯ|t 0 !P( BP(hae}1Knnf,79j%DcaBVE־cO֭Pr$bywϾ~Mc7nxwyi$С뗕N4¡{i"^c@'G~ 4/j$$ %,]$J| ֊?\ X/ieGRTk*K@`@@$@ Rrh01Q4N),Coi@ܾ1!olN74FM7pjht"2w`tx5N L$!#cK@@"<_:"։k:'% !$Xe8RHē J9.8)2ٴizjyxE;Q8@r}jI BP(  ջTPb^'#ݪ)ٴvI 3gVHe.#$x$nYGm?)Zp@׵F[6s2=[=99b +H& wv4YV-kVcKUìgETҎ+6 F.>[:2ê!= $:F.*"͋tTI{ @m{ {'|PE441IT.Y3ccNd2="QeU*/(.d*$'R{?n_S+biO| BP( Ȧs eʯ~d[!"Ò-M @~MJeWZ.fa"-9/ H:=7áX"w׻jMLtl9aM;kr67<Ǚ{ =0Kp7ecű|Fdڴds=<غ5Bϖ ǧd =6>QXvCcjjj~I)eE T7s.9UH 3?*CĪ]Q%6OР.kd8v>qo?9(޵~?/r ^ __7tMBP( BxZ}A$n^LD'_}HrH&S3BHCgKVeK[٥׵!"b!$-1Δe|3GXs{˶o^iU6#3sDp ,7~CSÅTckkC$n*,( 4 C3Z$,"Og3#㳎Ţص #Dھ^hbpp|uZ5iLZ\)&FX.g,DQ@"2jEٌ> {)ȣR\H?W.\|806}zVE[zb|ozW]cʂgŚb|8b??g5Kb+5&->HA@  0Tb}n$X*+uy3t)-vl6+;^ڜ5O֓|: }O>A/ ӿэkܯvKyɒCu$lS( BP(o0. hߴ:"kik~æoU81N" "I:Xn1D~[oa"+&:/G%c醑˽ȳ]'muo޹mDcD FdL8;J9?x%/-IR^[傟W^r]2\C.}k=o:z𩑹Cn!אXaU1V!jiQHB~b6L4<i{0= \ȩ\i=uT~-б?O4 ß{~,>{W,|>})m"gީ$HBP( B5& !{~W}+XP1 zի939%&O֨@ZLM97Q ͓H(bz.=8Ϲ:3f)I3TI3Ҏ!{Hw5v޲1);,+IQ;Pr@*|_T৺N D(zml)2 4 u5i r0lt`[L$ؕi+B ?((EΗz[@0ʼXKzھ޸vna͞'3{.<|j BP( +U! xw>]3y{j $hL eX ьh]+-_xyN?SKΖ˶;:z셗Nfxc懶?#w-Y X &v=9^c]/I*E^t˝/J _0޺?񁡑ǎػC^a޲e}kKPH 9޲ţDő9QvIJyu" DzNi7z6-飃ddCy&G535l/4]2 $K, *GAIyŴ4<{oqnd͊;P( BP(WUEpqy:s4%KLM bv^b@PHX qӌ$}4n~}]J &}5t{{߃߲vx2 %voRץtHϞKJ dݢOV.H ʃx..ޤ/AHx^nuFO.6nXӌq`Y&td@g|6P31;C #gc':XGGYnYU>vP>ix"Ѯ/[ΕǨ!d~zE@+N42=lx9޹$jS6|SHY ND%Ҏ&U3Ӣ=$@V"b6k^[}1sL*;ۛnXL BP( v/]=ͷݴlBx!0eKds t^}xnǫ{+IM8pj]G^99dqpӺyODzY-*!0]VrEQ$W?@tkjzӛ8vDd[#tV G@Bȹ\q !W9O$p\g\<ٸq kjNl\ꖝⰾI_,%fvFd&U:7PQKi2\c8/ѵܵ5_|zY& aċ Uc Jm sf!KMk9x7 nHX$Æ.֎ⷯ?맔A7ݢAP( BPP"qGkKϮpC!$1 L @CDبCH)$y\DtV48\yq )}X'I^O~aѧ~ܻ'BP( BP( Ybg:Wt;Mo߾ұT)==>pr7$"ɀnH\C7\pHSg^|ǝZ u [9o=>09okx:[5K9GP4.ixӦgwݴfI(;7b ЌF7 !b gA)[$H K-FJRgӚ&95nY6S8&53 ޔlھqӊݝv0them$.ˎ+ӓH ;J<"y+nݪ4߁qwnM42I$d |a:j0誸1X:5PRJ!AyW ~0/q?s/ZpLo?7IBP( BP\yU r_5/76DW}ť7h-QĈl$bM=-G;qfzŲӣN nu:V-A>(!1눹ٙ{|)YŲU6tlRH2ŒF4::;gŒv#%UZbZ:2Anڼvݚt[W%CajAP,Kʻv8 iUK"E #h.gkgr7K_9!s6aܘ 5$i/:aZ|qz*Q颱90#jm[.bv$SRN׫=zSJe@,axE[̔W4{A(VTXQ}nwGp$ld9?<41D mBR1dxI"'bc][`xB/2ǟ}H}F* BP( ZS% 2YkhIlxkӿ|ћ7nm4x "ssGO3֍[:WaZ^;u`)tfCϜ:sRT瑔$IA"FƸcf8$H8^fi5lyc$6L4G+Y@u*AZZ%źek֮8|\z|*besld X̾zLBgƞ0nd4 >]e=>CLϗH*[V -܁wdBvDuFm֌5}n2X&Sli0i /#C/t-۱d-kZ,Cg5S#+W/a}5 $},t @E¡?W_޽o֮. R2- xޛ ̓{,4ZoY'8sLY ~6 C{4E 4usV&2N%7&}=731pIG=8%mI?>4323+ɴ?c ٹt]O1B:&6Nϧg @5z̢ s(θA#K DiejST RJ PI=Z;Ŋ.ckD$ѕ9Ts'xc`ב/Z??Bky?$HBP( BP\s.5m>sQ(tUw_r3'څhɈ$@4s5arm_ڱV.{@VR{v߱~wqy(ι.9 djB V@xXsA 2/T_^ m̾{<Xjr/z#}& UW`A,y1!F $I_tsDF oYӷόy!аh&Z՗k׭ ֭&* fзoɲG͜jY IrXИĊ!/X?=yxB:c[yD8 Kēf(6D1M3 nX4ƈlg3Lz6ɖb!KRH W>zzѓw|6-YyE4nbR"Y%tIs%+(HXXzѲr"ZUe2bHW86lOgJ- =tbhLi븅\itnI* J5'Pg# wXދpSwhv'Y_,q"AJ$@T'@e@'R#^>V4ti=aq$5+flw56\qrP7R/RXP4:`$E=W3LVmF*%~򊼐RJR"~5>OBP( BP(y4@.|Χ^:OutKkא2 9eX,5|eۭ7O<6޶ Gd—~Y;w:955$>snM!i"k(\LykXmn^d<􋏿8tt*k$DȍpoņM[[Z 3lz(Ƹq]PJb۶4ى3Ãr~+ۗzLMnjMw{VlXEDt}#$Gk6oxW#;~+6l9"$S1{j7yS`509;t‰s5?[n_ϒ_@oݵٶ>g4[;Bw^Fcipv G(|Xal "D@D5 \J$g^鋱CW& 3#i)DPIiiZw-M]{^oFXHw$VKGP&ySƠhUN$ ̷%0ȐϹVwYCyfr}S{ |nPT( BP( uB[cZ{yn|2-(Q<-;#c%:D4l!cM0]zǞ}敽-h=:8q2 oY;0a` Y!^6B%R $hcS{=s@~nN@ [:noJ46'uC(?RTY2Pߴ*I!g'ǦFN;<3;s L&5752ylmwtCw,Yg7X׆@$m@Mo\6`LtEd3bo߻02@Ɇp.Ȝ<O2'$| Ɵ-rx~p>fKa}sO9\ۦ֏̍e`!CgrTrs'FYiN9]Ûk4aMTYXuQ>$Kn|ױTL!I4KqjhZmӊ/|W+q^ BP( BP\[^v} +۶ID1S, :z覞eN%hWl(`z4*7ާ9(m;7SH?982;G޷ԞELӮ}MWZ}sG"@HݼʷOHOyR0D 'n[t.$ 2h"ٗYvA04#8E{V]ix^14x@0Nݹ?ֻrg,!%\g83!kkkmMPFF׭k0C5#&oo.jKMDManf˹︙TpE w0cT}4t2x[Z21QQ<6ڰcS"mr,t&5B =\:ωR]@LEe$UYz3ɗ QH!0T^ <޹$S& Yqb70"TsqnSGJB["1p1EoF'@j`_SCqFlk|> QP( BP(:0,_?']w;Wm]Hׅ\^oƣ;Ǟx9l2 i︳}YC$bL|E+PFDO~ǿ!Tu@nD[;le{ƛZ43Nn~+ArEh XqimP];>=ԟ/b9{=\Sn}֞&-~^jrɏbo'w,*.7h4d nĨ{fJ.J+ .Dt: ӒX"c*sKLfO"!;4uTѱ&G|-z|ywrY#ڔcH2:ƶ9n u-OteE.D~;(}D!IyW&ͧ_Ӆ+cX$pTKA2Jo_9wM2 ?S=-PP( BP(j%=a1ny]G[lhښeI"Ir95bAaM6uˉz RKM%R=/O:Y+O,h^l=gdgj0pĚ A:t\*: Ag@$"$PovEt.]ٽ'ٝLy$Bɵ¿&'?^ՅZPx]J'eCoOk?<1=|)Y WhpBQ԰ TvHEmV0]:rEt]i{y|ؘ.9r}5&K|N3( wޱ~Z]=wmwo1"w;B>hogNj3{ȉoќ B 5! "9";ƒˑBi ysy&ҟ+( yEX){϶TtY(Jԑ!ߵ9t,GF P2:2@z=vjxQ:ePB`@ ޢuEǸ`.Rʳu%dz 3VF&*txyo,x) 6?,w+d(]_ca)P( BP( Pͯ%T(Vd.*\Ahgg'>ç)SfZv-@}q:5#MnX`J!(TY uJDPtK^o@XwniYƒgT|kr.9|dH>2H|)/}4DPna`ZGGOSck[G7%Z][&Z^x[I-v47޷-auNZ`߇tubspWKscs蚦qΐ?D{QB3䦊WN٠s" ?76)s8mGJ;ru$(=4TrɁq@˒wfQDnx;SJ<*:$"ͳX[ {Be[ ~%#{~+D5+2]]~ $]@Xqk^M6Su@xt\/pۦ#PP( BP(5%-E_ L{;{YVoF.A$bG]k~&-ᦖD4b2\hr~z?ʕ9Ϻң 1.ȫ|Ko{޼~mѦfXP0K&7"D ڳk23tY&PCMJ!C\KsNn6o9Se&tػe=֮ݺZ $8g$ HHZ(w[z񉙙3_r_ 꼷߽-w1Q-bo p/>ܫ{۶'V眇&cཷo+dSs %%6233sS91$@-%{Ą)%Wt޺vݛu jQgЬx>Nt!d' rҏ:FRH"!T,UT+ V߾QP+F@hDY~ȒeyMq˪BP( BP(^KXKܶko*/FO'zz|ɠғբzH\O# |σk tuNT4DԤqC'w䥣r4=K.߸5kDY= :'M0u{E1G8'P5ir(Zf6Rd͜8oāmo_mh V`m]XxǓ}*^}U1j*nj g!O䑌}ypwtjSilZj)2x"i #g>C6̦Ds=5F.dk"p wlN,? |:+sˣi(ٌ4M&s5=7c9wXy#V4'Ǧ-­ ői,8Zμ% P1bc-<3Xg Y@ &Gonʠ"{kz~@D?g6qBqP( BP(lҥ IMɡĻ>%hֱ"0 5$ƷAAn.[xf$_xvuy.vah1nI3`eҳs}[7l[ &($T.9zW t5bAhwvapDqefJki1]F3w: KeQޘ`i?_5TnșgCJ1s8#sui.WzƧ}FjŊ ۰vD"XLoi+;Ԉ Ϊ\J;Gχc?y5Mf~U˻?O>BP( BPhF!2W ^y753SZ{a-lh#"aU>+uoȈS3sX#ͷkipA=*"ΡWtn+bzT.kVp3|xT5HB&A4e02\8x؟ۺ%MH$R,K kioO/3ىԳ=dEσ}(]t9%!Mwo]O"F)ds_O[gs"3^,Yd-O>6jxS >yK#0dGNOYq扂ZWkQ3-'|͍0됇œnw mkIUÚM)TnB1]HMyٍAHRLn25 _yqR"f%_~¿͎$+fTr`">嗞%˅vs&{IՔ S~6Y/B֜ 8:+D3 HRK9GN:JoXKzFDA< h{SR<17ƗYfՆ[ifpξ$] ˔Aha7yLf802=>r\HB<5-q&}oʅICIM˰tNN G/`]Xci7_E|b1eh{S=9ټ]˛Q,OyS%DkcT :o_OrԮm))$(1ih+{I\k+^\سbt^ %2鳗}mr t7ȇ;VrVI%B`4 R\dh|_ eúd(p$3,4SC3pdǁ]ۻnzn-kK)Ab6tRG*w3_B[q u hYU h]n$DQ5@qU?vpjl*=W#m?|:%l pJ`[‰kiϋ%ub'nXag* H `|=5-K%^dXx1/Pg!>8u'6_ P!^x\Sx҆*)46*%-;bwiHD.~pR^ ~T}) BP( B[]Zt/վ^?Rn0}+?WL &$HDdB6zξH<1ݫ$$6r|aRu7>ڠ& V:Hdg㣪9i^7E((ֵm]W]]u꺫w6DC L213$P&@D}wn^XrU0|V![dGVs$fɟ4TQ͍zDfDB0xzB;WW i>QgjN0CP#0(K Chұ( Ir,RC2n&tc\#"E mh"<Uw,Y5vnw^ $pQj))mr544v5C󊪆cyfؓ,i)>Y} d!bb28kڱkt{+/m]Cs0y]!53ҜIٙ9Y)yiʒcĸ}gUeMcjW64=h,U5 r3/MRVFJ^vZZ=5^Qѷ0;1 $jz\S.kRUIt໫bz~NOuج本\Qާ 7F7!" 10P4G^n: .~v(D<'@夔Ayi$J2Xe$d?n[Q HQ3/!Pf69p *"0 DY6_slŊ.4t4$:#"|tKԦ ɐHO3 7yZT%d1YKːǍEٽm%rrӜ4gSE+ l}._WUWsp6^z$d8΍nK3&e(:HN/ʑ f*PIO_P7ׇ󉣦 %eJm ˚'vD!39,Ys)`c#yh.7Z˶ʐEUzR{ރV+u 3ۑy 1ƘpHDmsg^!dZ;.M|%ЊfӯPmGeNQϘ2vҸ!9m|yKWoiYMiǏ8cQ+z}[ܴ *3E')Lw&"-C:Wl(VZ}g>h??[^Y/w@EF>(1^_pvl.غrf??[VՉl/o`10 7=1 zV߲kڒMKʪ~8~yC ?jh1 蛛8!);g1ƽ !7 X,o}C3: dxP&j(u=̶y?? Mʻ}~W `29Og0,:i I) zg-\IGa} =%3ŚvH$bQ92h N;iP62 ^5;V . إ ۽of?0בE1 ))Ix=s4 9O.ݺA 芾x13rf~;ڑf,[8oNt{l!'%e;wniГOF)LbqkzH%uZC;K;Ural0yZ1(  cHYXv")sz΂jCGCd. ac ;MHBm ^_х~ ^зYӧ:n蠬$ ĉvpBqE.Z+H*2=-M2\jv'} &q%b1 Bd*v46)l6qKJ醿#&wDZڼcgv"f&gf,ѧ0 uՔ" a֤X3ysEQHw;[?j6V1Q>3Z7r-ڬķ1`H䦿}`_=ݬ8nhbĭ=يUc/6n볾p!F8%+.Y'9UO)9)Lk(+dVv/Y7? LƌES/ĒdlزOقzwUm\IN2檋O=Jz>8yֽُ U5>g}[/:ī.z'z@]Hb(0b\7($ȩY'83πe_]x3#JH[,3u=ųmZA(E_Ww$ˢ(rM2"lIbZ5#+f=w_{mA]EQ0;P9Nl0WE HdJ*X%]b: ej`3aLzLIKdeifI@kOJH;kfpKE L>/dnj:Qku=@#]NB b?*|?By@?9w|AG_~X]wun1'? ~?;R ]7y+s2S~}97]5#-ŞX#Ҫ?!Rn7{+~7fDop'Xzkw>ף#Ose~K'Į;:4P:"0 D"U9d'ך}>A4 D11$nQ P+A{!6HA!آ.njCӐMvls0Q"Dd2 V(#[HɰٸG! K]aH+85938o ]eod,1AOˢE+Y'_OH ʲ"PUOYPGvOK[zWoߏ,p4˶wzCd8sxfCտyG߸sorޫd\5Yҍ[wTjEiiqa]g+ 2NS<v?>/~^ʼ >/=_Ldg$´CƈtP@bʪ`JS8q!j!lIw$"Ù7#il!h̵C %ry}I7 9Eͷ;S̢~h>.[k|4$̍ ڲE| aS#9DÆ(6[LK'# >iI+[:lVOZU]S?(  D&v%UEOXZVrUl[YYμ~9f7DDVƸ%r8M Jb8p@PCZM!IU^mVX_UgԷfULIsӧєlWA#5DGi vU,yj-AR 0v 쒺hQ, ](ȉ5Ӟ6$,,γRyA=ɐ(o}3aDj֚|ٺ%}(! DQecB$P#0h=wEhȰhM$vHsoƃxr$J.]5W^tJFZ(XǒU[Ͽ_a_>읿hYǵqkʼ Tjϯ=ܧ[9KJ%C곯>ijA7ݵN{қÆv<.\kFTYﯯ?ߏﶋs{ۆ?z_|#; V* m 2ix@Q^_g?~/ PT׿z9w|63Q67.7wcƒH@Ȑ:lR8L`D$&eQ$HEbdp0W#3@ Uծr0td4U sQ ЍKT6jI)WMM"0V`;KeWm#D\( 5đlc%@BqVj,2$!+OONʮm&/qfi%Iqd Eq͒F/;ؕcmf@:`83.?.ph]qTrz_YZu2^|¤~Յ3g; #*ɩfAVV.y;HjN0 %tD,rL%. ((Pc" 3DlE5kVoY)F9An2d 5y'g h-s2UQJ+FĐ3A_Dss_w_{VN4U$€7x.ʚ[L`4h@5wOdpOJt 4J9MK!d,Ȃf7mTYRI ŎybEiD~#6~ %.v-A-wgCJK)gH= A!AqK,c$#3 D4DH!C@F(J0   9gC%\gpC7t]3jv[p)"Fh@^cceyv(ٶqgB.)TK wΆƖޟſ^,(旈^|!'ʻ{ö\s/{@aẑ; r#~x o}<{=~ui˟{7PYvK+\Rql?/ZT{oG{|w=}%IHػÈ()MD]u"'fZv& xVf I` 芃s1/| "(WVWjh0xL!dD=hAA 9#[)fɭ&`9 "hGQغUzu1\F `%2)+j7cfKm;0tD$LMjQ_Crmd Imr5Wש"Y $^:mt@RrR C 3׮ڸaݶJWs0&IBEy?_qlSrRsS1TH̲YFv7v5A_!xH7V((iAkfj(:) lɭ ;1Q0ԄA}ckkl ,7I3@NNʛ4ۢ'C&/BHպuk=" A``ĉGtQؙX*Glߌ<+E9KQ2$F8rpn)He~H>yw]aG]vz.Pո?牔7-_SO 驎2]n?Gi 7?iou\}fy%#PSO|w壎hz԰~0.|풻yu y.}ꙧ>/{꘾k*ה\qU ¢,\iw_qyjѫ(?3ɽ0ox X[2v96kY\jzˏܛُw/G^֎Z@ _^z]nVbMlgr9<*H@B&`6.`I1ˊz+K nTiC:&|Ϟi8O! 1/vw5 } d`C-=;%9&BP 駟~CB4f&q ! C$mT_'㘾Fi(`K-9iR  32C uqV1"ɲiUa#Xs0,9qgfdfĘ|8tbբU 4b-%=)" ]%@PT= s PsWj9IH# .`j"ID!Cuy+J OK%up3\"-EUM(un#D,o>'ڱxmKWif.秈1ZAYAHC "@ HD1_B"ܠ;;vfMiyNIĩUr55bBzr;̹V-H8RKǖj*-a" #)-u F\a N o f]k+(E8QlOj޼+PU'avre'Xe'pYMIbJsX_`Y8:&Щ%JsNx~cǀeL&Q;˪+^O)?bcm΁l춆i?e*/b8z橉˜vd-NfK3?x,vY'uIA_z57ބOY<;di|y>JqUvݝ7Od+[z6&+ڪƞ+M7~q,Fk3c?\"*|z@$y(m!#"Djǔ4"M:z ne%Z\⡣ɲd!=Òd Q{a"QUu]g0YLM#QUi""2DROU f΍e%{PaˏkB^R &HJLHeM:fdfL\ۢ0! 6 :xHMV{n[Ll7%HvMܟ(a oJ(7z`+0&Oqb&jOȯ;ᅷr} xNy91G!{s-%Loo̸wb6ה=w?]_v_;~1=e嵉걳ƕ>WK9=^f OvwM|/vJtC] AذzeA&Ӛn0h5_'`<%$HU¾-VnRBAٜcƞ \\.1ց- PdX-KD !yZfc 6Ty<6\t W ^.iQ,]fMfsKW_S,6њ$Y"ƀ7Vd@[N~DuREG#!+Tp%ȫ+=-^$&pdN ϻ@mas`G4ެvm+C-侒UТ-APt6;ζǾvQG.OWܣJ@e]MM 5rmzgAѫ5zVlmڸKԸ$njЎhZq1"(+v sm)x0M&bOkl3'%oo:?ث75Ͽu0-ٔ#Tʤa;~_H[lInÖ]8ZPYr}o|071{E'v$bxNy_v=%֨gΆa~d"&|2+CkqO܃xEAW?]ÚGiZEË]4 ),NdDIT{: .doKwWuٹE$$L}ғS̢X;[bR|WBAf5D ì 2@F  >0jO0 0da,_dsT8D O[vmvC@AH$$놢d2f.~}dD@2J7m/ݰS 3Xmq{td{ßL4Rʺ6]npĐ3f!"RbSPA .jY[Uז=kV1N+z;@NVZH]F  [MARUPMw{}ku%ݸKQe YbE[뚆ũu!bMdyl@<Mt.5/[v1vBώ:"S*l4 À39FrȮ*/M}˅G:PQ021=Wlpk6&kTָ^|f~, wXS'()p]+m}KY 3=Ȝwz哯*ȎXl=>VTK!2`kj_(:̒ddD)Wn$v4I0bֳuA2rDbMKYL$ *{Kp\DD,RCB 3<MڧejW_xcW?}{l€OvᚏoܭZ[>BL&M-V3Śa8 6<>6q:~ŝmߞ#AQ M-J@65%D3$I`P1##=i$pN*N:MZS7V)-e:B+Ή "#2(( "v(0wEveJuEצDVbŚp C w2ӎE=8e~\a骭xO Cyqi>9)E7< 7>{e&2rm$;DՏ%bP]4{`~yiMcf7~)Vq%44 $A54ttŮ <8s0 uyIJ`۫ۦ+*G15=܄I$يIYY   H%`dx -Y)d;e-9 ۈ _e׻sWȄcRD"2a/nIJJ$Ѭr qi) @c #"pNs"08:Tَ*vγ dњCu44  RQ9R".r@"08k+Dx94qx בȐaoy"Ĵn vǺLjAwY0+mr$ұÊ;cB/?g==9=q6r-[jvɃ[w>LOuf9 rX:(U #Suw tՌD@<>VOIÖIOOuج+=&Oe|X~,ћPj>?Tv{[3ғ2VKXRRWM]=^srɣ؜i1O4_ra>hAnzq촌4aN[lVsM]@Ccs T65V5YKMϾnacVPUQO~nh)3C$C&\Qm)%9Bf"lG1 seo-6U+l]qATKjUp] .ℂa#"kZVtfjE(Z*AeҕFiLKNOr:!2 "=M;~'KUd ) `ȁ"d"@fEѺ@TQU l^um7J G[!N$vUM7 %dBf_1Xe{KC $OwNUCbfBv^ƧU@1H@B@#ppbJڰ5^ KM]>El"jQIʎ1%( U1!5ڟ1.684K#>ܯ^ڇ$Xz[->eLz+N/z[ˇ.JLf|]5񴐓rŅS &7x~/oW~Ӷ [v߲sm&stA"{O>9tqwPA= 撊e%eU+n_We}}^_gD!K3ÎՇйY|hdnOOOei']'"Q Ѭv/b>![\9DbD`Κ8~$A cMM;n-)"ڟƈ1DLHM7>oAF3--EpMA CЌf?*90MPt&0Dv`0$PY]⒵AaSӕ*m6!Ksd-J;VfNYJOoB Hx1Úh 4L$p vs#1Т*])\ 1F-{ZɢmZ8vi Kf}?;wO /J_.۶ ;szRA.!8 B>ז|tI`(L8kO:hdm/m!0vZZ8%?j̲D )L@2tF )r@e3mfƄb. qh ` iYQ@qi5!H>@ Q@99Iv7}>/{o&&z1^!L:lV5bо\5 O'&$^u:WkyهH9tv'$a SW^t53O3]vS`?C]\s?}cFj׌+p)HI9.;})c${-}˅zù}Pב|y$ ^|2CO1υgN)vo^o0kw6nZUY592&89b r5 We (J?(-3*¹` "4b0 G .4>ENj=S;dh6q4"hv',H&LAD5Cik^-oX =al "0 3RG@2Q6\u#`b;Qu(C 7ڳi{JR^|UAR`aO޹ݠ9R6$,<<$UW$Q" *!E 8a@H .<1_ `u5W0'Z  ݖf IT.mb~UTڍg] TYD9Elo˦jY;~c34dDUȈl }cE"$A:~H˦lݻy^"<,eçb"i/ *2~R ᎝>qB[o/LIL&Y9K;ot`y鿽¼#8Cz5>ߵ׿Jq&=+d:|-w#QLsV'lزLM?k~yG6԰~?}_Oևs _ Lw(7qݲ/?[bw WtL¢gL+3JQSejv޼uߑ!M8iϓd16'{ip ɚLHeu 5ڙ7cXTPdX(T`IIb DB q)}E"~#9D ,gLךaPXxI:!J9)XLTWՠ]Kƞ:VQQDƄb·"Egrʶ-~5`q))VOIdYи!UAQ4]'C5~j3AH5y.^R}.$l)L^$ٔ$I KJ%Yֿ~muABf4r$TOu N6 dlG NM"p{t)@ (2{b6E3یjF Dd"F0ȻA NLQtpps[J<0vp41F'?~.1_kY)xxdE T#7~r0vy']zϾ]ow{e@P藏εK ߙ'9t+=81đSOOٷKQ^{Nzjx}zUNZL;n/wkсdΛ I5imVl޲}}k75{~y<8Rm$dL;F2D>/;蠟|sFZO!s_x{~u]<{mﯯ]+:lRZ(ɂ9+=и>HvUTnURzv!DȣU0yPfڐBw^v9Œ`d{q_3I@@Ʌj#))5)& #cjnh#Oyةc2d_Kju+k}JIb@nfQ" P %sݞiQTB*4OK9q^.8k!JuvStUY=p`s&7{V{(}_sO?zxrVo' 5߱vݥBt WN7˷|;|ݍ-Om<͸ݽ~W4ޥW9& kz Pyeْf% iY9'O3lLNfd;Prceq<vj9TM Dedde3s!={v( $C2j74n-J(]@̚>y :$vn:a+C*3R& ̗%Q$yDA%XU %_~rf}Fv直yǟvBRax :&wYb -jF[ȓ|7=o9oInܫ/9z^/?*LYwNf˷9lL fLOseceM5;Օm7EwW{伾) "0~:֌ZBz@U8qDNyl dgf$~6ɋ,B0d0jK=$"4[lpa)1)O,m;C&;S>y7_Wו ?5'M`v\Dj˺HǾ )3-zDUlPs Z*2P[UjvpO} RxȣlZ|vo ?!"0@q9C`Vns+ۛȥwxડT7haŧK}S6TTUJjnzz\9M;o)2푅Q@d e BϰS:$!$&18Z u7%*P@q7[i5,;<)]huwƕK`?8k_cy!7vY oLv^{QQ(3γ~Co|ىa?GE?}3蒵^zIySagp3Jjsպ/6+W1|_.VDfa?+qҩpds @$A([tq}Rșȑ!2=e9nYWq&,LfGl/3ÇK6;]w4mb4\a&`BqO6GF2h_6fp Z/ 6V&^p=2훷o۰a&9mЕTҐ&PP?UVCjH&rH4n*b@W.)r)O {-Y򇹵;7,"5QC 87PӉ:ߒo5⛍>\ZSh1geͪ*R]kdQ$b@oydDقA!I9L@Q@A"WjaE3wxz?~nlp~+3O=VOX5?OۍmY!P%7L/>}z8^xcvF7ۅUָ{e,mZ~=v c̝748n9[rG)>bai`h࿺>[w=,}s;uuet55~꽧5k-!q` RZ@g SN8aEO2q3fyzvҌKN2 S@u d,Bu@]˛S!E1C.uk*s"$;m$Й\ȓN>(U@kUp3PIp)O#ԉ^[3IOls`Q؏u =5;bԐ :,Q5WMO:hP$Kk@}Iۂ$aRj+|>" UC 0?!_սv͜˾tgM={7e S:Eu.q"а.Qx[-]tCPAVBQ#ڼeC]7[m;/)ٙ$0(-9i' &D Ȅf %K$F AQvAJnue{w hxw[9Ol0ˉ)#dpt  8/'X}+׋~lN =q6r-tcW<Ϟ8JY3R|둇ﺢ5k~^8RѼ\ؽmf'#?t}81+)cٟozُz_,#R[/d3c>rg=ޖWRķVق$ EA1?n,/64ElJ_@aE[o N~PW.9L=04_aK"(I>W`REyU;%+ʛȒd6uSǎ2.(EIcb1x }uOr7CA-&s}BG+vT謓RLB A(KxJɋokmH{QboEs)'Iiv qJ^#gdq_E7l[钅?1+|\=N{Y0L 90xX7UW[d[ ! Sk.pbIP ٷsƒkʶVW\uᅪ0aKvTVֺZT=.:c:>rT:.D: B4tx!@ Aq54UHh!3:\NT-bJ8Wom$Mb C9@N]peӎqڪf^~fۍ7ꢣzw_1fDk˺W_@,vVH3|KŒ_fuO:1FMt_\Rэ :w 5Ξ6nw䦿X[ҍ_6y czU,%5Bw^v⸡G -f\8Q2R/<=٩ac,#=bԹۛ*k~񧍲 NISCI\.Z%dTU77j2cMIvIV.Rءb/vc؀}r5 khyOW78qUQlb(Bdi'Ndݛʽ%EL#,]Dօ:.8%XTd`;5EJ:̅Ybx\Z5_ANLHE5Uߺ~SuM)gO-$D1#5DDd02G#r=AČ,ˠiGYlKem4j"0EL'[n}eɶۛ+@sM;vV[{业<7L7jE'?WH2%Y~{}tWjzWyGv`x=Ux/ģbDZyELI.;J q\HثM1t!#E0aԚQ<{At7w,]VLq5 .ѣGC9EGcb!,)M#6Nle:t j8GSl2Ry\!t8\>ݫ6hd[?+7l^X0ȍd 7#~g{ںU?quN!U)ۇ&5HbҍjD~Ds#FQ AG&!wןvȁ u?VmK#6S22Ygj19Vq(3Gx49 Uj.I֐YY8"E.H3qg?l޵rJb\5tR 4I)L2O;nE IE De&RR҇j[}zO]wݮ%^P34!g P"S9LQr,I62myWzlZck}CYU#C ha9 TLi.tg^F,-z)ʣbah#)V~[kX#~?uv/cFc"t9r/YdM\d˸#GE4|0/B&p0W6wr=~5NXc!z8[g$;l/uŢ(ܽ OA<ۃ0|ڿj  wx^ /- k.9['쵇zJ{~lϯvuG?\yS'J܎ęjB-,T4"3t9H/N-*?'O=`xaŰ@ZJ?@`"%dIJL?Kho߫6(8 e#BM_Tf./I+MYMUy'>bЂAYNQS# QybStDQŞ}S5@ ~oК[ --ʮ+]چ&_Ճ:9piHH #ňj!V;|[rDijhٶb]Jv}@jF{ƌqӧs2YntV[b!3ӝ@t =4?:vXȧ_0x<-\r.]S3#"^q޽)) z_-nn۹ ?2[[7sPT?]Nɫ;x@o [^V-M1ࣗ8֤|7bz}c#x ꟗ8x齲TnVY}9.z:{}Q@bg 9 IsΘe'A%'$9Ҳ &pJpt`Ls@ rMOQ)#&Jتc؟ʊb+Ad2~u;qYa(bP4[.YueI٦7NȞ:Yͭk#" @L)i)#KAk-f)3,#It@A[76~8oxeɓϜy^f^& 7 `ѼEoi>񜓓 %%!3ɉaD5nTrȽ$ (3?Udӷ>ݺ~ ԓL|לs(Jj?}؝"1"pQ$P&< &Af3H ]硠 *>jo66y=->7Xk7mu?80jKoV'[kj*Ȑ$[ίYZ[k4Qjc՝IHĈPeA#Rb;vֺ<>HNN8q 7p`^dpBأ` 2%9)qœis*?ǟp©GPs| ;{a`o='c_~2pHWlbg_8~lw S _Oz(s5O~E_ȱ54?7}k%Nt5q8az ⍿~5w,^ygE]2=qgL{ͥ9!^hYbsuM6Y#)V C7HioTs-;,\y(e.j,f0[2n QI0 N_[Ss?˾srXpfvyW\~iKܴsC2' gv+cY{R DE!,&' s Na:2a[g[m1-ٰùl[ͤ#laM&+]ʾڙ\7ɲ0d*iI鞟FDb0pI{Y)'s GCi͍Ҫǎt)OCKЫ66z*6/]LRFfjaQ3Ŗf&cL`3&rV#!mG&f)ĉ몮CA-P`(W}ߧ׻ )F@T] ]  ( !&;8H I`$v);=o^V-++5_jA@䑺FrB.:n^TCcCQ""Uw^b}iy]uuLrɣƎiZ$p][ 2xpǍ/|׾a-F Ӌg)Ͻ >kx%;l^Y0jXy]<ߋ) znijҸ!qiޫ|z9~EG _ {> o^>1vĀ}C/|ϝ^X-owJYOZ{2FnV~[j?+nV 4?{?wEc߱t}.O1d`H;3s%Nwq%;l9cTK1흾@IΝܥKXqǰᴙޠ.7xu3Îw$ki$zͲRԖ$%UV6f1,Z_oE ::,еJ#BZfk/hijy8‘ꈖu}} 9s/bMѾ%kSIfĈawT]=VA)0ўbjꧾMr9SSRЙ4*71q䋦:R@@9KK)"!q Sxq$5`㦎7IsVTԻ+ƍT[ȈYeaA}(RU=j}J}vi79S6d6fgɶ9dA&1IUH)2JQL&9st8r ]9 5s2 udp9NQuQMf3c IleY`LuN]i9˺%g9I 1!`"ej5VҡdM{i츿#uCո+l*YWVCn;"{̸8$7(dbȉa0ƀ֖c6^$dBL oo7)W?ޫ:^=q6rG ~!5:퐖b{f}?{HE̘pUgAu]Sd}0u SwnغtWMM{n(?ۚn]7_2O^y`o^h8{ڸĬJCq5n{w_s<(NTQӴb>Жl $.`9! 笆Du܈"FY2I!U*dywYPn01$ NKڵ{ cH00.ɇKjER{2EYS\x W^峯ٽx`h8glDD"H;W+c`|ik%0)w[Ywu+.dBWE/r 3OyZvq3 K0&@:u]`Cd2cGˈ)3׭[l}ɺme6!RVVGSlD4X74~_omuWnY4ӒfOKONIBdXK FIv6ŽbԸH@ QI1;&:kQSpIPUU鴎Q{wmMMCye͐~mFlEl =31\5lKut$ ku ZtCL?h#-*藖f5[E }yD܉YSOÎBRL .d\Ͽy7'?;~ʭ;vB^v|Y&~!dFoHlaE|>r#Ի|S/~-n*6s#SwM8 DrNb[w3zxD1rh߿?poRM= BQx>C r?;oҪ_|f?|9jhdJ(j& "Hc="D1H4 ] PB$[|ڐ`Cr9)66p BF'}Koz^^)'ڒI<, )˂Ӓ%kw{|W `J='SbrA#C!V޸o\+eN=ZVl,HgzW0˱jp@O&8 &b*s}Fff9  0C lS>pd֯TWq{yPE` Ѓ8#2Z !:8j((nx+._SwǶ+!̔[Q gnaNzfr$$#Iٝ:oGiV))=l#Ӯ02d -ܼ'nͥ;S s2a ~;]STiSCQq$CQ'tM;3O;0=O=,X &{.XHݖ=^ʻ㑍Ѽ"7+MNc1w|ٷK+닎mz&Yzyug=wE"m*pF'rLm&Ob{y6 eg']zѩ?,S6XUM*l|~EDPCNG٬EE$ebᢇD 0$Ni,ˡ""7y~A4Et #$ c̈ͧWcNE^esF] <.׺%`<~$ Hf cpK%n7H j4A 4}`Sfuܚdrٝ)LGFvJZb3IfQ…3 0ƺH<#c(E$FrX5CPԴ4#ĭrq_Y@ 9DŮ?1:`@*-^tWUeKu{syu,"gQNbSSe $IA7{;C ի)4'M8S8 ,9=?w>ɗymI6VɨXL~´9*1iVM0L1JN:kElr8HffIY%K&fn0] Id:iX0D*sUW7{=aWÚ 7]}FRʏ2~7U~SSGON;n]5׭olaѩ*q;9_@gR;%`Bp-ԏUvu zk9G(~ꅜg5yˊ'C?;ޱ~ qIgOj g'pqujB~sZ;7o?Y_O ڷo~3s_|1RB<=>T{#8Bv~?<:z}k;-n#J)|6粙@HTLŒ*ˀ%0BNuNe/#N%qfY/2Ba(B2j+^7`\Hr/1xݑsZƔmOyV@#EBBɼwKcO}#[vmTݫ~Ǐ ?ylhxԢp5U5`g1L Z4KT>f%ǿqk}< h۲':w?~p԰W*/WY W*N iAVdlb{dGi1ΞƇnk h#X#zntEQ&%eG-5cćN7x`!^nmj 667xn-p󧻩!悌IҼr Fl;83˶eD<]L9ϦS3Rqsi>= 55GZ}MqID)mXaN)0@ Q@WWˆ kuˢ5*ۧǟ}s*.XH2ڶӿ?fG[u޷jj [D s%QfBXGˤuEі,Kt. % 1dL_d UyK i!8eP  2HS*cTҫp*bBͥDA˙t֙}#d:R ep{{/r۽~m|>*U,v4Gw޴kq6vצF ǧrÜ 1@: erxTu75m˦Www 5DIX.˭m8C C|ghniF+}X*⇕  ! A̔EW(/(掀mjUK& A]a|QW%0? G'::/[4 P:,ch*RGO/Kmz[^+5zꒈ"gg b$"I~qGqqaXBT4s9=*b\:HMΟ<4G^vGHVeU$""M߸f@], ODBhD1VX<)ZW+mK42I d CCtTf Iwl0dhS:-]`xEޭ6ْ$ֲi;8oDSz[OKF-nܳi0#s}qc:W|'|dY7k 򝇟eB/=xA>[>^kCYm詅<|b{vg=  X ,~Wgo%czhq;nӵnukocˠ2b H,ĦH 9`il0F9(pWC#ƹ,6iq¿d[O G0$,WI !8w={Gڻ?~8 6@D$0j-kZWe+K^̕j纮d[c,ɪ4*kDAWo9cfd{#%g{ںMvI'Lbrl\NX_S"1B !ZJ8B0QkEA~׆G'!*-;Wrdi#+*2T:]*!ףu4 3 lDl 8{6&˵|~=|a[^4K}>[y0-+XW?}Ec5 t{ꋿKlg|f a;j_qB6<\=²$E!z:~j D}kkGCR{N ̃AW?|˯$& 䮛6UR C9gaqıêB0!1 qXȑ aɥK;bpʱneGĤI#B@6_% Oo}s۷!,* A B(C#P38DŽTT@#xy\ B.r%?yj+G~ekco3VH|!?%hPgs9 *AwsN䓰NbD6\sm]BaQ@Lt|6ӄNkn[`;-Z2BA_J9=/,.eBY˱xvl( HM3m-M I!#DɜX#"!I_ 5F9uiPhKrR&_-%3N9/GsinyZ! &!`e 7:ztptlz՘ ʪ4?u WK"#K0OG2ONsRlq664|>v6ʦ93 !9T! hŊB3:`~f?.ؤףs<-ԯguG|sB"7_Z"l\yu.Pїkۿ~g}q05xV6%;o᧵ԟ.&2;~2qۚEN>Ol- >܇|/ݛj>G￾.( +\MxKBՇ'd&pMB J3U+1*EUΌ j93+(f4,>#pވag z3+ 0n9P\u]hǞ[6TUT-Kbe1D$*fU"眢B0rr䉹i+n JPFZ~!sd ` ]VHO +UEB#l ntbT饄˗3)=rb$+vl?z|txd^YQ0GR`39p^+)!0l6 ;e R2Kl1+d8%4H8ͭP*" ́Ti\FWp)p ne!Ne9F S/YżRf&c''uPUMsAO[gcWOk \n Ǣ(hlu7''(`L#qdVtl}JKWPb fbǎ LN.d%Ӵ󺽭--MQf. L8;U[A#q; "Qk.{jx5 T;k}{/h|P-7wܴTsymMKkꅐ#ܹFEǞ9p}b:68:[]g?qW}stFە? >Bǰylr]u=\<&,sk8/C9?_L.8m h$Q21(/syA%qoP#2%KmCDW cR /[ ;q-WT*6g@!{[ZF~_}Gmضkw!L"H +MMB2+wpeۚhKX v4eHj^!xޤ-Y1aဉ U5M& @8ccۦIms Ih]W߲mUKKW UJ~Ϟ~-ɉx4Y֢ϥu5(JcfY2Y=Islq)l0f=%y T P[[W8kio =KuI dU-vrBa *!խFE&˹L))leKgb^)@cS8hno.%sB٩@ \=[6U*ş0T2b#c3cSN&Wz"iʲCrz {_ʥSp1i `btB3CoxwMveLޑ]y>`-,$B_yB>Z"6?|uasɡZ"|7:.VcwVR3Fg?jR?}zjFx2ד^=?Пz>K,)?Xh5fڵ/8ɅLz) ;BhWADQJX"Y孲=68.x2fYHY!DIDѣ)^M zMPc 64PL2t\(Jf`$ӹl:_JsB+LִL&ˢ(*DsaoKK)nmo݊"TIYղ#T)dUUMm~F9aŢQT!+D:YJgn*%"i_|.k[&_.~(7yp;տ2@R\.#Gfg㉥tT21U}@$P&xΙpL<"TF'O.It`h1 #Mhs(a,́#~Re &2_8-;z9G8?naq5*U.+j#n/:. za߉Z"c<?{V^yCcKCK{*K%"J(T橩T8ղMq۰ llnaj|zi)562:9680R}}WlhiDEMAa +2wjwq^)C|a>}~U0Ba,D`q,z^C@@o芄,K$54FoM[Ϫ`EtB!Q L\.um߮58L./O,b/ qF$ˊ4- h 4vab%(VI7ϕb|)b._2mfEAE&{<#H [[#ᰧ5r{dYj$ʗ 99WmMND,*//cQǢRhe3R/|zjv.Den9(/0g.As1 STa``|`p|fv1H[B@$ z`󩪨J~_dđn0%ٺ}RL|)Lrxr,$U].W }1;K"qD+nR2˻LƎť/|?ǵDӿyB>1V#-ukgϼx{?u\B͚j3y}!xˇK{ x3vuys}ZYzoڳٗk/jogБ 8p"b Ԩ Zeꍍ?~R.DAzhi&xX7CD< Ri"|,fV7o khT8;EU5(FPBA%u[HI"08#ة#5Rr-Gg}^y) {ξ?az E XMZ)8 @1_/rrN$džG2t>K-ˆpx7m\ݶkm;a,(7<258<166L"("nnjviH STsG眻ݚ͝B&/8@q}/nau+_(Kz|)OsECqۘm(dE] _cӴaK_wOԥaQ[_}l>3_g!8v:][}jۯ/:.!yԏ\$>xP/ye2^;i}WIP_w?QKG(ejS:ތB"fwwg}WճE; 0p1"YKʵwn~;wtxhbphdj>f勔 \8/8"@ qquM{"`LU/sm&p1d%sC@.;*$0B́T q ] ;vOMΏNMMOM 3 jr)pR'h96VeIJbB>KQFg=]=}koh\y4 gKkŲ ,[S:0& AAbHKDAlmml !nsD XEB1SJ)`B ؉񑅥D)Jc  "ݫZDιe\!% R)u'Fćw}og̢Բ)D0e'e:e(˅^,|*I$3lT2)Jm($j*xUQ~_FP ZBP! jJ8p+!Տ}/HAe-"-Q.1_B8?\1`Q+MM,f2Y0*)~? y]^qi"+,#dIR۲}>b.;02:61T˅9"DTbS6-Lݤɳ|^J& r*S\ZʎR\Zێluumʱ^P{loɇ7#!_=j\= s)Czeo;kvf᱉t8;>mRdbs,ݳ[7nqc+ g^9p9($ACÀoU+oV6wsq @-T3+xC:zH|2%oG5~M kwӳb'gcɅRf).KFQ]e6c6 ،reQ,[۲niي30_&EHK K&N2U\9Pk-ϑW *Lz:O -hiaL3$Z%A(3%oL;r^I,ӡ` A{5{SoG4R,Zt~*\BQ5>UiF5v6L;_3BX\22<>9T2GD&-Z&Lf6aHRW 2fdr|/Kr"JeK7B޶#I$YtRݑh0D"acK%{4*pl9ե ]R|\ְTڽ5,JHSeMgblzlf1Hd9˶g Dr2789~W_wMѲIDDj<̚-KbV=?O>XĻ⯾S[5͇m[m9eϽk:Rvh]˄{]U /Ff6,T鈄|^$ܽ!p|ׯըy KO>W.YȷAIY-1G!ʁ%El6uBE*J'_;AWsߧ.0@ q@A&"9gQ8bRR鯾ϰn.ImYMF3A(Cɲb2̫TJC͈f+VB:Y2UF蔇 ,;= cCS6Q.륒˕eTSKe 2 `0 K%PQAEXW$HsH%UU]UeB82в/{Ǘ A@Ny}Wrb|F_(;""1, !9UwLs X}g{}n{[ݽMkvy.<$p8!("MWo,BLB1*B~${}bFG={T.Ws0. ,K֨ gRDmDpnm9m[ulD)FL`.JeSCFX*뺭VTeL._(\/bĸ Y.ͣBasSeE%RvDjz~ufR+h`[[H`x||qi/qQƐ?jc(Wr3)qEUQdEi.821>>5H2yggӞ6t 8bF8T\ө"aM`2c?{q^$|Í+g=.5B2G7_8˿'?sů<ݯb|LM7<[^:^ g׺:S%ĉIݰjpi^XHôѾirݶJϹGcg=wo?|<ު_}/|_{zF|G|@ P[q쒣D͢(c<~֛+@jU*،#DJ@He_x/ԿX8,pąD˸h aBR-be[%W$2J$,I<!Ԧ!˴1`4q(q1 ,R_ƈWOS 敽=|FMˮDZܸ9Gxp=I7zp<=1gW߸F:.2. YeW:F!~ 8 &s?zxNp]7|h{X$JqYȽJ3iraR\<3>:~-%ʷߒg4U12%̃ DE!88.K&Z`IN/g7ffʟA@DJq gTMJa(GH'Mm-[Qtpl(` W=d72H21F&ضÝ}_OO;B|jbb~va~v!HO򅼀WryPYHb$I!+thC=mp%#`J)z(gq`hbbX*+; ~_iZ8vlx=qLL(nM5M$)MO.Mb|A&BwSU;:,rzq5A@(ǧv{|两 Έ?d8rByrԽ&4j!zgꗴ:.ȍ@g>oߞsJٗ}]3l+XA>ohXSo?5wc׎[y8r9Xd--5qja!G'2bg=H_w{R?s7]EH=wb2QAb /!N9j9,qj<,U>`Q n Xm9+薕Oˋ|֫ '#vȩJ C!WŠen x$aaU6@ 0p L]&W~*Vr#\I8F)3եSI DC?;nz-: z.sLN˪[yUsnUtj.hhpUqۢ D8Dbf!>14:O-:LEW}5p0 \6 `|:/M9M3=s @wW[ }KxUM ۡaS,UȗRh aةL$q ,wj +H> .8f 1qۦ"Ɓ9ȶiвng2>_4t`z7^OSUC7tlXee̕la)M BX,f&f\ ĥ^S~흍Ku)K&^nf&3L b#G;9˖ a h(֦V4UQqI08\"L%$6mXݳ~}oKssKg0R=/5z4"0T zB Dn|`f|`(]kw5gf:.U-R΀[jb!G,d _`!>}GZC4cZ"lZu5[.ڀ||ΛKaMg}yq 1<>W{vv\cm-,K_]PW{CS4P_ 5bϮ_õ0CsWXS{w߲s篾nC}豽]uW_{zpG{1ė}y82dZG\3lTx"S?'6m^禝0{M38+B!!WlN;~vR1 u/BUˏ4#؉ [VzF"S*p;񹱙+oZ xbo vAb O566ns9"Jh&_r  Ks'sxTFew]{+AYSn5ZǦ:kR4?K/%''b 9f3YbpdwyYK[jY7ObHSSnrunvnlk'rEL pEqxϼ>;O$l^\Zώ5{@Be`8#2)CW20\tgl?|*vټjS wb9g 8˭coh>EU?KFyٲi`CóhzA;%ԉ00lnj1,xa*9ۘ=G8C 9,2@!FS +QD Ԑ5n{ߍۯۮ*" qCKsqv].Mellaibb,606:'sj[Lnui.Áp$nlhy<ߧ` 19gs ^gLO2[́`Bb^#LAbv`a؉ڠřX'guƀwWWM֯dd.)%ô KT&.Qj #(kxa_1j(dyf3 ܡz٠D7z Dp=@ `JDD:6`%QO,Nn ]k6x^B1p+KɂF0bS#s|8omذYy&'B QP[- - ^\@_^SD2WWESOR*w'sϼtt-ښss,拨_YGg}\Lg +{aܥg!GkQ[Gar&vi|Bȿ|?\\,<'!:r5Wljսq9@#)|s9e[ !9_ PQ-(ɒ(u %Ilۦ^]O峦c*fUE>[k"D6q\~!ݚOT0p,E0;y?HB6"SL,e!0p\yY|1g1%Ob\W#w߲~aB)=߮+#Z !?{.rOdMudvv5Bv5\o5]{^wJCsK9x2jcKqm"p|">qr.9KO<(W~eg_[(s,_"ZF}m_aMo[-,%ff5FXS_Ni-N^ GGk-osɋ|=?~ϖ Wm۲{MoǭEƙEqUƑW8Ba >wR'&skJcjd"A0" YlD"iJئxF&G2.tQݘ8%r[Q&UJ1Sl֭ͦDx'rg%N}Q9B0sD&GO:,!k.?0v'/&$Alno' ?zc$#SnXm QDڒ]%T2o[B7.srr6]J'Ne0l&eB6Mr`ƣкn@-3653;30[.gf[ŽA Iz׭]a/k+%qIXB󅾮Z6]X_m-|p[_g-v`ǒCZz s{;ZvmݺgK5^Eb!OY08 vT rfמ:x|NtC/n^80 EnaȶsI ScV*S FGOqMQEز%1v(u,X+{ݛ*.+&6 i!w{ 3#(dm*ъ⮕_dIN+okܱ?zg?~6v|--CLX44Y~ն[#fc^oyDoLcLd-K;)JyTĄLbQ#cu]mMp\O~;/]fL?T*--ͳ33xnQNo&զ@B"*BvvR6 X( t2'gfbsDܲqZxUGD% z4Mx\, ROgt*+ڱmU9o}n٥SX(^jUMƋ MvY}l(aM&G"5%ʧJxT-8~Ԯ޾E (Ȳ,F>5-yݰ1&~s" Y9CQ8&X%"Icc5S#ӓlF@9Zӱ5+p&CL/J@T]08#EKzSmi[)Ra,*h)}rh~ò<>38_Wl]U 9T*.M_@ܧoX29_|ǟqB?}(|w_/l^k"^B⬎7lKd/kd{k*BQ/릦LOxϯёs>sO[۾wvjǚH ނ sgaaڻ">:sk/䲩5;_y)e\.*`P1[a DD3#(bbj.15mmVc"8۶ T^|5ěx9qR Un7_6ǮUC ub)@᧣z8^pvW7n>px*>_}#l<6%ߺH2|]fӪ[ﹱoK/Ɋ7s46b )/Hp8ҋm\٤qPf)N|g[Q_I-̻Z]$Acw?puo}[mƩGsjwѽzbD5-h7~ Ae B!۲JR1Wz>bKt,/ƦgST"cQDAPY$U#IMFWx]&bg< rlÃI˱.OKiڮ :<#$-OF3Y1LGI6ɩr\X.e}8-m{K MW~hѨ_A y:Lb @Qt"@$QJu’H"r$g󹁡R܉{zvl\ xdYp0L|Q\~YqX0 0A TV8cB&^<6֭^mu(s ==3UJcE;2/>6KGgU~+ [HSWl[Gϝ 1-Nk[7?za!Go|&|vh->k[Ĉ~OKd{: =K; k~gRK\Gk<#~'{/A~ʭooW]뮹buWm B^32~#@T/-Ơko$0- #siuHu;"/q) "Q S17@g--qm=:D0JON/~PՂsl]zC; Hxk?mͥJ|$h$:Z\_o3ǂGO~ૉǒ 9|WlܽG +juUtw+g m/ 7d(3&-fTa0&*(ظt4sElqb:>6=vt( @j'2(g7}kC|5L=l63u7]nm→2 ˶dEr{]˶9#p@oܹɪ"r0)۲M,劅\+ͥSD<8˧ R&%l2IȒ>-Hss84G[ED9wb;nukwwGcb>MA6E 憟M+fi.7[סEps-0kU v`Ə-})PSU#UEPّ2eܲQVι0d1@10 !P\34Vuimk(b+3 s@_TBE!T= 8#514쵡R:z[85Sf`: TZpΩR{hxq1# •ny-VY_L0|Ŷ5Fxu }OOuB+k$=iqOfk||!ײy6_ KUK5k!th*u K:n/_>`lralr[?x!vkp ~1˜P+>@Hp=+}[Od&+Tr*ܬoYr(N0#uK7$AeCC`YG^Yi C318!bBd]KEzP!D\HU*ѷ\ѩiL/KVrXy?p~0eHo=Krࠒ+oI^<1K?k qlVC.p&oKS8A``bچ2nV.Q>Rѐ$Sjs 1%#R.[;~h(1*Ѷh:YBg1 Ͼљ)SXD*h)Hڋ>޾{6-N%9ghn^5orr0+ܮL2Y.}ߕ\e&SS_I$Y< sZ_.L(dZ/Kd,r`jz:LeJtAGŔ@"4V.9 :;"PTU/\J9DmXT (UUX24ɧR@^BTڰ*tmQSWZ?32ؽɜ*MbjJ K%4E' C-`ǡA9ᘈD˶K35=72358ɦE``꾭5|.I"Wm"s_GToDQL*|ٳ $j{O&E׿:$rb*c@+R!lSdC[ps5WOwo~_Pǖ ݲ$}yHVES4ws0>{?t5tr-nvƵK\-fVus:.9%e;!Hw_)"Z:|ssg!k;3~]cػh##͟j| wrErc!g!2 !t""eđ7}u[{ /]H&saLC E:Y4U%]Gz T(b 2gĎ.$^<8`ێ@8ԡQrRrrԲAj&T-y`sGOS!Nl%X,XcCۯ܂0ViF_)x0w?Ok'"\{9;%Gv}ͩLQ:}^ ka!3b=ڰk#|R]/#O{}$z֏=pӝ7D[/s΁Dƀ13He& - 6"1o64uq$OL.J, T`AR$mi[iv12qA !%/l[&fZ 0l۲lj3˖ 9:5854Ep*>U/9w(2 ?;N$bXUu%E0\O:se?;}H;yV69[i]޲Q9„`qnѩss0PLvZ*qۂ6fCMlJer֑Lz`N2dž&晣7['gt۱ާ< 'X|q~nH^{hv~va~q)t{yW -]y[w-M@ K;[Rd09 y^ C,$G $Auk~ڛvL%KKs3xbqi||rrj*L ˦DU=LKuƜ xLǎ46l\il܊e!R+9MA-Ĩ/&D:[!coϖSsz[vn]@E)"2ƫ+#"cIRmfbb*[,MkzۚG1l3L%MF< ʊW"",SxE1Lu\, &D^u}nWĂQZ<8FEb>tlb.?}@(͏޾aMG}^Z!wl "*4U M!@PUHG`֎`LFlQ/U7dDLA9nXt )0Cw@lQTR-Ӷ) yC/gbF L,QʱSt!rn,(u2@ !L!gQ gnڎeێDTٹ؜s #bnM0B!N*߆c18t*e 13M9$>Y:~?{CX2(ԡa:>|6) ظ^ȧJzi ӯ;sW^8;!6eDtܲebpb`htx>dT׍^1* '{n7nʩr2#c{> -L=? ]]7zf ц(N*_tN#@$WnbQKRb!83;;=53?=87% !Q4YP9rb ~p$y5٣n8e0TZ[:떕z=u579w%0g"Lcc,ƐŲaN/$N ML%3REiê=mP#J2 [kd {"^_ (@P~\^~s%/?460 i^Riit1A#r9BaiSSsȒwt]gbK^nk_|.E*SayP߹+!|W0y]ӽP y* 3nk[6Ekdh{<[H*:xr@|;x392= Dp\hdc!W2%%)"MszH6%`B!؎,<6X̶v)pB8[0x^trp!ʆ) `qUyނ[7T +;9L/AVAY7)D<3;|̺uE)$PF#bN288fFLs,`ũ XqǟMmSLJ=q!Y$۲}?}w"AO(S RONf[WyǙZ0lmܙǎmھFÉؑ_}5$]ÑHSKS  aP0AL[-sƚǣy\PSW)\6IgcssӓSssBܚ' UlL\vlm-MnM<!nj4D a[e(AF_T*:\y( EHaĸwhxlB,Y EWu4mYjmo[scu BGs/ò$M$BxYY@C@8L -<<˕흽1rs奌QF:,{W/ եmZ ]8bW{Wl]uk99>5lk_pj~=Ka/j Ͼ|d-["}-k|NzPΝI |C,d.\NkB{gՇfމT{`!J-A8Mu]08C"39*+: jhF8]kU,tV%U)cm9$=0҅խRI/9 !n+jَnYŲ)dp.Ѕ٩~(bN7 -H8¦dsTr~*[[ʲ]*K1 #,mSS1@aȖTD$Ge"B0|&ky&/\hsL)瘝9y$ ^12e'15M#5A8s:A6ENs9}q!?px.ŧya Ec2kG~~vd0 QT1#MQ9|?ſblr}׽. G a93@<.Wʊ3H( ,zn ; xǽ::lz2BPݒ0AmAE'(ҡ |%&KR!r(GutCRXr;Ϥ9ÖA5U4 [ӈi[,JG(Gs`Y%32K@ԅ z9ġ@!:^ [6hE\mFX/c[0SռtG۶cG"9eal-JP0,RC_:g65@  y "W7;=%R{-TrvfV׋cۖhNjX"Զ ml {Bpu}<&~7Fl0o޴iUl`"WwrOz#<④''l?18Gk>.a-lQ$5\ǥ\S{0G50._-Buj-xu=U cc}'=ӧ(Gsiȏ/?7ڱB^+gT|ieC"<ꍇd˹c'CM>E+#DmSܢ0<13<~\j&&U(g LIT.pa;zLgD,?zxlIYq i65Q6.Y[/sYVe R@ *ds Pm(!`f3sz$ "+eG&TIsv{ ,QrE=s5lMD*fۆxֳ#ua<3Y냷]}:?2 _Ůpdvbfzp_~Sѥ醞ɧ@R?x$?5kN/6RO 3\>HP,w~+t O=3> {{.Jb3 x>ύJ*b2Nd,c'Z#qS?yjjq>Xgt`[%,+mMݝQyuߪԼ͸Mp(ށS,'gNJ$}Z(mj {>aeJ/8H ><1ȉቅ%Hлvuu#* zXs|Լ[q->߾V&gb/;qMۗ:.(ka!9??k|ќH|Uȋٙ{Kٹ^R(.2.gV3/h*jm{NLO-hnұ*qI|2eZ6B{\ (e's7FaM_gNf x2[Jr31 i>B604} uhh۸Q i:$ $J,y=]rkSxU[?xBq}Sm{G?{utbb{>A !I>{.mk oqi19窖zyGRvuV:Ύ??>0G_~W&cƉ||Bt}(bBPURn"CU.E;;[X}嵫9PL/}S ҼfCJK9*sn`"R2c U ^‰Q2@&͊]2ƏϞvzbR(0m0G!n:.Yf1_S"0#ԡC)Gq*JnQF e#9W*.flz܄SdFQ27Sƌ/-`EʤR|fۅe| B'e("?6yϼϿ)ۼW8 h=;voY/d:20v|sK?~290>iF:l<_*RXD&hD/}ݛn{{x@s^V6Xw3sdeꪯu-6ڴޯ3|NTGsA_ yZw=0RU=hu?_M&b8D A#ze;Ʀ@ ޟ_zٞ83\d*dq|!nynb1Đ01^XD h>%JnL(^*XP=62l(r(`H @傮JX$v=֤҅PSY"#9fqLE0bɁcWvND`lJ%iڼl3MT,1H㽫:)R!c Y2ԑх¼mđWw-lE(bje}_WmmX~rlhԡͦ>d⁒;"ޙqh-;}h+=3{_;(ne>^ŒK▔| ɟD||M;7cLAC>s6ܺ*/>C Mq˥_د}uו{zY1Zfv c3o6frlb;ـ;!f˖,YFff?zFl%[hfvN{ittvu˗ɴǵv>P]Sw]rFIdx|`6|pءg}rl(XgNQj&(j""# qF!l&jF]KI%nS"|&u鹙lJJ|N39N%D2H$X:AaFS[s9f#P ,K&0X[- 0'۴߻}yґPlm}lS[+Eȼ5&Ea^Nëvb@tيUWQGP,Ȓa(2 kJ* ,V`{wPU/*(L*C{QcQ"" (` 8X!*ia( )MY6Qe9HJcEEX"!YQKt"˚NP*#:dҔԱC/x"#Ee:*Q]HFtX9n]%U8aE6t)gbFQZV$)@4@ FNN_iIq 7h,-9S3O?(1bEybI+dZ(R执i+2 bD8`2ֵKc=v WћVo\)* Svɀi\մb2EU@i#mqY^ajOEfz\}j4d29<>!vDgp_k82H8-rHw]曗WUqF#kGڿu=C۞ZRQVU_ˆQ`dK;gy[",͊{"a8``y#XDwYpwx`$rHHJGv3X `bKRS]SUU4 85<0bp`v2`b&c+\ht1k/il&³L,+:qRBp4)>>8<K_ZWSYRXW]TZrLtA x"Li-pzc K+Vi%=x%?ؒǙ?~k΢7WT'=~ D:W]g!sOĒ]>"4v$޳Q(c5[g<޳lwtѝ7]>ؽP־ݫ4&WO??,`!)(!y $Cl " NÝ~ŲŔa;.Z*OT*Tj5YF_eĩdlm#?0VRC]{0"OE֞D!T\0&dUVQjc*#ބuk*JKL"'{mfhK蚮N&3"°032 MT%nXrkDϤ%A(7 ښڦڲʒVMyѺ+|UGյI9#ǢѡT4(`l0Bd~_5]Ep%Xɡa fuٱ+oZ/oZ.~a`D6 ;*+8[6^اI +9 3 \⪫/*(2X̛sXMGE8yvh4>1=Tɼ}^|c/>KU0O ص*y"(%8ڻ}w{geYaM E`{5ŀ7tx<0fDrWe+.L` ,^)uj*(T U"ivI q4 "0P [ɛh8e>oH (BV%&8MO1b[ZZ[bs ČOj2/B E4Tgv8-)p5VUWحfsL2!1 #*2 @/詴br:7_# 9ɗ?rK<(pu/|h˷|i=ӻi9n9n1s<K7wfUS9Ӆ =@(/ӈ@N|w8P^-/f%SiHю֎񳐔4߾osld!)PX0 ~ 6./,ݡY5Hr&*  KOܻCJ+.W,o~y/LEߙIncdt|zddj[ID ,|`d<P@SpG[Һt"Juxщ0@4Q(If_%Id ר.M 2tGćo,-ǙƷ[><~ۧ|S\"XZ}>yݶ\,XnxkwpbQ0g!ORi)rP౟,dg=[Kym0ŋ/h?eElh4 bY@as?=7'wMhd2ɤڟ);wt O76Ueecp:mup N3ɘ&G+,wt`4ESJ)fEjFFuV ꬱr[U%LI|n} UuBt(D)tb!p) 0@)40846=w582deAISTMu*T$297!QY)lq{?=71Fd%N!Q`DKq/x-K>\dO몎1jU+/޸(8x6oۥ #DJ(UbjlnD:IB q&OO'br(:4]Lfт(E,``8(Pj*uB4P$)FA 9 "fy@ _>09uwm"*j MG"jRB: Y蔛 $>;46IlV{]sesmUIddMF pxgݬ^A{pպǞݓK> WYX3_-g>cA2 lc4Z< ǻ`8Ƴ6By0sF5pȋ{%=<ܵSVJ/$Ouwv nwgd$ugl=QJs'''Q ' 0@LTYZtH,y7a%Byֳf!ct$̷n˖#s593\67DA8whl&_ c9ijp]Ox2}hct9ԝc"ܶʏ߿|w;T4"ED)` }8% :HL&ޮCә$Ew:VnaVm m/AJJdY'a-185A654ONph:! !D3փG'fXR4]'Ѱ?3҉! 6)MMJL2SX+^$RNw޺^^\2&gF $&`8`DK,`\-*1:IFKFӉ>SrR?8! 7mh ؽn4ɲcRL5.\];?:i;jSOw羃=UU%gQǾ8Y_Wby_ZQ_ğ2Bo}}u<5-t*.ɚOLLݹ?02{(4լliv YCY$=3bd?r煗_hw[&"n{*rL:~bDB0P('SJ*?XXQUR2M%K|3 L+ PpQ@p|}' !Q꬜'ڧ tTp45g%ew(H\j{sh, !%ƺRi28t]xF ,*N>tq\"9U\yъBL0 =owuyQM߯~hj~'!7gӘL>0ά^V/<޵έr`d*Ӆ\6?!s?y%Xf s?sGb3ҿ+V+ܾ4!+7p7Rmt:zVh9bd{$l۽bmsCS"Tdi x\.Nd<5-&N`YL@َ#c>? UjY7@VcHH?hjlsYvNA PN\[_7ru30>.r&dbP<5FDHw|cXj%:Ef,nj,MꈪP,:2 ?PJl0̂e/,t9;aJ}J/\¸: !*/HXnO8T5LeӳGY3=`5e69mPjMI'c`2\[}^WZ0 y2"Yr3 .Ტ_iR+.8?= f驑D:dRrjld$0(x-}\~?qGtZj`0_q=w &մq%EOOO=cV\iu07ȝ/\gsX(GtIiX!@1})HeԶq``>T$ D91gfBmGi0P4]ve,Evro^-xM0%j`mHxNSfY:Aӑ) \q=I AL8. lqk*Kʊ  "hca %Kjg;8uCJKy>xuz"3nI#ׯ:}bF.מmƝ5Elo.ྡ|Os٠\@uJXN!j+9,l"U_xȟԖI9so߳bIuKSy3B t 1|o0l f(F D2 |HhZZ[bI}QbG e < p|5tk*Y#*J)=}vW}g,n]7_?o1Нly9p,w_W02>ӻzŧwf??3<~ןm οk8_y[wp2i9³탹l^_]rgH@<-86Zr*Z+^dJ;us#g2t?>/-P/$"1)b("3q[g(4P?-+v5\s{0` _),YQvCK2.s=hߑ!YN v*c.q#C r ( X-lra b9sQaS}}sDt]WD9\nb  jUWt&TRL<Ir$d2)$`E E`b P0 XDmWY G T8'B!)-!MdMѰB崤Դ`Yo2R$2!uk5F{NYZT\\[U[QXu{e\li(eucH<0QG8&6Wk8_7W X/?3 ݺlͪږZ ("t \:xw9851X噣CDhuǎu8X{HXdyu%}cmUo.^___]Xj`<`c8 UY&D̿⍷MPBUJ}vb}&³` צ#(35:psщ,>9<̗TWddKFF'**U)IiXify(yt2',,ńtϑ!Ly}eՒ ۤIh&"  H(BP` =58>=KvgcUQeRDDl9 [Wh(^ RJhxOcl ߿z{`!3qſ/sEG>zM^?_iErp͗}cb8C]㰚dzqk~s5ML戩P.'m3|Vpg>|-h<5.d}>sf^^`fh_=/ߞg! X踢t^^nb` N@'?K46W|wNuioM)Kee06[͵MU U d g:tOv{m뮽Mu-Vb@ %rȌز꺺+99;*<D)nSz媘.S]MFRf" .ne2FJA@BS`@@TUTEN/t>ʊT%@㫀Dg`s l;2vwcs K*J*<MTԄ` ;F31x6k,E,'<1dawVU%-O>kN(ݞr^_U@mCH4Mv xq˖5]5MCobvعc%W\.p"/ U'nM,n絗]r*]ׇs?8576=ǟc2) to{X0`t[}eS~)Szfn˗LB!Pec;6o/qۛj|F֔Fthm"0E(n?xkpt<15c_~ +3qwhXg0Z&c*99> ۺh/b؂9x5 ),lxx2O;,5KJ|>$ <E098'Jg&j8NSf4ח_iً>/,8sx}ow]ӵKɔ?>5 >09MX>y#{u8ΩG, sP_u>J<㽆Mk7mn?փ7#b F"tG&ffF|}K6-fd-/ 7]V ED" ƇƦƧ{{z-Ss=b`6 ض,f/uFDbĉ`{fu_qU|:^T"gC;ymMndҙtV" Lʨ}caDlYM\et\zx jqLLGc .j+α/=p~D&#p˅B#yISLf4. D 'PDd.msٗmh)) O&5F")E+:b=CmCc`#(kj*3F1&^@0|[4g6SZ/h.7r|sǁ־\69\OBd0w;zt|N(ɧ1s-M?;oog sf!N030߬#GƵJRM5hx.wk˧YzY*Հ!DΚjEVhǡ;ev.V-/IIcTՐ[XK", z:Bcl2#BL3LxA@[@P # ta@xC)ETJ$L"E0 CY{b@Qlc9QF k"ScbjyjK9U[j.qf(1 :quBoiiIMMEmSjjJF9>>=ne.4M5>4UVX;>p5ZV 脾'Bl+t78-@`"1Yk=t1 w#x]yU9+r" !Y~9#G[սÀ`钺D&qСVhf&Ҍl+owRTˉl}/>rQQlTd%&B +hb6pXDZJ <΋/]͗Xp B=;d0zR(:\t{qσf~)dONgQVJ`hDu-uU%3gKPE9r]RD`,50uox|6i()+:6 rꀐchʱnUxUΉZ *vM4p'cy٦eis;8/h}=^AZ!3Qʧn?| fa美7,}g4Mo1>{Yjsy}O[]ĪcMt|z'c9{ew <n{y7<]lXphO'r4=/V}?9( xmZ~@QuJ,IX +,n7ҩmv@2cXײai,n?9xspxtj4SDFqQV@paU,unث-+NGLJLnۮ-*c"4<1<{G7U85%% EQۺu G fdVӵ]ߴL4"JDZf %L2e+k7nj-DbSr\2K.33v=׭% 9}v` 33wt_*"{:_j7*Ll: Ml3AQζYX4ѲY0$ 2#@?M xlJ_y=׿+/^a y9|c=S_ ]1B@" #j6+k5 /NJ(NT o$Q`<ž"4hb-O#),ņ$XOiTׁgTUOU9@"R"3$&u*#7YXp4CHbzlz>1(Tz\BPhSzW^y7V`YpY$Q_tfDQ-4 ƠQ1z:{y=EⴖWҊJ" Aww#?::=j݊wKJ&1MUΒ`LRrGccpחV2G"zͳ# ˊDh^:dJ>xgh|:bTs`ZjyY J-7]JRth4XD$$3@")_T?4;\fwsvH5Kmz 2ݘI&BS#hLH([(_bXI)᱙${\Ҳr02j6YX!DNޯ(/=g adu:V7;\߹?UBg6  |cpҚ'>c}99޳u">#_y+XJ9YQᅗZ,dn֯j8RqzSزhkWӘK=;>vG尜7yz!Q oţ MQEYPDu@LL  UUDB 'ff&_zrjT8W_:yQ ϘcvST][vSK2im`v`+vVW EAB@ ,F(aQ PJ(LŐMϼ"(QJ0P^`FX8^V^`0 :tRjD2;汻[[f&i&=58˔Y$X B,3ddyhxd28 `j˗&0"uEiD2ĪUX$ⱄ$Dj:KP0> esZ91W-m\n^T-0D@ 1`0|Fɪ4Y,!ڏ9Lh-8Dѐ "3SQ/,.-.l>p`tlK/k\n'0i;t=ӮH456uUn^uЋR;[nە M/m)R5gV@HCc#}Ss~0+ jKB4Yvw\ӲBhX)tVy(TX0%N ň)B[&͙υv qCłP:U Uͫ\.LJS*ɔl¾07I#xr.9pwp2,&꺊bYLa @z#>p#QuYm]хg zui${tM׬K<~O&SҢ#=>q}0%\lâo(W+6w-kg`X%UL.DmsPWΛɝyrqGTK,ˬ^^dxӯS9?,b`/"k*W'K^;&BP=RFKD0sFhm^^RX "I˭,)+JX Y3ka<%-.[7;;vcxxkrlr5Թ4Z]hF2ń*NfhY]Xbeay@r<ƈ2lDE 1 HvV@VR2`bHѡ}]IHI!I-J'bu%jR թF6e $ u,VN@8rCL6qjMᙀD0իnd+pSSo)NJFSUyn. ƂI`GdB#Yhv  ˄D˨LH@vlnmBgjJ<<:bJ0vo.hw[)&b))ovKn)ٖ @F1S5tOw '3ICuYq]yAUap|%]0Qizy%Q`20EI֎Jg}eiP&'#ss ˶drE?67'Fw(¼URm=Ó3!LQUqiuyyeYIyj6b 9 W@("(+{N2Ysޓ|?}b Bh.7~o)?yǙW?{ܢŹ ?c"m{&6o?;ls╹/m;.ʋV=Kߑg!'ߗ•83O>WQ\"_`5U80~hP.qںFgwRMg9!J A,[: Yȷxa_ X#" *QL&)ECn)*,OT|br:x%˗zK}%5%eQc(>n5ۭ.X S]S}e뗔J+}FY0c 0aU0, Gl|[fcCFn8D.iHf$&S5FDMtOƧB7o&sTtxjcd9"DӒXt直X; 15B0HPpV#*i uXf̙ 'gfC`drbf`bj<ENmEr-k/Xo:1{Z(ykfXSP0 G]XL]88,:L.3MU:vlXaJy7`c6\dGD\XqEW\[njni2[ #?|G*h*EB(A,C'֎G8CuTWZW46W8ݎ-/hUaU"DUD J MGBR̕X/۰eYea/2 CGFf〹eu5MV'!0j]?'ySNp2 >50:6HziaQcuE]EyIqA҉c0rFOXoWflCܯ'㯜E|#EОݤ/.zC?Ag^[t?=_Ƚmr9}טMٟk.]Kק?&_y+dcA3⹰h9Zz|G|0^:_o?-ϲ96W$G6s&}ZqL.qzo?#' I ) D)Q>0ƘRX 6_r0O"`J YAXa /VX^髎2i6G±H[d]˗G/ ~#7W^nƊJgLD&FUu5NYWRT8Ҭ(ʶE(ǺtOɊj7;TW.,M(,t]y`0q"D г|"!kuN _w.<Ȯ-]׈}g3rlXոqMc 3o|{U}{?v7 DkNw}k,fC.yhK݂A/Z05zxd-Mmߝ 2W^KW(\ry֒ǹǾ%֝,bLka:׀_( Z~ɚ&J F5m],)}lh2ģ?:xqe8D  h+Z."kݱ}c#}wlxxKݔ'tXq2YDy+ƿ' Q\Rpf[%i9"9vDhɱ!Uf3Mv-F9=1q$J.5=1 x Om݃L)&REрPqyAaaK[,\ [N"UEONM~T*588XTW鸴Y]9E@0τf&C}\,>4@bxZ:GuML<'D\$2'rx-וVa#D5Y|OOЊ+CNapsgt:UjKT8|.[sLDrה"90] meELgtUg0Ϊ3zWĶ}^eIcҕ-ն"7D c@xwh&UWV5U8f"+ىT4&%ST1v%}z.zX{L$7Kj6|Nn3S*sk0p#/<0AAwA| O r}% 9[ %}si_םL]u}˥xEGퟶv5LgsqEuŅs9.X sŽ#;_#z-Mg1 B,ii g>t}|{du0<; R[DRzs+T~5!; "D TIeD`ˢt:N޶{wZgS捘b(0V4J|7YqYgk󛷷5wlfjTRuFli6΀|tЃ DUK_6i°nƒ<; +*5e%f'8Vpfx`j6]\lt`FNLhbXc J*3~9)3֔M$v--plZ^U^\bvp]N1oHp*nw(3SPDJeNa'ǁ"Fal97748n)/ϰ,@Xx3,3, k\عV=DlE;y6tC=L?kxncu,}I*->cٛϡ$_\XH=oγy[_sa9>&̷x2}#о5_fó?E1<Ԯ\wڳ:9L ǿ _Xqde!)@ APJvi[+Ҭ]vy'MS4UIhXA`zh"4wlU"wIE)'`5(DUའyySm/n}2L%tLC=s=Gnwr&ўCdl0")**,{c}}` !炁`$SdunfQظ02 E]8}*4/-@FCÁ )춚 MվK*+NO`a(<B("PD BJ)APຢ*oj۞c35ծ\V\R+hX0@1le@(U}|m4>2j'Z/ĥ ╷pO:-d[s}ٹ9myjע}<Ͽ%'%]'?S99lXس{A|ُ(=KC4Gv \o|s$\eqag?)GAL(LkSG;:'&g2ewV7Ty\NUE4ey@-)!t[ .B;{쿿vzD|_\ yv,i(u O~׋}#9jxs.w|q.,$B7_mWٌ}ZZn͢B<|)~O"|Kss_y7MW|\e;<x\X~Xy DOH@A (4[`("6ۧNJS yk,xkPDS$)`D̋VƇz:uU{@8oʱ<'7hn.,^sl,5jn?34446E4+eYNʤp08Ⲳxon1 6 kfWS0*|"oCKS`r.8PE4<g^́S»N7#LcBSI*(M  >hñ$("Èh1[nf*.-t\%^JA"c]{XMby]Eò_ihXA{?xo_mb5M}w *婴<&,2G[h6Y,fh.8𭿹=r6>k/|9>wN6_{jݒ(z:#/|2_g3X.J΃giUl=Kvq.>Ex' z91[xg)gٿ|6_0oiKq>x%<љEo~s8_3yXR1Ɲ@;B­;(Pco"'}*@N̫ M(@n w%AIN>jʧc=ݏ/WtIAt^Pl9SYS1 y|_`fԮ^ y4 M1JR*)Ee\ycsEs5D36jI]Y: 8еhlPUR+w]CVV' Bl.s}}력p^ye(;.MUG{v[ E+k5J ռh#MOZmeU`$pLJetMGtRf:?0N bJ˗7mv # Kx,QEl+ꫥ?OV7) 9Ncw\ sm{Tnl˿^VJbUKUܲs6~X(]iםN<~~'s ?]S[Uس!l6iWqosd9ɗy41m7l2;QO}!vuW54Օ[]s{ћOgB#_9eMbUy6Giw24;auGQBɣo8CǤkjZ%RZJ9ȋr{EuKess>Wz0E%׮n~b]S-09oÂv-#dہ~%*+76+ox+k0fgUYrٚy>RH&hDQeJ59ڧrIUɊMNh1E@0LD‰h,Jk2Ŏ3H<Bc00Vvx=.Y\ZҢt]E6DN&֪%5ٮщh,YJ=En55+z!^A=aZaמhUeڲ㵚3RD)i(gwhU^^XX{|vECBKQ*i]ÄbH,?07 %0ʊ*ˊ .c0B2 l,G&+ v,ב`7]jY*y/-^ӿ,k_(qz񭿹-rtb/Oz㎉O0?_Eۑ#Ur=o`rt}X p2_ =?͓_-R9%_\i߹o#/SrL?!Tp W{EE %x'YH I"@R Q#$I)$+l08ENse"_;i:yy~DV_QUQTE$TD46oyVBV<ܮ fPZQzQp8z4KRiUUT,mծt[L"gœs,p[,J)I%tJ#;7 82wuympACY&Kt:Iǥt8&@$O$SEF`g8d[a& pFl6 B#Ss㳳Xf2V,-5%KKj5-*ia  y꩛0VV^qMK4 o~wr|Nj,Ţ\4N NLLL[Vs55MFsv.3{0yK W.[-h(0Pqt~]d0:04zwdrfPZ4TWUUyNQxh*`*c^@@Q}愥uʃz>oŞ%6oz&xP| $=п,_'"8شuͻt-:o~]gevpe+2zCϿtKW+ݖOk ;k c'n~co~Sy7_-/EO._P^;se^D<{"n8W"Wrˎ< jZIxu!)PPt:>6J *"AILd D]j K58Vt@ 0z2fpz92?}'QMSi9r6|Wb"ІVq2^QL.Sǚm~U *q)WUx ^+<ŀs&8k("Ѩ(Hj Kj+Sd-k2F"Fh4XNRl46x<fd&HGbh, 8h1`2CT-NLDgi[j ˽Meu* |F90T 'w:N2M71-m]Ccj$o2f86D"Ѩs/-x}emSXlΕ),43ҟĈBEY+l("LwH[$).f„h̋2YAAjP:ST?|v=zA!W<^^YG+jczv/[.+`(<Ƿ]7{zZwOxw_k23_ 9/~G_٘سN{N,ll>q`kɔt_ c̉/=>{M};]zAKQ3_?'kiZǮgg_<9aœǙC.5;BR@S Y?˭cNC8,˪,É۷XԼlKvF?)@:f B1! J$`iS^ڷpyu n0c3cHaQ'"fSj6؅70EFȋ5P#^d ,ɋ2]GIcqױmv'~XWRU3yDp2q2h6#gbab* e-jF$)ģX8fHfv6S%֒\ZɨD'J C UŞ%--eU^O곳8 I*F("LK* uGgSX< c BYqeUm2<69aESQIitz. C `L4? "Puvb:|X_wt8XubD< "g4#aD/,8x; [zRz,يn?]CUw4/8]nX]>zJ=K(_ _a銥G;r 2>=?bbB\6SP:K-f''9{'=>:'>-D{.K~/xjˁ?6_H%ԗ?q#7wʥ5;u5B1Ìѐg! ɒE%io߱'wNO 就S+hYe$ FYIz''&;;C~ytdtx6^pŪR@ٖȓhz7 ٩Rѩ\D ϳ.}Պ5v;-K_i;N(!>OO` Ci`ƷL =Ei ϲS  !X#6,bs<~J=bQ1l08esGt%]SUvUCql+B "()4fs 3& r a̲hxmBTe$)fRq9JIhw=vg`70Ou8 @f{g7҅xi?ڜB%(ESicʤ⩉`)W~|ke>XІ gz_ ?Gs#+ꭟs2-zY-m|&͊%kWԽ IQ;?iu_\֮ˑ|kFgy.g$ pPmzsO /.*.jY\P.E͢aamk;6>8}3m[F#3v !&Ben^c,7H*=7143̤4EG(X2rekj|sܝ"DwجFDB @tp(B4p.O8Ie}Pvb,qBNO$聀J ˱h|hhoe+V3`Py{ֹ(K#Du&jZA óQD3獜hprrQYU fŬ` Ǔ%QDNp0D4ᑎ<:03:0JRQWQ^U"Bt2Er,<8ttwrbfNҕB(P"`ys1y7V<=-b 0%{Tp_`l>15M>02Vx#g;;v7m|S]yok^uHખWZ d[(e|MCMy]}W>pp;]@ig`xjGjo|r36???Szbi o{SNV;>7l#~zk\S.ߴ~X.o'nxL0o ! Vֿc^Ңb!{0&"Ou|~f:rɪU.bC˚f)r>6pX퀐k]Vӫ׬~g=7K =>2 o74 Q@`m;vMO g9.1:ƺ"!DSq*j( j6ɤ3Pc59 ypAZ;@1[<k(& i+p_'G(*׉tZ|.tZx(${͞߼# PD R)DNIR ƬbrA4a8#aN`XDIR! Q5J)}x[HO0nNIցi㽥E%%D0ŐBJƦCsLVں2b2D< @Afx9?_L䎽7~hW?lȳj/=`꫿8Oo}ߦU= _<}~O鯟v|e]zAK{sH~Yƚ\ϞC=g:lrҞ}?Ͼg]Ǿo;-peg399x{|izu۾s`m (_<;<?ӖYSw_}-7=ml^g!_ vhT/c[d`t77ڰzi a:tr{Uax[\ږ?w<73[)nY+N3<!H$_8JmEeui(25UKKO=|l}3,KC JEAPU-JS]#D%XWTDtDvn-z:̹:i4"'4#ш,HpjbsϴޓIҌ,u WfnY]AȘ4?Ǜ}(Fh:f2,YXaYr)` I,BAAHGHCDkg+@ҧFGCW]_)pT`h4O P@% c MF^V䮫)l+w`Tk1 {XWԖ|d|9kOp'g-oxJk5`{|:/Oh_wDp] ;Zs 2O~+I_=qh?cK_^[J<;ͫϓ0;_L驙~viwM;6^͙0f1F z'o3 \QYQiQl$C(_đ|=B!RaD^jS{̆kV !WBd:ԕ͌]h6|]])0ǧ}O&.Na<-f;/tUyM2v r&ʐ ȕc|010'uYJ!Thɤ.I/=r*DˎBeYM2Plj:0>1788>4410015 ǢiI02 v@QBa0 AP]Q$%%QL,X*KHVutYG:3JX;"[63vηNb F#aa&"u˲Ҳzi:ͅ3i(6R)bI?}їu N*^XT_]YVp8t ,DldhS{a8EWס^#_@u.y1# ۻ?{Gqߙ-Owr{dJ @B B$@ ;bS1wYzvwN,U->`˧ٙٽݷ^EJVr] o6_$Sf϶z* nj[ XUݸ/؟j;a_<D';l/7&U*Bcs;qV㼡Jƍ=o~mwZ8p#>j24wgHk_|3?a]+bEeF{k.>S;w?hKxMڥҗ??f\N}\>F[qx?q}5m F^xB: VKi(*Qq#!YƢqEs*6hy6{BB@FM_WXzq- %굁>]cq{1>Ƅ$HQ$ 4ƫVʱc}C˽' Yf&M=tH>F[RXT h:joaa$dH(dҶ H !dFJĢHH@-{ JF,ĴM?K` i j!iADyۥ3m̏ky%8[F@D a 3UNh)C1[xnD(CI!)p"LSX,&-U3>|oo(.C D&X<5"MWl6v]N{~wa?/v;].+0ΑQ&uh;rڗP&ȴiB,TTm:Gk6bcH֝U5M "dH@GիÖA C 6 G+C4,@@hgLܴXMCdμ?[Q)O \Rp:Ul5ip.Xŝ hWzfh?ݿp?!){~^} s.jZu_ssiK<>%zlkrE[Ͽv)ôθ]$״̛xD4zɻ6yt]64in os ,\U#lA{D㖾ݍ'RB(Ģek;wMj?\r 6<[/Y=)uuq;*9>d=H>z?yWoV՗VD۟O?n]t 7=B oj6}ù?x9'| ""hܔEO]I"x?-[4 cLond1˖6==dpqM]hca'μ aY*jCX!"Y  B8׀1" jS;vʻ?lld:ˣIFR&P4*N;D$qQS'd*%R7_|1WkV,E EcUqNFH` cl8]Nr{99\qxM0N34iA"@46f皪n4+v4ځ[\Sl'4_ln 47mD%qLRH⑘5Y?.xU 1Æ T<(?) !tI(`deNn9uNڸq}}gp@k rOF TM=憛>kNFvBSK;~&k}ݏYH]S_0e;s O:?~LM;W?gp@?`G_}O7L]>N9}S&fI#O1kO'uw<Ewx 0wq;$u'w^Yx]ݏ/^{y0eOIߞ/[w׸Q75g"՚Y)_?J~IDWnuv~3 Bi~7 );SG*[}!c pmO9%̄)61 PV\ EM zLm!n:n|1'srvW#@@HLDNi(|]5[B&: 3EIFLuG?#H`& ǬXLHdLϚ1k;UɈAmZW>?J""B)ȊbTK H QUT]9vrrr\.q|ntUQғ9HDRf"M "*LQUUU纮usDT5;6EI%@2Nf"EB 0BDӂk[~y?/^YhZTQX0a r6' mksJQS"`q_u-L:d u? ij KѸizO}WO{ϽMwkC cn&K-.9{}u_ v9G:z?^􃿞pm_P嚆Wg\Ǔe+k.o3\q9vlWOM2iPQV'O|\|q'ru4y홗6CQ{fꦗٟ{w#/`">5> G]Z[Sv :?}c2e3fmci39t|El,/rO~ICnsphؖZc:2~nc~vĺPQ7,P ˲Pa,iQNJ[ <f~i(' sH3 2 nVp(3HSJ`I$2"q졮X$`!I )v[ sη_^hޏ+J!?7PUz#ȴLH,΁q-O64$*rns=<<>_t;4M$1C{J~QQ,VZa$ 2!2TD@ ðLSZ$ebW: "oi[9K/LR~gG Xgw:bvu4P 68mcወaJ@Ԗ:ӇMP7~Zǟ{yxYᦳ6cμnvD,lj y9k.>![ڕ+򒼇xyvcn[{>7/z='EᗞsZϼPSI7 ~}z;Qyٍ (͟Џ|z 2AdY럯^@6|uMg$ ʆ 81%O,=ލJ 3([[=9>Fm O&3h<K}Z8$$l/A$FPSJDEXƙt]-Hd2H$LjtUKUy&J{CDMp=7E!  XR G͸($Ivܟf|VEF"Bqĝ^\Ju9$DiH(G"` jnT'|^Wyq{ s]Nî HLt32"Kޒ0Rh )eZƙ~[MH)Z[f]^Ъ8~q^I t*J %vuN[C`q㳋[]2">?k}[?;1p}yz܅3H;+?⬓,''?l[JRϫ3,c\QƧ)p|-|W-Gw,7bhYEYAUuc7o/;爩G (+xϿl&vHK\Ч3f~CVTuuyq-Z_ݿ`IU6;|Ho=?g~c檪NO_7/#ekdD[Sr9'OOQ=Bfn| #w:u ة 'hӵ9=qwx=ʵ5z4@!BEDxꖮ^=vzv;xڦT[^$B$"Ih^_b׹IJ[P4&$ !Į.-&.8%j#HKTD ZCqc0l @[hʶz IiK!4axܔqLQ jʼn3)WX\SYYmbHV&zc[ꂑ )O# (V4dҌcP477H4aYvݦk=%e%v9v]53D`eTBUɌ#a!aDL \[ˢտ,Z|UM$jx= 0fPq_PNT Iv"]/-$qnb!v"2=9q/D@G﹂sg뽭4.>֟U[_{oOӃ rqGi&^wq}tsuꈹWN>׏w)SW-8窿lmJģX'77>2~w։|vDcspsy;\t_{* -@4~6G;{zƦk {G>7zk8Wea;逬v^yg^멮1piUu7J;tܗ.fb:5wmUoȈ=?tjNCyWmЗZ"oz^*DJmȐ/]ZPKx*B:=%"8uY]WQ>4scؑ@""S)%"0d]&C !P(,W*,ZM$Wi*49H8AimΞiG@Zg׶/BtiZIE dR$ $$&a3>|fբps]"B`bWƌN!0\ᚢt,**`DҴTʌDbh% "-і@$7tu_G5v: > ߛr9uæȸ4%G vRMikQoj_j˗ #N}1CGpSƅ3}5%y?msv, yqvjO?xmߟj,\գo}O{Uׄn`䦻˟xמ?]Sw3't}_a#'t#zmÕ7㓯Fsytxhg^<閿}Nyvcsէ|);#GuU5=pz~`=6D)~S"pie]r;BNذa /I֧_|>㋊5av !{55Z6ueַ2oΒ8ټ>a&jj0=álFx LuF Ĥw]z3cB& T )4S-5T4U\t (#H$RHD8ih԰LfLY$"f0MM1,+ 6ׯlZte S {ٰu<e tE՚:v:t9]~1iF"iĢF8%@0\_DZ$I鴹v7ؽng^|\eלUcw3؎1m2KVTӲ,ollֹ:dACd´ nt2DjS?C%?@7Nxt/ӎ}wS{\?Ɩ@䑧3vcs{.=xs{nO~z /=ә??p%gxN4s篼7pi;e̎څI T}s|6|po:Oî} M-=,[|'|Ysy9β=.U/杺8tk1fDS]|#lgMMu;ezK%sy/M~ ^)9ҝε֟`4/3oicN@vէçe"ښŋl%%G1.i{:0b)zE*--fO{䡻81늦X?^[Ʋk*<TAU]DDIIiL3*;mš$ )-)d[q*ZUNCRoQ'ء{1F/ZV*¢"!D2&'4JMTEETUBJR( G3@u TB )y$n0&iHZH !* "c(Ry?U.]P[ 6%iHtdu!]$~/k^_uV{n݅E>4Xtp4`$Hh FHUui;vs ϟt+!K"e9T8o5uI*+3|԰|&$l6ݮz޳x6@v/$lc 0IrFqK_I;0?<%{?ә?:'͕'u~xG^ek^w =~3{1|=3O8`μ> n#fd!3gݦ]s}|* o?w%zR?|o+?o{%w]cy 7_/;KOv{GZ]bQ~l~SvO{g]r;y?K϶tEvID 㙗>{fe="ZLyE>!ɶ.CY$+*-H3[>t2B[iS.ZY׬MBYW_+WU56K!!6++U,nWM;pcMDB@.M;Ts u!{#ڈH*4ne`?tv/,*T<CU"KU+(eZ*"2MSSc(haX%ZDdZ H(*7LrUA !̘kl^Xfu0hYdj; 6gltf"o2֐H0Œ-7T$ G#hxikBK0]nݦ{\N)bI!d2h"75B`$CƦfa Ιt:m~sxysΒ)33xuM]՚5Mфv;w[6fĠyNk dL:PtMe-F"4HHM´'k" W_tLaA΅k XQVpGy~N7b%_6v/8]dM?Kr/;a=cSx4ڱ{tީv'~Tچ{op5{G{@?<s gq4>y* |Cm w\`i+T4Ã/>^{gb{3froy*6`ЀyN]QY?vٿg E}+NCw,d/pԛ:e8T5wOw=).NO_~^LMw{r^3S~q1 *`('_i?bҊoM1%MG"""qF PT`:h4(ʤ#P0SN7vM':ӈƨ!0`m%IV ېI粨ST3~nm;5#/,/9+y`s:%-!e"n @TJIMcѸ!9#)I$\ A)V0TljYS447Դ46ZTEi,v Q:.@ beE aD<BH$CE+Uqu_q;t.# qfF"H,Gͭ`5͖%UUqvMuMdZd G[(˟4lA%+J^7Qӹá:c"tTCv^:t>}hv91󯔭N=f߂ܜ.;o-VU7r3''p pisxQis}]gMxʼnO8mEȴgT3w>)7^q^Gnj {@(c+9}{峞`W-.: 8%?={V^;ڋCH'_}om_;>x==:ݴcn)i[cswG_~};ݲkٺ y?xE~lN;vY=O5nSqu 0u\_N95zq)~3s8)}&"7z͞5:#ƍ/G{J@KWpT\T\%"(s8a (9hIΛ`e;(-):l:( KIs!cl!d8LFV*Q]SGRa2/;~Nfwm6^W>6];1^ O0cijĢ;2p?Z0 |\S"Ev++viH(E[@T%-32PRssC2F-FҨ_[hjhiiE#XزR$L"I(5:Zd ӆ(2l6.+.,0D"&[@0KZC+*#Su{nϛxoQQBbDcX5LD@  FZAK)cEӼ>=F5ȟlN5c:CB&f61>FԳDop>Aov>B8`긯߹ T-A2e/eA9}8Vj/[Y;3_~{kwH{ܯ/9~Zegx`o,t¯/9nWp'ݲhٚh軷>nʄ~)컣x"O|o׵g:?~uyUN?;9tJ5I͕'BZmbmmxiw)j?]u!{>ۻo;छ{*rCw){)kٵ5Ͻ2ٗ?][r>w琁ŻȲy'rM/=O$JS :iԘC(SޑN푻/??VfeƬ_J gpGUՍ=S/|(+λƳQsZHX$USSOݝ_L^e4@Ǧ j9 ?f|9s/[ruæ\P^7"g¸! *%! yOJK'G a:A>rIgҲ3ju0k3 ۓ4Lz\u 1Ɖ:˭T,S5e?~CU!c.rl6i/ cvݴ UUHJ&D<OX,GcшD,jI >}e+V 7mLhWQ:eDfc NWj*W5r qB!Chk0L&,!±h02LrmrI%aw"mlO!'9wf`_|3oA%E%~u#5C5a{= Ǟ|^J0>>9;)G`ƏG q8pn}] M ~7x="H=ϾsN~'=b?|j t'oqCv5SQV_r#ô9\N[ϳ۰C4ySo:G_ 6><''=q?}56W- }_z'L* ) x  42[oM9v+(K+GLx~-EKV,]Yq~W5bHYnO(] @@+\A" ujL2PQxӋJ tUDB uUGݕ2`mumRGidwt6&( $RTD MO=d舁-Gkj[jÁPSCu s*HI"2RaR @h6FMB 72!8>Dɒ&93LD"$m<082_fF!?o#5|Դޞ{#rADiQWt6O_ܦk5'Nsnl9piլ~Ͽj~~}a*7>7["//o>y ]bp/>}iLQ^׃œ?ً>, rw^}w¡Oc7~ٟ{Zᮇ^K Otо0r@i~n"}3{׳K9kO:}ښ9?|k}7%7!];{ovjgsCv^澿z_5vq,}7g׳}.Vv|-161U{I("mv0) ˈs# '5d˖4̛緆3[0wjTO0XsB$ B" -;i^d#0jH4G]]+ˋ僴&\#5b浕F(njIFp(F2D,NIbRHI$ieXkZ'>F,E`wZN,.4M3SdL KBX]u8\N{QRx* 'p(ƒ KgM9.79KKsݚ¤R MUenuMUQ8g =u7bmp킉^q߷Mdrޢ<ܻ۶@2e|OiîO?lA#Z6rXya^Ζ7eZjmƥ+yyVfqGPqK9~wGu`mmo?qSs?O>aZYVG +=bAlwEeUW,]U3eU%pS;BdpSys~&_/~?Ң)F>`K .f%VT.]Y|U¥Us\bǘɸ]} XOɿ\p5e{[/Kg^t{`(}͘cGT>`aJ ޭN4i\ӰlU5nR;KVdH0LPq(/)JHv <1ëqK%Sf"L\ܒ4qLiJ.^Xh%X]`@HDs3v#DJNd%HM+m "k{)-HHG I DRJ ]ӴRX/# (媍9;UoO8}'vП@䳙t5u¢|_iQá}^uB  Gh8kokhqƳn]oȊc폿:S{d n\/svfɓA5t)_vl7{#OvۀWcM.ٞBMòr@y׫v2cjKJNc$%-bR`<F$e (vTc}x1M,Aܲ !މ5>g!XB)EEΉt 2YȶD1#)%@&$ev!*ߧGBDBF)x0 S!L L0]7MSt?AjJRИ%baCVJW14x*jjYhEHkHR$!u,n6KD7(`ٮ;d0Zڼ~QG~!6*%)#755Mu 5MM pp9;GLSsvf' r?nRHcSEUưS7VB8֩ ;0h۴D$?BovG{5!v3'oX^4&vz<164;]vcϾMS?~jS(}ҁ/Շٱ?=YH8r=u*,v9}CYLg'/}yoleGKL0ⵧo*W~C㉿\su탏xYW]pɣz{CwtNq;!^;)s]!_Z #D4TUnI˴ KUoM|'U{ $6EGT@Q|Ujk#f*B \+?7>!v@nK*IR 2<$`D,oH7JdljR۬:ݮP4 KC#`"LJ&dҲLHXRȔaI],銦qEEMe 9l rysDMUIJD4^V]r5Uu֠wP[/$@[zy[3@2ΐV_kwi!rV"O@KïT՗:\LJIm$,I B@ QJ"# 4QbwFtfߏ6r7wh}8 ?Mw+mWE3{mLp58 y'dv=F/'*ر` w>'faoڑ4܂%UdI#w|=^]@yIO߶١BLm>xwog犝S^6ꈳO:0^}B5|Hٿ,~G+춳Hu3S&666 BD·6 i"4cXa+̸OG@̨'( loƔGkJF^3d$t4R;4굡ի>iP!ۈ7zPJfk\4K*ڴ 9JEPDD\[@!,(bK!TUA Ea$9V`nܹAe'oYvOKꛥ ))3kc v2M6BqD}]JbjI MAۦi** 8.Sښ5t,+XB3e&Frg߱c'qPR=S/|]]}? Yb@i{鍏h*7s'd7(=7aZ'}wNOs.M``y߹Ttˍ׿t(*p(HôG`:"D2dF"&3&$$d"D 3UMJj5HH $a*8#ʨ%Ec+i5uGryMsS$+`S:t"RQTM$ ai.DXҮ81h'uUpۭ:qN;sT6)4#` CiWMDbCLrPs#J'2 N䆋9刊-UU\(-|vDd`C04yG{ߏ~z `R-NIBXB&q JeC28wPě;켨Y^))t}9H nnŰn61E}DҖczvҶ"sSm]ro?}KSw'#Y r嚞 rKqq?~)3wd->̙bnݦkW=}kߓ]} /@EY+NR؟#xrs|ƽW_t c57d[RA=xE_C$%EH]^Y9$ KXvα1'jNp$d K;6%Ul6y1Ȕcc:x5fd"#󤄑XdjUmM3vԠ1 jWiu IaqR5eys)!S8k#17Rd!$I*K= ' ,+)\Qif;nf眈X(M$Ͱ&B|$?,Lr|nNAEy*(MB`Š'QDTDȲ(Kpg\,iJ?괎yčeozel vrEq5=7묛{sOM4~X$X|a{etˑ}~ӳie$;Q|:PU=t݇_t_wxRȡ?z1*K;`긳z`Ɋ pO36<8+냩٫&6gӵCw̥sO9{ܙNg׽:'d4fz=%6UPסlC@2d*bEUUA2&)fh+&HAEFJ$6 DXl!#2B,)%‰&myUcBC8Ĵ:0D4y.qAɀ \4䄀v>Ū1@8ڦ01 28k6d3{~jg:qw(kŶ-4at0,9(ۡ?]X41Ѥ{am )c3 b@@tܹVJjlh~./:tiG0~^?;.:-N{O}z L'|ld mw\|هޭN9]A P*iTUU'S2LF"D0SB@4RҠtr d=IcmmF>V|5WS43dАǍ(tW8}NqN;{*X9U+y`k(xnjPZu@}`KH%/oI ),#@bIH>zxdzۺ,NSnkq`͸7lءv>cښOi !=A[q#8}~ؽ]h[}+8}w$tH4!h4O$)IHx*e +}/AFD"lM2)C:H fB DT-` '!V0Ee,,B!Jgsҁ{r>o_$e[5"isoZrici~-HQTB @]{9䈃'T9c` I_yg?55pش" A(PXB+n3Lo R0l@Xd؁hNLuR*li0 "-1q/:e!m72)yC6j̰RVZ9103DD)K&P{P{>=fv‰̤YlKjjk ۝^OPV>j輂Br$d i60LHe 'FbUzd4WwNv/KEn6]}מ ۀ<1s^qqyGe#Ȼ(]}1?QOEvyв^y];^( ö `a^3_KwZcWo;nyϞc~~}zw(+x߿{)f}^mccO {}!w3߼oz*F;zp6~T$Z Me6W"nѸ8Jq S_IJPc'$ ь H$(,59jcGZB5M~{/*t#,j55nA0bta#),acw2iDB"'Tu*SB|¢U+c-Mx]MyW,+* T6pPt8DH U~w1XVlњ3oG?s RҦ2)aEI._u7[Ԗ6$ ٔ)U_*WYH‚]jnq˴DSftU3gCm/q۝NfSrIDv{2l膕[@SsK MZBa#P4Ui%*5:=)% 1ș1y*HܪyCv ]}&w ֬uuUnk>t1KPƳ~uy'_zo_]p1\Y`7N vvyGfGnA?zw?u7s<]wƕ<.cՠ)7cSߞ{峻_u]scn⤬mv*wp湧L(>+SLq%1}ҥ׳gp+˻YIGMw(ma!ٰu01jdYVli 數 nb ˦*|5qqs/vṇaq$b<96O#U QAA(B:["D@h gP3m4%CWΙvTLō˫Vq9laCˋ |:ŕ #iYvU5=ǷQG.Ԥ6Cp #]1nHai:XJBKsۧaڪv[MAqRϟ8Nnk綐dg!$t{l5vg 5o5vVVW^^cw{6(~Ĺ2y%yJa fLSBP1jq|X,ZzH1imCڵ t9fskN]sݦ5ŦN/s6(mp:s=Z%Hֱ]9a6?jޚڹ3 ]4DP¬paӵ+?l/O}gZbz9G=idv1Mp{ T^aC?w?oaθE.xYcglՁ6ڐBoͪG-q1#vѤ0 ^XIɸAF$! HvCCvRt!"b 4X<* 5-(6eۡQI0D 3 ,PlvAC&M̟_rE@ n6mꀑÑ+p6 F0p+T{q pjx4]0sX b1@ -!7Zo溘u&vMnæ%bLKm_;[zazU™$ΘMNWA~Aa^AA~Bj'`a²,qF m1$!o^jey %FVaD4ƃH$d[ X8 G׬ZRTsy8JJs ]LfyIB&;CH3-1gČs䶹n86iV˂hsc.O<(c<}&^J2-`1|NomH_@5yg^tUU}ߩGz̾dAD=f7#ۗ1ϼKo}>x/:ckW,qGCOoDcowם~v |6eϽ2wfɭxIx{qVnwʘN=c:lO (zi?^xmm[=>TZ۫NU'7gɫ}tE3O<364λ6+8B Q#JyjC + 3!0Q͡{}4mi)f;V̳# [#ЉN̂h\{3~[dœƏr욮2!chY&^^T>fPk9654-e*vG~ـw3uJހ24dL 0ĴDR4X4jgh% RU!R2f) kč?2p #ĵ ]QLPxHє}'ϚUKshi=F!:<477*i)PH 4US5x&cOԄniu]%%%%99bEU$P2EbКu pFX4 XZusEy^ݡ**X@$6id[^Mr fMc mc[Ԧw9#,M?(#HL @Qd禈Hr=-Wzէޚ;3k[N}GO;}ҽfY[{Oեn7L7t¸~ở+}ܾ)mSߕ .ן ~̻uisU'Gɻ΋?|_Og'SF'x1dMo>)}Wޙ[_͘K_;7}N=h׌*#{n^{ڧ3+3xJ8G -YFf#8/_4gފ>f[Tٗ;t؎~\QY8쀉8&d{nBnzQN:jZ8`Ɯ>wx': !YIB>Wr'|O/;НzC+2fgMU=tʱNœ=?~O}Arg#O<}$S"-M9y9'OooϚDrG =GFMժUU\.-Qæ)))TL\\N-fz^'bu+Vqu }2ҥK܍M aI)Tr튕0MWWåjcnj/6_R`:EFjHIaA$&ZNj1PEA6$NhMj&6l#Nl`nţW2cK Jsss 2dR메AR* GD (W4M%""3v*XP0e˥)+j[BD  bu-Ս&IMv,//.xvx`.S,!T0 CP8 ‘5KUsr<g(4↮buLޖ6n췝K"Ip"!I%cc6S[ u 2ri^ckw\Y?,ܞ>8>uҨ^c'64`s;`μ_~;;- Tr{eGrGrv쾧ݜ%~Xݜ%_^j>f>{gƻ]ltKԧ |6i-visXt:lGч KVKH9eˆɻwʘ}Yl |^y|ީ'S76fUBC4rQ{Yq5}w?x aќ_/^vʚj ڪ\NQMMw)=e:âg/3oŢUYh\^7yNDҾJHmDJYJ1cFN =t$Dɸi(`DIiRv.Hu@r0=JrjVYÂZ" -@leu$.(W,_U*WDiҔ@a9=.[1fxٰ҂9)[D>d,͔1Z7Zyt:v1[ )H׀,Ks1u]벀6EtJil{_׳n05auWñ#+&:q{M1~`Yvֶ'KgN pQS'Js~Z`IU5*kм<cG7bacFT8zvolU5ַP]ԑ%)-u^kjCG-O|y+/[vp4#[ T2lPɰA%cGVf`ӵo>74/^=e?_1IժuH,*M>l>f=GgvFW[ZMֶW6$s}sв#g+Wɔa.Z+^QYlU u\2zĀq#N0"MD$C!sÔZOT7Q0Mi3R "JэPaؤ cU VꃫZfM](3Re+$m״d(P̪\>dok\w^~8eՇ͖##|F&ɬ ٵy jPRHLX8ʁ(D$4X[@A'ttaI@+U3φ2UFK{eNCf_A~AWZRjeYRS \24M4sH&ݮi kcsȄ2S;O'tIf2mPX,(vK dhKs5FTj T. %H878yni0ղ(4-P$Сmfr{0LosI\) jj Ԭmtǩ!I2,d*'9G:fd;_? Ok_sPU8oEKpՋI6Jrǎ1lc6eggGN sN9(;}vi7-|UM}c)44T5[ӞlU^R(/) }Cxݎ y H sk$hhk@M}k iI@5  }>O347tPI׮ Ms{1Ъplmmsk0&ҿ]!}ծgTVZWT%yJ (ʎmDYq^Vʺ!tMcܐ= iŲDM}KUucmCkm}K(O_T7mŅCY_~(_ZVL)t pnMmv5M?`xͫk=JFbI"Qn D8l W8C$  1ooBDX%X>MR ݮ@ 0z%%\W][W4s)--fsHj['nBH% T2Uu 3lh0H5"l6)/-+:N]M# !tݲ,UQcPd8פi$IT3Υmo7)IB)(OZە[ΑssG$ӰRI!P5MƆ@$Drn9|~w^nNn^N~77[R^*3@OpaLSI)4$"2TRZt g g m9Y!TT?,QUݸzmcUuCUuSUuCu]K0 anyN-){sJ | P-sSx\qޑوϝG0zPd0/0/';Y s)Fd",w١zm`[504 {O>jʧ~?f̴̦팁lmD!.SΦOJ@S!ti+ɫ=X --;'>]S㶤eL $lH;8ttP}h5D̐eBN!R*%l l:rnq IX*'6vhyKȎ\#ש"i9h:lΓ:d i 1v]+,(NeSm q{$cH \劢&IEa*c,`+"W8"RFGi]/ɴ:u {fdԲ%W7؝Kq^bpi% +JB`0h[֮j\VgXa/(s\.WQQ^Oitr{p= ǎηو@P[ȸ+|"%X4*q4JR™j,z2xcBiteϓ6Dc'iaׯ8HfEYdEYdE܁3vi MǁHU`[߅[|URͥ iYcU&I4C%9pHI=UH@BW$#PuBɐq88qNH:l Dn'LQɄ!͛/$Fj)+"HQX7tkĎ4ft]D~t*ގHԕ$/-e" CZݞa{<IHJa 44MuMT)eY(LQ8̗ A)D$TPU9ZIBL6l:$)IN(HL4OsV|ќ@t\i6n}9\.mB}x "X4RV2.Z[P)NIhTjnvq{O8>x,lsA%@ ixҲ1J( 7t}7:X;-s,",",Ȣ[h@)²=yքVS?ຮPT0h\e1rݮʴݭzۈVڀpNARixu!ɟВ4 PIύɶND diVqKJHpv*$A\a2B=f1)!IDD`I!:RhKBF )Hኦ"c>Oyoks% DjC jv<71bhnc+r󜪎[BB 1R"4Tssx_}>w{ QD"Hv`6R&$qDTc3=$f*%ˮ$mYdm۝8g]z|v$",",ȢbXȎԀ(RQ(7}K̤I0iİyx/_}7xI$qad ZC"J U1 !( LF0\6Q!mdC:mLU!S,`@rKPBe%kbQ0iGD?ayeE+!BR&%L@H]ג K KK;aWD&iFa^XϲLhJ7M E*ErsDo C*tnq[qF,L& mj P8riiG02Y;sB3_[~w:p2+nҜD$9c%21!L 0p,ò$R')gD!}5Y3Fqfǥ\!`@ݮIKp3L$c*璤 g1  3`1.4Rp8 GufNv#Wt] ے (MTN5%[6 \cٸnwx7^xmΜʱc޶FP**rlc- |+m+*@X$'R6X!rrӓ G @)* `r,1Ҥ0 "ّKKJ rg%.1Ð`,K6[CS`Ғ"˭GFDdJ0,P@lC/",z 9wҪ0u&̎dYdEYdEYel/$HCnFji֮mjm  V^М2ię'|_*[W}~A}|HL(PL(+2ML9vΘ$ޖ#ҍ!%$ '-YIoV4Zx~Dw{OMU( c)@S@lsR5fR`یD\J2 A*6I  # sӰD<Oqfe ,KB,a&1 ]d6+$2Ps+ c+(%!$I)SqjEu5 nr -3p1UUUaݡJDq%ј2c.v:6dJZ"q3erE5S)N\$$@˼UM w?ZdyqaQـ]5QJHJ)jLJ&).2^2J˲$Dpbuj֞\D ![VX'B‚v)\!dD$ƟhZd JiYd?f Y!dYdEYdEY}(4DzP d"LD0յE*j:rT?hrckg?JD̜ 6f>!b8fu%eɔ%p  ˈ9MW2Md 7E樕Y"uPeY&R&*j(r52n%B~`wWp7M֕KH)LL:l崣e%"U, iJ)T bI$$Z"@I %"KK HH0IZJq0,(I& !-DpU6bqaIRWIlq)I0΄L@cbՒ@H]Nq0UQRFcC.4R2))2Zx"t 6]EbH$OJ .[41M/ '_Im 5Gf6[dY**.PQ/.-)PRPw-`KXa0Șvtd2|1 "Agm'^u@D|y?.[ƥhrǍ(.-rj 4A 1Eeeb*#6,bf;- \z!S#EYdEYdE}J=4yi غP IThm65 r =N bGXޟve͟\m^0KS1a=#P Rc ^ʘ %^pAtmn$JLC#E(IcA4xDaIƑY_5ks FiKM͒2$9H!vUXRQ$ChRQ,4 ZnU%20)K"!P)f~36=,"ZHB$@ÔѸ)RdPaa)LUe nYJ8h ԯ\k&Nۜá%a+!9\Adx"R9]< @h,a)9n6]UTdmlUEa H W`NabD\ZPT2]fjB> %V.$AIi0k[uLĽph#v3%}j!ɓ)ysZ1f!>܏hJ@ ,+YZLH$e)He=ҐYd}!uXVМEYdEYdE}Ȧv5ʄ' 3ek4[v}РRéq B: (oYS"T<9İCjN4ufuӂ2] @1K B02dJbI!`!2,9Z2 d*JD2׍QDmyd-IbBS3YBxR:hAcJT qm4|]㪊\LUI T%T5s2ah4 p:TEq WCX(Ƣ 6rTMU"Ch_LP\AddY2m ض7YDr+k̫ [r }Tn*JE1Zk%v$$H@.9t8fR3ηCfH1Ls~ZR5u}İcA tθ@) I$" )ik!bbMMӫN ysN,",",PrYgK|ߵV_ CJ1bL(*02fĘp13)#@HAFwuu˻Su.Z1u޾y{]>'wڙn>yܼp Ca|hԟp\[5- B ϼc* ׮~' CzGTrrUHф,T,T1U$,˔R qiU]ȋ>VKVdR a*S)@ 04:)NA*@} ?4? `W]3\ Ysսx B˻Nv ΁  qΐ\lxljbv%" q<߷C0T꬚fe\EGA/0@ѪA]>ٓo?x0xtP@q]v0<E2'[$\i n`@?uj>Yi]b*p8wMan7\zݍ`:W-S\ g3@Z#"J Z0ߔB-(Ȥz-73e+YPPPPPPPPP?"j^ܖwz\;Oe4յm|HI(g0Eđ,;xpoLJ2Z„7 ?8u5KM`,N" [H =R1d`1"gOy{WtwwJvgkG8yBy]rl)J|F:og< I??wO <˵#ed.~do>NFG0 ,N0RzݫWk/u91_eZ+d?ۿG˻orZ41ԙ6,9_QFBivRd=t]0"?"4iR!pu>PrEO䛟`z?NQ8/ev֫ 7JZ+9[(NM|F[)9,25 $NT}_T#._ \Lq칞Դ#޺lo4z}S~zV2]FoD\IcT꺖)@aXh` @+ O]#Tls+YPPPPPPPPPILUdjÛU}4],DȋI{}~n7 {"ػ{RҶigb;V%d$XH2i|Ҹ,i2 2.8 A r$e)iMZRE, % PJ-4# K7a1k$FL&'?x뭃{wni,ly21 02Ҡ޽'Siv/y_\--mC\[1.4cCZ&J$b@D@i DQ$iF`:p: b3К2![ҷ^}ڛov]Z`VC&.)NO=y7>?:4ZjJ il>O3Ʌp\Z-1Zx4RG)hV.{0$Z,˄00$LiL@.̽(i{-lhR훇´<߱0 :r*yVka$)GfX xP.6Cyуq3ΛUs/VΠ&X3dYH"I4 "$2nSXfs4@hy㲵TfXv"֤Çã{})ؖEq}ǮVz@1kk4epq|A?\aI4R!_ln2*Wk~UDz.< aB$<$ ӘeǶ@6(QY2 LgYh*43(2@ `@!bu-u:pئ]ՆIʲX04=N&'oy_|nR$! gYo4?xt?O|ڥ̫k[BuIAD9Zٲ,2$IEqIبWlBD۲R( P9]`Z!<\0M\peN~jWvoto~0/y~)WZo4*kkk7jq%Sadp Apx|{ǜ ðhtJ]uǵL[pC (\ |!~_d-D !;\ifߺݽÃ^KDYzW^Y+\-$҄K>k}b bfah"(+*e4'/?׿J|x y:}N@bƅSCQ2G0MOn+_}oz|`7YXtҵk pܽHzf% !ܖ`F1ReMʲz5ۜryY7W%ۄJ 7&GÓ,^jԵeTJ~we( [͚i-ێQR "GGYJd2A4,Mex?Z)GnCð'ƽRYi(WJiaeYi O&FCDz`2JfYk%RDZrdT5S%ۭxzepy{w-@}|4 &eB%^o4Kfݲl"{v|4ѸwL=q8RJR:$LTjۯ]ԍk"|DqojxqIhpJZ2ĴҶm#Na2im&i AklD<;3%c}K'>PGI8OAgmٶcVflU׺+6 'J%>;l`%͛dhVJe٪fhV=ϰ];A)SsƐ#EGȾGk|4Ӄۏ;(e|o6Z;[׮nonm3Mw"Nry1 <߲M,NX)dEa:,K]S 4ǯ~.0ΩgT.>9wYlF5<'q:]خm߸C'>͛Oo{xum;\k%zA.Iet.'-gl]iEh5K~ԸUi$)U&wy{tx<瓑NbY\qRɵ*=Vc̶MfaQ*MҸZkzQ܊l؟&xY<<-y6}swy:ZP,Zѽ[O rk6=Z4?ugyvk=z6}ֿ9ovw6w_V bD*8E\[̻[܁,uuluge+trQ߹=9:<&I"#-47֚ZP^!Ll -@aas2,]DhvJ*ba3`Ea'Ӹ?D4Meu$Raei&RZ+"1ƒ45-3?S){cu#nۦe[)*R#NrVS軥h,FޓރA2Lcw[xfmZ uޢ@->Uyz8p$d`fVZqmg^.  $IJﺎmZ2A%qpJ:6Rđ#c d! TBI)2ir ,YdRTJ+E*$DK/z[ZSEU<~n6Ziz18}v|p|bNGH H5K~)HmrիJ$,fYܯZ+҄0a*r0Pʸ٪eqMƉq,t8Q1&AQfidȃ04dJt$\{:T*4K-rl[ɬ2KkTqaՈF|qoX_v[V!Cw!9ѩW]e_̍kW.iN#+igGl@`ZJuDZ,)0LqLn J*.X&3 npF$ A)Ő1ߴa04Y&g HҴ_-,Ȧ\|`(mФ42fǽ|i{(ǣ`t);iVyk^oktMƋAvt8]!j!h:?~|On{|p[FlVvv666 Fȋ2Ch I5}^- "G5@,4Lƣ)2vkn\9XdA4?|-tb% >q>^B" 鏾SBE4[g`):;M7ɕK1)2"xtr _G߸}G~p|tk_mv;[jucrH%u6A \i}ZoQFVU($0dZ+Z,Hg0| ǷohF=앮n_ڬUK4l~8onfųmI a"aj>i@3 ⦃^ ZK.8Q4w}ʴJuTj-5f:x%$Qf 'bY%2avcJg#4AJ>r`N;߼;='RJ%kvwݦ회"D:_U`DK#!*}`1O,a޸zW[FJi@klM IUJcY (CM ^.kqg Igs`#fD 8C"̲Ԉ u\ ^GVC+{U\ V:Eޘ nzMaR'Ep6fd2 -/nݭWkJ. rpYLp)!>= fg IgZJHPkM޿g(Mɍjlnnoi Hke 8@IG@eW9ٖ[kov&ťk 5]FQղ-7MS/)$4c<ˤi,M9t*S". -VH&ɳ={$OHid.%ZtkW͒a4G0?xo|vpT;f(kiMZSH\mg%Sr98q0pr+YgD I% Sf PJ.9Z N]GGG0\tZ.ol7:mZ 33Q#(O}g8 VRgeR\Q#P(l>(rd9*SQ$q L>FRJ3^I^M[#a342@H "jh-5?Db@ Hpv޽#r@)໻+VRq5.& |NF?LG{Gi ϷZ]im]ng9R-2abq2 <9}{{O{airy{}}wsjV*DZy.# ´-eggRf{.B:7m@@y`YgxJ@>Z{٠ Lwשּׂuʆ Ii㹇p#U7Kv>G~|p2}z2>pt0K49)l0FiqLUPuL °u4MOpHd<M٩Lb0@@`@`AD<䶧5t<;>:Go=NBnOV̭Nq҆wj\`"@M8$ݷ 3z*"" l2!+ղmI$Nq|ǶM64i !C $ 1@2 gCLJ9Z/4,HP+1d+/'؏p%}ڛ;Vw2'͓|Yx-ZJS7jmo6vYBi4/l<ϧ/?gvE}v#@޺͇N(#BV*o],|@ C92-8"0ŕgPe.90! ,BT<9O!cf촺r2m~ g߃?m~<|-_ORD r@x:xb:O|vY"jD Pr}wV_TH1dW/7/m7DѫGGg Fy6tāGGwP0i "BeҶ ,TRqH)FJ ق߸9FZ*ٖiKBfWm\6z[zlnn6/_Y_(oj2J ȇA:o<¹;ƕ hjH9/zӇwoݟ &qͦxuU\fs Rˇyx[>}z2yew3~u@2t< 0yJ,(DfRc\y`D Ȑ!\v!˗rqj  !# |YhټV=G{_D? ~_Ki8͎O<:~vϞ߻Tf>-@Aq[]+kbO__{^%\j90Dg2ԚEIzp8}7߽t6Rlm\Z7[M0y*3`38rԦ0oڌ|p,y n*˦ TϧdΦXJl<}ODzRw/;?[dAAAAAAAAA'B̧ tw)|IIb:IzC@*WnYo,Vr,~NrMsW/^ZRvr2yl7 yQI5Sł8[,LI%3B!@* lj$2ѴMi~Fe wL<6ղW`4B@v-Sĵnu}ky;߹nnvX̛c02ra/( tE#<]$rfIY޷?,Clmot~3ͮm A";o[=J5Ld鳓0L<+7Ct, h4cec; ׵tq H154 e&!)'Lx>ni. ^VLf8$YYּR|ٲG D59""Eg+~Tj׀o|4<ǝnvRb_Y%~$Z$v>\eNe{\;;cJ;}ϦӀ9J^޽S)Ę֤5Vd@6e`7}[&<θ=092PHKE?OGVvQm€Tl$:}WVYPϿG/-VwBj ʁH+EA>}6 䶉ozaI)ѭB<_YInI%Rp|| ?vke[FRp|2̔.d:Qj0 ӘL_+9 ǮoyoTj͝] a4oV*ukonߺsy{zPSq˶h Rͦ?,WngϞo}KW^x3w/+s kPw~U!<Pk<9ͧ׿ѳGZ曯+_r9üZ^ŷ)0 |NFOONf@tycWxFKfE$eNZ*9DaHٶeӔ1s+(\k( D B@L?.{Je4ip؟YLn]mVRk9gl_,}9֦ez*ʓcY4;쒆`2I0iڪ{>SyRiRZF҅w?E!4Q''{{OFㅒȀ[^ty֨ PB.s4,̷MB6+%ar9. 0Xa0Y}߫׼Zl s{QFx>GQP|cթ#H$L (X~U |4 ֍W3D3JVWO}ꗿ x[wZ;;7]*kW˾"WVN+Hj·Vn ےJb8JK [ڵ!`n\z!cQbYӠa!"!31sb:տO}ޟspr#˵>KFU09Zy]rյ9rٮT7iB{O?ٿ?oW_+KeǴ^۹I]dD>z$ '}ڏ67:g-(>+j,PJOLJpFgehֺnm6Tsc^$8gs`g<.đ"T}Yq <bQM'ds^&@DHIkH57F M)/ʒ;N_0Ǔ'OpWv+zq8"f;VNek*’a0D!"fș@ (mm[??yLFl;~]k^lt[ݪ_r앚tixonxNֲww׶ׄ cS)T.D!NNjQ޹SVO}z۽U*\Ҥ9rJ] Lh:,?z|ltp2 HFm7n8 aĆ`m۵l#KbMơLarr=09ce8CdZ)}g갾=wzh$x1qzT\O0F 5f5]SBg\'Llc|'l΃V˱^jRbD읯}+ <Ԓu.oQӄ+o^ q=U `0}ɻ7߽7Tk8rs\蚶AM[!87-# mrl,;cg3,َ8t H~%RplڭȖB"yِ^g&CXIpI˂w~z-rug~ J|.r!h)v*c8T*nQ^yx8=UVS1 4}I> 9*.zWDhY[FS›7^׎_R E=ۛWww6׶NEX @FbtJOA@$ӆ׻.}S&{]ioxh1q9]oW^3[YҺJy>8C}lVv^u%iΒ!2&B-TxyqOf';nR]l57ۈ=׶lò 0&(\eö44 -΄\iGj<^<2L֚Y~o/{w`ؕ._{^TF*كޝwv׷:;kkknF2\P.zccqT8M4 0KS"Zm y)NeY.֊FO4}E@ +<:8<2Xk)nVNf+Fv 5"P@yy(y&D|[j"`!e8Z]- hXM`HSənvj1-jәwU;e"ΟZQKW~V~r8{p:%4Q(TKn>2i7<Ѩ3sy \y/?x~ȃi˙p|wG{3iDavZ\j4jJ 8'H6M! e1Фº},9sѵqLbnld"h d&tkV-ଡ;I/<Ɓ3p9w ,((nG7L4,(((((((((#>=- V,ĉYUS^Tx &4 pڜ^V*vQBԹ? ϴG/x0;iFJ,ߪ"Jww'wlѳ{'JյKW߸ '=PWg~usÓÓÓn767^~vZ8Ft4zQQMS;!C{_R ps}B~*<8?D, gF߼yçAǰ[VYow;W KXA8iABey7ˎYai n,@FX0v=FAGM۱?ϥLEls_[?]dAAAAAAAAA"BD4 J$/$r0])]nloT|D@X|FO2ںij G_덆Gï^EV]omnue>ycݵOϽ׻?w_ֽ;4f'>ѹvmgsyvRuqz}ҲF&oA z)I-s W%%De*~orx<|z{zx?O0& ׾|jݪl65 .8 _zw#bẎh2#֛N+64MYcR$!MH*@L#S8i4inurն,d,IMX%m}9tvU0 sHpbέ/& "H X8kK(QYb1[/vNrh>*"4Q|Γ۷>yr4-ҖazwիvB+9#ki"I!SW̲sxՒڴ0fI 54ea;A*ku+v+lGb17M5%Y&,}ECLfl7~^-+YPPPPPPPPP^]Ṗ5Gf o-1m͸RX0?=LLffkeDyNQZg~wcJ%j8GvyLW8yk?9\8YɯF&M=VtOmpxɳ2m \#!rh07߾ugVV-]rekk(;IϦCO߂e%{Og*SPY8 gIpt<O?=>9,#oK_~ ;rS3{Zc0,vRn& j 4 DR") uA4Lj@1< /NDK" MxX̓,՜R&Cϵ?W#N( =Pj[jjV%϶-!JTuww}a(8[ai]F`m4"4/6mYNyluqw8[Z{^9~!Br Y#h`E|x0z>xt1OIQnonou;kE2(*- ,ުe4ȴm08! #iBq8In=yC9G *}2|Yœgc"ʯ+YPPPPPPPPP8?)}Gb"h$WϮ6+WĘן 0f\k*6/;C?FR3TS Oz(Gg|Əv7qû^rj B6Z׵RZiqP?<*)O_+?ZH(~w V0Q 4??<}W/mon/U*Z˶MWMNDϩw y`xYo7N{yigZe7,I$xۃ'~F9mƃp6y$ 층_*50RV@@a2Yէ4i q qHE))%iILJJl: 8MeVT*ۦ陲Ԍ蜠VZ|i4M$}u'5>|8xWjkKUDz*JEt8&baZ^\ݹtiZs  $"y8-G8R5! .*]rڂ%q2_''hu?k?NŲ,@l>Kl< UF\`khzǹa<>Ϫ2e/qHűS-֚H&ȕ` 3"R*e:o{'HQ]zw\:BFRQd4D][uV@A*%XPPPPPPPPP s!/$gi9@eҧ #yx4΢hf{Fv׵sʹqU`:Sӌ>=`&Uֶ#L< ?޳wll5-rc"P28ZuUlk7~KW_{}ݱNSk֥W.i.v굚Tj,&d2qQxS;=6k׶.^Qu]>s-˚WZz<pI<LN<ȤB,V6kڦanl6Mƌ1kkRh?\V^ȣX&b̧1Enͭ ZC+gyO`/tzJ.躨B*3 C'$04ϣ0qUn\zW+4^89:Fd`I&i4Q|i6ʝV(W*^k6cH\^嫜% Ȑpz^g2{,yTFwkޝGXi0 Vvz4|!ΏL#H0;zYu6-_Ht/z0C*s~XƂ$Ĺc(.F߹WUZ}\\q=4",`0ΦhOgQ&520jJu˾Xv\x(\0L[J .^ ᣣ۷gN,*cJ/)֊3L)$m8Cp\;- R@D<˥VJ5b1WJzgTWaJIAT'&-wqϽTb c"_>KZjD.#W7k.S<>>Nzpgts+%v,JT*{+ls@FkhjZʔɕ4y|q?S ˮi6N^/UAe\YK6Vlh4+q۵ S#W 8eb> LeӲ{{G4*Ei<&y<,<]%h 08}p Tߑ[}e국O>_dAAAAAAAAA% @㎡t Ô0L|G2~坦m`yEDE<:l6Jfӭ6_l\~II1{vwYXh7'W@i FhЛ;wZERj (gYNGI >lqFXfTY_S`$㧇apeۀ@D 1IStfJ+"@dRFjɿ]-1K|~be? p< WwAx:?j\s0H0`VH3%K 8g1S\#˔re2IdF|gaZCuU5s%P$U,`2ML)皌?65XܹHxZh( CDJr6 'ߛL|2 }`|RvkZV/%ϳb+%T/ ^ *͇d4Tێ}V:&ȇ@lB$pI;vPD΢$ijN[ jd1@vB#H5""dB( y!Xv: ׷!6m>)FűB%:$5jtډ}3VOlLiqZh\kK"C`Hzyu 7J~usk[Z3)a> q78>`q;y[[Y*ZݘNx(1:Gr7/v&Dd(z}r|<@ڦQV/m\4[1$hdjzrCPY\RZ뾪esѕeZFH<( hAIjZ6z9\/4t!Y4&i$Kt[nb[ I6'~kYPP7/v?QdAAAAAAAAA\qEw^>biWKh< F[P-_$]P_XpҴ\vv/MeA/Ki;L.h'j~YY2McAVe߷|z!0d=$}KuL>a2n׮QYeJ+aRm,rAҬn̓홇C#O'oR2R}3˜2pDyI epC[ y紤s DK -DdQk5&!&̲з$@9t:fa&7 #,]bI"go2HG1 1ʅeĒ8P+`(^p ۲,I|OUlZUԦ h_5qx4Mãߛ=o܎c5M5*Z2#w>Z,~{o?>:'qM.Fwg{ksc1m4bVcgDJ= SX̒m9a;sb,uE0dl:),Sll6kU4 #ug,Kg-/`&Ūכ8 N؜1Bl։킂ff/+i+YPPPPPPPPPƋ&^ȏPpmAhZݸ].m0yF$JPṢY|u\ /$ M ,x<#mXmnXI%07iMZV/^ @$c0ZG18X" 18<4iҥw2:J!"cLpAZ3d>tiGpaRV}l(A,e4I,VVu8j dLq"JeRe,;Ő!YhY\a$AL"FRi$42()$qqς]۪Uo};ɒZmc]xfTr=ߴ%`*Ne&!c:4]c5Ϟ?:[oZdG\Ɉ/.\ʼpjӣ?(J2Dq׻W.mlt2! .D$vSJq-ȱYJdY*ILw,pYƮ+zݱmhg홥 27_3Pzkj"Đ Xf$u| _e{[?UdAAAAAAAAA1;D7kmQ2;k_,@1&S˾E5r_V88&Ss}V{A+_$A"hY&P y~Y_o*2Z6Tb(\wg{R-~$2Mg_h`eRVFZuֺjŭV]5 继gܩI+$F$!qZKܥ!c*iԶq%S93(SP=?u7<; FZ;Z09C4`gZJ-kylVh[ȭDJ(PQ:Fq76ʭk۰t(sW#?]N՗ D`ǂNZJ%`@``pxIϟ_- >_xx-jXɂ<|(=Ĺu)1LA*?cڶW)iҵV=S4+ʌ&ͣT3J[[ݍkW.,ZeifYEi%iHQET2 #)%QII04RD`r V)xAzLxIej>_,|.p\wkkT tgBP#)@F2M^J&iՊ{eHϔ$Z  ˠ2D0"HdA5hm6\2-:`Bܺڍjt,sr9x4͟=;;N[jikFU-=vMΏ /E/!qQ\0aoIo:>R]^u˫C}p0ݛw=x4h Z\ٹ~JV C)sa1)AyYZ6W6J3ScL0-LS ( 惓(v׫7Jcr> 绛>KܯB3gx4 '(ow*g ,4G,Bn o+O_ѝv?׆5b98TfD/r4\nm4ʶc ( `峫?3%M H$08 2N~o6FA8mw-bQ?89s ii?;Ydf*clviX^]u\2DZl!GNKM r \QV{WA8}qIf$M$]@I۷߱nWU˶jJ-aԫrwulsI*5,MDTJLg`@˴*yaZ5Y\o I$Ð4KMisqlxȍL)X,tq|4ZZ+saI T+F4zs:|<>+׮muNsl6[x^}uSqXDި??GѳzYhkFe}YygժBwN5<.kb$r8s s?$C::>xxpFLIET^kuw.w0d&ΥORJ3l[U]3+ŠlOخ8șd<i$iVU<3Zڋ?<;&d4 LV*vl[X>⍫od ͑^+ Y|_՗/?_,cAAAAAAAAA*81" "%|7K8J lП:%3̴eZ3ڕJ^qܒ_ᜃLk""c" "B ͆e[h2LgQ0͵x[ XZ+jlc!cS,ZWQ 0-uV`H4|dQ$yQF tLa֫zZV]4jncV*la,DKNV-K)90Lӌ!(+mL5* J#%5["#51IHJ Chpe<`@PNFLJ,vJ,K c!|qQLqF SZ?yɓ۵67kKn4e3;fVrl1 M:_`덞zJm2LӱN^:zڎiX9JΔiPj .]bqsX,?45Lvv|s2M4m6MôL08pE8/G}1&0M-d2daowgWw?kfŴTr:Q!.+%χ@҄a&DYٳ,;[c̷իkF-G#2eKdæXdP1aHѶ$%lY$`e6m3=3Uwu[s=~?~efd̷իAU!_oWsΗ:l1:HDpn-Sp8}t QoCD˄`fЊq&sÌ5ķȇOo=?AFi??=>tP[}ޝnqt6磣:s_I|2?>>~rx4?A~:Z~p8y^n9l z`x @U'dz}GFEY9lVݻZg93jc}zPn7Nj!**|n5FC+ed &q9h|ϻwփP)|w>HJcQrz,hvx 4YKA"_\_$An | /4w0U"E5%8obx6q!rh6/fQ\שAAknw{VP ]?pܚӨm-ϊ(G''|y̨LIIbG"KwMsUw*ު޵՞68?> G<7k8leEkV^ZQ_ ܂~h4TF /9_ ­ kqgҥk Rh(K hX"ŧϧI fu\` ^h5r@cXe_9kLTk٠ㆋ8>4/ kaAi8oΏ /?A=dnΦGm1ĤHAYZfiVE^8Ԙ2Ny4(,4+[ KURyOٴ_3d եMHVk}odzh:d2=z6VV{权~5fW5j Ŏw_/_,r;_OOfÏ> @[?V}=WU???;Na9CՄ庙a Qj ꝭLH7:TA)?~4؟0 /^ӳ?ߖ 5n ExsyMdr-RIdb>v͠P[D\C )Gc:-TeQcVeYjfOp ЍV,8]̊EloĹ7qE?KjTs]27I<l1O~xӬn ! yf4P|Bxg8nZm!IŬ8;[' akը,\40*mURNTES^?&;Fmtڍ~8^i8^d8h<i2Nd{5Ok~Ãvv:zw|0qY)K/`f˖rO_~B֖)ѳO<=3Drgggt?/~SOnV (+ NgNj$.h:Lʲ-Zky\W F^,q eyux%^lhV37Nn0Ҥ d0xr|xW zV7f(RcsPYzg>,ʲzt7UER&.wB r)Vfk .CD@@$c1Zdq|&%GZ%b& kaV 8eEn4w|' fnu{ƽ>Jt4y~vz<8K,0SˏFnvtjw{A7kuu5h k֯+)uϞ>|syn;80,CԨQmvs=ЎEb ,0:=ͦwt{{Am^m6,3&I9$N&i9pZ@]> +JGASu1~x#%`6?OW$ _;w )xXPT," X@dv T3R7*+q00!0$E+GE ,O^2L'YtYn5@' |>Obe4@Zz2\*ުw?=|:Iѳq $BYϞJ]uغ!u%Gf&Gm?@RD^&7[t{em;ysqNeYW\Z,sfK–V: RH3UO@& u4.+k|X)] 5* zD`6 J9gOgd:^qNYc:swO[Y\OKb[|>%)$l!{vs~s߬azGt"J&h:/$&"Z>hŵi?whk4[^ kq) 5#QYvr6}gkJZÇۍ^ kyVx]ja*RkâU+ PqK(*'d?MvA^m7^%{W/ u)D,'آ֎Exbd8J?|AݬAﴻf^k7[q 4˳<'t:-x6.E4G?< ÃݎpF, {Nzwɧ?{l4dc=?l^j HiZk:NCX*"6RQt={:Ssz^; ^i+|A LOOgSa 5USy@+pʦ 6뿵Z7Mk'NEqIAݬ3/WmO 1 䭶am]@j$pnfitQ,kZA20dYIZ J)hԹEϣr<"xxhJ,LZM#V)*28Xy` Ե]CGVM*ۏ/ōzq W_yG|qg%0PKh̃mYEQX"FDV`@: S,C`%C}@D"/>?r'AU:#_v|nD˔gf>_Lɓǧ{Z8)̤)J2lh2Ϧdz2y|$z o7BM$AY"D\ k+]Hl2",MaAE"N)x2ˣ"-@ `A:\Dhi%)] `D @5- Y$Kܵ3K?30"Xf\5-dQfrkV̀`+kհyۉPՄo JeY4EAdR|WU7>ZThj-E̩ϴҎBT8g$5yZo=w:5AJs#|!w!*J8;͞|>Lfyp~pad d/4(5Qj;iZd7ˍ›˄BBD"BEa;+2J4/ k^Njtu:a1jG;C"ϲ(J{UZ;SUԮQaie8Z+::E-PE^J5lQi5q!3N'bMi[ϙVq>W3=ա3ZKY6<}6=^FZ,JNҢ(mEi^3/1NYf8Op6Otrrrg(i4^^oԃVV;E|%qh1|1O4ҸH-[_;Ͱ>ۻ3uv'Z2`A*ͺnֹ!υZ}he-!8Qlx:JcxZ 5:\]Aގ6ZItz2L֚V+<zݺVPn/%B"2"&t4~@t p5_c_xƒٽnS& :ʿ{/_sM*7vr*O""FTa$Ēke@i>(="˳Y9cH[Dd*EdjKcIFQN^NG@1؍2,ӳ[9_k+  G|5[:`m- {?i&w [ZF=B$0l60tNkfA(JeX0<<IjAK%]-/%/ PEzr2eY4ABxCF'GV)D^м! {#-'a/?eign 8toko}{NgO} ~WRUj#C ,,@$  $ΌQӭfHi\,<yl:Ɩ/<*EP̌lcݻzx8;in""Xc-QE'C6n5=kZFsA﹎yJuTZ)5i5?|>~R#TkT ҃[SZQtIfX;,P; 4+T\6T|ƧϢ"ã;f!+WZ@(b StEfxhQw}_#a!&p5h Jat+6%5QM:_ o_?qr6p_Xb% v~_y:~lNG& [GX١]%2p7NXt Cf< 4CEEnΆc$KzvSxzD.% XjM֖Ḥ[;Z[cD}plO>,ɛ"O)s|D YӟeG VRtP/샇;"jik91ei.&>y 9.[[E4{5QۡNM1Ѻ[dֺ^1cPg<[ӹ}|8|f~!1,̤SyXoeLR{:L<̲vk5uY`Y4FE:/NY4s$ :nd%Xv-P;Ji}&M>kX0 ׅUA?3Sa,*φɈ˲ھ`U4¾瘷> -Cd>.(˓F}pwmz¥Ͳ躚"VfWV독7+=XTGA2O?;oOΦQw~Ʒ$>  »Ƌs!/4w8Wy b !*GibkatEez'O$$PrK*bJ)cRV)l ɲA"BFfz_=yOK:c-e\ @!`ЬR,ΦNXhwGy^9_e%.i hi2tHwyw*rۆ9!`PZ*A4EzfRAEZ=L'vNDY5粅OtXcAlz|rEƹ->> kAs="b,O<2rY?{m4T iUUm-g?N8J:{;|D & L*0͊ph1O`[jt"e19dzhN4Y6U:E.KȌt>gh6Yyӟ4/|gd Bւڝ^5;{xg.˥.)@E,Kk/VJ[kѐQ9."5 H!ߠ}G4ƔcJS)(PvT53Q$Ibʢ4 6h~G)TO&=h 2g,Ͳ(/^q05((s qeUeHO㓉V6xi{Ai#+B \ Z•v|uYbAAAKǫlC %KHt7Kˌ5`K'90Y@fdٔ, "!0ZQJYcUM!"w[Ts_~G|G~Ϣ8֊| }u0;f{{fV°b[p9XkKxȎ"ekʒs0VKXRiZ䄈nǐa1ٖIZD=7Jb,8-KSYYzZcy-2 kyc=gE<φd<N&! x;^_~T! |GkBd6\6M0gEa{-WK:7ۯҹ;t:K󈑏zwap2t o\Wfc(;>MQ䮯?{݆i<5JUv+bYV^Y`4xiZqjðnqĩ5VUfz.Q\׳6?5?qdvn ]W9ک׃N\}{u 2lL,-"?xp6gQ;(JuJ#cp8omx&Q%sU%* šš˵4iZ9FQYdas䗶knF^8y:0 # ea҄gO){^| EՌ6兝Lgn6jEKC'^k:?*bZuXI2 yk+A"o   ˡ/~ً=2.#篆È ÞW+ k /lO"X,Z2֐&4f xSp1YgQ_uA.!"ThQ%h" \df\ $B`s,Zfk-dBm%dFf|iNWk5CD4Y~r<;TYd&cؖs]s$KfyQ2n4IqQl1OZjցM`ҺihQy[yɵCZ;qxnJ!3aMxddJ@{nR1ر*dFdIRi<4[Gu7`(t2EZpxv0ԛ v./?; hGR.20wqel1MlhZyp]m~|Wt`"UX/p::%IҨ}xmhFdM+ªps2T;!   *^xi2%U1/]FɁ@AXSn;gqǐ%̋,7El-eJsbH4EgSS 6l=c&b@"񪺖fZDDDћsvK2zYKJt[F@T:z#)e@ŀ Ir\ YQy8J<+*6۝hkl:3Ssݣ~l &w=]4vaMyTLY0^ICUSʺHU īT;,KHsz<.y[G p0~ʹ /-&i||v;5|]UEeܝF6hgl8ZLfE+eZA/byy 1ӽ卿_ƕm8UE^]t!Px^?Բw lW$k-Rea̎Xk]׍$MYTE`BDVDĀ dʵ .}xq\^a&Yi+FW H$(|;:7!6ls_dĦݪkdž8LĈHdI+sզW,MI4$iT?<4@_ zhK &p=~zG{u*3qSZ0Fi1&ϟyA4uɴەTS9m%DG\F,0ە4BU^S&K82"MLHEG^-yDP2#yPU.sfE1.Fx4E£^h}f -yiH X6$:9i8{v)U.omk /hl[ZLb>'h:MFEd {^6!kMJ*q\MJU6@M6nvv7289NyQff"V@<Յռ̔<beuoRS$HAAAAxIگ5}; or%\ Z/7-Zd$jh#* #kT[`-XJ(]L$p8t\G+V4NiHOO wP5yN"[vBf5tQp^fѠ+I,r''8bYrHR*V"׹%Q|:yi߹ s_yQV?/0uagpk4+kxk^?d@ka<&p8QAZ}f:;.E\gde4;~^ht\t\i6VUvK| t8. כNЮb*=zcuͻ/w !RAAA^9Wint0;)*ʬ0e qT$., Wb l,. (JcJXDiI@Uk @dq\<M7>'~Gѧ}߻ :Z,K)ݞr:0pgh:ɏZo||nEw~<^4yOyBߖY:lYitH㺫P. q1>{ k_._8Ghx:qԃ^V =hg-quUejj^Ex<$e^6~۩y"_w4\v `,.b4ON6 Z+\fU|ԎZ^/&]ƚ&$+m bMLDKovJ#U'k:1cZ[o}$tlAAAAxU",|܅l.d)/vC4%;Yb˲?>y:3ǡ^ۿwnȓ$Z8q]u3kg@ǥ5"T~.F^m4XW_Vk~H˦MAAAA $l_iJSLh&]o+,2HceY42YZXCEDc84Y#c(~h4(YE~6~=G%4k6_~ ~{?=y2dfYBMi]%XAAA/8rmJ-b.pCY5qWEf2Pp6*4gEf\ú)m$<n 4eh|'35qO'pо{m4\E\IiV jUH/Xs%&ҹp|˥jURD bB$B$&5q&m[$s)#W$B~iB"0"B   AekR!Wo/SM}+_*+M5 .qT֘0ӹV.Ẓ84ϬsQ+Bd"&BS41%Zڼ(fyќN0,q]r6=HiWX( w[\EB:[b3&I>:^{0贚,*Ktݡ^7$@PXE09{>LZ{ãz-TԺoJvȃ[wGDv" ňjLF<L%1\|[2 )   —/'u,UҨ[ ,u)MA` `ZcJ#Ҏ>lo+on@Pv[!!G!TI[lO4Mc/Px!b@_ŽDH*KE鳧8$:ݰ@^ _v"UFDc0͞>ͣ,N^(]$cjhXT$)NG㧳, <4 qxl.7J`eϪmDR@Tia)RLH GXe;VCkּbc榉SAAA^*,4"oxY;*FqtW~P׾"Eڸ"+5@]΂:tEf`[3"D; h-XVfm=7hܞǺ{慪*IRϟ?%s~0փתµ@v>Ȍ$V)ux4LqTA[»ˌgČ %|.f1 ^YD 勵鐸Dyzr6,Up_Rl㲞Wv]en&-jv\W 1n3"]nW=)B\c-9/t2Β$ 0|Elp+ns"ٹ"X'd'CB`ohTk m~pUi40CYh2N]=j&˓⪓#"a*VEdUj& 1"R\װe䋋İ}rGvMrY yEdA   « u]ԧޤI-6I1WXjlTH`FV-U)ei'M&Q>`px}h XzمU";;GpZu?D Ze6'|lݔq+p V2-l)nnp    rg#_>nAֲnqץxWsĭos/3r //2q.RЬMHG@cf$Ѐ e_eb%U9NeP+s)} V%U:^8nre)r׹۲fBm^k xn7";nK؍8kv Eӻ1 Uj5w:ut[@k~q ꟹewnZ1F)3f+7қybxށ/G    [H.e1 rx+lxU 7_^: |U^ /-wJI![7   kBT7Żߎp*F3v W͗oQ%v4VK,%CAAAWDT7Ȼٵ{ݙAv}ZMp߽ގ1p\/ex e8*kMϷmˑ[-˥oG]!/ !VAAA^ B)v0#|56[\7#Nj^&7ZQY)7 y|4_<2n79FsfN"A   BTH5Uo1    o|F=0wFK/Ԭ,eE|znׄmUboR{/[,R    _$ŀ s^wt߀ 4޺ZR5V7?o:#ǭEn8q=MdnnxQ^9"   K"_U0]f^%o;nDoC_DAAA$U*}>MeG%|ww.P$HAAAAxY$mf_UB׶@d!m^|[i|4;#tCïvcAAA/B~ 9j5̀F39קր/;n_t   pk"pw+     _\H8y׾ e[KV/,6Oޮ]W%Ă   rMW _a/$bY}uv^|&__.;t|c~&B޵_Z _\w *D0J.W*g/ǣ    lTe8~ioJM so}#7_ je#\w"{eFFWwd>o׽gk3#L   p;%o+H׻V"J4AAAAxMh$``;L,%N7ʻ½;[q-¿a[e%L)   5h$JT>oM 3T׫"LĭCw~]/T" /NǪ Z[/߽[Xz̚qse_!_?`fR7/7"5/ܷ=oy^,kw\,ܺ_/~F]<_H|MO    |hHiÝGmrr2nU sNěKR!_Ѷ/r6E^W׿\[[GViZ[n AI<*87^Y0j|ο7Zʗ+Děw7ع+.]ʑ_yUdv   B40 la5 0­qƖ<#Yki\:k; ._犻&mp;NGޥXzvhF;dZ%K67k>;RMEӌ­w_t*䮵 ]o2`a+o$CHz$*e{HɆke7_zܮmv}Gk\DRAAAn"m,#WBF ,trq.BUJAEw׃[wW_Bke5㵫eiE6UVpiŽ#yL{a7NDNo\w;k<;So yś>w7 ]3Kw>|Qݺzw?D\.\+DAAAnyDLIENDB`BrianPugh-cyclopts-921b1fa/assets/social-preview.png000066400000000000000000004311161517576204000226140ustar00rootroot00000000000000PNG  IHDRLBsRGBYiTXtXML:com.adobe.xmp 1 ^0IDATxw\es[lzo RE4E:HEEA[5^7N{qg6[M3J;sΝ<̚' N00000000         000000000         000000000         000000000         000000000        000000000         000000000         000000000         000000000         000000000O̚'q"a$DD"DDDL$L}$LL]ww돐-;G}%H~A] ={nG/Y!3qn]HTSngFm?ļפI=cK$݌AwOA,B`$Zzܺ>M?Ү{RUۓU %@%[jO^{Kn}v1bݷۭs{/X{qif wL6tz{>{ߤbدrm+wuDŽ>^ugS>eo麓8,\^w2 cPu=t&' ;ΞDoM5+mZ]z֓ƞ{]ou= @o~6=w]/y`9<'S;xoӺ+U튗#x3^ɽ7?xSP+wk'/_A7—)G@q+޺^?wۦ_6?3ؓAn=y+=}Ăgo@,x]|z(bOz<@l%9;6ϟU?}K_hS\3k=^fҽo^2ۦ7nhp> ^owױ~~GCH~>7fy^*fkYD_La$""&cWZ${-%BEk抨 Y(~+ړtV½NJ`;yvq^\ ⍙bܵQl0]]94LW1eM&l?Gĕޓsb"b u3"oAZ`"".Խ-o_8xC4x͒}V~_eLgkL&6vW)֛XH&Q+ )Sߙt>%~zojbK~nxi{GgM/Mxֱb2 bX13b2 Z ueoM8t?GQ `XO <ԙ"F~4@x/M׿oǫEkfw+To KSD/M*92G"{[@gc>8orZ^ /~oq7[ba&&d)e^# `jڢIkEѻO ڇu!=90lj(tnSg{`)̛M3]ؘjWD͂|ޯeW ^A _ݦ H[ADbԖ7ꑖIŌ$ we*nXTk{oֲ5_)!ow+!,72ۚ[_dY)!&agu7Qy D5-г^_^ æ _=~>Y]G`E 2=bf&ZC ?L54`4׬{Qt[0i&!EIlBuN"!f&n/ WnLL5+<{tEpqZ5h{'06[r ÷+~Wvf 4` }{7r`R,jN+DBdmM$&o=[ϪK}koME^T)jEX ]䘹+<7$z; כoƎvJNW]"̃ʴş ,P"VQ&VJzG7ןE{~ < :zFčy|\O,,"N\q֛iB"ffE23/_},w]nb~ld$O0**Ms̠OTED?V6+2$Dݻsu Fo zCD7w2| "]/R~ `CؘiF,VIPX񨿒|vY&/"'X]9@`H-=ninЙirz@F\ 7jPvx#/_ȷQnSKZ˃.J᯼qaȨ~Ҡu.j/ lx4Nr@. MLjS&&RI3e 31JދMRGdnY-ʾ_} V,OA] PP#4D" _Yn̬f[e6",SdQ޾ f~w~{w<` P6N}*&-^Q}y/uT=N0XXg:tSK!'ezg#l[O`P'}Zl>&[G+36beشTfR~~V~6 F@MK>fb2H|]uߝi9TBF~)>_n2sT_2@3kQPd7u [wW:\k}Ž3,SvVvzÕ0Eq]=ša +"8[!f6KFW1v]hϐ2 dSnk,:62%RqqCDH4"]$;O@jrb,:;b!5R+Z{b=MK$݌))7}^Yw,(mswfQ3v43 Amt]׮։&krWD*k]w"6(Q:2{wa-LL"Bl]dIb;Qٴ-Z"EEkV6HGsD T?5iHK |0$k `Vo[Fb>%giR=d,DIwK)!`b{p1$6 ϖ>A2'F@Ѡ=R$̤kmLYbD,bf+Ell0YZf\ϊ=b("c]Šfz1,fb2 1X,܍-Ŷi66Vc[!nɆOa!&}>3/ o>[v_uVz e,%i}1y;:*XW\&כ:WoͦOFJl* 7D7Rl2kijRĊXn n+^fng7 S6+QRl(2 6M,L-7>}l6dB){׵=fLMPPoB o'w׹_}u) X2+( C-أIe)60M3eLSY&&Ad)&Eyޮ$ ,,?~ 2,mL*=^0>L BL*;+b(6Megd|6&D6Ť^/MQ"柁k0OhER7"w_id(FKsWM[nwB#oA\m{E1f^KeF7'EDl^S)3el6ʄ"-}%6f%::֮&f*NcWˌhRIG"L2HjwID\-Knn}_wW`y~n58掼&4B,9wOSdYFOqoz5w>7e<[ounRoܱF}3) BDn#fИ+*EuEW,Y8og|l骕kd:0RZ 7u"pW&Z(Re1FJD%-=\M.EDmZGaC4000w%+l[v\1k/vI74!%q?3\h!O|MȴT&lhh]0w{:E /kmYpt؈H!C#677;|m/bh_=n{5Eތ+K%-.(-k2HduIX1wp!J9j"JX0+;zk {օGH RdYD(1u{c)IYh3O,^!ML7i].+GB0L&MheM8ˏu '֨Zfk,)[ҋIbĊ`޳37ܚI$B"EW\"GX\!bX  ##-8LI"}Ddl1?ğ)5 X\-_y_~9;*T`{Io?X| 26ܗy筭n&2 >eڄvvcFWK"*d9Jk]aWtYBZO^tE6gnWÜ lے^L8uv)B^4'-⮯}DlMy.CyOIhw!yū5*`ma ##C%)"^~oM~O'~[v%$\i͟.}:{*>C%xd_hayGIԒW8,ɈS2a}v\n8)wՌzN uv+ l 8`*A>EbY +fֺŊ6gg  x̚3E-&RTox9lVI",\SR\))5cWeoR0ol^cCVK>%˯ߋlV_[颿GxJy28M11b4|7!y^#VgWNR̹$̆ɶim`[%dV,w^^vS=z}wPї,_f,bv煉O?h>p:ClRS,[ִNۖ*~8d-{ϲͫgra~R{åpu:_qWrJS!7FNBel0)b,%b6lC:xxc'-e1C?tbCܵDrBiiYYLJy2ЮuU}'cDb4gA~mʾCYϚ_I͜͹?Šoi-zJ(-m%jŊQ/c-^[{r(/RlŁ>?/7ڛbJ1[V3[ɰ[RwK'u k=oUw~:˭('nF )aV^b,]9JrE\pwΥ\jWx-}TgL,D2;2;^|=qk<;wVW״-ّvDaiiiH1)E^0xldg|ۆ 6X0=*_⍁FlC,"1.I * lGXJ:]+ 3+aa0y/=/Oqr_aU7?;/_\w_{y͟W}|Hnkԍ)yQY.bָw_hg>fHENb""SiҜԛ+m]?bi;M1#$e\5 1QlެYV)kc/?6;o'ohѐD"ݜ<rVԿk;t+o>џgf(fW&k SdEzE O֮jP eC +*+K+EA6"!oZ{¶Wkl/h֮KKZSڕ kUy!enkgn<ݯ2H&X|ބB2 XZGL."֬ SXI]z/n+pѧi%^z+NxFyq]uǻ,!_+G|_~yȑ|_qؙH+O<68#fQhdDk(JRtW2IRvI#a=6$$g> vm[zY?=O5G%fV}wZx޲h[jV[]0}PɸNX<O;-Mի׮\zmC]}#'bĎR څ%UC Y6txiAiAAI^  `|6&rI49."ZDdHn$+ lXT"^7YW3%B:B吞*,(ױ`QJ–(C ٢͗y.3LHږ|.\jQP=.;qaKJiHpY†O_zuvTUe%W=:k֘i7V'٦_1ydqlIITa5VwNÎR ن .NNoo>}ywߦzYOᛋV.UbnD3hٹuѲ5K:9rDi,6ls;;9&A6?^~Γ&f5 g_xKϽln\N^we>ػ}̏N7ohauLDڛ/usTCGN'NU2tx/ &vhNgzm^"TU;?u{ݍԣSWn:I&h<OU׮\vmSSSGGG20 -# TVVTQQ:dhYEUAYeIYQAIa^(l۶}v"}>xӏi+M+j?tI}C[[[3Ɔ |kmyW VUWV(*.,+- ,bR$hץlѹd QOѤN$TĆk'Ə;c9t\'v>| 闣Tfa%zV7O'{^xY7^{=뀭?}gs;o-4}Ӂ8>w+y巿[G1kݷ4WZ}&^뤛ꮇCSrv(]O鼛;~fMը%&lyP8~v8n&D;c-mMukZWhoonlJĢN(P8%%%CKG:dhq( y@4M6 &$:[LIuxp7!3-ox7͝1"; >_)庎#iJT*f2T2ΈkZ?V:yqS5qp0h iGk Dd*(_ǓJ)J13]]7wwy:e]w1lۥTRR-N{B{Ws˃g_L\"yB$W7zM;ex&\pU?9|XgVyƙW6rq,{f(X9ݗߚQ5阽9nDUUtH9WՐM8S.;%\Rvt1 #ށR[2sON3T&H7467]zƖ斖榖h,rRBi﫨,VQRWV9tHeq0+, EB~iBB1x Kj"Ӱ^=zYS~adQ&2uÊJ~6Hkc&3g7ɎxkK{ݪeK^SS[SXO&>k!FO4j'8 ϶Y+E6Q):d01ab+_`>y#+I 8 ?W7DlQvz8¯ɿo! R/'1f"_iey[0!"w>^;Ӫ&V}ɢH,x;i/',SюNC)7d)2ҩSiYVIyi0? òRJ1+Jϊ/&g ?v]Wn26wT7\[ml]pICSckkk}m}g4IeJ$?(2x=j>kW{j[ᅰ00f7 x=_@JEzeJ^[dMD̦&&ŊMV6)K&ڢ+|>| 6GBS =Ya%ߦ0)"LW8L#?\Y_2Mh!e[_Girׯ+ tFnzfqK󢩶hk+y uo6]aӆa0k-"&5PY>b4ץn'|\qG90bT C6KK-Z^{սc͚Z_r/pO9d13}ypAm,V*Jd2'RT&6C@8 B>͆uy]!ɅcY/:w Bډw2t<]6^[vM]]m%Kdrfヲ|%!~VL}=# #/2huMĦZg\wB~4Ѵ*}+)b"r{͋;JX1Jexwfybh5edjJH[j:O=]x%g ъ"B]oIgD" &l-qhk^wݞv]M'{`g[ɣnJ^$0tБSF /)*/C~1Y+̢r5Ҕ@gnwn3ê*3 +[[8lxgS^GwcKڥLډ':ch<ՙI't*銰>B!;*()|/t]H$tI$%BHA$AӶ,m2 VfxïvI-'wM3֤u_ZB_?i)-ӏ<~S8p_qQ"J$=e>p;q~7c߶YRJ)6,bHXyx; 7^BPf N?XΛ< +WՍS#<ǻ\NJ=vNjç8 MF&YJ1+bEDFW#(fְ`o+Z潽xa㧍ptxէL2"Rtgkb# )>qDň!/˸n*ţx*Ohx"~7~;ypa?`eKq'Wm))I+XeÆT ^N֮XSbU ł/>99lwrzlX6\otd y /PEKq3'ږPZvɧx(_>4R !Ѯ(ʽU3hEb rt͜#9q38xawӜk~q*m&1+mۓ0 +qi:Z++ 9L5շ~g>b<`5+]~m5 MA_^afMr-_:T于hrJ^tŏc铆K -{19O~{ӝNцa{7]Ïڅػ-9n);!Ve&q3:tX*GɎv8u[J0p~ CJ)efRT,kookmial+ #H( o|mQWW`i-DCg2_,i6,{!!+QZOBX"{_{Zyyhwmg0qIK׆MBh`kf 9kޛp܍Ø[Bb5oݭ/˸q 1lS͜2u_PhWk%t1P(,9 Hxu$]QQfEv83GL~f۰MӴ,&RJ0 9K lhg*4PxcmeXEŅmd(D%.܌ۛګWYp{~|q2Gm9~׃wai&6J;r~㭿W}ԟ@A5|xo&SOV7?SM۾~de5a]Idr@]\sh!uNFɄٞjo9L3MG _0ϴ¢"f?/R_1L8X,Hu "ReBp0>eyEc-झtv'-X]MM0lm]4 0ABuum]g]KчY|,qq N g>'N?4Mc#)8Ol=g&NL_|<'T K--km7F5aڤ}gK*#$o6 x]`0,#,zr/)n㇍)<,e[eYLZd(L2RYɪ cf/pW^dRD6LaeJ7lO^yꍏ?Ya䑻{ /<nodQ~8iؐ𰑅|H7WY4 zL)f&1?\Qd;|7f]gKc3M%%k._΢.TaISw.[4udh`MVEsڟ{|Eu/8v"eE;NqqN&\ӦH e1x{bR\U VB&)RHX^}7^S5r贩[.l~9~~?miy!϶C$u3lyΟ9sqF y$[,r?_B.Ə13jfeʏJFTE"H(}6*xr6 ItWnb3ۂ,BՙT&pD=3)I%p&ɮ'A͂b_(d$h{,wRtƕ#Ah\2lte(H-u[kݸtѪ5>LfD""L[,մzusucsO?w/9;9NFu;N0jW[+{@N-N-&@FN68ۏ?4iCeU [i4Vϯ>>kO9rWQq'éޑ^n|1ū[or1acZO~"7|m]P{ ?ˋN:̐jv3њ$ťB;Zs Snp?IHoive[63 yKxzq͊5"ŰqvaIİtuꛚ,[ǟxgc8g+r=[/ыn ѻu;dtՉd:6wwX,d\l¢PW\TPRQ44G"@(!vZu{5pv$y$w:iq2gd*dtcSb_:hOF /iˏIlR%%v(kol^\ͤmC.[V>62[^kݛT_w0l򺓇 )LZQ^2zWu|pRL"à&tGg<K8nyG#A2#y!}yp0..eIkGlj=BRq87l['~xg_x/Oe &MeR Ս k?X_PitS]fU^Lo|8yCĽՖ̔L:kj;iyH:gn*ٻe3%}lY&9n#WN9M4r&l5u̖SFO?٤֢ȴ֎_gKV'Q+bv۪088D߉GLƿ<|uw{tTAatee>_sїsF($k.hLjw:-N}cb7k:"ѧ2tl 8Jj("57]wT`v*+O LE-mVr%澵_!lxkFkaQDE, )>'L$g|kng3L!HrUGM?Q7)/;#!E$(E^6˓JK.wMd㸉x#lkljim%cx,OUX(/6,RRY~϶M'.$:s{]b ֤TG}﫪>eWרT%kWy̪D\sǿ]S)2#’ʪ¢pyyYyEAyY ?/0-J% sCћ]6b/[Y۟> YS@IQ{}42`M7+ѧKz}k-xwiK'@Fdʼ~v'V>.LT֪OW] 7suH-Numc~hj9'1{mܣ)\_oFF&Z隩=vQBŠ /YZ\q\Bpj3սA,b-RJ֮$R%bʇ_~rE.{I͛5yg5g^7,Z0(JZ%Sd2q㊈Xi+2 6 ea( DJMl,hZ2Nh2O;;D4n&T*S>#/(*ȯ(,+)(/+,*>o٠+Bh&/7o%ڝ[tQL^Ӱ3!}XNI3w]o]a8nڦƖhgSkJryvqq^IqaCk뭶gqxo^\+/3X̽jDs#]SolMih}^ڎq} SA_;p0")6Ջ/[,sshЫ)D~)P~L[X'IɸuJ"]vRrWvTQ$N>uuuͮM-w_w[?{^z+0𿞿;U L5u54tmjmmE3vj,PPb1Le[f~Ȏ BP  }vgYEkŊD\quD3h*[[;Z::c흝!0s UeeiAAp"_,*|`Я|LJS%x{/bʔZ ?[qf%ܻuH)uM7^}񟮞:Gx"EhҕK,;gΒ+[JCguʎ032]xw~I(w~‘`0l;b?Ymg"`Vf1΢%2swQ\9 dž,K+\5oe{ŗQ,f+}ʮBƆtMC"Ȣ+OW-+>_HD)^;ԕV4!'=i))yQm^W˥l $#NLG;tCB^dN~/"h4m,5s_.eev½2pCsӭ`A?=+n?xY%ev,nID]byDDebauQ3+eLm_ %EEHAW\)+yA"m*V`!8;:;;;;bx2Ad"R%CGN2|ǝg⭉OIqŴ)mՂWWicﭕMH&ڶX~7qUEH;xa╍ dƶeEeq2vO(ìCOW%䖨~ )!n]&v--~wV9d'zi?.(0MśRݷ@XzqYR?lW3b#1hvZ5p㺭m5W.mn^1uTחϱ?gzgν%ڸP?pq?g]~ydm8DYZy`-,BzPqyJrq "&2 ömFfyI$? R\**`"^WbM3]t&rcDg{{[KkCsKmsg}}q?g'oҒʩFivf\aϽQF}םވL][Z>?>I[NFW뼢piUe(?`ZZt"ƣ?w>t~zQK0}`y3i>]ϼ2{n6lwfүN<n݌j0lF Ӹ亻 󢳎ow௕!Ч=bJH7ۅYe\]p}O&Ljx^^"ۨrkڶM}dkcղ.T}F8+0 17xd*)b;̚\To[R2hZh$sUBI$ӬXMR~S )`դ:VyA"A;l7ǗpUCA"6HHv]q5i!!ebRJ)0PsBdsq}+wOyO_G/k,X=waq\qR䳍<YYި1[l=qvnC***ˇ,bu3άD)ܚ]f=i }QyLrm_ﭙIm u6%DߓeZ&3CC~ }_"z/~zwŋ}2Mas]=)kZ0%^#\>EbaE!X㸮Wtܺ VD̢H(Glda't[͔_O?r}u먾% %Zy fD6,>|Ej]*aE{}U~^|v2 YRUtO/yϷuꊎ o+/^ި3 ʽ]Ig2y0n03gtZg)#inT(s8{ҳ);wߒ +>k{H1}mb /O|J6H-<ݵScDVLM7vU׿`C?c/^qcw>gzūj(RZBZK<~hAu3ުDuw@hrkTkw.k!--TgQe4]y5\VSoi'[frx' ktHVJ|U.}#/z6˳pg]55m)f7ex2&8xA$1rq9/_ 7 7H|mM߯<^[KBk:`}4|?IEHqK+w+ZY1zu߯w=_D.?q7?_JK6."Oj\diYEִu-_%)GSͳv| ljK ca[ܔ=h{Uo{<)C~2( ZȌRU6ko޵֤uFD(t5,oj`]/u=^~n'_hYuS* T^~hO޼˪rV[O,(3 f嵱s9ebM] ̊9Wh1-2,j=/{/Vἠ&'n 9J؋Kg:zO&K%{c&dZ:}/;vɔȨ('FViΤ9PR`?Xڿ=fAP%Z^Z(L +0fptY3K:{T%G|GgM-+|6I]v!VIiEQYwsS [ AJX1+[o5cYcld(aaDe:7e,Flڔ2}[~~W{kW^V;ܿ-66 3 ô-ӶL4 |`R)/f҉xTՒN?(MM- ~(b >6oҷ|//f"}sfDWɗyPCU-VjdүKƕ|]f]wS/ke2&ڔ숅|p t݄ab:P,%ɰ̂`6 3ƔlEѬ-L]G{K\Ґ|:JڈյG;c=F333qƐ| &\`6 #9 ny=}4wQ{<㇏DyV87dĆax-/-ؒT6Kot{qW_?=#&ěpZ0) ˲ s\b"Gq(!NhvNov .^tђdTʪYRI׶Dߜ.'$Sk|L[,$nG"YӬ"U}ʿۨ&=$Zڲ>ٙ}en'KTB:]œwE70BCCOѻD~Du`Ly`B9( TbBRD:WGi'wyѣAB{ V/[QYPmL.ܻغ7FŋހkЦnʫ_8 MLtyOOo#_b|4c;;K938=Г3fLSY^ |W3+4sƍ]wW,phm.Y nȋ:򢽏h#/ȋι5k2H'e?֪wo?Gpôm=s݀[Yrb8hamW^ IP &ÌEW4`qum40~]yE>dc]eUm3g1{-Ov4twb" F=-m&UzVU"DN&8jiH_}έ+/g+~aO1a;>7>cICEwU𴽬a~Zz M3#ɵ-n4)Iݙ/ҙt2?0m8yU6wNciԊ77^Vd4ajW_RH&IA6sY~5~VNk*>D7I'VҮ[N0Bpѯ;hBKeDX)^hʆg]Ӏ,2u'wL?[X׼I(\^62f>g=geˊP+59=N=4yKIȬoq`Ŭl?ҿ/Y^^3rȤƏ:uc^2TM8{/rG^DkZhu-+V/^k]|Uu_6]wXCs3?ym<9XI ?V_o's+i 9\ɫr2i)-T`3W4kMdO١-d(w"[n큟]zi7ٯMqgrG]aʶn9j\)#g1>owҖI&7497uT1)7oܱ>)':Ty{v<~-/+WdfHY?hZLbU%rjRɄ"+8dNJ7C Tz?94utΩW6tRRӫ7=yskSN6, &TFndOw2GF*v%?a~٧Z[їsUZ!af0PM&=k}h6ڴeWި=:ȷV6"J%E|E'č>tcw^|㵧p̾o3$b[7Lkq]q\q?{ -1#+m3~q:뉻.ykg?~{n;N+)ܫK_{k.a+WםxM_"xaj0PtЭPugX4 |N7P2q->$W m-d(R52y Wuiㇿ8¿]z }BMdN&k3Lq&2p]3RJ wO&B,NJrcsK:3oL`O&$)ץO'l_-^SkZp*lX]6Q#*2iWBPQF䷂e=ZZ>Zk^ MhGVzPjmKv_$engUM|w:װV/U7ڧ:TGIjTJHk8֢E 9))ZwF?/orr5wcysܦx%M. [kH37I\ݭYW'jfW4E{N^:O>oi/οW' n[g?ُ]_㶓 )!"'piqo= ~9w]d'rb 0F -yiWv3m/¥k5<202f֚vs]/և\tR`f}("yyd9$!h4CI$Qʮ4ygQ%L`i1ZubMYR$i S(͔n Xм1DCVjZR)IQ.mT\ch݊EsV?zȝvT2؞,9F,_G[X$iqՔq"yQv[V[He[O Yئv2 W4 ƒϭnd],!ɸ,D9ʬhK}SxYۻKX*_]"ݙ֤5%꒺ߌjm}[sS"}|@ܭ V"Zu_?ZL$BZ$=w6zEnϳy @4msr/}MUs>|#w4ad[[E64~)??;.z=|M/us?.[ \ -=4+ - o:Jvevܧn81,ԺFrDbqY|a_%s3h<&.Z?hT-)Ƥ3Z{x͉ڑ.J"݁{nVgow˭4IIGݨ . &7"io/`ɭF"40vmmy'ˆ,:ܞ7REeJ5mͥyἀh8N%+#Y}+o~߯ėֺuL~3P)frVϚ?mDRC(>5"s\1 S$%E+Bd(&fbr.{/Xz_2X RJkaY6;n}c{ t'փ_W4{ᄋuoDnp6Q X e`g-Y'_3o픗\#^}{n`>'ffFy`ݔo=>뤃"MƇ~K} `pmɊ smmf: |~{츪4i"VQjiwDȰ Lbr7&?QunMco,{{E[TKړ:!퐛mӻ.{Hzޤ,¤퐶 ;uGTwH?Y{U{sGBe3rĩGze?|貵-[XRjhj]UMѕ>%)uIXSoug<.n]%o/wίNnnxEy馎@A~(/_1g;s(;n=&0"0Q$RdKs}Pj̤Jju]&vtm RڨߜmgN2a~ewi[ )׽ٿ8xE'L8Igͷ*5o9m\CwM̼mwuj=Gs[' C ]5_ή^=H1cjZs7f6wY =tU}][{g ١[Om6^_XꢆXcK|X[ΰf#%gװaEl\V9IҔjv;S[<rNgӞGṳ68TxY ozV?vę룆|wW>Z+ GtƩ^"(ƔQT֩tFmGv,>mǗ}S-JQŒ蔛l㋶?[u۝i`I; $jZ[]X ] ǔ ?p6ijw(}H[T6!0B S"HD.9+VrWlU8#ծd%\}We$ u,%}`+w)Z,$Vbmaɣ;-vR; B_sN9$@POh ^XA"\vpLZdɣ[jRKmiqHk)R`W 쑛YqH)r^ێXbC?.??Ch+׬Ƣ̤nԻ7WOV.֠Fmn-mwܺ6iKCrvJ]s8xB$d;j_vb4TQ.(hooh^QVXq4)M$슦L{$] {^ | lrv^mXYMdIݐpDkd oTg7U슏{y>:n"aX[ӄl5ʷKBlȈ<, FaBE)H$ T2:.31J [`'KEhSW4g{`f0xTwM uZfxYZN_qPi߫utKZIh㚴)M}T,5D&fxqgN#GcM Ͻ3իmU0|dI^^`BW>"ך)ÙNs{+HS4Rs;3*);2,JQ&L> ڹtqWxbZ%5W'M$R:[Nyz oowZj[](T# FmhnfrBI$b?Յ֬|tSl~kqsk_]Ծ5+Ҫ[ q+rۏ-*7BFT:ٵH5XqtޜМ]W6x sTh-wJr,m&4uӛۄO4;/Jdu vZ1;=  d[^w]K[c הI6&"#;Ww˝ZQJ>{Ѯެ]f"2M3LtT6f˟}/N>]~8zPgt:]gRW*zݩ)'"ަ;4*'syQR;:aygaʔPmpPf3v)Вҝ0ILW^R1kjxD&꟟w!ț]a?ׄX8:5.\\Tuk|~cb q̹/V-iâUp6G=̛Xenlsݯz:$YVVel&^Zm›͒;SȖХSW[no}^{lw$"N:3lh]7wgiԉ#couM4ܕ~rMHnKzzd"Y0n糇Wl?h*sj3#M l_eOTC (#^w4eK{WZW&!N%5-KƖ6罚'>[ڼ=M.}nn.ڸ&H2{cuE-]|%-7I,ko %\ zݻt/3+ӸOlc øܟ F +sG^__^v1~YCWkc}kSlğSw_vwtҘ i]z-_tuIYdڤa~HRMMxJϚZxE5gG s:MOɿ_y}̎N<[ lJ CoԊ5q<"QJyDy[vXP|@}:5l|Տ'`ê&Md"g$`MerY^tdDY?mx# & -uJq8J,^m!KՅձfG0H {EY?;DIlt-T:u>䚐bfX⍿W@cܲ̆aOa]Z(ycݳ]cue~_F&ت!%vd6a_R'?j赗ݗ=wſn>wvSݼwګIlgϭ-;Xi113&gQfIcP"m]ǟ-9GT!^X}O>CI^IP2MeZ24ؐbt˴aoh P'r{=Ze([/14eG#'_鬩F':V?jZ2/ZZ&-9r֌~>[}aԪ5ng?so/Ty>Z6w7#D^U:mܳgW~> "G_91h 7Z)v5n4^=g:N"H$/HQ^n b #?k\F&aOGN(o=Oc ]WwM3'CfW12ߔ6\&&2XYb:K|z7u5/.9ˎ{6jiRF"7)ʉv}CC嫨[z皺z&hJiY>&؜Mt텭{4},ÕnO!;,72XeS&scvzžp *rp2U`eKĹ}_uܪw*;L9[V5^e oD5`L1ݽumtz۸EƏr_fIN*Xx#?b1[GÌIm-+SL1[=I6¥ VX?|9{~\)pi (݂ ER.:簳.\s÷>^~7}'^z_nU Kn{w-IS TJuD fv<>3/H)oq[f*5{o]*[ݹDcg-L,<|)0sV?~lmx?];|iPU&>k fsvI(]͈]LJ'&sIy2w^^w nݨ1J)f]*xӢ0fo(;gC᳟~v5zDn`-~_/ o,}W@]-jD$"ݷ )=7R:vK*TUQ9~T帊aGV3{n5_sW<6K.Q,]8֛ϲuzaW;gL6|+Ddm2Vb-5":RVFTY$gX"IF~87-H+7P+WJEv[T^xuC7=`ſ^wִ&Vze}񵏮zXus+DJ& +rX.G6iNp;峉.ã W\LvıbVUwi-;\ie|5aP5kӺzh _83}j}O2+׵kɨm;pv2 ސz|#ƕEll_uiZG_rgjV?z '38?y~yϸ/ 9WczϪ>3T_S/_upѽ7W`CK䃈_=I'omO,^RͮRl[acjfkۖI״w,ͤqEӆ;h +K,h_հnKsʆٟR{LRx] DJ Z)X9N![ҒXښZ|iO}wּ0g+sRxmm'#Cw6ikH~[U%35j,L^fI̓hs]kAޢ ք0ׇ?_r vïvmt#@?dgqn}>{6p/2 kV -k-twߐDU DFnj"/sD퍉?Gsm3qǽ"adO)ao`g2is/97yxh؎;pү.?|9{z{8vM^6z| e'0tS}=𤳏?_\xݑ']%Xw>U+x[2l^ˆ4ۅ['u7wwg3[tU:I bb7uQd-lv7Qd4/mY2+/?vQ(U2b-H#A*֦JiN]2Yhvĥ\+bb^T42 ҖU@2nt՜^i|e&::X6c*4r[]Yy^Weuӄ8;o>~);r tQlg[~~_C WABB+bI`+׫WJ n렆LϿ3yM/iwgT(Ӕ/=rq7FJEa׬hto=R&Y5O=N8ҙ_X9T.#hՎ]뽣v]u!H Sv8ϽsϮ?.ͯJĒY<:NrfZ]]eVMjnsYH/6eE `JCCGT(C&W7ּ>7]ۑhlo^Jg a J4YenkU_Īoo.&Άha”-ײݘ\ijj|oI+k[&3n폵|mem&ҹI.tz!X%.S9\| |s5dςz„=w2V[4 ڭ-fG"0qǟo=qdM- le-,Dakyuէ\q\]wx>lMzi4EE@7@Q`("RtN l:3~&TgMșٙ;w==on1Q#5i& d aB)((RJ)Ac3ιFAZ]O kr,Ӵ SSRB,{UwRhDhƍ33r(be[ WJBŤ’r{Qlm b"QG'FBuZu,e9NCqEޤ͇Nd2'Jh1}]UBC:U{^(;31 |Nc*;Ҋe s#VΑ'0@UTU쮖E݊Fy_Q,ٴi٥;/ CM _5_5_5_5_5Nl%@UkF EII*1 " eEJy47ϚKj7"N#ײW}Ҵ=dNaÞoa uC0&TRQV|С]ٺiIG2-e% 8cLAVim=b; .8 pn[ySm?4Z΁ҋM[4? Ъ P1"H#":A2Dtj V EfYRG4*Dݣ1:!u"IJY%v(8ţ+aOp MJ*Lf`N$V$#//s/&JlXH05_5_5_5_5J8/[=.U|8"={׫EiI[K !DvߪACbċ*RJhT\!8!QZ0u7I=l;pq4t2S8c&*ݻ}>o|>YMg_rեya(vȘi#B;|߈m0wi/O߿ !|Mq`)ss8tm3DPdMʰd@9y{0Sɷw%;,X-uU~$SkNQca-?x]bbb,$+V[RR\A./U'OUua8Ǽ:@(3Ɋ|M2 qvui<_W֤~L~^ةt/ OިQ,ɾ|||||jr_^Y3ԣ1v_{:S t(6Eި 7DRɽ%DEVϘ[ZУ{?,F8bA/j;xݮMGŅmXnuwY'kYxvU+rWS^{ܲl`c2Ѩvkؓ/5VG~'Bw*RtBXNSVmeIjJU,$֋g"!~Bx$Up?Db)e,pF5/:/6NjJoKKfibP5j t)ݙҨ`b)*Q>Tqjqw) JB,yI|||||oW;3dԶmоmřׯqm rLE,-ju-5FU,HI$a9A(~ۢYJ6{TxߢkR3Y瘐ҢjʻKv洴c }OYvmmژ۶+yxPg^c?/$1BCmyz|-C2 !j>!p2IcT?0zPBlݱIV3i-2*2duHH*HC6PMD9K+wYsr'KvX/"A]uL&K yqߴ:d>̑>?;o-}t ~xiބb_寯7( xbXylЗ^=OtuY'SL9G 9/#T3 v۰E(*Бs0o[2{luՎk< MwT<SAHKI?糵gRnr~-aa6)e2Rd$L*UyYzHA-e,5\e XLCǼט?M[_a+02/SI+(,gFQ`_ɩY&:\'6f?+dAˬI5S#ANu১AؠZsSsxZ%;mg d]pT ys+ΡJ򗹓%IGտݟՔ*e9vsey [7FTkkkkk>o j81V|rfjp!aNKӤVPPR!JjFWAC#Dv퇳r#?Bbv0pT]~9yfmmlh4*q`[qO92$Ѽ=4~rpRι͛=Аh\hdRqMp۹, AfKOI~#gS'Gܣ8}G7ʊwjmk0Ɣ* T-RjTT: iVM5*J`k }! CV=Dg*PJ=ś*Pu[~qj/ ܛN/cՌ*P!!ep "{«dU`=,:pbJ͚KB͹E Ǻ&^O×E7؀[8bDf|ĤSƄչĸ&#5±fVttMD ē9F&ibdĹlF÷l //ߋ<Ĩ2'ϳS8!n.#kENvM{|r/ ;)y~Xւ*EN@B"SDR :]d#Ip͊R74u‹9'A&"/? `^E7v)iAz~@{j1JOxD,|9yQ1q˺ Sܡ[LȁI9wUWVcrŖ쬂#"R"`Z< }9r}^Nf(*ĜWHT1Z,wZ\V&2@ڴ!,eYhG,."m}fUƯITֲCƁbX1/\)eކjh\/ z]mFiyϽ񀈅&ҰE>}#¯ X9'`~:ҽqM[~5|¿7CRWmػsɢ?C-:*a86[Ƕ3բШ^lͻke a0 Pg-%=7%-SRrJ.IO"Tip_Ę 7J^;>BӀ$3]H!8*H?}șSgRrK*Ȋt$B0DVV'>YZcbBjŅcQ5pI9w,)Sg23-V$ɲrYĂ ި^l u#cAVؿ#76002+5#/l䌃R'gf:\.H|`iՎjӤ~|: aaA-;v`_1x2@99.@|>N  e6yZ}غ~XHQ%;-&SJ[ٜn~ٜrrF֤~wޡqDX Qu` | 2F\ZMNӊz1-$ߝߝNGƭsJGH7Fko\a3u;%6a  EXc-ӝ8ZBcjL<8ƅDE~j1&@߿Ea=ǵoO965keKVJNJ1  z*s߰҅ZtM)cjTh0SnM+ غSJ\VJͥ9ۊJU划Y"XrC\1wo&FQ 41H/->,doV]+CQblm#1P-˩p.W /F̲"KtS576SlƼd\PXt/tkٲy]Eq_@pqM )ُ8dr{|7Kw >{AҞso|ms0F~#J)h/,g;<_}_+(ݶ~_~29 WvRe1~`̔8u&q*2ҳ o;' :א})b>(h/,?jb۾/2釵,j.<ï y^Wn.*RC^(!⎥sVAQH̟ԶoLփ7 qҶk# ~^99gmr5VEB[F~EJZyibR%Yi}N;#KT:ŤwJNiveyֿ^xh37%FEjvtZS0op.+ߚ8P-A`9B7 ~Y%jw=%[]Wu, 2^=;4e ҟsUs>\d 4Na28t:70E^Fʥ[3GWoPsV9xGԔ3>pEcV9=O"T-~?͘fzcըFmeF0yo>vl: }j-e3_Z{b@.̀qrm:v7߈?=R+4*B"+?7n?/`:=7@arRZX\ٷ+mlK 0!R({[6#wӣ(g(b@ϦLj颕[K-h0aȾ#ѪYo|s@W0 le9(9]lkL& F 0S AzQE0dt_8"$˼沒֝VZ\AVm?IԌw+1uRarڹM,/ƀ@#Āk\h,&\۽egrZF~p- ޢc: &ۼ/L6,79UJڿm KLRz<͕Yg}bHP^#w2sUDdzAf mKS`RTtƺRE.o;^v&[$E/Z׊PͩvZWN#L!0s켌ww"yGsYpQ pCQ | KCɍ\F`Bpzzç[}H}s 9>Tn])s歑s3g)^}or63ћ2R_z ysъ $&>ө9T%2@L`.{O>gFN\a;%94OzꣿwI _]1qyT%WR??h3e;NXKٝW8ە?9|OonjB)w ~빫E }~#aB eDzq^!v$ BA0Gs8`~(@^8T ,08sg KA# Dػ-;v'<.AM !3"$R Ƅ?}\VQ/%?.^q:A0&fGߚ'3?;mӓOgQpkw vkeuU&/˶Դۯ:>fـQNK;!)A!~>g'BŖWJRVe2"˄2A<'tE1:c/^8Fa_>MzlвFMՁyE`kq !L@"O\\,RrϞ9RmZՈAB|:;d#9Tkc뤹2p1E|nMto:QXByNgUHqI=|&0,[J7 *!q^TGJDAh5)%qIBJp nʐbWsRٲ]g2tff.Q~0!'i`ZP]Guk$AF U1 @1:?)#$s-Ů\A~BW&J^99፱Z% |7ܘm`ێ8K@;NƸKFASJ5u.Yo۰]j >䔣'jff0sOrju=gkr.kțmcr+iWm{G<NdߢLVmr&䱱cɾޝ/B ¾?5^Qlc1cLgJW!{k:[uEjzYqP0s#t+XWVt%3y惙i!rerN(ԫ%⬂)P!B!1fPr8\2K23rUQ$HʱHVҬiKJ`P%IܱVX@1%+ \ w*Rl/o=sv_<+wI62V*DFyt~^__Ι{QQ~Y[tL(w贗0M7>  ގ_aKƨ:ooEx. ЭmtA'9C2?0B>IHUBpƷvqٌn?j'oCXt:sS}Ւ(%uK[Fv#N}y6dd"[1Jؼ?ͼO_._IpOGPU\gw쑟T( qN֬[Tn oH;dE ?Ѡ;G\aiQ ؛td!b)9Bc `KÉGj&{}^"QXlg,@0pUŅESԵ똌&[cy2NpTjtnyE t_jo|<>IΞ͡N cJ7sxSwlӰcۆ7>bg|eo.׎Bt_{qXJGޛo񝂁֬wngu,+?[4|'Y$X@EaQ>+#:^+' Ł}&OucrU_,o߉R**4k8 (E+CJ5 sMw5qF^pKEE6T*N01aଢ)8 yy;mڭpX2˼[~y!(6].@F&2*Ҡ~$ bž#90d;`+GgX̤eq4p) d2Lαvb._{#`9 Dfq$HPsS@nk |1v\ϗ:ɭ!:v*cFݩumޤHRunqnAɼʂfyB[ wC1"O~wZ`& ;m.iV7.:Ԡܽ?Jo8xM aQB J#:nпw|lhԈ{{.}l(!(xƌoV2{Ƹ/\g{0:|k_ a50qq׿m6nP&#)}uñV+rr>ƾɽl0TDcDPqu;O.?܄zjӋH\?}w[CF:c!A`2]̴g{%gť!5HuTgImҕyRfL-r&Xr?T+ I{WKR=їGŅ19ct̜‹ ٿL?S0Ȏ<%vX!V rqJ]y3@Dz|P|t3yizr͗ nB%^ҧ6_W]fqԳyS?e#ݟynw\~V)#@_0ƸliyGFM-(ݽd^{sX+.la!wDSQظ`{ڴaSՍ!`_&Xx_vTMj7_VdHXs9W&Ɋ̒w,)бԤSgL\{dpy:Ԕl+kjDž6o*m5ɴZ3)wo.BH>/x}g 25Lmײ^u׏7U"&bZ cH'>s虓gKo^{V$}:> E*)o?-+,uǫC &Iu>m S2c|w P O{eӯػypQ}VG>ɟp5Ǚ eri|_U5,E""" gwuQ\dukMn1;gKƏye%2*nѾuG~Rv>r$ӥR !# P]8R"KiW \Wp3>HU/Ql Q42V푨:S- \ϥsf~|A<զv 6*NÄK)q)YgsCxͪiӋ)S?[0<{ C]qU۸qkd]+$now[05)38_9 ώ(;]5QMyfo虱o oпcN%FF% +8B0BsRJǗ~XuR|ǬOըě"|>w#z}zӡU:u#L&n+θ[6ƽ?i?K\OjݬABB\ڠE\ a 8->u&kxR恨Es^оM\y}m]ۡLD+ɵMjxGՊjΘ[G_~@#(qXIgvHu]OMd'?6ռ|7<\(VGyn !ڰҔ_+V7LKGȫt/KIr|l2)<&6.,B CЛ@aTQK~D ڤW<r:\imܴfݺ繈ؐ'IjvDDK7QĢ@mOpCOyK+ r+d0Ƹ0Yf̹eIv:]vd(2n+rĽ[G:K&n?DCYL[18܊C7y<)I1.V1ۆsE_/p sw;_ T;ҲYoUSQ||ho{9הk~^%!Tr11w[~i{¬>bm0c޵3>y>=ZG*,۝$ˊc8cLQ,ɲ%۝~F]_~恍K>ZG 7\[O0~Z}w6?}wFu"_}m?YתyANᒥgxdS~ݘ!wnkg|6_}:gG_j^=;|L~ۢI{ 0쁻ԋ*ksa3Oˊ"dᔝRX_M_,3-+h\+}?tTxPMYD1./k}+[7; |׏RE5ؐ%6(Цk=9u 'wjTz&Q6W _dhܢvݱreC$>a2١OmF̶%DO #7q`Ǧuyڝߙ~u4P=Y^ncO*JPE(ŧKc|P=I0`}ßފ E:-8rW7K~VJc%xшP(V,UFXO ~~*jD cr1YJM./K~lG֊ÂKgFsv95_ ~:,vIGS8,h1ՅG DާY+#3h3iy"0ɿZNT*#RrnCD^ YEUӲ[SU A7"M=|7p5"9^z[/Zifue{t{39z_ SQMz7oxқo䎨JX|g\Gq{ZMzt&ANs/خ1㞺ƣ5?LEaᒍc^v]Dopѡ MX%oqdw+78r#tۏ{r"5>ąK6>6+o٤KOw%ݼt}DD ?z5wц:nP'ߨ{~yٜۏ8rIWX1Fy%Ǜ3"&*:;#wlҶEV3U>~V4굯<3t2H{ONd]]}Xۢ@Dh_/ٕ"t\Uԏ풬j(8#鄥G<[(2D.Xg;ך CF 7꘤B}'XtF-~;JgǑAMJ+c3O`?Ȋ|kj1Ƙݱbɟ-ĔbZ {sCl0鹢܊Y]>2%?/t'iײފ 򻹣RR^َlqys?Ovp#51Eo.js 5qӎ#;ԍs;4ᒢ[30(\o+r;_?ѥ,">\㹻!0^qrL0!"g,_kCNݡcrv?v\zlfc~!P A;Ի2r =x|ͅDŽ%*=d+B2Z@zk+t9Rr Sկ1,'ΤnP}daZM#O$^ˁ (̊r +P24gj=)"%ݞ#y*J\{֬߹wkỳ1\ SngI+JZp&H٥ҷwJ U*~~":GwL@ ! K0xu$tvN1 !FtʊTʴx[\~eBg9+_})gs1W;ZQ*1ߍ)J6 kc)m>j0Q0"x]Y^Q[\\uOqYu;\+ٹ骟ɁZZvJb[seEvɭZ$: 0zz)9TՑO(IKWׁ~lI{8 ;]-cc.Itnk4Q WX/9єI)IMy׊~ CuYG4 ^pwlƵ~l^I ֓uJ-eEdYVNx$}+ѱSivҾIEs^ܡv$cr;4{vp U`~*GBBBEB:'S y UQ+; C$L#RR)RV.*Io4]\AoڽR[v0PhО(ĨWYpAдvL%%4NUAKw3ƪ/90@UӦabV8C(s5[i[nG%i;/Q!0YV8q1bP# \ieN-;5'w\}ɎrW6u*X]mo!!&A: {s`qB2Q!*4s|؝d~F/c\kPBr7xtඒ sD[:^kOuE^"Z5IkuF/isLQqiqٷ?f჻c һK^߿}U:u{R!8'txFv o*-UapI/͉k:084JZq8ttoW`?;%dݲw/WtDм/'4jXo_Mt6o=>˭|>\3PT @ !mtwsYH:x8c7u?YH4zs #^B\AUP]\EpB56,qG zW]Fu V)\Y]LK/>(hH@5Ǖ.?BNv= 2𒗰}Vg-6G{0s6BQVm2NK. 9$dKD[wm+~&c<>gθxgjWa!R̘[!9 #6 eťrdsZ6sVdeY6 j+M#B:'t` !lP@5sAAHAq=!&ܓ v CY1|6Sx]| --f ja~#_{pcT\R^a{cՂޢ8ڴkY]\j"ʿ=qNZ4}uzDtRjTIOtۙ R ֠{:xࡓgh=?]f5wo6nJdfEQؠ~\^M-;}6{)Jf^vMįΘ}mNWB\/'kO|mn"!r9\9ՋLhڤ%9OG>9/  xp}$*:%cI/g0Oz򱯟3g`[zøGOjGB$\W qY.X^ ĘXm?Niԯ8/+5_^z]:urĜN Dhl0!eإ«s90.#޸Nƀ Zbc$;x$6v?6 d`Qml1תHT$sԳclgO^2I{naE~AIj;>s{ݭ4UDlH{[֐Y߼q;bÝj"%$˲~# $G!.w)A)fbXmyNNCSӝ1c:F 2qJ]`!D@.d K#|p PUt!VoȽsN)?Vo0p2EfK+ޠ2?OUxodYfZQ1 ʘౣ^w\T\?l2'?“d+<$`/[Į1jAEєרܼQye{ ZQ}ZÒ,_ sb]Q4c޻Z5+Hd^q&֗r_iPUK ʜ[dH=U:un-yN/ U΅^W&FLZ z}}O<ѮOϦ;уǿ^wOx8q9pO:o 08:3{2e ,n9Z*C:A 9I6WxXw:ouoGvg\Ź1i?r֬$]-Yp~]㢚7&4 @v[%3,"$7~}Dݧ FL@r/]{չW8p"GDL9RCf)”]j6[UCNHb^6(K;%3>Xٔ5| ZiV-; U Ϯc{%{cܱUvH%sioFOS1_NGόv-\ 0FtMbqaQGi^wW4UdI 0~Q(1_-V|1Fvi?[*իK_M 5T㊬ {w^uGSO\Ij+r z <2.^IUaL%3'?`lEOk,$çNby|K_ߗ/ՋO~[޿^SL(nsIs yBSr(UQ&F0fA}KhةSƐ?d+bӦk}d.zvƪ9%6dYufZ,2ÜUzO h7/ F}-e?9R㧽w8rM{h,7XELN8 >ۢ+ % mڥv+:7'y9x0:>sfo;4O*s1IS'SO>g~/?3_Nհ]퐄 }FP v\ (>_߁s'G0G3ŅmH)9*X}pnlZ4kTq@0~23i=s$sW]WVx1Bt'~Y?/[!(Hʫ=xOFpG^u_P³hT~z?ZcK?jNp(.y΂/BG7ꮏP1ko1Ɠ_ފ$u*>U&ފlcto+^ukucdTCBN;FⓃ z% {[/T{*ۋL\-&sp0@!/O,W9΢"̮3Qs snL0́c`pZcs?ɮk3hF?=_s @Wc`g__oFnT0pޜj5Ohбԡ+r 8!!Aa܉0 g8oQ;S:~:թ'yɴKa"P  S3Eo>vפICfp>ʹ>1=Fm٫Uֵ&*Y,1Ee @.sSNKL)swfX9OŦc 'θΑzs; 0(פ8vgtZGZ&Q yBfHh5Ob/}_1R?d@߿}㳉ODSpܼgʀ{;DYlRNQNdqʗ=͕_,ˊ؜ibJ%?rwn2p*Ru|n۹iׅ0)c}'Ky!0ؿmoضj3_`oSzԨǕb [~#o_#?(\pLb|=Ԝ^jFSd]P&2+\!viU x`ԋ##J,Vԍ l_޾ЙÙ * 8p*- m%qCƽ8`Ҕ_|ΟYO?x'~tmvwķ4!AT`0S\.Ҥ5!1HGC HCȀV;5y_[֍ ?O8( d_q W@0VUTH[9WKg)M%ș_TAa !w :PZmy7*RjD/FԘ} F(Bɡ)ҾSSMXMtZ3OV\/'tҬA0T(e馓33f\5+MjR?Csl~3#Kذ樂PCbb`7{ a]\JV\t(∌1YV:k-o/~k֬@\6+C`.7髟=t1E~LHcAüO1IvIRas%eJ@%=Sw&[#sX7o;_RmhQ\L!0V 0=wDŽ9`1Ϊ}*A@GȗuPN͘U'.X@NK*Y]{92(jK\@r^΁ʈ0Dpl]礝Զɀ{[kժry\hA^Nb%IFFLp?V "BvoJff^ A.#qKr];%T$rJNt\rIeYU*Fm4~&Jbś?w9cazDZVA7#>yQ!IZ dίFyy=iI"[IY<֮s:9711~Og i BubwqEA  w/@ 6'թ3jY+.|&޷z=U.z mAviTrF獠kB\%S"ߘR_Ιo۪^̈́O`Ya+up: >|ڵ#oJ/B0j܌l}-19U蹧d>_zLۏ ztR?S'o*'U\uE_ۧ]M?|jq!DUhA;׭;琥BK",gs7y_q D7r8.E)-gYBf7":#t3CI(  dW4j'<؛qslMT ]#A@ S'~s%ףD_ܟ֣~n%,}1`!PRT`ˮp:Y0 {zuaw{r,=i>闩/}^D\Gg6&K}?)gM_k.u|GJ"UB #"@+08BQk_68Rg(qV;׽O>[rmؼkiult<)EBgW?8+%dy#9ѓg/Z]a>oوAۅy@#D C), #w{c\nYj)F, u >ןPrTxịڶ80z5yIۿ9hs{~`^fRMQ\rM%9נr7x7j8Ƹ@WG4!{Ӂܭ9ɻwg 8q11Ƙ/mkMMq$ołOg-ʜ.Uv$qw `jpS&KR*leiٌ4!2D# ܻL7ygIPL2T3 9Iб7ig0| ~Q9!6k_:T "( g @*]xL*~ߝNUU ƖU۾E>:w|8Wa `PL D8%3,K"o[^Fm֊10BΠM kӹ}wz{x;mbhJs6Z?\ܭ:qX^I^!3@ Pg(LEE"@c!>֒Ut9*>hWؾc^&Tr[f>ܾw-S0p`N{Xo}KmZekŅ{ctSa/qa̰>z\%Xb*=U:/>1IrMs%!FoEAQ_d+[' ;xsH#vZxڝ9`7 ^ڷjRg`w,ˑ!9Z9sq9J Tޥg6}o=xA=[%?Ey%E%rU^!%C9KrɲKR\s$KrI*8eJ^!?Sc a+`b q6?=61vڈgc6f}q@O'Tܝȋ d8n ""G֩gRItlpԣ39RN\,B籓g'H;Sسo+B ˆ.9goç ')&P*S!qTMD)@Uy>eBl؞3 >֨H'M_'1~a>J;T 8왩o߾}7IU?efov(_P3H%t2Ƃ@|F{:JK扤΃ (Еsӣ-T֬3`Do2ЬQ-YtJ7>qtDF*-[IJܼ`z?fXog 5H|`q}$٬kNzG% 8ͮ8(=QU35g U[AeW’JTG` (!ڲN0Vx.)*囔JER^b$,Isޤu{++.߶fwaq3^uy1 qU ĥ&N aW%rgM!M]Fr: SOMٿ/A`x⾾FZ ,%fsYYKTjASꢢj׊dSD-zL^ bME NnpK sR_b;*Ơ   4iZP`sINӜkٹ)]:N*NMD@s>Yh(WIgrs zmi4! :3N:x()?\4~~ D_g0|Fd̹CbNK/mx4m_9-*"?&IZ:JRrZVnUkGl^QDh-.(}iǑcW/xwBr; z%-m[]dA۲!jT~8w⧿xc|]-WNt@ǽ9|YN%s}?,'}1N; %Y9/_~pX/4߰)&q['>w2F!HxӦ?Z|L^`lT{o?0P9憥V9 PNF#W' w`Z0N.ΐ+bApYy; 0էcΡᡄ@?TbDw+)Z-mԮyw  7vub@es`UOۛ7㕙iIgh@b)/-:RJQ87J7ێɡFPsʭV<;(bE4Z?fjՂ8g9)w2p`2%JebsQaRfPEE0P)@#R٤?WTjޘ>hY!~_L>߳oKJ'gΑ*=~fG)-PtaMF?n 0uT\h^CUX= ^q.z(DS"V\&_$m^2~boJg-~:Ŀ77}!# קK5+ ~Cova>Ըo1;\FyQ/ԯ T;k[Ǻ֍ M[ZZ=> mZܺ*ɾF⛓poݲ߾Z&͚5j.a}CpZTB1YY9!0\Lt~W,B` +Oί,~91%`M/,rΙ?>VשS]׮h 4 R<~u[9o&`W+K.Yr9 ItO.kϊ,QI$+VU/1}zn|nGb.߾ /djT eyE99%Eť K*$0Hl 4h* yeWa.ە2[iqyIAiNVq~viEy"K@*$evV=m.|]BNBBm]Ѡ:+4$`1;O)s?誖Aƭ?kj>-ZA{:!pߣWכٲmtC’ۆ=;՛ͩ,-|*d,:2xςo%=SQh{i?|c@mB` (h.vnN]m{:eb2F"W[Ҳ "wcB-pQuKaO&Ey5߂!X]$YY<Ψs'8~( Ui J. 9岔[ʋ̩g$<~3BiSI]A˹̸&^響{y0^WZfQBBLj#qqYA%lw~:SQZN l,Q؈ A1y钟6~ Ӕ/MⲴsy?Z[n(2Z*̥E܂"siju)h"b"bCcbbbBu:NQay:q J)')Wv\YZh)+.4y9%LA[Euۇs?4auEU`@ߝsπ 9ex,٩иȸѤk .goV~ݛKJ-.b4CC#"BB z0*[Eaw}^(?}wﶾB:Ͼz!4sS?5=#* L۸~e ~] #ds8{ykSW5ե_'kϿo|i#opJ\WGЛZ|n_]u Ã߹;B<:^sAK_4ƸmdzyȘa?~@;4w}n߈spRQ-̲ؖⷿ>_ȐZ*=M:NA` Ш30F\.JE/'`>c`qQ0YTpQB!JҎݴS%%hAj!+,v \\R\\d֏k֥Y BbuSAsJ~ƗZ9x3)9>!"&>PqPdd`;8G3}..1`$0 YDOZr?`|k{~LDߠW\auSr3 M4~wuձ)e .n6Zj.+*.*))*./,p0QD&}h>"20"",.6(#6oƺɴn^T\vUkm}A; _BD O8_ycc33 !鹍?G򃧟}|/]`yq^ Fg͌ʝ1*(*k}ܫO;61Ύ}'jiGёwtS-?7;_MF*.<0P0so6~ƨ]o`!)FU XXavFʱ&%gr-gҊ* =]+PTa*):g[IiQ1!A!~$ Ce"4Cf1ͬqx4w5hm܄[uBryAiAv^Ʃܼ"siyEEKL2gaޫJT DƇ%DGEF։ Ո`9WE 넫?O|ۓˡ,q'BV AzȲ8K8yO7>qҩm::"?4d0jQ͘ۊ>}牰Ê5;O+,,V؁1 !FcߠDUVTnYy&[l.ᐝtp+2g s(vV\\VTZ^XT_TT^ap8 QEBYuy+w8ٟ0iB6S;%q7q*<`, " kEh0j EԬ](@a15Sr2˒';k.-Hútj^g_;&Z: }U<6aX '_킵Wy]o14Lfzao^:%*=l*Хv ~|U-EQMjܯA~/=t4_[jx;3oxr?zuب=kfԌ\JHjF^yCFЀ΋{NJs ; 75σW*Q.**zk{XO {֍EQc`eQL(P`؄qF +ʬ"М_ZXTQRj-3W$( :F͢Aɣ#]*Xe\)\ 9Ɯ.)).'Rp$S٭efKI%$%"A::8o7 yp/S!uaM`e{lyAV1Ej.)6wkգc@88GTKLah>H҉VI'[7߻yÖѳg3&{{0m'GȷnLɡc]{ry֭} 'Q[(LSxcg17`I5&MpqApɗ]C045-}f{i^iԂjT?_3jӪW/xK7Rݸaxc骖*Q8ueYYK_z[owѦy[tbyy6\ثkvئA@3_>WeaZ@ZaAɲϤ=3vO׶ PU<"n+,0gqIv!$'9sٷGo]sV<-sD1j3"ݷ8+j]7n^@cV L߭רNAdqJ |8nʨF{i}%fmޟX?626(U7D։k\a\|lٙD7mRku. K:s25ٴ3i9" pS݄ȈJVdpجNGqG5=8F*a#@)HaÖ?CQL&vhߩe f/X KJJ3sr v 'ؘȰ؄¢C;W {f1CB"B4*"ˈsF/(;|ӧlv[\D]Z6k@] Wk)Hm0F詚 LN3Gj}bz9{&鋺v[ `?rU-C53<4X;wߩYEkVjRj7Gob{q41v7wyC'M>|E`q^22|Uw^#oQ c'51ŋ.c4Ol0^QA08&p@Ur@Pݥn"H8l?u<~48!js:li%)ԞiOϒ Ck-hӭ5QKad8u*T \[3b !Gz({7x{G[WbPK~?l.NvũE7[d1ФC#,TsA^^qpܴTsYb)\gVV&uءr6HyG^yQcGkф Z@ HSբ}G>77 Ǚ}#O?۰ivU{ߝ}v֋/};~[sǎ9 (DTE)!#!t9pLj:uDcyf)*9#MݛBbAZO:ΕIv:[T=z>1]˦~zFe} bTkBk7nزquFA*5BfaYেzq{zLZ& &?V'"KbƉU7x($*>LL!#B8 ! _1p׮\N4bMk{@3P%{WgfwOswqWPzB wsݙKrx| !yfowv>} Yn}i+?NF޺pVu7mϦeRH;S Y,DB~Cf8#Hԛ\3&B+FWiD $"F0!rZ3©Ll\lE>;8!){#LZsM{|^c6gӞN/XDD4MSU7u:`xo&  9u+)\j6m1ٔZ\嫬Y\V)=c2PG+swm\cTy@+8P !b=qH`PRO[ܥW]>x?9G;}V{P e&v~7f$I\41}(ăg9Jr,g2MfrTc4fse͡jpcEe%EMZ{ZW6pĩuWzv/+*ưcWpdt֭/g-xk8㗡[IK#'9#kֳfMX+qY;7o<\FFF8pHWނspvjTsE߮0|Лk5n}}~(1=Ko0 kq5EEU&vwH"Qlߙ3IΨU}>Գq ZM&qnro -P*K<@&mU (Ӳ-@" "`yYO/EVq;r7fݻ)9ʪCUvB!3vxW~<5,+6/R•{:ۼQu1MVg]. 729kBSt Vg49Q](`\b0"Ps\j5YOcCv[/#v.#ԅ~ò&ng[;j iFUh@`}C$@"4\.%W$/Z…LEW/0::(2-WJ# }=;6vD`) !)c ܼcw?Z#ΏG60" deem%3!RezN`[79q_غdGzR i.{,^c켋D*Ca? O{vf~-4 +o4R֟޶`}^=l,GNn&E\V3*ԭ$O? ]ۄP3=#cB`MF[29TLQL >@Ȅvl)-)Ue$gf: Ce.>a>>^27wP@,06U(DBl6.λ|Li+Rj\=\1m6uDUt>U]ӓ2FsV:&]e6.r`vK uc%ԋ9k:l|`ZoubVmҡu7\ MSepjF<Z$8~ZzInQhTok&X;~|!ްGӜg.጑y5HظntN׋Dv2wVW $AH ! 'C p@Ti~v8{;Lgx(|Be..!t:^SVkiv1~O% h1&²RnCeϧ tW%+{-4*8}d_ Dž k 5e*+ȇ9`9-|よ`rJeݹ\3%`,O,\_㼂NEڴ7Knݺݢ}}g}=n\]#*w% ۘ?̖ʇ3F^J!ԩ{'X@1EJ ;¤cneܬܛWo$\K-)V6* r\^?<7OY=M8;o56̜ө>ҘH$>v:qZ =d}7rbk٦Ag8`s K JK$ub[6,!-{I]E)(H ^I,J pwJK%*!Aüac%654E:@!iṱfm8˿0~ݛuk߰qhXHx5)NQ:9%s A?W}hwi؇1g9pv[ Nn˹yVt;Έ#+ 6Ѯ3FLe*(2de2MRPaL $b@<9e aa1|Ց.E qBC4&EL%yCaU#x Z{kɾ>VkszTqn}7nb9֝ckwkYYX=$X판~?Nнm[ Eb6ԩ4m9r뮾D~9q%e%z#sP.u)/# Ϛ<{-g.g&/T&aDfZYfزdњٿf6릐ĔgnoyaY1?|pNHB j{CC#BA?SB@(*0jfUe:E* xOM*ؾY83PrLi}WU X\euTa'y9fs쏳>y|YW֭U#!NivĜ,oO:`;HI" pֲoVPUt<RAL8L\.ߤo`EA8p&jTIi)99Y nlZ Fc Rb*72<88$WpygB($gTC2O @ Jݛ0.MZ鲳y6 Q E-6*>!gݫ.-)Цـ>(gt~"xJ (ESZuFe-{Xw\[6Ɲ JsqC%g/_f>*GD(*oeNU(zAHQN>y!BQdo eƜuS)ݭ]w7KĬ ?.<]@Tժ?!Og( pAG@ 8"m%5mU38'\MHP&&27t 'S/۸sŢ{ JT&n5+VyW3nlh 6ѳ{JBҭCT3~mw?[l5;VmH~cG^k`B )Q0R7OYXoу{uګkL* iݲyPiYsNn?yڶo^l q`d;k{ iJHJxDI[rx|[u|-ʼF )Vm:6o&j7VkJD9Oi^K$t*Ty%]}"tܨ΄nwQϪ@J튍vwuyohg=? #,_l! y7A1!z@c7j9w&rnZV5'-].ٷc[LatQ"eƕ)?}ԧsrKh*Q $t}F .G܅p^~ 0_b 6^~5m_0;!y ܜ;9Vv\ڱ-{u%E&&?d1b [dMV^JKҮgt: &l6b2f+o62ӹ#Lzb&3og͎m6laѦh"Vh &Vk 6NӞvƪ7yebԜr36Y+^eעGW/֦'4ܢq<*8?'k+={rk{}#`_~5V<͊f]dNn궭z}t߆]}5ʕ[9o?Ξ۔)_KܥoXt!]嗙.it6h٢B*NNLpwiղALHF?#<Qi]8r:)zDL$ E"RõFۀmƹZYff ئU~G =& enYť*M^Qɍ4n?}w;$"0ט>1|"%B4O ./QYxIy`DKd}pB4Tfnγn:}ftk$ߗX*]]]y?MQgP0㢃kD;ɓ]tmW\ jWsFxå%*%d6>U=[D3bv W* '^rf؄1C?7wh,,O$L`[LSj=CTB"( c[2]7ԭݴc Z+,|yPMXnn{ܻCC3U0ce]n^n-pR~ٺCVʾ U ÑݝNJۺ<|z!V_32< : F+jlB/Xxũ2`N<Ʊ$ߪ .@Q}/B8;9ԯCn/unS/S"~U8Y?ZocYYU@BB>r4vdW_g+[l5̙Inˊ-{v埚psɗ}d۾.b ׮e'fu@J%QZǛm4EeWͺWաRe++T*TJ_mky3O(R%_h3Z ym=MQƔSd+sy䫹Vm?yKUt#;f,;r2 azu;EGx*dBP/&2z]sd?Vϝ|S[^jE%i|9cϡJ%+-}-Z}Mi6MMVսW_~i]{6_=gY_-s{wC>#)-=wiЬX477gޜ+_`5UR/($'9T`B۴0-=*K@6 @(BT"(B D֕iU=0JO{ =e~CC<}\b@Ⱥ՛vl QQ:{fWͳ%!-;qweid!HQmg%l} omD)ǿݽ,75[z4nzcͽzF$"8X DH8?uzyuݲt`"h݂,ZLں>]׋s˄J{B B\q?*_bߠVd՝T=h5[l^Uy1zPGg^y:*,r W N# sF8#X<_5++&Д2훥2)A//cM37)RǏ8hRH.`R1R8U&mݬևZ0k:ywN1#W3y}tFk@U~J;VASUA E (} 5zd XNE Y+p-]YhW7iP/_swx*:.MDS+*Y|e2IZa]z6̿L@d1MܻpQ[XРsz \9'/w#bF?~и؀jrwwZ$bL݋w'nt¬삌{79Yp54!ӪRJ2Ey)Db+sݤ9aɕEr0P^޵լ g 42""Ǩ͖mv[ N;*4&C$C ,fKIagvmݙ"MhޮqWoj͛yzӦ]apkTJUh?o(Xo%D}4|C+6o/3IܜXGE-ޝ0k߮PHxҩK ofU{DR8kGn~rS:Dô;EKVqݨS;=4: b}|$gY;Lln xUPث,!c~?p,¿|5ljC^z0|m:{:g{!2H{fNQJF;&^d=[7Q$`? U !PL!! V@@;kW=yAcuufh QqsCFt DTU\ \6hv 7؏=w#{b۴νq%) #  Q=0@*;s:7no$_͔P#RC4꼔T(O?_Ũ/( Y!ĘpD!4ڴ[%BwqQB,:r&^i7xud /B {'m#\\9Y%iI)" -5mph8f^^j^qdd_Ңk p`;hΒ  lJVn͝;b'O"<*tg.\vouܱuR-Zy֨jKt*¥KuCJ2zܨ}I:1k;x[ёr+`Fj;ds~$CEZ{[ם}#dЍjm>.oJ#Š ֗I%>~bl(ηh4BYU>u!;:1&"Y:" -`D\Tp୧ʴ%gQ|LtN^I_:#ߥmHϽj/_2lrǿ\6,rm9'.ZoBk1''D'OҦyd_-Xcn/,u+xf /ws1<9\mPo0wC jӊfUqh xْ_:v37oj}=|gxU9`0]Q%!t`QUx@ œ }аYժ6ڐ}pHLPPW+)h P%:esH&]ǍcƆijgS⯨ x;/`hpVգ_xe_UXVP/Sf84 ,R$m} ݤ< %ײ+*AFX@UQPk@2,.g}<`H,LA^~vN^ݤK i)ta>^yZbb˦@$0s'}~QUJLu굂lZUTv-/^մI|a0f\)S۾iFg^^lP.ܱ}'3[,6ЄIvlE hrl"!1X3@(Z5=zsmڣFoШfk4YoJ-+6"@p}}<fcJ@Bڢ'9wU j5<<KiI$,EaBghN^urBԢQ`? #@"1!CC } 1Fm9Q)?c'OGyT~dފ=3 y}{|A:DUsfAuVnc۵w'1S4۬z5cC욮IPaRsCxGYmUӣ2`-LiҴ+ٰN~C,jykO]}d .F,>a!uI$Rkھ;>}&;a|O>?˾M^~y2r^>bO0% M(y~7Vlo)ͩF# `K `:5?}D sJ=<}c߷_޺o-yzk=~gv:]Ұ]uWM&6;*oJpo6,6ҳsq^^rxz1 &T@or?! B'C'^9q[;)fSŲ x;m>h*;p {^c"Q)JѩLv!v4^ҧd7TZfAsq~H$x%>Ԍ"^ltDU@qvV `lwa'GvU/|Bg6r2Tڽ{ѤQ / ۴IGEwnך}'!xrNY󬕿xgD뷜x,YʂѴT ( 1@H$q padRDQ@ @D<1 j%# f#/5~q7 U3#Vu iK-KKKvQ<3XD֯ٽC'3֛MHP/Ի3#6'y;ؗ Ԩ7s=#t < jG:3NJ4yj6V|g3§.$ AY{{ɢUw{B~ EYEiy׭QM"%UU_Æ1\&}:115ع[$R~0&BG4Dqͤ31+,p \SA* ״}]o{wv;@W6MtsYnS';s-}>?Oy/ǓrM)\8 IHJDTK @Q#'vqR#O֩nD~h &ڥ"W0kױ; g UyڵSӡ:{߷㇎ֳmn[s۶HZ5&~7zb,Hx,J$"`,Sk 2 "B*35)8g/n'?|`mݦUdLxnRα:AԚtk#b0O.rhN_9[-ԕ`;(2)sŞza:5pltq}DAj vr#D x;$kʜ"bDX(ʨ# Nc]ժdQ*@)B<)-p3A~C{x}`PF DAD"!Dcw`X@G c.yՂ O+R*'iEE፻ܾA=N @BfIi4U#<|TOWg*_)kIiYAXZ=r3!A<;yCޓ.^K,*Q;#[VD.Uجyد͚]Na`BȆ'1W^~BN>yHE!Xk BY\o.Lë[mP?9x|;&@wT K~(C |LKT=;UUGjգߍ^UP] S`@!@B*'‚@YX!DB7{ BnkME٦4)EEP3Vdt5o?e1m/G9&3'oܳhoxXSTJ<< >~//ÝrWPrZ"4EA<D"XdZ9=Фi J=C NF_~*?|O&_PBH I9/$!D 0eXUejJ^Cci}ϫeo /-]yS)ymizN-7P+vphK~ac~pISհGq4;&>/?f5ۭB `ݺv'M;~Z5*qrWS(34`E]?y#P)9>&:d=rvڤikm_Ej@o'I+Ynu?0/_u"!Ny[1nP+™8~V\i/q[3;٧FmD.7n]p88+6"|5__[NVgdkx}Bg'RU!1} ^U24@!F!1 } \:eT !@Zo9.O=8j*A#`\dBad*=#)W3PRxCbCB"B.ZEv+%N:# @c' yjVTyE .m~͛G]Ӳsðbj.^~31Y(C?׬hPyxb7"0<;اIPU*63UGwS#QBD$ibodZF%I)+5j y_ێhFt~k]!Qv\+wMFCDWZ4-Ts̚w¸Mb( BlO($*W8xӀu[0JiQw rEL(1 jch iM=~6Dj -R)-vg./oePhJUC ˃5O?g9$ 'Q4$ @sS 4{y*U(z> ٛFN6:MjR_%nǓvxB"1a(}`s68Xɡڮ7%Fby[u3Sm,`qhz.p}۬]D~z^{Z+?z=J,lB3N5#Ǿ@0E좘cI>BoO>VMZ$ ^E8ObWU/P~ GV a}p!(0͢)3 etY>-E 2tgٶd㹽Ǖ%sbWi˞-]+.{BBX0hRm=woE\E`6jmo\;pdD~-cc#} y:_|2&dGwZպǐ#>sd:57" GC V;踼DiY_8s݄Rlk˴7{ʪVm^v҂fk;cs*=P)݇nٴG̀:BW@4a # @nUe*}qEoPnsQ}u I-'9bC͆5jiKRJG0@ ]v?lz^D`=uP=$rۭ9Pe]z=‚pņ#N__M^EX$T8/b)@H.M,,V s )SߑKnZ Nʙt!91}@&-CA:K[u$<&ג^{nB[_h."qXo{x=Zz2!,g/e4YЪ_9R@f9;z!o\鈓 /8 ^'VL=~pXXIhڪp~f?t'@HJh @S=!b/9Y4ejAtuZ}[w `{X=w+3!.7**θriߵU5;铮C{/^DtPlpNM괮/sUC3Iw1XU,\9jӵnUg'A?<_ B NMI߹śV-ߺmӡKgn+)EKЉ;.YybkbozRV@䓮]4k,#,]DFͫ}BRVJJW|2;:-Z֐y޼:I"oت (NI+B4D~yKsQ֭ѵA ՍKDޏ[%OO0 ~Vߏv\8N^̲"@8A5cC7c'Wa BG Bm;or{ѩtQ:댰Fgp;kg:BH5_")4NsJ]|Z)}D#9\.rd(a˶|=hd"MC}}ZTZ_fYe]:^JOZFTA~ٖ=NEoxݚ/U0BfXv .{ʶ}tBsu2K 0ǵhרnHIͥ_| D!Z?y&'~>n}5Tqy[J_0v|:/NEB.*V%$gêXgrB ッxU9 EQ_J=u;hG~?zf|0q~͊[.U zf BE#Q4,^ !lFuy3ڀ uPec3YYP(붫66~̯w|ca29#RJ˶c 4JZٻ&kԂ"Mj"Wѭjb(䑯G@Tl>*,Q3+~ 0B i "BҔ$PQ݅NԌO"v_;c@tFkM:%fn.]2gKZт^rŘ(d#wRL͔5FCB3팿d:7 +䜩nUMf蜜I: !* _˝Bգg/%<2?Ęo4UU"R*hE}'1w%hJm^AAAi33r":{hѩXضh?_8o~@ 8B)y MD֪UM,7ny_zVa?/~ - $Y(HC!Ey 9/I/p0sPX]XߒnK 縗_ jq"ڸ~=a I|2}Yf|͂_>cβycm]%-ZLS@qP^f͆fo^ԍ[78b塒Cwt\ b ]b7 pb^Xo1dr 2A -"/o3xryy6ܽq̱R1]n DrQ14!0rfU4*r)(J-і6q`}ˠO eƽFBP4CH"ruc/!"p4ݮWk LѡQBaYjVYn!*wj4#'o[~`סK.!zX*Hp  agky{ݷ8X* c" }?:62:j_->D!DQhsvW ,=w1_.h?73~;IYO݀4cWAG*渎jRI-{\ B<)Lۉ~W7Jcm{PʛJ ۷ӿ4b|oH`W]_VܼZiI mu>C*/ a$VFvў×Qi !x7){ӎSntSKʴPP9s )E7T,TUHRd2eCn>{hO: n KR/WW6G>z߿3ELݾn^xߩ赁s={m>n3?ۯ嫉W6!XLf1@пȿk@/se<ܺt{Ѵcy"%(дF EyYɪ-+J -ξ]w,`M` f@/$P@ MߪB79iGH( df-FDCD![m9vRkmp`njՐK]OO퉤RL!Q4<%NGA/z=Q/y%G,L7~P5үs6&-`Lfۗь)9OPBHn~W<\«0˵oYCLE۬vɲr1g.. j0&qo;_kmwWjBS :r3`Bu\r}5oR? o<}L4CşN=njJ*_*a+(V07+J@/_;ծEݛYyQij* f&٦C>ZyN|7іЇN9US!p~K\4g&OG"r,C x{lS|=-c-ɿnȠeWuܹmLj:ƕŒj yݵIG\U A9Zmn>R@›BlVRRjYpJog٬܂Wlurђۼh8y;)9Tg2ѐA`D6l3IWU$'dɿVT mv yP>J  *B@ J 7.d޸]A,ŚS3J@i:5Sf&։۽] WL!r]\MaOH'0jMzWC+1mټּƈHx7hൣBB;i>k=wfT LX&;3oN;}17R%תW+^gm Iy:%g/ܦ+P hCs_>y._O)+ѼМ[LN2_Klf2\|/?Wٲn|wܢ"%]ș lyW8o?L,I^~JNekA~SFBzϑ|L~[NrIK>yE%u( )KԇO]wF8$GR D#<}Gj0erQ?R-B,+-dFXBQԵ[iTF`"ii꣇RZ^FءW 4]S4E(d7j-g1 hGZ_$~`!%7n?󝬴h#oݬ+Hi&MӚL\[0QX?MfO[V&?0d.$:L Gl9;V;~Ҫg#ka8@ϡ=Fc?<,!MV2[S @(hѷo1ǖssKbjr a9C9L M$"><|~ a݂<_@"ylA-#˖_dWZ9offU@H)(c-,޾~ .\}֟s%;7n>y<`ey CQ iiH1DZfAWR'Mͻ}3)7!$Lo5ŀ6Z(wSu%YV@EQ̞rݧӹMncG)e ]\0CcX !ykow$THBx˜>}:)jޝ8 sӳ i!S) BfBڠ1>vQI]4Z37`% !bQݜ,~+H6ٵ]p,Ň}WɬGVL6Z)qφ|8>qY_ pĬ狁! +>1Y wNe hcAh 5=ӎ|GL2a.t􅄡*8Bp#cZ`0'_IP6Mk:)ӛ>vqnN2#Zjͻ-6{\e`קi*'pά)XتI\_!$mr͌]z^r",B􊉈(XTJ oLX` 6+ "EU .j>d#V+6زʗW!y"ތ&̲ƘRjRFlVDB!@ B"H M2:?0-;쫉TY;iiF@@(D(P-\HݾY7[M"=h77D3|XO=PEG4?\b>IØ8Qݟ wj9vMRe"iD:f疀ʡޡqݸpgвSÂ$Jb=ܘ݆i|B1/X <BZ$8uV!?N_L9~FzvT9}ڴs{ԍS^t4q!!3|G$_V*AH kIO'n۪g{ﳗv7(Q m2[jW Oiߏ _!D `F hdrݔџ))ռ| L!,;z zZkxeBf;#аv:_T? B%; ڷԪՈhۮeY^+L!Xi FﻗT"2+%)i&;KX%/wAJZi3hm#iHy(h#vTѪu&d+ o!`XVk/)d3lYZ ޻a#;VT9DWtP: "@3jX/1Cƽ7pMԑI3|d۲`]WXl SH(iD!6٢5JuQL͙@Hѐ)ZձޛlS"Cصg^^" \H@t8PM'=0!ᅏ߬$!o#p\&StW((VMyy?@ JRw5i󷩵'|%!1vr}wh5q1!5<ԵH0!2-X:f4p/,9umڽ,2cūwLMnA Iay$6Cv^`5VhK3ӝ1$b/He8Z+ăiK>y,bY?;="'38s%ߎEBָvvo;%]*A`X9ڇnūU{WR<˅uT"zHVm>}O](!PPX͢O&-Vi 4#D*Y> c# B !?}>cOwӮ]~ui/aנ(D +~o3 '^+6t/KL ~poYǙ]Wo0?}mwLE=!btW?Whע ^5[J{yKwzkC@mO|zr?^d㛦) Ww[_m5`y{F'X˿7r굛?X&cГ`cWf'}6K1BQ0'[7;[w9ּqĤFCG59;(*X\Բ{)gI]x rs +E\׏a fh ЎB B( 2Y)LgV [ O @Hg0 Rn\M}%/'ESV= ޡYYZ/HEbM&"@QTi-Flpwqr u{x($"QB0X;g.7٢A;!"H( &Gⷣ?>"G:+&"+6킉C$5yy'ϢgLǍ֮ym\ 0~iAJBJU[c3A.nM+O#-`ѷ:A~W&xf͔5OW, }!/h!@`5;7mEwoOq# {mDx ?BH!hd1Zpg0fL\3@[pTI{{9,OZȜ0hRSt$#տC:io4Ym%ke=-~^Z MrftDy$'(-,wz"4`g)!ig/]趽ا2ɶ.}z^6(\O4phyҾ~n#tչqB8쏎O.!AL}"΋K ܩmZrWysw?=,&%<˿*\"[#Fmvy_Use?YoehXvRХn踸h???T"Ȇ-c:l6!Cլ1h@׺|4 w``a~ʬ]wQjf3>.!Hu6q:5,g_r줪H"  <ܽhyB1 cq6jjUzRRg5Vc9@gol;tpbݎ}g:kaβ&I742F3 F!%uzxRDyd4+eBFch V DFY,azth"(.H*eh !? |8FT6:=*/ըnԀ-׏rɤ1?#,P`QsՄl&߿& b+-L۩k ^S;Ո *+&ʾM<;6ٍ2~gOxe*}PHwNjU+6L.`{]|[0j/T?ws㓬Ok/p̝ﭖjٛˤz5# qi@ewOӡKwStkګsӰ` BX,WX:z G.'>鞼xcFu{ՑYU ? 6=WnO#4C؆oOǁeL,۽+v;skZ?qB}彇w y₎'> "bOMOs47Ǡ d2F(bhT&@@SqFn :QgYl,gu~2| 'N]RY@M}|8"#dyn5zfՙV V@E(WrͅfflЂ>=b.TB膏]>>o I108jn<\l3}i?8"\~J o~2iexoYYQ!5==<&IȓC|βY%Rro'fpdy{Λ2v`֯4_uu??_ 4T:;v/ lިzu׍]=˵b w|wL3 srF;|a]L]Fd2Kד/ p?T4Գc14qPaۉY{ڿȠ6k[z/w =Tsz\,_~6r/3p(g|VozBmغnQq1!/֋fdݸqZc# AJBQH75SEtO&?(8c&t}Ы6d,' [5kӬfjq!~#?&G@r$e_մ§]n\P@ *\C E͞uC|;kظ.E=]޽`mdvڍ{3 /&9tH RH%ՙ]:4!pual)RT*!$"DV:paaT= b4kbsH}QsV%E|ARZFTUjefp<0&s1cQUO=t~Zqouih63ZyM.NdKD2aAt&cZ@$wWy0H?CHȽt9W2BЍ\k1sf+10D re",߫f!~ޞ T$ "@HJvfVђ_L,s/%LS Zǧ-URkiny:'@{_Ds2i`0-{> 婈U=,&"0(hgYf2[5:sm֣C9ߏz6@tN~I_=[;WPRRl%7{SʤDдcg9lL;2rY Ep"GOv{_ ~ g>s{L.z.H(p>;˙-Vܱbq 0bǮlvR&E"hr_fdZ^ChGoբr&/t =M_\IOZ}Zk7HDR!T6f7iѷ_Μ8tMky( $<*;h1xͿz/$*[&^'6~蔣r>6Q>>>(WvdSW~s쫴bc_ gwO{O@uW=E{7\i*W~g嬲e~!WSWT'<+T^ ;]泏r,AwZ9gϽеWxdu܇ y'O^kYvTMMl6_~e7;u!{ڣ)#c`ZSԲ qG CtczA,r*N$x:"e h[mH1 眳zEmoqױ&#r ]'jG0?gL$DS<E8 pЁCvXC8!D<4q1=0qc>_d`Z2M MΛ (aOW M}/.|ގ_^oǶ'#sID}Isv]o~3-7ya~8f|KB{W³ԱEog}DcLgtmч/uZNÜu1{ؒ}O??沣XϽo^^]S_{ᶑD$ZY~ȃ>?`O/ATE!;6/ '@T+4ФFLy]6S  bm {qEQF3_^yͷ޻v\jIdCF_H(kdT.m5 /6Imw$D Θ+H(zK%hD U]* S! %C%h{K[b*{8gSS]@JxnFU?˟.?<婭mV@2˲3CgẺ/YԱxQXD w5H~ 1Fg {½nXv"W^v‘.Ggr蟮?^k11kfO;4_-$![;~C78wҍ?̲%^Jra9o<xM c;X'?tĔ }:X4U{j_zpZֲ%o_Hp=?G:`FDB7}缂O[,U :B;~O9ztA n_wO䃼$'b~?;Orw{~c"xNݏ>={ƫ5]U%8=`lw)X6٧<b@'m[FX%!HSkR:dJ9h5U&4E*9Xڿp'7yߓ d%l=j˓om2%3_gV6}t~/}w?^!90Tqd\ |Md,5/Ze6%$W,G/7۽CEm,&wpq 3`B:矰o|CLF?/X*S8spCtƆGRXӏ)LTTZm )IJ" @JGJG("q]rw@8@O~0A~q2c5?o$؇RS:ѫP*/ǫ ,_:C4mo>>ݕx9N0{ ׅKp^\S{.<NZ5$쫟yeJ36\KWv i-Y0UE ֝N|K~_<¶RRJzu>ogN .yw^sN?B8b_un['S}U3]UeOH!گ\JUft^O}3~=bm[vBlَ#eaJZqQԩT-RAXLԨ.P|ǝ} nYu(gj}n}-9{/ vd! u$gK9kђ1Zp}纯}_6me 'g]ۿ?c~&8rvJmRin-5Kw/οD/Ž d$h:%ɎX8* # n3O>6ߢ{ɉy~v8-5&R*]q{BWPxނD}yt K ю!b]}O|[x^Ox+y83;elE+%11!A"@&I87>{tY/}531zB ]뮻~3SUR7hY̹7gW?E(5M)P:}hDZlL>/.;K/EÚҞr{6$=AHS{_Zv"ːM%{!% +P'h**Xomwg>;~-żY(maţD"ӝ) 4b$!g M{?#sU6t'tx$BD\:}M tvQ.4Ba|.h; x`;ʫW[;z#c/9ŀ*?%9CU?}v ==o>a b^*>W\/bO?zשbLWsun[a?k}]guʡuL{DB7^3i/754g Z4t\Uz}芟߲uUUcmqA\Q]6Wi/y_]x Խo45w}v?7E(3r֑|? W\~'qñ:Ѭ5+SAUej lR2IP۸jw/暇u% C575>joiS ?Sv{޺z-;꨾9ǷoYV.II6TTzvXU1H&P8{x(Д1ng38sƝo|[{L C3g F19dFղPT]X\gc|bIضp$#`8J&#poLE Q)~?~Ȃ 7)'N]aa=I϶#oR;"tU*_K4cr=?{˯?&CZ6?ﬣ/K3Vǿǟ'Ữ3(J굷{|l}J |G?̓vME2 L<×/~|ҧ*06ꆻoӪxEN?\xY=xxrk?+}6Ee2>S9tmtr&6I y3{˽]WNUJvʧZuh5IG/;N,\{u=cvc};wA3;߾`/ ,H5Nįv%oH["\mRA^Nj+j4V5dR V:Ebi ۳U]|Ej+N Xh0$ 4ܾ3ߑQ &HWԎdoo `tPٴ!TMAd\+?>wVd2PSpOw# X4 2jj 4:tO,$Sx<>C4єᭇ:c޿r?p)D#=pó/h} =|=î?GۖW^uW/LuA$g>$,]?}zK<5zö?޿ "gr,_4o  ʖ/~ɢ7z=Y>-$ۡ"r]kTO| fxP7{F9v pjbK䌡W[V |3#9CTz,1|՛o?X = v$G:x])g/ *exfM^umx5H8̝s;e #J%[okoYܖR&Es~zWQ"rMI[??xf<[7ދ>ﬣ=|.8.%F7o}_r -MP:@-| v=oUǯM7_uYj,7voO_֮{nO|~?rpRcG,;ԌA0!ktݔbFRJ*z:43 i ߃N};tl3>ؙ8ϼ3wuC6T3"0WTe0+#%IEQ$WXZßx[.捇$N4Mv3a5T<)i;Y6rHZmR*kt$|38\W~>lKʿY7p~2'4%<wz"︉*l~wO}<[zOە\`pY':HGmw>|YmӶ 0=r90D_cLa \xd}~(ϟӷߢGÖt%! {U=LTzxbny<| G-;p9X8ww7Wog3Ϭۺ~H%7t?|ެ]Yz"p΁!H*W>uGA(HWib0r 945w0vzfȚG֎mfKI~_@UtjI|F"``!K p INvޑ St5Еk*ܿn2юvNG79=}bipHDS䲶İ貟ۿ@IB~:Ec G0G:k: 2R際ùV]q˛;jnidL(岍 P4JE"0K؅bBT,Tͦw`Iߪڕtt4$ԫ`௛o翾ө׾7v _>vdkOxx7K7UUiUs[FV=n#gJPyE^"cT"tKglnoW2s%xꌷnO;|*gu`^-f6lʵzaLe,q9CMS>-7_:;wJ&a#G[+*XN&_*jcB,UjBJ ļ=}@84+CùGynsC7J՚i6[ lڒHv9x48{Fܙ=˗̚;; E@־ GZfrM{깧nٲ}TUkٲLe;ww Հ_z(Kbsgv/в}]D"`;p{xC)*՚Y3Lll-w szR*uAvBesCO-+F4fjY$N3MSu]3:osp*M|{0daChgsw~k?>j4ʻSTXUEEd ]Ld z242#r9r߯d29tN?OET'ёV6ʦX& C,T1{&nx'8-VAZ$BY9.'x΀E!po_Z&Cfvut&l1r3 Mm8W W̳~V^q[;fNaŶT2kQof&J6 狕#T5=LT,$jtr⑱h0вO~y{P?O FAUURP )]l(=m__wDoC._F2GCc#l\,*Ն;HNZ0BA_2NÝޮ`_#Jv%-YJ0 1|]k|Ƀ5M/?{ɇ:NB^.Ɛ!ko2!@T͗jjRm&6[e9}p? c@?YED$iD@Dl}@$ܫ16yh?^m J,뵆)t0[  BA"FCڽ IJ@^KXH*BV7˕eBzk@(OáH`d_j}/c{|ڕ}jD{!e dT-kZV7l9Ti ߧz$Eh|^KdBDiCRo_/:< >HOTHXiф)l"I CplFf)%p膦 iL\JrHA CHeqK"7rK09wIY8T2M M36oԮ&{bz}KxK`;>/{hRJRh8"ck8 )/~uէ _\?w1v }avz4" 'SDħ# lP,g3J,kZB~MfLph2CSN<* +SO2Z-/nV+:7C8U(3B[|]CcYvrG8BXSZ3ͦi>EaUUU45ARz|K7~UUo :g̅)tWumSp"w'eoAOv!E?>)]Kyn8C69޷Ɠ{+g$Ct5[$ӚQCw̡5O<-۔Z,su(VJ8BJ{nr K.~=Z**C-DRHLA p6k6hh}7ՍU ZSDEQ9-q)dg{ =nvv@ܛx_n/.SMtF ,9l#gϊH`Z5UTUSMU|>_05PpT9cHZ4M MOJI.O"0~kl)Y}7|?ĥow=C_v0RJ)h[bo%Ҥ^ѵA 1d!2Zik &~w}R:-l !8~ PW:h9}?×sY4w`t*"!ϧ+ R:fi4*Uӫn4ç{c[PJ8-iZNjO$kvlq?>ɏ}VBHvxv+wcԲG)[UMm$$2@V)6& 't0q뺇slVP) p M/,$pl"A$$$].u"0Fڨ7rIT29"UPYfI 4Y.J2H5Yݑ)P8XiʦIN0$rGJ9IH@)4󕊔ҧA]7M努p ~#rΘ骢:*j+L7T# Ghkey}ҏc?qrN$kN皏Ys:8dg.O¦[3E{p|=#m^ DMes~ a3g|Ѳyg;-툍#qĎBH7f8+|?=9xӎ?G9ȍׯݖɔbvDTUq h99/ŏY'n_^UPi6,F2bh-Wn5ZNVwk_Cs4m6Cډdp@8@-KMYQ6ۇ( `ڜwSrdYkO?YfFǬmk֙k[KjRLfi4ǶҶQuDlh˸X&r2UM[ل8`!8rD$)Tg/Tq{$gEcCw><}x@WG0n 3|\Q97Zc\P}Z25cl0-CRh4[*Wj9c!g\QW ,8 [#lL?Ħ<fJ!n3׎a}A}F[ іrnZ#f=ÓJ]TR/D0[;\uqaX.V=98)ɓ'OEYg?7/뎿W9 8;S,[uLS4v@n#D$)HA¡%MSjN,sm޴e-RU!"2 +/r6qY[{]~{w]MO<ѨօDamȺ`$-[lesK0FVlK,nv$&$1"FCK̳Ùgͪ% % rDӁ='hhy5+oA˗F"AD(phg3uv}iZZCSYgg"t8mI)NUFC/~|> b;r;~{O\/0V+5ź9ϘhL**4=&~-hc LDBD[V}x[/8'5S8lkhjH+!"t=Afw~;y#5UqȢWԓ'O`O<=a{rD;zz M ~L W{ߠzw'ѓtÜECC(!cRQdĈqqHp9ZӺҎ$ 0˟}Jf]~ƤRh=wF:;Ή]]Z0]glx8,ݎul˲F(|!?:^f&J\ZC[KhKi#b<Óf!FEDTF2V>=Ƕ?S"uӪp|_0(M)PPد*D1&Õ6M~O=ce}SOo/Xg x\id3BVlTEKbP3I5Mb 1I(bf[=~Q/^j׮8 ӴN{Hӳ#D^[k6l=>jJ%cu!IGp>3ttC']\q $:yӜn=c>?S/y٧v͏>̫`O?顱:t['9`q$Q8$'NOk3v8r* "0&A0@Vv'z'6=*dZ"JRXTS( ~X7XP.̺퀪pDP$N/] "܀48ղM2kfZ*rPl+J2HcHW;x,`+嫚΃2ehd8S :{/I:,Nl_vt^i Yo4:!Є͖XBU55 @ MU1D Gdz?U':s]2ͯ}hш /u~Q7jn|X4ۗJtz`(u1/=+VO?h4,L~Úed7Lk]7 NJ?XHǶ jn5[㣹LRifjMa6m(4EUMc~7t7H2tWD׵ZىukZ{x2eJnR-u[/UĴN[8RbN%gww!zQkV1~a)+B@D2!T24Wr]zaT$LJjN\/ىR&S+s顐?$RQçj: Z5mfrP:{f5S`׊4g7޿ꩇ[)V¾eK +A:W"Qq-F@QudRch(2T:֑*4ipk^S{k}O~멎z'O<`p]wd+4um 3<\.kʺ:|ɤ0A0@ mz⯫?n8ph9 {S=}F(l\W!HK@HcfFG&n۶fMadK\ hݱhl!\< Ǎ :ӑX/Ù¹˜@D* 7B,1"ɀiV8-ǪmY$9H10ݧWuP}:(ngw[_y'Ο%t60Ȳ;:|h8"2j5\:6R*jj~_`Ǒ/&Z, 082d%c<#ܘ׻dh$8 JH0B Jī+3ZVlH#d5: N?=}'{rg?~.7ד'=y{(deԑ5#FR#z ,AXYۇ&o{zГoؚ/ךH"G0Ek W58f)Z:c[$eS$Ȅ?ra̙ѹ`N*1 IIā1+xv污LQGs.Y2dQgG_T &m'q^{8Hkl^Tu'I̙Փ҂ W$K(lN&SzfZeȡ.9':|S؎ϯ:Y-ǶOA]7T%I`@(ݓE!s6'ZVǑh[N8q+^zI wI7WoQUo\-VKEy [ZN,-㱋}GͻkS]vT`$Ǧ܃wۇ2>E_<{Y} +k\UO(x\Q2DR3jv0OuFaV_$.yY\<+[o=yؓ'O{0+M$0D՚5iR!`H`ӨfV;.|bbn4MOh'VO͝3p~O8u ID3dMre[O}{>nдVSݨ[5Oo} cC5;8Hmu b3ٰ-Ϭٲq˶#h4hE ;{aۭߧ!iM|~9ӈQ25 frHw ,GV+bnv8l$;>9Uǟ𳗜ލo;ys$]s'?pFc9x|k{߰@ԾO3kU{Wzԉˏ?y8x4>s΅l[MtsٳgΊ'@sΙ  * b*(5vvV-[;á 8`MA <1f댋.[К<8 ,[2[z7nF$IJ! 2d ٲscO=e|`9v<9{`p`F_8T2&U+ Zn2 Έ$0 9gvz^(D$CUV8H$LJBxpO:\ N?;-$m {)ZͦlJ3tw͟;??S7{s"իvԉxWW W G#pVzt~m-K©yϟE :2B_1xCq9k4x\ݓL1'i"턻uU?푳Ey[=A'O{`=~"qrܦf/Ā 3[vf2:Zhf:O kg 3%]ے;ΊDNo(zͷ$x7?h3To9ڴܶb-wyݽ@$7iT)7nY~cj:`% gL23)>_OwgOO# 1)mI;l$1F*go4qhf"/V*_AǞ) =𗿢jn7C +]|5_n;/`;; vQ" ckfPu.$Bdi9v$x0SU (Ȭ%0>OmZ~(/;#-ۯwɂPDEeF2ͪ)(3 dH !ʑS -ZLղHL'NzFձ\?} o_tٝt#bz䩡'6>^ZfCԫ1]骪={s?Tuc^?qO_⾧g~B,+O^ܙqqgG7=r^~=w?g-̙5wdg:'q|h)V2&J4~bM2X2P56ɫ;1ʠ.~SNSq`DTuGpܛV7_7t?_Θ`=y! X2%\]q/#0@frPߴ=v†_"0UI'>aܻb[>7{,INg =rܙ|a,׌[i6?- |Sdr޳o>5Z,C/[`fwo1)neD PhD*?vesd4OٺiӈrK:QYpI HdC"sZ#F(ބ4,rD{!:ʕf~),DR3k$'"1I~ 2o\qo>n^C$PYTlf! x櫅B.dyi;U< vSX|O_uʳN?! ?%2DF ukXk r,^0oT2!Ip4 UQg\ՒL3i0T:Ot]}/J)m)X>M`Ȟ_?nzɃUU GzG$Mc#c ѧ{ďK>tEo:8uon^-@J03{p¹]c frA"kKq'kq"H :6UJD^#1(1")llXTR"M9ܓ`nCjmUñe(}U rmۧX|O{ /B_L:DUKzTiVjͲ,Y5IPם|ÎW>ʫ?_an" @F\6 Z'?pάE RRH!?D}UYhV:LƌP|>@ 1ۂl11=x2 iW"0"W|MħR$%/6 uΧK|>C#=y`O<0%#,$" m&kM0]z0 ѰG'*°3*#m0>vnx<>^eR&nOY?C$qѲVoY i˄WhoWO,1͑l!WU+=wg;N@hN#r.fRߴi7l8+V̖jj<I&~_Tl(>s;rX"C]5  `S!fD@&pll5ڐhIHZ(FOw-wse,[O{^䲕 {6+iTMDx$:BT4 qdtzٲ/UHnj2F7<'hk튳N?>‹>L{υF6l^G]W,V>sL$#GJ)54qE|#3^:Q]g@OH,[2c`4Է+I/ {:]ş׿ݞo}o;i޸yx ̛97SaXUU2PɯTD G *Cd ŢWV3 ttDtNw7ھEj>WU:, `7D _o s7o{Ƀ,/!yɓ{Q Ӗ 7ljRM7o0|@w4 :)'`M1"6bx87:^RUM> L$`X4r-w<}X놏z"G.q~gvݒՊsɗ~֟{I+y|h<8SŧH# ab>[(T]Ubp3FBJǦJ۶mth8[*7lGD¡D$DXVkrR*MBȞt'~j fj%`MST}>(5t+%2weQ&S)pc1zC6Ll `4~>aN`\oN9mɥ0-}׿휣.!)AeRU$`e5vPgFF m +X( vX< F+*CiKVZsUuxN/͎͆G*=fG2?wAPmG74XtaE/!pbeDz2 t@d]§Ѷ|u#NG&B#Kӿ-y]_]]nK~'_zտ2>j[{}#CqS"‘3dLꚪ0n"`@ i zT5'LUHJŪ5*IMlf{w|xl{2wstܢR: !"cɂ`YNcB>>:Q,M"⡀'7nNSZW ɿ%{Zy60ӳtH$u)%0 k㱈qiB(m+%11Qm6D,Xۯ ]km;/JVՙ&Aw"yi˰Z7='Wo~Ƀ;})oM/ W|^'m8/zwo:T@.Aq> zΌj%S|M S+ N6REi S4#آY F˲l!,[ *O?t\>a6IE'$ȏ|i)izά;H-u_d j6VMn#J4A#d}2l׭rMVP5B XE72BmY.Wn;yA\կcZj|O^k3N=& Jdx50Ƥlx5۷s͞Ƅ#Zf ST=h(ᱨpǑcb &ƋvӎDWqrָ&(_4GFʭf# vuD~F:.Umlnkܓ.||=y`O<x`4j0"I_qGU+Dt8 sa)I%b31VNT3 ((D>zϥWؗH<3 ۀB;<Wq3`R |IBcL j +{Wc;" jَ8cK N{*/[yf~WwOJ NSn`1[JZF2u uER:CHϹ LiD**UрF $#*zXU~wé\h~5kĢx,NzSH"5U܍QO$I"ڶ([7g~8O^{N9*<1&%TydUOo6QHYp^27t!6-+a@Tb,n ǁjd*DczgZKӻy@Tw^M67Tjh^`wg8c$>pl [k"{aF@N$=~177{\ǣoE3Ů'O{ɓE<Y{Oj:88~y &IPG+LA7d*ju \IMZg\ jZYJO|뺭#ht6 pTUXp(tMG'<ٍ3gϚ;~4C)t) Цg7?cɂR ۱[f4[VlZ޲Z-l\r~C;ߗu]Q8PI$%i.Vr)hJFÑfdq2ȉy( P@Q[ HRP)h7K喔,bz"WcTbnʎdr.t#6ЙHFbSUUAO0F3ӴsM15N9* .{H@Kb[ I ҉eK̙97tMWUMc\h3&bG5#BnhL#Jesb\B<L Dݪps7RX x$l "52TR)MS3{z[j_~쌷}ރ 9?o'm !IW퉉DW{ds$ڠҝHDD $1VdѐlT, *eshK6W(7Zm 4V"ᐢ*wC`( `\᜹&7( O>546|d2#8th^H$[_"% cER Qo5VPjl6kucޞH4EP4 uCS9WѰ jijEVjpB!Gڈ!JC`XQUpELū$omݒa4I(o=uA|¡-J3+fm6j i;? twc`, 5ιk+Y9bI'-_r>t)GE">hWgBD$YZn>fݳF#O,pf@`-jF3tR X$DY`frF ]HXswh*Nwf`S;DwIQȯH>/]n[Gjrxs?`=yUIL2t$[h91ܨ/R4zO_4WۯdHJBB0LV6T]Gr6mޒU*)[ܯAr#PwW+g m# $ґȹiYccZv[BƘA#s p12Dbqΐv(h6F^Wz>W6uUMLU|P#9s9`XJ$f be C~oPGJGAZ-+),D`}h @h["_hV AMSl[Jv :e{z`z;5/ݓ;?|a.c#\Ш6#SpWG+K!MU΍>邏\v 8CDPJgXuZ#1gTwOwx $ RәPNT"11KE{|֨;D  t&.N-X2zRTL1C.u NvŽ/=}w|eק}Z%>ogc^ד'=` ]iDI2ɛ6k9 $Mt *%3rEOAE %DIDD$%BJdPZ\T)JǞZVlͩ x$J"xDfñ,ȁqH$P$Iq,nYlZVѨkMƜshrlqm;R dqk+ ֮n6[P  A g膮qcaBF(h0T*dr'jOGǑ`h, ZV4% i KG0~Uϯ6G񱒰e"Ou}>C9۲-Qt6n{ZfoضPvǺ-;ЗHBsfU(T'&[7lt#cՉwV޿k:##anP jCwx5ͦǻRtu93;j<4'f)'d F܈CH(#J%}A?)D}b=h,q3C`4Ñ_;3mLJ2|皗<1v.yG:-ȓ+U<`Y&T8 s\Jl6|9>Zl`HFc@9@R B"2! [ RQw̆;\㧟z $Gߘla9BeYNlZUתZ4?fa6-؎#p|}sg͚ 9 =fذ4|F ` U]T@5sЩ;z<5 ?`wg;9Иm Ђkn,Θ#`*P@FcʙFv{"q&)qX1?:#D8͍JBR`M͙5cX"E|_%jul02/yn6.|1xܿ\PB&[cV=[ҕ7sFWg2j:\q,fl>o6[!3;fvvE7ǒrj9Ѹ+VnSq_*B0(adrX9ꖟC|m|XN_w3VURk1`é._z:cjg^jT+F|ALiܙeՆlhz UD,J{Rt") qVr\:狅Z0¢!39;t܁doO,_w9b1.G>>4lYs ]4\7"X'Y0 H!^̸Y,V~_Ow4VgϯP05WyӴ3x,:P@"Ci*N'>UR+k;??l{oz<ҫupe`Rku^ŏ zF!$-͑$j% I )  RZ(WljJ(GPeT%!2ƹ:/|Whm[F9MiuvgTxv./ߐL/ :}ga2DnK8@Dȁ(e՝BEqmIU~׸9\BLkt^4PWW$m;?1!}M7n#mD2besE |Tb#s f|a,f+ |JW:2k0=;'W2C fȐ8` oT)*>LnjTNsC׬ސcD#6zB ~O.M^ד~"v_<%{OO6&Hf p 6B$De#I9Z-7DIJSaN0RTZqƑ!`2}WXߌnI`X0~#l:#珀rZy9@KBv_Lo\,&riCScHD蠞tDRJDy/ݩ~`\~C|\WIWQRe(99Wku+u#wb!}w|@SFىf>gN$ $ɦ&VвEP+L#@ J*Fl˾NM_OWN`x~B,_K&O<6-5/[@J3'O{+9kDLl9 IDd[0z URir XP*ZõqRJE2py25E oGlC,,YRNwtW!sƸO4E*Wa,!Ħ[w(۽Vn\#ᠪZ0[-pGH C`H H D@*_t`oOHI$%c,zw}0 a~*N#24l^.5|_Gg4wsUï lYstR*zPDR@!lԝFST2[*U\\Lp4N]d2 h6#HTkZ9TMHQgRK1j91ZWl]SSp,S #{؅ѶT6GFfÌ=]`@C@ 55E" }u̵m?!_X$}p,/Kn5wdw]$<`UɅi*@HfIjZ)VJF g>Crr!m'ɶ,oZ~ӍC#<\BdҶ[۶}@Rm5pU/EP8RX(umGA(8r;0D㿿>zBX-e;2_(j^.WJQf$GRH $q6{`9 @D$Ύ<3<C(A>4Ec\눌5jD>_Ww<#a?XD@8͕焍pG:v2p;03 \kN&e+)grz\irf;tg:tttR*Su<8#:H8 TUa "z<# 2_ZÞ+O4|uEab*eΪSv Ų923kD<"InsUU %SSHvUxz\?ʟ|fӯ~mw>͗[ch[?r?(y cLa6>fÕ鬮Τm~=yag"hg_%$r XKȦL*L]e!,,eH-K] #dw>nIUHZWCpT3)*rcp4x7ш"@" a뚶nq> -0v)"(z۝O]y~s9!Zm 0j5[|P*Ucщ ٴZriU 8㎰p*Ѕpۡpih NEk,Ue6vP,YCz橮Qr_AJ*͡hCbJˍBe4Wu]3DOgj?(gO<55‚~GT\ G `6)Wr8Vg*H4t͉yoMJ:ZhHDC~$.6@e*R3[%M`Ob\{z|JD_:3( H)MZ-@CS8Pϕtw?vv!`َ,cZ B#IVu3 sw4Z6?> !H=yɓ'={MC>.k!H M=rf$*EQӴUDt @EQ O<3r.'"X$LD:⁠RJ @@ኔR8%Q{og>pсRn k v_\{9cDRJ04VB`ji6뵦p0 Jϥ$UAGG-?wNDfVnwUw; BŃe ƃf OYH?5;w8H_=֢O>UI׷WAg8>iթºم6{Xrr|بTXȒ.ަ+S2wpٜ*?f6.^E'FX7 :/^ufn.jE^^߂D4Շlme4 O$W'ⵕj5dũY5% J*bLu:NGդ{;L:Z98lԢ4M"}9Բ%;5`!LTTfӤ{;v2L|WQ\\O~?w_?;MZRo$*v{rx7mjE͊4NR鬝s.5O`ry>đ?9iףHKS4o?𪢒&덟=t_>=lkxo]^b:$72'ǭGN$fJB/RϬ"6q i&d8Lz74Q ['ժ,FoaQ?y[s^{xFIN,[}0 2Ho*ol[<ײ,Il6IGzx?혤!MMĩ:fbR,di:u!".M4a0w{j7[{_}+p8/eL\'Wooje:kzWI6NTJU,TUE.FlncgAmӽvgSRvh:N?o `%ܽ7UwR 4m 8;,h0w z0M , Y+BBVQzu};ͪx#|~D$X"&TZ\.ίp4Vޭ:M+*Xwv,gK}W+QRߛًn~kV%gY|~3N{;Z7\+4NT<\#g#h>>`gNB2v5cGNxlS*sg~doWUsŽahGov.8& `mXWg}kxa4IF lZj48`8%&N?w#?g?+I94r"i2덺Rÿo/7QyM+V^S'iE#H"O0L=sOwxPcgbm*"ib7/.F~aPfiqI𝭧*"wyq;9=}t܊b_\KbSf$xy6rnڻvͷ;Ϋ8ɇ]u>^wS:LJtѮT$|[ZJ#v `,+rotI>CfX!M'2h8 L'iI0{E{8`2QW*?ѿEZE^#1 Fh05*ՈZh'JW8rQG>ȒEh=uFq'fl컺7PBzxf25[Fu~v|f8 [kONZG(bx ^V1__]tURDl9ycݛ_^""R֪悉mYxZWxcߙM30LXf.;-4O$`ӑL&td^>ISkԪdu#?9iﵪNꎟf?,jQ{;t|uJl*j|ZuYh""Ήh\\Eޝ>?Dm]DMfYg47?ݭ7*"l&[DW˳J%Z. "L"JO967WWJÓJͬZQ寧/j[qZxs޼H:XOK՜+d"q 0L?0{Mxy1?o}qª?;ǥkHV$Mx\4xm5N,ףHEcI27A?j>vv{qq꫱/=^zcu8zS[ň/g0%7Wg$Iw=m6bs;IE[<"ED nyS/|$bӒyM!k~MZԹಧt _[ZRz=Wp~WGorDS1&?.~v;fN8U4ITdf!I-1K,l!3\68_H̉48I('sf:/^viZ9]Oбd^\ _GG{Oo4"Q]ʃ[t{/;z5~`'+k/JH`^t^+/Ed^_t//:Q?h>m+J+,%¥tzggQ.2q\-rBX?yfXT|::XkvUsNH63:qNSsNfQ<_hV|*@mt"xYo|g_ŋ0 `bE 9ʍ7(m_P2b'wdb. jQdA$Va,I_ ^u?}rppI( J p~4N'ݽ^m Β3~u~;Ow[Ul_0]^<ʋuwWNӓ݃jqP5DzDռ2:I3tAm ok?jUbgԊUsML k痣&W99i֪Q1]`R<1Q ͝GfUv[4UMxTEgUEt3z.X3U5-Dx^, ٴk֋U5qhWES]"` 3x1^oRBz kc3]l YD4IVIP !;{YSԜ`h췚qR1[WUsJv-QѠ*={v}l4.$Y.+vKs"".r9K_'jN_ G׵ͯ;M*wg( @G ;1-/["UsןTuj{$|sv"t$;g8 BjV^ X]Uq뉳a?9U?ŪduPH]`dW.Ow ` य़xw aۥˣn\b m!|k(rE_畮aQv|SK'%cN `V,(48ri+j9mY n1sئ9?{}) @?hCٷo|f_ٚ?MT̅cM?Mst.nZ-ۂ`/1',g_7ϻ6/-l+ZvH6D_]g6w# \ l Xi^]md+)-/ɛWw9ݝmAŕbbbS5?\[Y*Ƌu`-zҵsw%]0nP ]J?ZDGYuk&?tq6+yk,ݛϻq1rNvY>d'5I5`|ޭQLڼް[r{ޚX6aWît޹г,MӺM8w_+w2 %qVG~ՑMgN噵g)/+Z\ئ)W.8M 5.}O/|ij0> ^Z\vU1bޚݱs2Oŵ鶓_osN3/|:չp-fϚ:3s(xA峂mKi.F-ULW7-xد^kzKzM;y]0/_=~Y=*: עto@ˋvL%}?2F@6ƎosC{Gب}M/`KODmݖb`Lg>4j,wS5] |({]m[qw.._7{{kvkn:aOfokmiɝ,z1ύ-߿>[KLu\~۶ԯq;_bf}/ \w~ ߿4`.쏛 r%Ħbe+ _)g3#P)_TPa'Ω:1s> *A|e} 7-G/? V׽La7xf._L[n(uio׾yHwr>hL~ԽY?_YޘގwU;crVn^T^)m~M1W4競\mq+TKm8Pw?nhoRQyk/nIW-^qnVٴ=|rO~In`9;t㉳K7-:Ebufwsk3r?.< 7m-]bGCS͏Ɇ,.fvoK;-gbOL궄ҫE/dۏ'Uwli?&NTv8V_As4X,Ux0L+G6@EL,K)dRX)qfRu֯_y ]Plgio}q'Z`}݁mwSۘny|a_z_޵](˹Xׯ[e#Y~Z`ql]@m'````````                 000000000@@@@@@@@@`````````        9@@@@@@@@@`````````                 000000000@@@@@@@@```````_9 .IENDB`BrianPugh-cyclopts-921b1fa/cyclopts/000077500000000000000000000000001517576204000175055ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/__init__.py000066400000000000000000000065521517576204000216260ustar00rootroot00000000000000__version__: str try: # _version.py is auto-generated by hatch-vcs during build from git tags from cyclopts._version import __version__ # type: ignore[import-not-found] except ImportError: __version__ = "0.0.0.dev0" __all__ = [ "__version__", "App", "Argument", "ArgumentCollection", "ArgumentOrderError", "Token", "CoercionError", "CombinedShortOptionError", "CommandCollisionError", "CycloptsError", "CycloptsPanel", "Dispatcher", "DocstringError", "EditorError", "EditorNotFoundError", "EditorDidNotSaveError", "EditorDidNotChangeError", "Group", "UnknownCommandError", "MissingArgumentError", "ConsumeMultipleError", "MixedArgumentError", "RepeatArgumentError", "RequiresEqualsError", "Parameter", "ResultAction", "UnknownOptionError", "UnusedCliTokensError", "UNSET", "ValidationError", "config", "convert", "default_name_transform", "edit", "env_var_split", "types", "validators", "run", ] from cyclopts._convert import convert from cyclopts._env_var import env_var_split from cyclopts._result_action import ResultAction from cyclopts._run import run from cyclopts.argument import Argument, ArgumentCollection from cyclopts.core import App from cyclopts.exceptions import ( ArgumentOrderError, CoercionError, CombinedShortOptionError, CommandCollisionError, ConsumeMultipleError, CycloptsError, DocstringError, MissingArgumentError, MixedArgumentError, RepeatArgumentError, RequiresEqualsError, UnknownCommandError, UnknownOptionError, UnusedCliTokensError, ValidationError, ) from cyclopts.group import Group from cyclopts.panel import CycloptsPanel from cyclopts.parameter import Parameter from cyclopts.protocols import Dispatcher from cyclopts.token import Token from cyclopts.utils import UNSET, default_name_transform # Lazy imports for opt-in features (saves ~6ms on import) # These modules are only loaded when explicitly accessed by user code _LAZY_IMPORTS = { # Submodules - opt-in features not needed for basic CLI parsing "config": "cyclopts.config", # Configuration file parsing (JSON, TOML, YAML, env) "types": "cyclopts.types", # ~3ms - special types like ResolvedExistingPath "validators": "cyclopts.validators", # ~2ms - validators like Number, Path # Editor functionality - rarely used "edit": "cyclopts._edit", # ~4ms "EditorError": "cyclopts._edit", "EditorNotFoundError": "cyclopts._edit", "EditorDidNotSaveError": "cyclopts._edit", "EditorDidNotChangeError": "cyclopts._edit", } def __getattr__(name: str): """Lazy-load opt-in features and rarely-used functionality.""" if name in _LAZY_IMPORTS: import importlib module_path = _LAZY_IMPORTS[name] if name in ("config", "types", "validators"): # These are submodules, import the module itself module = importlib.import_module(module_path) globals()[name] = module return module else: # These are attributes from modules (e.g., edit, EditorError) module = importlib.import_module(module_path) value = getattr(module, name) globals()[name] = value return value raise AttributeError(f"module {__name__!r} has no attribute {name!r}") BrianPugh-cyclopts-921b1fa/cyclopts/__init__.pyi000066400000000000000000000041341517576204000217710ustar00rootroot00000000000000from cyclopts import config as config from cyclopts import types as types from cyclopts import validators as validators from cyclopts._convert import convert as convert from cyclopts._edit import EditorDidNotChangeError as EditorDidNotChangeError from cyclopts._edit import EditorDidNotSaveError as EditorDidNotSaveError from cyclopts._edit import EditorError as EditorError from cyclopts._edit import EditorNotFoundError as EditorNotFoundError from cyclopts._edit import edit as edit from cyclopts._env_var import env_var_split as env_var_split from cyclopts._result_action import ResultAction as ResultAction from cyclopts._run import run as run from cyclopts.argument import Argument as Argument from cyclopts.argument import ArgumentCollection as ArgumentCollection from cyclopts.core import App as App from cyclopts.exceptions import ArgumentOrderError as ArgumentOrderError from cyclopts.exceptions import CoercionError as CoercionError from cyclopts.exceptions import CombinedShortOptionError as CombinedShortOptionError from cyclopts.exceptions import CommandCollisionError as CommandCollisionError from cyclopts.exceptions import CycloptsError as CycloptsError from cyclopts.exceptions import DocstringError as DocstringError from cyclopts.exceptions import MissingArgumentError as MissingArgumentError from cyclopts.exceptions import MixedArgumentError as MixedArgumentError from cyclopts.exceptions import RepeatArgumentError as RepeatArgumentError from cyclopts.exceptions import UnknownCommandError as UnknownCommandError from cyclopts.exceptions import UnknownOptionError as UnknownOptionError from cyclopts.exceptions import UnusedCliTokensError as UnusedCliTokensError from cyclopts.exceptions import ValidationError as ValidationError from cyclopts.group import Group as Group from cyclopts.panel import CycloptsPanel as CycloptsPanel from cyclopts.parameter import Parameter as Parameter from cyclopts.protocols import Dispatcher as Dispatcher from cyclopts.token import Token as Token from cyclopts.utils import UNSET as UNSET from cyclopts.utils import default_name_transform as default_name_transform __version__: str BrianPugh-cyclopts-921b1fa/cyclopts/__main__.py000066400000000000000000000001441517576204000215760ustar00rootroot00000000000000"""Cyclopts CLI entry point.""" from cyclopts.cli import app if __name__ == "__main__": app() BrianPugh-cyclopts-921b1fa/cyclopts/_convert.py000066400000000000000000001047531517576204000217100ustar00rootroot00000000000000import collections.abc import inspect import json import operator import re import sys import typing from collections.abc import Callable, Iterable, Sequence from datetime import date, datetime, timedelta from enum import Enum, Flag from functools import partial, reduce from typing import ( TYPE_CHECKING, Any, Literal, TypeVar, Union, get_args, get_origin, ) if sys.version_info >= (3, 12): from typing import TypeAliasType else: TypeAliasType = None from cyclopts.annotations import ( ITERABLE_TYPES, get_annotated_discriminator, is_annotated, is_enum_flag, is_nonetype, is_union, resolve, resolve_optional, ) from cyclopts.exceptions import CoercionError, ValidationError from cyclopts.field_info import FieldInfo, get_field_infos from cyclopts.utils import UNSET, default_name_transform, grouper, is_builtin, is_class_and_subclass if sys.version_info >= (3, 12): # pragma: no cover from typing import TypeAliasType else: # pragma: no cover TypeAliasType = None if TYPE_CHECKING: from cyclopts.argument import Token T = TypeVar("T") E = TypeVar("E", bound=Enum) F = TypeVar("F", bound=Flag) # Mapping from bare concrete types to their default parameterized versions. # Used when type parameters are not specified (e.g., bare `list` becomes `list[str]`). _implicit_iterable_type_mapping: dict[type, type] = { frozenset: frozenset[str], list: list[str], set: set[str], tuple: tuple[str, ...], dict: dict[str, str], } # Mapping from abstract collection types to their concrete implementations. # Used to convert abstract types like collections.abc.Set to concrete types like set. _abstract_to_concrete_type_mapping: dict[type, type] = { Iterable: list, typing.Sequence: list, Sequence: list, collections.abc.Set: set, collections.abc.MutableSet: set, collections.abc.MutableSequence: list, collections.abc.Mapping: dict, collections.abc.MutableMapping: dict, } NestedCliArgs = dict[str, Union[Sequence[str], "NestedCliArgs"]] def _bool(s: str) -> bool: s = s.lower() if s in {"no", "n", "0", "false", "f"}: return False elif s in {"yes", "y", "1", "true", "t"}: return True else: # Cyclopts is a little bit conservative when coercing strings into boolean. raise CoercionError(target_type=bool) def _int(s: str) -> int: s = s.lower() if s.startswith("0x"): return int(s, 16) elif s.startswith("0o"): return int(s, 8) elif s.startswith("0b"): return int(s, 2) elif "." in s: # Casting to a float first allows for things like "30.0" # We handle this conditionally because very large integers can lose # meaningful precision when cast to a float. return int(round(float(s))) else: return int(s) def _bytes(s: str) -> bytes: return bytes(s, encoding="utf8") def _bytearray(s: str) -> bytearray: return bytearray(_bytes(s)) def _date(s: str) -> date: """Parse a date string. Returns ------- datetime.date """ return date.fromisoformat(s) def _datetime(s: str) -> datetime: """Parse a datetime string. Returns ------- datetime.datetime """ try: return datetime.fromisoformat(s) except ValueError: # Fallback for space-separated format (not ISO 8601 compliant) # Python 3.11+ fromisoformat() accepts spaces, but 3.10 doesn't # Convert space to 'T' to make it ISO-compliant return datetime.fromisoformat(s.strip().replace(" ", "T", 1)) def _timedelta(s: str) -> timedelta: """Parse a timedelta string.""" negative = False if s.startswith("-"): negative = True s = s[1:] matches = re.findall(r"((\d+\.\d+|\d+)([smhdwMy]))", s) if not matches: raise ValueError(f"Could not parse duration string: {s}") seconds = 0 for _, value, unit in matches: value = float(value) if unit == "s": seconds += value elif unit == "m": seconds += value * 60 elif unit == "h": seconds += value * 3600 elif unit == "d": seconds += value * 86400 elif unit == "w": seconds += value * 604800 elif unit == "M": # Approximation: 1 month = 30 days seconds += value * 2592000 elif unit == "y": # Approximation: 1 year = 365 days seconds += value * 31536000 if negative: seconds = -seconds return timedelta(seconds=seconds) def get_enum_member( type_: type[E], token: Union["Token", str], name_transform: Callable[[str], str], ) -> E: """Match a token's value to an enum's member. Applies ``name_transform`` to both the value and the member. """ from cyclopts.argument import Token is_token = isinstance(token, Token) value = token.value if is_token else token value_transformed = name_transform(value) for name, member in type_.__members__.items(): if name_transform(name) == value_transformed: return member raise CoercionError( token=token if is_token else None, target_type=type_, ) def convert_enum_flag( enum_type: type[F], tokens: Iterable[str] | Iterable["Token"], name_transform: Callable[[str], str], ) -> F: """Convert tokens to a Flag enum value. Parameters ---------- enum_type : type[F] The Flag enum type to convert to. tokens : Iterable[str] | Iterable[Token] The tokens to convert. Can be member names or :class:`Token` objects. name_transform : Callable[[str], str] | None Function to transform names for comparison. Returns ------- F The combined flag value. Raises ------ CoercionError If a token is not a valid flag member. """ return reduce( operator.or_, (get_enum_member(enum_type, token, name_transform) for token in tokens), enum_type(0), ) # For types that need more logic than just invoking their type _converters: dict[Any, Callable] = { bool: _bool, int: _int, bytes: _bytes, bytearray: _bytearray, date: _date, datetime: _datetime, timedelta: _timedelta, } def _convert_tuple( type_: type[Any], *tokens: "Token", converter: Callable[[type, str], Any] | None, name_transform: Callable[[str], str], ) -> tuple: convert = partial(_convert, converter=converter, name_transform=name_transform) inner_types = tuple(x for x in get_args(type_) if x is not ...) inner_token_count, consume_all = token_count(type_) # Elements like boolean-flags will have an inner_token_count of 0. inner_token_count = max(inner_token_count, 1) if consume_all: # variable-length tuple (list-like) remainder = len(tokens) % inner_token_count if remainder: raise CoercionError( msg=f"Incorrect number of arguments: expected multiple of {inner_token_count} but got {len(tokens)}." ) if len(inner_types) == 1: inner_type = inner_types[0] elif len(inner_types) == 0: inner_type = str else: raise ValueError("A tuple must have 0 or 1 inner-types.") return tuple( convert(inner_type, chunk[0] if inner_token_count == 1 else chunk) for chunk in grouper(tokens, inner_token_count) ) else: # Fixed-length tuple if inner_token_count != len(tokens): raise CoercionError( msg=f"Incorrect number of arguments: expected {inner_token_count} but got {len(tokens)}." ) args_per_convert = [token_count(x)[0] for x in inner_types] it = iter(tokens) batched = [[next(it) for _ in range(size)] for size in args_per_convert] batched = [elem[0] if len(elem) == 1 else elem for elem in batched] out = tuple(convert(inner_type, arg) for inner_type, arg in zip(inner_types, batched, strict=False)) return out def _validate_json_extra_keys( data: dict, type_: type, token: "Token | None" = None, ) -> None: """Validate that JSON data doesn't contain extra keys not in the type's fields. Parameters ---------- data : dict The JSON dictionary to validate. type_ : type The target type (dataclass, etc.) to validate against. token : Token | None Optional token for error context. Raises ------ CoercionError If the data contains keys not present in the type's fields. """ field_infos = get_field_infos(type_) # Collect all valid names including aliases (e.g., Pydantic camelCase aliases) valid_names: set[str] = set() for field_name, field_info in field_infos.items(): valid_names.add(field_name) valid_names.update(field_info.names) extra_keys = set(data.keys()) - valid_names if extra_keys: extra_key = sorted(extra_keys)[0] # Report first extra key alphabetically for determinism valid_fields = ", ".join(sorted(field_infos.keys())) raise CoercionError( msg=f'Unknown field "{extra_key}" in JSON for {type_.__name__}. Valid fields: {valid_fields}', target_type=type_, token=token, ) def _convert_json( type_: Any, data: dict, field_infos: dict, converter: Callable | None, name_transform: Callable[[str], str], ): """Convert JSON dict to dataclass with proper type conversion for fields. Parameters ---------- type_ : Type The dataclass type to create. data : dict The JSON dictionary containing field values. field_infos : dict Field information from the dataclass. converter : Callable | None Optional converter function. name_transform : Callable[[str], str] Function to transform field names. Returns ------- Instance of type_ with properly converted field values. """ from cyclopts.token import Token # Validate no extra keys in JSON data _validate_json_extra_keys(data, type_) converted_data = {} for field_name, field_info in field_infos.items(): if field_name in data: value = data[field_name] # Convert the value to the proper type if value is not None and not is_class_and_subclass(field_info.hint, str): # Create a token for the value and convert it token = Token(value=json.dumps(value) if isinstance(value, dict | list) else str(value)) # Always attempt conversion, let errors propagate for consistency converted_value = convert(field_info.hint, [token], converter, name_transform) else: converted_value = value converted_data[field_name] = converted_value # Create the dataclass with converted values return type_(**converted_data) def _create_json_decode_error_message( token: "Token", type_: Any, error: json.JSONDecodeError, ) -> str: """Create a helpful error message for JSON decode errors. Parameters ---------- token : Token The token containing the invalid JSON. type_ : Type The target type we were trying to convert to. error : json.JSONDecodeError The JSON decode error that occurred. Returns ------- str A formatted error message with context and hints. """ value_str = token.value.strip() # Try to provide context around the error error_pos = error.pos if hasattr(error, "pos") else error.colno - 1 if hasattr(error, "colno") else 0 # Create a snippet showing the error location snippet_start = max(0, error_pos - 20) snippet_end = min(len(value_str), error_pos + 20) snippet = value_str[snippet_start:snippet_end] # Add markers if we truncated if snippet_start > 0: snippet = "..." + snippet if snippet_end < len(value_str): snippet = snippet + "..." # Calculate where the error marker should point marker_pos = error_pos - snippet_start if snippet_start > 0: marker_pos += 3 # Account for "..." # Common error patterns with helpful hints hint = "" if re.search(r"\bTrue\b", value_str): hint = "\n Hint: Use lowercase 'true' instead of Python's True" elif re.search(r"\bFalse\b", value_str): hint = "\n Hint: Use lowercase 'false' instead of Python's False" elif re.search(r"\bNone\b", value_str): hint = "\n Hint: Use 'null' instead of Python's None" elif "'" in value_str: hint = "\n Hint: JSON requires double quotes, not single quotes" return f"Invalid JSON for {type_.__name__}:\n {snippet}\n {' ' * marker_pos}^ {error.msg}{hint}" def instantiate_from_dict(type_: type[T], data: dict[str, Any]) -> T: """Instantiate a type with proper handling of parameter kinds. Respects POSITIONAL_ONLY, KEYWORD_ONLY, and POSITIONAL_OR_KEYWORD parameter kinds when constructing the object. This function is necessary because `inspect.signature().bind(**data)` has the same limitation we're solving: it cannot accept positional-only parameters as keyword arguments. For example, `def __init__(self, a, /, b)` requires `a` to be passed positionally, but when we have a dict `{"a": 1, "b": 2}`, we need to transform this into the call `type_(1, b=2)`. Parameters ---------- type_ : type[T] The type to instantiate. data : dict[str, Any] Dictionary mapping field names to values. Returns ------- T Instance of type_ constructed from data. """ field_infos = get_field_infos(type_) if not field_infos: return type_(**data) pos_args = [] kwargs = {} for field_name, value in data.items(): field_info = field_infos.get(field_name) if field_info and field_info.kind == FieldInfo.POSITIONAL_ONLY: pos_args.append((field_name, value)) else: kwargs[field_name] = value # Sort positional args by their order in field_infos field_names_order = list(field_infos.keys()) pos_args.sort(key=lambda x: field_names_order.index(x[0])) return type_(*(v for _, v in pos_args), **kwargs) def _convert_structured_type( type_: type[T], token: Sequence["Token"], field_infos: dict[str, "FieldInfo"], convert: Callable, ) -> T: """Convert tokens to a structured type with proper positional/keyword argument handling. Respects the parameter kind of each field: - POSITIONAL_ONLY: passed as positional argument - KEYWORD_ONLY or POSITIONAL_OR_KEYWORD: passed as keyword argument This correctly handles types with keyword-only fields (e.g., dataclasses with kw_only=True). Parameters ---------- type_ : type[T] The target structured type to instantiate. token : Sequence[Token] The tokens to convert. field_infos : dict[str, FieldInfo] Field information for the structured type. convert : Callable Conversion function for nested types. Returns ------- T Instance of type_ constructed from the tokens. """ i = 0 data = {} hint = type_ for field_name, field_info in field_infos.items(): hint = field_info.hint # Convert the token(s) for this field if is_class_and_subclass(hint, str): # Avoids infinite recursion value = token[i].value i += 1 should_break = False else: tokens_per_element, consume_all = token_count(hint) if tokens_per_element == 1: value = convert(hint, token[i]) i += 1 else: value = convert(hint, token[i : i + tokens_per_element]) i += tokens_per_element should_break = consume_all data[field_name] = value # Handle consume_all or end of tokens if should_break: break if i == len(token): break assert i == len(token) return instantiate_from_dict(type_, data) def _convert( type_, token: Union["Token", Sequence["Token"]], *, converter: Callable[[Any, str], Any] | None, name_transform: Callable[[str], str], ): """Inner recursive conversion function for public ``convert``. Parameters ---------- converter: Callable name_transform: Callable """ from cyclopts.argument import Token from cyclopts.parameter import Parameter converter_needs_token = False if is_annotated(type_): from cyclopts.parameter import Parameter type_, cparam = Parameter.from_annotation(type_) if cparam.converter: converter_needs_token = True def converter_with_token(t_, value): assert cparam.converter # Resolve string converters to methods on the type resolved_converter = cparam.converter if isinstance(resolved_converter, str): resolved_converter = getattr(t_, resolved_converter) # Detect bound methods (classmethods/instance methods) # Bound methods already have their first parameter bound if inspect.ismethod(resolved_converter): # Call with just tokens - cls/self already bound return resolved_converter((value,)) else: # Regular function - pass type and tokens return resolved_converter(t_, (value,)) converter = converter_with_token if cparam.name_transform: name_transform = cparam.name_transform else: cparam = None convert = partial(_convert, converter=converter, name_transform=name_transform) convert_tuple = partial(_convert_tuple, converter=converter, name_transform=name_transform) origin_type = get_origin(type_) # Normalize abstract origin types to concrete types early # (e.g., collections.abc.Set -> set) so we only check ITERABLE_TYPES later if origin_type in _abstract_to_concrete_type_mapping: origin_type = _abstract_to_concrete_type_mapping[origin_type] # Inner types **may** be ``Annotated`` inner_types = get_args(type_) if type_ is dict: out = convert(dict[str, str], token) elif type_ in _implicit_iterable_type_mapping: out = convert(_implicit_iterable_type_mapping[type_], token) elif type_ in _abstract_to_concrete_type_mapping: # Bare abstract type (e.g., collections.abc.Set with no [T]) # Convert to default parameterized concrete type concrete_type = _abstract_to_concrete_type_mapping[type_] default_param = _implicit_iterable_type_mapping.get(concrete_type, concrete_type) out = convert(default_param, token) elif TypeAliasType is not None and isinstance(type_, TypeAliasType): out = convert(type_.__value__, token) elif is_union(origin_type): for t in inner_types: if is_nonetype(t): continue try: out = convert(t, token) break except Exception: pass else: if isinstance(token, Sequence): raise ValueError # noqa: TRY004 raise CoercionError(token=token, target_type=type_) elif origin_type is Literal: # Try coercing the token into each allowed Literal value (left-to-right). last_coercion_error = None for choice in get_args(type_): try: res = convert(type(choice), token) except CoercionError as e: last_coercion_error = e continue if res == choice: out = res break else: if last_coercion_error: last_coercion_error.target_type = type_ raise last_coercion_error else: raise CoercionError(token=token[0] if isinstance(token, Sequence) else token, target_type=type_) elif origin_type is tuple: if isinstance(token, Token): # E.g. Tuple[str] (Annotation: tuple containing a single string) out = convert_tuple(type_, token, converter=converter) else: out = convert_tuple(type_, *token, converter=converter) elif origin_type in ITERABLE_TYPES: # NOT including tuple; handled in ``origin_type is tuple`` body above. # Note: origin_type has already been normalized from abstract to concrete count, _ = token_count(inner_types[0]) if not isinstance(token, Sequence): raise ValueError # Check if tokens are JSON strings inner_type = inner_types[0] if ( count > 1 and any(isinstance(t, Token) and t.value.strip().startswith("{") for t in token) and inner_type is not str ): # Each token is a complete JSON representation of the dataclass gen = token elif count > 1: gen = zip(*[iter(token)] * count, strict=False) else: gen = token out = origin_type(convert(inner_types[0], e) for e in gen) elif is_class_and_subclass(type_, Flag): # TODO: this might never execute since enum.Flag is now handled in ``convert``. out = convert_enum_flag(type_, token if isinstance(token, Sequence) else [token], name_transform) elif is_class_and_subclass(type_, Enum): if isinstance(token, Sequence): raise ValueError if converter is None: out = get_enum_member(type_, token, name_transform) else: out = converter(type_, token.value) else: field_infos = get_field_infos(type_) # Hope that if there is no field_info, that it takes `*args` and would be happy with a single ``str`` input. # This is common for many types, such as libraries that try to mimic pathlib.Path interface. # TODO: This doesn't respect the type-annotation of ``*args``. if is_builtin(type_) or not field_infos: assert isinstance(token, Token) try: if token.implicit_value is not UNSET: out = token.implicit_value elif converter is None: out = _converters.get(type_, type_)(token.value) # pyright: ignore[reportOptionalCall] elif converter_needs_token: out = converter(type_, token) # pyright: ignore[reportArgumentType] else: out = converter(type_, token.value) except CoercionError as e: if e.target_type is None: e.target_type = type_ if e.token is None: e.token = token raise except ValueError: raise CoercionError(token=token, target_type=type_) from None else: # Convert it into a user-supplied class. # First check if we have a single token that's a JSON string if isinstance(token, Token) and token.value.strip().startswith("{") and type_ is not str: try: data = json.loads(token.value) if not isinstance(data, dict): # JSON was valid but didn't produce a dict (e.g., it was an array or scalar) raise TypeError # noqa: TRY301 # Convert dict to dataclass with proper type conversion out = _convert_json(type_, data, field_infos, converter, name_transform) except json.JSONDecodeError as e: # Create helpful error message for invalid JSON msg = _create_json_decode_error_message(token, type_, e) raise CoercionError(msg=msg, token=token, target_type=type_) from e except TypeError: # Fall back to positional argument parsing if not isinstance(token, Sequence): token = [token] out = _convert_structured_type(type_, token, field_infos, convert) else: # Standard positional argument parsing if not isinstance(token, Sequence): token = [token] out = _convert_structured_type(type_, token, field_infos, convert) if cparam: # An inner type may have an independent Parameter annotation; # e.g.: # Uint8 = Annotated[int, ...] # rgb: tuple[Uint8, Uint8, Uint8] try: for validator in cparam.validator: # pyright: ignore validator(type_, out) except (AssertionError, ValueError, TypeError) as e: raise ValidationError(exception_message=e.args[0] if e.args else "", value=out) from e return out def convert( type_: Any, tokens: Sequence[str] | Sequence["Token"] | NestedCliArgs, converter: Callable[[type, str], Any] | None = None, name_transform: Callable[[str], str] | None = None, ): """Coerce variables into a specified type. Internally used to coercing string CLI tokens into python builtin types. Externally, may be useful in a custom converter. See Cyclopt's automatic coercion rules :doc:`/rules`. If ``type_`` **is not** iterable, then each element of ``tokens`` will be converted independently. If there is more than one element, then the return type will be a ``Tuple[type_, ...]``. If there is a single element, then the return type will be ``type_``. If ``type_`` **is** iterable, then all elements of ``tokens`` will be collated. Parameters ---------- type_: Type A type hint/annotation to coerce ``*args`` into. tokens: Union[Sequence[str], NestedCliArgs] String tokens to coerce. Generally, either a list of strings, or a dictionary of list of strings (recursive). Each leaf in the dictionary tree should be a list of strings. converter: Optional[Callable[[Type, str], Any]] An optional function to convert tokens to the inner-most types. The converter should have signature: .. code-block:: python def converter(type_: type, value: str) -> Any: "Perform conversion of string token." This allows to use the :func:`convert` function to handle the the difficult task of traversing lists/tuples/unions/etc, while leaving the final conversion logic to the caller. name_transform: Optional[Callable[[str], str]] Currently only used for ``Enum`` type hints. A function that transforms enum names and CLI values into a normalized format. The function should have signature: .. code-block:: python def name_transform(s: str) -> str: "Perform name transform." where the returned value is the name to be used on the CLI. If ``None``, defaults to ``cyclopts.default_name_transform``. Returns ------- Any Coerced version of input ``*args``. """ from cyclopts.argument import Token if not tokens: raise ValueError if not isinstance(tokens, dict) and isinstance(tokens[0], str): tokens = tuple(Token(value=str(x)) for x in tokens) if name_transform is None: name_transform = default_name_transform convert_priv = partial(_convert, converter=converter, name_transform=name_transform) convert_tuple = partial(_convert_tuple, converter=converter, name_transform=name_transform) type_ = resolve(type_) if type_ is Any: type_ = str type_ = _implicit_iterable_type_mapping.get(type_, type_) # Handle bare abstract types (e.g., collections.abc.Set without [T]) # Convert to their default parameterized concrete versions if type_ in _abstract_to_concrete_type_mapping: concrete_type = _abstract_to_concrete_type_mapping[type_] type_ = _implicit_iterable_type_mapping.get(concrete_type, concrete_type) origin_type = get_origin(type_) # Normalize abstract origin types to concrete types early if origin_type in _abstract_to_concrete_type_mapping: origin_type = _abstract_to_concrete_type_mapping[origin_type] maybe_origin_type = origin_type or type_ if origin_type is tuple: return convert_tuple(type_, *tokens) # pyright: ignore elif maybe_origin_type in ITERABLE_TYPES: return convert_priv(type_, tokens) # pyright: ignore elif maybe_origin_type is dict: if not isinstance(tokens, dict): raise ValueError # Programming error try: value_type = get_args(type_)[1] except IndexError: value_type = str dict_converted = { k: convert(value_type, v, converter=converter, name_transform=name_transform) for k, v in tokens.items() } return _converters.get(maybe_origin_type, maybe_origin_type)(**dict_converted) elif isinstance(tokens, dict): raise ValueError(f"Dictionary of tokens provided for unknown {type_!r}.") # Programming error elif is_enum_flag(maybe_origin_type): # Unlike other types that can accept multiple tokens, the result is not a sequence, it's a single # enum.Flag object. return convert_enum_flag(maybe_origin_type, tokens, name_transform) else: tokens_per_element, consume_all = token_count(type_) if consume_all: return convert_priv(type_, tokens) # pyright: ignore elif len(tokens) == 1: return convert_priv(type_, tokens[0]) # pyright: ignore elif tokens_per_element == 1: return [convert_priv(type_, item) for item in tokens] # pyright: ignore elif len(tokens) == tokens_per_element: return convert_priv(type_, tokens) # pyright: ignore else: raise NotImplementedError("Unreachable?") def token_count(type_: Any, skip_converter_params: bool = False) -> tuple[int, bool]: """The number of tokens after a keyword the parameter should consume. Parameters ---------- type_: Type A type hint/annotation to infer token_count from if not explicitly specified. skip_converter_params: bool If True, don't extract converter parameters from __cyclopts__. Used to prevent infinite recursion when determining consume_all behavior. Returns ------- int Number of tokens to consume. bool If this is ``True`` and positional, consume all remaining tokens. The returned number of tokens constitutes a single element of the iterable-to-be-parsed. """ # Discriminated unions (e.g. Annotated[Cat | Dog, pydantic.Field(discriminator="type")]) # consume a single JSON string token regardless of member field counts. # Check before get_parameters strips the Annotated metadata. if get_annotated_discriminator(resolve_optional(type_)) is not None: return 1, False # Check for explicit n_tokens in Parameter annotation before resolving # This handles nested cases like tuple[Annotated[str, Parameter(n_tokens=2)], int] from cyclopts.parameter import get_parameters resolved_type, parameters = get_parameters(type_, skip_converter_params=skip_converter_params) for param in parameters: if param.n_tokens is not None: if param.n_tokens == -1: return 1, True else: # Recursively determine consume_all from the type's natural structure. # Only recurse if the type has changed (e.g., Annotated wrapper was removed). # If resolved_type is the same as type_, recursing would cause infinite loop. if resolved_type is not type_: # Skip converter params to avoid infinite recursion when converter is decorated # with @Parameter(n_tokens=...) and attached to a class via @Parameter(converter=...). _, consume_all_from_type = token_count(resolved_type, skip_converter_params=True) else: # Type didn't change (e.g., class decorated with @Parameter(n_tokens=...)) # Can't determine natural consume_all by recursing on same type consume_all_from_type = False return param.n_tokens, consume_all_from_type type_ = resolved_type origin_type = get_origin(type_) # Normalize abstract origin types to concrete types early if origin_type in _abstract_to_concrete_type_mapping: origin_type = _abstract_to_concrete_type_mapping[origin_type] # Handle bare abstract types like bare concrete types if type_ in _abstract_to_concrete_type_mapping: concrete_type = _abstract_to_concrete_type_mapping[type_] type_ = _implicit_iterable_type_mapping.get(concrete_type, concrete_type) origin_type = get_origin(type_) if (origin_type or type_) is tuple: args = get_args(type_) if args: return sum(token_count(x)[0] for x in args if x is not ...), ... in args else: return 1, True elif (origin_type or type_) is bool: return 0, False elif type_ in ITERABLE_TYPES or (origin_type in ITERABLE_TYPES and len(get_args(type_)) == 0): return 1, True elif is_enum_flag(type_): return 1, True elif origin_type in ITERABLE_TYPES and len(get_args(type_)): return token_count(get_args(type_)[0])[0], True elif TypeAliasType is not None and isinstance(type_, TypeAliasType): return token_count(type_.__value__) elif is_union(type_): sub_args = get_args(type_) token_count_target = token_count(sub_args[0]) for sub_type_ in sub_args[1:]: this = token_count(sub_type_) if this != token_count_target: raise ValueError( f"Cannot Union types that consume different numbers of tokens: {sub_args[0]} {sub_type_}" ) return token_count_target elif is_builtin(type_): # Many builtins actually take in VAR_POSITIONAL when we really just want 1 argument. return 1, False else: # This is usually/always a custom user-defined class. field_infos = get_field_infos(type_) count, consume_all = 0, False for value in field_infos.values(): if value.kind is value.VAR_POSITIONAL: consume_all = True elif not value.required: continue elem_count, elem_consume_all = token_count(value.hint) count += elem_count consume_all |= elem_consume_all # classes like ``enum.Enum`` can slip through here with a 0 count. if not count: return 1, False return count, consume_all BrianPugh-cyclopts-921b1fa/cyclopts/_edit.py000066400000000000000000000070541517576204000211510ustar00rootroot00000000000000import os import tempfile import time from collections.abc import Sequence from pathlib import Path class EditorError(Exception): """Root editor-related error. Root exception raised by all exceptions in :func:`.edit`. """ class EditorDidNotSaveError(EditorError): """User did not save upon exiting :func:`.edit`.""" class EditorDidNotChangeError(EditorError): """User did not edit file contents in :func:`.edit`.""" class EditorNotFoundError(EditorError): """Could not find a valid text editor for :func`.edit`.""" def edit( initial_text: str = "", *, fallback_editors: Sequence[str] = ("nano", "vim", "notepad", "gedit"), editor_args: Sequence[str] = (), path: str | Path = "", encoding: str = "utf-8", save: bool = True, required: bool = True, ) -> str: """Get text input from a user by launching their default text editor. Parameters ---------- initial_text: str Initial text to populate the text file with. fallback_editors: Sequence[str] If the text editor cannot be determined from the environment variable ``EDITOR``, attempt to use these text editors in the order provided. editor_args: Sequence[str] Additional CLI arguments that are passed along to the editor-launch command. path: Union[str, Path] If specified, the path to the file that should be opened. Text editors typically display this, so a custom path may result in a better user-interface. Defaults to a temporary text file. encoding: str File encoding to use. save: bool **Require** the user to save before exiting the editor. Otherwise raises :exc:`EditorDidNotSaveError`. required: bool **Require** for the saved text to be different from ``initial_text``. Otherwise raises :exc:`EditorDidNotChangeError`. Raises ------ EditorError Base editor error exception. Explicitly raised if editor subcommand returned a non-zero exit code. EditorNotFoundError A suitable text editor could not be found. EditorDidNotSaveError The user exited the text-editor without saving and ``save=True``. EditorDidNotChangeError The user did not change the file contents and ``required=True``. Returns ------- str The resulting text that was saved by the text editor. """ import shutil import subprocess for editor in (os.environ.get("EDITOR"), *fallback_editors): if editor and shutil.which(editor): break else: raise EditorNotFoundError if path: path = Path(path) path.parent.mkdir(exist_ok=True, parents=True) else: path = Path(tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False).name) path.write_text(initial_text, encoding=encoding) past_time = time.time() - 5 # arbitrarily set time to 5 seconds ago; some systems only have 1 second precision. os.utime(path, (past_time, past_time)) # Set access and modification time start_stat = path.stat() try: subprocess.check_call([editor, path, *editor_args]) end_stat = path.stat() if save and end_stat.st_mtime <= start_stat.st_mtime: raise EditorDidNotSaveError edited_text = path.read_text(encoding=encoding) except subprocess.CalledProcessError as e: raise EditorError(f"{editor} exited with status {e.returncode}") from e finally: path.unlink(missing_ok=True) if required and edited_text == initial_text: raise EditorDidNotChangeError return edited_text BrianPugh-cyclopts-921b1fa/cyclopts/_env_var.py000066400000000000000000000027401517576204000216610ustar00rootroot00000000000000import os from pathlib import Path from typing import Any, get_args from cyclopts._convert import resolve, token_count def _is_path(type_) -> bool: if type_ is Path: return True for inner_type in get_args(type_): inner_type = resolve(inner_type) if _is_path(inner_type): return True return False def env_var_split( type_: Any, val: str, *, delimiter: str | None = None, ) -> list[str]: """Type-dependent environment variable value splitting. Converts a single string into a list of strings. Splits when: * The ``type_`` is some variant of ``Iterable[pathlib.Path]`` objects. If Windows, split on ``;``, otherwise split on ``:``. * Otherwise, if the ``type_`` is an ``Iterable``, split on whitespace. Leading/trailing whitespace of each output element will be stripped. This function is the default value for :attr:`cyclopts.App.env_var_split`. Parameters ---------- type_: type Type hint that we will eventually coerce into. val: str String to split. delimiter: str | None Delimiter to split ``val`` on. If None, defaults to whitespace. Returns ------- list[str] List of individual string tokens. """ type_ = resolve(type_) count, consume_all = token_count(type_) if count > 1 or consume_all: return val.split(os.pathsep) if _is_path(type_) else val.split(delimiter) else: return [val] BrianPugh-cyclopts-921b1fa/cyclopts/_markup.py000066400000000000000000000064521517576204000215240ustar00rootroot00000000000000"""Markup and format conversion utilities. Pure utility layer for text processing across help and docs systems. """ import io from typing import TYPE_CHECKING, Any, Optional if TYPE_CHECKING: from rich.console import Console def extract_text(obj: Any, console: Optional["Console"] = None, preserve_markup: bool = False) -> str: """Extract text from Rich renderables or any object. Parameters ---------- obj : Any Object to convert to text. console : Console | None Console for rendering Rich objects. preserve_markup : bool If True, preserve original markdown/RST markup when available. When False, always render to plain text. Returns ------- str Text representation (plain or with markup preserved). """ if obj is None: return "" if hasattr(obj, "primary_renderable"): primary = getattr(obj, "primary_renderable", None) if primary is not None: if preserve_markup and hasattr(primary, "markup"): return primary.markup.rstrip() return extract_text(primary, console, preserve_markup=preserve_markup) if hasattr(obj, "plain"): return obj.plain.rstrip() if preserve_markup and hasattr(obj, "markup"): return obj.markup.rstrip() if hasattr(obj, "__rich_console__"): from rich.console import Console plain_console = Console( file=io.StringIO(), width=console.width if console else 120, force_terminal=False, no_color=True, highlight=False, markup=False, emoji=False, ) with plain_console.capture() as capture: plain_console.print(obj, end="") return capture.get().rstrip() return str(obj).rstrip() def escape_rst(text: str | None) -> str: """Escape special reStructuredText characters in text. Parameters ---------- text : str | None Text to escape. Can be None. Returns ------- str Escaped text safe for RST. """ if not text: return "" return text.replace("\\", "\\\\") def escape_markdown(text: str | None) -> str | None: """Escape special markdown characters in text. If the text appears to already contain markdown formatting (bold, italic, code, links, or headings), it is returned unchanged. Otherwise, pipe characters are escaped for table compatibility. Parameters ---------- text : str | None Text to escape. Can be None. Returns ------- str | None Escaped text safe for markdown, or None if input was None. """ if not text: return text if any(pattern in text for pattern in ["**", "``", "`", "](", "#"]): return text text = text.replace("|", "\\|") return text def escape_html(text: str | None) -> str: """Escape special HTML characters in text. Parameters ---------- text : str | None Text to escape. Can be None. Returns ------- str Escaped text safe for HTML. """ if not text: return "" text = text.replace("&", "&") text = text.replace("<", "<") text = text.replace(">", ">") text = text.replace('"', """) text = text.replace("'", "'") return text BrianPugh-cyclopts-921b1fa/cyclopts/_path_type.py000066400000000000000000000130271517576204000222160ustar00rootroot00000000000000""":class:`StdioPath` - A Path subclass that treats ``-`` as stdin/stdout. Requires Python 3.12+ for proper Path subclassing support. """ import io import sys from pathlib import Path from typing import IO, TYPE_CHECKING from cyclopts.parameter import Parameter if TYPE_CHECKING: from collections.abc import Buffer class _NonClosingIOWrapper: """Wrapper around an IO stream that doesn't close on context exit. This is used to wrap stdin/stdout so they can be used as context managers without being closed when the context exits. """ def __init__(self, stream: IO, detach_on_exit: bool = False): self._stream = stream self._detach_on_exit = detach_on_exit def __enter__(self): return self._stream def __exit__(self, *args): # Flush any buffered data (important for TextIOWrapper) self._stream.flush() if self._detach_on_exit: self._stream.detach() # Detach TextIOWrapper without closing underlying buffer def __getattr__(self, name): return getattr(self._stream, name) @Parameter(allow_leading_hyphen=True) class StdioPath(Path): """A :class:`~pathlib.Path` subclass that treats ``-`` as stdin/stdout.""" STDIO_STRING: str = "-" """The string that represents stdin/stdout. Override in subclasses for custom behavior.""" @property def is_stdio(self) -> bool: """Return True if this represents stdin/stdout. Override this property in subclasses for custom matching logic (e.g., matching multiple strings or using pattern matching). """ return str(self) == self.STDIO_STRING def __repr__(self): return f"{type(self).__name__}({str(self)!r})" def exists(self, *, follow_symlinks: bool = True) -> bool: """Return True if path exists. Always True for stdio.""" return True if self.is_stdio else super().exists(follow_symlinks=follow_symlinks) def open( # pyright: ignore[reportIncompatibleMethodOverride] self, mode: str = "r", buffering: int = -1, encoding: str | None = None, errors: str | None = None, newline: str | None = None, ): """Open the file or return stdin/stdout. For stdio paths, returns a wrapper around the appropriate stream (stdin for reading, stdout for writing) that doesn't close on context exit. For regular paths, behaves like the standard Path.open(). """ if self.is_stdio: is_binary = "b" in mode is_write = "w" in mode or "a" in mode # Always get the buffer stream buffer_stream = sys.stdout.buffer if is_write else sys.stdin.buffer if is_binary: stream = _NonClosingIOWrapper(buffer_stream) else: # For text mode, wrap the binary stream with TextIOWrapper text_stream = io.TextIOWrapper( buffer_stream, encoding=encoding or "utf-8", errors=errors or "strict", newline=newline, ) stream = _NonClosingIOWrapper(text_stream, detach_on_exit=True) return stream return super().open(mode, buffering, encoding, errors, newline) def read_text(self, encoding: str | None = None, errors: str | None = None, newline: str | None = None) -> str: """Read entire contents as text.""" if self.is_stdio: wrapper = io.TextIOWrapper( sys.stdin.buffer, encoding=encoding or "utf-8", errors=errors or "strict", newline=newline, ) try: return wrapper.read() finally: wrapper.detach() # Detach without closing stdin.buffer # newline parameter added in Python 3.13 if sys.version_info >= (3, 13): return super().read_text(encoding=encoding, errors=errors, newline=newline) else: return super().read_text(encoding=encoding, errors=errors) def read_bytes(self) -> bytes: """Read entire contents as bytes.""" if self.is_stdio: return sys.stdin.buffer.read() return super().read_bytes() def write_text( self, data: str, encoding: str | None = None, errors: str | None = None, newline: str | None = None, ) -> int: """Write text data.""" if self.is_stdio: wrapper = io.TextIOWrapper( sys.stdout.buffer, encoding=encoding or "utf-8", errors=errors or "strict", newline=newline, ) try: wrapper.write(data) wrapper.flush() # TextIOWrapper doesn't return bytes written, so calculate from encoded data # Apply same newline translation that TextIOWrapper does if newline is None or newline == "": encoded_data = data else: encoded_data = data.replace("\n", newline) return len(encoded_data.encode(encoding or "utf-8", errors or "strict")) finally: wrapper.detach() # Detach without closing stdout.buffer return super().write_text(data, encoding=encoding, errors=errors, newline=newline) def write_bytes(self, data: "Buffer") -> int: """Write binary data.""" if self.is_stdio: return sys.stdout.buffer.write(data) return super().write_bytes(data) BrianPugh-cyclopts-921b1fa/cyclopts/_result_action.py000066400000000000000000000107671517576204000231040ustar00rootroot00000000000000import sys from collections.abc import Callable, Iterable from typing import Any, Literal, cast from cyclopts.utils import is_iterable ResultActionSingle = ( Literal[ "return_value", "call_if_callable", "print_non_int_return_int_as_exit_code", "print_str_return_int_as_exit_code", "print_str_return_zero", "print_non_none_return_int_as_exit_code", "print_non_none_return_zero", "return_int_as_exit_code_else_zero", "print_non_int_sys_exit", "sys_exit", "return_none", "return_zero", "print_return_zero", "sys_exit_zero", "print_sys_exit_zero", ] | Callable[[Any], Any] ) ResultAction = ResultActionSingle | Iterable[ResultActionSingle] def handle_result_action( result: Any, action: ResultAction, print_fn: Callable[[Any], None], ) -> Any: """Handle command result based on result_action. When ``action`` is a sequence, actions are applied left-to-right in a pipeline, where each action receives the result of the previous action. For example, with ``result_action=[uppercase, add_greeting]``: result → uppercase(result) → add_greeting(uppercase(result)) Parameters ---------- result : Any The command's return value. action : ResultAction The action (or sequence of actions) to take with the result. If a sequence, actions are chained left-to-right. print_fn : Callable[[Any], None] Function to call to print output (e.g., console.print). Returns ------- Any Processed result based on action (may call sys.exit() and not return). """ if is_iterable(action): for single_action in cast(Iterable[ResultActionSingle], action): result = handle_result_action(result, single_action, print_fn) return result if callable(action): return action(result) match action: case "print_non_int_sys_exit": if isinstance(result, bool): sys.exit(0 if result else 1) elif isinstance(result, int): sys.exit(result) elif result is not None: print_fn(result) sys.exit(0) else: sys.exit(0) case "return_value": return result case "call_if_callable": if callable(result): return result() return result case "sys_exit": if isinstance(result, bool): sys.exit(0 if result else 1) elif isinstance(result, int): sys.exit(result) else: sys.exit(0) case "print_non_int_return_int_as_exit_code": if isinstance(result, bool): return 0 if result else 1 elif isinstance(result, int): return result elif result is not None: print_fn(result) return 0 else: return 0 case "print_str_return_int_as_exit_code": if isinstance(result, str): print_fn(result) return 0 elif isinstance(result, bool): return 0 if result else 1 elif isinstance(result, int): return result else: return 0 case "print_str_return_zero": if isinstance(result, str): print_fn(result) return 0 case "print_non_none_return_int_as_exit_code": if result is not None: print_fn(result) if isinstance(result, bool): return 0 if result else 1 elif isinstance(result, int): return result return 0 case "print_non_none_return_zero": if result is not None: print_fn(result) return 0 case "return_int_as_exit_code_else_zero": if isinstance(result, bool): return 0 if result else 1 elif isinstance(result, int): return result else: return 0 case "return_none": return None case "return_zero": return 0 case "print_return_zero": print_fn(result) return 0 case "sys_exit_zero": sys.exit(0) case "print_sys_exit_zero": print_fn(result) sys.exit(0) case _: raise ValueError BrianPugh-cyclopts-921b1fa/cyclopts/_run.py000066400000000000000000000067371517576204000210370ustar00rootroot00000000000000import inspect import sys from collections.abc import Callable, Coroutine from functools import partial from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload from cyclopts._result_action import ResultAction if sys.version_info < (3, 11): # pragma: no cover from typing_extensions import assert_never else: # pragma: no cover from typing import assert_never if TYPE_CHECKING: from cyclopts.core import App V = TypeVar("V") # App will be lazily imported to avoid circular imports App = None # type: ignore[assignment] def _run_maybe_async_command( command: Callable, bound: inspect.BoundArguments | None = None, backend: Literal["asyncio", "trio"] = "asyncio", ): """Run a command, handling both sync and async cases. If the command is async, an async context will be created to run it. Parameters ---------- command : Callable The command to execute. bound : inspect.BoundArguments | None Bound arguments for the command. If None, command is called with no arguments. backend : Literal["asyncio", "trio"] The async backend to use if the command is async. Returns ------- return_value: Any The value the command function returns. """ if not inspect.iscoroutinefunction(command): if bound is None: return command() else: return command(*bound.args, **bound.kwargs) if backend == "asyncio": import asyncio if bound is None: return asyncio.run(command()) else: return asyncio.run(command(*bound.args, **bound.kwargs)) elif backend == "trio": import trio if bound is None: return trio.run(command) else: return trio.run(partial(command, *bound.args, **bound.kwargs)) else: # pragma: no cover assert_never(backend) @overload def run(callable: Callable[..., Coroutine[None, None, V]], /, *, result_action: Literal["return_value"]) -> V: ... @overload def run(callable: Callable[..., V], /, *, result_action: Literal["return_value"]) -> V: ... @overload def run( callable: Callable[..., Coroutine[None, None, Any]], /, *, result_action: ResultAction | None = None ) -> Any: ... @overload def run(callable: Callable[..., Any], /, *, result_action: ResultAction | None = None) -> Any: ... def run(callable, /, *, result_action: ResultAction | None = None): """Run the given callable as a CLI command. The callable may also be a coroutine function. This function is syntax sugar for very simple use cases, and is roughly equivalent to: .. code-block:: python from cyclopts import App app = App() app.default(callable) app() Parameters ---------- callable The function to execute as a CLI command. result_action How to handle the command's return value. If not specified, uses the default ``"print_non_int_sys_exit"`` which calls :func:`sys.exit` with the appropriate code. Can be set to ``"return_value"`` to return the result directly for testing/embedding. Example usage: .. code-block:: python import cyclopts def main(name: str, age: int): print(f"Hello {name}, you are {age} years old.") cyclopts.run(main) """ global App if App is None: from cyclopts.core import App as _App App = _App app = App(result_action=result_action) app.default(callable) return app() BrianPugh-cyclopts-921b1fa/cyclopts/annotations.py000066400000000000000000000155001517576204000224150ustar00rootroot00000000000000import inspect import sys import typing from collections.abc import Iterable, Sequence from enum import Flag from types import UnionType from typing import Annotated, Any, Union, get_args, get_origin import attrs from cyclopts.utils import is_class_and_subclass if sys.version_info < (3, 11): # pragma: no cover from typing_extensions import NotRequired, Required else: # pragma: no cover from typing import NotRequired, Required if sys.version_info >= (3, 12): # pragma: no cover from typing import TypeAliasType else: # pragma: no cover TypeAliasType = None # from types import NoneType is available >=3.10 NoneType = type(None) AnnotatedType = type(Annotated[int, 0]) ITERABLE_TYPES = { Iterable, typing.Sequence, Sequence, frozenset, list, set, tuple, } def is_nonetype(hint): return hint is NoneType def is_union(type_: type | None) -> bool: """Checks if a type is a union.""" # Direct checks are faster than checking if the type is in a set that contains the union-types. if type_ is Union or type_ is UnionType: return True # The ``get_origin`` call is relatively expensive, so we'll check common types # that are passed in here to see if we can avoid calling ``get_origin``. if type_ is str or type_ is int or type_ is float or type_ is bool or is_annotated(type_): return False origin = get_origin(type_) return origin is Union or origin is UnionType def is_pydantic(hint) -> bool: return hasattr(hint, "__pydantic_core_schema__") def is_pydantic_secret(hint) -> bool: """Check if a type is a Pydantic secret type (SecretStr, SecretBytes, Secret, etc.).""" return ( hasattr(hint, "__module__") and hint.__module__ == "pydantic.types" and hasattr(hint, "get_secret_value") and callable(getattr(hint, "get_secret_value", None)) ) def is_dataclass(hint) -> bool: return hasattr(hint, "__dataclass_fields__") def is_namedtuple(hint) -> bool: return is_class_and_subclass(hint, tuple) and hasattr(hint, "_fields") def is_attrs(hint) -> bool: return attrs.has(hint) def is_enum_flag(hint) -> bool: """Check if a type hint is an enum.Flag subclass.""" return is_class_and_subclass(hint, Flag) def is_annotated(hint) -> bool: return type(hint) is AnnotatedType def is_iterable_type(hint) -> bool: """Check if a type hint is a collection/iterable type (list, set, tuple, etc.). Handles Annotated, Optional, TypeAlias, and NewType wrappers. """ hint = resolve(hint) origin = get_origin(hint) return is_class_and_subclass(origin, tuple(ITERABLE_TYPES)) def contains_hint(hint, target_type) -> bool: """Indicates if ``target_type`` is in a possibly annotated/unioned ``hint``. E.g. ``contains_hint(Union[int, str], str) == True`` """ hint = resolve(hint) if is_union(hint): return any(contains_hint(x, target_type) for x in get_args(hint)) else: return is_class_and_subclass(hint, target_type) def is_typeddict(hint) -> bool: """Determine if a type annotation is a TypedDict. This is surprisingly hard! Modified from Beartype's implementation: https://github.com/beartype/beartype/blob/main/beartype/_util/hint/pep/proposal/utilpep589.py """ hint = resolve(hint) if is_union(hint): return any(is_typeddict(x) for x in get_args(hint)) if not is_class_and_subclass(hint, dict): return False return ( hasattr(hint, "__annotations__") and hasattr(hint, "__total__") and hasattr(hint, "__required_keys__") and hasattr(hint, "__optional_keys__") ) def resolve( type_: Any, ) -> type: """Perform all simplifying resolutions.""" if type_ is inspect.Parameter.empty: return str type_prev = None while type_ != type_prev: type_prev = type_ type_ = resolve_type_alias(type_) type_ = resolve_annotated(type_) type_ = resolve_optional(type_) type_ = resolve_required(type_) type_ = resolve_new_type(type_) return type_ def resolve_optional(type_: Any) -> Any: """Only resolves Union's of None + one other type (i.e. Optional).""" type_ = resolve_type_alias(type_) # Python will automatically flatten out nested unions when possible. # So we don't need to loop over resolution. if not is_union(type_): return type_ non_none_types = [t for t in get_args(type_) if t is not NoneType] if not non_none_types: # pragma: no cover # This should never happen; python simplifies: # ``Union[None, None] -> NoneType`` raise ValueError("Union type cannot be all NoneType") elif len(non_none_types) == 1: type_ = non_none_types[0] elif len(non_none_types) > 1: return Union[tuple(resolve_optional(x) for x in non_none_types)] # pyright: ignore # noqa: UP007 else: raise NotImplementedError return type_ def resolve_annotated(type_: Any) -> type: type_ = resolve_type_alias(type_) if is_annotated(type_): type_ = get_args(type_)[0] return type_ def get_annotated_discriminator(annotation) -> Any: """Return the ``discriminator`` metadata from an ``Annotated[...]`` hint, else ``None``. Only inspects ``Annotated`` hints — for other parameterized types (``list[X]``, ``dict[K, V]``, etc.) this returns ``None`` so that an incidental ``.discriminator`` attribute on a type parameter cannot spuriously match. """ if not is_annotated(annotation): return None for meta in get_args(annotation)[1:]: try: return meta.discriminator except AttributeError: pass return None def resolve_required(type_: Any) -> type: if get_origin(type_) in (Required, NotRequired): type_ = get_args(type_)[0] return type_ def resolve_new_type(type_: Any) -> type: try: return resolve_new_type(type_.__supertype__) except AttributeError: return type_ def resolve_type_alias(type_: Any) -> Any: """Resolve TypeAliasType (Python 3.12+ 'type' statement) to its underlying type.""" if TypeAliasType is not None and isinstance(type_, TypeAliasType): return type_.__value__ return type_ def get_hint_name(hint) -> str: if isinstance(hint, str): return hint if is_nonetype(hint): return "None" if hint is Any: return "Any" if is_union(hint): return "|".join(get_hint_name(arg) for arg in get_args(hint)) if origin := get_origin(hint): out = get_hint_name(origin) if args := get_args(hint): out += "[" + ", ".join(get_hint_name(arg) for arg in args) + "]" return out if hasattr(hint, "__name__"): return hint.__name__ if getattr(hint, "_name", None) is not None: return hint._name return str(hint) BrianPugh-cyclopts-921b1fa/cyclopts/app_stack.py000066400000000000000000000141331517576204000220260ustar00rootroot00000000000000from collections.abc import Sequence from contextlib import contextmanager from itertools import chain from typing import TYPE_CHECKING, Any, TypeVar, cast, overload from cyclopts.group_extractors import inverse_groups_from_app from cyclopts.parameter import Parameter if TYPE_CHECKING: from cyclopts.core import App V = TypeVar("V") class AppStack: def __init__(self, app): # the ``stack`` is guaranteed to have the self-referencing app at the top of the stack. self.stack: list[list[App]] = [[app]] # Stack of overrides passed to parse_args/call that should be propagated self.overrides_stack: list[dict[str, Any]] = [{}] @contextmanager def __call__(self, apps: Sequence["App"] | Sequence[str], overrides: dict[str, Any] | None = None): # set `overrides` default-values with current overrides so that they properly propagate down the call-stack. overrides = self.overrides | (overrides or {}) self.overrides_stack.append(overrides or {}) if not apps: try: yield finally: self.overrides_stack.pop() return # Convert strings to Apps if needed if isinstance(apps[0], str): str_apps = cast(Sequence[str], apps) _, apps_tuple, _ = self.stack[0][0].parse_commands(str_apps, include_parent_meta=True) resolved_apps: list[App] = list(apps_tuple) else: resolved_apps = cast(list["App"], list(apps)) del apps if not resolved_apps: try: yield finally: self.overrides_stack.pop() return so_far = [] app_ids = {id(app) for app in resolved_apps} for app in resolved_apps: if app._meta_parent is None: # Do not include the prior meta-app. while so_far and so_far[-1]._meta_parent is not None: so_far.pop() so_far.append(app) app.app_stack.stack.append(so_far.copy()) # Also push the overrides onto this app's stack app.app_stack.overrides_stack.append(overrides or {}) # Also traverse the app's meta app meta_app = app while (meta_app := meta_app._meta) is not None: if id(meta_app) in app_ids: # It will be handled conventionally continue meta_subapps = so_far.copy() meta_subapps.append(meta_app) meta_app.app_stack.stack.append(meta_subapps) # Also push the overrides onto the meta app's stack meta_app.app_stack.overrides_stack.append(overrides or {}) try: yield finally: for app in resolved_apps: app.app_stack.stack.pop() app.app_stack.overrides_stack.pop() # Also pop from meta apps meta_app = app while (meta_app := meta_app._meta) is not None: if id(meta_app) in app_ids: continue meta_app.app_stack.stack.pop() meta_app.app_stack.overrides_stack.pop() # Pop overrides from stack self.overrides_stack.pop() @property def overrides(self) -> dict: out = {} for overrides_frame in reversed(self.overrides_stack): for key, value in overrides_frame.items(): if value is not None: out.setdefault(key, value) return out @property def default_parameter(self) -> Parameter: """default_parameter has special resolution since it needs to include the command groups in the derivation.""" cparams = [] for child_app in chain.from_iterable(self.stack): if child_app._meta_parent: continue cparams.extend([group.default_parameter for group in child_app.app_stack.command_groups]) cparams.append(child_app.default_parameter) return Parameter.combine(*cparams) @property def current_frame(self) -> list["App"]: if not self.stack: raise ValueError return self.stack[-1] @overload def resolve(self, attribute: str) -> Any: ... @overload def resolve(self, attribute: str, override: V) -> V: ... @overload def resolve(self, attribute: str, override: V | None, fallback: V) -> V: ... @overload def resolve(self, attribute: str, override: V | None = None, *, fallback: V) -> V: ... def resolve(self, attribute: str, override: V | None = None, fallback: V | None = None) -> V | None: """Resolve an attribute from the App hierarchy.""" if override is not None: return override # Check if we have a stored override from parent invocations (most recent first) for overrides_frame in reversed(self.overrides_stack): if attribute in overrides_frame: value = overrides_frame[attribute] if value is not None: return value # `reversed` so that "closer" apps have higher priority. for app in reversed(list(chain.from_iterable(self.stack))): result = getattr(app, attribute) if result is not None: return result # Check parenting meta app(s) meta_app = app while (meta_app := meta_app._meta_parent) is not None: result = getattr(meta_app, attribute) if result is not None: return result return fallback @property def command_groups(self) -> list: command_app = self.current_frame[-1] try: current_app: App | None = self.current_frame[-2] except IndexError: current_app = None while current_app is not None: try: return next(x for x in inverse_groups_from_app(current_app) if x[0] is command_app)[1] except StopIteration: current_app = current_app._meta_parent return [] BrianPugh-cyclopts-921b1fa/cyclopts/argument/000077500000000000000000000000001517576204000213275ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/argument/__init__.py000066400000000000000000000010031517576204000234320ustar00rootroot00000000000000"""Argument and ArgumentCollection classes for CLI parsing.""" from cyclopts.token import Token from ._argument import Argument from ._collection import ( ArgumentCollection, _resolve_groups_from_callable, update_argument_collection, ) from .utils import get_choices_from_hint, resolve_parameter_name __all__ = [ "Argument", "ArgumentCollection", "Token", "_resolve_groups_from_callable", "get_choices_from_hint", "resolve_parameter_name", "update_argument_collection", ] BrianPugh-cyclopts-921b1fa/cyclopts/argument/_argument.py000066400000000000000000001331631517576204000236710ustar00rootroot00000000000000"""Argument class and related functionality.""" import inspect import json import operator import re import sys from collections.abc import Callable, Sequence from contextlib import suppress from functools import partial, reduce from typing import TYPE_CHECKING, Any, get_args, get_origin from attrs import define, field from cyclopts._convert import ( _validate_json_extra_keys, convert, instantiate_from_dict, token_count, ) from cyclopts.annotations import ( ITERABLE_TYPES, contains_hint, get_annotated_discriminator, is_attrs, is_dataclass, is_enum_flag, is_namedtuple, is_nonetype, is_pydantic, is_typeddict, is_union, resolve, resolve_annotated, resolve_optional, ) from cyclopts.exceptions import ( CoercionError, CycloptsError, MissingArgumentError, MixedArgumentError, RepeatArgumentError, ValidationError, ) from cyclopts.field_info import ( FieldInfo, _attrs_field_infos, _generic_class_field_infos, _pydantic_field_infos, _typed_dict_field_infos, get_field_infos, signature_parameters, ) from cyclopts.parameter import ITERATIVE_BOOL_IMPLICIT_VALUE, Parameter from cyclopts.token import Token from cyclopts.utils import UNSET, grouper, is_builtin, parse_version from .utils import ( enum_flag_from_dict, get_choices_from_hint, missing_keys_factory, startswith, ) if TYPE_CHECKING: from cyclopts.argument._collection import ArgumentCollection @define(kw_only=True) class Argument: """Encapsulates functionality and additional contextual information for parsing a parameter. An argument is defined as anything that would have its own entry in the help page. """ tokens: list[Token] = field(factory=list) """ List of :class:`.Token` parsed from various sources. Do not directly mutate; see :meth:`append`. """ field_info: FieldInfo = field(factory=FieldInfo) """ Additional information about the parameter from surrounding python syntax. """ parameter: Parameter = field(factory=Parameter) """ Fully resolved user-provided :class:`.Parameter`. """ hint: Any = field(default=str, converter=resolve) """ The type hint for this argument; may be different from :attr:`.FieldInfo.annotation`. """ index: int | None = field(default=None) """ Associated python positional index for argument. If ``None``, then cannot be assigned positionally. """ keys: tuple[str, ...] = field(default=()) """ **Python** keys that lead to this leaf. ``self.parameter.name`` and ``self.keys`` can naively disagree! For example, a ``self.parameter.name="--foo.bar.baz"`` could be aliased to "--fizz". The resulting ``self.keys`` would be ``("bar", "baz")``. This is populated based on type-hints and class-structure, not ``Parameter.name``. .. code-block:: python from cyclopts import App, Parameter from dataclasses import dataclass from typing import Annotated app = App() @dataclass class User: id: int name: Annotated[str, Parameter(name="--fullname")] @app.default def main(user: User): pass for argument in app.assemble_argument_collection(): print(f"name: {argument.name:16} hint: {str(argument.hint):16} keys: {str(argument.keys)}") .. code-block:: bash $ my-script name: --user.id hint: keys: ('id',) name: --fullname hint: keys: ('name',) """ _value: Any = field(alias="value", default=UNSET) """ Converted value from last :meth:`convert` call. This value may be stale if fields have changed since last :meth:`convert` call. :class:`.UNSET` if :meth:`convert` has not yet been called with tokens. """ _accepts_keywords: bool = field(default=False, init=False, repr=False) _default: Any = field(default=None, init=False, repr=False) _lookup: dict[str, FieldInfo] = field(factory=dict, init=False, repr=False) children: "ArgumentCollection" = field(init=False, repr=False) """ Collection of other :class:`Argument` that eventually culminate into the python variable represented by :attr:`field_info`. """ _marked_converted: bool = field(default=False, init=False, repr=False) _mark_converted_override: bool = field(default=False, init=False, repr=False) _missing_keys_checker: Callable | None = field(default=None, init=False, repr=False) _internal_converter: Callable | None = field(default=None, init=False, repr=False) _enum_flag_type: Any | None = field(default=None, init=False, repr=False) def __attrs_post_init__(self): from cyclopts.argument._collection import ArgumentCollection self.children = ArgumentCollection() hint = resolve(self.hint) hints = get_args(hint) if is_union(hint) else (hint,) if self.parameter.count: # Perform type-annotation validation. resolved_hint = resolve_optional(hint) # Technically, bool is a subclass of int, so we need to explicitly check. if resolved_hint is bool or not ( resolved_hint is int or (isinstance(resolved_hint, type) and issubclass(resolved_hint, int)) ): raise ValueError( f"Parameter(count=True) requires an int type hint, got {self.hint}. " f"Use 'Annotated[int, Parameter(count=True)]' for counting flags." ) if self.parameter.requires_equals and self.parameter.consume_multiple: raise ValueError( "Parameter(requires_equals=True) and Parameter(consume_multiple=...) cannot be used together. " "requires_equals enforces '--option=value' syntax, which is incompatible with " "consume_multiple's space-separated value consumption." ) if not self.parse: # Validate that non-parsed parameters are keyword-only or have defaults is_keyword_only = self.field_info.kind is self.field_info.KEYWORD_ONLY has_default = self.field_info.default is not self.field_info.empty if not (is_keyword_only or has_default): raise ValueError( f"Non-parsed parameter '{self.field_info.name}' must be a KEYWORD_ONLY function parameter " "or have a default value." ) return if self.parameter.accepts_keys is False: return for hint in hints: origin = get_origin(hint) hint_origin = {hint, origin} field_infos = get_field_infos(hint) if dict in hint_origin: self._accepts_keywords = True key_type, val_type = str, str args = get_args(hint) with suppress(IndexError): key_type = args[0] val_type = args[1] if key_type is not str: raise TypeError('Dictionary type annotations must have "str" keys.') self._default = val_type elif is_typeddict(hint): self._missing_keys_checker = missing_keys_factory(_typed_dict_field_infos) self._accepts_keywords = True self._update_lookup(field_infos) elif is_dataclass(hint): self._missing_keys_checker = missing_keys_factory(_generic_class_field_infos) self._accepts_keywords = True self._update_lookup(field_infos) elif is_namedtuple(hint): self._missing_keys_checker = missing_keys_factory(_generic_class_field_infos) self._accepts_keywords = True if not hasattr(hint, "__annotations__"): raise ValueError("Cyclopts cannot handle collections.namedtuple without type annotations.") self._update_lookup(field_infos) elif is_attrs(hint): self._missing_keys_checker = missing_keys_factory(_attrs_field_infos) self._accepts_keywords = True self._update_lookup(field_infos) elif is_pydantic(hint): self._missing_keys_checker = missing_keys_factory(_pydantic_field_infos) self._accepts_keywords = True self._update_lookup(field_infos) elif is_enum_flag(hint): self._enum_flag_type = hint self._accepts_keywords = True self._update_lookup(field_infos) elif not is_builtin(hint) and field_infos: self._missing_keys_checker = missing_keys_factory(_generic_class_field_infos) self._accepts_keywords = True self._update_lookup(field_infos) elif self.parameter.accepts_keys is None: continue if self.parameter.accepts_keys is None: continue self._accepts_keywords = True self._missing_keys_checker = missing_keys_factory(_generic_class_field_infos) for i, field_info in enumerate(signature_parameters(hint.__init__).values()): if i == 0 and field_info.name == "self": continue if field_info.kind is field_info.VAR_KEYWORD: self._default = field_info.annotation else: self._update_lookup({field_info.name: field_info}) def _update_lookup(self, field_infos: dict[str, FieldInfo]): from typing import Literal discriminator = get_annotated_discriminator(self.field_info.annotation) for key, field_info in field_infos.items(): if existing_field_info := self._lookup.get(key): if existing_field_info == field_info: pass elif discriminator and discriminator in field_info.names and discriminator in existing_field_info.names: existing_field_info.annotation = Literal[existing_field_info.annotation, field_info.annotation] existing_field_info.default = FieldInfo.empty else: raise NotImplementedError else: self._lookup[key] = field_info @property def value(self): """Converted value from last :meth:`convert` call. This value may be stale if fields have changed since last :meth:`convert` call. :class:`.UNSET` if :meth:`convert` has not yet been called with tokens. """ return self._value @value.setter def value(self, val): if self._marked: self._mark_converted_override = True self._marked = True self._value = val @property def _marked(self): """If ``True``, then this node in the tree has already been converted and ``value`` has been populated.""" return self._marked_converted | self._mark_converted_override @_marked.setter def _marked(self, value: bool): self._marked_converted = value @property def _accepts_arbitrary_keywords(self) -> bool: args = get_args(self.hint) if is_union(self.hint) else (self.hint,) return any(dict in (arg, get_origin(arg)) for arg in args) @property def show_default(self) -> bool | Callable[[Any], str]: """Show the default value on the help page.""" if self.required: return False elif self.parameter.show_default is None: return self.field_info.default not in (None, self.field_info.empty) elif (self.field_info.default is self.field_info.empty) or not self.parameter.show_default: return False else: return self.parameter.show_default @property def _use_pydantic_type_adapter(self) -> bool: return bool( is_pydantic(self.hint) or ( is_union(self.hint) and ( any(is_pydantic(x) for x in get_args(self.hint)) or get_annotated_discriminator(self.field_info.annotation) ) ) ) def _type_hint_for_key(self, key: str): try: return self._lookup[key].annotation except KeyError: if self._default is None: raise return self._default def _should_attempt_json_dict(self, tokens: Sequence[Token | str] | None = None) -> bool: """When parsing, should attempt to parse the token(s) as json dict data.""" if tokens is None: tokens = self.tokens if not tokens: return False value = tokens[0].value if isinstance(tokens[0], Token) else tokens[0] if not value.strip().startswith("{"): return False if self._accepts_keywords: if self.parameter.json_dict is not None: return self.parameter.json_dict if contains_hint(self.field_info.annotation, str): return False return True hint = resolve(self.hint) origin = get_origin(hint) if origin in ITERABLE_TYPES: args = get_args(hint) if args and args[0] is not str: return True return False def _should_attempt_json_list( self, tokens: Sequence[Token | str] | Token | str | None = None, keys: tuple[str, ...] = () ) -> bool: """When parsing, should attempt to parse the token(s) as json list data.""" if tokens is None: tokens = self.tokens if not tokens: return False _, consume_all = self.token_count(keys) if not consume_all: return False if isinstance(tokens, Token): value = tokens.value elif isinstance(tokens, str): value = tokens else: value = tokens[0].value if isinstance(tokens[0], Token) else tokens[0] if not value.strip().startswith("["): return False if self.parameter.json_list is not None: return self.parameter.json_list for arg in get_args(self.hint) or (str,): if contains_hint(arg, str): return False return True def match( self, term: str | int, *, transform: Callable[[str], str] | None = None, delimiter: str = ".", ) -> tuple[tuple[str, ...], Any]: """Match a name search-term, or a positional integer index. Raises ------ ValueError If no match is found. Returns ------- tuple[str, ...] Leftover keys after matching to this argument. Used if this argument accepts_arbitrary_keywords. Any Implicit value. :obj:`~.UNSET` if no implicit value is applicable. """ if not self.parse: raise ValueError return ( self._match_index(term) if isinstance(term, int) else self._match_name(term, transform=transform, delimiter=delimiter) ) def _normalize_trailing_keys(self, trailing: tuple[str, ...]) -> tuple[str, ...]: """Map kebab-case segments back to their canonical Python field names. Walks the type hint segment-by-segment: * Dynamic ``dict`` keys pass through unchanged (advance to the value type). * Segments addressing a structured type (pydantic / dataclass / attrs / TypedDict / NamedTuple) are looked up in a ``{name_transform(name): name}`` map built from the type's field_infos; on hit, the segment is replaced with the canonical name and the walk advances to that field's annotation. * On miss or when the hint is unwalkable (plain scalar, unresolved forward ref, etc.) remaining segments pass through unchanged — this preserves the existing raw-snake_case behavior as a backward-compat fallback. """ name_transform = self.parameter.name_transform if name_transform is None or not trailing: return trailing # Seed from ``self.hint`` (not ``field_info.annotation``): for # ``**kwargs: SubConfig``, the annotation is ``SubConfig`` but the hint # is ``dict[str, SubConfig]`` — we need the wrapped form so the first # trailing segment is routed as a dict key rather than a field name. hint = resolve(self.hint) out: list[str] = [] i = 0 while i < len(trailing): segment = trailing[i] hint = resolve_optional(hint) if get_origin(hint) is dict: out.append(segment) args = get_args(hint) hint = args[1] if len(args) > 1 else str i += 1 continue field_infos = {} try: field_infos = get_field_infos(hint) except Exception: pass if not field_infos: out.extend(trailing[i:]) break # Build a kebab→canonical map from ``fi.names`` only. Cyclopts's # field_info extractors populate ``names`` with exactly the names # the underlying library accepts (e.g. pydantic omits the python # name when ``populate_by_name=False``); trust that. name_map: dict[str, tuple[str, Any]] = {} for canonical_name, fi in field_infos.items(): for alias in fi.names: name_map.setdefault(name_transform(alias), (canonical_name, fi.annotation)) match = name_map.get(segment) if match is None: out.extend(trailing[i:]) break canonical_name, next_hint = match out.append(canonical_name) hint = resolve(next_hint) i += 1 return tuple(out) def _match_name( self, term: str, *, transform: Callable[[str], str] | None = None, delimiter: str = ".", ) -> tuple[tuple[str, ...], Any]: """Check how well this argument matches a token keyword identifier. Parameters ---------- term: str Something like "--foo" transform: Callable Function that converts the cyclopts Parameter name(s) into something that should be compared against ``term``. Raises ------ ValueError If no match found. Returns ------- tuple[str, ...] Leftover keys after matching to this argument. Used if this argument accepts_arbitrary_keywords. Any Implicit value. """ if self.field_info.kind is self.field_info.VAR_KEYWORD: return self._normalize_trailing_keys(tuple(term.lstrip("-").split(delimiter))), UNSET trailing = term implicit_value = UNSET assert self.parameter.name for name in self.parameter.name: if transform: name = transform(name) if startswith(term, name): trailing = term[len(name) :] implicit_value = True if self.hint is bool or self.hint in ITERATIVE_BOOL_IMPLICIT_VALUE else UNSET if trailing: if trailing[0] == delimiter: trailing = trailing[1:] break else: return (), implicit_value else: hint = self._negatives_hint if is_union(hint): hints = get_args(hint) else: hints = (hint,) for hint in hints: hint = resolve_annotated(hint) double_break = False for name in self.parameter.get_negatives(hint): if transform: name = transform(name) if startswith(term, name): trailing = term[len(name) :] if hint in ITERATIVE_BOOL_IMPLICIT_VALUE: implicit_value = False elif is_nonetype(hint) or hint is None: implicit_value = None else: hint = resolve_optional(hint) implicit_value = (get_origin(hint) or hint)() if trailing: if trailing[0] == delimiter: trailing = trailing[1:] double_break = True break else: return (), implicit_value if double_break: break else: raise ValueError if not self._accepts_arbitrary_keywords: raise ValueError return self._normalize_trailing_keys(tuple(trailing.split(delimiter))), implicit_value def _match_index(self, index: int) -> tuple[tuple[str, ...], Any]: if self.index is None: raise ValueError elif self.field_info.kind is self.field_info.VAR_POSITIONAL: if index < self.index: raise ValueError elif index != self.index: raise ValueError return (), UNSET def append(self, token: Token): """Safely add a :class:`Token`.""" if not self.parse: raise ValueError if any(x.address == token.address for x in self.tokens): if self.parameter.allow_repeating is False: raise RepeatArgumentError(token=token) _, consume_all = self.token_count(token.keys) if self.parameter.allow_repeating is True: if not consume_all: # "last wins" for scalar types — remove old tokens with same address self.tokens = [x for x in self.tokens if x.address != token.address] elif not consume_all and not self.parameter.count: raise RepeatArgumentError(token=token) if self.tokens: if bool(token.keys) ^ any(x.keys for x in self.tokens): raise MixedArgumentError(argument=self) self.tokens.append(token) @property def has_tokens(self) -> bool: """This argument, or a child argument, has at least 1 parsed token.""" # noqa: D404 return bool(self.tokens) or any(x.has_tokens for x in self.children) @property def children_recursive(self) -> "ArgumentCollection": from cyclopts.argument._collection import ArgumentCollection out = ArgumentCollection() for child in self.children: out.append(child) out.extend(child.children_recursive) return out def _convert_pydantic(self): if self.has_tokens: import pydantic unstructured_data = self._json() try: return pydantic.TypeAdapter(self.field_info.annotation).validate_python(unstructured_data) except pydantic.ValidationError as e: self._handle_pydantic_validation_error(e) else: return UNSET def _convert(self, converter: Callable | None = None): from cyclopts.argument._collection import update_argument_collection if self.parameter.converter: # Resolve string converters to methods on the type if isinstance(self.parameter.converter, str): converter = getattr(self.hint, self.parameter.converter) else: converter = self.parameter.converter elif converter is None: converter = partial(convert, name_transform=self.parameter.name_transform) assert converter is not None # Ensure converter is set at this point def safe_converter(hint, tokens): if isinstance(tokens, dict): try: return converter(hint, tokens) # pyright: ignore except (AssertionError, ValueError, TypeError) as e: raise CoercionError(msg=e.args[0] if e.args else None, argument=self, target_type=hint) from e else: try: # Detect bound methods (classmethods/instance methods) if inspect.ismethod(converter): # Call with just tokens - cls/self already bound return converter(tokens) # pyright: ignore[reportCallIssue] else: # Regular function - pass type and tokens return converter(hint, tokens) # pyright: ignore[reportCallIssue] except (AssertionError, ValueError, TypeError) as e: token = tokens[0] if len(tokens) == 1 else None raise CoercionError( msg=e.args[0] if e.args else None, argument=self, target_type=hint, token=token ) from e if not self.parse: out = UNSET elif self.parameter.count: out = sum(token.implicit_value for token in self.tokens if token.implicit_value is not UNSET) elif not self.children: positional: list[Token] = [] keyword = {} def expand_tokens(tokens): for token in tokens: if self._should_attempt_json_list(token): try: parsed_json = json.loads(token.value) except json.JSONDecodeError as e: raise CoercionError(token=token, target_type=self.hint) from e if not isinstance(parsed_json, list): raise CoercionError(token=token, target_type=self.hint) if not parsed_json: yield token.evolve(value="", implicit_value=[]) else: for element in parsed_json: if element is None: yield token.evolve(value="", implicit_value=element) elif isinstance(element, dict): yield token.evolve(value=json.dumps(element)) else: yield token.evolve(value=str(element)) else: yield token expanded_tokens = list(expand_tokens(self.tokens)) for token in expanded_tokens: resolved_hint = resolve_optional(self.hint) if token.implicit_value is not UNSET and isinstance( token.implicit_value, get_origin(resolved_hint) or resolved_hint ): assert len(expanded_tokens) == 1 return token.implicit_value if token.keys: lookup = keyword for key in token.keys[:-1]: lookup = lookup.setdefault(key, {}) lookup.setdefault(token.keys[-1], []).append(token) else: positional.append(token) if positional and keyword: # pragma: no cover raise MixedArgumentError(argument=self) if positional: if self.field_info and self.field_info.kind is self.field_info.VAR_POSITIONAL: hint = get_args(self.hint)[0] tokens_per_element, _ = self.token_count() out = tuple(safe_converter(hint, values) for values in grouper(positional, tokens_per_element)) else: out = safe_converter(self.hint, tuple(positional)) elif keyword: if self.field_info and self.field_info.kind is self.field_info.VAR_KEYWORD and not self.keys: out = {key: safe_converter(get_args(self.hint)[1], value) for key, value in keyword.items()} else: out = safe_converter(self.hint, keyword) elif self.required: raise MissingArgumentError(argument=self) else: return UNSET else: data = {} out = UNSET if self._enum_flag_type: out = self._enum_flag_type(0) if self._enum_flag_type and self.tokens: converted_flags = safe_converter(self._enum_flag_type, self.tokens) out |= reduce(operator.or_, converted_flags) if isinstance(converted_flags, list) else converted_flags if self._should_attempt_json_dict(): while self.tokens: token = self.tokens.pop(0) try: parsed_json = json.loads(token.value) except json.JSONDecodeError as e: raise CoercionError(token=token, target_type=self.hint) from e _validate_json_extra_keys(parsed_json, self.hint, token) update_argument_collection( {self.name.lstrip("-"): parsed_json}, token.source, self.children_recursive, root_keys=(), allow_unknown=False, ) if self._use_pydantic_type_adapter: return self._convert_pydantic() if self.tokens and not self._enum_flag_type: positional_tokens = [token for token in self.tokens if not token.keys] if positional_tokens: return safe_converter(self.hint, tuple(positional_tokens)) for child in self.children: assert len(child.keys) == (len(self.keys) + 1) if child.has_tokens: data[child.keys[-1]] = child.convert_and_validate(converter=converter) elif child.required: obj = data for k in child.keys: try: obj = obj[k] except Exception: raise MissingArgumentError(argument=child) from None child._marked = True self._run_missing_keys_checker(data) if self._enum_flag_type: out |= enum_flag_from_dict(self._enum_flag_type, data, self.parameter.name_transform) if not out: out = UNSET elif data: out = instantiate_from_dict(self.hint, data) elif self.required: raise MissingArgumentError(argument=self) # pragma: no cover else: out = UNSET return out def convert(self, converter: Callable | None = None): """Converts :attr:`tokens` into :attr:`value`. Parameters ---------- converter: Callable | None Converter function to use. Overrides ``self.parameter.converter`` Returns ------- Any The converted data. Same as :attr:`value`. """ if not self._marked: try: self.value = self._convert(converter=converter) except CoercionError as e: if e.argument is None: e.argument = self if e.target_type is None: e.target_type = self.hint raise except CycloptsError as e: if e.argument is None: e.argument = self raise return self.value def validate(self, value): """Validates provided value. Parameters ---------- value: Value to validate. Returns ------- Any The converted data. Same as :attr:`value`. """ assert isinstance(self.parameter.validator, tuple) # Only use pydantic validation if pydantic v2+ is available. # Pydantic v1 has an incompatible API (e.g. no TypeAdapter). if "pydantic" in sys.modules: import pydantic pydantic_version = parse_version(pydantic.__version__) if pydantic_version < (2,): pydantic = None else: pydantic = None def validate_pydantic(hint, val): if not pydantic: return if self._use_pydantic_type_adapter: return try: pydantic.TypeAdapter(hint).validate_python(val) except pydantic.ValidationError as e: self._handle_pydantic_validation_error(e) except pydantic.PydanticUserError: pass try: if not self.keys and self.field_info and self.field_info.kind is self.field_info.VAR_KEYWORD: hint = get_args(self.hint)[1] for validator in self.parameter.validator: for val in value.values(): validator(hint, val) validate_pydantic(dict[str, self.field_info.annotation], value) elif self.field_info and self.field_info.kind is self.field_info.VAR_POSITIONAL: hint = get_args(self.hint)[0] for validator in self.parameter.validator: for val in value: validator(hint, val) validate_pydantic(tuple[self.field_info.annotation, ...], value) else: for validator in self.parameter.validator: validator(self.hint, value) validate_pydantic(self.field_info.annotation, value) except (AssertionError, ValueError, TypeError) as e: raise ValidationError(exception_message=e.args[0] if e.args else "", argument=self) from e def convert_and_validate(self, converter: Callable | None = None): """Converts and validates :attr:`tokens` into :attr:`value`. Parameters ---------- converter: Callable | None Converter function to use. Overrides ``self.parameter.converter`` Returns ------- Any The converted data. Same as :attr:`value`. """ val = self.convert(converter=converter) if val is not UNSET: self.validate(val) elif self.field_info.default is not FieldInfo.empty: self.validate(self.field_info.default) return val def token_count(self, keys: tuple[str, ...] = ()): """The number of string tokens this argument consumes. Parameters ---------- keys: tuple[str, ...] The **python** keys into this argument. If provided, returns the number of string tokens that specific data type within the argument consumes. Returns ------- int Number of string tokens to create 1 element. consume_all: bool :obj:`True` if this data type is iterable. """ if self.parameter.count: return 0, False # Check for explicit n_tokens override # This applies to values at any level: root values (keys=()) or nested values (keys=(...)) # For example, **kwargs: Annotated[str, Parameter(n_tokens=2)] means each kwarg value needs 2 tokens if self.parameter.n_tokens is not None: if self.parameter.n_tokens == -1: return 1, True else: # Determine consume_all based on the hint at the requested level # by recursively calling token_count on the hint if len(keys) > 1: hint = self._default elif len(keys) == 1: hint = self._type_hint_for_key(keys[0]) else: hint = self.hint # Recursively call token_count to get the consume_all behavior # We ignore the token count from the recursive call and use our explicit n_tokens _, consume_all_from_type = token_count(hint) return self.parameter.n_tokens, consume_all_from_type if len(keys) > 1: hint = self._default elif len(keys) == 1: hint = self._type_hint_for_key(keys[0]) else: hint = self.hint if self._enum_flag_type and not keys: return 1, True tokens_per_element, consume_all = token_count(hint) return tokens_per_element, consume_all @property def _negatives_hint(self): # Mirrors ``field_info.annotation`` but substitutes ``type(default)`` when # there is no annotation, so an unannotated ``foo=False`` is treated as # ``bool`` for negative-flag purposes. Unlike ``self.hint``, this preserves # ``Optional`` / unions, which ``negative_none`` depends on. hint = self.field_info.annotation if hint is inspect.Parameter.empty or resolve(hint) is Any: default = self.field_info.default if default is not inspect.Parameter.empty and default is not None: hint = type(default) return resolve_annotated(hint) @property def negatives(self): """Negative flags from :meth:`.Parameter.get_negatives`.""" return self.parameter.get_negatives(self._negatives_hint) @property def name(self) -> str: """The **first** provided name this argument goes by.""" return self.names[0] @property def names(self) -> tuple[str, ...]: """Names the argument goes by (both positive and negative).""" import itertools assert isinstance(self.parameter.name, tuple) return tuple(itertools.chain(self.parameter.name, self.negatives)) def env_var_split(self, value: str, delimiter: str | None = None) -> list[str]: """Split a given value with :meth:`.Parameter.env_var_split`.""" return self.parameter.env_var_split(self.hint, value, delimiter=delimiter) @property def show(self) -> bool: """Show this argument on the help page. If an argument has child arguments, don't show it on the help-page. Returns False for arguments that won't be parsed (including underscore-prefixed params). """ if self.children: return False if self.parameter.show is not None: # User explicitly set show return self.parameter.show # Default to whether this argument is parsed return self.parse @property def parse(self) -> bool: """Whether this argument should be parsed from CLI tokens. If ``Parameter.parse`` is a regex pattern, parse if the pattern matches the field name; otherwise don't parse. """ if self.parameter.parse is None: return True if isinstance(self.parameter.parse, re.Pattern): return bool(self.parameter.parse.search(self.field_info.name)) return bool(self.parameter.parse) @property def required(self) -> bool: """Whether or not this argument requires a user-provided value.""" if self.parameter.required is None: return self.field_info.required else: return self.parameter.required def is_positional_only(self) -> bool: return self.field_info.is_positional_only def is_var_positional(self) -> bool: return self.field_info.kind == self.field_info.VAR_POSITIONAL def is_flag(self) -> bool: """Check if this argument is a flag (consumes no CLI tokens). Flags are arguments that don't consume command-line tokens after the option name. They typically have implicit values (e.g., `--verbose` for bool, `--no-items` for list). Returns ------- bool True if the argument consumes zero tokens from the command line. Examples -------- >>> from cyclopts import Parameter >>> bool_arg = Argument(hint=bool, parameter=Parameter(name="--verbose")) >>> bool_arg.is_flag() True >>> str_arg = Argument(hint=str, parameter=Parameter(name="--name")) >>> str_arg.is_flag() False """ return self.token_count() == (0, False) def get_choices(self, force: bool = False) -> tuple[str, ...] | None: """Extract completion choices from type hint. Extracts choices from Literal types, Enum types, and Union types containing them. Respects the Parameter.show_choices setting unless force=True. Parameters ---------- force : bool If True, return choices even when show_choices=False. Used by shell completion to always provide choices. Returns ------- tuple[str, ...] | None Tuple of choice strings if choices exist and should be shown, None otherwise. Examples -------- >>> argument = Argument(hint=Literal["dev", "staging", "prod"], parameter=Parameter(show_choices=True)) >>> argument.get_choices() ('dev', 'staging', 'prod') >>> argument = Argument(hint=Literal["dev", "staging", "prod"], parameter=Parameter(show_choices=False)) >>> argument.get_choices() # Returns None for help text >>> argument.get_choices(force=True) # Returns choices for completion ('dev', 'staging', 'prod') """ if not force and not self.parameter.show_choices: return None choices = get_choices_from_hint(self.hint, self.parameter.name_transform) return tuple(choices) if choices else None def _json(self) -> dict: """Convert argument to be json-like for pydantic. All values will be str/list/dict. JSON-serialized strings (from sources like config files or environment variables) are deserialized back to their original dict/list structure. """ out = {} if self._accepts_keywords: for token in self.tokens: node = out for key in token.keys[:-1]: node = node.setdefault(key, {}) node[token.keys[-1]] = token.value if token.implicit_value is UNSET else token.implicit_value for child in self.children: child._marked = True if not child.has_tokens: continue keys = child.keys[len(self.keys) :] if child._accepts_keywords: result = child._json() if result: out[keys[0]] = result elif (get_origin(child.hint) or child.hint) in ITERABLE_TYPES: for token in child.tokens: if token.implicit_value is not UNSET: out.setdefault(keys[-1], []).extend(token.implicit_value) else: value = token.value # Deserialize JSON strings (from update_argument_collection) back to dict/list if isinstance(value, str) and value.strip() and value.strip()[0] in ("{", "["): try: value = json.loads(value) except json.JSONDecodeError: pass out.setdefault(keys[-1], []).append(value) else: token = child.tokens[0] out[keys[0]] = token.value if token.implicit_value is UNSET else token.implicit_value return out def _run_missing_keys_checker(self, data): if not self._missing_keys_checker or (not self.required and not data): return if not (missing_keys := self._missing_keys_checker(self, data)): return missing_key = missing_keys[0] keys = self.keys + (missing_key,) missing_arguments = self.children.filter_by(keys_prefix=keys) if missing_arguments: raise MissingArgumentError(argument=missing_arguments[0]) else: missing_description = self.field_info.names[0] + "->" + "->".join(keys) raise ValueError( f'Required field "{missing_description}" is not accessible by Cyclopts; possibly due to conflicting POSITIONAL/KEYWORD requirements.' ) def _handle_pydantic_validation_error(self, exc): import pydantic error = exc.errors()[0] if error["type"] == "missing": loc = error["loc"] # Pydantic includes list indices in loc for list-element errors # (e.g. ("animals", 0, "dog", "name")). Cyclopts doesn't model list # items as individual Arguments, so no prefix-match can correctly # map — fall through to the native pydantic error, which shows the # full nested path. if not any(isinstance(part, int) for part in loc): candidate = tuple(loc) while candidate: missing_arguments = self.children_recursive.filter_by(keys_prefix=self.keys + candidate) # An Argument that already has tokens cannot be "missing"; # the stripping heuristic has wandered into a populated sibling. missing_arguments = [a for a in missing_arguments if not a.tokens] if missing_arguments: raise MissingArgumentError(argument=missing_arguments[0]) from exc if len(candidate) == 1: break # For discriminated unions pydantic prepends the discriminator # value (e.g. loc=("cat", "rainbow")). Strip leading elements # until we find a real child Argument. candidate = candidate[1:] # Fall through to ValidationError. if isinstance(exc, pydantic.ValidationError): raise ValidationError(exception_message=str(exc), argument=self) from exc else: raise exc BrianPugh-cyclopts-921b1fa/cyclopts/argument/_collection.py000066400000000000000000000672731517576204000242120ustar00rootroot00000000000000"""ArgumentCollection class and related functionality.""" import inspect import itertools import json from collections.abc import Callable, Iterable, Sequence from typing import TYPE_CHECKING, Any, SupportsIndex, TypeVar, overload if TYPE_CHECKING: from cyclopts.core import App from cyclopts.exceptions import ( UnknownOptionError, ) from cyclopts.field_info import ( signature_parameters, ) from cyclopts.group import Group from cyclopts.parameter import Parameter from cyclopts.token import Token from cyclopts.utils import UNSET, is_iterable from ._argument import Argument from .utils import ( KIND_PARENT_CHILD_REASSIGNMENT, PARAMETER_SUBKEY_BLOCKER, extract_docstring_help, resolve_parameter_name, to_cli_option_name, walk_leaves, ) T = TypeVar("T") class ArgumentCollection(list[Argument]): """A list-like container for :class:`Argument`.""" def __init__(self, *args): super().__init__(*args) def copy(self) -> "ArgumentCollection": """Returns a shallow copy of the :class:`ArgumentCollection`.""" return type(self)(self) @overload def __getitem__(self, term: SupportsIndex, /) -> Argument: ... @overload def __getitem__(self, term: slice, /) -> list[Argument]: ... @overload def __getitem__(self, term: str, /) -> Argument: ... def __getitem__( self, term: str | SupportsIndex | slice, ) -> Argument | list[Argument]: if isinstance(term, (SupportsIndex, slice)): return super().__getitem__(term) return self.get(term) def __contains__(self, item: object, /) -> bool: """Check if an argument or argument name exists in the collection. Parameters ---------- item : Argument | str Either an Argument object or a string name/alias to search for. Returns ------- bool True if the item is in the collection. Examples -------- >>> argument_collection = ArgumentCollection( ... [ ... Argument(parameter=Parameter(name="--foo")), ... Argument(parameter=Parameter(name=("--bar", "-b"))), ... ] ... ) >>> "--foo" in argument_collection True >>> "-b" in argument_collection # Alias matching True >>> "--baz" in argument_collection False """ if isinstance(item, str): try: self[item] return True except KeyError: return False else: return super().__contains__(item) @overload def get( self, term: str | int, default: type[UNSET] = ..., *, transform: Callable[[str], str] | None = None, delimiter: str = ".", ) -> Argument: ... @overload def get( self, term: str | int, default: T, *, transform: Callable[[str], str] | None = None, delimiter: str = ".", ) -> Argument | T: ... def get( self, term: str | int, default: Any = UNSET, *, transform: Callable[[str], str] | None = None, delimiter: str = ".", ) -> Argument | Any: """Get an :class:`Argument` by name or index. This is a convenience wrapper around :meth:`match` that returns just the :class:`Argument` object instead of a tuple. Parameters ---------- term : str | int Either a string keyword or an integer positional index. default : Any Default value to return if term not found. If :data:`~cyclopts.utils.UNSET` (default), will raise :exc:`KeyError`/:exc:`IndexError`. transform : Callable[[str], str] | None Optional function to transform string terms before matching. delimiter : str Delimiter for nested field access. Returns ------- Argument | None The matched :class:`Argument`, or ``default`` if provided and not found. Raises ------ :exc:`KeyError` If ``term`` is a string and not found (when ``default`` is :data:`~cyclopts.utils.UNSET`). :exc:`IndexError` If ``term`` is an int and is out-of-range (when ``default`` is :data:`~cyclopts.utils.UNSET`). See Also -------- :meth:`match` : Returns a tuple of (:class:`Argument`, keys, value) with more detailed information. """ try: argument, _, _ = self.match(term, transform=transform, delimiter=delimiter) return argument except ValueError: if default is UNSET: if isinstance(term, str): raise KeyError(f"No such Argument: {term}") from None else: raise IndexError(f"Argument index {term} out of range") from None return default def match( self, term: str | int, *, transform: Callable[[str], str] | None = None, delimiter: str = ".", ) -> tuple[Argument, tuple[str, ...], Any]: """Matches CLI keyword or index to their :class:`Argument`. Parameters ---------- term: str | int One of: * :obj:`str` keyword like ``"--foo"`` or ``"-f"`` or ``"--foo.bar.baz"``. * :obj:`int` global positional index. Raises ------ ValueError If the provided ``term`` doesn't match. Returns ------- Argument Matched :class:`Argument`. tuple[str, ...] Python keys into :class:`Argument`. Non-empty iff :class:`Argument` accepts keys. Any Implicit value (if a flag). :obj:`~.UNSET` otherwise. """ best_match_argument, best_match_keys, best_implicit_value = None, None, UNSET for argument in self: try: match_keys, implicit_value = argument.match(term, transform=transform, delimiter=delimiter) except ValueError: continue if best_match_keys is None or len(match_keys) < len(best_match_keys): best_match_keys = match_keys best_match_argument = argument best_implicit_value = implicit_value if not match_keys: break if best_match_argument is None or best_match_keys is None: raise ValueError(f"No Argument matches {term!r}") return best_match_argument, best_match_keys, best_implicit_value def _set_marks(self, val: bool): for argument in self: argument._marked = val def _convert(self): """Convert and validate all elements.""" self._set_marks(False) for argument in sorted(self, key=lambda x: x.keys): if argument._marked: continue argument.convert_and_validate() @classmethod def _from_type( cls, field_info, keys: tuple[str, ...], *default_parameters: Parameter | None, group_lookup: dict[str, Group], group_arguments: Group, group_parameters: Group, parse_docstring: bool = True, docstring_lookup: dict[tuple[str, ...], Parameter] | None = None, positional_index: int | None = None, _resolve_groups: bool = True, ): from cyclopts.parameter import get_parameters out = cls() if docstring_lookup is None: docstring_lookup = {} cyclopts_parameters_no_group = [] hint = field_info.hint hint, hint_parameters = get_parameters(hint) cyclopts_parameters_no_group.extend(hint_parameters) if not keys: if field_info.kind is field_info.VAR_KEYWORD: hint = dict[str, hint] elif field_info.kind is field_info.VAR_POSITIONAL: hint = tuple[hint, ...] if _resolve_groups: cyclopts_parameters = [] for cparam in cyclopts_parameters_no_group: resolved_groups = [] for group in cparam.group: # pyright:ignore if isinstance(group, str): group = group_lookup[group] resolved_groups.append(group) cyclopts_parameters.append(group.default_parameter) cyclopts_parameters.append(cparam) if resolved_groups: has_visible_group = any(g.show for g in resolved_groups) all_nameless = all(not g.name for g in resolved_groups) if has_visible_group: cyclopts_parameters.append(Parameter(group=resolved_groups)) elif all_nameless: default_group = ( group_arguments if field_info.kind in (field_info.POSITIONAL_ONLY, field_info.VAR_POSITIONAL) else group_parameters ) all_groups = (default_group,) + tuple(resolved_groups) cyclopts_parameters.append(Parameter(group=all_groups)) else: cyclopts_parameters.append(Parameter(group=resolved_groups)) else: cyclopts_parameters = cyclopts_parameters_no_group upstream_parameter = Parameter.combine( ( Parameter(group=group_arguments) if field_info.kind in (field_info.POSITIONAL_ONLY, field_info.VAR_POSITIONAL) else Parameter(group=group_parameters) ), *default_parameters, ) immediate_parameter = Parameter.combine(*cyclopts_parameters) if keys: cparam = Parameter.combine( upstream_parameter, PARAMETER_SUBKEY_BLOCKER, immediate_parameter, ) cparam = Parameter.combine( cparam, Parameter( name=resolve_parameter_name( upstream_parameter.name, # pyright: ignore (immediate_parameter.name or tuple(cparam.name_transform(x) for x in field_info.names)) + cparam.alias, # pyright: ignore ) ), ) else: cparam = Parameter.combine( upstream_parameter, immediate_parameter, ) assert isinstance(cparam.alias, tuple) if cparam.name: if field_info.is_keyword: assert isinstance(cparam.name, tuple) cparam = Parameter.combine( cparam, Parameter(name=resolve_parameter_name(cparam.name + cparam.alias)) ) else: if field_info.kind in (field_info.POSITIONAL_ONLY, field_info.VAR_POSITIONAL): cparam = Parameter.combine(cparam, Parameter(name=(name.upper() for name in field_info.names))) elif field_info.kind is field_info.VAR_KEYWORD: cparam = Parameter.combine(cparam, Parameter(name=("--[KEYWORD]",))) else: assert cparam.name_transform is not None cparam = Parameter.combine( cparam, Parameter( name=tuple("--" + cparam.name_transform(name) for name in field_info.names) + resolve_parameter_name(cparam.alias) ), ) if field_info.is_keyword_only: positional_index = None argument = Argument(field_info=field_info, parameter=cparam, keys=keys, hint=hint) if positional_index is not None: if not argument._accepts_keywords or argument._enum_flag_type: argument.index = positional_index positional_index += 1 out.append(argument) if argument._accepts_keywords: hint_docstring_lookup = extract_docstring_help(argument.hint) if parse_docstring else {} hint_docstring_lookup.update(docstring_lookup) for sub_field_name, sub_field_info in argument._lookup.items(): updated_kind = KIND_PARENT_CHILD_REASSIGNMENT[(argument.field_info.kind, sub_field_info.kind)] if updated_kind is None: continue sub_field_info.kind = updated_kind if sub_field_info.is_keyword_only: positional_index = None subkey_docstring_lookup = { k[1:]: v for k, v in hint_docstring_lookup.items() if k[0] == sub_field_name and len(k) > 1 } subkey_argument_collection = cls._from_type( sub_field_info, keys + (sub_field_name,), cparam, ( Parameter(help=sub_field_info.help) if sub_field_info.help else hint_docstring_lookup.get((sub_field_name,)) ), Parameter(required=argument.required & sub_field_info.required), group_lookup=group_lookup, group_arguments=group_arguments, group_parameters=group_parameters, parse_docstring=parse_docstring, docstring_lookup=subkey_docstring_lookup, positional_index=positional_index, _resolve_groups=_resolve_groups, ) if subkey_argument_collection: argument.children.append(subkey_argument_collection[0]) out.extend(subkey_argument_collection) if positional_index is not None: positional_index = subkey_argument_collection._max_index if positional_index is not None: positional_index += 1 return out @classmethod def _from_callable( cls, func: Callable, *default_parameters: Parameter | None, group_lookup: dict[str, Group] | None = None, group_arguments: Group | None = None, group_parameters: Group | None = None, parse_docstring: bool = True, _resolve_groups: bool = True, ): out = cls() if group_arguments is None: group_arguments = Group.create_default_arguments() if group_parameters is None: group_parameters = Group.create_default_parameters() if _resolve_groups: group_lookup = { group.name: group for group in _resolve_groups_from_callable( func, *default_parameters, group_arguments=group_arguments, group_parameters=group_parameters, ) } else: group_lookup = {} docstring_lookup = extract_docstring_help(func) if parse_docstring else {} positional_index = 0 for field_info in signature_parameters(func).values(): if parse_docstring: subkey_docstring_lookup = { k[1:]: v for k, v in docstring_lookup.items() if k[0] == field_info.name and len(k) > 1 } else: subkey_docstring_lookup = None iparam_argument_collection = cls._from_type( field_info, (), *default_parameters, Parameter(help=field_info.help) if field_info.help else docstring_lookup.get((field_info.name,)), group_lookup=group_lookup, group_arguments=group_arguments, group_parameters=group_parameters, positional_index=positional_index, parse_docstring=parse_docstring, docstring_lookup=subkey_docstring_lookup, _resolve_groups=_resolve_groups, ) if positional_index is not None: positional_index = iparam_argument_collection._max_index if positional_index is not None: positional_index += 1 out.extend(iparam_argument_collection) return out @property def groups(self): groups = [] for argument in self: assert isinstance(argument.parameter.group, tuple) for group in argument.parameter.group: if group not in groups: groups.append(group) return groups @property def _root_arguments(self): for argument in self: if not argument.keys: yield argument @property def _max_index(self) -> int | None: return max((x.index for x in self if x.index is not None), default=None) def filter_by( self, *, group: Group | None = None, has_tokens: bool | None = None, has_tree_tokens: bool | None = None, keys_prefix: tuple[str, ...] | None = None, kind: inspect._ParameterKind | None = None, parse: bool | None = None, show: bool | None = None, value_set: bool | None = None, ) -> "ArgumentCollection": """Filter the :class:`ArgumentCollection`. All non-None filters will be applied. Parameters ---------- group: Group | None The :class:`.Group` the arguments should be in. has_tokens: bool | None Immediately has tokens (not including children). has_tree_tokens: bool | None :class:`Argument` and/or it's children have parsed tokens. kind: inspect._ParameterKind | None The :attr:`~inspect.Parameter.kind` of the argument. parse: bool | None If the argument is intended to be parsed or not. show: bool | None The :class:`Argument` is intended to be show on the help page. value_set: bool | None The converted value is set. """ ac = self.copy() cls = type(self) if group is not None: ac = cls(x for x in ac if group in x.parameter.group) # pyright: ignore if kind is not None: ac = cls(x for x in ac if x.field_info.kind == kind) if has_tokens is not None: ac = cls(x for x in ac if not (bool(x.tokens) ^ bool(has_tokens))) if has_tree_tokens is not None: ac = cls(x for x in ac if not (bool(x.tokens) ^ bool(has_tree_tokens))) if keys_prefix is not None: ac = cls(x for x in ac if x.keys[: len(keys_prefix)] == keys_prefix) if show is not None: ac = cls(x for x in ac if not (x.show ^ bool(show))) if value_set is not None: ac = cls(x for x in ac if ((x.value is UNSET) ^ bool(value_set))) if parse is not None: ac = cls(x for x in ac if not (x.parse ^ parse)) return ac def _resolve_groups_from_callable( func: Callable[..., Any], *default_parameters: Parameter | None, group_arguments: Group | None = None, group_parameters: Group | None = None, ) -> list[Group]: argument_collection = ArgumentCollection._from_callable( func, *default_parameters, group_arguments=group_arguments, group_parameters=group_parameters, parse_docstring=False, _resolve_groups=False, ) resolved_groups = [] if group_arguments is not None: resolved_groups.append(group_arguments) if group_parameters is not None: resolved_groups.append(group_parameters) for argument in argument_collection: for group in argument.parameter.group: # pyright: ignore if not isinstance(group, Group): continue if any(group != x and x._name == group._name for x in resolved_groups): raise ValueError("Cannot register 2 distinct Group objects with same name.") if group.default_parameter is not None and group.default_parameter.group: raise ValueError("Group.default_parameter cannot have a specified group.") # pragma: no cover try: next(x for x in resolved_groups if x._name == group._name) except StopIteration: resolved_groups.append(group) for argument in argument_collection: for group in argument.parameter.group: # pyright: ignore if not isinstance(group, str): continue try: next(x for x in resolved_groups if x.name == group) except StopIteration: resolved_groups.append(Group(group)) return resolved_groups def _meta_arguments(apps: Sequence["App"]) -> ArgumentCollection: argument_collection = ArgumentCollection() for app in apps: if app._meta is None: continue argument_collection.extend(app._meta.assemble_argument_collection()) return argument_collection def _is_valid_option_key(option_key: str, arguments: "ArgumentCollection") -> bool: """Check if option_key corresponds to a valid root argument. When processing nested config keys like {"p": {"timeout": 3}}, the fallback alias matching needs to verify that "p" is actually a valid parameter before matching nested fields. This prevents unknown keys like "np" from incorrectly matching against valid nested arguments. If no root argument exists (children-only collection, e.g., from JSON env var processing), returns True since the option_key is implicitly valid. Parameters ---------- option_key : str The top-level config key to validate (e.g., "p" from {"p": {"timeout": 3}}). arguments : ArgumentCollection The argument collection to validate against. Returns ------- bool True if option_key is valid, False otherwise. """ root_arg = next((arg for arg in arguments if arg.keys == ()), None) if not root_arg: return True # Children-only collection, implicitly valid cli_parent = to_cli_option_name(option_key) return bool( (root_arg.parameter.name and cli_parent in root_arg.parameter.name) or option_key in root_arg.field_info.names ) def update_argument_collection( config: dict, source: str, arguments: ArgumentCollection, apps: Sequence["App"] | None = None, *, root_keys: Iterable[str], allow_unknown: bool, ): """Updates an argument collection with values from a configuration dictionary. This function takes configuration data (typically from JSON, TOML, YAML files or environment variables) and populates the corresponding arguments in the ArgumentCollection with tokens representing those values. The function handles various naming conventions, including: - Exact matches (e.g., "storage_class" matches "storage_class") - Transformed matches (e.g., "storage-class" matches "storage_class") - Pydantic aliases (e.g., "storageClass" matches field with alias "storageClass") Parameters ---------- config : dict Configuration dictionary with nested structure mapping to CLI arguments. source : str Source identifier (e.g., file path or "env") for error messages and tracking. arguments : ArgumentCollection Collection of arguments to populate with configuration values. apps : Optional[Sequence[App]] Stack of App instances for meta-argument handling. We need to know all the meta-apps leading up to the current application so that we can properly detect unknown keys. root_keys : Iterable[str] Base path keys to prepend to all configuration keys. allow_unknown : bool If True, ignore unrecognized configuration keys instead of raising errors. Raises ------ UnknownOptionError If a configuration key doesn't match any argument and allow_unknown is False. Notes ----- Arguments that already have tokens are skipped to preserve command-line precedence over configuration files. """ meta_arguments = _meta_arguments(apps or ()) do_not_update = {} for option_key, option_value in config.items(): for subkeys, value in walk_leaves(option_value): cli_option_name = to_cli_option_name(option_key, *subkeys) complete_keyword = "".join(f"[{k}]" for k in itertools.chain(root_keys, (option_key,), subkeys)) try: meta_arguments.match(cli_option_name) continue except ValueError: pass argument = None remaining_keys = () try: argument, remaining_keys, _ = arguments.match(cli_option_name) except ValueError: if subkeys and _is_valid_option_key(option_key, arguments): for arg in arguments: if len(subkeys) != len(arg.keys): continue all_match = True for i, (subkey, arg_key) in enumerate(zip(subkeys, arg.keys, strict=False)): if subkey == arg_key: continue if i == len(arg.keys) - 1: if subkey not in arg.field_info.names: all_match = False break else: parent_keys = arg.keys[: i + 1] alias_found = False for parent_arg in arguments: if parent_arg.keys == parent_keys and subkey in parent_arg.field_info.names: alias_found = True break if not alias_found: all_match = False break if all_match: argument = arg remaining_keys = () break if not argument: if allow_unknown: continue if apps and apps[-1]._meta_parent: continue raise UnknownOptionError( token=Token(keyword=complete_keyword, source=source), argument_collection=arguments ) from None if do_not_update.setdefault(id(argument), bool(argument.tokens)): continue if not is_iterable(value): value = (value,) if value: for i, v in enumerate(value): if v is None: token = Token( keyword=complete_keyword, implicit_value=None, source=source, index=i, keys=remaining_keys, ) else: if isinstance(v, dict | list): # Serialize to JSON string; will be deserialized in Argument._json() value_str = json.dumps(v) else: value_str = str(v) token = Token( keyword=complete_keyword, value=value_str, source=source, index=i, keys=remaining_keys ) argument.append(token) else: token = Token( keyword=complete_keyword, implicit_value=value, source=source, index=0, keys=remaining_keys ) argument.append(token) BrianPugh-cyclopts-921b1fa/cyclopts/argument/utils.py000066400000000000000000000205021517576204000230400ustar00rootroot00000000000000"""Shared helper functions and constants for the argument package.""" import sys from collections.abc import Callable, Iterator from contextlib import suppress from enum import Enum, Flag from functools import partial from typing import TYPE_CHECKING, Annotated, Any, Literal, TypeVar, get_args, get_origin if TYPE_CHECKING: from cyclopts.argument._argument import Argument F = TypeVar("F", bound=Flag) from cyclopts._convert import convert_enum_flag from cyclopts.annotations import ( ITERABLE_TYPES, is_class_and_subclass, is_union, resolve_annotated, ) from cyclopts.field_info import ( KEYWORD_ONLY, POSITIONAL_ONLY, POSITIONAL_OR_KEYWORD, VAR_KEYWORD, VAR_POSITIONAL, FieldInfo, ) from cyclopts.parameter import Parameter if sys.version_info >= (3, 12): # pragma: no cover from typing import TypeAliasType else: # pragma: no cover TypeAliasType = None PARAMETER_SUBKEY_BLOCKER = Parameter( name=None, alias=None, converter=None, # pyright: ignore validator=None, accepts_keys=None, consume_multiple=None, env_var=None, ) KIND_PARENT_CHILD_REASSIGNMENT = { (POSITIONAL_OR_KEYWORD, POSITIONAL_OR_KEYWORD): POSITIONAL_OR_KEYWORD, (POSITIONAL_OR_KEYWORD, POSITIONAL_ONLY): POSITIONAL_ONLY, (POSITIONAL_OR_KEYWORD, KEYWORD_ONLY): KEYWORD_ONLY, (POSITIONAL_OR_KEYWORD, VAR_POSITIONAL): VAR_POSITIONAL, (POSITIONAL_OR_KEYWORD, VAR_KEYWORD): VAR_KEYWORD, (POSITIONAL_ONLY, POSITIONAL_OR_KEYWORD): POSITIONAL_ONLY, (POSITIONAL_ONLY, POSITIONAL_ONLY): POSITIONAL_ONLY, (POSITIONAL_ONLY, KEYWORD_ONLY): None, (POSITIONAL_ONLY, VAR_POSITIONAL): VAR_POSITIONAL, (POSITIONAL_ONLY, VAR_KEYWORD): None, (KEYWORD_ONLY, POSITIONAL_OR_KEYWORD): KEYWORD_ONLY, (KEYWORD_ONLY, POSITIONAL_ONLY): None, (KEYWORD_ONLY, KEYWORD_ONLY): KEYWORD_ONLY, (KEYWORD_ONLY, VAR_POSITIONAL): None, (KEYWORD_ONLY, VAR_KEYWORD): VAR_KEYWORD, (VAR_POSITIONAL, POSITIONAL_OR_KEYWORD): POSITIONAL_ONLY, (VAR_POSITIONAL, POSITIONAL_ONLY): POSITIONAL_ONLY, (VAR_POSITIONAL, KEYWORD_ONLY): None, (VAR_POSITIONAL, VAR_POSITIONAL): VAR_POSITIONAL, (VAR_POSITIONAL, VAR_KEYWORD): None, (VAR_KEYWORD, POSITIONAL_OR_KEYWORD): KEYWORD_ONLY, (VAR_KEYWORD, POSITIONAL_ONLY): None, (VAR_KEYWORD, KEYWORD_ONLY): KEYWORD_ONLY, (VAR_KEYWORD, VAR_POSITIONAL): None, (VAR_KEYWORD, VAR_KEYWORD): VAR_KEYWORD, } def get_choices_from_hint(type_: type, name_transform: Callable[[str], str]) -> list[str]: """Extract completion choices from a type hint. Recursively extracts choices from Literal types, Enum types, and Union types. Parameters ---------- type_ : type Type annotation to extract choices from. name_transform : Callable[[str], str] Function to transform choice names (e.g., for case conversion). Returns ------- list[str] List of choice strings extracted from the type hint. """ get_choices = partial(get_choices_from_hint, name_transform=name_transform) choices = [] _origin = get_origin(type_) if isinstance(type_, type) and is_class_and_subclass(type_, Enum): choices.extend(name_transform(x) for x in type_.__members__) elif is_union(_origin): inner_choices = [get_choices(inner) for inner in get_args(type_)] for x in inner_choices: if x: choices.extend(x) elif _origin is Literal: choices.extend(str(x) for x in get_args(type_)) elif _origin in ITERABLE_TYPES: args = get_args(type_) if len(args) == 1 or (_origin is tuple and len(args) == 2 and args[1] is Ellipsis): choices.extend(get_choices(args[0])) elif _origin is Annotated: choices.extend(get_choices(resolve_annotated(type_))) elif TypeAliasType is not None and isinstance(type_, TypeAliasType): choices.extend(get_choices(type_.__value__)) return choices def startswith(string, prefix): def normalize(s): return s.replace("_", "-") return normalize(string).startswith(normalize(prefix)) def missing_keys_factory( get_field_info: Callable[[Any], dict[str, FieldInfo]], ) -> Callable[["Argument", dict[str, Any]], list[str]]: def inner(argument: "Argument", data: dict[str, Any]) -> list[str]: provided_keys = set(data) field_info = get_field_info(argument.hint) return [k for k, v in field_info.items() if (v.required and k not in provided_keys)] return inner def enum_flag_from_dict( enum_type: type[F], data: dict[str, bool], name_transform: Callable[[str], str], ) -> F: """Convert a dictionary of boolean flags to a Flag enum value. Parameters ---------- enum_type : type[F] The Flag enum type to convert to. data : dict[str, bool] Dictionary mapping flag names to boolean values. Returns ------- F The combined flag value. """ return convert_enum_flag(enum_type, (k for k, v in data.items() if v), name_transform) def extract_docstring_help(f: Callable) -> dict[tuple[str, ...], Parameter]: from docstring_parser import parse_from_object with suppress(AttributeError): f = f.func # pyright: ignore[reportFunctionMemberAccess] result = {} # For classes, walk through MRO to include base class fields. # parse_from_object only extracts docstrings from the **immediate** class's source code, # not from inherited fields. # From docstring_parser docs: # # When given a class, only the attribute docstrings of that class are parsed, not its # inherited classes. This is a design decision. Separate calls to this function # should be performed to get attribute docstrings of parent classes. if mro := getattr(f, "__mro__", None): # Process base classes first (reversed MRO order), so derived classes can override # their parent's docstrings if they redefine the same field with a new docstring. for base_class in reversed(mro[:-1]): # Exclude 'object' try: parsed = parse_from_object(base_class) for dparam in parsed.params: result[tuple(dparam.arg_name.split("."))] = Parameter(help=dparam.description) except (TypeError, AttributeError): # Some base classes may not have parseable docstrings (e.g., built-in classes) continue else: # For functions/callables (original behavior) try: parsed = parse_from_object(f) for dparam in parsed.params: result[tuple(dparam.arg_name.split("."))] = Parameter(help=dparam.description) except (TypeError, AttributeError): # parse_from_object may fail for some callables pass return result def resolve_parameter_name_helper(elem): if elem.endswith("*"): elem = elem[:-1].rstrip(".") if elem and not elem.startswith("-"): elem = "--" + elem return elem def resolve_parameter_name(*argss: tuple[str, ...]) -> tuple[str, ...]: """Resolve parameter names by combining and formatting multiple tuples of strings. Parameters ---------- *argss Each tuple represents a group of parameter name components. Returns ------- tuple[str, ...] A tuple of resolved parameter names. """ argss = tuple(x for x in argss if x) if len(argss) == 0: return () elif len(argss) == 1: return tuple("*" if x == "*" else resolve_parameter_name_helper(x) for x in argss[0]) out = [] for a1 in argss[0]: a1 = resolve_parameter_name_helper(a1) for a2 in argss[1]: if a2.startswith("-") or not a1: out.append(a2) else: out.append(a1 + "." + a2) return resolve_parameter_name(tuple(out), *argss[2:]) def walk_leaves( d, parent_keys: tuple[str, ...] | None = None, ) -> Iterator[tuple[tuple[str, ...], Any]]: if parent_keys is None: parent_keys = () if isinstance(d, dict): for key, value in d.items(): current_keys = parent_keys + (key,) if isinstance(value, dict): yield from walk_leaves(value, current_keys) else: yield current_keys, value else: yield (), d def to_cli_option_name(*keys: str) -> str: return "--" + ".".join(keys) BrianPugh-cyclopts-921b1fa/cyclopts/bind.py000066400000000000000000000665551517576204000210140ustar00rootroot00000000000000import inspect import itertools import os import shlex import sys from collections.abc import Callable, Iterable, Sequence from contextlib import suppress from functools import partial from typing import TYPE_CHECKING, Any, NamedTuple, get_origin from cyclopts._convert import _bool from cyclopts.annotations import resolve_optional from cyclopts.argument import Argument, ArgumentCollection from cyclopts.exceptions import ( ArgumentOrderError, CoercionError, CombinedShortOptionError, ConsumeMultipleError, CycloptsError, MissingArgumentError, RequiresEqualsError, UnknownOptionError, ValidationError, ) from cyclopts.field_info import POSITIONAL_ONLY, POSITIONAL_OR_KEYWORD from cyclopts.token import Token from cyclopts.utils import UNSET, is_option_like if sys.version_info < (3, 11): # pragma: no cover pass else: # pragma: no cover pass if TYPE_CHECKING: from cyclopts.group import Group CliToken = partial(Token, source="cli") class _KeywordMatch(NamedTuple): """Represents a matched CLI token with its corresponding argument.""" matched_token: str """The actual CLI token that was matched (e.g., '-o', '--option').""" argument: Argument """The matched Argument object.""" keys: tuple[str, ...] """Leftover keys for nested arguments.""" implicit_value: Any """Implicit value if this is a flag, otherwise UNSET.""" def normalize_tokens(tokens: None | str | Iterable[str]) -> list[str]: if tokens is None: tokens = sys.argv[1:] # Remove the executable elif isinstance(tokens, str): tokens = shlex.split(tokens) else: tokens = list(tokens) return tokens def _common_root_keys(argument_collection) -> tuple[str, ...]: if not argument_collection: return () common = argument_collection[0].keys for argument in argument_collection[1:]: if not argument.keys: return () for i, (common_key, argument_key) in enumerate(zip(common, argument.keys, strict=False)): if common_key != argument_key: if i == 0: return () common = argument.keys[:i] break common = common[: len(argument.keys)] return common def _parse_kw_and_flags( argument_collection: ArgumentCollection, tokens: Sequence[str], *, end_of_options_delimiter: str = "--", stop_at_first_unknown: bool = False, ) -> tuple[list[str], int | None]: """Extract keyword arguments and flags from the token stream. Returns ------- unused_tokens: list[str] Tokens not consumed by any keyword or flag. contiguous_positional_count: int | None Number of leading contiguous non-option tokens before the first gap caused by keyword extraction. ``None`` if all non-option tokens are contiguous (i.e. no keywords were interleaved among positional tokens). For example, given ``a b c --bar 8 --baz 10 d``, the unused tokens are ``['a', 'b', 'c', 'd']`` with original indices ``[0, 1, 2, 6]``. The gap between indices 2 and 6 yields ``contiguous_positional_count=3``. This is used by ``_parse_pos`` to prevent positional-only list parameters from consuming tokens that appeared after keyword arguments. """ unused_tokens, positional_only_tokens = [], [] unused_token_original_indices: list[int] = [] skip_next_iterations = 0 if end_of_options_delimiter: try: delimiter_index = tokens.index(end_of_options_delimiter) except ValueError: pass # end_of_options_delimiter not in token stream else: positional_only_tokens = tokens[delimiter_index:] tokens = tokens[:delimiter_index] for i, token in enumerate(tokens): # If the previous argument was a keyword, then this is its value if skip_next_iterations > 0: skip_next_iterations -= 1 continue if not is_option_like(token, allow_numbers=True): if stop_at_first_unknown: # Stop parsing and return all remaining tokens as unused unused_tokens.extend(tokens[i:]) unused_token_original_indices.extend(range(i, len(tokens))) break unused_tokens.append(token) unused_token_original_indices.append(i) continue cli_values: list[str] = [] consume_count = 0 # startswith("-") is redundant, but it's cheap safety. allow_combined_flags = token.startswith("-") and not token.startswith("--") # Try splitting on "=" for long options or short options that match exactly if "=" in token: cli_option, cli_value = token.split("=", 1) # Try to match the part before "=" try: argument_collection.match(cli_option) # Matched! Use the split cli_values.append(cli_value) consume_count -= 1 allow_combined_flags = False except ValueError: # No match - might be GNU-style like "-pfile=value" # Don't split, treat whole token as the option cli_option = token else: cli_option = token matches: list[_KeywordMatch] = [] attached_value: str | None = None # Track value attached to a GNU-style combined option try: matches.append(_KeywordMatch(cli_option, *argument_collection.match(cli_option))) except ValueError: # Length has to be greater than 2 (hyphen + character) to be exploded. # Also exclude numeric values (e.g., -10, -3.14) from combined flag parsing. if allow_combined_flags and len(token) > 2 and is_option_like(token, allow_numbers=False): # GNU-style combined short options: process left-to-right # Once we hit an option that takes a value, the rest is the value chars = cli_option.lstrip("-") position = 0 while position < len(chars): char = chars[position] test_flag = f"-{char}" try: arg, keys, implicit = argument_collection.match(test_flag) if implicit is not UNSET or arg.parameter.count: # This is a flag (boolean or counting) - consume just this character matches.append(_KeywordMatch(test_flag, arg, keys, implicit)) position += 1 else: # This option takes a value - rest of the string is the value remainder = chars[position + 1 :] matches.append(_KeywordMatch(test_flag, arg, keys, implicit)) if remainder: # Value is attached: -uroot or -fvuroot # Store it separately, will be added to cli_values when processing this match attached_value = remainder consume_count -= 1 # Stop processing further characters break except ValueError: # Unknown flag if stop_at_first_unknown: unused_tokens.extend(tokens[i:]) return unused_tokens, None unused_tokens.append(test_flag) unused_token_original_indices.append(i) position += 1 if not matches: # No valid matches found at all continue else: if stop_at_first_unknown: # Unknown option, stop parsing and return all remaining tokens unused_tokens.extend(tokens[i:]) return unused_tokens, None unused_tokens.append(token) unused_token_original_indices.append(i) continue for match_index, match in enumerate(matches): # For GNU-style combined options, add the attached value only when processing # the last match (the value-taking option), not for preceding flags if attached_value is not None and match_index == len(matches) - 1: cli_values.append(attached_value) if match.argument.parameter.count: match.argument.append(CliToken(keyword=match.matched_token, implicit_value=1)) elif match.implicit_value is not UNSET: # A flag was parsed if cli_values: try: coerced_value = _bool(cli_values[-1]) except CoercionError as e: if e.token is None: e.token = CliToken(keyword=match.matched_token) if e.argument is None: e.argument = match.argument raise if coerced_value: # --positive-flag=true or --negative-flag=true or --empty-flag=true match.argument.append( CliToken(keyword=match.matched_token, implicit_value=match.implicit_value) ) else: # --positive-flag=false or --negative-flag=false or --empty-flag=false if isinstance(match.implicit_value, bool): match.argument.append( CliToken(keyword=match.matched_token, implicit_value=not match.implicit_value) ) else: # A negative for a non-bool field doesn't really make sense; # e.g. --empty-list=False # So we'll just silently skip it, as it may make bash scripting easier. pass else: match.argument.append(CliToken(keyword=match.matched_token, implicit_value=match.implicit_value)) else: # This is a value-taking option (not a flag or counting parameter) # Error only if we're trying to combine multiple value-taking options without values # (e.g., -fu where both -f and -u take values would be invalid) # But -fu where -f is a flag and -u takes a value is valid (GNU-style) if len(matches) > 1: # Count how many value-taking options we have value_taking_count = sum( 1 for m in matches if m.implicit_value is UNSET and not m.argument.parameter.count ) if value_taking_count > 1: raise CombinedShortOptionError( msg=f"Cannot combine multiple value-taking options in token {cli_option}" ) tokens_per_element, consume_all = match.argument.token_count(match.keys) if match.argument.parameter.requires_equals and match.matched_token.startswith("--") and not cli_values: raise RequiresEqualsError( argument=match.argument, keyword=match.matched_token, ) # Consume the appropriate number of tokens # cm_bounds is either None or (min, max) — guaranteed by _consume_multiple_converter cm_bounds = match.argument.parameter.consume_multiple assert cm_bounds is None or isinstance(cm_bounds, tuple) cm_min, cm_max = cm_bounds if cm_bounds is not None else (0, None) with suppress(IndexError): if consume_all and cm_bounds is not None: for j in itertools.count(): token = tokens[i + 1 + j] if not match.argument.parameter.allow_leading_hyphen and is_option_like(token): break cli_values.append(token) skip_next_iterations += 1 else: consume_count += tokens_per_element for j in range(consume_count): if len(cli_values) == 1 and ( match.argument._should_attempt_json_dict(cli_values) or match.argument._should_attempt_json_list(cli_values, match.keys) ): tokens_per_element = 1 # Assume that the contents are json and that we shouldn't # consume any additional tokens. break token = tokens[i + 1 + j] if not match.argument.parameter.allow_leading_hyphen and is_option_like(token): raise MissingArgumentError( argument=match.argument, tokens_so_far=cli_values, keyword=match.matched_token, ) cli_values.append(token) skip_next_iterations += 1 if not cli_values: # No values were consumed after the keyword if consume_all and cm_bounds is not None: if cm_min > 0: # Minimum count not met — treat as missing argument raise ConsumeMultipleError( argument=match.argument, tokens_so_far=cli_values, keyword=match.matched_token, min_required=cm_min, max_allowed=cm_max, actual_count=0, ) # Allow empty iterables (e.g., --urls with no values behaves like --empty-urls) hint = resolve_optional(match.argument.hint) empty_container = (get_origin(hint) or hint)() match.argument.append( CliToken(keyword=match.matched_token, implicit_value=empty_container, keys=match.keys) ) else: # Non-iterables or consume_multiple=False require at least one value raise MissingArgumentError( argument=match.argument, tokens_so_far=cli_values, keyword=match.matched_token ) elif len(cli_values) % tokens_per_element: # For multi-token elements (e.g., tuples), ensure we have complete sets raise MissingArgumentError( argument=match.argument, tokens_so_far=cli_values, keyword=match.matched_token ) else: # Check min/max count for consume_multiple if cm_bounds is not None: n_elements = len(cli_values) // max(1, tokens_per_element) if n_elements < cm_min: raise ConsumeMultipleError( argument=match.argument, tokens_so_far=cli_values, keyword=match.matched_token, min_required=cm_min, max_allowed=cm_max, actual_count=n_elements, ) if cm_max is not None and n_elements > cm_max: raise ConsumeMultipleError( argument=match.argument, tokens_so_far=cli_values, keyword=match.matched_token, min_required=cm_min, max_allowed=cm_max, actual_count=n_elements, ) # Normal case: append the consumed values for index, cli_value in enumerate(cli_values): match.argument.append( CliToken(keyword=match.matched_token, value=cli_value, index=index, keys=match.keys) ) # Compute the number of contiguous positional (non-option-like) unused tokens # before the first gap caused by keyword extraction. This prevents positional-only # list parameters from consuming tokens that appeared after keyword arguments. # Only set when a gap is detected; None means no gap (all tokens are contiguous). contiguous_positional_count: int | None = None for j in range(1, len(unused_token_original_indices)): if unused_token_original_indices[j] != unused_token_original_indices[j - 1] + 1: contiguous_positional_count = j break unused_tokens.extend(positional_only_tokens) return unused_tokens, contiguous_positional_count def _future_positional_only_token_count(argument_collection: ArgumentCollection, starting_index: int) -> int: n_tokens_to_leave = 0 for i in itertools.count(): try: argument, _, _ = argument_collection.match(starting_index + i) except ValueError: break if argument.field_info.kind is not POSITIONAL_ONLY: break future_tokens_per_element, future_consume_all = argument.token_count() if future_consume_all: raise ValueError("Cannot have 2 all-consuming positional arguments.") n_tokens_to_leave += future_tokens_per_element return n_tokens_to_leave def _preprocess_positional_tokens(tokens: Sequence[str], end_of_options_delimiter: str) -> list[tuple[str, bool]]: try: delimiter_index = tokens.index(end_of_options_delimiter) return [(t, False) for t in tokens[:delimiter_index]] + [(t, True) for t in tokens[delimiter_index + 1 :]] except ValueError: # delimiter not found return [(t, False) for t in tokens] def _parse_pos( argument_collection: ArgumentCollection, tokens: list[str], *, end_of_options_delimiter: str = "--", contiguous_positional_count: int | None = None, ) -> list[str]: """Assign positional tokens to positional parameters. Parameters ---------- argument_collection: ArgumentCollection Arguments whose keyword/flag tokens have already been consumed. tokens: list[str] Unused tokens from ``_parse_kw_and_flags``. end_of_options_delimiter: str Delimiter after which all tokens are forced positional. contiguous_positional_count: int | None If not ``None``, the number of leading contiguous positional tokens that were adjacent in the original CLI input (before keyword extraction created a gap). Used to cap how many tokens a ``POSITIONAL_ONLY`` list/iterable parameter may consume, preventing it from greedily swallowing tokens that originally appeared after keyword arguments. See ``_parse_kw_and_flags`` for how this value is computed. """ prior_positional_or_keyword_supplied_as_keyword_arguments = [] if not tokens: return [] tokens_and_force_positional = _preprocess_positional_tokens(tokens, end_of_options_delimiter) for i in itertools.count(): try: argument, _, _ = argument_collection.match(i) except ValueError: break if argument.field_info.kind is POSITIONAL_OR_KEYWORD: if argument.tokens and argument.tokens[0].keyword is not None: prior_positional_or_keyword_supplied_as_keyword_arguments.append(argument) # Continue in case we hit a VAR_POSITIONAL argument. continue if prior_positional_or_keyword_supplied_as_keyword_arguments: token = tokens[0] if not argument.parameter.allow_leading_hyphen and is_option_like(token): # It's more meaningful to interpret the token as an intended option, # rather than an intended positional value for ``argument``. raise UnknownOptionError(token=CliToken(value=token), argument_collection=argument_collection) else: raise ArgumentOrderError( argument=argument, prior_positional_or_keyword_supplied_as_keyword_arguments=prior_positional_or_keyword_supplied_as_keyword_arguments, token=tokens_and_force_positional[0][0], ) tokens_per_element, consume_all = argument.token_count() tokens_per_element = max(1, tokens_per_element) if consume_all and argument.field_info.kind is POSITIONAL_ONLY: # POSITIONAL_ONLY parameters can come after a POSITIONAL_ONLY list/iterable. # This makes it easier to create programs that do something like: # $ python my-program.py input_folder/*.csv output.csv # Need to see how many tokens we need to leave for subsequent POSITIONAL_ONLY parameters. n_tokens_to_leave = _future_positional_only_token_count(argument_collection, i + 1) # Cap at the contiguous positional count to prevent consuming tokens # that appeared after keyword arguments (issue #763). if contiguous_positional_count is not None: n_tokens_to_leave = max( n_tokens_to_leave, len(tokens_and_force_positional) - contiguous_positional_count ) else: n_tokens_to_leave = 0 new_tokens = [] while (len(tokens_and_force_positional) - n_tokens_to_leave) > 0: if (len(tokens_and_force_positional) - n_tokens_to_leave) < tokens_per_element: raise MissingArgumentError( argument=argument, tokens_so_far=[x[0] for x in tokens_and_force_positional], ) for index, (token, force_positional) in enumerate(tokens_and_force_positional[:tokens_per_element]): if not force_positional and not argument.parameter.allow_leading_hyphen and is_option_like(token): raise UnknownOptionError(token=CliToken(value=token), argument_collection=argument_collection) new_tokens.append(CliToken(value=token, index=index)) tokens_and_force_positional = tokens_and_force_positional[tokens_per_element:] if not consume_all: break argument.tokens[:0] = new_tokens # Prepend the new tokens to the argument. if not tokens_and_force_positional: break return [x[0] for x in tokens_and_force_positional] def _parse_env(argument_collection: ArgumentCollection): for argument in argument_collection: if argument.tokens: # Don't check environment variables for parameters that already have values from CLI. continue assert argument.parameter.env_var is not None for env_var_name in argument.parameter.env_var: try: env_var_value = os.environ[env_var_name] except KeyError: pass else: argument.tokens.append(Token(keyword=env_var_name, value=env_var_value, source="env")) break def _bind( argument_collection: ArgumentCollection, func: Callable, ): """Bind the mapping to the function signature.""" bound = inspect.signature(func).bind_partial() for argument in argument_collection._root_arguments: if argument.value is not UNSET: bound.arguments[argument.field_info.name] = argument.value return bound def _parse_configs(argument_collection: ArgumentCollection, configs): for config in configs: # Each ``config`` is a partial that already has apps and commands provided. config(argument_collection) def _sort_group(argument_collection) -> list[tuple["Group", ArgumentCollection]]: """Sort groups into "deepest common-root-keys first" order. This is imperfect, but probably works sufficiently well for practical use-cases. """ out = {} # Sort alphabetically by group-name to enfroce some determinism. for i, group in enumerate(sorted(argument_collection.groups, key=lambda x: x.name)): group_arguments = argument_collection.filter_by(group=group) common_root_keys = _common_root_keys(group_arguments) # Add i to key so that we don't get collisions. out[(common_root_keys, i)] = (group, group_arguments.filter_by(keys_prefix=common_root_keys)) return [ga for _, ga in sorted(out.items(), reverse=True)] def create_bound_arguments( func: Callable, argument_collection: ArgumentCollection, tokens: list[str], configs: Iterable[Callable], *, end_of_options_delimiter: str = "--", ) -> tuple[inspect.BoundArguments, list[str]]: """Parse and coerce CLI tokens to match a function's signature. Parameters ---------- func: Callable Function. argument_collection: ArgumentCollection tokens: list[str] CLI tokens to parse and coerce to match ``f``'s signature. configs: Iterable[Callable] end_of_options_delimiter: str Everything after this special token is forced to be supplied as a positional argument. Returns ------- bound: inspect.BoundArguments The converted and bound positional and keyword arguments for ``f``. unused_tokens: list[str] Remaining tokens that couldn't be matched to ``f``'s signature. """ unused_tokens = tokens try: unused_tokens, contiguous_positional_count = _parse_kw_and_flags( argument_collection, unused_tokens, end_of_options_delimiter=end_of_options_delimiter ) unused_tokens = _parse_pos( argument_collection, unused_tokens, end_of_options_delimiter=end_of_options_delimiter, contiguous_positional_count=contiguous_positional_count, ) _parse_env(argument_collection) _parse_configs(argument_collection, configs) argument_collection._convert() groups_with_arguments = _sort_group(argument_collection) try: for group, group_arguments in groups_with_arguments: for validator in group.validator: # pyright: ignore validator(group_arguments) # pyright: ignore[reportOptionalCall] except (AssertionError, ValueError, TypeError) as e: raise ValidationError(exception_message=e.args[0] if e.args else "", group=group) from e # pyright: ignore for argument in argument_collection: # if a dict-like argument is missing, raise a MissingArgumentError on the first # required child (as opposed generically to the root dict-like object). if argument.parse and argument.field_info.required and not argument.keys and not argument.has_tokens: raise MissingArgumentError(argument=argument) bound = _bind(argument_collection, func) except CycloptsError as e: e.root_input_tokens = tokens e.unused_tokens = unused_tokens raise return bound, unused_tokens BrianPugh-cyclopts-921b1fa/cyclopts/cli/000077500000000000000000000000001517576204000202545ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/cli/__init__.py000066400000000000000000000006211517576204000223640ustar00rootroot00000000000000"""Cyclopts CLI implementation.""" import cyclopts # Create the main CLI app app = cyclopts.App(name="cyclopts") app.register_install_completion_command( help="""\ Register shell-completion for the cyclopts CLI itself. """ ) # Explicitly import command modules from cyclopts.cli import ( _complete, # noqa: F401 docs, # noqa: F401 run, # noqa: F401 ) __all__ = ["app"] BrianPugh-cyclopts-921b1fa/cyclopts/cli/_complete.py000066400000000000000000000101571517576204000226010ustar00rootroot00000000000000"""Hidden completion helper command for dynamic shell completion.""" from pathlib import Path from typing import TYPE_CHECKING, Annotated from cyclopts.cli import app from cyclopts.loader import load_app_from_script from cyclopts.parameter import Parameter if TYPE_CHECKING: from cyclopts import App MAX_DESCRIPTION_LENGTH = 60 def _extract_short_description(help_text: str | None) -> str: """Extract first line of help text as a short description. Parameters ---------- help_text : str | None Full help text to extract from. Returns ------- str First line of help text, or empty string if parsing fails. """ from cyclopts.help.help import docstring_parse try: parsed = docstring_parse(help_text, "plaintext") return parsed.short_description or "" except Exception: return str(help_text or "").split("\n")[0] def _print_subcommand_completions(app_obj: "App") -> None: """Print completions for subcommands of the given app. Parameters ---------- app_obj : App Application object to extract subcommands from. """ from cyclopts.group_extractors import groups_from_app for _, registered_commands in groups_from_app(app_obj): for registered_command in registered_commands: if registered_command.app.show: for name in registered_command.names: if not name.startswith("-"): short_desc = _extract_short_description(registered_command.app.help) if short_desc: print(f"{name}:{short_desc}") else: print(name) def _print_option_completions(app_obj: "App") -> None: """Print completions for options of the given app's default command. Parameters ---------- app_obj : App Application object to extract options from. """ if not app_obj.default_command: return try: arguments = app_obj.assemble_argument_collection(parse_docstring=True) for argument in arguments: if not argument.is_positional_only() and argument.show: for name in argument.names: if name.startswith("-"): desc = argument.parameter.help or "" desc = desc.split("\n")[0][:MAX_DESCRIPTION_LENGTH] if desc: print(f"{name}:{desc}") else: print(name) except Exception: pass @app.command(name="_complete", show=False) def complete( subcommand: Annotated[str, Parameter(allow_leading_hyphen=True)], script: Annotated[Path, Parameter(allow_leading_hyphen=True)], *words: Annotated[str, Parameter(allow_leading_hyphen=True)], ) -> None: """Internal completion helper (hidden from users). This command is called by the shell completion system to dynamically generate completions for the 'run' command by loading the target script and extracting its available commands and options. Parameters ---------- subcommand : str The cyclopts subcommand being completed (e.g., "run"). script : Path Python script path to load for completion extraction. words : str Current command line words for context-aware completion. """ if subcommand != "run": return try: app_obj, _ = load_app_from_script(script) except (ImportError, SyntaxError, AttributeError, FileNotFoundError): return words_list = list(words) if words else [] # Complete from root app if no words or only empty string (initial completion) if not words_list or (len(words_list) == 1 and not words_list[0]): _print_subcommand_completions(app_obj) _print_option_completions(app_obj) else: try: _, execution_path, _ = app_obj.parse_commands(words_list) current_app = execution_path[-1] _print_subcommand_completions(current_app) _print_option_completions(current_app) except Exception: pass BrianPugh-cyclopts-921b1fa/cyclopts/cli/docs.py000066400000000000000000000075121517576204000215630ustar00rootroot00000000000000"""Generate documentation for Cyclopts applications.""" from pathlib import Path from typing import Annotated from cyclopts.cli import app from cyclopts.docs.types import ( FORMAT_ALIASES, DocFormat, normalize_format, ) from cyclopts.group import Group from cyclopts.loader import load_app_from_script from cyclopts.parameter import Parameter from cyclopts.utils import UNSET def _format_group_validator(argument_collection): format_arg = argument_collection.get("--format") output_arg = argument_collection.get("--output") if format_arg.value is UNSET: if output_arg.value is UNSET: raise ValueError('"--format" must be specified when output path is not provided.') if not output_arg.value or not hasattr(output_arg.value, "suffix"): raise ValueError('"--output" must be a valid file path when format is not specified.') suffix = output_arg.value.suffix.lower() if not suffix: raise ValueError( "Output file must have an extension to infer format (e.g., .md, .html, .rst). " 'Please specify "--format" explicitly or add an extension to the output file.' ) # Strip the leading period from suffix to look up in FORMAT_ALIASES suffix_key = suffix.lstrip(".") if not suffix_key: raise ValueError( "Invalid file extension. Output file must have a valid extension after the period. " 'Please specify "--format" explicitly.' ) inferred_format = FORMAT_ALIASES.get(suffix_key) if inferred_format is None: supported_extensions = [f".{ext}" for ext in FORMAT_ALIASES.keys() if len(ext) <= 4] raise ValueError( f'Cannot infer format from output extension "{suffix}". ' f"Supported extensions: {', '.join(sorted(set(supported_extensions)))}. " f'Please specify "--format" explicitly.' ) format_arg.value = inferred_format format_group = Group(validator=_format_group_validator) @app.command(default_parameter=Parameter(negative="")) def generate_docs( script: str, output: Annotated[Path | None, Parameter(alias="-o", group=format_group)] = None, *, format: Annotated[ DocFormat | None, Parameter(alias="-f", group=format_group), ] = None, include_hidden: bool = False, heading_level: int = 1, usage_name: str | None = None, ): """Generate documentation for a Cyclopts application. Parameters ---------- script : str Python script path, optionally with ``':app_object'`` notation to specify the App object. If not specified, will search for App objects in the script's global namespace. output : Optional[Path] Output file path. If not specified, prints to stdout. format : Optional[DocFormat] Output format for documentation. If not specified, inferred from output file extension. include_hidden : bool Include hidden commands in documentation. heading_level : int Starting heading level for markdown format. usage_name : Optional[str] Replace the app name shown in ``Usage:`` lines with this string. For example, ``"uv run cli"`` for an app whose runtime name is ``"cli"``. Headings and anchors are unaffected. Default is None. """ if format is None: # Handled by _format_group_validator raise ValueError("Must specify format.") format = normalize_format(format) app_obj, _ = load_app_from_script(script) docs_content = app_obj.generate_docs( output_format=format, include_hidden=include_hidden, heading_level=heading_level, usage_name=usage_name, ) if output: output.write_text(docs_content) else: print(docs_content) BrianPugh-cyclopts-921b1fa/cyclopts/cli/run.py000066400000000000000000000022621517576204000214340ustar00rootroot00000000000000"""Run Cyclopts applications from Python scripts.""" from pathlib import Path from typing import Annotated from cyclopts.cli import app from cyclopts.loader import load_app_from_script from cyclopts.parameter import Parameter @app.command(help_flags="") def run( script: Annotated[ Path, Parameter(allow_leading_hyphen=True), ], /, *args: Annotated[str, Parameter(allow_leading_hyphen=True)], ): """Run a Cyclopts application from a Python script with dynamic shell completion. All arguments after the script path are passed to the loaded application. Shell completion is available. Run once to install (persistent): ``cyclopts --install-completion`` Parameters ---------- script : str Python script path with optional ':app_object' notation. args : str Arguments to pass to the loaded application. Examples -------- Run a script: cyclopts run myapp.py --verbose foo bar Specify app object: cyclopts run myapp.py:app --help """ if str(script) in app.help_flags: app.help_print() return app_obj, _ = load_app_from_script(script) return app_obj(args) BrianPugh-cyclopts-921b1fa/cyclopts/command_spec.py000066400000000000000000000153041517576204000225120ustar00rootroot00000000000000"""Lazy-loadable command specification for deferred imports.""" import importlib from itertools import chain from typing import TYPE_CHECKING, Any from attrs import Factory, define, field if TYPE_CHECKING: from cyclopts.core import App from cyclopts.group import Group @define class CommandSpec: """Specification for a command that will be lazily loaded on first access. This allows registering commands via import path strings (e.g., "myapp.commands:create") without importing them until they're actually used, improving CLI startup time. Parameters ---------- import_path : str Import path in the format "module.path:attribute_name". The attribute should be either a function or an App instance. name : str | tuple[str, ...] | None CLI command name. If None, will be derived from the attribute name via name_transform. For function imports: used as the name of the wrapper App. For App imports: must match the App's internal name, or ValueError is raised at resolution. app_kwargs : dict Keyword arguments to pass to App() if wrapping a function. Raises ValueError if used with App imports (Apps should be configured in their own definition). Examples -------- >>> from cyclopts import App >>> app = App() >>> # Lazy load - doesn't import myapp.commands until "create" is executed >>> app.command("myapp.commands:create_user", name="create") >>> app() """ import_path: str name: str | tuple[str, ...] | None = None app_kwargs: dict[str, Any] = Factory(dict) help: str | None = None sort_key: Any = None group: "Group | str | tuple[Group | str, ...] | None" = None _show: bool | None = field(default=None, alias="show") @property def show(self) -> bool: if self._show is None: return True return self._show _resolved: "App | None" = field(init=False, default=None, repr=False) def resolve(self, parent_app: "App") -> "App": """Import and resolve the command on first access. Parameters ---------- parent_app : App Parent app to inherit defaults from (help_flags, version_flags, groups). Required to match the behavior of direct command registration. Returns ------- App The resolved App instance, either imported directly or wrapping a function. Raises ------ ValueError If import_path is not in the correct format "module.path:attribute_name". ImportError If the module cannot be imported. AttributeError If the attribute doesn't exist in the module. """ if self._resolved is not None: return self._resolved # Parse import path module_path, _, attr_name = self.import_path.rpartition(":") if not module_path or not attr_name: raise ValueError( f"Invalid import path: {self.import_path!r}. Expected format: 'module.path:attribute_name'" ) # Import the module and get the attribute try: module = importlib.import_module(module_path) except ImportError as e: raise ImportError(f"Cannot import module {module_path!r} from {self.import_path!r}") from e try: target = getattr(module, attr_name) except AttributeError as e: raise AttributeError( f"Module {module_path!r} has no attribute {attr_name!r} (from import path {self.import_path!r})" ) from e # Wrap in App if needed from cyclopts.core import App if isinstance(target, App): # Validate that no kwargs were provided for App imports if self.app_kwargs: raise ValueError( f"Cannot apply configuration to imported App. " f"Import path {self.import_path!r} resolves to an App, " f"but kwargs were specified: {self.app_kwargs!r}. " f"Configure the App in its definition instead." ) # Validate that the App's name matches the expected CLI command name # The name used for CLI registration is stored in self.name if self.name is not None and target.name[0] != self.name: raise ValueError( f"Imported App name mismatch. " f"Import path {self.import_path!r} resolves to an App with name={target.name[0]!r}, " f"but it was registered with CLI command name={self.name!r}. " f"Either use app.command('{self.import_path}', name='{target.name[0]}') " f"or change the App's name to match." ) # Copy parent groups if not set (matches direct App registration behavior) from cyclopts.core import _apply_parent_defaults_to_app _apply_parent_defaults_to_app(target, parent_app) self._resolved = target else: # It's a function - wrap it in an App with parent defaults # Match the behavior of direct function registration app_kwargs = dict(self.app_kwargs) # Copy to avoid mutating from cyclopts.core import _apply_parent_groups_to_kwargs app_kwargs.setdefault("help_flags", parent_app.help_flags) app_kwargs.setdefault("version_flags", parent_app.version_flags) if "version" not in app_kwargs and parent_app.version is not None: app_kwargs["version"] = parent_app.version _apply_parent_groups_to_kwargs(app_kwargs, parent_app) self._resolved = App(name=self.name, **app_kwargs) self._resolved.default(target) # Apply registration-time overrides to the resolved App if self.help is not None: self._resolved.help = self.help if self.sort_key is not None: self._resolved.sort_key = self.sort_key if self.group is not None: self._resolved.group = self.group if self._show is not None: self._resolved.show = self._show if self._resolved._name_transform is None: self._resolved.name_transform = parent_app.name_transform # Hide help and version flags from subapp help output # This matches the behavior of direct App/function registration in core.py for flag in chain(self._resolved.help_flags, self._resolved.version_flags): self._resolved[flag].show = False return self._resolved @property def is_resolved(self) -> bool: """Check if this command has been imported and resolved yet.""" return self._resolved is not None BrianPugh-cyclopts-921b1fa/cyclopts/completion/000077500000000000000000000000001517576204000216565ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/completion/__init__.py000066400000000000000000000005221517576204000237660ustar00rootroot00000000000000"""Shell completion generation for Cyclopts applications.""" from cyclopts.completion.detect import ShellDetectionError, detect_shell from cyclopts.completion.install import add_to_rc_file, get_default_completion_path __all__ = [ "detect_shell", "ShellDetectionError", "get_default_completion_path", "add_to_rc_file", ] BrianPugh-cyclopts-921b1fa/cyclopts/completion/_base.py000066400000000000000000000150311517576204000233010ustar00rootroot00000000000000"""Shared shell completion infrastructure. Provides data extraction, type analysis, and text processing utilities. """ import os import re import warnings from enum import Enum from pathlib import Path from typing import TYPE_CHECKING, Any, get_args, get_origin from cyclopts.annotations import ITERABLE_TYPES, is_annotated, is_union from cyclopts.argument import ArgumentCollection from cyclopts.exceptions import CycloptsError from cyclopts.group_extractors import RegisteredCommand, groups_from_app from cyclopts.utils import frozen, is_class_and_subclass if TYPE_CHECKING: from cyclopts import App class CompletionAction(Enum): """Shell-agnostic completion action types.""" NONE = "none" FILES = "files" DIRECTORIES = "directories" @frozen class CompletionData: """Completion data for a command path.""" arguments: "ArgumentCollection" commands: list[RegisteredCommand] help_format: str def extract_completion_data(app: "App") -> dict[tuple[str, ...], CompletionData]: """Recursively extract completion data for app and all subcommands. Parameters ---------- app : App The Cyclopts application to extract completion data from. Returns ------- dict[tuple[str, ...], CompletionData] Mapping from command path tuples to their completion data. """ completion_data: dict[tuple[str, ...], CompletionData] = {} def _extract(command_path: tuple[str, ...] = ()): """Recursively extract completion data for command and subcommands.""" try: _, execution_path, _ = app.parse_commands(list(command_path)) command_app = execution_path[-1] except (CycloptsError, ValueError, TypeError) as e: if os.environ.get("CYCLOPTS_COMPLETION_DEBUG"): raise warnings.warn(f"Failed to extract completion data for command path {command_path!r}: {e}", stacklevel=2) help_format = app.app_stack.resolve("help_format", fallback="markdown") completion_data[command_path] = CompletionData( arguments=ArgumentCollection(), commands=[], help_format=help_format ) return from cyclopts.core import _iter_resolution_argument_collections arguments = ArgumentCollection() with app.app_stack(execution_path): for _, app_arguments in _iter_resolution_argument_collections(execution_path, parse_docstring=True): arguments.extend(app_arguments) commands = [] for group, registered_commands in groups_from_app(command_app, resolve_lazy=True): if group.show: for registered_command in registered_commands: if registered_command.app.show and registered_command not in commands: commands.append(registered_command) help_format = command_app.app_stack.resolve("help_format", fallback="markdown") completion_data[command_path] = CompletionData(arguments=arguments, commands=commands, help_format=help_format) for registered_command in commands: for cmd_name in registered_command.names: if not cmd_name.startswith("-"): _extract(command_path + (cmd_name,)) _extract() return completion_data def get_completion_action(type_hint: Any) -> CompletionAction: """Get completion action from type hint. Parameters ---------- type_hint : Any Type annotation. Returns ------- CompletionAction Completion action for type. """ if is_annotated(type_hint): return get_completion_action(get_args(type_hint)[0]) if is_union(type_hint): for arg in get_args(type_hint): if arg is not type(None): action = get_completion_action(arg) if action != CompletionAction.NONE: return action return CompletionAction.NONE origin = get_origin(type_hint) # For collection types, unwrap to get element type if is_class_and_subclass(origin, tuple(ITERABLE_TYPES)): args = get_args(type_hint) if args and len(args) >= 1: # list[Path], set[Path], tuple[Path, ...] -> check first arg return get_completion_action(args[0]) target_type = origin or type_hint if target_type is Path or is_class_and_subclass(target_type, Path): return CompletionAction.FILES return CompletionAction.NONE def clean_choice_text(text: str) -> str: """Clean choice text without shell-specific escaping. Parameters ---------- text : str Raw choice text. Returns ------- str Cleaned text (not shell-escaped). """ text = re.sub(r"[\x00-\x1f\x7f]", "", text) text = re.sub(r"\s+", " ", text).strip() return text def escape_for_shell_pattern(name: str, chars: str = "*?[]") -> str: """Escape glob/pattern characters for shell case patterns. Both bash and zsh case patterns treat glob characters as special even inside quotes. This function escapes them with backslashes for literal matching. Parameters ---------- name : str String to escape. chars : str Characters to escape. Default covers basic glob chars. For zsh, also pass "()|" for extended patterns. Returns ------- str Escaped string safe for shell case patterns. """ # Escape backslashes first to avoid double-escaping result = name.replace("\\", "\\\\") for char in chars: result = result.replace(char, f"\\{char}") return result def strip_markup(text: str, format: str = "markdown", max_length: int = 80) -> str: """Strip markup and render to plain text for shell completions. Converts formatted text (markdown/RST/rich) to plain text suitable for shell completion descriptions. Removes control characters, normalizes whitespace, and truncates if needed. Parameters ---------- text : str Text with markup. format : str Markup format: "markdown", "rst", "rich", or "plaintext". max_length : int Maximum length before truncation. Returns ------- str Plain text (not shell-escaped). """ from cyclopts._markup import extract_text from cyclopts.help.inline_text import InlineText inline = InlineText.from_format(text, format=format) text = extract_text(inline) text = re.sub(r"[\x00-\x1f\x7f]", "", text) text = re.sub(r"\s+", " ", text).strip() if len(text) > max_length: text = text[: max_length - 1] + "…" return text BrianPugh-cyclopts-921b1fa/cyclopts/completion/bash.py000066400000000000000000000501601517576204000231470ustar00rootroot00000000000000"""Bash completion script generator. Generates static bash completion scripts using COMPREPLY and compgen. Targets bash 3.2+ with no external dependencies. """ import re from typing import TYPE_CHECKING from cyclopts.annotations import is_iterable_type from cyclopts.completion._base import ( CompletionAction, CompletionData, clean_choice_text, escape_for_shell_pattern, extract_completion_data, get_completion_action, ) if TYPE_CHECKING: from cyclopts import App def generate_completion_script(app: "App", prog_name: str) -> str: """Generate bash completion script. Parameters ---------- app : App The Cyclopts application to generate completion for. prog_name : str Program name (alphanumeric with hyphens/underscores). Returns ------- str Complete bash completion script. Raises ------ ValueError If prog_name contains invalid characters. """ if not prog_name or not re.match(r"^[a-zA-Z0-9_-]+$", prog_name): raise ValueError(f"Invalid prog_name: {prog_name!r}. Must be alphanumeric with hyphens/underscores.") func_name = prog_name.replace("-", "_") completion_data = extract_completion_data(app) lines = [ f"# Bash completion for {prog_name}", "# Generated by Cyclopts", "", f"_{func_name}() {{", " local cur prev", "", ] lines.extend(_generate_completion_function_body(completion_data, prog_name, app)) lines.extend(["}"]) lines.append("") lines.append(f"complete -F _{func_name} {prog_name}") lines.append("") return "\n".join(lines) def _escape_bash_choice(choice: str) -> str: r"""Escape a choice for embedding inside a bash double-quoted string. Choices live inside ``local -a _c=("..." "...")`` arrays in the generated script (not as ``compgen -W`` whitespace-split tokens), so the only characters the shell still interprets are ``\``, ``"``, ``$`` and backtick. """ choice = choice.replace("\\", "\\\\") choice = choice.replace('"', '\\"') choice = choice.replace("$", "\\$") choice = choice.replace("`", "\\`") return choice def _emit_choice_completion(choices: list[str], indent: str) -> list[str]: """Emit bash that prefix-matches ``$cur`` against an array of choices. Avoids ``compgen -W`` whitespace tokenization, which mangles choices containing spaces, single quotes, or characters re-parsed by the surrounding ``$(...)`` (e.g. backticks). Parameters ---------- choices : list[str] Raw choice strings (already cleaned via ``clean_choice_text``). indent : str Indentation prefix. Returns ------- list[str] Bash code lines. """ escaped = [_escape_bash_choice(c) for c in choices] array_body = " ".join(f'"{c}"' for c in escaped) return [ f"{indent}local -a _c=({array_body})", f"{indent}COMPREPLY=()", f'{indent}for _x in "${{_c[@]}}"; do', f'{indent} [[ "$_x" == "${{cur}}"* ]] && COMPREPLY+=("$_x")', f"{indent}done", ] def _escape_bash_description(text: str) -> str: r"""Escape description text for bash comments.""" text = text.replace("\n", " ") text = text.replace("\r", " ") return text def _map_completion_action_to_bash(action: CompletionAction) -> str: """Map completion action to bash compgen flags. Parameters ---------- action : CompletionAction Completion action type. Returns ------- str Compgen flags ("-f", "-d", or ""). """ if action == CompletionAction.FILES: return "-f" elif action == CompletionAction.DIRECTORIES: return "-d" return "" def _generate_completion_function_body( completion_data: dict[tuple[str, ...], CompletionData], prog_name: str, app: "App", ) -> list[str]: """Generate the body of the bash completion function. Parameters ---------- completion_data : dict All extracted completion data. prog_name : str Program name. app : App Application instance. Returns ------- list[str] Lines of bash code for the completion function body. """ lines = [] lines.append(' cur="${COMP_WORDS[COMP_CWORD]}"') lines.append(' prev="${COMP_WORDS[COMP_CWORD-1]}"') lines.append("") lines.extend(_generate_command_path_detection(completion_data)) lines.append("") lines.extend(_generate_completion_logic(completion_data, prog_name, app)) return lines def _generate_command_path_detection(completion_data: dict[tuple[str, ...], CompletionData]) -> list[str]: """Generate bash code to detect the current command path. This function generates two passes through COMP_WORDS: 1. First pass builds cmd_path by identifying valid command names 2. Second pass counts positionals (non-option words after the command path) The two-pass approach is necessary because we need to know the full command path length before we can correctly identify which words are positionals. Note: all_commands is built globally across all command levels. If a positional argument value happens to match a command name from a different level, it could be incorrectly classified (though this represents poor CLI design). Parameters ---------- completion_data : dict All extracted completion data. Returns ------- list[str] Lines of bash code for command path detection. """ options_with_values = set() all_commands = set() for data in completion_data.values(): for argument in data.arguments: if not argument.is_flag() and argument.parameter.name: for name in argument.parameter.name: if name.startswith("-"): options_with_values.add(name) for registered_command in data.commands: for cmd_name in registered_command.names: if not cmd_name.startswith("-"): all_commands.add(cmd_name) lines = [] lines.append(" # Build list of options that take values (to skip their arguments)") if options_with_values: # Option/command names are constrained to ``-``, ``_``, and alphanumerics, # so they cannot contain characters that need shell escaping. opts_str = " ".join(sorted(options_with_values)) lines.append(f" local options_with_values='{opts_str}'") else: lines.append(" local options_with_values=''") lines.append("") lines.append(" # Build list of all valid command names (to distinguish from positionals)") if all_commands: cmds_str = " ".join(sorted(all_commands)) lines.append(f" local all_commands='{cmds_str}'") else: lines.append(" local all_commands=''") lines.append("") lines.append(" # Detect command path by collecting valid command words only") lines.append(" local -a cmd_path=()") lines.append(" local i skip_next=0") lines.append(" for ((i=1; i list[str]: """Generate the main completion logic using case statements. Parameters ---------- completion_data : dict All extracted completion data. prog_name : str Program name. app : App Application instance. Returns ------- list[str] Lines of bash code for completion logic. """ lines = [] help_flags = tuple(app.help_flags) if app.help_flags else () version_flags = tuple(app.version_flags) if app.version_flags else () lines.append(" # Determine command level and generate completions") lines.append(' case "${#cmd_path[@]}" in') max_depth = max(len(path) for path in completion_data.keys()) for depth in range(max_depth + 1): relevant_paths = [path for path in completion_data.keys() if len(path) == depth] if not relevant_paths: continue lines.append(f" {depth})") if depth == 0: lines.extend(_generate_completions_for_path(completion_data, (), " ", help_flags, version_flags)) else: lines.append(' case "${cmd_path[@]}" in') for path in sorted(relevant_paths): # Escape glob characters in command names for case pattern matching escaped_path = [escape_for_shell_pattern(cmd) for cmd in path] path_str = " ".join(escaped_path) lines.append(f' "{path_str}")') lines.extend( _generate_completions_for_path(completion_data, path, " ", help_flags, version_flags) ) lines.append(" ;;") lines.append(" *)") lines.append(" ;;") lines.append(" esac") lines.append(" ;;") lines.append(" *)") lines.append(" ;;") lines.append(" esac") return lines def _generate_completions_for_path( completion_data: dict[tuple[str, ...], CompletionData], command_path: tuple[str, ...], indent: str, help_flags: tuple[str, ...], version_flags: tuple[str, ...], ) -> list[str]: """Generate completions for a specific command path. Parameters ---------- completion_data : dict All extracted completion data. command_path : tuple[str, ...] Current command path. indent : str Indentation string. help_flags : tuple[str, ...] Help flag names. version_flags : tuple[str, ...] Version flag names. Returns ------- list[str] Lines of bash code for completions at this command path. """ if command_path not in completion_data: return [f"{indent}COMPREPLY=()"] data = completion_data[command_path] lines = [] options = [] keyword_args = [arg for arg in data.arguments if not arg.is_positional_only() and arg.show] for argument in keyword_args: for name in argument.parameter.name or []: if name.startswith("-"): options.append(name) for name in argument.negatives: if name.startswith("-"): options.append(name) flag_commands = [] for registered_command in data.commands: for name in registered_command.names: if name.startswith("-"): flag_commands.append(name) for flag in help_flags: if flag.startswith("-") and flag not in options and flag not in flag_commands: options.append(flag) for flag in version_flags: if flag.startswith("-") and flag not in options and flag not in flag_commands: options.append(flag) options.extend(flag_commands) commands = [] for registered_command in data.commands: for cmd_name in registered_command.names: if not cmd_name.startswith("-"): commands.append(cmd_name) positional_args = [arg for arg in data.arguments if arg.index is not None and arg.show] positional_args.sort(key=lambda a: a.index if a.index is not None else 0) lines.append(f"{indent}if [[ ${{cur}} == -* ]]; then") if options: lines.extend(_emit_choice_completion(options, f"{indent} ")) else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent}else") needs_value_completion = _check_if_prev_needs_value(data.arguments) if needs_value_completion: value_completion_lines = _generate_value_completion_for_prev( data.arguments, commands, positional_args, f"{indent} " ) lines.extend(value_completion_lines) elif commands: lines.extend(_emit_choice_completion(commands, f"{indent} ")) elif positional_args: lines.extend(_generate_positional_completion(positional_args, f"{indent} ")) else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent}fi") return lines def _generate_positional_completion(positional_args, indent: str) -> list[str]: """Generate position-aware positional argument completion. Parameters ---------- positional_args : list List of positional arguments sorted by index. indent : str Indentation string. Returns ------- list[str] Lines of bash code for position-aware positional completion. """ lines = [] def _emit_one(argument, body_indent: str) -> list[str]: choices = argument.get_choices(force=True) if choices: cleaned = [clean_choice_text(c) for c in choices] return _emit_choice_completion(cleaned, body_indent) compgen_flag = _map_completion_action_to_bash(get_completion_action(argument.hint)) if compgen_flag: return [f'{body_indent}COMPREPLY=( $(compgen {compgen_flag} -- "${{cur}}") )'] return [f"{body_indent}COMPREPLY=()"] # An iterable positional (``list[X]``, ``set[X]``, or ``*args``) greedily # consumes all remaining positions starting at its index. The args that # follow it in ``positional_args`` are positional-or-keyword-with-default # entries that can still be filled via their ``--name`` keyword forms but # never end up at a later positional slot. Picking the rest-owner here: # prefer the actual var-positional, otherwise the first iterable. rest_idx = None for i, arg in enumerate(positional_args): if arg.is_var_positional(): rest_idx = i break if rest_idx is None: for i, arg in enumerate(positional_args): if is_iterable_type(arg.hint): rest_idx = i break # Numbered cases only for positions strictly before the rest-owner. head = positional_args if rest_idx is None else positional_args[:rest_idx] rest_owner = None if rest_idx is None else positional_args[rest_idx] if not head and rest_owner is not None: # Rest-owner at index 0 — no case statement needed; the iterable # answers every position. lines.extend(_emit_one(rest_owner, indent)) elif len(head) == 1 and rest_owner is None: # Single non-iterable positional — simple case. lines.extend(_emit_one(head[0], indent)) else: lines.append(f"{indent}case ${{positional_count}} in") for idx, argument in enumerate(head): lines.append(f"{indent} {idx})") lines.extend(_emit_one(argument, f"{indent} ")) lines.append(f"{indent} ;;") lines.append(f"{indent} *)") if rest_owner is not None: lines.extend(_emit_one(rest_owner, f"{indent} ")) else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent} ;;") lines.append(f"{indent}esac") return lines def _check_if_prev_needs_value(arguments) -> bool: """Check if any options take values, requiring prev-word completion logic. Parameters ---------- arguments : ArgumentCollection Arguments to check. Returns ------- bool True if any option (starts with -) takes a value (is not a flag). """ for argument in arguments: if not argument.is_flag(): for name in argument.parameter.name or []: if name.startswith("-"): return True return False def _generate_value_completion_for_prev(arguments, commands: list[str], positional_args, indent: str) -> list[str]: """Generate value completion based on previous word. Parameters ---------- arguments : ArgumentCollection Arguments with potential values. commands : list[str] Available commands at this level. positional_args : list List of positional arguments sorted by index. indent : str Indentation string. Returns ------- list[str] Lines of bash code for value completion. """ lines = [] # Real interactive bash treats ``=`` as a COMP_WORDBREAK, so # ``--opt=value`` tokenizes to ``--opt`` ``=`` ``value`` and ``$prev`` # ends up as ``=``. Resolve through the equals sign to the actual option # name two slots back so the dispatch case below works for both forms. lines.append(f'{indent}local _value_prev="${{prev}}"') lines.append(f'{indent}if [[ "$_value_prev" == "=" && $COMP_CWORD -ge 2 ]]; then') lines.append(f'{indent} _value_prev="${{COMP_WORDS[COMP_CWORD-2]}}"') lines.append(f"{indent}fi") lines.append(f'{indent}case "$_value_prev" in') has_cases = False for argument in arguments: if argument.is_flag(): continue names = [name for name in (argument.parameter.name or []) if name.startswith("-")] if not names: continue has_cases = True choices = argument.get_choices(force=True) action = get_completion_action(argument.hint) for name in names: lines.append(f"{indent} {name})") if choices: cleaned = [clean_choice_text(c) for c in choices] lines.extend(_emit_choice_completion(cleaned, f"{indent} ")) else: compgen_flag = _map_completion_action_to_bash(action) if compgen_flag: lines.append(f'{indent} COMPREPLY=( $(compgen {compgen_flag} -- "${{cur}}") )') else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent} ;;") if has_cases: lines.append(f"{indent} *)") if commands: lines.extend(_emit_choice_completion(commands, f"{indent} ")) elif positional_args: lines.extend(_generate_positional_completion(positional_args, f"{indent} ")) else: lines.append(f"{indent} COMPREPLY=()") lines.append(f"{indent} ;;") lines.append(f"{indent}esac") else: lines = [] if commands: lines.extend(_emit_choice_completion(commands, indent)) elif positional_args: lines.extend(_generate_positional_completion(positional_args, indent)) else: lines.append(f"{indent}COMPREPLY=()") return lines BrianPugh-cyclopts-921b1fa/cyclopts/completion/detect.py000066400000000000000000000047641517576204000235130ustar00rootroot00000000000000"""Shell detection utilities for completion generation. This module provides functionality to detect the current shell type by inspecting environment variables. This is useful for dynamically generating appropriate completion scripts for different shell environments. """ import os import subprocess from pathlib import Path from typing import Literal class ShellDetectionError(Exception): """Raised when the shell type cannot be detected.""" def _extract_shell_name(shell_string: str) -> Literal["zsh", "bash", "fish"] | None: """Extract shell name from a string (path or process name). Parameters ---------- shell_string : str String that may contain a shell name (e.g., "/bin/bash", "zsh", "-bash"). Returns ------- Literal["zsh", "bash", "fish"] | None The detected shell type, or None if not recognized. """ shell_lower = shell_string.lower() if "zsh" in shell_lower: return "zsh" elif "bash" in shell_lower: return "bash" elif "fish" in shell_lower: return "fish" return None def detect_shell() -> Literal["zsh", "bash", "fish"]: """Detect the current shell type using multiple detection methods. Returns ------- Literal["zsh", "bash", "fish"] The detected shell type. Raises ------ ShellDetectionError If the shell type cannot be determined from any detection method. Examples -------- >>> shell = detect_shell() # doctest: +SKIP >>> print(f"Detected shell: {shell}") # doctest: +SKIP Detected shell: bash """ if os.environ.get("ZSH_VERSION"): return "zsh" elif os.environ.get("BASH_VERSION"): return "bash" elif os.environ.get("FISH_VERSION"): return "fish" try: ppid = os.getppid() result = subprocess.run( ["ps", "-p", str(ppid), "-o", "comm="], capture_output=True, text=True, timeout=1, ) if result.returncode == 0 and result.stdout: parent_process = result.stdout.strip() shell = _extract_shell_name(parent_process) if shell: return shell except (subprocess.SubprocessError, FileNotFoundError, OSError): pass shell_path = os.environ.get("SHELL", "") if shell_path: shell_name = Path(shell_path).name shell = _extract_shell_name(shell_name) if shell: return shell raise ShellDetectionError("Unable to detect shell type.") BrianPugh-cyclopts-921b1fa/cyclopts/completion/fish.py000066400000000000000000000370221517576204000231650ustar00rootroot00000000000000"""Fish completion script generator. Generates static fish completion scripts using `complete -c COMMAND` statements. Completions auto-load from ~/.config/fish/completions/PROGNAME.fish. """ import re from typing import TYPE_CHECKING from cyclopts.completion._base import ( CompletionAction, CompletionData, clean_choice_text, extract_completion_data, get_completion_action, strip_markup, ) if TYPE_CHECKING: from cyclopts import App from cyclopts.command_spec import CommandSpec def generate_completion_script(app: "App", prog_name: str) -> str: """Generate fish completion script. Parameters ---------- app : App The Cyclopts application to generate completion for. prog_name : str Program name for completion (alphanumeric with hyphens/underscores). Returns ------- str Complete fish completion script. Raises ------ ValueError If prog_name contains invalid characters. """ if not prog_name or not re.match(r"^[a-zA-Z0-9_-]+$", prog_name): raise ValueError(f"Invalid prog_name: {prog_name!r}. Must be alphanumeric with hyphens/underscores.") completion_data = extract_completion_data(app) lines = [ f"# Fish completion for {prog_name}", "# Generated by Cyclopts", "", ] has_nested_commands = any(len(path) > 0 for path in completion_data.keys()) if has_nested_commands: lines.extend(_generate_helper_functions(prog_name, completion_data)) lines.append("") help_flags = tuple(app.help_flags) if app.help_flags else () version_flags = tuple(app.version_flags) if app.version_flags else () lines.extend(_generate_completions(completion_data, prog_name, help_flags, version_flags)) return "\n".join(lines) + "\n" def _escape_fish_string(text: str) -> str: r"""Escape single quotes for fish strings.""" return text.replace("'", r"'\''") def _escape_fish_description(text: str) -> str: """Escape description text for fish.""" text = text.replace("\n", " ") text = text.replace("\r", " ") return _escape_fish_string(text) def _generate_helper_functions( prog_name: str, completion_data: dict[tuple[str, ...], CompletionData], ) -> list[str]: """Generate helper function for command path detection. Parameters ---------- prog_name : str Program name. completion_data : dict Completion data used to identify options that take values. Returns ------- list[str] Lines defining the helper function. """ options_with_values = set() for data in completion_data.values(): for argument in data.arguments: if not argument.is_flag() and argument.parameter.name: for name in argument.parameter.name: if name.startswith("-"): options_with_values.add(name) func_name = f"__fish_{prog_name}_using_command" lines = [ "# Helper function to check exact command path sequence", f"function {func_name}", " set -l cmd (commandline -opc)", " set -l subcommands", ] if options_with_values: escaped_opts = " ".join(_escape_fish_string(opt) for opt in sorted(options_with_values)) lines.append(f" set -l options_with_values '{escaped_opts}'") else: lines.append(" set -l options_with_values ''") lines.extend( [ " set -l skip_next 0", " # Extract non-option words (commands) from command line", " for i in (seq 2 (count $cmd))", " set -l word $cmd[$i]", " if test $skip_next -eq 1", " set skip_next 0", " continue", " end", " if string match -qr -- '^-' $word", " # Check if this option takes a value (exact match)", ' if string match -q -- "* $word *" " $options_with_values "', " set skip_next 1", " end", " else", " # Non-option word is a command", " set -a subcommands $word", " end", " end", " # Check if subcommand sequence matches expected path", " if test (count $subcommands) -ne (count $argv)", " return 1", " end", " for i in (seq 1 (count $argv))", " if test $subcommands[$i] != $argv[$i]", " return 1", " end", " end", " return 0", "end", ] ) return lines def _map_completion_action_to_fish(action: CompletionAction) -> str: """Map completion action to fish flags. Parameters ---------- action : CompletionAction Completion action type. Returns ------- str Fish completion flags ("-r -F" for files, "-r -a '(...)'" for directories, "" otherwise). """ if action == CompletionAction.FILES: return "-r -F" if action == CompletionAction.DIRECTORIES: return "-r -a '(__fish_complete_directories)'" return "" def _generate_completions( completion_data: dict[tuple[str, ...], CompletionData], prog_name: str, help_flags: tuple[str, ...], version_flags: tuple[str, ...], ) -> list[str]: """Generate all fish completion commands. Parameters ---------- completion_data : dict Extracted completion data. prog_name : str Program name. help_flags : tuple[str, ...] Help flags. version_flags : tuple[str, ...] Version flags. Returns ------- list[str] Completion command lines. """ lines = [] for command_path, _data in sorted(completion_data.items()): lines.extend( _generate_completions_for_path( completion_data, command_path, prog_name, help_flags, version_flags, ) ) if command_path != max(completion_data.keys(), key=len): lines.append("") return lines def _generate_completions_for_path( completion_data: dict[tuple[str, ...], CompletionData], command_path: tuple[str, ...], prog_name: str, help_flags: tuple[str, ...], version_flags: tuple[str, ...], ) -> list[str]: """Generate completions for a specific command path. Parameters ---------- completion_data : dict Extracted completion data. command_path : tuple[str, ...] Command path. prog_name : str Program name. help_flags : tuple[str, ...] Help flags. version_flags : tuple[str, ...] Version flags. Returns ------- list[str] Completion command lines. """ if command_path not in completion_data: return [] data = completion_data[command_path] lines = [] condition = _get_condition_for_path(command_path, prog_name) lines.extend(_generate_subcommand_completions(data, command_path, prog_name, condition)) keyword_args = [arg for arg in data.arguments if not arg.is_positional_only() and arg.show] if keyword_args or help_flags or version_flags: lines.extend(_generate_option_section_header(command_path)) lines.extend(_generate_help_version_completions(prog_name, condition, help_flags, version_flags)) lines.extend(_generate_keyword_arg_completions(keyword_args, prog_name, condition, data.help_format)) lines.extend(_generate_command_option_completions(data.commands, prog_name, condition, data.help_format)) return lines def _generate_subcommand_completions( data: CompletionData, command_path: tuple[str, ...], prog_name: str, condition: str, ) -> list[str]: """Generate completions for subcommands. Parameters ---------- data : CompletionData Completion data. command_path : tuple[str, ...] Command path. prog_name : str Program name. condition : str Fish condition. Returns ------- list[str] Completion command lines. """ commands = [ name for registered_command in data.commands for name in registered_command.names if not name.startswith("-") ] if not commands: return [] lines = [] if command_path: lines.append(f"# Subcommands for: {' '.join(command_path)}") else: lines.append("# Root-level commands") for registered_command in data.commands: for cmd_name in registered_command.names: if cmd_name.startswith("-"): continue desc = _get_description_from_app(registered_command.app, data.help_format) escaped_desc = _escape_fish_description(desc) escaped_cmd = _escape_fish_string(cmd_name) lines.append(f"complete -c {prog_name} {condition} -a '{escaped_cmd}' -d '{escaped_desc}'") return lines def _generate_option_section_header(command_path: tuple[str, ...]) -> list[str]: """Generate section header comment for options. Parameters ---------- command_path : tuple[str, ...] Command path. Returns ------- list[str] Comment line. """ if command_path: return [f"# Options for: {' '.join(command_path)}"] return ["# Root-level options"] def _generate_help_version_completions( prog_name: str, condition: str, help_flags: tuple[str, ...], version_flags: tuple[str, ...], ) -> list[str]: """Generate completions for help and version flags. Parameters ---------- prog_name : str Program name. condition : str Fish condition. help_flags : tuple[str, ...] Help flags. version_flags : tuple[str, ...] Version flags. Returns ------- list[str] Completion command lines. """ lines = [] for flag in help_flags: if flag.startswith("--"): long_name = flag[2:] lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d 'Display this message and exit.'") elif flag.startswith("-") and len(flag) == 2: short_name = flag[1] lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d 'Display this message and exit.'") for flag in version_flags: if flag.startswith("--"): long_name = flag[2:] lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d 'Display application version.'") elif flag.startswith("-") and len(flag) == 2: short_name = flag[1] lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d 'Display application version.'") return lines def _generate_keyword_arg_completions( keyword_args: list, prog_name: str, condition: str, help_format: str, ) -> list[str]: """Generate completions for keyword arguments. Parameters ---------- keyword_args : list Keyword arguments. prog_name : str Program name. condition : str Fish condition. help_format : str Help text format. Returns ------- list[str] Completion command lines. """ lines = [] for argument in keyword_args: desc = strip_markup(argument.parameter.help or "", format=help_format) escaped_desc = _escape_fish_description(desc) is_flag = argument.is_flag() choices = argument.get_choices(force=True) action = get_completion_action(argument.hint) for name in argument.parameter.name or []: if not name.startswith("-"): continue if name.startswith("--"): long_name = name[2:] line_parts = [f"complete -c {prog_name} {condition} -l {long_name}"] elif len(name) == 2: short_name = name[1] line_parts = [f"complete -c {prog_name} {condition} -s {short_name}"] else: continue if is_flag: line_parts.append(f"-d '{escaped_desc}'") elif choices: escaped_choices = [_escape_fish_string(clean_choice_text(c)) for c in choices] choices_str = " ".join(escaped_choices) line_parts.append(f"-x -a '{choices_str}' -d '{escaped_desc}'") else: action_flags = _map_completion_action_to_fish(action) if action_flags: line_parts.append(f"{action_flags} -d '{escaped_desc}'") else: line_parts.append(f"-r -d '{escaped_desc}'") lines.append(" ".join(line_parts)) for name in argument.negatives: if not name.startswith("-"): continue if name.startswith("--"): long_name = name[2:] lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d '{escaped_desc}'") elif len(name) == 2: short_name = name[1] lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d '{escaped_desc}'") return lines def _generate_command_option_completions( commands: list, prog_name: str, condition: str, help_format: str, ) -> list[str]: """Generate completions for commands that look like options. Parameters ---------- commands : list List of RegisteredCommand tuples. prog_name : str Program name. condition : str Fish condition. help_format : str Help text format. Returns ------- list[str] Completion command lines. """ lines = [] for registered_command in commands: for cmd_name in registered_command.names: if not cmd_name.startswith("-"): continue desc = _get_description_from_app(registered_command.app, help_format) escaped_desc = _escape_fish_description(desc) if cmd_name.startswith("--"): long_name = cmd_name[2:] lines.append(f"complete -c {prog_name} {condition} -l {long_name} -d '{escaped_desc}'") elif len(cmd_name) == 2: short_name = cmd_name[1] lines.append(f"complete -c {prog_name} {condition} -s {short_name} -d '{escaped_desc}'") return lines def _get_condition_for_path(command_path: tuple[str, ...], prog_name: str) -> str: """Generate fish condition string for a command path. Parameters ---------- command_path : tuple[str, ...] Command path (empty for root). prog_name : str Program name. Returns ------- str Fish condition flag. """ if not command_path: return "-n __fish_use_subcommand" func_name = f"__fish_{prog_name}_using_command" escaped_commands = " ".join(_escape_fish_string(cmd) for cmd in command_path) return f"-n '{func_name} {escaped_commands}'" def _get_description_from_app(cmd_app: "App | CommandSpec", help_format: str) -> str: """Extract description from App. Parameters ---------- cmd_app : App | CommandSpec Command app or spec. help_format : str Help text format. Returns ------- str Description text. """ from cyclopts.help.help import docstring_parse try: parsed = docstring_parse(cmd_app.help, "plaintext") text = parsed.short_description or "" except Exception: text = str(cmd_app.help or "") return strip_markup(text, format=help_format) BrianPugh-cyclopts-921b1fa/cyclopts/completion/install.py000066400000000000000000000211771517576204000237060ustar00rootroot00000000000000"""Shell completion installation utilities. This module handles the installation of completion scripts to shell-specific locations and the updating of shell RC files to load completions. """ import os import sys from collections.abc import Callable from pathlib import Path from typing import Annotated, Literal from cyclopts.parameter import Parameter def _detect_omz_completions_dir() -> Path | None: """Detect oh-my-zsh custom completions directory. Uses ``$ZSH_CUSTOM/completions`` (the recommended location for user completions in oh-my-zsh). Falls back to ``$ZSH/custom/completions`` when ``$ZSH_CUSTOM`` is not set. Returns ------- Path | None Path to the oh-my-zsh custom completions directory, or None if oh-my-zsh is not detected. """ zsh_custom_str = os.environ.get("ZSH_CUSTOM") if zsh_custom_str: zsh_custom = Path(zsh_custom_str) if zsh_custom.is_dir(): return zsh_custom / "completions" zsh_dir_str = os.environ.get("ZSH") if not zsh_dir_str: return None zsh_dir = Path(zsh_dir_str) if zsh_dir.is_dir(): return zsh_dir / "custom" / "completions" return None def get_default_completion_path(shell: Literal["zsh", "bash", "fish"], prog_name: str) -> Path: """Get the default completion script path for a given shell. Parameters ---------- shell : Literal["zsh", "bash", "fish"] Shell type. prog_name : str Program name for the completion script. Returns ------- Path Default installation path for the shell. Raises ------ ValueError If shell type is unsupported. """ home = Path.home() if shell == "zsh": omz_completions = _detect_omz_completions_dir() if omz_completions is not None: omz_completions.mkdir(parents=True, exist_ok=True) return omz_completions / f"_{prog_name}" # Vanilla zsh fallback zsh_completions = home / ".zsh" / "completions" zsh_completions.mkdir(parents=True, exist_ok=True) return zsh_completions / f"_{prog_name}" elif shell == "bash": bash_completions = home / ".local" / "share" / "bash-completion" / "completions" bash_completions.mkdir(parents=True, exist_ok=True) return bash_completions / prog_name elif shell == "fish": fish_completions = home / ".config" / "fish" / "completions" fish_completions.mkdir(parents=True, exist_ok=True) return fish_completions / f"{prog_name}.fish" else: raise ValueError(f"Unsupported shell: {shell}") def add_to_rc_file(script_path: Path, prog_name: str, shell: Literal["bash", "zsh"]) -> bool: """Add completion configuration to shell RC file. For bash, adds a source line to load the completion script. For zsh, adds the completion directory to fpath so compinit can find it. Parameters ---------- script_path : Path Path to the completion script. prog_name : str Program name for display in comments. shell : Literal["bash", "zsh"] Shell type. Returns ------- bool True if configuration was added, False if it already existed or on error. """ if shell == "bash": rc_file = Path.home() / ".bashrc" config_line = f'[ -f "{script_path}" ] && . "{script_path}"' comment = f"# Load {prog_name} completion" elif shell == "zsh": if _detect_omz_completions_dir(): return False rc_file = Path.home() / ".zshrc" completion_dir = script_path.parent config_line = f"fpath=({completion_dir} $fpath)" comment = f"# {prog_name} completions" else: raise NotImplementedError rc_file = rc_file.resolve() if rc_file.exists(): content = rc_file.read_text() # For zsh, check if this directory is already in fpath configuration # For bash, check if the exact source line exists if shell == "zsh" and str(script_path.parent) in content and "fpath=" in content: return False elif config_line in content: return False else: content = "" if shell == "zsh": # Prepend to ensure fpath is set before any compinit call rc_file.write_text(f"{comment}\n{config_line}\n{content}") else: # Bash: append needs_newline = content and not content.endswith("\n") with rc_file.open("a") as f: if needs_newline: f.write("\n") f.write(f"{comment}\n{config_line}\n") return True def create_install_completion_command( install_completion_fn: Callable[..., Path], add_to_startup: bool, ): """Create a command function for installing shell completion. Parameters ---------- install_completion_fn : Callable Function that performs the actual installation (typically App.install_completion). Should accept (shell, output, add_to_startup) and return the installation path. add_to_startup : bool Whether to add source line to shell RC file. Returns ------- Callable Command function that can be registered with App.command(). """ def _install_completion_command( *, shell: Annotated[Literal["zsh", "bash", "fish"] | None, Parameter()] = None, output: Annotated[Path | None, Parameter(name=["-o", "--output"])] = None, ): """Install shell completion for this application. This command generates and installs the completion script to the appropriate location for your shell. After installation, you may need to restart your shell or source your shell configuration file. Parameters ---------- shell : Literal["zsh", "bash", "fish"] | None Shell type for completion. If not specified, attempts to auto-detect current shell. output : Path | None Output path for the completion script. If not specified, uses shell-specific default. """ from cyclopts.completion.detect import ShellDetectionError, detect_shell if shell is None: try: shell = detect_shell() except ShellDetectionError: print( "Could not auto-detect shell. Please specify --shell explicitly.", file=sys.stderr, ) sys.exit(1) install_path = install_completion_fn(shell=shell, output=output, add_to_startup=add_to_startup) print(f"✓ Completion script installed to {install_path}") if shell == "zsh": if _detect_omz_completions_dir(): print("✓ Detected oh-my-zsh: completions directory is already in $fpath.") print("\nRestart your shell or run: exec zsh") elif add_to_startup: zshrc = Path.home() / ".zshrc" completion_dir = install_path.parent print(f"✓ Added {completion_dir} to fpath in {zshrc}") print("\nNote: Ensure compinit is configured in your .zshrc (most zsh setups already have this).") print("Restart your shell or run: exec zsh") else: completion_dir = install_path.parent print(f"\nTo enable completions, ensure {completion_dir} is in your $fpath.") print("Add this to your ~/.zshrc or ~/.zprofile if not already present:") print(f" fpath=({completion_dir} $fpath)") print(" autoload -Uz compinit && compinit") print("\nThen restart your shell or run: exec zsh") elif shell == "bash": if add_to_startup: bashrc = Path.home() / ".bashrc" print(f"✓ Added completion loader to {bashrc}") print("\nRestart your shell or run: source ~/.bashrc") else: print("\nCompletions will be automatically loaded by bash-completion.") print("If completions don't work:") print(" 1. Ensure bash-completion is installed (v2.8+)") print(" 2. Restart your shell or run: exec bash") print("\nNote: bash-completion is typically installed via:") print(" - macOS: brew install bash-completion@2") print(" - Debian/Ubuntu: apt install bash-completion") print(" - Fedora/RHEL: dnf install bash-completion") elif shell == "fish": print("\nCompletions are automatically loaded in fish.") print("Restart your shell or run: source ~/.config/fish/config.fish") else: raise NotImplementedError return _install_completion_command BrianPugh-cyclopts-921b1fa/cyclopts/completion/zsh.py000066400000000000000000000770341517576204000230470ustar00rootroot00000000000000"""Zsh completion script generator. Generates static zsh completion scripts using the compsys framework. No runtime Python dependency. """ import re from textwrap import dedent from textwrap import indent as textwrap_indent from typing import TYPE_CHECKING from cyclopts.annotations import is_iterable_type from cyclopts.completion._base import ( CompletionAction, CompletionData, clean_choice_text, escape_for_shell_pattern, extract_completion_data, get_completion_action, strip_markup, ) from cyclopts.help.help import docstring_parse if TYPE_CHECKING: from cyclopts import App from cyclopts.argument import Argument, ArgumentCollection from cyclopts.command_spec import CommandSpec def generate_completion_script(app: "App", prog_name: str) -> str: """Generate zsh completion script. Parameters ---------- app : App The Cyclopts application to generate completion for. prog_name : str Program name (alphanumeric with hyphens/underscores). Returns ------- str Complete zsh completion script. Raises ------ ValueError If prog_name contains invalid characters. """ if not prog_name or not re.match(r"^[a-zA-Z0-9_-]+$", prog_name): raise ValueError(f"Invalid prog_name: {prog_name!r}. Must be alphanumeric with hyphens/underscores.") completion_data = extract_completion_data(app) lines = [ f"#compdef {prog_name}", "", f"_{prog_name}() {{", " local line state", "", ] lines.extend( _generate_completion_for_path( completion_data, (), prog_name=prog_name, help_flags=tuple(app.help_flags) if app.help_flags else (), version_flags=tuple(app.version_flags) if app.version_flags else (), ) ) lines.extend( [ "}", "", ] ) return "\n".join(lines) + "\n" def _generate_run_command_completion( arguments: "ArgumentCollection", indent_str: str, prog_name: str, ) -> list[str]: """Generate dynamic completion for the 'run' command. Parameters ---------- arguments : ArgumentCollection Arguments for run command. indent_str : str Indentation string. prog_name : str Program name. Returns ------- list[str] Zsh completion code lines. """ template = dedent(f"""\ local script_path local -a completions local -a remaining_words # If completing first argument (the script path), suggest files if [[ $CURRENT -eq 2 ]]; then _files return fi # Get absolute path to the script file script_path=${{words[2]}} script_path=${{script_path:a}} if [[ -f $script_path ]]; then remaining_words=(${{words[3,-1]}}) local result local cmd if command -v {prog_name} &>/dev/null; then cmd="{prog_name}" else return fi # Call back into cyclopts to get dynamic completions from the script result=$($cmd _complete run "$script_path" "${{remaining_words[@]}}" 2>/dev/null) if [[ -n $result ]]; then # Parse and display completion results completions=() while IFS= read -r line; do completions+=($line) done <<< $result _describe 'command' completions fi fi""") indented = textwrap_indent(template, indent_str) return [line.rstrip() for line in indented.split("\n")] def _generate_nested_positional_specs( positional_args: list["Argument"], help_format: str, ) -> list[str]: """Generate positional argument specs for nested command context. In nested contexts (after *::arg:->args), word indexing is shifted: - words[1] = subcommand name - words[2] = first positional argument - words[3] = second positional argument, etc. Parameters ---------- positional_args : list[Argument] Positional arguments to generate specs for. help_format : str Help text format. Returns ------- list[str] List of zsh positional argument specs. """ specs = [] # Check if we have variadic positionals (including collection types like list[X]) variadic_args = [arg for arg in positional_args if arg.is_var_positional() or is_iterable_type(arg.hint)] non_variadic_args = [ arg for arg in positional_args if not arg.is_var_positional() and not is_iterable_type(arg.hint) ] def _build(arg, position: str) -> str: choices = arg.get_choices(force=True) if choices: escaped_choices = [_escape_choice_for_dq_spec(clean_choice_text(c)) for c in choices] choices_str = " ".join(escaped_choices) action = f"({choices_str})" desc = _escape_zsh_description_dq(_description_text(arg, help_format)) quote = '"' else: action = _map_completion_action_to_zsh(get_completion_action(arg.hint)) desc = _get_description_from_argument(arg, help_format) quote = "'" return f"{quote}{position}:{desc}:{action}{quote}" if action else f"{quote}{position}:{desc}{quote}" # Generate specs for non-variadic positionals for arg in non_variadic_args: # Position in nested context: After *::arg:->args, $words[1] is the subcommand # So positionals start at position 1 (not 2) # Use 1-based indexing: first positional is '1:', second is '2:', etc. pos = 1 + (arg.index or 0) specs.append(_build(arg, str(pos))) # Emit at most one rest-arg (``*:``) spec. zsh's ``_arguments`` errors # with "doubled rest argument definition" if more than one is present. # When a function has multiple iterable positional-or-keyword params # (e.g. several ``list[X]`` defaults), only the first can realistically # be filled positionally — the others remain available via their # ``--name`` keyword forms emitted elsewhere. chosen = next((a for a in variadic_args if a.is_var_positional()), None) if chosen is None and variadic_args: chosen = variadic_args[0] if chosen is not None: specs.append(_build(chosen, "*")) return specs def _generate_describe_completion( argument: "Argument", help_format: str, indent_str: str, ) -> list[str]: """Generate _describe-based completion for a single positional argument. Parameters ---------- argument : Argument Argument to generate completion for. help_format : str Help text format. indent_str : str Indentation string. Returns ------- list[str] Zsh completion code lines. """ lines = [] desc = _get_description_from_argument(argument, help_format) # Check for choices (Literal/Enum types) choices = argument.get_choices(force=True) if choices: # Generate choices array with descriptions escaped_choices = [_escape_completion_choice(clean_choice_text(c)) for c in choices] lines.append(f"{indent_str}local -a choices") lines.append(f"{indent_str}choices=(") for choice in escaped_choices: lines.append(f"{indent_str} '{choice}:{desc}'") lines.append(f"{indent_str})") lines.append(f"{indent_str}_describe 'argument' choices") else: # Use completion action (files, directories, or nothing) action = get_completion_action(argument.hint) if action == CompletionAction.FILES: lines.append(f"{indent_str}_files") elif action == CompletionAction.DIRECTORIES: lines.append(f"{indent_str}_directories") # For other types, provide no completion return lines def _generate_completion_for_path( completion_data: dict[tuple[str, ...], CompletionData], command_path: tuple[str, ...], indent: int = 2, prog_name: str = "cyclopts", help_flags: tuple[str, ...] = (), version_flags: tuple[str, ...] = (), ) -> list[str]: """Generate completion code for a specific command path. Parameters ---------- completion_data : dict Extracted completion data. command_path : tuple[str, ...] Command path. indent : int Indentation level. prog_name : str Program name. help_flags : tuple[str, ...] Help flags. version_flags : tuple[str, ...] Version flags. Returns ------- list[str] Zsh code lines. """ data = completion_data[command_path] commands = data.commands arguments = data.arguments indent_str = " " * indent lines = [] if command_path == ("run",) and prog_name == "cyclopts": lines.extend(_generate_run_command_completion(arguments, indent_str, prog_name)) return lines args_specs = [] positional_specs = [] # Separate positional from keyword arguments # Include all arguments with an index (both positional-only and positional-or-keyword) positional_args = [arg for arg in arguments if arg.index is not None and arg.show] keyword_args = [arg for arg in arguments if not arg.is_positional_only() and arg.show] # Sort positionals by index (should never be None for positional-only args) positional_args.sort(key=lambda a: a.index or 0) # Generate keyword argument specs for argument in keyword_args: specs = _generate_keyword_specs(argument, data.help_format) args_specs.extend(specs) # Check for flag commands (commands that look like options) flag_command_names = set() for registered_command in commands: if any(name.startswith("-") for name in registered_command.names): specs = _generate_keyword_specs_for_command( registered_command.names, registered_command.app, data.help_format ) args_specs.extend(specs) flag_command_names.update(registered_command.names) # Add help and version flags to all command paths (if not already added as flag commands) for flag in help_flags: if flag.startswith("-") and flag not in flag_command_names: spec = f"'{flag}[Display this message and exit.]'" args_specs.append(spec) for flag in version_flags: if flag.startswith("-") and flag not in flag_command_names: spec = f"'{flag}[Display application version.]'" args_specs.append(spec) has_non_flag_commands = any( not cmd_name.startswith("-") for registered_command in commands for cmd_name in registered_command.names ) # Generate positional argument specs # Only add positionals if there are no subcommands (they conflict in zsh) if positional_args and not has_non_flag_commands: if command_path: # Nested context: use shifted positional indexing (words[1] is subcommand) positional_specs = _generate_nested_positional_specs(positional_args, data.help_format) else: # Root context: standard _arguments works fine. As in the # nested helper, only one rest-arg (``*:``) spec is allowed — # collapse multiple iterable positionals to the first one # (var-positional preferred). The other iterables remain # available via their ``--name`` keyword specs. seen_rest = False iterable_args = [a for a in positional_args if a.is_var_positional() or is_iterable_type(a.hint)] chosen_rest = next((a for a in iterable_args if a.is_var_positional()), None) if chosen_rest is None and iterable_args: chosen_rest = iterable_args[0] for argument in positional_args: is_rest = argument.is_var_positional() or is_iterable_type(argument.hint) if is_rest: if argument is not chosen_rest or seen_rest: continue seen_rest = True spec = _generate_positional_spec(argument, data.help_format) positional_specs.append(spec) # Add positionals BEFORE options to prioritize them in completion args_specs = positional_specs + args_specs if has_non_flag_commands: args_specs.append("'1: :->cmds'") args_specs.append("'*::arg:->args'") # Eq-form pre-pass: zsh's ``_arguments`` only handles ``--opt=value`` # value-completion when the spec name carries an ``=`` suffix, which # also forces ``=`` insertion on name TAB. We want the natural # ``--opt `` (trailing space) name TAB *and* ``--opt=value`` # value completion. Achieved with a pre-pass that intercepts # ``--opt=...`` patterns before ``_arguments`` runs and dispatches to # the same value action with the ``--opt=`` prefix consumed via # ``compset -P``. ``Parameter(requires_equals=True)`` already emits the # eq spec directly, so those options are skipped here. eq_prepass = _generate_eq_form_prepass(keyword_args, indent_str) lines.extend(eq_prepass) if args_specs: c_flag = "-C " if has_non_flag_commands else "" lines.append(f"{indent_str}_arguments {c_flag}\\") for spec in args_specs[:-1]: lines.append(f"{indent_str} {spec} \\") lines.append(f"{indent_str} {args_specs[-1]}") lines.append("") if has_non_flag_commands: lines.append(f"{indent_str}case $state in") lines.append(f"{indent_str} cmds)") cmd_list = [] for registered_command in commands: for cmd_name in registered_command.names: if not cmd_name.startswith("-"): desc = _safe_get_description_from_app(registered_command.app, data.help_format) escaped_cmd_name = _escape_completion_choice(cmd_name) cmd_list.append(f"'{escaped_cmd_name}:{desc}'") lines.append(f"{indent_str} local -a commands") lines.append(f"{indent_str} commands=(") for cmd in cmd_list: lines.append(f"{indent_str} {cmd}") lines.append(f"{indent_str} )") lines.append(f"{indent_str} _describe -t commands 'command' commands") lines.append(f"{indent_str} ;;") lines.append(f"{indent_str} args)") lines.append(f"{indent_str} case $words[1] in") for registered_command in commands: for cmd_name in registered_command.names: if cmd_name.startswith("-"): continue sub_path = command_path + (cmd_name,) if sub_path in completion_data: escaped_case_name = _escape_command_name_for_case(cmd_name) lines.append(f"{indent_str} {escaped_case_name})") sub_lines = _generate_completion_for_path( completion_data, sub_path, indent + 8, prog_name, help_flags, version_flags ) lines.extend(sub_lines) lines.append(f"{indent_str} ;;") lines.append(f"{indent_str} esac") lines.append(f"{indent_str} ;;") lines.append(f"{indent_str}esac") return lines def _shell_single_quote(s: str) -> str: r"""Wrap ``s`` in POSIX-safe single quotes for embedding as a shell argument. The only character that can't appear inside a single-quoted shell string is ``'`` itself, which is handled with the ``'\''`` end-and-restart trick. Everything else (spaces, parens, ``$``, backticks, etc.) is literal — no backslash-escaping is needed, and adding any would just surface as visible backslashes in the resulting argument. """ return "'" + s.replace("'", "'\\''") + "'" def _escape_completion_choice(choice: str) -> str: """Escape a choice value for embedding in a single-quoted shell context. Used for ``_describe`` array elements (``'value:desc'``) where the only parser the value passes through is the array-element parser, not the ``_arguments`` choice-list eval. Choice should already be cleaned via ``clean_choice_text()``. Parameters ---------- choice : str Cleaned choice value. Returns ------- str Escaped choice value safe for zsh completion. """ choice = choice.replace("\\", "\\\\") choice = choice.replace("'", r"'\''") choice = choice.replace("`", "\\`") choice = choice.replace("$", "\\$") choice = choice.replace('"', '\\"') choice = choice.replace(" ", "\\ ") choice = choice.replace("(", "\\(") choice = choice.replace(")", "\\)") choice = choice.replace("[", "\\[") choice = choice.replace("]", "\\]") choice = choice.replace(";", "\\;") choice = choice.replace("|", "\\|") choice = choice.replace("&", "\\&") choice = choice.replace(":", "\\:") return choice def _escape_choice_for_dq_spec(value: str) -> str: r"""Escape a choice value for ``_arguments``' parenthesized choice list. The value passes through *two* parsers: 1. zsh's outer double-quoted-string parser, which interprets ``\``, ``"``, ``$`` and backtick. 2. ``_arguments``' choice-list parser, which whitespace-tokenizes and reads ``\X`` as a literal X. A literal ``'`` cannot be embedded in a single-quoted spec string (``'\''`` ends/restarts the quoting and the parser then sees an unbalanced ``'``), so choice-bearing specs are emitted with a *double*- quoted outer string and routed through this helper. """ # Layer 1: choice-list parser escapes. The parser eval-style processes # each token, so ``$`` and backtick must be escaped here even though # they're DQ-specials too — DQ stripping happens *first* and would # otherwise leave them bare for the parser. Backslash first to avoid # double-escaping the slashes the loop introduces. s = value.replace("\\", "\\\\") for ch in " '\"()[]:;|&$`": s = s.replace(ch, "\\" + ch) # Layer 2: outer double-quote escapes. Re-escape backslashes (preserves # all the layer-1 ones) and re-escape DQ-specials so each one survives # to the choice-list parser as ``\X``. s = s.replace("\\", "\\\\") s = s.replace('"', '\\"') s = s.replace("$", "\\$") s = s.replace("`", "\\`") return s def _escape_zsh_description_dq(text: str) -> str: r"""Escape a description for embedding in a double-quoted spec string. Same as ``_escape_zsh_description`` but skips the ``'`` -> ``'\\''`` substitution: ``'`` is literal in a double-quoted context. """ text = text.replace("\\", "\\\\") text = text.replace("`", "\\`") text = text.replace("$", "\\$") text = text.replace('"', '\\"') text = text.replace(":", r"\:") text = text.replace("[", r"\[") text = text.replace("]", r"\]") return text def _escape_command_name_for_case(name: str) -> str: """Escape special characters in command name for zsh case patterns. In zsh case patterns, glob characters need to be escaped to match literally. Colons also need escaping because zsh's completion system may treat them specially when populating the $words array after _describe completion. Parameters ---------- name : str Command name. Returns ------- str Escaped command name safe for zsh case patterns. """ # zsh case patterns have more special chars than bash: includes ()| # Colons (:) also need escaping for completion $words matching (issue #715) return escape_for_shell_pattern(name, chars="*?[]()|:") def _escape_zsh_description(text: str) -> str: """Escape special characters in description text for zsh. Parameters ---------- text : str Cleaned description text. Returns ------- str Escaped description safe for zsh completion. """ text = text.replace("\\", "\\\\") text = text.replace("`", "\\`") text = text.replace("$", "\\$") text = text.replace('"', '\\"') text = text.replace("'", r"'\''") text = text.replace(":", r"\:") text = text.replace("[", r"\[") text = text.replace("]", r"\]") return text def _generate_eq_form_prepass(keyword_args: list, indent_str: str) -> list[str]: """Emit a ``--opt=value`` pattern dispatcher to run before ``_arguments``. For each keyword argument with a long name and a value action, emits a ``--opt=*)`` case branch that strips the ``--opt=`` prefix via ``compset -P`` and then dispatches to the value action (choice list, ``_files``, or ``_directories``). The natural-TAB experience on the option *name* is preserved by leaving the underlying ``_arguments`` spec without an ``=`` suffix; this pre-pass only catches the user explicitly typing the eq form. Skipped for arguments whose ``Parameter.requires_equals`` is True — those already emit the ``=`` spec, which handles eq-form completion via ``_arguments``. Parameters ---------- keyword_args : list Keyword argument objects from ArgumentCollection (already filtered to ``arg.show``). indent_str : str Leading indentation. Returns ------- list[str] Zsh code lines (empty if no eligible options). """ cases: list[tuple[str, str]] = [] # (option_name, completion_action_lines) for argument in keyword_args: if argument.is_flag() or argument.parameter.requires_equals: continue long_names = [name for name in (argument.parameter.name or []) if name.startswith("--")] if not long_names: continue choices = argument.get_choices(force=True) if choices: # ``compadd`` adds its arguments verbatim — no inner parser to # interpret backslash escapes — so we use POSIX single-quoting # rather than ``_escape_completion_choice`` (which is built for # ``_describe``'s inner parser). quoted = [_shell_single_quote(clean_choice_text(c)) for c in choices] action_line = "compadd -- " + " ".join(quoted) else: action = get_completion_action(argument.hint) zsh_action = _map_completion_action_to_zsh(action) if zsh_action == "_files": action_line = "_files" elif zsh_action == "_directories": action_line = "_directories" else: continue # Nothing to dispatch to. for name in long_names: cases.append((name, action_line)) if not cases: return [] lines = [ f"{indent_str}case ${{words[CURRENT]}} in", ] for opt_name, action_line in cases: lines.append(f"{indent_str} {opt_name}=*)") lines.append(f"{indent_str} compset -P '{opt_name}='") lines.append(f"{indent_str} {action_line}") lines.append(f"{indent_str} return") lines.append(f"{indent_str} ;;") lines.append(f"{indent_str}esac") return lines def _generate_keyword_specs(argument: "Argument", help_format: str) -> list[str]: """Generate zsh _arguments specs for a keyword argument. Parameters ---------- argument : Argument Argument object from ArgumentCollection. help_format : str Help text format. Returns ------- list[str] List of zsh argument specs. """ specs = [] flag = argument.is_flag() # Determine completion action. When choices are present we emit the spec # in a *double-quoted* outer string so a literal ``'`` inside a choice # can be backslash-escaped (single-quoted specs can't carry a literal # ``'`` past the inner ``_arguments`` choice-list eval). action = "" has_choices = False choices = argument.get_choices(force=True) if choices: has_choices = True escaped_choices = [_escape_choice_for_dq_spec(clean_choice_text(c)) for c in choices] choices_str = " ".join(escaped_choices) action = f"({choices_str})" flag = False else: action = _map_completion_action_to_zsh(get_completion_action(argument.hint)) desc = ( _escape_zsh_description_dq(_description_text(argument, help_format)) if has_choices else _get_description_from_argument(argument, help_format) ) quote = '"' if has_choices else "'" # Generate specs for positive names (from parameter.name). # # For options that take a value, prefix the spec with ``*`` so # ``_arguments`` allows the option to repeat (matches bash's behavior # and is required for collection-typed options like ``list[Path]`` where # ``--file a --file b`` is the natural usage). Bool flags stay # non-repeating per zsh convention. # # The ``=`` suffix on the option name is the *only* knob zsh exposes for # eq-form support, and it's load-bearing in two ways at once: # # 1. With ``=``, ``--opt=value`` value-completion works. # 2. With ``=``, TAB-completing the option *name* inserts ``--opt=`` # (no trailing space). Without ``=``, TAB inserts ``--opt`` plus a # space — which most users prefer. # # Since most CLIs in the wild lean on the space form and users find a # forced ``=`` insertion surprising, the default (``requires_equals=False``) # emits the plain spec — accepting the cost that ``--opt=value`` # value-completion silently does nothing in zsh. When the parser is # configured to *require* the eq form (``requires_equals=True``), we # emit the ``=`` spec so completion mirrors what the parser will # accept. Bash is unaffected: its eq-form completion is driven by # ``_value_prev`` hopping over the ``=`` token, not by the spec. requires_eq = bool(argument.parameter.requires_equals) # An option "takes a value" iff it isn't a bool flag — independent of whether # zsh has a completion *action* for the value. Collection-typed options like # ``list[int]`` have no action (``get_completion_action`` only knows FILES / # DIRECTORIES) but still need ``*`` so ``_arguments`` allows repetition. takes_value = not flag for name in argument.parameter.name: # pyright: ignore[reportOptionalIterable] if not name.startswith("-"): continue accepts_eq = requires_eq and name.startswith("--") and takes_value spec_name = f"{name}=" if accepts_eq else name repeat_prefix = "*" if takes_value else "" if flag and not action: spec = f"{quote}{repeat_prefix}{spec_name}[{desc}]{quote}" elif action: spec = f"{quote}{repeat_prefix}{spec_name}[{desc}]:{name.lstrip('-')}:{action}{quote}" else: spec = f"{quote}{repeat_prefix}{spec_name}[{desc}]:{name.lstrip('-')}{quote}" specs.append(spec) # Generate specs for negative names (always flags, consume no tokens). # No choice action, so single-quoted is fine and keeps the description # escaping consistent with the other flag specs. desc_sq = _get_description_from_argument(argument, help_format) for name in argument.negatives: if not name.startswith("-"): continue spec = f"'{name}[{desc_sq}]'" specs.append(spec) return specs def _generate_positional_spec(argument: "Argument", help_format: str) -> str: """Generate zsh _arguments spec for a positional argument. Parameters ---------- argument : Argument Positional argument object. help_format : str Help text format. Returns ------- str Zsh positional argument spec. """ # Check for choices first (Literal/Enum types). Choice-bearing specs use # double-quoted outer to allow embedding a literal ``'`` in a choice. choices = argument.get_choices(force=True) if choices: escaped_choices = [_escape_choice_for_dq_spec(clean_choice_text(c)) for c in choices] choices_str = " ".join(escaped_choices) action = f"({choices_str})" desc = _escape_zsh_description_dq(_description_text(argument, help_format)) quote = '"' else: action = _map_completion_action_to_zsh(get_completion_action(argument.hint)) desc = _get_description_from_argument(argument, help_format) quote = "'" if argument.is_var_positional() or is_iterable_type(argument.hint): # Variadic positional (*args) or collection type (list[X], set[X], etc.) return f"{quote}*:{desc}:{action}{quote}" if action else f"{quote}*:{desc}{quote}" # Regular positional - zsh uses 1-based indexing if argument.index is None: raise ValueError(f"Positional-only argument {argument.names} missing index") pos = argument.index + 1 return f"{quote}{pos}:{desc}:{action}{quote}" if action else f"{quote}{pos}:{desc}{quote}" def _generate_keyword_specs_for_command( names: tuple[str, ...], cmd_app: "App | CommandSpec", help_format: str ) -> list[str]: """Generate zsh _arguments specs for a command that looks like a flag. Parameters ---------- names : tuple[str, ...] Registered names for the command. cmd_app : App | CommandSpec Command app or spec. help_format : str Help text format. Returns ------- list[str] List of zsh argument specs. """ specs = [] desc = _safe_get_description_from_app(cmd_app, help_format) for name in names: if name.startswith("-"): spec = f"'{name}[{desc}]'" specs.append(spec) return specs def _map_completion_action_to_zsh(action: CompletionAction) -> str: """Map shell-agnostic completion action to zsh-specific completion command. Parameters ---------- action : CompletionAction Shell-agnostic completion action. Returns ------- str Zsh completion command (e.g., "_files", "_directories", or ""). """ if action == CompletionAction.FILES: return "_files" elif action == CompletionAction.DIRECTORIES: return "_directories" return "" def _get_description_from_argument(argument: "Argument", help_format: str) -> str: """Extract plain text description from Argument, escaping zsh special chars. Parameters ---------- argument : Argument Argument object with parameter help text. help_format : str Help text format. Returns ------- str Escaped plain text description (truncated to 80 chars). Falls back to argument name if help text is empty, since zsh _arguments requires a non-empty description for positional specs to work correctly. """ return _escape_zsh_description(_description_text(argument, help_format)) def _description_text(argument: "Argument", help_format: str) -> str: """Plain-text description for an argument with the empty-help fallback.""" text = strip_markup(argument.parameter.help or "", format=help_format) if not text: # Use primary argument name as fallback - zsh _arguments requires non-empty # description for positional specs to provide completions text = argument.names[0] if argument.names else "argument" return text def _safe_get_description_from_app(cmd_app: "App | CommandSpec", help_format: str) -> str: """Extract plain text description from App, escaping zsh special chars. Parameters ---------- cmd_app : App | CommandSpec Command app or spec with help text. help_format : str Help text format. Returns ------- str Escaped plain text description (truncated to 80 chars). """ try: parsed = docstring_parse(cmd_app.help, "plaintext") text = parsed.short_description or "" except Exception: text = str(cmd_app.help or "") text = strip_markup(text, format=help_format) return _escape_zsh_description(text) BrianPugh-cyclopts-921b1fa/cyclopts/config/000077500000000000000000000000001517576204000207525ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/config/__init__.py000066400000000000000000000004631517576204000230660ustar00rootroot00000000000000__all__ = [ "ConfigFromFile", "Dict", "Env", "Json", "Toml", "Yaml", ] from cyclopts.config._common import ConfigFromFile, Dict from cyclopts.config._env import Env from cyclopts.config._json import Json from cyclopts.config._toml import Toml from cyclopts.config._yaml import Yaml BrianPugh-cyclopts-921b1fa/cyclopts/config/_common.py000066400000000000000000000147611517576204000227640ustar00rootroot00000000000000import errno import os from abc import ABC, abstractmethod from collections.abc import Iterable from contextlib import suppress from itertools import chain from pathlib import Path from typing import TYPE_CHECKING, Any from attrs import define, field from cyclopts.argument import ArgumentCollection, update_argument_collection from cyclopts.exceptions import CycloptsError from cyclopts.utils import to_tuple_converter def _root_keys_converter(value: Iterable[str]) -> tuple[str, ...]: return to_tuple_converter(value) # type: ignore[return-value] if TYPE_CHECKING: from cyclopts.core import App @define(kw_only=True) class ConfigBase(ABC): """Base class for configuration sources. Handles the common logic of processing configuration dictionaries and updating ArgumentCollections. """ root_keys: Iterable[str] = field(default=(), converter=_root_keys_converter) allow_unknown: bool = field(default=False) use_commands_as_keys: bool = field(default=True) _source: str | None = field(default=None, alias="source") @property @abstractmethod def config(self) -> dict[str, Any]: """Return the configuration dictionary.""" raise NotImplementedError @property @abstractmethod def source(self) -> str: """Return a string identifying the configuration source for error messages.""" raise NotImplementedError def __call__( self, app: "App", commands: tuple[str, ...], arguments: ArgumentCollection, ): config: dict[str, Any] = self.config.copy() try: for key in chain(self.root_keys, commands if self.use_commands_as_keys else ()): config = config[key] except KeyError: return # Hierarchical config uses current app; flat config uses root app to filter sibling commands if self.use_commands_as_keys: filter_app = app else: filter_app = next((a for a in app.app_stack.current_frame if not a._meta_parent), app) config = {k: v for k, v in config.items() if k not in filter_app} update_argument_collection( config, self.source, arguments, app.app_stack.stack[-1], root_keys=self.root_keys, allow_unknown=self.allow_unknown, ) class FileCacheKey: """Abstraction to quickly check if a file needs to be read again. If a newly instantiated ``CacheKey`` doesn't equal a previously instantiated ``CacheKey``, then the file needs to be re-read. """ def __init__(self, path: str | Path): self.path = Path(path).absolute() if self.path.exists(): stat = self.path.stat() self._mtime = stat.st_mtime self._size = stat.st_size else: self._mtime = None self._size = None def __eq__(self, other): if not isinstance(other, type(self)): return False return self._mtime == other._mtime and self._size == other._size and self.path == other.path @define class ConfigFromFile(ConfigBase): """Configuration source that loads from a file. Supports file caching and parent directory searching. """ path: str | Path = field(converter=Path) must_exist: bool = field(default=False, kw_only=True) search_parents: bool = field(default=False, kw_only=True) _config: dict[str, Any] | None = field(default=None, init=False, repr=False) "Loaded configuration structure (to be loaded by subclassed ``_load_config`` method)." _config_cache_key: FileCacheKey | None = field(default=None, init=False, repr=False) "Conditions under which ``_config`` was loaded." @abstractmethod def _load_config(self, path: Path) -> dict[str, Any]: """Load the config dictionary from path. Do **not** do any downstream caching; ``ConfigFromFile`` handles caching. Parameters ---------- path: Path Path to the file. Guaranteed to exist. Returns ------- dict Loaded configuration. """ raise NotImplementedError @property def config(self) -> dict[str, Any]: assert isinstance(self.path, Path) for parent in self.path.expanduser().resolve().absolute().parents: candidate = parent / self.path.name if candidate.exists(): cache_key = FileCacheKey(candidate) if self._config_cache_key == cache_key: return self._config or {} try: self._config = self._load_config(candidate) self._config_cache_key = cache_key except CycloptsError: raise except Exception as e: msg = getattr(type(e), "__name__", "") with suppress(IndexError): exception_msg = e.args[0] if msg: msg += ": " msg += exception_msg raise CycloptsError(msg=msg) from e return self._config elif self.search_parents: # Continue iterating over parents. continue elif self.must_exist: raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(self.path)) # No matching file was found. if self.must_exist: raise FileNotFoundError(errno.ENOENT, os.strerror(errno.ENOENT), str(self.path)) self._config = {} return self._config @property def source(self) -> str: """Return a string identifying the configuration source for error messages.""" if self._source is not None: return self._source assert isinstance(self.path, Path) return str(self.path.absolute()) @source.setter def source(self, value: str) -> None: self._source = value @define class Dict(ConfigBase): """Configuration source from an in-memory dictionary. Useful for programmatically generated configurations. """ data: dict[str, Any] @property def config(self) -> dict[str, Any]: return self.data @property def source(self) -> str: """Return a string identifying the configuration source for error messages.""" if self._source is not None: return self._source return "dict" @source.setter def source(self, value: str) -> None: self._source = value BrianPugh-cyclopts-921b1fa/cyclopts/config/_env.py000066400000000000000000000050051517576204000222530ustar00rootroot00000000000000import os from typing import TYPE_CHECKING from attrs import define, field from cyclopts.argument import Argument, ArgumentCollection, Token if TYPE_CHECKING: from cyclopts.core import App def _transform(s: str) -> str: return s.upper().replace("-", "_").replace(".", "_").lstrip("_") @define class Env: prefix: str = "" source: str = field(default="env", kw_only=True) command: bool = field(default=True, kw_only=True) show: bool = field(default=True, kw_only=True) def _prefix(self, commands: tuple[str, ...]) -> str: prefix = self.prefix if self.command and commands: prefix += "_".join(x.upper() for x in commands) + "_" return prefix def _convert_argument(self, commands: tuple[str, ...], argument: Argument) -> str: """For generating environment variable names for the help-page. Internal Cyclopts use only. """ return self._prefix(commands) + _transform(argument.name) def __call__(self, app: "App", commands: tuple[str, ...], arguments: ArgumentCollection): added_tokens = set() prefix = self._prefix(commands) candidate_env_keys = [x for x in os.environ if x.startswith(prefix)] candidate_env_keys.sort() delimiter = "_" for candidate_env_key in candidate_env_keys: try: argument, remaining_keys, _ = arguments.match( candidate_env_key[len(prefix) :], transform=_transform, delimiter=delimiter, ) except ValueError: continue if set(argument.tokens) - added_tokens: # Skip if there are any tokens from another source. continue # There's inherently an ambiguity because we use "_" as the key-delimiter. # However, we can somewhat resolve this ambiguity by checking if the argument # accepts subkeys. If there are no children arguments, then just re-combine the # remaining_keys. if not argument.children and remaining_keys: remaining_keys = (delimiter.join(remaining_keys),) remaining_keys = tuple(x.lower() for x in remaining_keys) for i, value in enumerate(argument.env_var_split(os.environ[candidate_env_key])): token = Token(keyword=candidate_env_key, value=value, source=self.source, index=i, keys=remaining_keys) argument.append(token) added_tokens.add(token) BrianPugh-cyclopts-921b1fa/cyclopts/config/_json.py000066400000000000000000000006431517576204000224370ustar00rootroot00000000000000import json from pathlib import Path from typing import Any from cyclopts.config._common import ConfigFromFile from cyclopts.exceptions import CoercionError class Json(ConfigFromFile): def _load_config(self, path: Path) -> dict[str, Any]: with path.open() as f: try: return json.load(f) except json.JSONDecodeError as e: raise CoercionError from e BrianPugh-cyclopts-921b1fa/cyclopts/config/_toml.py000066400000000000000000000010511517576204000224330ustar00rootroot00000000000000from pathlib import Path from typing import Any from cyclopts.config._common import ConfigFromFile class Toml(ConfigFromFile): def _load_config(self, path: Path) -> dict[str, Any]: try: # Attempt to use builtin >=python3.11 import tomllib # pyright: ignore[reportMissingImports] except ImportError: # Fallback to most popular pypi toml package. import tomli as tomllib # pyright: ignore[reportMissingImports] with path.open("rb") as f: return tomllib.load(f) BrianPugh-cyclopts-921b1fa/cyclopts/config/_yaml.py000066400000000000000000000005101517576204000224210ustar00rootroot00000000000000from pathlib import Path from typing import Any from cyclopts.config._common import ConfigFromFile class Yaml(ConfigFromFile): def _load_config(self, path: Path) -> dict[str, Any]: from yaml import safe_load # pyright: ignore[reportMissingImports] with path.open() as f: return safe_load(f) BrianPugh-cyclopts-921b1fa/cyclopts/core.py000066400000000000000000003204241517576204000210140ustar00rootroot00000000000000import importlib import inspect import os import sys import traceback from collections.abc import Callable, Coroutine, Iterable, Iterator, Sequence from contextlib import suppress from copy import copy from enum import Enum from functools import lru_cache, partial from itertools import chain from pathlib import Path from types import ModuleType from typing import ( TYPE_CHECKING, Annotated, Any, Literal, Optional, TypeVar, Union, cast, overload, ) from attrs import Factory, define, field from cyclopts.annotations import resolve_annotated from cyclopts.app_stack import AppStack from cyclopts.argument import ArgumentCollection from cyclopts.bind import create_bound_arguments, is_option_like, normalize_tokens from cyclopts.command_spec import CommandSpec from cyclopts.config._env import Env from cyclopts.exceptions import ( CommandCollisionError, CycloptsError, UnknownCommandError, UnknownOptionError, UnusedCliTokensError, ValidationError, ) from cyclopts.group import Group, sort_groups from cyclopts.group_extractors import groups_from_app from cyclopts.panel import CycloptsPanel from cyclopts.parameter import Parameter, validate_command from cyclopts.protocols import Dispatcher from cyclopts.token import Token from cyclopts.utils import ( UNSET, create_error_console_from_console, default_name_transform, help_formatter_converter, optional_to_tuple_converter, sort_key_converter, to_list_converter, to_tuple_converter, ) if sys.version_info < (3, 11): # pragma: no cover pass else: # pragma: no cover pass with suppress(ImportError): # By importing, makes things like the arrow-keys work. # Not available on windows import readline # noqa: F401 if TYPE_CHECKING: from rich.console import Console from cyclopts.docs.types import DocFormat from cyclopts.help import HelpPanel from cyclopts.help.protocols import HelpFormatter from cyclopts._result_action import ResultAction, ResultActionSingle from cyclopts._run import _run_maybe_async_command T = TypeVar("T", bound=Callable[..., Any]) V = TypeVar("V") DEFAULT_FORMAT = "markdown" def _result_action_converter( value: "ResultAction | ResultActionSingle | None", ) -> tuple["ResultActionSingle", ...] | None: """Convert result_action value, ensuring non-empty sequences. Intended to be used in an ``attrs.Field`` for result_action. Raises ValueError if an empty iterable is provided. """ if value is None: return None result = to_tuple_converter(value) if not result: raise ValueError("result_action cannot be an empty sequence") return result class _CannotDeriveCallingModuleNameError(Exception): pass def _get_root_module_name(): """Get the calling package name from the call-stack.""" for elem in inspect.stack(): module = inspect.getmodule(elem.frame) if module is None: continue root_module_name = module.__name__.split(".")[0] if root_module_name == "cyclopts": continue return root_module_name raise _CannotDeriveCallingModuleNameError # pragma: no cover def _validate_default_command(x: Callable[..., Any] | None) -> Callable[..., Any] | None: if isinstance(x, App): raise TypeError("Cannot register a sub-App to default.") return x def _get_version_command(app): if callable(app.version) and inspect.iscoroutinefunction(app.version): return app._version_print_async else: return app.version_print def _apply_parent_defaults_to_app(app: "App", parent_app: "App") -> None: """Apply parent app's group defaults to app if not already set. Parameters ---------- app : App The app to apply defaults to. parent_app : App The parent app to inherit defaults from. """ if app._group_commands is None: app._group_commands = copy(parent_app._group_commands) if app._group_parameters is None: app._group_parameters = copy(parent_app._group_parameters) if app._group_arguments is None: app._group_arguments = copy(parent_app._group_arguments) if app.version is None and parent_app.version is not None: app.version = parent_app.version def _apply_parent_groups_to_kwargs(kwargs: dict[str, Any], parent_app: "App") -> None: """Apply parent app's groups to kwargs dict if not already specified. Parameters ---------- kwargs : dict Keyword arguments dict to modify. parent_app : App The parent app to inherit groups from. """ if "group_commands" not in kwargs: kwargs["group_commands"] = copy(parent_app._group_commands) if "group_parameters" not in kwargs: kwargs["group_parameters"] = copy(parent_app._group_parameters) if "group_arguments" not in kwargs: kwargs["group_arguments"] = copy(parent_app._group_arguments) def _normalize_for_matching(s: str) -> str: """Normalize a string for fuzzy command matching. Removes hyphens, underscores, and converts to lowercase (e.g., 'mycommand' matches 'my-command'). .. warning:: This fuzzy matching is primarily for backward compatibility with the introduction of ``_pascal_to_snake`` in ``default_name_transform``. It should **probably be removed in v5** once users have migrated their camelCase command names. Parameters ---------- s : str String to normalize. Returns ------- str Normalized string with hyphens/underscores removed and lowercased. """ return s.replace("-", "").replace("_", "").lower() def _combined_meta_command_mapping( app: Optional["App"], recurse_meta=True, recurse_parent_meta=True ) -> dict[str, "App | CommandSpec"]: """Return a mapping of command names to Apps or CommandSpecs. CommandSpec instances are NOT resolved here - they are resolved lazily only when the command is actually executed, enabling true lazy loading. Parameters ---------- app : App | None The app to get commands from. recurse_meta : bool If True, include commands from the app's meta. recurse_parent_meta : bool If True, include commands from parent meta apps. Returns ------- dict[str, App | CommandSpec] Mapping of command names to :class:`App` or :class:`CommandSpec` instances. """ if app is None: return {} command_mapping = dict(app._commands) # Add flattened subapp commands (parent commands take precedence) for subapp in app._flattened_subapps: for cmd_name in subapp: if cmd_name not in command_mapping: command_mapping[cmd_name] = subapp[cmd_name] if recurse_meta and app._meta: command_mapping.update(_combined_meta_command_mapping(app._meta, recurse_parent_meta=False)) if recurse_parent_meta and app._meta_parent: meta_parent_commands = _combined_meta_command_mapping(app._meta_parent, recurse_meta=False) command_mapping.update(meta_parent_commands) return command_mapping def _walk_metas(app: "App"): """Typically the result looks like [app] or [meta_app, app]. Iterates from deepest to shallowest meta-app (and app). """ meta_list = [app] # shallowest to deepest meta = app while (meta := meta._meta) and meta.default_command: meta_list.append(meta) yield from reversed(meta_list) def _iter_resolution_argument_collections( execution_path: Sequence["App"] | None, fallback_app: "App | None" = None, *, parse_docstring: bool, ) -> Iterator[tuple["App", ArgumentCollection]]: """Yield ``(subapp, argument_collection)`` for each app contributing parameters to a help page. Pairs ``App._get_resolution_context`` with ``App.assemble_argument_collection`` so that the usage-line, help-panel, and completion generators all see the same set of contributing apps. Callers must already be inside the relevant ``app_stack`` context if the assembled collection depends on resolved stack values. ``execution_path`` is used when available (help/completion paths). ``fallback_app`` is used for standalone calls (e.g. docs generation) that walk only the app's own meta chain. """ if execution_path: resolution_apps = execution_path[-1]._get_resolution_context(execution_path) elif fallback_app is not None: resolution_apps = list(_walk_metas(fallback_app)) else: return for subapp in resolution_apps: if subapp.default_command: yield subapp, subapp.assemble_argument_collection(parse_docstring=parse_docstring) def _group_converter(input_value: None | str | Group) -> Group | None: if input_value is None: return None elif isinstance(input_value, str): return Group(input_value) elif isinstance(input_value, Group): return input_value else: raise TypeError _ConfigCallable = Callable[["App", "tuple[str, ...]", "ArgumentCollection"], Any] def _app_optional_name_converter(value: str | Iterable[str] | None) -> tuple[str, ...] | None: return optional_to_tuple_converter(value) # type: ignore[return-value] def _app_str_tuple_converter(value: str | Iterable[str] | None) -> tuple[str, ...]: return to_tuple_converter(value) # type: ignore[return-value] def _app_config_converter( value: "_ConfigCallable | Iterable[_ConfigCallable] | None", ) -> "tuple[_ConfigCallable, ...] | None": return optional_to_tuple_converter(value) # type: ignore[return-value] def _app_group_tuple_converter(value: Group | str | Iterable[Group | str] | None) -> tuple[Group | str, ...]: return cast(tuple[Group | str, ...], to_tuple_converter(value)) def _app_validator_converter( value: Callable[..., Any] | Iterable[Callable[..., Any]] | None, ) -> list[Callable[..., Any]]: return to_list_converter(value) # type: ignore[return-value] @define class App: # This can ONLY ever be Tuple[str, ...] due to converter. # The other types is to make mypy happy for Cyclopts users. _name: None | str | tuple[str, ...] = field(default=None, alias="name", converter=_app_optional_name_converter) _help: str | None = field(default=None, alias="help") usage: str | None = field(default=None) # Everything below must be kw_only alias: None | str | tuple[str, ...] = field( default=None, converter=_app_str_tuple_converter, kw_only=True, ) default_command: Callable[..., Any] | None = field(default=None, converter=_validate_default_command, kw_only=True) default_parameter: Parameter | None = field(default=None, kw_only=True) # This can ONLY ever be None or Tuple[Callable, ...] _config: ( None | Callable[["App", tuple[str, ...], ArgumentCollection], Any] | Iterable[Callable[["App", tuple[str, ...], ArgumentCollection], Any]] ) = field( default=None, alias="config", converter=_app_config_converter, kw_only=True, ) version: None | str | Callable[..., str] | Callable[..., Coroutine[Any, Any, str]] = field( default=None, kw_only=True ) # This can ONLY ever be a Tuple[str, ...] _version_flags: str | Iterable[str] = field( default=["--version"], converter=_app_str_tuple_converter, alias="version_flags", kw_only=True, ) show: bool = field(default=True, kw_only=True) _console: Optional["Console"] = field(default=None, kw_only=True, alias="console") _error_console: Optional["Console"] = field(default=None, kw_only=True, alias="error_console") # This can ONLY ever be a Tuple[str, ...] _help_flags: str | Iterable[str] = field( default=["--help", "-h"], converter=_app_str_tuple_converter, alias="help_flags", kw_only=True, ) help_format: Literal["markdown", "md", "plaintext", "restructuredtext", "rst", "rich"] | None = field( default=None, kw_only=True ) help_on_error: bool | None = field(default=None, kw_only=True) help_prologue: str | None = field(default=None, kw_only=True) help_epilogue: str | None = field(default=None, kw_only=True) version_format: Literal["markdown", "md", "plaintext", "restructuredtext", "rst", "rich"] | None = field( default=None, kw_only=True ) # This can ONLY ever be Tuple[Union[Group, str], ...] due to converter. # The other types is to make mypy happy for Cyclopts users. group: Group | str | tuple[Group | str, ...] = field( default=None, converter=_app_group_tuple_converter, kw_only=True ) # This can ONLY ever be a Group or None _group_arguments: Group | str | None = field( alias="group_arguments", default=None, converter=_group_converter, kw_only=True, ) # This can ONLY ever be a Group or None _group_parameters: Group | str | None = field( alias="group_parameters", default=None, converter=_group_converter, kw_only=True, ) # This can ONLY ever be a Group or None _group_commands: Group | str | None = field( alias="group_commands", default=None, converter=_group_converter, kw_only=True, ) validator: list[Callable[..., Any]] = field(default=None, converter=_app_validator_converter, kw_only=True) _name_transform: Callable[[str], str] | None = field( default=None, alias="name_transform", kw_only=True, ) _sort_key: Any = field( default=None, alias="sort_key", converter=sort_key_converter, kw_only=True, ) end_of_options_delimiter: str | None = field(default=None, kw_only=True) print_error: bool | None = field(default=None, kw_only=True) exit_on_error: bool | None = field(default=None, kw_only=True) verbose: bool | None = field(default=None, kw_only=True) suppress_keyboard_interrupt: bool = field(default=True, kw_only=True) backend: Literal["asyncio", "trio"] | None = field(default=None, kw_only=True) help_formatter: Union[None, Literal["default", "plain"], "HelpFormatter"] = field( default=None, converter=help_formatter_converter, kw_only=True ) error_formatter: Callable[["CycloptsError"], Any] | None = field(default=None, kw_only=True) # This can ONLY ever be None or Tuple[ResultActionSingle, ...] due to converter. # The other types is to make type checkers happy for Cyclopts users. result_action: ResultAction | ResultActionSingle | None = field( default=None, converter=_result_action_converter, kw_only=True, ) ###################### # Private Attributes # ###################### # `init=False` tells attrs not to include it in the generated __init__ # Maps CLI-name of a command to either an App or a CommandSpec (lazy). _commands: dict[str, "App | CommandSpec"] = field(init=False, factory=dict) # Subapps whose commands should be flattened into this app (registered via name="*") _flattened_subapps: list["App"] = field(init=False, factory=list) _meta: Optional["App"] = field(init=False, default=None) _meta_parent: Optional["App"] = field(init=False, default=None) _instantiating_module_name: str | None = field(init=False, default=None, repr=False) """Module name (e.g., '__main__' or 'mypackage.cli') captured during App initialization. Captured from the calling frame's __name__ for lazy module resolution and automatic version detection. Populated in __attrs_post_init__ via frame introspection. Used by the _instantiating_module property to lazily resolve the actual module object. This optimization avoids the expensive inspect.getmodule() call at init time, deferring it until the module is actually needed (typically for --version). """ _instantiating_module_cache: ModuleType | None | type[UNSET] = field(init=False, default=UNSET, repr=False) """Cached module object resolved from _instantiating_module_name. Starts as UNSET sentinel value. On first access via the _instantiating_module property, the module name is resolved to a module object from sys.modules and cached here. Subsequent accesses return this cached value without re-resolution. Set to None if module name was not captured or module is not in sys.modules. """ _fallback_console: Optional["Console"] = field(init=False, default=None) _fallback_error_console: Optional["Console"] = field(init=False, default=None) app_stack: AppStack = field(init=False, default=Factory(AppStack, takes_self=True)) def __attrs_post_init__(self): # Trigger the setters self.help_flags = self._help_flags self.version_flags = self._version_flags # Capture the module name from the instantiating frame. # This is cheap (just dict lookup) compared to inspect.getmodule(). # inspect.stack()[2] is needed in attrs class because the call stack is deeper: # [0]: __attrs_post_init__ # [1]: the attrs-generated __init__ # [2]: the caller who created the instance try: frame = sys._getframe(2) self._instantiating_module_name = frame.f_globals.get("__name__") except (IndexError, AttributeError): self._instantiating_module_name = None ########### # Methods # ########### def _delete_commands(self, commands: Iterable[str]): """Safely delete commands. Will **not** raise an exception if command(s) do not exist. Parameters ---------- commands: Iterable[str, ...] Strings of commands to delete. """ # Remove all the old version-flag commands. for command in commands: try: del self[command] except KeyError: pass @property def version_flags(self): return self._version_flags @version_flags.setter def version_flags(self, value): self._delete_commands(self._version_flags) self._version_flags = value if self._version_flags: self.command( self.version_print, name=self._version_flags, help_flags=[], version_flags=[], version=self.version, help="Display application version.", ) @property def help_flags(self): return self._help_flags @help_flags.setter def help_flags(self, value): self._delete_commands(self._help_flags) self._help_flags = value if self._help_flags: self.command( self.help_print, name=self._help_flags, help_flags=[], version_flags=[], version=self.version, help="Display this message and exit.", ) @property def name(self) -> tuple[str, ...]: """Application name(s). Dynamically derived if not previously set.""" if self._name: return self._name + self.alias # pyright: ignore elif self.default_command is None: name = Path(sys.argv[0]).name if name == "__main__.py": name = _get_root_module_name() return (name,) + self.alias # pyright: ignore else: try: func_name = self.default_command.__name__ except AttributeError: # This could happen if default_command is wrapped in a functools.partial func_name = self.default_command.func.__name__ # pyright: ignore[reportFunctionMemberAccess] return (self.name_transform(func_name),) + self.alias # pyright: ignore @property def group_arguments(self): if self._group_arguments is None: return Group.create_default_arguments() return self._group_arguments @group_arguments.setter def group_arguments(self, value): self._group_arguments = value @property def group_parameters(self): if self._group_parameters is None: return Group.create_default_parameters() return self._group_parameters @group_parameters.setter def group_parameters(self, value): self._group_parameters = value @property def group_commands(self): if self._group_commands is None: return Group.create_default_commands() return self._group_commands @group_commands.setter def group_commands(self, value): self._group_commands = value @property def config(self): return self.app_stack.resolve("_config") @config.setter def config(self, value): self._config = value @property def help(self) -> str: if self._help is not None: return self._help elif self.default_command is None: # Try and fallback to a meta-app docstring. if self._meta is None: return "" else: return self.meta.help else: # Try to handle a potential partial function if "functools" in sys.modules: from functools import partial if isinstance(self.default_command, partial): doc = self.default_command.func.__doc__ else: doc = self.default_command.__doc__ else: doc = self.default_command.__doc__ if doc is None: return "" else: return doc @help.setter def help(self, value): self._help = value @property def name_transform(self): return self._name_transform if self._name_transform else default_name_transform @name_transform.setter def name_transform(self, value): self._name_transform = value @property def sort_key(self): return None if self._sort_key is UNSET else self._sort_key @sort_key.setter def sort_key(self, value): self._sort_key = sort_key_converter(value) @property def _registered_commands(self) -> dict[str, "App"]: """Commands that are not help or version commands. This includes commands from flattened subapps. """ out = {} for x in self: if x in self.help_flags or x in self.version_flags: continue out[x] = self[x] return out @property def console(self) -> "Console": result = self.app_stack.resolve("_console") if result is not None: return result # We always want to return back the same console object, # but if someone manually overrides `console`, then # we want to return that. if self._fallback_console is None: from rich.console import Console self._fallback_console = Console() return self._fallback_console @console.setter def console(self, console: Optional["Console"]): self._console = console @property def error_console(self) -> "Console": result = self.app_stack.resolve("_error_console") if result is not None: return result if self._fallback_error_console is None: self._fallback_error_console = create_error_console_from_console(self.console) return self._fallback_error_console @error_console.setter def error_console(self, console: Optional["Console"]): self._error_console = console @property def _instantiating_module(self) -> ModuleType | None: """Lazily resolve the module name to a module object.""" if self._instantiating_module_cache is UNSET: if self._instantiating_module_name: self._instantiating_module_cache = sys.modules.get(self._instantiating_module_name) else: self._instantiating_module_cache = None return cast(ModuleType | None, self._instantiating_module_cache) def _get_fallback_version_string(self, default: str = "0.0.0") -> str: """Get the version string with multiple fallback strategies. First tries to derive from the instantiating module, then tries to get it from the calling code's module, and finally falls back to a default. Parameters ---------- default : str Default version to use if no version can be determined. Returns ------- str Version string. """ from importlib.metadata import PackageNotFoundError from importlib.metadata import version as importlib_metadata_version if self._instantiating_module is not None: full_module_name = self._instantiating_module.__name__ root_module_name = full_module_name.split(".")[0] try: return importlib_metadata_version(root_module_name) except PackageNotFoundError: pass try: return self._instantiating_module.__version__ # type: ignore[attr-defined] except AttributeError: pass try: root_module_name = _get_root_module_name() except _CannotDeriveCallingModuleNameError: # pragma: no cover return default try: return importlib_metadata_version(root_module_name) except PackageNotFoundError: pass # Attempt packagename.__version__ # Not sure if this is redundant with ``importlib.metadata``, # but there's no real harm in checking. try: module = importlib.import_module(root_module_name) return module.__version__ # type: ignore[attr-defined] except (ImportError, AttributeError): pass return default def _format_and_print_version(self, version_raw: str, console: Optional["Console"]) -> None: """Format and print the version string. Parameters ---------- version_raw : str Raw version string to format and print. console : ~rich.console.Console Console to print to. """ from cyclopts.help import InlineText version_format = self.app_stack.resolve("version_format") if version_format is None: version_format = self.app_stack.resolve("help_format", fallback=DEFAULT_FORMAT) version_formatted = InlineText.from_format(version_raw, format=version_format) (console or self.console).print(version_formatted) def version_print( self, console: Annotated[Optional["Console"], Parameter(parse=False)] = None, ) -> None: """Print the application version. Parameters ---------- console: ~rich.console.Console Console to print version string to. If not provided, follows the resolution order defined in :attr:`App.console`. """ if self.version is not None: if callable(self.version): # Note: async version callables are handled by _version_print_async if inspect.iscoroutinefunction(self.version): raise ValueError("async version handler detected. Use App.run_async within an async context.") version_raw = cast(str, self.version()) else: version_raw = self.version else: version_raw = self._get_fallback_version_string() self._format_and_print_version(version_raw, console) async def _version_print_async( self, console: Annotated[Optional["Console"], Parameter(parse=False)] = None, ) -> None: """Async version of version_print for handling async version callables. Parameters ---------- console: ~rich.console.Console Console to print version string to. If not provided, follows the resolution order defined in :attr:`App.console`. """ if self.version is not None: if callable(self.version): if inspect.iscoroutinefunction(self.version): version_raw = await self.version() else: # This should never happen, since if ``self.version`` is callable # and not async, then we would be using ``App.version_print``. # This is only here for completeness. version_raw = cast(str, self.version()) else: version_raw = self.version else: version_raw = self._get_fallback_version_string() self._format_and_print_version(version_raw, console) @property def subapps(self): for k in self: yield self[k] def resolved_commands(self) -> dict[str, "App"]: """Get all commands as resolved App instances. This function resolves any lazy-loaded commands (CommandSpec) into App instances. Note: This will import modules for all lazy-loaded commands, which may impact performance and memory usage. Consider accessing commands individually via ``app["command_name"]`` if you don't need all commands at once. Returns ------- dict[str, App] Mapping of command names to resolved :class:`App` instances. Examples -------- .. code-block:: python from cyclopts import App app = App() app.command("myapp.commands:create") app.command("myapp.commands:delete") # Resolve all lazy commands commands = app.resolved_commands() assert "create" in commands assert isinstance(commands["create"], App) """ resolved = { name: cmd.resolve(self) if isinstance(cmd, CommandSpec) else cmd for name, cmd in self._commands.items() } # Add flattened subapp commands (parent commands take precedence) for subapp in self._flattened_subapps: for cmd_name in subapp: if cmd_name not in resolved: resolved[cmd_name] = subapp[cmd_name] return resolved def __getitem__(self, key: str) -> "App": """Get the subapp from a command string. All commands get registered to Cyclopts as subapps. The actual function handler is at ``app[key].default_command``. If the command was registered via lazy loading (import path string), it will be imported and resolved on first access. Example usage: .. code-block:: python from cyclopts import App app = App() app.command(App(name="foo")) @app["foo"].command def bar(): print("Running bar.") app() """ cmd = self._get_item(key) # Resolve lazy commands on access if isinstance(cmd, CommandSpec): return cmd.resolve(self) return cmd def _get_item(self, key, recurse_meta=True) -> "App | CommandSpec": """Internal getter that returns App or unresolved CommandSpec.""" if recurse_meta and self._meta: with suppress(KeyError): return self.meta._get_item(key, recurse_meta=True) if self._meta_parent: with suppress(KeyError): return self._meta_parent._get_item(key, recurse_meta=False) # Check local commands first if key in self._commands: return self._commands[key] # Check flattened subapps for subapp in self._flattened_subapps: with suppress(KeyError): return subapp._get_item(key, recurse_meta=False) raise KeyError(key) def __delitem__(self, key: str): del self._commands[key] def __contains__(self, k: str) -> bool: if k in self._commands: return True if self._meta_parent: if k in self._meta_parent: return True for subapp in self._flattened_subapps: if k in subapp: return True return False def __iter__(self) -> Iterator[str]: """Iterate over command & meta command names. Example usage: .. code-block:: python from cyclopts import App app = App() @app.command def foo(): pass @app.command def bar(): pass # help and version flags are treated as commands. assert list(app) == ["--help", "-h", "--version", "foo", "bar"] """ commands = list(self._commands) yield from commands commands = set(commands) if self._meta_parent: for command in self._meta_parent: if command not in commands: yield command commands.add(command) for subapp in self._flattened_subapps: for command in subapp: if command not in commands: yield command commands.add(command) @property def meta(self) -> "App": if self._meta is None: self._meta = type(self)( help_flags=self.help_flags, version_flags=self.version_flags, group_commands=copy(self._group_commands), group_arguments=copy(self._group_arguments), group_parameters=copy(self._group_parameters), result_action=self.result_action, ) self._meta._meta_parent = self return self._meta def parse_commands( self, tokens: None | str | Iterable[str] = None, *, include_parent_meta=True, ) -> tuple[tuple[str, ...], tuple["App", ...], list[str]]: """Extract out the command tokens from a command. You are probably actually looking for :meth:`parse_args`. Parameters ---------- tokens: None | str | Iterable[str] Either a string, or a list of strings to launch a command. Defaults to ``sys.argv[1:]`` include_parent_meta: bool Controls whether parent meta apps are included in the execution path. When True (default): - Parent meta apps (i.e. the "normal" app ) are added to the apps list. - Meta app options are consumed while parsing commands. - Used for getting the inheritance hierarchy. When False: - Meta app options are treated as regular arguments. - Used for getting the execution hierarchy. This parameter is primarily for internal use. Returns ------- tuple[str, ...] Strings that are interpreted as a valid command chain. tuple[App, ...] The execution path - apps that will be invoked in order. list[str] The remaining non-command tokens. """ tokens = normalize_tokens(tokens) command_chain = [] app = self apps: list[App] = [] unused_tokens = tokens def add_parent_metas(app): """If ``app`` is a meta-app, also add it's "normal" app. We assume that ``app._meta`` will always invoke the ``app``. """ if not include_parent_meta: return meta_parents = [] meta_parent = app while (meta_parent := meta_parent._meta_parent) is not None: meta_parents.append(meta_parent) # The "root" non-meta app gets highest priority (first) apps.extend(meta_parents[::-1]) add_parent_metas(app) apps.append(app) command_mapping = _combined_meta_command_mapping(app, recurse_parent_meta=include_parent_meta) unused_tokens = tokens while unused_tokens: token = unused_tokens[0] app_or_spec = None # Try exact match first; O(1) if token in command_mapping: app_or_spec = command_mapping[token] # Don't apply fuzzy matching to option-like tokens (starting with -) # Fuzzy matching is for camelCase command names, not for flags like --h matching -h # Issue #698 elif not token.startswith("-"): # Try fuzzy match (backward compatibility for camelCase commands) O(n). # NOTE: This fuzzy matching is for v4 backward compatibility with # _pascal_to_snake introduction. Consider removing in v5. normalized_token = _normalize_for_matching(token) # Also exclude option-like commands (--help, --version, etc.) from fuzzy matching. # Prevents "version" from matching to "--version" matches = [ cmd_name for cmd_name in command_mapping if not cmd_name.startswith("-") and _normalize_for_matching(cmd_name) == normalized_token ] if len(matches) == 1: # Single fuzzy match found app_or_spec = command_mapping[matches[0]] elif len(matches) > 1: # Ambiguous match - multiple commands match after normalization raise ValueError(f"Ambiguous command '{token}'. Could match: {', '.join(sorted(matches))}.") if app_or_spec is None: # Token is not a command. Try to consume it as a meta app parameter. # This is only relevant when ``include_parent_meta==True``, because # otherwise it will be handled by the natural parsing process. if include_parent_meta: remaining = self._consume_leading_meta_options(apps, unused_tokens) if len(remaining) < len(unused_tokens): # Some meta parameters were consumed, continue looking for commands unused_tokens = remaining continue # Not a command or meta parameter, stop parsing commands break # Resolve CommandSpec if needed (lazy loading) # Note: CommandSpec.resolve() has built-in caching via its _resolved field # Pass the current app as parent to inherit its defaults if isinstance(app_or_spec, CommandSpec): parent_app = app # Save parent before overwriting app = app_or_spec.resolve(parent_app) else: app = app_or_spec # Found a command - add it to the chain add_parent_metas(app) apps.append(app) command_mapping = _combined_meta_command_mapping(app, recurse_parent_meta=include_parent_meta) command_chain.append(token) unused_tokens = unused_tokens[1:] return tuple(command_chain), tuple(apps), unused_tokens def _get_resolution_context(self, execution_path: Sequence["App"]) -> list["App"]: """Get all apps that contribute to parameter resolution for the given execution path. This includes parent meta apps and the meta app of the final command app. Parameters ---------- execution_path : Sequence[App] The execution path returned from parse_commands. Returns ------- list[App] All apps that contribute configuration and parameters, ordered by priority. """ apps = [] # For the last app in execution path, include it and its meta if execution_path: last_app = execution_path[-1] # Include all metas from walk_metas (includes the app itself) for app in _walk_metas(last_app): if app not in apps: apps.append(app) # Include parent metas from the stack if they exist if last_app.app_stack.stack: for app in last_app.app_stack.stack[-1]: # Include the app's meta if it exists and isn't already in the list if app._meta and app._meta not in apps: # Check if last_app is a command from the meta app is_meta_command = last_app in app._meta._commands.values() if not is_meta_command: apps.append(app._meta) return apps def _consume_leading_meta_options(self, apps: list["App"], tokens: list[str]) -> list[str]: """Consume meta app options from the beginning of the token stream. This is used to skip over meta app parameters when looking for commands. Limitation: positional parameters for the meta app are NOT skipped. This is because we do not know which meta-parameters are for the meta-app itself vs which it will pass along to the normal app. Parameters ---------- apps: list[App] Current app stack including parent meta apps. tokens: list[str] Tokens to try parsing, starting from current position. Returns ------- list[str] The remaining unused tokens after consuming any leading meta options. """ if not apps or not tokens: return tokens from cyclopts.bind import _parse_kw_and_flags # Resolve end_of_options_delimiter from the partially-resolved app stack with self.app_stack(apps): end_of_options_delimiter = self.app_stack.resolve("end_of_options_delimiter", fallback="--") # Collect meta apps that could have parameters # We need both: # 1. Meta apps in the current stack (apps that ARE meta apps) # 2. The meta app of the current context (if it exists) meta_apps_to_try = [app for app in apps if app._meta_parent is not None and app.default_command] # Add the current app's meta if it exists if apps[-1]._meta and apps[-1]._meta.default_command: meta_apps_to_try.append(apps[-1]._meta) # Try to parse with each meta app's parameters unused_tokens = tokens for meta_app in meta_apps_to_try: try: argument_collection = meta_app.assemble_argument_collection() # Try to consume tokens with this meta app's parameters # stop_at_first_unknown=True ensures we only consume contiguous leading options unused_tokens, _ = _parse_kw_and_flags( argument_collection, unused_tokens, end_of_options_delimiter=end_of_options_delimiter, stop_at_first_unknown=True, ) except Exception: # If parsing fails, try next meta app continue return unused_tokens # This overload is used in code like: # # @app.command # def my_command(foo: str): # ... @overload def command( # pragma: no cover self, obj: T, name: None | str | Iterable[str] = None, *, alias: None | str | Iterable[str] = None, **kwargs: object, ) -> T: ... # This overload is used in code like: # # @app.command(name="bar") # def my_command(foo: str): # ... @overload def command( # pragma: no cover self, obj: None = None, name: None | str | Iterable[str] = None, *, alias: None | str | Iterable[str] = None, **kwargs: object, ) -> Callable[[T], T]: ... # This overload is used for lazy loading: # # app.command("mymodule.commands:create_user", name="create") @overload def command( # pragma: no cover self, obj: str, name: None | str | Iterable[str] = None, *, alias: None | str | Iterable[str] = None, **kwargs: object, ) -> None: ... def command( self, obj: T | None | str = None, name: None | str | Iterable[str] = None, *, alias: None | str | Iterable[str] = None, **kwargs: object, ) -> T | Callable[[T], T] | None: """Decorator to register a function as a CLI command. Example usage: .. code-block:: from cyclopts import App app = App() @app.command def foo(): print("foo!") @app.command(name="buzz") def bar(): print("bar!") # Lazy loading via import path app.command("myapp.commands:create_user", name="create") app() .. code-block:: console $ my-script foo foo! $ my-script buzz bar! $ my-script create # Imports and runs myapp.commands:create_user Parameters ---------- obj: Callable | App | str | None Function, :class:`App`, or import path string to be registered as a command. For lazy loading, provide a string in format "module.path:function_or_app_name". name: None | str | Iterable[str] Name(s) to register the command to. If not provided, defaults to: * If registering an :class:`App`, then the app's name. * If registering a **function**, then the function's name after applying :attr:`name_transform`. * If registering via **import path**, then the attribute name after applying :attr:`name_transform`. Special value ``"*"`` flattens all sub-App commands into this app (App instances only). See :ref:`Flattening SubCommands` for details. `**kwargs` Any argument that :class:`App` can take. """ if obj is None: # Called ``@app.command(...)`` return partial(self.command, name=name, alias=alias, **kwargs) # pyright: ignore[reportReturnType] # Handle flattening: app.command(subapp, name="*") if name == "*": if not isinstance(obj, App): raise TypeError( 'Flattening (name="*") is only supported for App instances, not functions or import paths.' ) if kwargs: raise ValueError('Cannot supply additional configuration when flattening a sub-App (name="*").') _apply_parent_defaults_to_app(obj, self) self._flattened_subapps.append(obj) return obj # pyright: ignore[reportReturnType] # Convert string path to a CommandSpec if isinstance(obj, str): # Determine command name(s) if name is None: # Extract from import path: "myapp.commands:create_user" -> "create-user" _, _, func_name = obj.rpartition(":") name = (self.name_transform(func_name),) else: name = to_tuple_converter(name) if alias is None: alias = () else: alias = to_tuple_converter(alias) # Create CommandSpec with the resolved name (first name if multiple) # The name will be used when wrapping functions in an App # Pop CommandSpec-specific fields from kwargs before storing the rest as app_kwargs spec = CommandSpec( import_path=obj, name=name[0] if name else None, help=kwargs.pop("help", None), # type: ignore[arg-type] sort_key=kwargs.pop("sort_key", None), group=kwargs.pop("group", None), # type: ignore[arg-type] show=kwargs.pop("show", None), # type: ignore[arg-type] app_kwargs=kwargs, ) # Register the CommandSpec for n in name + alias: if n in self: raise CommandCollisionError(f'Command "{n}" already registered.') self._commands[n] = spec return None if isinstance(obj, App): app = obj if app._name is None and name is None: raise ValueError("Sub-app MUST have a name specified.") if kwargs: raise ValueError("Cannot supplied additional configuration when registering a sub-App.") _apply_parent_defaults_to_app(app, self) else: kwargs.setdefault("help_flags", self.help_flags) kwargs.setdefault("version_flags", self.version_flags) if "version" not in kwargs and self.version is not None: kwargs["version"] = self.version _apply_parent_groups_to_kwargs(kwargs, self) app = type(self)(**kwargs) # pyright: ignore # directly call the default decorator, in case we do additional processing there. app.default(obj) for flag in chain(app.help_flags, app.version_flags): app[flag].show = False if app._name_transform is None: app.name_transform = self.name_transform if name is None: name = app.name else: name = to_tuple_converter(name) if alias is None: alias = () else: alias = to_tuple_converter(alias) for n in name + alias: # pyright: ignore[reportOperatorIssue] if n in self: raise CommandCollisionError(f'Command "{n}" already registered.') self._commands[n] = app return obj # pyright: ignore[reportReturnType] # This overload is used in code like: # # @app.default # def my_command(foo: str): # ... @overload def default( # pragma: no cover self, obj: T, *, validator: Callable[..., Any] | None = None, ) -> T: ... # This overload is used in code like: # # @app.default() # def my_command(foo: str): # ... @overload def default( # pragma: no cover self, obj: None = None, *, validator: Callable[..., Any] | None = None, ) -> Callable[[T], T]: ... def default( self, obj: T | None = None, *, validator: Callable[..., Any] | None = None, ) -> T | Callable[[T], T]: """Decorator to register a function as the default action handler. Example usage: .. code-block:: python from cyclopts import App app = App() @app.default def main(): print("Hello world!") app() .. code-block:: console $ my-script Hello world! """ if obj is None: # Called ``@app.default_command(...)`` return partial(self.default, validator=validator) # pyright: ignore[reportReturnType] if isinstance(obj, App): # Registering a sub-App raise TypeError("Cannot register a sub-App to default.") if self.default_command is not None: raise CommandCollisionError(f"Default command previously set to {self.default_command}.") validate_command(obj) self.default_command = obj if validator: self.validator = validator # pyright: ignore[reportAttributeAccessIssue] return obj def assemble_argument_collection( self, *, default_parameter: Parameter | None = None, parse_docstring: bool = False, ) -> ArgumentCollection: """Assemble the argument collection for this app. Parameters ---------- default_parameter: Parameter | None Default parameter with highest priority. parse_docstring: bool Parse the docstring of :attr:`default_command`. Set to :obj:`True` if we need help strings, otherwise set to :obj:`False` for performance reasons. Returns ------- ArgumentCollection All arguments for this app. """ if self.default_command is None: raise ValueError( "Cannot assemble argument collection: no default command is registered. " "Use @app.default to register a default command, or access a specific " "subcommand's argument collection via app['command_name'].assemble_argument_collection()." ) return ArgumentCollection._from_callable( self.default_command, # pyright: ignore Parameter.combine(self.app_stack.default_parameter, default_parameter), group_arguments=self._group_arguments, # pyright: ignore group_parameters=self._group_parameters, # pyright: ignore parse_docstring=parse_docstring, ) def parse_known_args( self, tokens: None | str | Iterable[str] = None, *, console: Optional["Console"] = None, error_console: Optional["Console"] = None, end_of_options_delimiter: str | None = None, ) -> tuple[Callable[..., Any], inspect.BoundArguments, list[str], dict[str, Any]]: """Interpret arguments into a registered function, :class:`~inspect.BoundArguments`, and any remaining unknown tokens. Parameters ---------- tokens: None | str | Iterable[str] Either a string, or a list of strings to launch a command. Defaults to ``sys.argv[1:]`` console: ~rich.console.Console Console to print help and runtime Cyclopts errors. If not provided, follows the resolution order defined in :attr:`App.console`. error_console: ~rich.console.Console Console to print error messages. If not provided, follows the resolution order defined in :attr:`App.error_console`. end_of_options_delimiter: str | None All tokens after this delimiter will be force-interpreted as positional arguments. If None, inherits from :attr:`App.end_of_options_delimiter`, eventually defaulting to POSIX-standard ``"--"``. Set to an empty string to disable. Returns ------- command: Callable Bare function to execute. bound: inspect.BoundArguments Bound arguments for ``command``. unused_tokens: list[str] Any remaining CLI tokens that didn't get parsed for ``command``. ignored: dict[str, Any] A mapping of python-variable-name to annotated type of any parameter with annotation ``parse=False``. :obj:`~typing.Annotated` will be resolved. Intended to simplify :ref:`meta apps `. """ overrides = { "_console": console, "_error_console": error_console, "end_of_options_delimiter": end_of_options_delimiter, } with self.app_stack([], overrides=overrides): command, bound, unused_tokens, ignored, _ = self._parse_known_args(tokens) return command, bound, unused_tokens, ignored def _parse_known_args( self, tokens: None | str | Iterable[str] = None, *, raise_on_unused_tokens: bool = False, ) -> tuple[Callable[..., Any], inspect.BoundArguments, list[str], dict[str, Any], ArgumentCollection]: if tokens is None: _log_framework_warning(_detect_test_framework()) tokens = normalize_tokens(tokens) meta_parent = self # We need both versions of the apps list: # 1. apps_for_context (with parent metas) - for setting up the app_stack context # 2. execution_apps (without parent metas) - for determining the actual execution command # These can differ when parse_commands is called from a meta app, so we must # call parse_commands twice. This is not inefficient since the parsing is fast. _, apps_for_context, _ = self.parse_commands(tokens, include_parent_meta=True) command_chain, execution_apps, unused_tokens = self.parse_commands(tokens, include_parent_meta=False) # We don't want the command_app to be the version/help handler; we handle those specially command_app = execution_apps[-1] with suppress(IndexError): # Remove trailing help/version commands from the execution chain. # When users provide multiple flags (e.g., "myapp cmd --help --help"), the parser # may treat trailing help/version flags as commands in the chain. We must remove ALL # such trailing commands and keep command_chain synchronized with execution_apps. while command_chain and command_chain[-1] in set( execution_apps[-2].help_flags + execution_apps[-2].version_flags # pyright: ignore[reportOperatorIssue] ): execution_apps = execution_apps[:-1] command_chain = command_chain[:-1] command_app = execution_apps[-1] del execution_apps # Always use AppStack from here-on. ignored: dict[str, Any] = {} with self.app_stack(apps_for_context): config: tuple[Callable, ...] = command_app.app_stack.resolve("_config") or () config = tuple(partial(x, command_app, command_chain) for x in config) end_of_options_delimiter = self.app_stack.resolve("end_of_options_delimiter", fallback="--") # Special flags (help/version) get intercepted by the root app. # Special flags are allows to be **anywhere** in the token stream. help_flag_index = _get_help_flag_index(tokens, command_app.help_flags, end_of_options_delimiter) try: if help_flag_index is not None: # Remove ALL help and version flags from both token lists. # Users can provide multiple flags (e.g., "myapp --help --help --version"). # When help is requested, it takes priority over version, so we remove all # occurrences of both flag types to prevent downstream parsing errors. flags_to_remove = set(command_app.help_flags + command_app.version_flags) # pyright: ignore[reportOperatorIssue] tokens[:] = [t for t in tokens if t not in flags_to_remove] unused_tokens[:] = [t for t in unused_tokens if t not in flags_to_remove] command = self.help_print while meta_parent := meta_parent._meta_parent: command = meta_parent.help_print bound = inspect.signature(command).bind(tokens, console=command_app.console) unused_tokens = [] argument_collection = ArgumentCollection() elif any(flag in tokens for flag in command_app.version_flags): command = _get_version_command(command_app) while meta_parent := meta_parent._meta_parent: command = _get_version_command(meta_parent) bound = inspect.signature(command).bind(console=command_app.console) unused_tokens = [] argument_collection = ArgumentCollection() else: if command_app.default_command: command = command_app.default_command validate_command(command) argument_collection = command_app.assemble_argument_collection() ignored: dict[str, Any] = { argument.field_info.name: resolve_annotated(argument.field_info.annotation) for argument in argument_collection.filter_by(parse=False) } bound, unused_tokens = create_bound_arguments( command_app.default_command, argument_collection, unused_tokens, config, end_of_options_delimiter=end_of_options_delimiter, ) try: for validator in command_app.validator: validator(**bound.arguments) except (AssertionError, ValueError, TypeError) as e: raise ValidationError(exception_message=e.args[0] if e.args else "", app=command_app) from e try: for command_group in command_app.app_stack.command_groups: for validator in command_group.validator: # pyright: ignore validator(**bound.arguments) except (AssertionError, ValueError, TypeError) as e: raise ValidationError( exception_message=e.args[0] if e.args else "", group=command_group, # pyright: ignore ) from e else: if unused_tokens: raise UnknownCommandError(unused_tokens=unused_tokens) else: # Running the application with no arguments and no registered # ``default_command`` will default to ``help_print``. command = self.help_print bound = inspect.signature(command).bind(tokens=tokens, console=command_app.console) unused_tokens = [] argument_collection = ArgumentCollection() if raise_on_unused_tokens and unused_tokens: for token in unused_tokens: if is_option_like(token): token = token.split("=")[0] raise UnknownOptionError( token=Token(keyword=token, source="cli"), argument_collection=argument_collection, ) raise UnusedCliTokensError(target=command, unused_tokens=unused_tokens) except CycloptsError as e: e.target = command_app.default_command e.app = command_app if command_chain: e.command_chain = command_chain if e.console is None: e.console = command_app.error_console raise return command, bound, unused_tokens, ignored, argument_collection def parse_args( self, tokens: None | str | Iterable[str] = None, *, console: Optional["Console"] = None, error_console: Optional["Console"] = None, print_error: bool | None = None, exit_on_error: bool | None = None, help_on_error: bool | None = None, verbose: bool | None = None, end_of_options_delimiter: str | None = None, error_formatter: Callable[["CycloptsError"], Any] | None = None, ) -> tuple[Callable[..., Any], inspect.BoundArguments, dict[str, Any]]: """Interpret arguments into a function and :class:`~inspect.BoundArguments`. Raises ------ UnusedCliTokensError If any tokens remain after parsing. Parameters ---------- tokens: None | str | Iterable[str] Either a string, or a list of strings to launch a command. Defaults to ``sys.argv[1:]``. console: ~rich.console.Console Console to print help and runtime Cyclopts errors. If not provided, follows the resolution order defined in :attr:`App.console`. error_console: ~rich.console.Console Console to print error messages. If not provided, follows the resolution order defined in :attr:`App.error_console`. print_error: bool | None Print a rich-formatted error on error. If :obj:`None`, inherits from :attr:`App.print_error`, eventually defaulting to :obj:`True`. exit_on_error: bool | None If there is an error parsing the CLI tokens invoke ``sys.exit(1)``. Otherwise, continue to raise the exception. If :obj:`None`, inherits from :attr:`App.exit_on_error`, eventually defaulting to :obj:`True`. help_on_error: bool | None Prints the help-page before printing an error. If :obj:`None`, inherits from :attr:`App.help_on_error`, eventually defaulting to :obj:`False`. verbose: bool | None Populate exception strings with more information intended for developers. If :obj:`None`, inherits from :attr:`App.verbose`, eventually defaulting to :obj:`False`. end_of_options_delimiter: str | None All tokens after this delimiter will be force-interpreted as positional arguments. If :obj:`None`, inherits from :attr:`App.end_of_options_delimiter`, eventually defaulting to POSIX-standard ``"--"``. Set to an empty string to disable. Returns ------- command: Callable Function associated with command action. bound: inspect.BoundArguments Parsed and converted ``args`` and ``kwargs`` to be used when calling ``command``. ignored: dict[str, Any] A mapping of python-variable-name to type-hint of any parameter with annotation ``parse=False``. :obj:`~typing.Annotated` will be resolved. Intended to simplify :ref:`meta apps `. """ if tokens is None: _log_framework_warning(_detect_test_framework()) tokens = normalize_tokens(tokens) # Store overrides for nested calls overrides = { k: v for k, v in { "_console": console, "_error_console": error_console, "print_error": print_error, "exit_on_error": exit_on_error, "help_on_error": help_on_error, "verbose": verbose, "end_of_options_delimiter": end_of_options_delimiter, "error_formatter": error_formatter, }.items() if v is not None } # overrides isn't being propagated to subcommands because they aren't provided to the context manager here. with self.app_stack([], overrides=overrides): try: command, bound, _, ignored, _ = self._parse_known_args( tokens, raise_on_unused_tokens=True, ) except CycloptsError as e: print_error = self.app_stack.resolve("print_error") exit_on_error = self.app_stack.resolve("exit_on_error") help_on_error = self.app_stack.resolve("help_on_error") verbose = self.app_stack.resolve("verbose") e.verbose = verbose if verbose is not None else False e.root_input_tokens = tokens assert e.console is not None if help_on_error if help_on_error is not None else False: self.help_print(tokens, console=e.console) if print_error if print_error is not None else True: resolved_error_formatter = self.app_stack.resolve("error_formatter") if resolved_error_formatter is not None: e.console.print(resolved_error_formatter(e)) else: e.console.print(CycloptsPanel(e)) if exit_on_error if exit_on_error is not None else True: sys.exit(1) raise return command, bound, ignored def _is_nested_call(self) -> bool: """Check if this is a nested call (meta app pattern or same-app recursion).""" return len(self.app_stack.overrides_stack) > 1 or ( self._meta is not None and len(self._meta.app_stack.overrides_stack) > 1 ) def __call__( self, tokens: None | str | Iterable[str] = None, *, console: Optional["Console"] = None, error_console: Optional["Console"] = None, print_error: bool | None = None, exit_on_error: bool | None = None, help_on_error: bool | None = None, verbose: bool | None = None, end_of_options_delimiter: str | None = None, backend: Literal["asyncio", "trio"] | None = None, result_action: ResultAction | None = None, error_formatter: Callable[["CycloptsError"], Any] | None = None, ) -> Any: """Interprets and executes a command. Parameters ---------- tokens : None | str | Iterable[str] Either a string, or a list of strings to launch a command. Defaults to ``sys.argv[1:]``. console: ~rich.console.Console Console to print help and runtime Cyclopts errors. If not provided, follows the resolution order defined in :attr:`App.console`. error_console: ~rich.console.Console Console to print error messages. If not provided, follows the resolution order defined in :attr:`App.error_console`. print_error: bool | None Print a rich-formatted error on error. If :obj:`None`, inherits from :attr:`App.print_error`, eventually defaulting to :obj:`True`. exit_on_error: bool | None If there is an error parsing the CLI tokens invoke ``sys.exit(1)``. Otherwise, continue to raise the exception. If :obj:`None`, inherits from :attr:`App.exit_on_error`, eventually defaulting to :obj:`True`. help_on_error: bool | None Prints the help-page before printing an error. If :obj:`None`, inherits from :attr:`App.help_on_error`, eventually defaulting to :obj:`False`. verbose: bool | None Populate exception strings with more information intended for developers. If :obj:`None`, inherits from :attr:`App.verbose`, eventually defaulting to :obj:`False`. end_of_options_delimiter: str | None All tokens after this delimiter will be force-interpreted as positional arguments. If :obj:`None`, inherits from :attr:`App.end_of_options_delimiter`, eventually defaulting to POSIX-standard ``"--"``. Set to an empty string to disable. backend: Literal["asyncio", "trio"] | None Override the async backend to use (if an async command is invoked). If :obj:`None`, inherits from :attr:`App.backend`, eventually defaulting to "asyncio". If passing backend="trio", ensure trio is installed via the extra: `cyclopts[trio]`. result_action: ResultAction | None Controls how command return values are handled. Can be a predefined literal string or a custom callable that takes the result and returns a processed value. If :obj:`None`, inherits from :attr:`App.result_action`, eventually defaulting to "print_non_int_return_int_as_exit_code". See :attr:`App.result_action` for available modes. Returns ------- return_value: Any The value the command function returns. """ if tokens is None: _log_framework_warning(_detect_test_framework()) tokens = normalize_tokens(tokens) overrides = { k: v for k, v in { "_console": console, "_error_console": error_console, "print_error": print_error, "exit_on_error": exit_on_error, "help_on_error": help_on_error, "verbose": verbose, "backend": backend, "result_action": result_action, "error_formatter": error_formatter, }.items() if v is not None } if self._is_nested_call(): overrides.setdefault("result_action", "return_value") with self.app_stack(tokens, overrides): command, bound, _ = self.parse_args( tokens, console=console, end_of_options_delimiter=end_of_options_delimiter, ) resolved_backend = cast(Literal["asyncio", "trio"], self.app_stack.resolve("backend", fallback="asyncio")) try: result = _run_maybe_async_command(command, bound, resolved_backend) return self._handle_result_action(result) except KeyboardInterrupt: if self.suppress_keyboard_interrupt: sys.exit(130) # Use the same exit code as Python's default KeyboardInterrupt handling. else: raise async def run_async( self, tokens: None | str | Iterable[str] = None, *, console: Optional["Console"] = None, error_console: Optional["Console"] = None, print_error: bool | None = None, exit_on_error: bool | None = None, help_on_error: bool | None = None, verbose: bool | None = None, end_of_options_delimiter: str | None = None, backend: Literal["asyncio", "trio"] | None = None, result_action: ResultAction | None = None, error_formatter: Callable[["CycloptsError"], Any] | None = None, ) -> Any: """Async equivalent of :meth:`__call__` for use within existing event loops. This method should be used when you're already in an async context (e.g., Jupyter notebooks, existing async applications) and need to execute a Cyclopts command without creating a new event loop. Parameters ---------- tokens : None | str | Iterable[str] Either a string, or a list of strings to launch a command. Defaults to ``sys.argv[1:]``. console: ~rich.console.Console Console to print help and runtime Cyclopts errors. If not provided, follows the resolution order defined in :attr:`App.console`. error_console: ~rich.console.Console Console to print error messages. If not provided, follows the resolution order defined in :attr:`App.error_console`. print_error: bool | None Print a rich-formatted error on error. If :obj:`None`, inherits from :attr:`App.print_error`, eventually defaulting to :obj:`True`. exit_on_error: bool | None If there is an error parsing the CLI tokens invoke ``sys.exit(1)``. Otherwise, continue to raise the exception. If :obj:`None`, inherits from :attr:`App.exit_on_error`, eventually defaulting to :obj:`True`. help_on_error: bool | None Prints the help-page before printing an error. If :obj:`None`, inherits from :attr:`App.help_on_error`, eventually defaulting to :obj:`False`. verbose: bool | None Populate exception strings with more information intended for developers. If :obj:`None`, inherits from :attr:`App.verbose`, eventually defaulting to :obj:`False`. end_of_options_delimiter: str | None All tokens after this delimiter will be force-interpreted as positional arguments. If :obj:`None`, inherits from :attr:`App.end_of_options_delimiter`, eventually defaulting to POSIX-standard ``"--"``. Set to an empty string to disable. backend: Literal["asyncio", "trio"] | None Override the async backend to use (if an async command is invoked). If :obj:`None`, inherits from :attr:`App.backend`, eventually defaulting to "asyncio". If passing backend="trio", ensure trio is installed via the extra: `cyclopts[trio]`. result_action: ResultAction | None Controls how command return values are handled. Can be a predefined literal string or a custom callable that takes the result and returns a processed value. If :obj:`None`, inherits from :attr:`App.result_action`, eventually defaulting to "print_non_int_return_int_as_exit_code". See :attr:`App.result_action` for available modes. Returns ------- return_value: Any The value the command function returns. Examples -------- .. code-block:: python import asyncio from cyclopts import App app = App() @app.command async def my_async_command(): await asyncio.sleep(1) return "Done!" # In an async context (e.g., Jupyter notebook or existing async app): async def main(): result = await app.run_async(["my-async-command"]) print(result) # Prints: Done! asyncio.run(main()) """ if tokens is None: _log_framework_warning(_detect_test_framework()) tokens = normalize_tokens(tokens) overrides = { k: v for k, v in { "_console": console, "_error_console": error_console, "print_error": print_error, "exit_on_error": exit_on_error, "help_on_error": help_on_error, "verbose": verbose, "backend": backend, "result_action": result_action, "error_formatter": error_formatter, }.items() if v is not None } if self._is_nested_call(): overrides.setdefault("result_action", "return_value") with self.app_stack(tokens, overrides): command, bound, _ = self.parse_args( tokens, console=console, end_of_options_delimiter=end_of_options_delimiter, ) try: if inspect.iscoroutinefunction(command): result = await command(*bound.args, **bound.kwargs) else: result = command(*bound.args, **bound.kwargs) return self._handle_result_action(result) except KeyboardInterrupt: if self.suppress_keyboard_interrupt: sys.exit(130) # Use the same exit code as Python's default KeyboardInterrupt handling. else: raise def help_print( self, tokens: Annotated[None | str | Iterable[str], Parameter(show=False)] = None, *, console: Annotated[Optional["Console"], Parameter(parse=False)] = None, ) -> None: """Print the help page. Parameters ---------- tokens: None | str | Iterable[str] Tokens to interpret for traversing the application command structure. If not provided, defaults to ``sys.argv``. console: ~rich.console.Console Console to print help and runtime Cyclopts errors. If not provided, follows the resolution order defined in :attr:`App.console`. """ from cyclopts.help import format_doc, format_usage from cyclopts.help.formatters import DefaultFormatter tokens = normalize_tokens(tokens) command_chain, apps, _ = self.parse_commands(tokens) executing_app = apps[-1] overrides = {"_console": console} with self.app_stack(apps, overrides=overrides): console = executing_app.console # Prepare usage if executing_app.usage is None: usage = format_usage(self, command_chain, execution_path=apps) elif executing_app.usage: # i.e. skip empty-string. usage = executing_app.usage + "\n" else: usage = None # Prepare description help_format = executing_app.app_stack.resolve("help_format", fallback=DEFAULT_FORMAT) description = format_doc(executing_app, help_format) # Prepare panels with their associated groups help_panels_with_groups = self._assemble_help_panels(tokens, help_format) # Render prologue if help_prologue := executing_app.app_stack.resolve("help_prologue"): from cyclopts.help import InlineText prologue = InlineText.from_format(help_prologue, format=help_format) console.print(prologue) console.print() # Add blank line after prologue # Render usage default_formatter = executing_app.app_stack.resolve("help_formatter", fallback=DefaultFormatter()) if hasattr(default_formatter, "render_usage"): default_formatter.render_usage(console, console.options, usage) elif usage: console.print(usage) # Render description if hasattr(default_formatter, "render_description"): default_formatter.render_description(console, console.options, description) elif description: console.print(description) # Render each panel with its group's formatter (or default) for group, panel in help_panels_with_groups: formatter = group.help_formatter if group else None if formatter is None: formatter = default_formatter formatter = cast("HelpFormatter", formatter) formatter(console, console.options, panel) # Render epilogue if help_epilogue := executing_app.app_stack.resolve("help_epilogue"): from cyclopts.help import InlineText console.print() # Add blank line before epilogue epilogue = InlineText.from_format(help_epilogue, format=help_format) console.print(epilogue) def _assemble_help_panels( self, tokens: None | str | Iterable[str], help_format, ) -> list[tuple[Optional["Group"], "HelpPanel"]]: from rich.console import Group as RichGroup from rich.console import NewLine from cyclopts.help import ( HelpPanel, InlineText, create_parameter_help_panel, format_command_entries, ) command_chain, execution_path, _ = self.parse_commands(tokens) command_app = execution_path[-1] help_format = command_app.app_stack.resolve("help_format", help_format, DEFAULT_FORMAT) panels: dict[str, tuple[Group, HelpPanel]] = {} # Handle commands first; there's an off chance they may be "upgraded" # to an argument/parameter panel. for subapp in _walk_metas(command_app): for group, apps_with_names in groups_from_app(subapp): if not group.show: continue # Fetch a group's help-panel, or create it if it does not yet exist. try: _, command_panel = panels[group.name] except KeyError: command_panel = HelpPanel(title=group.name, format="command") panels[group.name] = (group, command_panel) if group.help: group_help = InlineText.from_format(group.help, format=help_format, force_empty_end=True) if command_panel.description: command_panel.description = RichGroup(command_panel.description, NewLine(), group_help) else: command_panel.description = group_help # Add the command to the group's help panel. command_panel.entries.extend(format_command_entries(apps_with_names, format=help_format)) # Handle Arguments/Parameters # We have to combine all the help-pages of the command-app and it's meta apps. for subapp, argument_collection in _iter_resolution_argument_collections(execution_path, parse_docstring=True): # Special-case: add config.Env values to Parameter(env_var=) configs: tuple[Callable, ...] = subapp.app_stack.resolve("_config") or () env_configs = tuple(x for x in configs if isinstance(x, Env) and x.show) for argument in argument_collection: for env_config in env_configs: env_var = env_config._convert_argument(command_chain, argument) assert isinstance(argument.parameter.env_var, tuple) argument.parameter = Parameter.combine( argument.parameter, Parameter(env_var=(*argument.parameter.env_var, env_var)), ) for group in argument_collection.groups: if not group.show: continue group_argument_collection = argument_collection.filter_by(group=group) if not group_argument_collection: continue _, existing_panel = panels.get(group.name, (None, None)) new_panel = create_parameter_help_panel(group, group_argument_collection, help_format) if existing_panel: # An imperfect merging process existing_panel.format = "parameter" new_panel.entries = new_panel.entries + existing_panel.entries # Commands go last if new_panel.description: if existing_panel.description: new_panel.description = RichGroup( existing_panel.description, NewLine(), new_panel.description ) else: new_panel.description = existing_panel.description panels[group.name] = (group, new_panel) groups = [x[0] for x in panels.values()] help_panels = [x[1] for x in panels.values()] out = [] sorted_groups, sorted_panels = sort_groups(groups, help_panels) for group, help_panel in zip(sorted_groups, sorted_panels, strict=False): help_panel._remove_duplicates() if help_panel.format == "command": # don't sort format == "parameter" because order may matter there! help_panel._sort() out.append((group, help_panel)) return out def generate_docs( self, output_format: "DocFormat" = "markdown", recursive: bool = True, include_hidden: bool = False, heading_level: int = 1, max_heading_level: int = 6, flatten_commands: bool = False, usage_name: str | None = None, ) -> str: """Generate documentation for this CLI application. Parameters ---------- output_format : DocFormat Output format for the documentation. Accepts "markdown"/"md", "html"/"htm", or "rst"/"rest"/"restructuredtext". Default is "markdown". recursive : bool If True, generate documentation for all subcommands recursively. Default is True. include_hidden : bool If True, include hidden commands/parameters in documentation. Default is False. heading_level : int Starting heading level for the main application title. Default is 1 (single # for markdown, = for RST). max_heading_level : int Maximum heading level to use. Headings deeper than this will be capped at this level. Standard Markdown and HTML support levels 1-6. Default is 6. flatten_commands : bool If True, generate all commands at the same heading level instead of nested. Default is False. usage_name : str | None Optional replacement for the root app name shown in ``Usage:`` lines of the generated documentation. Useful when the runtime invocation differs from the app's configured name — for example, an app named ``"cli"`` that is typically invoked as ``uv run cli``. Only the ``Usage:`` lines change; document titles, section headings, and table-of-contents anchors continue to use ``app.name[0]``. Default is None (use ``app.name[0]``). Returns ------- str The generated documentation. Raises ------ ValueError If an unsupported output format is specified. Examples -------- >>> app = App(name="myapp", help="My CLI Application") >>> docs = app.generate_docs() # Generate markdown as string >>> html_docs = app.generate_docs(output_format="html") # Generate HTML >>> rst_docs = app.generate_docs(output_format="rst") # Generate RST >>> # To write to file, caller can do: >>> # Path("docs/cli.md").write_text(docs) >>> # Override the invocation shown in Usage: lines (e.g., uv run cli) >>> docs = app.generate_docs(usage_name="uv run cli") """ from cyclopts.docs import ( generate_markdown_docs, generate_rst_docs, normalize_format, ) from cyclopts.docs.html import generate_html_docs output_format = normalize_format(output_format) if output_format == "markdown": doc = generate_markdown_docs( self, recursive=recursive, include_hidden=include_hidden, heading_level=heading_level, max_heading_level=max_heading_level, flatten_commands=flatten_commands, usage_name=usage_name, ) elif output_format == "html": doc = generate_html_docs( self, recursive=recursive, include_hidden=include_hidden, heading_level=heading_level, max_heading_level=max_heading_level, flatten_commands=flatten_commands, usage_name=usage_name, ) elif output_format == "rst": doc = generate_rst_docs( self, recursive=recursive, include_hidden=include_hidden, heading_level=heading_level, max_heading_level=max_heading_level, flatten_commands=flatten_commands, no_root_title=False, # Default to False for direct API usage usage_name=usage_name, ) return doc def generate_completion( self, *, prog_name: str | None = None, shell: Literal["zsh", "bash", "fish"] | None = None, ) -> str: """Generate shell completion script for this application. Parameters ---------- prog_name : str | None Program name for completion. If None, uses first name from app.name. shell : Literal["zsh", "bash", "fish"] | None Shell type. If None, automatically detects current shell. Supported shells: "zsh", "bash", "fish". Returns ------- str Complete shell completion script. Examples -------- Auto-detect shell and generate completion: >>> app = App(name="myapp") >>> script = app.generate_completion() >>> Path("_myapp").write_text(script) Explicitly specify shell type: >>> script = app.generate_completion(shell="zsh") Raises ------ ValueError If app has no name or shell type is unsupported. ShellDetectionError If shell is None and auto-detection fails. """ if prog_name is None: if not self.name: raise ValueError("App must have a name to generate completion script") prog_name = self.name[0] if isinstance(self.name, tuple) else self.name if shell is None: from cyclopts.completion import detect_shell shell = detect_shell() if shell == "zsh": from cyclopts.completion.zsh import generate_completion_script return generate_completion_script(self, prog_name) elif shell == "bash": from cyclopts.completion.bash import generate_completion_script return generate_completion_script(self, prog_name) elif shell == "fish": from cyclopts.completion.fish import generate_completion_script return generate_completion_script(self, prog_name) else: raise ValueError(f"Unsupported shell: {shell}") def install_completion( self, *, shell: Literal["zsh", "bash", "fish"] | None = None, output: Path | None = None, add_to_startup: bool = True, ) -> Path: """Install shell completion script to appropriate location. Generates and writes the completion script to a shell-specific location. Parameters ---------- shell : Literal["zsh", "bash", "fish"] | None Shell type for completion. If not specified, attempts to auto-detect current shell. output : Path | None Output path for the completion script. If not specified, uses shell-specific default: - zsh: ~/.zsh/completions/_ (or $ZSH_CUSTOM/completions/_ with oh-my-zsh) - bash: ~/.local/share/bash-completion/completions/ - fish: ~/.config/fish/completions/.fish add_to_startup : bool If True (default), adds source line to shell RC file to ensure completion is loaded. Set to False if completions are already configured to auto-load. Returns ------- Path Path where the completion script was installed. Examples -------- Auto-detect shell and install: >>> app = App(name="myapp") >>> path = app.install_completion() Install for specific shell: >>> path = app.install_completion(shell="zsh") Install to custom path: >>> path = app.install_completion(output=Path("/custom/path")) Install without modifying RC files: >>> path = app.install_completion(shell="bash", add_to_startup=False) Raises ------ ShellDetectionError If shell is None and auto-detection fails. ValueError If shell type is unsupported. """ from cyclopts.completion.detect import detect_shell if shell is None: shell = detect_shell() from cyclopts.completion.install import add_to_rc_file, get_default_completion_path script_content = self.generate_completion(shell=shell) if output is None: output = get_default_completion_path(shell, self.name[0]) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(script_content) # Fish does not need any startup script changes. if add_to_startup and shell in ("bash", "zsh"): add_to_rc_file(output, self.name[0], shell) return output def register_install_completion_command( self, name: str | Iterable[str] = "--install-completion", add_to_startup: bool = True, **kwargs, ) -> None: """Register a command for installing shell completion. This is a convenience method that creates a command which calls :meth:`install_completion`. For more control over the command implementation, users can manually define their own command. Parameters ---------- name : str | Iterable[str] Command name(s) for the install completion command. Defaults to "--install-completion". add_to_startup : bool If True (default), adds source line to shell RC file to ensure completion is loaded. Set to False if completions are already configured to auto-load. **kwargs Additional keyword arguments to pass to :meth:`command`. Can be used to customize the command registration (e.g., `help`, `group`, `help_flags`, `version_flags`). Examples -------- Register install-completion command: >>> app = App(name="myapp") >>> app.register_install_completion_command() >>> app() # Now responds to: myapp --install-completion Use a custom command name: >>> app.register_install_completion_command(name="--setup-completion") Customize help text: >>> app.register_install_completion_command(help="Install shell completion for myapp.") Customize command registration: >>> app.register_install_completion_command(group="Setup", help_flags=[]) Install without modifying RC files: >>> app.register_install_completion_command(add_to_startup=False) See Also -------- install_completion : The underlying method that performs the installation. """ from cyclopts.completion.install import create_install_completion_command command_fn = create_install_completion_command(self.install_completion, add_to_startup) self.command(command_fn, name=name, **kwargs) def interactive_shell( self, prompt: str = "$ ", quit: None | str | Iterable[str] = None, dispatcher: Dispatcher | None = None, console: "Console | None" = None, exit_on_error: bool = False, result_action: ResultAction | None = None, **kwargs, ) -> None: """Create a blocking, interactive shell. All registered commands can be executed in the shell. Parameters ---------- prompt: str Shell prompt. Defaults to ``"$ "``. quit: str | Iterable[str] String or list of strings that will cause the shell to exit and this method to return. Defaults to ``["q", "quit"]``. dispatcher: Dispatcher | None Optional function that subsequently invokes the command. The ``dispatcher`` function must have signature: .. code-block:: python def dispatcher(command: Callable, bound: inspect.BoundArguments, ignored: dict[str, Any]) -> Any: return command(*bound.args, **bound.kwargs) The above is the default dispatcher implementation. console: Console | None Rich Console to use for output. If :obj:`None`, uses :attr:`App.console`. exit_on_error: bool Whether to call ``sys.exit`` on parsing errors. Defaults to :obj:`False`. result_action: ResultAction | None How to handle command return values in the interactive shell. Defaults to ``"print_non_int_return_int_as_exit_code"`` which prints non-int results and returns int/bool as exit codes without calling sys.exit. If :obj:`None`, inherits from :attr:`App.result_action`. `**kwargs` Get passed along to :meth:`parse_args`. """ if os.name == "posix": # pragma: no cover # Mac/Linux print("Interactive shell. Press Ctrl-D to exit.") else: # pragma: no cover # Windows print("Interactive shell. Press Ctrl-Z followed by Enter to exit.") if quit is None: quit = ["q", "quit"] if isinstance(quit, str): quit = [quit] def default_dispatcher(command, bound, _): return command(*bound.args, **bound.kwargs) if dispatcher is None: dispatcher = default_dispatcher overrides = {} if result_action is not None: overrides["result_action"] = result_action if console is not None: overrides["_console"] = console while True: try: user_input = input(prompt) except EOFError: # pragma: no cover break tokens = normalize_tokens(user_input) if not tokens: continue if tokens[0] in quit: break try: with self.app_stack(tokens, overrides): command, bound, ignored = self.parse_args( tokens, console=console, exit_on_error=exit_on_error, **kwargs ) result = dispatcher(command, bound, ignored) self._handle_result_action(result, fallback="print_non_int_return_int_as_exit_code") except CycloptsError: # Upstream ``parse_args`` already printed the error pass except Exception: print(traceback.format_exc()) def _handle_result_action(self, result: Any, fallback: ResultAction = "print_non_int_sys_exit") -> Any: """Handle command result based on result_action. Parameters ---------- result : Any The command's return value. fallback : ResultAction The fallback result_action if none is configured. Defaults to "print_non_int_sys_exit". Returns ------- Any Processed result based on action (may call sys.exit() and not return). """ from cyclopts._result_action import handle_result_action action = cast( ResultAction, self.app_stack.resolve("result_action", fallback=fallback), ) return handle_result_action(result, action, lambda x: self.console.print(x)) def update(self, app: "App"): """Copy over all commands from another :class:`App`. Commands from the meta app will **not** be copied over. Parameters ---------- app: cyclopts.App All commands from this application will be copied over. """ self._commands.update(app._commands) def __repr__(self): """Only shows non-default values.""" non_defaults = {} for a in self.__attrs_attrs__: # pyright: ignore[reportAttributeAccessIssue] if not a.init: continue v = getattr(self, a.name) # Compare types first because of some weird attribute issues. if type(v) != type(a.default) or v != a.default: # noqa: E721 non_defaults[a.alias] = v signature = ", ".join(f"{k}={v!r}" for k, v in non_defaults.items()) return f"{type(self).__name__}({signature})" def _get_help_flag_index(tokens, help_flags, end_of_options_delimiter) -> int | None: delimiter_index = None if end_of_options_delimiter: with suppress(ValueError): delimiter_index = tokens.index(end_of_options_delimiter) for help_flag in help_flags: with suppress(ValueError): index = tokens.index(help_flag) if delimiter_index is None or index < delimiter_index: break else: index = None return index class TestFramework(str, Enum): UNKNOWN = "" PYTEST = "pytest" @lru_cache def _detect_test_framework() -> TestFramework: """Detects if we are currently being ran in a test framework. Returns ------- TestFramework Name of the testing framework. Returns an empty string if not testing framework discovered. """ # Check if pytest is in sys.modules; PYTEST_VERSION can be set if # a cyclopts script is invoked via subprocess within a pytest unit-test. if "pytest" in sys.modules and os.environ.get("PYTEST_VERSION") is not None: # "PYTEST_VERSION" is set as of pytest v8.2.0 (Apr 27, 2024) return TestFramework.PYTEST else: return TestFramework.UNKNOWN @lru_cache # Prevent logging of multiple warnings def _log_framework_warning(framework: TestFramework) -> None: """Log a warning message for a given testing framework. Intended to catch developers invoking their app during unit-tests without providing commands and erroneously reading from :obj:`sys.argv`. TO ONLY BE CALLED WITHIN A CYCLOPTS.APP METHOD. """ if framework == TestFramework.UNKNOWN: return import warnings for elem in inspect.stack(): frame = elem.frame f_back = frame.f_back calling_module = inspect.getmodule(f_back) if calling_module is None or f_back is None: continue calling_module_name = calling_module.__name__.split(".")[0] if calling_module_name == "cyclopts": continue # The "self" is within the Cyclopts codebase App.ANY_METHOD_HERE, # so this is a safe lookup. Skip if self doesn't exist (e.g., standalone functions). if "self" not in frame.f_locals: continue called_cyclopts_app_instance = frame.f_locals["self"] # Find the variable name in the previous frame that references this object candidate_variables = {**f_back.f_globals, **f_back.f_locals} matched_app_variables = [] for var_name, var_instance in candidate_variables.items(): if var_instance is called_cyclopts_app_instance: matched_app_variables.append(var_name) if len(matched_app_variables) != 1: # We could not determine the exact variable name; just call it app var_name = "app" else: var_name = matched_app_variables[0] message = f'Cyclopts application invoked without tokens under unit-test framework "{framework.value}". Did you mean "{var_name}([])"?' warnings.warn(UserWarning(message), stacklevel=3) break BrianPugh-cyclopts-921b1fa/cyclopts/docs/000077500000000000000000000000001517576204000204355ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/docs/__init__.py000066400000000000000000000010111517576204000225370ustar00rootroot00000000000000"""Documentation generation for cyclopts CLI applications.""" from cyclopts.docs.html import generate_html_docs from cyclopts.docs.markdown import generate_markdown_docs from cyclopts.docs.rst import generate_rst_docs from cyclopts.docs.types import ( FORMAT_ALIASES, CanonicalDocFormat, DocFormat, normalize_format, ) __all__ = [ "generate_html_docs", "generate_markdown_docs", "generate_rst_docs", "DocFormat", "CanonicalDocFormat", "FORMAT_ALIASES", "normalize_format", ] BrianPugh-cyclopts-921b1fa/cyclopts/docs/base.py000066400000000000000000000362141517576204000217270ustar00rootroot00000000000000"""Base utilities for documentation generation.""" import re from collections.abc import Sequence from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from cyclopts.core import App from cyclopts.help import HelpPanel from cyclopts.command_spec import CommandSpec from cyclopts.help import format_doc, format_usage def should_show_usage(app: "App") -> bool: """Determine if usage should be shown for an app. Root apps always show usage (even without default_command, showing "app COMMAND"). Subcommands only show usage if they have a default_command. This skips usage for command groups that can't be invoked directly. The determination is made by checking the app_stack depth: - Stack length of 1 means root app (just the initial frame) - Stack length > 1 means we're in a subcommand context (frames were pushed) Parameters ---------- app : App The App instance to check. Returns ------- bool True if usage should be shown. """ # Check if we're in a subcommand context by examining the stack depth is_root = len(app.app_stack.stack) == 1 if is_root: # Root app: always show usage return True else: # Subcommand: only show if it has a default_command return app.default_command is not None def should_show_commands_list(app: "App") -> bool: """Determine if commands list should be shown for an app. Only show commands list for apps with a default_command. Command groups (apps without default_command) skip the list since their commands will be documented recursively anyway. Parameters ---------- app : App The App instance to check. Returns ------- bool True if commands list should be shown. """ return app.default_command is not None def _is_builtin_flag(app: "App", name: str) -> bool: """Check if a flag name is a built-in help or version flag. Parameters ---------- app : App The App instance to check against. name : str The flag name to check. Returns ------- bool True if this is a built-in help or version flag. """ help_flags = set(app.app_stack.resolve("help_flags", fallback=())) version_flags = set(app.app_stack.resolve("version_flags", fallback=())) builtin_flags = help_flags | version_flags return name in builtin_flags def is_all_builtin_flags(app: "App", names: Sequence[str]) -> bool: """Check if all names in the sequence are builtin help or version flags. Parameters ---------- app : App The App instance to check against. names : Sequence[str] Sequence of flag names to check. Returns ------- bool True if all names are builtin flags. """ if not names: return False return all(_is_builtin_flag(app, name) for name in names) def normalize_command_filters( commands_filter: list[str] | None = None, exclude_commands: list[str] | None = None, ) -> tuple[set[str] | None, set[str] | None]: """Normalize command filter lists by converting underscores to dashes. Parameters ---------- commands_filter : list[str] | None List of commands to include. exclude_commands : list[str] | None List of commands to exclude. Returns ------- tuple[set[str] | None, set[str] | None] Normalized include and exclude sets for O(1) lookup. """ normalized_include = None if commands_filter is not None: normalized_include = {cmd.replace("_", "-") for cmd in commands_filter} normalized_exclude = None if exclude_commands: normalized_exclude = {cmd.replace("_", "-") for cmd in exclude_commands} return normalized_include, normalized_exclude def should_include_command( name: str, parent_path: list[str], normalized_commands_filter: set[str] | None, normalized_exclude_commands: set[str] | None, subapp: "App", ) -> bool: """Determine if a command should be included based on filters. Parameters ---------- name : str The command name. parent_path : list[str] Path to parent commands. normalized_commands_filter : set[str] | None Set of commands to include (already normalized). normalized_exclude_commands : set[str] | None Set of commands to exclude (already normalized). subapp : App The subcommand App instance. Returns ------- bool True if the command should be included, False otherwise. """ full_path = ".".join(parent_path + [name]) if parent_path else name if normalized_exclude_commands: if name in normalized_exclude_commands or full_path in normalized_exclude_commands: return False for i in range(len(parent_path)): parent_segment = ".".join(parent_path[: i + 1]) if parent_segment in normalized_exclude_commands: return False if normalized_commands_filter is not None: if name in normalized_commands_filter or full_path in normalized_commands_filter: return True for i in range(len(parent_path)): parent_segment = ".".join(parent_path[: i + 1]) if parent_segment in normalized_commands_filter: return True if hasattr(subapp, "_commands") and subapp._commands: for filter_cmd in normalized_commands_filter: if filter_cmd.startswith(full_path + "."): return True return False return True def adjust_filters_for_subcommand( name: str, normalized_commands_filter: set[str] | None, normalized_exclude_commands: set[str] | None, ) -> tuple[list[str] | None, list[str] | None]: """Adjust filter lists for subcommand context. Parameters ---------- name : str The current command name. normalized_commands_filter : set[str] | None Set of commands to include (already normalized). normalized_exclude_commands : set[str] | None Set of commands to exclude (already normalized). Returns ------- tuple[list[str] | None, list[str] | None] Adjusted commands_filter and exclude_commands lists (denormalized). """ sub_commands_filter = None if normalized_commands_filter is not None: sub_commands_filter = [] for filter_cmd in normalized_commands_filter: if filter_cmd.startswith(name + "."): sub_filter = filter_cmd[len(name) + 1 :] sub_commands_filter.append(sub_filter.replace("-", "_")) elif filter_cmd == name: sub_commands_filter = None break if sub_commands_filter is not None and not sub_commands_filter: sub_commands_filter = [] sub_exclude_commands = None if normalized_exclude_commands: sub_exclude_commands = [] for exclude_cmd in normalized_exclude_commands: if exclude_cmd.startswith(name + "."): sub_exclude = exclude_cmd[len(name) + 1 :] sub_exclude_commands.append(sub_exclude.replace("-", "_")) else: sub_exclude_commands.append(exclude_cmd.replace("-", "_")) return sub_commands_filter, sub_exclude_commands def get_app_info(app: "App", command_chain: list[str] | None = None) -> tuple[str, str, str]: """Get app name, full command path, and title. Parameters ---------- app : App The cyclopts App instance. command_chain : Optional[List[str]] Chain of parent commands leading to this app. Returns ------- Tuple[str, str, str] (app_name, full_command, title) """ if not command_chain: app_name = app.name[0] full_command = app_name title = app_name else: app_name = command_chain[0] full_command = " ".join(command_chain) title = full_command return app_name, full_command, title def build_command_chain(command_chain: list[str] | None, command_name: str, app_name: str) -> list[str]: """Build command chain for a subcommand. Parameters ---------- command_chain : Optional[List[str]] Current command chain. command_name : str Name of the subcommand. app_name : str Name of the root app. Returns ------- List[str] Updated command chain. """ if command_chain: return command_chain + [command_name] else: return [app_name, command_name] def apply_usage_name(command_chain: list[str], usage_name: str | None) -> list[str]: """Return a display command chain with the root replaced by ``usage_name``. When ``usage_name`` is ``None``, returns ``command_chain`` unchanged so callers can use this helper unconditionally. When ``usage_name`` is ``""``, the root token is dropped rather than substituted, so downstream formatters never see an empty element (which would render as stray leading/internal whitespace). When the chain is empty and ``usage_name`` is a non-empty string, returns a single-element list containing ``usage_name``. Parameters ---------- command_chain : list[str] The logical command chain (root app name first). usage_name : str | None Replacement for the chain's root element used in Usage: lines only. An empty string drops the root token entirely. Returns ------- list[str] A new list with the root replaced/dropped, or the original chain when ``usage_name`` is ``None``. """ if usage_name is None: return command_chain if usage_name == "": return command_chain[1:] if not command_chain: return [usage_name] return [usage_name, *command_chain[1:]] def generate_anchor(command_path: str) -> str: """Generate a URL-friendly anchor from a command path. Converts spaces to hyphens and lowercases the string to match how markdown/HTML processors generate anchors from headings. Strips leading dashes to match markdown processor behavior. Parameters ---------- command_path : str Full command path (e.g., "myapp files cp"). Returns ------- str Anchor string (e.g., "myapp-files-cp"). Examples -------- >>> generate_anchor("myapp files cp") 'myapp-files-cp' >>> generate_anchor("myapp --install-completion") 'myapp-install-completion' """ anchor = command_path.lower().replace(" ", "-") # Collapse consecutive dashes to single dash (markdown processors do this) anchor = re.sub(r"-+", "-", anchor) return anchor def should_skip_command(command_name: str, subapp: "App", parent_app: "App", include_hidden: bool) -> bool: """Check if a command should be skipped. Parameters ---------- command_name : str Name of the command. subapp : App The subcommand App instance. parent_app : App The parent App instance. include_hidden : bool Whether to include hidden commands. Returns ------- bool True if command should be skipped. """ if _is_builtin_flag(parent_app, command_name): return True if not isinstance(subapp, type(parent_app)): return True if not include_hidden and not subapp.show: return True return False def filter_help_entries(app: "App", panel: "HelpPanel", include_hidden: bool) -> list[Any]: """Filter help panel entries based on visibility settings. Parameters ---------- app : App The App instance to check against. panel : HelpPanel The help panel to filter. include_hidden : bool Whether to include hidden entries. Returns ------- List[Any] Filtered panel entries. """ if include_hidden: return panel.entries return [e for e in panel.entries if not (e.names and is_all_builtin_flags(app, e.names))] def extract_description(app: "App", help_format: str) -> Any | None: """Extract app description. Parameters ---------- app : App The App instance. help_format : str Help format type. Returns ------- Optional[Any] The extracted description object, or None. """ description = format_doc(app, help_format) return description def extract_usage(app: "App") -> Any | None: """Extract usage string. Parameters ---------- app : App The App instance. Returns ------- Optional[Any] The extracted usage object, or None. """ if app.usage is not None: return app.usage if app.usage else None usage = format_usage(app, []) return usage def format_usage_line(usage_text: str, command_chain: list[str], prefix: str = "") -> str: """Format usage line with proper command path. Parameters ---------- usage_text : str Raw usage text. command_chain : List[str] Command chain for the app. prefix : str Optional prefix for the usage line (e.g., "$"). Returns ------- str Formatted usage line. """ if not usage_text: return "" if "Usage:" in usage_text: usage_text = usage_text.replace("Usage:", "").strip() full_command = " ".join(command_chain) if command_chain else "" parts = usage_text.split(None, 1) if len(parts) > 1 and command_chain: usage_line = f"{prefix} {full_command} {parts[1]}" if prefix else f"{full_command} {parts[1]}" elif command_chain: usage_line = f"{prefix} {full_command}" if prefix else full_command else: usage_line = f"{prefix} {usage_text}" if prefix else usage_text return usage_line.strip() def iterate_commands(app: "App", include_hidden: bool = False, resolve_lazy: bool = True): """Iterate through app commands, yielding valid resolved subapps. Automatically resolves CommandSpec instances to App instances. Each unique subapp is yielded only once (first occurrence wins). Parameters ---------- app : App The App instance. include_hidden : bool Whether to include hidden commands. resolve_lazy : bool If ``True`` (default), resolve lazy commands (import their modules) to include them in the output. If ``False``, skip unresolved lazy commands. Set to ``True`` when generating static artifacts that need all commands, such as documentation or shell completion scripts. Yields ------ Tuple[str, App] (command_name, resolved_subapp) for each valid command. """ if not app._commands: return seen: set[int] = set() for name, app_or_spec in app._commands.items(): if _is_builtin_flag(app, name): continue if isinstance(app_or_spec, CommandSpec): if not app_or_spec.is_resolved and not resolve_lazy: continue subapp = app_or_spec.resolve(app) else: subapp = app_or_spec if not isinstance(subapp, type(app)): continue if not include_hidden and not subapp.show: continue # Skip if we've already yielded this app (alias) app_id = id(subapp) if app_id in seen: continue seen.add(app_id) yield name, subapp BrianPugh-cyclopts-921b1fa/cyclopts/docs/html.py000066400000000000000000000451731517576204000217650ustar00rootroot00000000000000"""HTML documentation generation for cyclopts apps.""" from typing import TYPE_CHECKING from cyclopts._markup import escape_html, extract_text from cyclopts.docs.base import ( apply_usage_name, build_command_chain, extract_description, extract_usage, filter_help_entries, format_usage_line, generate_anchor, iterate_commands, ) if TYPE_CHECKING: from cyclopts.core import App def _generate_html_toc( lines: list[str], app: "App", include_hidden: bool, app_name: str, prefix: str, depth: int = 0, ) -> None: """Recursively generate HTML table of contents.""" if not app._commands: return for name, subapp in iterate_commands(app, include_hidden): display_name = f"{prefix}{name}" if prefix else name full_path = f"{app_name}-{display_name.replace(' ', '-')}".lower() indent = " " * (depth + 1) lines.append(f'{indent}

  • {name}') if subapp._commands: lines.append(f"{indent}
      ") _generate_html_toc(lines, subapp, include_hidden, app_name, f"{display_name} ", depth + 1) lines.append(f"{indent}
    ") lines.append(f"{indent}
  • ") # CSS styles embedded as a string - clean, modern design DEFAULT_CSS = """ :root { --bg-color: #ffffff; --text-color: #333333; --border-color: #e0e0e0; --code-bg: #f5f5f5; --link-color: #0066cc; --header-bg: #f8f9fa; --required-color: #d73027; } * { box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; line-height: 1.6; color: var(--text-color); background: var(--bg-color); max-width: 1200px; margin: 0 auto; padding: 20px; } h1, h2, h3, h4, h5, h6 { margin-top: 24px; margin-bottom: 16px; font-weight: 600; line-height: 1.25; } h1 { font-size: 2em; border-bottom: 2px solid var(--border-color); padding-bottom: 0.3em; } h2 { font-size: 1.5em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; } h3 { font-size: 1.25em; } h4 { font-size: 1em; } code { background: var(--code-bg); padding: 2px 6px; border-radius: 3px; font-family: 'Courier New', Consolas, monospace; font-size: 0.9em; } pre { background: var(--code-bg); padding: 16px; border-radius: 6px; overflow-x: auto; font-family: 'Courier New', Consolas, monospace; font-size: 0.9em; } pre code { background: none; padding: 0; } .usage-block { margin: 16px 0; } .usage { background: #f8f9fa; border-left: 4px solid #0066cc; } .description, .app-description, .command-description { margin: 16px 0; color: var(--text-color); } .panel-description { margin: 12px 0; color: #666; } .help-panel { margin: 24px 0; } /* List styles for commands and parameters */ .commands-list, .parameters-list { list-style: none; padding-left: 0; margin: 16px 0; } .commands-list li, .parameters-list li { padding: 8px 0; border-bottom: 1px solid var(--border-color); } .commands-list li:last-child, .parameters-list li:last-child { border-bottom: none; } .commands-list code, .parameters-list code { font-weight: 600; } /* Metadata styling */ .parameter-metadata { display: inline-flex; gap: 8px; margin-left: 8px; flex-wrap: wrap; align-items: center; } .metadata-item { display: inline-block; padding: 2px 8px; font-size: 0.85em; border-radius: 4px; background: var(--code-bg); border: 1px solid var(--border-color); } .metadata-required { background: #fee; border-color: #fcc; color: #c00; font-weight: 600; } .metadata-default { background: #f0f8ff; border-color: #d0e8ff; color: #0066cc; } .metadata-env { background: #f0fff0; border-color: #d0ffd0; color: #080; } .metadata-choices { background: #fffaf0; border-color: #ffd0a0; color: #840; } .metadata-label { font-weight: 600; opacity: 0.8; text-transform: uppercase; font-size: 0.9em; } /* Table of Contents */ .table-of-contents { background: var(--header-bg); border-radius: 6px; padding: 16px; margin: 24px 0; } .table-of-contents h2 { margin-top: 0; border-bottom: none; padding-bottom: 0; } .table-of-contents ul { margin: 8px 0; padding-left: 24px; } .table-of-contents li { margin: 4px 0; } .table-of-contents a { color: var(--link-color); text-decoration: none; } .table-of-contents a:hover { text-decoration: underline; } /* General link styles */ a { color: var(--link-color); text-decoration: none; } a:hover { text-decoration: underline; } .commands-list a code { color: var(--link-color); } /* Back to top link */ .back-to-top { display: inline-block; margin-top: 8px; font-size: 0.9em; opacity: 0.7; } .back-to-top:hover { opacity: 1; } /* Responsive design */ @media (max-width: 768px) { body { padding: 10px; } .commands-list, .parameters-list { font-size: 0.9em; } } /* Dark mode support */ @media (prefers-color-scheme: dark) { :root { --bg-color: #1e1e1e; --text-color: #e0e0e0; --border-color: #444; --code-bg: #2d2d2d; --link-color: #66b3ff; --header-bg: #2d2d2d; } .usage { background: #2d2d2d; border-left-color: #66b3ff; } .table-of-contents { background: #2d2d2d; } .metadata-required { background: #4a2020; border-color: #6a3030; color: #ff9999; } .metadata-default { background: #20304a; border-color: #304060; color: #99ccff; } .metadata-env { background: #204a20; border-color: #306030; color: #99ff99; } .metadata-choices { background: #4a3020; border-color: #604030; color: #ffcc99; } } /* Command sections */ .command-section { margin-top: 32px; padding-top: 16px; border-top: 2px solid var(--border-color); } .command-section:first-child { border-top: none; } """ def generate_html_docs( app: "App", recursive: bool = True, include_hidden: bool = False, heading_level: int = 1, max_heading_level: int = 6, standalone: bool = True, custom_css: str | None = None, command_chain: list[str] | None = None, generate_toc: bool = True, flatten_commands: bool = False, usage_name: str | None = None, ) -> str: """Generate HTML documentation for a CLI application. Parameters ---------- app : App The cyclopts App instance to document. recursive : bool If True, generate documentation for all subcommands recursively. Default is True. include_hidden : bool If True, include hidden commands/parameters in documentation. Default is False. heading_level : int Starting heading level for the main application title. Default is 1. max_heading_level : int Maximum heading level to use. Headings deeper than this will be capped at this level. HTML supports levels 1-6. Default is 6. standalone : bool If True, generate a complete HTML document with , , etc. If False, generate only the body content. Default is True. custom_css : str Custom CSS to use instead of the default styles. command_chain : list[str] Internal parameter to track command hierarchy. Default is None. generate_toc : bool If True, generate a table of contents for multi-command apps. Default is True. flatten_commands : bool If True, generate all commands at the same heading level instead of nested. Default is False. usage_name : str | None Optional replacement for the root app name in every ``Usage:`` line (root and subcommands). Section headings and anchors continue to use ``app.name[0]``. Default is None. Returns ------- str The generated HTML documentation. """ from cyclopts.help.formatters.html import HtmlFormatter # Initialize command chain if not provided if command_chain is None: command_chain = [] # Build the main documentation lines = [] # Only add the outer div for standalone documents or root level if standalone or not command_chain: lines.append('
    ') # Determine the app name and full command path if not command_chain: # Root level - use app name or derive from sys.argv app_name = app.name[0] full_command = app_name title = app_name # Add title for all levels effective_level = min(heading_level, max_heading_level) lines.append(f'{title}') else: # Nested command - build full path app_name = command_chain[0] if command_chain else app.name[0] full_command = " ".join(command_chain) # Create anchor-friendly ID using shared logic anchor_id = generate_anchor(full_command) effective_level = min(heading_level, max_heading_level) lines.append('
    ') lines.append( f'{escape_html(full_command)}' ) # Add application description help_format = app.app_stack.resolve("help_format", fallback="restructuredtext") description = extract_description(app, help_format) if description: desc_text = extract_text(description, None) if desc_text: lines.append(f'
    {escape_html(desc_text)}
    ') # Generate table of contents if this is the root level and has commands if generate_toc and not command_chain and app._commands: lines.append('
    ') lines.append("

    Table of Contents

    ") lines.append("
      ") _generate_html_toc(lines, app, include_hidden, app_name, "", 0) lines.append("
    ") lines.append("
    ") # Add usage section if not suppressed usage = extract_usage(app) if usage: usage_level = min(heading_level + 1, max_heading_level) lines.append(f"Usage") lines.append('
    ') if isinstance(usage, str): usage_text = usage else: usage_text = extract_text(usage, None) display_chain = apply_usage_name(command_chain, usage_name) usage_text = format_usage_line(usage_text, display_chain, prefix="$") lines.append(f'
    {escape_html(usage_text)}
    ') lines.append("
    ") # Get help panels for the current app # Use app_stack context - if caller set up parent context, it will be stacked with app.app_stack([app]): help_panels_with_groups = app._assemble_help_panels([], help_format) # Render panels formatter = HtmlFormatter( heading_level=heading_level + 1, include_hidden=include_hidden, app_name=app_name, command_chain=command_chain, ) formatter.reset() for group, panel in help_panels_with_groups: if not include_hidden and group and not group.show: continue # Filter out entries based on include_hidden if not include_hidden: panel.entries = filter_help_entries(app, panel, include_hidden) if panel.entries: # Only render non-empty panels formatter(None, None, panel) panel_docs = formatter.get_output().strip() if panel_docs: lines.append(panel_docs) # Handle recursive documentation for subcommands if app._commands: # Iterate through registered commands for name, subapp in iterate_commands(app, include_hidden): # Build the command chain for this subcommand sub_command_chain = build_command_chain(command_chain, name, app_name) # Determine heading level for subcommand if flatten_commands: sub_heading_level = heading_level else: sub_heading_level = heading_level + 1 # Generate subcommand documentation lines.append('
    ') # Create anchor-friendly ID anchor_id = ( f"{app_name}-{'-'.join(sub_command_chain[1:])}".lower() if len(sub_command_chain) > 1 else f"{app_name}-{name}".lower() ) effective_sub_level = min(sub_heading_level, max_heading_level) lines.append( f'{escape_html(" ".join(sub_command_chain))}' ) # Get subapp help # Include parent app in the stack so default_parameter is properly inherited with subapp.app_stack([app, subapp]): sub_help_format = subapp.app_stack.resolve("help_format", fallback=help_format) sub_description = extract_description(subapp, sub_help_format) if sub_description: sub_desc_text = extract_text(sub_description, None) if sub_desc_text: lines.append(f'
    {escape_html(sub_desc_text)}
    ') # Generate usage for subcommand sub_usage = extract_usage(subapp) if sub_usage: if flatten_commands: usage_heading_level = heading_level + 1 else: usage_heading_level = heading_level + 2 usage_heading_level = min(usage_heading_level, max_heading_level) lines.append(f"Usage") lines.append('
    ') if isinstance(sub_usage, str): sub_usage_text = sub_usage else: sub_usage_text = extract_text(sub_usage, None) sub_display_chain = apply_usage_name(sub_command_chain, usage_name) sub_usage_text = format_usage_line(sub_usage_text, sub_display_chain, prefix="$") lines.append(f'
    {escape_html(sub_usage_text)}
    ') lines.append("
    ") # Only show subcommand panels if we're in recursive mode if recursive: # Get help panels for subcommand sub_panels = subapp._assemble_help_panels([], sub_help_format) # Render subcommand panels if flatten_commands: panel_heading_level = heading_level + 1 else: panel_heading_level = heading_level + 2 panel_heading_level = min(panel_heading_level, max_heading_level) sub_formatter = HtmlFormatter( heading_level=panel_heading_level, include_hidden=include_hidden, app_name=app_name, command_chain=sub_command_chain, ) for sub_group, sub_panel in sub_panels: if not include_hidden and sub_group and not sub_group.show: continue if not include_hidden: sub_panel.entries = filter_help_entries(subapp, sub_panel, include_hidden) if sub_panel.entries: sub_formatter(None, None, sub_panel) sub_panel_docs = sub_formatter.get_output().strip() if sub_panel_docs: lines.append(sub_panel_docs) # Recursively handle nested subcommands if recursive and subapp._commands: for nested_name, nested_app in iterate_commands(subapp, include_hidden): # Build nested command chain nested_chain = build_command_chain(sub_command_chain, nested_name, app_name) # Determine heading level for nested commands if flatten_commands: nested_heading_level = heading_level else: nested_heading_level = heading_level + 2 # Set up context for nested_app, then recurse # The recursive call's app_stack([app]) will stack on top of this with nested_app.app_stack([subapp, nested_app]): nested_docs = generate_html_docs( nested_app, recursive=recursive, include_hidden=include_hidden, heading_level=nested_heading_level, max_heading_level=max_heading_level, standalone=False, # Not standalone for nested custom_css=None, command_chain=nested_chain, # Pass the command chain generate_toc=False, # No TOC for nested commands flatten_commands=flatten_commands, usage_name=usage_name, ) lines.append(nested_docs) # Add back to top link if we're in a nested section if command_chain: lines.append('↑ Back to top') lines.append("
    ") # Close section if nested command if command_chain: lines.append("
    ") # Only close cli-documentation div for standalone or root if standalone or not command_chain: lines.append("
    ") # Close cli-documentation div # Join all lines into body content body_content = "\n".join(lines) # If standalone, wrap in complete HTML document if standalone: css = custom_css if custom_css else DEFAULT_CSS doc = f""" {escape_html(app_name)} - CLI Documentation {body_content} """ return doc else: return body_content BrianPugh-cyclopts-921b1fa/cyclopts/docs/markdown.py000066400000000000000000001015711517576204000226360ustar00rootroot00000000000000"""Documentation generation functions for cyclopts apps.""" from typing import TYPE_CHECKING from cyclopts._markup import extract_text from cyclopts.core import DEFAULT_FORMAT from cyclopts.docs.base import ( adjust_filters_for_subcommand, apply_usage_name, build_command_chain, extract_description, extract_usage, format_usage_line, generate_anchor, get_app_info, is_all_builtin_flags, iterate_commands, normalize_command_filters, should_include_command, should_show_commands_list, should_show_usage, ) if TYPE_CHECKING: from cyclopts.core import App def _collect_commands_for_toc( app: "App", include_hidden: bool = False, prefix: str = "", commands_filter: list[str] | None = None, exclude_commands: list[str] | None = None, parent_path: list[str] | None = None, skip_filtered_command: bool = False, ) -> list[tuple[str, "App"]]: """Recursively collect all commands for table of contents. Returns a list of (display_name, app) tuples. """ commands = [] if parent_path is None: parent_path = [] normalized_commands_filter, normalized_exclude_commands = normalize_command_filters( commands_filter, exclude_commands ) for name, subapp in iterate_commands(app, include_hidden): if not should_include_command( name, parent_path, normalized_commands_filter, normalized_exclude_commands, subapp ): continue # Skip the command itself if it's the single filtered command with subcommands # (we'll include its children directly instead) skip_this_command = skip_filtered_command and subapp._commands if not skip_this_command: display_name = f"{prefix}{name}" if prefix else name commands.append((display_name, subapp)) # Collect nested commands nested_path = parent_path + [name] # Always include parent in prefix for nested commands (for correct paths) nested_prefix = f"{prefix}{name} " nested = _collect_commands_for_toc( subapp, include_hidden=include_hidden, prefix=nested_prefix, commands_filter=commands_filter, exclude_commands=exclude_commands, parent_path=nested_path, skip_filtered_command=False, # Only skip at the top level ) commands.extend(nested) return commands def _generate_toc_entries(lines: list[str], commands: list[tuple[str, "App"]]) -> None: """Generate TOC entries with proper indentation. Parameters ---------- lines : list[str] List to append TOC entries to. commands : list[tuple[str, "App"]] List of (display_name, app) tuples. """ anchor_counts: dict[str, int] = {} for display_name, _app in commands: depth = display_name.count(" ") - 1 indent = " " * depth cmd_name = display_name.split()[-1] anchor = generate_anchor(display_name) if anchor in anchor_counts: anchor_counts[anchor] += 1 anchor = f"{anchor}_{anchor_counts[anchor]}" else: anchor_counts[anchor] = 0 lines.append(f"{indent}- [`{cmd_name}`](#{anchor})") def _build_command_map(app: "App", include_hidden: bool = True) -> dict[str, "App"]: """Build mapping of command names to App objects. Parameters ---------- app : App The app to extract commands from. include_hidden : bool Whether to include hidden commands. Returns ------- dict[str, App] Mapping of command names to App instances. """ command_map = {} if app._commands: for name, subapp in iterate_commands(app, include_hidden): command_map[name] = subapp return command_map def _append_if_present(lines: list[str], content: str, add_blank: bool = True) -> None: """Append content to lines if present, optionally adding blank line. Parameters ---------- lines : list[str] List to append to. content : str Content to append (only if non-empty). add_blank : bool Whether to add a blank line after content. """ if content: lines.append(content) if add_blank: lines.append("") def _render_description_section(app: "App", help_format: str, lines: list[str]) -> None: """Extract and render app description. Parameters ---------- app : App The app to extract description from. help_format : str Help format (e.g., "markdown", "rich"). lines : list[str] List to append description to. """ description = extract_description(app, help_format) if description: # Preserve markup when help_format matches output format (markdown) preserve = help_format in ("markdown", "md") desc_text = extract_text(description, None, preserve_markup=preserve) if desc_text: lines.append(desc_text.strip()) lines.append("") def _render_usage_section( app: "App", command_chain: list[str], lines: list[str], usage_name: str | None = None, ) -> None: """Render usage console block. Parameters ---------- app : App The app to extract usage from. command_chain : list[str] Command chain for usage line. lines : list[str] List to append usage to. usage_name : str | None Optional replacement for the chain's root in Usage: lines only. """ if should_show_usage(app): usage = extract_usage(app) if usage: lines.append("```console") if isinstance(usage, str): usage_text = usage else: usage_text = extract_text(usage, None, preserve_markup=False) display_chain = apply_usage_name(command_chain, usage_name) usage_line = format_usage_line(usage_text, display_chain) lines.append(usage_line) lines.append("```") lines.append("") def _render_toc( app: "App", app_name: str, include_hidden: bool, commands_filter: list[str] | None, exclude_commands: list[str] | None, skip_filtered_command: bool, lines: list[str], ) -> None: """Generate and render table of contents. Parameters ---------- app : App The app to generate TOC for. app_name : str Application name for TOC prefixes. include_hidden : bool Whether to include hidden commands. commands_filter : list[str] | None Commands to include. exclude_commands : list[str] | None Commands to exclude. skip_filtered_command : bool Whether to skip the single filtered command in TOC. lines : list[str] List to append TOC to. """ # Collect all commands recursively for TOC toc_commands = _collect_commands_for_toc( app, include_hidden=include_hidden, prefix=f"{app_name} " if app_name else "", commands_filter=commands_filter, exclude_commands=exclude_commands, skip_filtered_command=skip_filtered_command, ) if toc_commands: lines.append("## Table of Contents") lines.append("") _generate_toc_entries(lines, toc_commands) lines.append("") def _render_parameter_panel(panel, formatter, lines: list[str]) -> None: """Render a parameter panel as-is. Parameters ---------- panel : HelpPanel The parameter panel to render. formatter : MarkdownFormatter Formatter to use for rendering. lines : list[str] List to append rendered content to. """ # Render panel content first to check if there's anything formatter.reset() panel_copy = panel.copy(title="") formatter(None, None, panel_copy) output = formatter.get_output().strip() # Only render if there's actual content if output: if panel.title: lines.append(f"**{panel.title}**:\n") lines.append(output) lines.append("") def _filter_command_entries( entries: list, command_map: dict[str, "App"], parent_path: list[str], normalized_filter: set[str] | None, normalized_exclude: set[str] | None, ) -> list: """Filter command entries based on inclusion/exclusion rules. Parameters ---------- entries : list Command entries to filter. command_map : dict[str, App] Mapping of command names to App objects. parent_path : list[str] Parent command path. normalized_filter : set[str] | None Normalized filter set. normalized_exclude : set[str] | None Normalized exclude set. Returns ------- list Filtered command entries. """ filtered_entries = [] for entry in entries: if entry.names: cmd_name = entry.names[0] subapp = command_map.get(cmd_name) if subapp is None: # If command not in map and no filters, include it if normalized_filter is None and normalized_exclude is None: filtered_entries.append(entry) else: # Check if command should be included if should_include_command(cmd_name, parent_path, normalized_filter, normalized_exclude, subapp): filtered_entries.append(entry) return filtered_entries def generate_markdown_docs( app: "App", recursive: bool = True, include_hidden: bool = False, heading_level: int = 1, max_heading_level: int = 6, command_chain: list[str] | None = None, generate_toc: bool = True, flatten_commands: bool = False, commands_filter: list[str] | None = None, exclude_commands: list[str] | None = None, no_root_title: bool = False, code_block_title: bool = False, skip_preamble: bool = False, usage_name: str | None = None, ) -> str: """Generate markdown documentation for a CLI application. Parameters ---------- app : App The cyclopts App instance to document. recursive : bool If True, generate documentation for all subcommands recursively. Default is True. include_hidden : bool If True, include hidden commands/parameters in documentation. Default is False. heading_level : int Starting heading level for the main application title. Default is 1 (single #). max_heading_level : int Maximum heading level to use. Headings deeper than this will be capped at this level. Standard Markdown supports levels 1-6. Default is 6. command_chain : list[str] Internal parameter to track command hierarchy. Default is None. generate_toc : bool If True, generate a table of contents for multi-command apps. Default is True. flatten_commands : bool If True, generate all commands at the same heading level instead of nested. Default is False. commands_filter : list[str] | None If specified, only include commands in this list. Supports nested command paths like "db.migrate". Default is None (include all commands). exclude_commands : list[str] | None If specified, exclude commands in this list. Supports nested command paths like "db.migrate". Default is None (no exclusions). no_root_title : bool If True, skip the root application title. Used for plugin contexts. Default is False. skip_preamble : bool If True, skip the description and usage sections for the target command when filtering to a single command via ``commands_filter``. Useful when the user provides their own section introduction. Default is False. usage_name : str | None Optional replacement for the root app name used in ``Usage:`` lines only. Headings and TOC anchors still use ``app.name[0]``. Default is None (use ``app.name[0]`` as before). Returns ------- str The generated markdown documentation. """ from cyclopts.help.formatters.markdown import MarkdownFormatter # Build the main documentation lines = [] if command_chain is None: command_chain = [] is_root = True else: is_root = False # Determine if we should skip the current level's content # When filtering to a single command, skip the root app content skip_current_level = is_root and commands_filter is not None and len(commands_filter) == 1 # Determine the app name and full command path app_name, full_command, base_title = get_app_info(app, command_chain) # Always use full command path for nested commands to avoid anchor collisions # (e.g., "files cp" and "other cp" would both generate #cp without this) if command_chain: # Show full command path (same for both hierarchical and flattened modes) title = f"`{full_command}`" if code_block_title else full_command else: # Root app: use base title title = base_title # Add title for all levels (unless skipping root title or skipping current level entirely) if not skip_current_level and not (no_root_title and is_root): effective_level = min(heading_level, max_heading_level) lines.append(f"{'#' * effective_level} {title}") lines.append("") # Get help format (needed for both current level and recursive docs) help_format = app.app_stack.resolve("help_format", fallback=DEFAULT_FORMAT) # Add usage section first (skip if skipping current level or skip_preamble is True) if not skip_current_level and not skip_preamble: _render_usage_section(app, command_chain, lines, usage_name=usage_name) # Add application description (skip if skipping current level or skip_preamble is True) if not skip_current_level and not skip_preamble: _render_description_section(app, help_format, lines) # Generate table of contents if this is the root level and has commands if generate_toc and not command_chain and app._commands: _render_toc(app, app_name, include_hidden, commands_filter, exclude_commands, skip_current_level, lines) # Get help panels for the current app (skip if skipping current level) # Use app_stack context - if caller set up parent context, it will be stacked if not skip_current_level: with app.app_stack([app]): help_panels_with_groups = app._assemble_help_panels([], help_format) # Set up command filtering (used for command panels only) normalized_commands_filter, normalized_exclude_commands = normalize_command_filters( commands_filter, exclude_commands ) parent_path = [] # Build a mapping of command names to App objects for filtering command_map = _build_command_map(app, include_hidden=True) # Create formatter formatter = MarkdownFormatter( heading_level=heading_level + 1, include_hidden=include_hidden, table_style="list", ) # Iterate through panels in the order provided by _assemble_help_panels (already sorted) for group, panel in help_panels_with_groups: # Skip hidden groups if not include_hidden and group and not group.show: continue if panel.format == "command": # Always filter out built-in flags (--help, --version) from command panels # These are standard CLI flags, not commands, and shouldn't appear here command_entries = [e for e in panel.entries if not (e.names and is_all_builtin_flags(app, e.names))] if not command_entries: continue # Skip empty panel # Apply command filtering filtered_entries = _filter_command_entries( command_entries, command_map, parent_path, normalized_commands_filter, normalized_exclude_commands ) # Only render if there are filtered entries if filtered_entries: if panel.title: lines.append(f"**{panel.title}**:\n") # Render command entries with hyperlinks to their sections for entry in filtered_entries: if entry.names: cmd_name = entry.names[0] # Generate anchor for the full command path # Use full_command (not app_name) to include the complete path for nested apps full_cmd_path = f"{full_command} {cmd_name}" anchor = generate_anchor(full_cmd_path) desc_text = ( extract_text(entry.description, None, preserve_markup=True) if entry.description else "" ) if desc_text: lines.append(f"* [`{cmd_name}`](#{anchor}): {desc_text}") else: lines.append(f"* [`{cmd_name}`](#{anchor})") lines.append("") elif panel.format == "parameter": # Handle parameter panels - split into arguments and options if needed _render_parameter_panel(panel, formatter, lines) else: # When skipping current level, still need to set up filter variables for recursive docs normalized_commands_filter, normalized_exclude_commands = normalize_command_filters( commands_filter, exclude_commands ) parent_path = [] # Handle recursive documentation for subcommands if app._commands: # Iterate through registered commands using iterate_commands helper # This automatically resolves CommandSpec instances for name, subapp in iterate_commands(app, include_hidden): if not should_include_command( name, parent_path, normalized_commands_filter, normalized_exclude_commands, subapp ): continue # Build the command chain for this subcommand sub_command_chain = build_command_chain(command_chain, name, app_name) # Determine heading level for subcommand if flatten_commands: sub_heading_level = heading_level elif no_root_title and not command_chain: # When root title is skipped, subcommands "take over" the root heading level sub_heading_level = heading_level else: sub_heading_level = heading_level + 1 # Check if we should skip this command's title heading # Skip title when: root was skipped (single command filter) AND this is the direct target # OR this is an intermediate command on the path to a nested target # This allows the markdown author's section title to serve as the heading is_single_filter = commands_filter is not None and len(commands_filter) == 1 is_exact_target = is_single_filter and commands_filter is not None and name == commands_filter[0] is_intermediate_path = ( is_single_filter and commands_filter is not None and commands_filter[0].startswith(name + ".") ) skip_this_command_title = skip_current_level and is_exact_target # Also skip intermediate commands entirely when skip_preamble is set skip_intermediate = skip_preamble and skip_current_level and is_intermediate_path # Skip preamble for the exact target when skip_preamble is set (even in recursive calls) skip_target_preamble = skip_preamble and is_exact_target # Generate subcommand title (skip if this is the single filtered command at root level, # or if this is an intermediate command and skip_preamble is set) if not skip_this_command_title and not skip_intermediate: # Always use full command path to avoid anchor collisions display_name = " ".join(sub_command_chain) display_fmt = f"`{display_name}`" if code_block_title else display_name effective_sub_level = min(sub_heading_level, max_heading_level) lines.append(f"{'#' * effective_sub_level} {display_fmt}") lines.append("") # Get subapp help - show description, usage, and panels for included commands # Skip preamble (description + usage) if: # - skip_preamble is True and this is the exact target (even in recursive calls) # - or this is an intermediate command on the path to a nested target skip_this_preamble = skip_target_preamble or skip_intermediate # Include parent app in the stack so default_parameter is properly inherited with subapp.app_stack([app, subapp]): sub_help_format = subapp.app_stack.resolve("help_format", fallback=help_format) # Preserve markup when sub_help_format matches output format (markdown) preserve_sub = sub_help_format in ("markdown", "md") if not skip_this_preamble: # Generate usage first for subcommand _render_usage_section(subapp, sub_command_chain, lines, usage_name=usage_name) _render_description_section(subapp, sub_help_format, lines) # Only show subcommand panels if we're in recursive mode # (Otherwise we just show the basic info about this command) if recursive: # Get help panels for subcommand (already sorted) sub_panels = subapp._assemble_help_panels([], sub_help_format) # Set up command filtering for this subcommand sub_commands_filter_for_panel, sub_exclude_commands_for_panel = adjust_filters_for_subcommand( name, normalized_commands_filter, normalized_exclude_commands ) normalized_sub_filter_panel, normalized_sub_exclude_panel = normalize_command_filters( sub_commands_filter_for_panel, sub_exclude_commands_for_panel ) # Build a map of command names to App objects for filtering sub_command_map = _build_command_map(subapp, include_hidden=True) # Build parent path for nested commands # Use empty path since filter was already adjusted to strip current level's prefix nested_parent_path_for_panel = [] # Create formatter if flatten_commands: panel_heading_level = heading_level + 1 else: panel_heading_level = heading_level + 2 sub_formatter = MarkdownFormatter( heading_level=panel_heading_level, include_hidden=include_hidden, table_style="list" ) # Check if we'll be recursively documenting commands will_recurse = recursive and subapp._commands # Iterate through panels in order for group, panel in sub_panels: # Skip hidden groups if not include_hidden and group and not group.show: continue if panel.format == "command" and should_show_commands_list(subapp): # Always filter out built-in flags (--help, --version) from command panels command_entries_list = [ e for e in panel.entries if not (e.names and is_all_builtin_flags(subapp, e.names)) ] if not command_entries_list: continue # Skip empty panel # Apply command filtering for command panels if will_recurse: # Show simple command list command_entries = [] for entry in command_entries_list: if entry.names: cmd_name = entry.names[0] sub_cmd_app = sub_command_map.get(cmd_name) if sub_cmd_app and not should_include_command( cmd_name, nested_parent_path_for_panel, normalized_sub_filter_panel, normalized_sub_exclude_panel, sub_cmd_app, ): continue desc_text = ( extract_text(entry.description, None, preserve_markup=preserve_sub) if entry.description else "" ) # Generate anchor for the full command path full_cmd_path = " ".join(sub_command_chain + [cmd_name]) anchor = generate_anchor(full_cmd_path) if desc_text: command_entries.append(f"* [`{cmd_name}`](#{anchor}): {desc_text}") else: command_entries.append(f"* [`{cmd_name}`](#{anchor})") if command_entries: if panel.title: lines.append(f"**{panel.title}**:\n") lines.extend(command_entries) lines.append("") else: # Show full command panel filtered_entries = [] for entry in command_entries_list: if entry.names: cmd_name = entry.names[0] sub_cmd_app = sub_command_map.get(cmd_name) if sub_cmd_app and not should_include_command( cmd_name, nested_parent_path_for_panel, normalized_sub_filter_panel, normalized_sub_exclude_panel, sub_cmd_app, ): continue filtered_entries.append(entry) if filtered_entries: if panel.title: lines.append(f"**{panel.title}**:\n") sub_formatter.reset() filtered_panel = panel.__class__( title="", entries=filtered_entries, format=panel.format, description=panel.description, ) sub_formatter(None, None, filtered_panel) output = sub_formatter.get_output().strip() if output: lines.append(output) lines.append("") elif panel.format == "parameter": # Handle parameter panels - split into arguments and options if needed _render_parameter_panel(panel, sub_formatter, lines) # Process nested commands INSIDE the with block so context is preserved if recursive and subapp._commands: sub_commands_filter, sub_exclude_commands = adjust_filters_for_subcommand( name, normalized_commands_filter, normalized_exclude_commands ) normalized_sub_filter, normalized_sub_exclude = normalize_command_filters( sub_commands_filter, sub_exclude_commands ) # Build parent path for nested commands # Use empty path since filter was already adjusted to strip current level's prefix nested_parent_path = [] for nested_name, nested_app in iterate_commands(subapp, include_hidden): if not should_include_command( nested_name, nested_parent_path, normalized_sub_filter, normalized_sub_exclude, nested_app ): continue # Build nested command chain (always use full path for correct usage) nested_command_chain = build_command_chain(sub_command_chain, nested_name, app_name) # Determine heading level for nested commands if flatten_commands: nested_heading_level = heading_level elif skip_this_command_title: # When parent command's title was skipped, promote nested commands to parent's level nested_heading_level = sub_heading_level else: nested_heading_level = sub_heading_level + 1 # Determine commands_filter for the recursive call # Adjust filter to strip current command's prefix for the nested level if normalized_sub_filter: nested_commands_filter, _ = adjust_filters_for_subcommand( nested_name, normalized_sub_filter, normalized_sub_exclude ) else: nested_commands_filter = None # Check if this nested command is the target for skip_preamble purposes # This handles nested paths like "parent.child" where "child" is the target nested_is_target = ( skip_preamble and sub_commands_filter is not None and len(sub_commands_filter) == 1 and nested_name == sub_commands_filter[0] ) # Also check if this is an intermediate on a deeper path nested_is_intermediate = ( skip_preamble and sub_commands_filter is not None and len(sub_commands_filter) == 1 and sub_commands_filter[0].startswith(nested_name + ".") ) # Set up context for nested_app, then recurse # The recursive call's app_stack([app]) will stack on top of this with nested_app.app_stack([subapp, nested_app]): nested_docs = generate_markdown_docs( nested_app, recursive=recursive, include_hidden=include_hidden, heading_level=nested_heading_level, max_heading_level=max_heading_level, command_chain=nested_command_chain, generate_toc=False, # Don't generate TOC for nested commands flatten_commands=flatten_commands, commands_filter=nested_commands_filter, exclude_commands=sub_exclude_commands, no_root_title=nested_is_intermediate, # Skip title for intermediate paths code_block_title=code_block_title, skip_preamble=nested_is_target or nested_is_intermediate, usage_name=usage_name, ) # Just append the generated docs - no title replacement lines.append(nested_docs) lines.append("") # Join all lines into final document doc = "\n".join(lines).rstrip() + "\n" # Normalize multiple consecutive blank lines to a single blank line # This ensures consistent spacing regardless of how content was assembled import re doc = re.sub(r"\n{3,}", "\n\n", doc) return doc BrianPugh-cyclopts-921b1fa/cyclopts/docs/rst.py000066400000000000000000000415541517576204000216300ustar00rootroot00000000000000"""RST documentation generation functions for cyclopts apps.""" from typing import TYPE_CHECKING from cyclopts._markup import extract_text from cyclopts.docs.base import ( adjust_filters_for_subcommand, apply_usage_name, extract_description, extract_usage, generate_anchor, get_app_info, is_all_builtin_flags, iterate_commands, normalize_command_filters, should_include_command, should_show_usage, ) if TYPE_CHECKING: from cyclopts.core import App def make_rst_code_block_title(title: str) -> list[str]: """Create an RST code block containing the title. Parameters ---------- title : str Title text to display in code block. Returns ------- list[str] RST formatted code block lines. """ return [ ".. code-block:: text", "", f" {title}", ] def make_rst_section_header(title: str, level: int) -> list[str]: """Create an RST section header. Parameters ---------- title : str Section title. level : int Heading level (1-6). Returns ------- list[str] RST formatted section header lines. """ markers = { 1: "=", 2: "-", 3: "^", 4: '"', 5: "'", 6: "~", } if level < 1: level = 1 elif level > 6: level = 6 marker = markers[level] underline = marker * len(title) if level == 1: return [underline, title, underline] else: return [title, underline] def _build_command_map(app: "App", include_hidden: bool = True) -> dict[str, "App"]: """Build mapping of command names to App objects. Parameters ---------- app : App The app to extract commands from. include_hidden : bool Whether to include hidden commands. Returns ------- dict[str, App] Mapping of command names to App instances. """ command_map = {} if app._commands: for name, subapp in iterate_commands(app, include_hidden): command_map[name] = subapp return command_map def _filter_command_entries( entries: list, command_map: dict[str, "App"], parent_path: list[str], normalized_filter: set[str] | None, normalized_exclude: set[str] | None, ) -> list: """Filter command entries based on inclusion/exclusion rules. Parameters ---------- entries : list Command entries to filter. command_map : dict[str, App] Mapping of command names to App objects. parent_path : list[str] Parent command path. normalized_filter : set[str] | None Normalized filter set. normalized_exclude : set[str] | None Normalized exclude set. Returns ------- list Filtered command entries. """ filtered_entries = [] for entry in entries: if entry.names: cmd_name = entry.names[0] subapp = command_map.get(cmd_name) if subapp is None: # If command not in map and no filters, include it if normalized_filter is None and normalized_exclude is None: filtered_entries.append(entry) else: # Check if command should be included if should_include_command(cmd_name, parent_path, normalized_filter, normalized_exclude, subapp): filtered_entries.append(entry) return filtered_entries def _generate_toc(lines: list[str]) -> None: """Generate table of contents using RST contents directive. The `.. contents::` directive automatically generates a TOC from section headings, which is the idiomatic approach for RST/Sphinx. """ lines.append(".. contents:: Table of Contents") lines.append(" :local:") lines.append(" :depth: 6") lines.append("") def generate_rst_docs( app: "App", recursive: bool = True, include_hidden: bool = False, heading_level: int = 1, max_heading_level: int = 6, command_chain: list[str] | None = None, generate_toc: bool = True, flatten_commands: bool = False, commands_filter: list[str] | None = None, exclude_commands: list[str] | None = None, no_root_title: bool = False, code_block_title: bool = False, skip_preamble: bool = False, usage_name: str | None = None, ) -> str: """Generate reStructuredText documentation for a CLI application. Parameters ---------- app : App The cyclopts App instance to document. recursive : bool If True, generate documentation for all subcommands recursively. Default is True. include_hidden : bool If True, include hidden commands/parameters in documentation. Default is False. heading_level : int Starting heading level for the main application title. Default is 1 (uses '=' markers). max_heading_level : int Maximum heading level to use. Headings deeper than this will be capped at this level. RST uses different underline characters for each level. Default is 6. command_chain : list[str] Internal parameter to track command hierarchy. Default is None. generate_toc : bool If True, generate a table of contents for multi-command apps. Default is True. flatten_commands : bool If True, generate all commands at the same heading level instead of nested. Default is False. commands_filter : list[str], optional If specified, only include commands in this list. Supports nested command paths like "db.migrate". Default is None (include all commands). exclude_commands : list[str], optional If specified, exclude commands in this list. Supports nested command paths like "db.migrate". Default is None (no exclusions). no_root_title : bool If True, skip generating the root application title. Useful when embedding in existing documentation with its own title. Default is False. skip_preamble : bool If True, skip the description and usage sections for the target command when filtering to a single command via ``commands_filter``. Useful when the user provides their own section introduction. Default is False. usage_name : str | None Optional replacement for the root app name used in ``Usage:`` lines only. Section headings, anchors, and TOC continue to use ``app.name[0]``. Default is None. Returns ------- str The generated RST documentation. """ from cyclopts.help.formatters.rst import RstFormatter lines = [] if command_chain is None: command_chain = [] app_name, full_command, base_title = get_app_info(app, command_chain) # Title logic: match markdown behavior for consistency # - Hierarchical mode: show just command name (last part of chain) # - Flattened mode: show full command path # - Root: use base title if command_chain and not flatten_commands: # Hierarchical: show just the command name (last part of chain) title = command_chain[-1] elif command_chain: # Flattened: show full command path title = full_command else: # Root app: use base title title = base_title # Always generate RST anchor/label with improved namespacing # RST uses a "cyclopts-" prefix for namespacing anchor_parts = ["cyclopts"] if command_chain: anchor_parts.extend(command_chain) else: anchor_parts.append(app_name) # Use shared anchor generation logic, then add RST-specific slash replacement anchor_name = generate_anchor(" ".join(anchor_parts)).replace("/", "-") lines.append(f".. _{anchor_name}:") lines.append("") # Determine effective heading level for this command if no_root_title and not command_chain: # Skip title entirely for root when no_root_title is True effective_heading_level = heading_level elif flatten_commands and command_chain: # When flattening, all commands use the same heading level effective_heading_level = heading_level else: # Normal hierarchical: increment level for nested commands effective_heading_level = heading_level + len(command_chain) - 1 if command_chain else heading_level # Cap at max_heading_level effective_heading_level = min(effective_heading_level, max_heading_level) if not (no_root_title and not command_chain): if code_block_title: header_lines = make_rst_code_block_title(title) else: header_lines = make_rst_section_header(title, effective_heading_level) lines.extend(header_lines) lines.append("") help_format = app.app_stack.resolve("help_format", fallback="restructuredtext") # Add usage section first if appropriate (skip if skip_preamble is True) if not skip_preamble and should_show_usage(app): # Generate usage line - only if we're documenting a specific command. # When no_root_title is set at the root (e.g., in Sphinx contexts), the # root Usage: line is intentionally suppressed; usage_name still applies # to every subcommand usage block below. if not (no_root_title and not command_chain): # Extract usage from app usage = extract_usage(app) usage_text = None if usage: if isinstance(usage, str): usage_text = usage else: usage_text = extract_text(usage, None, preserve_markup=False) # Apply usage_name override to the display chain (only for the Usage: line) display_chain = apply_usage_name(command_chain, usage_name) # Format usage with the display chain when one is present if display_chain: parts = usage_text.split(None, 1) if len(parts) > 1: usage_text = f"{' '.join(display_chain)} {parts[1]}" else: usage_text = " ".join(display_chain) if usage_text: # Use literal block with double colon lines.append("::") lines.append("") # Indent usage text with 4 spaces for literal block for line in usage_text.split("\n"): lines.append(f" {line}") lines.append("") # Add description (skip if skip_preamble is True) if not skip_preamble: description = extract_description(app, help_format) if description: # Extract plain text from description # Preserve markup when help_format matches output format (RST) preserve = help_format in ("restructuredtext", "rst") desc_text = extract_text(description, None, preserve_markup=preserve) if desc_text: lines.append(desc_text.strip()) lines.append("") # Generate table of contents at root level only if generate_toc and not command_chain and app._commands: _generate_toc(lines) # Get help panels for the current app # Use app_stack context - if caller set up parent context, it will be stacked with app.app_stack([app]): help_panels_with_groups = app._assemble_help_panels([], help_format) # Set up command filtering normalized_commands_filter, normalized_exclude_commands = normalize_command_filters( commands_filter, exclude_commands ) parent_path: list[str] = [] # Build a mapping of command names to App objects for filtering command_map = _build_command_map(app, include_hidden=True) # Create formatter for help panels formatter = RstFormatter(heading_level=heading_level + 1, include_hidden=include_hidden) # Render panels as-is without categorization for group, panel in help_panels_with_groups: # Skip hidden panels unless include_hidden is True if not include_hidden and group and not group.show: continue # Skip if no_root_title and we're at root if no_root_title and not command_chain: continue # Render command panels as grouped command lists if panel.format == "command": # Filter out built-in flags (--help, --version) from command panels command_entries = [e for e in panel.entries if not (e.names and is_all_builtin_flags(app, e.names))] if not command_entries: continue # Skip empty panel # Apply command filtering filtered_entries = _filter_command_entries( command_entries, command_map, parent_path, normalized_commands_filter, normalized_exclude_commands ) if not filtered_entries: continue # Skip if nothing after filtering # Render group title if panel.title: lines.append(f"**{panel.title}:**") lines.append("") # Render commands as RST definition list for entry in filtered_entries: primary_name = entry.names[0] if entry.names else "" desc = extract_text(entry.description, None) lines.append(f"``{primary_name}``") if desc: lines.append(f" {desc}") lines.append("") # Render parameter panels as-is elif panel.format == "parameter": # Render content first to check if there's anything formatter.reset() panel_copy = panel.copy(title="") formatter(None, None, panel_copy) output = formatter.get_output().strip() # Only render if there's actual content if output: if panel.title: lines.append(f"**{panel.title}:**") lines.append("") lines.append(output) lines.append("") if recursive and app._commands: normalized_commands_filter, normalized_exclude_commands = normalize_command_filters( commands_filter, exclude_commands ) parent_path = [] for name, subapp in iterate_commands(app, include_hidden): if not should_include_command( name, parent_path, normalized_commands_filter, normalized_exclude_commands, subapp ): continue lines.append("") subcommand_chain = command_chain + [name] if command_chain else [app_name, name] if flatten_commands: next_heading_level = heading_level elif no_root_title and not command_chain: next_heading_level = heading_level - 1 else: next_heading_level = heading_level sub_commands_filter, sub_exclude_commands = adjust_filters_for_subcommand( name, normalized_commands_filter, normalized_exclude_commands ) # Determine if this subcommand should skip its preamble # Skip preamble when: we're at root, skip_preamble is True, and this is the single target command # OR this is an intermediate command on the path to a nested target is_single_target = ( not command_chain and skip_preamble and commands_filter is not None and len(commands_filter) == 1 and name == commands_filter[0] ) is_intermediate_path = ( not command_chain and skip_preamble and commands_filter is not None and len(commands_filter) == 1 and commands_filter[0].startswith(name + ".") ) # Push subapp onto app_stack - context will stack with recursive call's app_stack([app]) with subapp.app_stack([app, subapp]): subdocs = generate_rst_docs( subapp, recursive=recursive, include_hidden=include_hidden, heading_level=next_heading_level, max_heading_level=max_heading_level, command_chain=subcommand_chain, generate_toc=False, # Only generate TOC at root level flatten_commands=flatten_commands, commands_filter=sub_commands_filter, exclude_commands=sub_exclude_commands, no_root_title=is_intermediate_path, # Skip title for intermediate path commands code_block_title=code_block_title, skip_preamble=is_single_target or is_intermediate_path, # Skip preamble for target or intermediate usage_name=usage_name, ) lines.append(subdocs) # Join and normalize multiple consecutive blank lines to a single blank line import re doc = "\n".join(lines) doc = re.sub(r"\n{3,}", "\n\n", doc) return doc BrianPugh-cyclopts-921b1fa/cyclopts/docs/types.py000066400000000000000000000025151517576204000221560ustar00rootroot00000000000000"""Type definitions and utilities for documentation generation.""" from typing import Literal # All accepted format values (canonical and aliases) DocFormat = Literal["markdown", "md", "html", "htm", "rst", "rest", "restructuredtext"] # Canonical format values only CanonicalDocFormat = Literal["markdown", "html", "rst"] # Map all aliases to their canonical format # Also used for suffix lookups by stripping the leading period FORMAT_ALIASES: dict[str, CanonicalDocFormat] = { "md": "markdown", "markdown": "markdown", "html": "html", "htm": "html", "rst": "rst", "rest": "rst", "restructuredtext": "rst", } def normalize_format(format_value: str) -> CanonicalDocFormat: """Normalize format aliases to standard format names. Parameters ---------- format_value : str The format string to normalize (case-insensitive). Returns ------- CanonicalDocFormat The canonical format name. Raises ------ ValueError If the format is not recognized. """ format_lower = format_value.lower() canonical_format = FORMAT_ALIASES.get(format_lower) if canonical_format is None: raise ValueError( f'Unsupported format "{format_value}". Supported formats: {", ".join(sorted(FORMAT_ALIASES.keys()))}' ) return canonical_format BrianPugh-cyclopts-921b1fa/cyclopts/exceptions.py000066400000000000000000000404321517576204000222430ustar00rootroot00000000000000import inspect import json from collections.abc import Callable, Sequence from enum import Enum from itertools import chain from typing import TYPE_CHECKING, Any, Literal, Optional, get_args, get_origin from attrs import define, field import cyclopts.utils from cyclopts.annotations import get_hint_name from cyclopts.command_spec import CommandSpec from cyclopts.group import Group from cyclopts.token import Token from cyclopts.utils import is_option_like, json_decode_error_verbosifier if TYPE_CHECKING: from rich.console import Console from cyclopts.argument import Argument, ArgumentCollection from cyclopts.core import App __all__ = [ "CoercionError", "CommandCollisionError", "CycloptsError", "DocstringError", "UnknownCommandError", "MissingArgumentError", "ConsumeMultipleError", "MixedArgumentError", "RepeatArgumentError", "RequiresEqualsError", "UnknownOptionError", "UnusedCliTokensError", "ValidationError", "CombinedShortOptionError", ] def _get_function_info(func): return inspect.getsourcefile(func), inspect.getsourcelines(func)[1] class CommandCollisionError(Exception): """A command with the same name has already been registered to the app.""" # This doesn't derive from CycloptsError since this is a developer error # rather than a runtime error. class DocstringError(Exception): """The docstring either has a syntax error, or inconsistency with the function signature.""" @define # (kw_only=True) class CycloptsError(Exception): """Root exception for runtime errors. As CycloptsErrors bubble up the Cyclopts call-stack, more information is added to it. """ msg: str | None = None """ If set, override automatic message generation. """ verbose: bool = True """ More verbose error messages; aimed towards developers debugging their Cyclopts app. Defaults to ``False``. """ root_input_tokens: list[str] | None = None """ The parsed CLI tokens that were initially fed into the :class:`App`. """ unused_tokens: list[str] | None = None """ Leftover tokens after parsing is complete. """ target: Callable | None = None """ The python function associated with the command being parsed. """ argument: Optional["Argument"] = None """ :class:`Argument` that was matched. """ command_chain: Sequence[str] | None = None """ List of command that lead to ``target``. """ app: Optional["App"] = None """ The Cyclopts application itself. """ console: Optional["Console"] = field(default=None, kw_only=True) """:class:`~rich.console.Console` to display runtime errors.""" def __str__(self): if self.msg is not None: return self.msg strings = [] if self.verbose: strings.append(type(self).__name__) if self.target: file, lineno = _get_function_info(self.target) strings.append(f'Function defined in file "{file}", line {lineno}:') strings.append(f" {self.target.__name__}{inspect.signature(self.target)}") if self.root_input_tokens is not None: strings.append(f"Root Input Tokens: {self.root_input_tokens}") else: pass if strings: return "\n".join(strings) + "\n" else: return "" @define(kw_only=True) class CombinedShortOptionError(CycloptsError): """Cannot combine short, token-consuming options with short flags.""" @define(kw_only=True) class ValidationError(CycloptsError): """Validator function raised an exception.""" exception_message: str = "" """Parenting Assertion/Value/Type Error message.""" group: Group | None = None """If a group validator caused the exception.""" value: Any = cyclopts.utils.UNSET """Converted value that failed validation.""" def __str__(self): message = "" if self.argument: value = self.argument.value if self.value is cyclopts.utils.UNSET else self.value try: token = self.argument.tokens[0] except IndexError: pass else: provided_by = "" if not token.source or token.source == "cli" else f' provided by "{token.source}"' name = token.keyword if token.keyword else self.argument.name.lstrip("-").upper() message = f'Invalid value "{value}" for "{name}"{provided_by}.' elif self.group: if self.group.name: message = f'Invalid values for group "{self.group.name}".' elif self.command_chain: message = f"Invalid values for command {self.command_chain[-1]!r}." else: raise NotImplementedError cyclopts_message = f"{super().__str__()}{message}" if self.exception_message: if cyclopts_message: return f"{cyclopts_message} {self.exception_message}" else: return self.exception_message else: return cyclopts_message @define(kw_only=True) class UnknownOptionError(CycloptsError): """Unknown/unregistered option provided by the cli. A nearest-neighbor parameter suggestion may be printed. """ token: Token """Token without a matching parameter.""" argument_collection: "ArgumentCollection" """Argument collection of plausible options.""" def __str__(self): value = self.token.keyword or self.token.value if self.token.source == "cli": response = f'Unknown option: "{value}".' else: response = f'Unknown option: "{value}" from "{self.token.source}".' if keyword := self.token.keyword or self.token.value: import difflib candidates = list(chain.from_iterable(x.names for x in self.argument_collection if x.parse)) close_matches = difflib.get_close_matches(keyword, candidates, n=1, cutoff=0.6) if close_matches: response += f' Did you mean "{close_matches[0]}"?' return super().__str__() + response @define(kw_only=True) class CoercionError(CycloptsError): """There was an error performing automatic type coercion.""" token: Optional["Token"] = None """ Input token that couldn't be coerced. """ target_type: type | None = None """ Intended type to coerce into. """ def __str__(self): if self.msg is not None: if not self.token or self.token.keyword is None: return self.msg else: return f"Invalid value for {self.token.keyword}: {self.msg}" else: # If a JsonDecodeError, try and verbosify it. if isinstance(self.__cause__, json.JSONDecodeError): msg = json_decode_error_verbosifier(self.__cause__) # pyright: ignore[reportArgumentType] if not self.token or self.token.keyword is None: return msg else: return f"Invalid value for {self.token.keyword}: {msg}" assert self.argument is not None assert self.target_type is not None msg = super().__str__() if get_origin(self.target_type) is Literal: choices = "{" + ", ".join(repr(x) for x in get_args(self.target_type)) + "}" target_type_name = f"one of {choices}" elif isinstance(self.target_type, type) and issubclass(self.target_type, Enum): nt = self.argument.parameter.name_transform choices = "{" + ", ".join(repr(nt(x)) for x in self.target_type.__members__) + "}" target_type_name = f"one of {choices}" else: target_type_name = get_hint_name(self.target_type) if not self.token: msg += f'Invalid value for "{self.argument.name}": unable to convert value to {target_type_name}.' elif self.token.keyword is None: positional_name = self.argument.name.lstrip("-").upper() if self.token.source == "" or self.token.source == "cli": msg += f'Invalid value for "{positional_name}": unable to convert "{self.token.value}" into {target_type_name}.' else: msg += f'Invalid value for "{positional_name}" from {self.token.source}: unable to convert "{self.token.value}" into {target_type_name}.' else: if self.token.source == "" or self.token.source == "cli": msg += f'Invalid value for "{self.token.keyword}": unable to convert "{self.token.value}" into {target_type_name}.' else: msg += f'Invalid value for "{self.token.keyword}" from {self.token.source}: unable to convert "{self.token.value}" into {target_type_name}.' return msg class UnknownCommandError(CycloptsError): """CLI token combination did not yield a valid command.""" def __str__(self): assert self.unused_tokens token = self.unused_tokens[0] response = f'Unknown command "{token}".' if self.app and self.app._commands: import difflib # Resolve CommandSpec and filter visible commands visible_commands = [] for name, app_or_spec in self.app._commands.items(): if name in self.app._help_flags or name in self.app._version_flags: continue # Resolve CommandSpec to App subapp = app_or_spec.resolve(self.app) if isinstance(app_or_spec, CommandSpec) else app_or_spec if not isinstance(subapp, type(self.app)): continue if subapp.show: visible_commands.append(name) close_matches = difflib.get_close_matches( token, visible_commands, n=1, cutoff=0.6, ) if close_matches: response += f' Did you mean "{close_matches[0]}"?' # The following is a heuristic to be "maximally helpful" to someone who may have # forgotten a command in their CLI call. max_commands = 8 available_commands = [name for name in visible_commands if not name.startswith("-")] if available_commands: if len(available_commands) > max_commands: response += f" Available commands: {', '.join(available_commands[:max_commands])}, ..." else: response += f" Available commands: {', '.join(available_commands)}." return super().__str__() + response @define(kw_only=True) class UnusedCliTokensError(CycloptsError): """Not all CLI tokens were used as expected.""" def __str__(self): assert self.unused_tokens is not None return super().__str__() + f"Unused Tokens: {self.unused_tokens}." @define(kw_only=True) class MissingArgumentError(CycloptsError): """A required argument was not provided.""" tokens_so_far: list[str] = field(factory=list) """If the matched parameter requires multiple tokens, these are the ones we have parsed so far.""" keyword: str | None = None """The keyword that was used when the error was raised (e.g., '-o' instead of '--option').""" def __str__(self): assert self.argument is not None strings = [] count, _ = self.argument.token_count() if count == 0: required_string = "flag required" only_got_string = "" elif count == 1: required_string = "requires an argument" only_got_string = "" else: required_string = f"requires {count} positional arguments" received_count = len(self.tokens_so_far) % count only_got_string = f" Only got {received_count}." if received_count else "" close_match_string = "" if self.unused_tokens and self.argument.field_info.is_keyword: import difflib candidates = [x for x in self.unused_tokens if is_option_like(x)] close_matches = difflib.get_close_matches(self.argument.name, candidates, n=1, cutoff=0.6) if close_matches and close_matches[0] not in self.argument.names: close_match_string = f'Did you mean "{self.argument.name}" instead of "{close_matches[0]}"?' param_name = self.argument.name if self.keyword is not None: param_name = self.keyword elif self.argument.tokens: for token in reversed(self.argument.tokens): if token.keyword is not None: param_name = token.keyword break if self.command_chain: strings.append( f'Command "{" ".join(self.command_chain)}" parameter "{param_name}" {required_string}.{only_got_string}' ) else: strings.append(f'Parameter "{param_name}" {required_string}.{only_got_string}') if close_match_string: strings.append(close_match_string) if self.verbose: strings.append(f" Parsed: {self.tokens_so_far}.") return super().__str__() + " ".join(strings) @define(kw_only=True) class ConsumeMultipleError(MissingArgumentError): """The number of values provided doesn't meet consume_multiple constraints.""" min_required: int = 0 max_allowed: int | None = None actual_count: int = 0 def __str__(self): assert self.argument is not None param_name = self.keyword or self.argument.name if self.actual_count < self.min_required: constraint = f"requires at least {self.min_required}" else: constraint = f"accepts at most {self.max_allowed}" if self.command_chain: base = f'Command "{" ".join(self.command_chain)}" parameter "{param_name}" {constraint} elements. Got {self.actual_count}.' else: base = f'Parameter "{param_name}" {constraint} elements. Got {self.actual_count}.' return CycloptsError.__str__(self) + base @define(kw_only=True) class RequiresEqualsError(CycloptsError): """A long option requires ``=`` to assign a value (e.g., ``--option=value``).""" keyword: str | None = None """The keyword that was used (e.g., '--name').""" def __str__(self): assert self.argument is not None param_name = self.keyword or self.argument.name return ( super().__str__() + f'Parameter "{param_name}" requires a value assigned with "=". Use "{param_name}=VALUE".' ) @define(kw_only=True) class RepeatArgumentError(CycloptsError): """The same parameter has erroneously been specified multiple times.""" token: "Token" """The repeated token.""" def __str__(self): return super().__str__() + f"Parameter {self.token.keyword} specified multiple times." @define(kw_only=True) class ArgumentOrderError(CycloptsError): """Cannot supply a POSITIONAL_OR_KEYWORD argument with a keyword, and then a later POSITIONAL_OR_KEYWORD argument positionally.""" token: str prior_positional_or_keyword_supplied_as_keyword_arguments: list["Argument"] def __str__(self): assert self.argument is not None plural = len(self.prior_positional_or_keyword_supplied_as_keyword_arguments) > 1 display_name = next((x.keyword for x in self.argument.tokens if x.keyword), self.argument.name).lstrip("-") prior_display_names = [ x.tokens[0].keyword for x in self.prior_positional_or_keyword_supplied_as_keyword_arguments ] if len(prior_display_names) == 1: prior_display_names = prior_display_names[0] return ( super().__str__() + f"Cannot specify token {self.token!r} positionally for parameter {display_name!r} due to previously specified keyword{'s' if plural else ''} {prior_display_names!r}. {prior_display_names!r} must either be passed positionally, or {self.token!r} must be passed as a keyword to {self.argument.name!r}." ) @define(kw_only=True) class MixedArgumentError(CycloptsError): """Cannot supply keywords and non-keywords to the same argument.""" def __str__(self): assert self.argument is not None display_name = next((x.keyword for x in self.argument.tokens if x.keyword), self.argument.name) return super().__str__() + f'Cannot supply keyword & non-keyword arguments to "{display_name}".' BrianPugh-cyclopts-921b1fa/cyclopts/ext/000077500000000000000000000000001517576204000203055ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/ext/__init__.py000066400000000000000000000000001517576204000224040ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/ext/mkdocs.py000066400000000000000000000230511517576204000221400ustar00rootroot00000000000000"""MkDocs plugin for automatic Cyclopts CLI documentation.""" import re from typing import TYPE_CHECKING, Any import yaml from attrs import define, field, validators from cyclopts.docs.markdown import generate_markdown_docs from cyclopts.utils import import_app if TYPE_CHECKING: from mkdocs.config.defaults import MkDocsConfig from mkdocs.structure.files import Files from mkdocs.structure.pages import Page from mkdocs.config import base from mkdocs.config import config_options as c from mkdocs.exceptions import PluginError from mkdocs.plugins import BasePlugin, get_plugin_logger logger = get_plugin_logger(__name__) @define(kw_only=True) class DirectiveOptions: """Configuration for the ::: cyclopts directive.""" module: str = field(validator=validators.instance_of(str)) heading_level: int = field(default=2, validator=validators.instance_of(int)) max_heading_level: int = field(default=6, validator=validators.instance_of(int)) commands: list[str] | None = field(default=None, validator=validators.optional(validators.instance_of(list))) exclude_commands: list[str] | None = field( default=None, validator=validators.optional(validators.instance_of(list)) ) recursive: bool = field(default=True, validator=validators.instance_of(bool)) include_hidden: bool = field(default=False, validator=validators.instance_of(bool)) flatten_commands: bool = field(default=False, validator=validators.instance_of(bool)) generate_toc: bool = field(default=True, validator=validators.instance_of(bool)) code_block_title: bool = field(default=False, validator=validators.instance_of(bool)) skip_preamble: bool = field(default=False, validator=validators.instance_of(bool)) usage_name: str | None = field(default=None, validator=validators.optional(validators.instance_of(str))) @classmethod def from_directive_block( cls, directive_text: str, *, default_heading_level: int | None = None, default_max_heading_level: int | None = None, ) -> "DirectiveOptions": """Parse options from a ::: cyclopts directive block. Expected format: ::: cyclopts module: myapp.cli:app heading_level: 2 max_heading_level: 6 recursive: true commands: - cmd1 - cmd2 Parameters ---------- directive_text : str The directive text to parse. default_heading_level : int | None Default heading level from plugin config. Used if :heading-level: not specified. default_max_heading_level : int | None Default max heading level from plugin config. Used if :max-heading-level: not specified. """ lines = directive_text.strip().split("\n") # Remove the ::: cyclopts line if lines and lines[0].strip().startswith("::: cyclopts"): lines = lines[1:] yaml_content = "\n".join(lines) options = yaml.safe_load(yaml_content) or {} if not isinstance(options, dict): raise TypeError("Invalid YAML in ::: cyclopts directive: expected a dictionary") if "module" not in options: raise ValueError('The "module" option is required for ::: cyclopts directive') if default_heading_level is not None: options.setdefault("heading_level", default_heading_level) if default_max_heading_level is not None: options.setdefault("max_heading_level", default_max_heading_level) # Convert keys with dashes to underscores normalized_options = {key.replace("-", "_"): value for key, value in options.items()} try: return cls(**normalized_options) except TypeError as e: raise ValueError(f"Error creating DirectiveOptions: {e}") from e # Regex to match ::: cyclopts directive blocks # The pattern matches: # - "^::: cyclopts\n" - the directive start on its own line # - "(?:[ \t]+.*\n?)*" - zero or more indented YAML lines (with optional trailing newline for EOF) DIRECTIVE_PATTERN = re.compile( r"^::: cyclopts\n(?:[ \t]+.*\n?)*", re.MULTILINE, ) def process_cyclopts_directives(markdown: str, plugin_config: Any) -> str: """Process all ::: cyclopts directives in markdown content. Parameters ---------- markdown : str The markdown content containing ::: cyclopts directives. plugin_config : CycloptsPluginConfig The plugin configuration with default values. If None, uses DirectiveOptions defaults. Returns ------- str The markdown content with directives replaced by generated documentation. """ # Find all code blocks to exclude from processing code_blocks = [] # Find fenced code blocks (triple backticks or tildes) fenced_pattern = re.compile(r"^[`~]{3,}.*?^[`~]{3,}", re.MULTILINE | re.DOTALL) for match in fenced_pattern.finditer(markdown): code_blocks.append((match.start(), match.end())) # Find indented code blocks (lines starting with 4 spaces or tab) # Indented code blocks are preceded by a blank line and consist of lines starting with 4 spaces/tab lines = markdown.split("\n") in_indented_block = False block_start = 0 current_pos = 0 for i, line in enumerate(lines): line_len = len(line) + 1 # +1 for the newline # Check if this line starts an indented code block if not in_indented_block: # Previous line must be blank (or be the first line) prev_blank = i == 0 or not lines[i - 1].strip() # Current line must start with 4 spaces or a tab and have content is_indented = (line.startswith(" ") or line.startswith("\t")) and line.strip() if prev_blank and is_indented: in_indented_block = True block_start = current_pos else: # Check if we're still in the indented block is_indented = (line.startswith(" ") or line.startswith("\t")) and line.strip() is_blank = not line.strip() # End block if we hit a non-indented, non-blank line if not is_indented and not is_blank: code_blocks.append((block_start, current_pos)) in_indented_block = False current_pos += line_len # If we ended while still in an indented block, add it if in_indented_block: code_blocks.append((block_start, current_pos)) def is_in_code_block(pos: int) -> bool: """Check if a position is inside a code block.""" for start, end in code_blocks: if start <= pos < end: return True return False def replace_directive(match: re.Match) -> str: # Skip if this match is inside a code block if is_in_code_block(match.start()): return match.group(0) directive_text = match.group(0) try: default_heading = plugin_config.default_heading_level if plugin_config else None default_max_heading = plugin_config.default_max_heading_level if plugin_config else None options = DirectiveOptions.from_directive_block( directive_text, default_heading_level=default_heading, default_max_heading_level=default_max_heading, ) app = import_app(options.module) markdown_docs = generate_markdown_docs( app, recursive=options.recursive, include_hidden=options.include_hidden, heading_level=options.heading_level, max_heading_level=options.max_heading_level, generate_toc=options.generate_toc, flatten_commands=options.flatten_commands, commands_filter=options.commands, exclude_commands=options.exclude_commands, no_root_title=True, # Skip root title in plugin context code_block_title=options.code_block_title, skip_preamble=options.skip_preamble, usage_name=options.usage_name, ) return markdown_docs except Exception as e: raise PluginError(f"Error processing ::: cyclopts directive: {e}") from e # Replace all directives in the markdown processed = DIRECTIVE_PATTERN.sub(replace_directive, markdown) return processed class CycloptsPluginConfig(base.Config): # type: ignore[misc] """Configuration schema for the Cyclopts MkDocs plugin.""" default_heading_level = c.Type(int, default=2) # type: ignore[attr-defined] default_max_heading_level = c.Type(int, default=6) # type: ignore[attr-defined] class CycloptsPlugin(BasePlugin[CycloptsPluginConfig]): # type: ignore[misc] """MkDocs plugin to generate Cyclopts CLI documentation. Usage in mkdocs.yml: plugins: - cyclopts: default_heading_level: 2 Usage in Markdown files: ::: cyclopts :module: myapp.cli:app :heading-level: 2 :recursive: true :commands: init, build :exclude-commands: debug """ def on_page_markdown(self, markdown: str, *, page: "Page", config: "MkDocsConfig", files: "Files", **kwargs) -> str: """Process ::: cyclopts directives in markdown content. This event is called after the page's markdown is loaded from file but before it's converted to HTML. """ if "::: cyclopts" not in markdown: return markdown return process_cyclopts_directives(markdown, self.config) BrianPugh-cyclopts-921b1fa/cyclopts/ext/sphinx.py000066400000000000000000000323501517576204000221730ustar00rootroot00000000000000"""Sphinx extension for automatic Cyclopts CLI documentation.""" from typing import TYPE_CHECKING, Any import attrs from cyclopts import __version__ from cyclopts.utils import import_app if TYPE_CHECKING: from sphinx.application import Sphinx from docutils import nodes from sphinx.application import Sphinx from sphinx.util import logging from sphinx.util.docutils import SphinxDirective logger = logging.getLogger(__name__) @attrs.define(kw_only=True) class DirectiveOptions: """Configuration for the Cyclopts directive.""" heading_level: int = 2 max_heading_level: int = 6 commands: list[str] | None = None exclude_commands: list[str] | None = None usage_name: str | None = None # All booleans must have ``False`` default. no_recursive: bool = False include_hidden: bool = False flatten_commands: bool = False code_block_title: bool = False skip_preamble: bool = False @classmethod def from_dict(cls, options: dict) -> "DirectiveOptions": """Create options from directive options dictionary.""" kwargs = {} for field in attrs.fields(cls): # Convert underscore to dash for looking up in options option_name = field.name.replace("_", "-") if field.type is bool: # For boolean fields using directives.flag, presence means True # The value is None when present, absent from dict when not specified if option_name in options: kwargs[field.name] = True # Use default value if not specified elif option_name in options: value = options[option_name] # Handle comma-separated lists for commands and exclude-commands if field.name in ("commands", "exclude_commands"): # Parse comma-separated list and strip whitespace if value: kwargs[field.name] = [cmd.strip() for cmd in value.split(",") if cmd.strip()] else: # Empty string means empty list kwargs[field.name] = [] else: kwargs[field.name] = value # If not specified, the dataclass default will be used return cls(**kwargs) @staticmethod def spec() -> dict[str, Any]: """Generate Sphinx option_spec from DirectiveOptions fields.""" from docutils.parsers.rst import directives type_mapping = { bool: directives.flag, int: directives.nonnegative_int, str: directives.unchanged, } option_spec = {} for field in attrs.fields(DirectiveOptions): option_name = field.name.replace("_", "-") # Handle List[str] fields (commands, exclude-commands) if field.name in ("commands", "exclude_commands"): validator = directives.unchanged # Will be parsed as comma-separated in from_dict else: validator = type_mapping.get(field.type, directives.unchanged) option_spec[option_name] = validator return option_spec def _should_include_command( command_name: str, command_path: list[str], commands_filter: list[str] | None, exclude_commands: list[str] | None, ) -> bool: """Check if a command should be included in documentation. Parameters ---------- command_name : str The name of the command. command_path : list[str] The full path to the command (including parent commands). commands_filter : list[str] | None If specified, only include commands in this list. exclude_commands : list[str] | None If specified, exclude commands in this list. Returns ------- bool True if the command should be included. """ # Build the full command path for nested commands full_path = ".".join(command_path + [command_name]) # Check exclusion list first if exclude_commands: # Check both the command name and full path if command_name in exclude_commands or full_path in exclude_commands: return False # Check if any parent path is excluded for i in range(len(command_path)): parent_path = ".".join(command_path[: i + 1]) if parent_path in exclude_commands: return False # Check inclusion list if commands_filter is not None: # If a filter is specified, only include if explicitly listed # Check if command name or full path is in the filter if command_name in commands_filter or full_path in commands_filter: return True # Check if any parent path is included (to include all subcommands) for i in range(len(command_path)): parent_path = ".".join(command_path[: i + 1]) if parent_path in commands_filter: return True # Also check if just the base command name matches for top-level commands if not command_path and command_name in commands_filter: return True return False # No filter specified, include by default return True def _filter_commands( commands: dict, commands_filter: list[str] | None, exclude_commands: list[str] | None, parent_path: list[str] | None = None, ) -> dict: """Filter commands based on inclusion/exclusion lists. Parameters ---------- commands : dict Dictionary mapping command names to App instances. commands_filter : Optional[List[str]] If specified, only include commands in this list. exclude_commands : Optional[List[str]] If specified, exclude commands in this list. parent_path : List[str] Path to the parent command for nested commands. Returns ------- dict Filtered commands dictionary. """ if parent_path is None: parent_path = [] filtered = {} for name, app in commands.items(): if _should_include_command(name, parent_path, commands_filter, exclude_commands): filtered[name] = app return filtered def _process_rst_content(content: str, skip_title: bool = False) -> list[str]: """Process RST content to remove problematic elements.""" lines = content.splitlines() processed = [] i = 0 while i < len(lines): line = lines[i] # Skip title and underline if requested if skip_title and i == 0 and line.strip() and i + 1 < len(lines): next_line = lines[i + 1].strip() if next_line and set(next_line) <= {"-", "=", "^", "~", '"'}: i += 2 continue # Skip .. contents:: directive if line.strip().startswith(".. contents::"): i += 1 while i < len(lines) and lines[i].strip() and lines[i][0] in " \t": i += 1 if i < len(lines) and not lines[i].strip(): i += 1 continue processed.append(line) i += 1 return processed def _create_section_nodes(lines: list[str], state: Any) -> list["nodes.Node"]: """Create section nodes from RST lines.""" from docutils.statemachine import StringList result = [] i = 0 while i < len(lines): line = lines[i] # Check for section header if i + 1 < len(lines): next_line = lines[i + 1].strip() if next_line and all(c == "-" for c in next_line): # Create section section = nodes.section() title_text = line.strip() section["ids"] = [title_text.lower().replace(" ", "-").replace("cyclopts-", "cli-cyclopts-")] section += nodes.title(text=title_text) # Collect section content content_lines = [] i += 2 # Skip title and underline while i < len(lines): next_line_stripped = lines[i + 1].strip() if i + 1 < len(lines) else "" if next_line_stripped and all(c == "-" for c in next_line_stripped): break content_lines.append(lines[i]) i += 1 if content_lines: state.nested_parse(StringList(content_lines), 0, section) result.append(section) continue # Check for literal block (::) if line.strip() == "::": # Skip the :: line i += 1 # Skip blank line after :: if i < len(lines) and not lines[i].strip(): i += 1 # Collect indented content for the literal block literal_content = [] while i < len(lines) and lines[i].startswith(" "): # Remove the 4-space indentation literal_content.append(lines[i][4:]) i += 1 # Create a literal block node directly if literal_content: literal_block = nodes.literal_block() literal_block.rawsource = "\n".join(literal_content) literal_block.append(nodes.Text("\n".join(literal_content))) result.append(literal_block) # Skip any trailing blank line if i < len(lines) and not lines[i].strip(): i += 1 continue # Regular content - accumulate consecutive lines if line.strip(): content_lines = [line] i += 1 # Collect consecutive non-empty lines that aren't section headers or literal blocks while i < len(lines): # Check if this is a section header next_line = lines[i + 1].strip() if i + 1 < len(lines) else "" if next_line and all(c == "-" for c in next_line): break # Check if this is a literal block if lines[i].strip() == "::": break # Check if this is a blank line if not lines[i].strip(): # Include the blank line and continue to see if there's more content content_lines.append(lines[i]) i += 1 # If the next line is also blank or we're at the end, stop if i >= len(lines) or not lines[i].strip(): break else: # Add non-empty line content_lines.append(lines[i]) i += 1 # Parse all accumulated lines together para = nodes.paragraph() state.nested_parse(StringList(content_lines), 0, para) if para.children: result.extend(para.children) else: i += 1 return result class CycloptsDirective(SphinxDirective): # type: ignore[misc,valid-type] """Sphinx directive for documenting Cyclopts CLI applications.""" has_content = False required_arguments = 1 optional_arguments = 0 final_argument_whitespace = False option_spec = DirectiveOptions.spec() def run(self) -> list["nodes.Node"]: """Generate documentation nodes for the Cyclopts app.""" module_path = self.arguments[0] opts = DirectiveOptions.from_dict(self.options) try: rst_content = self._generate_documentation(module_path, opts) return self._create_nodes(rst_content, opts) except Exception as e: return self._error_node(f"Error generating Cyclopts documentation: {e}") def _generate_documentation(self, module_path: str, opts: DirectiveOptions) -> str: """Generate RST documentation for the app.""" from cyclopts.docs.rst import generate_rst_docs app = import_app(module_path) # Call generate_rst_docs directly to access internal no_root_title parameter return generate_rst_docs( app, recursive=not opts.no_recursive, include_hidden=opts.include_hidden, heading_level=opts.heading_level, max_heading_level=opts.max_heading_level, flatten_commands=opts.flatten_commands, commands_filter=opts.commands, exclude_commands=opts.exclude_commands, no_root_title=True, # Always skip root title in Sphinx context code_block_title=opts.code_block_title, skip_preamble=opts.skip_preamble, usage_name=opts.usage_name, ) def _create_nodes(self, rst_content: str, opts: DirectiveOptions) -> list["nodes.Node"]: """Create docutils nodes from RST content.""" lines = _process_rst_content(rst_content, skip_title=False) # Title already skipped in generate_docs # Always use section nodes for better Sphinx integration return _create_section_nodes(lines, self.state) def _error_node(self, message: str) -> list["nodes.Node"]: """Create an error node with the given message.""" logger.error(message) return [nodes.error("", nodes.paragraph(text=message))] def setup(app: "Sphinx") -> dict[str, Any]: """Setup function for the Sphinx extension.""" app.add_directive("cyclopts", CycloptsDirective) return { "version": __version__, "parallel_read_safe": True, "parallel_write_safe": True, } BrianPugh-cyclopts-921b1fa/cyclopts/field_info.py000066400000000000000000000312621517576204000221610ustar00rootroot00000000000000import inspect import sys from typing import ( # noqa: F401 Annotated, Any, ClassVar, Optional, get_args, get_origin, get_type_hints, ) import attrs from attrs import field if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self from cyclopts.annotations import ( NotRequired, Required, is_annotated, is_attrs, is_dataclass, is_enum_flag, is_namedtuple, is_pydantic, is_pydantic_secret, is_typeddict, resolve, resolve_annotated, resolve_optional, ) from cyclopts.utils import UNSET, is_builtin POSITIONAL_OR_KEYWORD = inspect.Parameter.POSITIONAL_OR_KEYWORD POSITIONAL_ONLY = inspect.Parameter.POSITIONAL_ONLY KEYWORD_ONLY = inspect.Parameter.KEYWORD_ONLY VAR_POSITIONAL = inspect.Parameter.VAR_POSITIONAL VAR_KEYWORD = inspect.Parameter.VAR_KEYWORD def _replace_annotated_type(src_type, dst_type): if not is_annotated(src_type): return dst_type return Annotated[(dst_type,) + get_args(src_type)[1:]] # pyright: ignore @attrs.define class FieldInfo: """Extension of :class:`inspect.Parameter`.""" names: tuple[str, ...] = () kind: inspect._ParameterKind = inspect.Parameter.POSITIONAL_OR_KEYWORD required: bool = field(kw_only=True, default=False) default: Any = field(default=inspect.Parameter.empty, kw_only=True) annotation: Any = field(default=inspect.Parameter.empty, kw_only=True) help: str | None = field(default=None, kw_only=True) """Can be populated by additional metadata from another library; e.g. ``pydantic.FieldInfo.description``.""" ################### # Class Variables # ################### empty: ClassVar = inspect.Parameter.empty POSITIONAL_OR_KEYWORD: ClassVar = inspect.Parameter.POSITIONAL_OR_KEYWORD POSITIONAL_ONLY: ClassVar = inspect.Parameter.POSITIONAL_ONLY KEYWORD_ONLY: ClassVar = inspect.Parameter.KEYWORD_ONLY VAR_POSITIONAL: ClassVar = inspect.Parameter.VAR_POSITIONAL VAR_KEYWORD: ClassVar = inspect.Parameter.VAR_KEYWORD POSITIONAL: ClassVar[frozenset[inspect._ParameterKind]] = frozenset( {POSITIONAL_OR_KEYWORD, POSITIONAL_ONLY, VAR_POSITIONAL} ) KEYWORD: ClassVar[frozenset[inspect._ParameterKind]] = frozenset({POSITIONAL_OR_KEYWORD, KEYWORD_ONLY, VAR_KEYWORD}) @classmethod def from_iparam(cls, iparam: inspect.Parameter, *, annotation: Any = UNSET, required: bool | None = None) -> Self: if required is None: required = ( iparam.default is iparam.empty and iparam.kind != iparam.VAR_KEYWORD and iparam.kind != iparam.VAR_POSITIONAL ) return cls( names=(iparam.name,), annotation=iparam.annotation if annotation is UNSET else annotation, kind=iparam.kind, default=iparam.default, required=required, ) @property def hint(self): """Annotation with Optional-removed and cyclopts type-inferring.""" hint = self.annotation if hint is inspect.Parameter.empty or resolve(hint) is Any: hint = _replace_annotated_type( hint, str if self.default is inspect.Parameter.empty or self.default is None else type(self.default) ) hint = resolve_optional(hint) return hint @property def name(self): """The **first** provided name.""" return self.names[0] @property def is_positional(self) -> bool: return self.kind in self.POSITIONAL @property def is_positional_only(self) -> bool: return self.kind in (POSITIONAL_ONLY, VAR_POSITIONAL) @property def is_keyword(self) -> bool: return self.kind in self.KEYWORD @property def is_keyword_only(self) -> bool: return self.kind in (KEYWORD_ONLY, VAR_KEYWORD) def evolve(self, **kwargs): return attrs.evolve(self, **kwargs) def _typed_dict_field_infos(typeddict) -> dict[str, FieldInfo]: # The ``__required_keys__`` and ``__optional_keys__`` attributes of TypedDict are kind of broken in dict[str, FieldInfo]: out = {} for name, field_info in signature_parameters(f.__init__).items(): if field_info.name == "self": continue if not include_var_positional and field_info.kind is field_info.VAR_POSITIONAL: continue if not include_var_keyword and field_info.kind is field_info.VAR_KEYWORD: continue out[name] = field_info return out def _pydantic_field_infos(model) -> dict[str, FieldInfo]: from pydantic_core import PydanticUndefined out = {} for python_name, pydantic_field in model.model_fields.items(): names = [] if pydantic_field.alias: if model.model_config.get("populate_by_name", False): names.append(python_name) names.append(pydantic_field.alias) # Add legacy-compatible CLI form if not already present. # This allows both "user-name" (new) and "username" (legacy) to work as CLI options. # Old transform behavior: alias.lower() (no pascal_to_snake) # New transform behavior: _pascal_to_snake(alias).lower() legacy_form = pydantic_field.alias.lower() if legacy_form not in names: names.append(legacy_form) else: names.append(python_name) # Extract Field with description from metadata help = pydantic_field.description or None for meta in pydantic_field.metadata: if hasattr(meta, "description") and meta.description: help = meta.description # Pydantic places ``Annotated`` data into pydantic.FieldInfo.metadata, while # pydantic.FieldInfo.annotation contains the "real" resolved type-hint. # We have to re-combine them into a single Annotated hint. # For discriminated unions, pydantic stores discriminator separately (not in metadata), # so include pydantic_field itself to preserve the discriminator attribute. if pydantic_field.discriminator: annotation = Annotated[(pydantic_field.annotation, pydantic_field) + tuple(pydantic_field.metadata)] # pyright: ignore elif pydantic_field.metadata: annotation = Annotated[(pydantic_field.annotation,) + tuple(pydantic_field.metadata)] # pyright: ignore else: annotation = pydantic_field.annotation out[python_name] = FieldInfo( names=tuple(names), kind=inspect.Parameter.KEYWORD_ONLY if pydantic_field.kw_only else inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=annotation, default=FieldInfo.empty if pydantic_field.default is PydanticUndefined else pydantic_field.default, required=pydantic_field.is_required(), help=help, ) return out def _namedtuple_field_infos(hint) -> dict[str, FieldInfo]: out = {} type_hints = get_type_hints(hint) for name in hint._fields: out[name] = FieldInfo( names=(name,), kind=FieldInfo.POSITIONAL_OR_KEYWORD, annotation=type_hints.get(name, str), default=hint._field_defaults.get(name, FieldInfo.empty), required=name not in hint._field_defaults, ) return out def _attrs_field_infos(hint) -> dict[str, FieldInfo]: out = {} field_infos = signature_parameters(hint.__init__) for attribute in hint.__attrs_attrs__: if not attribute.init: continue field_info = field_infos[attribute.alias] if isinstance(attribute.default, attrs.Factory): # pyright: ignore required = False default = None # Not strictly True, but we don't want to invoke factory elif attribute.default is attrs.NOTHING: required = True default = FieldInfo.empty else: required = False default = attribute.default help = attribute.metadata.get("help") if attribute.metadata else None out[field_info.name] = field_info.evolve( names=(attribute.alias,), required=required, default=default, help=help ) return out def _dataclass_field_infos(hint) -> dict[str, FieldInfo]: import dataclasses out = {} fields = dataclasses.fields(hint) type_hints = get_type_hints(hint, include_extras=True) # resolves stringified type hints for f in fields: if f.default_factory is not dataclasses.MISSING: default = f.default_factory() required = False elif f.default is not dataclasses.MISSING: default = f.default required = False else: default = FieldInfo.empty required = True annotation = type_hints.get(f.name, FieldInfo.empty) kind = FieldInfo.KEYWORD_ONLY if f.kw_only else FieldInfo.POSITIONAL_OR_KEYWORD # Extract help text with precedence order: # 1. metadata["help"] - explicit help in metadata # 2. metadata["doc"] - doc stored in metadata # 3. f.doc - Python 3.14+ field(doc=...) parameter help = None if f.metadata: help = f.metadata.get("help") or f.metadata.get("doc") if not help and hasattr(f, "doc"): help = f.doc # type: ignore[attr-defined] out[f.name] = FieldInfo( names=(f.name,), kind=kind, required=required, annotation=annotation, default=default, help=help, ) return out def _enum_flag_field_infos(enum_flag) -> dict[str, FieldInfo]: """Extract field infos from a Flag enum, treating each member as a boolean field.""" out = {} for member_name in enum_flag.__members__: out[member_name] = FieldInfo( names=(member_name,), kind=FieldInfo.KEYWORD_ONLY, # The Enum member should NEVER have a type-annotation. # Thusly, it by definition cannot have an Annotated[...]. # see: https://typing.python.org/en/latest/spec/enums.html#defining-members annotation=bool, # Each flag acts as a boolean default=False, # Default to False (not included in combination) required=False, # All flags are optional ) return out def get_field_infos(hint) -> dict[str, FieldInfo]: # Early return for builtin types (int, str, etc.) to avoid expensive introspection. # Provides ~5-6x speedup for argument parsing by skipping signature_parameters() calls. if is_builtin(hint): return {} # Pydantic secret types (SecretStr, SecretBytes) should be treated as simple types if is_pydantic_secret(hint): return {} # NewType is a runtime identity function that returns its argument unchanged. # Use the field_infos of the underlying supertype instead of NewType's misleading __init__. if hasattr(hint, "__supertype__"): return get_field_infos(hint.__supertype__) if is_dataclass(hint): # This must be before ``is_pydantic`` check so that we # can handle pydantic dataclasses as vanilla dataclasses. return _dataclass_field_infos(hint) elif is_pydantic(hint): return _pydantic_field_infos(hint) elif is_namedtuple(hint): return _namedtuple_field_infos(hint) elif is_typeddict(hint): return _typed_dict_field_infos(hint) elif is_attrs(hint): return _attrs_field_infos(hint) elif is_enum_flag(hint): return _enum_flag_field_infos(hint) else: return _generic_class_field_infos(hint) def signature_parameters(f: Any) -> dict[str, FieldInfo]: if "functools" in sys.modules: from functools import partial func = f.func if isinstance(f, partial) else f else: func = f type_hints = get_type_hints(func, include_extras=True) out = {} for name, iparam in inspect.signature(f).parameters.items(): annotation = type_hints.get(name, iparam.annotation) out[name] = FieldInfo.from_iparam(iparam, annotation=annotation) return out BrianPugh-cyclopts-921b1fa/cyclopts/group.py000066400000000000000000000155151517576204000212220ustar00rootroot00000000000000import inspect import itertools import sys from collections.abc import Callable, Iterable from typing import ( TYPE_CHECKING, Any, Literal, Optional, Union, ) from attrs import field if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self from cyclopts.utils import ( UNSET, Sentinel, SortHelper, frozen, help_formatter_converter, is_iterable, resolve_callables, sort_key_converter, to_tuple_converter, ) if TYPE_CHECKING: from cyclopts.argument import ArgumentCollection from cyclopts.help.protocols import HelpFormatter from cyclopts.parameter import Parameter def _group_default_parameter_must_be_none(instance, attribute, value: Optional["Parameter"]): if value is None: return if value.group: raise ValueError("Group default_parameter cannot have a group.") # Used for Group.sorted _sort_key_counter = itertools.count() # Special sort markers that get specially handled by :meth:`SortHelper.sort` class DEFAULT_COMMANDS_GROUP_SORT_MARKER(Sentinel): # noqa: N801 pass class DEFAULT_ARGUMENTS_GROUP_SORT_MARKER(Sentinel): # noqa: N801 pass class DEFAULT_PARAMETERS_GROUP_SORT_MARKER(Sentinel): # noqa: N801 pass def _group_validator_converter( value: "None | Callable[[ArgumentCollection], Any] | Iterable[Callable[[ArgumentCollection], Any]]", ) -> "tuple[Callable[[ArgumentCollection], Any], ...]": return to_tuple_converter(value) # type: ignore[return-value] def _group_name_converter(val: str): return val if val else object() @frozen class Group: _name: str = field(default="", alias="name", converter=_group_name_converter) # pyright: ignore reportAssignmentType """ Name of the group. For anonymous groups (groups with no name that shouldn't appear on the help-page), we create a unique sentinel object. We cannot just use a static default value like :obj:`None` because python will resolve multiple independent, identically configured anonymous groups to the same underlying object. """ help: str = "" # All below parameters are keyword-only _show: bool | None = field(default=None, alias="show", kw_only=True) _sort_key: Any = field( default=None, alias="sort_key", converter=sort_key_converter, kw_only=True, ) # This can ONLY ever be a Tuple[Callable, ...] validator: None | Callable[["ArgumentCollection"], Any] | Iterable[Callable[["ArgumentCollection"], Any]] = field( default=None, converter=_group_validator_converter, kw_only=True, ) default_parameter: Optional["Parameter"] = field( default=None, validator=_group_default_parameter_must_be_none, kw_only=True, ) help_formatter: Union[None, Literal["default", "plain"], "HelpFormatter"] = field( default=None, converter=help_formatter_converter, kw_only=True ) @property def name(self) -> str: return "" if type(self._name) is object else self._name @property def show(self): return bool(self.name) if self._show is None else self._show @property def sort_key(self): return None if self._sort_key is UNSET else self._sort_key @classmethod def create_default_arguments(cls, name="Arguments") -> Self: return cls(name, sort_key=DEFAULT_ARGUMENTS_GROUP_SORT_MARKER) @classmethod def create_default_parameters(cls, name="Parameters") -> Self: return cls(name, sort_key=DEFAULT_PARAMETERS_GROUP_SORT_MARKER) @classmethod def create_default_commands(cls, name="Commands") -> Self: return cls(name, sort_key=DEFAULT_COMMANDS_GROUP_SORT_MARKER) @classmethod def create_ordered( cls, name="", help="", *, show=None, sort_key=None, validator=None, default_parameter=None, help_formatter=None, ) -> Self: """Create a group with a globally incrementing :attr:`~Group.sort_key`. Used to create a group that will be displayed **after** a previously instantiated :meth:`Group.create_ordered` group on the help-page. Parameters ---------- name: str Group name used for the help-page and for group-referenced-by-string. This is a title, so the first character should be capitalized. If a name is not specified, it will not be shown on the help-page. help: str Additional documentation shown on the help-page. This will be displayed inside the group's panel, above the parameters/commands. show: bool | None Show this group on the help-page. Defaults to :obj:`None`, which will only show the group if a ``name`` is provided. sort_key: Any If provided, **prepended** to the globally incremented counter value (i.e. has priority during sorting). validator: None | Callable[[ArgumentCollection], Any] | Iterable[Callable[[ArgumentCollection], Any]] Group validator to collectively apply. default_parameter: cyclopts.Parameter | None Default parameter for elements within the group. help_formatter: cyclopts.help.protocols.HelpFormatter | None Custom help formatter for this group's help display. """ count = next(_sort_key_counter) if inspect.isgenerator(sort_key): sort_key = next(sort_key) if sort_key is None: sort_key = (UNSET, count) elif is_iterable(sort_key): sort_key = (tuple(sort_key), count) else: sort_key = (sort_key, count) return cls( name, help, show=show, sort_key=sort_key, validator=validator, default_parameter=default_parameter, help_formatter=help_formatter, ) def sort_groups(groups: list[Group], attributes: list[Any]) -> tuple[list[Group], list[Any]]: """Sort groups for the help-page. Note, much logic is similar to here and ``HelpPanel.sort``, so any changes here should probably be reflected over there as well. Parameters ---------- groups: list[Group] List of groups to sort by their ``sort_key``. attributes: list[Any] A list of equal length to ``groups``. Remains consistent with ``groups`` via argsort. """ assert len(groups) == len(attributes) if not groups: return groups, attributes sorted_entries = SortHelper.sort( [ SortHelper(resolve_callables(group._sort_key, group), group.name, (group, attribute)) for group, attribute in zip(groups, attributes, strict=False) ] ) out_groups, out_attributes = zip(*[x.value for x in sorted_entries], strict=False) return list(out_groups), list(out_attributes) BrianPugh-cyclopts-921b1fa/cyclopts/group_extractors.py000066400000000000000000000147361517576204000235040ustar00rootroot00000000000000from typing import TYPE_CHECKING, Any from cyclopts.command_spec import CommandSpec from cyclopts.group import Group from cyclopts.utils import frozen if TYPE_CHECKING: from cyclopts.core import App @frozen class RegisteredCommand: """A command with the names it was registered under. Parameters ---------- names : tuple[str, ...] All names (including aliases) this command is registered under. app : App | CommandSpec The command's App or unresolved CommandSpec instance. """ names: tuple[str, ...] app: "App | CommandSpec" def _create_or_append( group_mapping: list[tuple[Group, list[Any]]], group: str | Group, element: Any, ): # updates group_mapping inplace. if isinstance(group, str): group = Group(group) elif isinstance(group, Group): pass else: raise TypeError for mapping in group_mapping: if mapping[0].name == group.name: mapping[1].append(element) break else: group_mapping.append((group, [element])) def groups_from_app(app: "App", resolve_lazy: bool = False) -> list[tuple[Group, list[RegisteredCommand]]]: """Extract Group/App association from all commands of ``app``. Parameters ---------- app : App The application to extract groups from. resolve_lazy : bool If ``True``, resolve lazy commands (import their modules) to include them in the output. If ``False`` (default), skip unresolved lazy commands. Set to ``True`` when generating static artifacts that need all commands, such as shell completion scripts. Returns ------- list List of items where each item is a tuple containing: * :class:`.Group` - The group * ``list[RegisteredCommand]`` - List of RegisteredCommand tuples containing the registered names and app instance for each command. """ assert not isinstance(app.group_commands, str) group_commands = app.group_commands or Group.create_default_commands() # First pass: collect all registered names and unique apps # Use __iter__ and __getitem__ to properly handle meta parents # # Skip unresolved lazy commands to avoid importing modules unnecessarily. # Group assignment for unresolved commands uses the group= field from # CommandSpec (set at registration time). Groups defined only inside # lazy modules won't be available until those modules are imported. app_names: dict[int, list[str]] = {} unique_apps: dict[int, App] = {} lazy_names: dict[int, list[str]] = {} unique_lazy: dict[int, CommandSpec] = {} for name in app: cmd = app._get_item(name, recurse_meta=True) if isinstance(cmd, CommandSpec) and not cmd.is_resolved: if not resolve_lazy: # Skip hidden lazy commands early to avoid keeping # an otherwise-empty group alive. if not cmd.show: continue cmd_id = id(cmd) lazy_names.setdefault(cmd_id, []).append(name) if cmd_id not in unique_lazy: unique_lazy[cmd_id] = cmd continue subapp = app[name] app_id = id(subapp) app_names.setdefault(app_id, []).append(name) if app_id not in unique_apps: unique_apps[app_id] = subapp group_mapping: list[tuple[Group, list[RegisteredCommand]]] = [ (group_commands, []), ] # Extract Group objects from resolved apps for subapp in unique_apps.values(): assert isinstance(subapp.group, tuple) for group in subapp.group: if isinstance(group, Group): for mapping in group_mapping: if mapping[0] is group: break elif mapping[0].name == group.name: raise ValueError(f'Command Group "{group.name}" already exists.') else: group_mapping.append((group, [])) # Assign resolved apps to groups with their registered names for app_id, subapp in unique_apps.items(): names = tuple(app_names[app_id]) registered_command = RegisteredCommand(names, subapp) if subapp.group: assert isinstance(subapp.group, tuple) for group in subapp.group: _create_or_append(group_mapping, group, registered_command) else: _create_or_append(group_mapping, group_commands, registered_command) # Extract Group objects from unresolved lazy commands for cmd in unique_lazy.values(): if cmd.group is not None: groups = cmd.group if isinstance(cmd.group, tuple) else (cmd.group,) for group in groups: if isinstance(group, Group): for mapping in group_mapping: if mapping[0] is group: break elif mapping[0].name == group.name: raise ValueError(f'Command Group "{group.name}" already exists.') else: group_mapping.append((group, [])) # Assign unresolved lazy commands to their group (or default) for cmd_id, cmd in unique_lazy.items(): names = tuple(lazy_names[cmd_id]) registered_command = RegisteredCommand(names, cmd) if cmd.group is not None: groups = cmd.group if isinstance(cmd.group, tuple) else (cmd.group,) for group in groups: _create_or_append(group_mapping, group, registered_command) else: _create_or_append(group_mapping, group_commands, registered_command) # Remove empty groups group_mapping = [x for x in group_mapping if x[1]] # Sort alphabetically by name group_mapping.sort(key=lambda x: x[0].name) return group_mapping def inverse_groups_from_app(input_app: "App", resolve_lazy: bool = False) -> list[tuple["App", list[Group]]]: out = [] seen_apps = [] for group, registered_commands in groups_from_app(input_app, resolve_lazy=resolve_lazy): for registered_command in registered_commands: app = registered_command.app if isinstance(app, CommandSpec): continue try: index = seen_apps.index(app) except ValueError: index = len(out) out.append((app, [])) seen_apps.append(app) out[index][1].append(group) return out BrianPugh-cyclopts-921b1fa/cyclopts/help/000077500000000000000000000000001517576204000204355ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/help/__init__.py000066400000000000000000000014451517576204000225520ustar00rootroot00000000000000__all__ = [ "Renderer", "HelpEntry", "TableSpec", "PanelSpec", "ColumnSpec", "HelpPanel", "create_parameter_help_panel", "format_command_entries", "format_doc", "format_usage", "InlineText", "DefaultFormatter", "MarkdownFormatter", "PlainFormatter", "NameRenderer", "DescriptionRenderer", "AsteriskRenderer", ] from .formatters import DefaultFormatter, MarkdownFormatter, PlainFormatter from .help import ( HelpEntry, HelpPanel, create_parameter_help_panel, format_command_entries, format_doc, format_usage, ) from .inline_text import InlineText from .protocols import Renderer from .specs import ( AsteriskRenderer, ColumnSpec, DescriptionRenderer, NameRenderer, PanelSpec, TableSpec, ) BrianPugh-cyclopts-921b1fa/cyclopts/help/formatters/000077500000000000000000000000001517576204000226235ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/help/formatters/__init__.py000066400000000000000000000004401517576204000247320ustar00rootroot00000000000000"""Help formatters for Cyclopts.""" from .default import DefaultFormatter from .html import HtmlFormatter from .markdown import MarkdownFormatter from .plain import PlainFormatter __all__ = [ "DefaultFormatter", "HtmlFormatter", "MarkdownFormatter", "PlainFormatter", ] BrianPugh-cyclopts-921b1fa/cyclopts/help/formatters/default.py000066400000000000000000000213531517576204000246250ustar00rootroot00000000000000"""Default Rich-based help formatter.""" from typing import TYPE_CHECKING, Any, Optional, Union from attrs import define from cyclopts.help.silent import SILENT if TYPE_CHECKING: from rich.console import Console, ConsoleOptions, RenderableType from cyclopts.help import HelpPanel from cyclopts.help.protocols import ColumnSpecBuilder from cyclopts.help.specs import ColumnSpec, PanelSpec, TableSpec @define(kw_only=True) class DefaultFormatter: """Default help formatter using Rich library with customizable specs. Parameters ---------- panel_spec : Optional[PanelSpec] Panel specification for the outer box/panel styling. table_spec : Optional[TableSpec] Table specification for table styling (borders, padding, etc). column_specs : Optional[Union[tuple[ColumnSpec, ...], ColumnSpecBuilder]] Column specifications or builder function for table columns. Notes ----- The relationship between these specs can be visualized as: :: ╭─ Commands ───────────────────────────────────────────────────────╮ ← panel_spec │ serve Start the development server │ (border, title) │ --help Display this message and exit. │ ╰──────────────────────────────────────────────────────────────────╯ ↑ ↑ col[0] col[1] (name) (description) ╭─ Parameters ─────────────────────────────────────────────────────╮ ← panel_spec │ * PORT --port Server port number [required] │ │ VERBOSE --verbose Enable verbose output [default: False] │ ╰──────────────────────────────────────────────────────────────────╯ ↑ ↑ ↑ │ col[1] col[2] │ (name/flags) (description) │ col[0] (required marker) Where: - ``panel_spec`` controls the outer panel appearance (border, title, etc.) - ``table_spec`` controls the inner table styling (no visible borders by default) - ``column_specs`` defines individual columns (width, style, alignment, etc.) """ panel_spec: Optional["PanelSpec"] = None """Panel specification for the outer box/panel styling (border, title, padding, etc).""" table_spec: Optional["TableSpec"] = None """Table specification for table styling (borders, padding, column separation, etc).""" column_specs: Union[tuple["ColumnSpec", ...], "ColumnSpecBuilder"] | None = None """Column specifications or builder function for table columns (width, style, alignment, etc).""" @classmethod def with_newline_metadata(cls, **kwargs): """Create formatter with metadata on separate lines. Returns a DefaultFormatter configured to display parameter metadata (choices, env vars, defaults) on separate indented lines rather than inline with descriptions. Parameters ---------- **kwargs Additional keyword arguments to pass to DefaultFormatter constructor. Returns ------- DefaultFormatter Configured formatter instance with newline metadata display. Examples -------- >>> from cyclopts import App >>> from cyclopts.help import DefaultFormatter >>> app = App(help_formatter=DefaultFormatter.with_newline_metadata()) """ def column_builder(console, options, entries): import math from cyclopts.help.specs import ( AsteriskColumn, ColumnSpec, DescriptionRenderer, NameRenderer, ) max_width = math.ceil(console.width * 0.35) name_column = ColumnSpec( renderer=NameRenderer(max_width=max_width), header="Option", style="cyan", max_width=max_width, ) description_column = ColumnSpec( renderer=DescriptionRenderer(newline_metadata=True), header="Description", overflow="fold" ) if any(x.required for x in entries): return (AsteriskColumn, name_column, description_column) return (name_column, description_column) return cls(column_specs=column_builder, **kwargs) def __call__(self, console: "Console", options: "ConsoleOptions", panel: "HelpPanel") -> None: """Format and render a single help panel using Rich. Parameters ---------- console : ~rich.console.Console Console to render to. options : ~rich.console.ConsoleOptions Console rendering options. panel : HelpPanel Help panel to render. """ rendered = self._render_panel(panel, console, options) console.print(rendered) def render_usage(self, console: "Console", options: "ConsoleOptions", usage: Any) -> None: """Render the usage line. Parameters ---------- console : ~rich.console.Console Console to render to. options : ~rich.console.ConsoleOptions Console rendering options. usage : Any The usage line (Text or str). """ if usage: from rich.text import Text # Add "Usage:" prefix if not already present (for custom usage strings) usage_str = str(usage) if not usage_str.strip().startswith("Usage:"): if isinstance(usage, Text): usage_with_label = Text("Usage: ", style="bold") + usage else: usage_with_label = Text(f"Usage: {usage}", style="bold") else: # Custom usage already has "Usage:", use as-is usage_with_label = usage if isinstance(usage, Text) else Text(str(usage), style="bold") console.print(usage_with_label) def render_description(self, console: "Console", options: "ConsoleOptions", description: Any) -> None: """Render the description. Parameters ---------- console : ~rich.console.Console Console to render to. options : ~rich.console.ConsoleOptions Console rendering options. description : Any The description (can be various Rich renderables). """ if description: console.print(description) def _render_panel(self, help_panel: "HelpPanel", console: "Console", options: "ConsoleOptions") -> "RenderableType": """Render a single help panel.""" if not help_panel.entries: return SILENT from rich.console import Group as RichGroup from rich.console import NewLine from rich.text import Text from cyclopts.help.specs import ( PanelSpec, TableSpec, get_default_command_columns, get_default_parameter_columns, ) panel_description = help_panel.description if isinstance(panel_description, Text): panel_description.end = "" if panel_description.plain: panel_description = RichGroup(panel_description, NewLine(2)) # Get table spec (styling only) table_spec = self.table_spec or TableSpec() panel_spec = self.panel_spec or PanelSpec() # Determine/Resolve column specs columns = self.column_specs if columns is None: # Use default columns based on panel format if help_panel.format == "command": columns = get_default_command_columns else: columns = get_default_parameter_columns if callable(columns): # It's a column builder columns = columns(console, options, help_panel.entries) # Build table with columns and entries table = table_spec.build(columns, help_panel.entries) # Build the panel assert panel_description is not None # Always true due to attrs converter if panel_spec.title is None: panel = panel_spec.build(RichGroup(panel_description, table), title=help_panel.title) else: panel = panel_spec.build(RichGroup(panel_description, table)) return panel BrianPugh-cyclopts-921b1fa/cyclopts/help/formatters/html.py000066400000000000000000000222161517576204000241440ustar00rootroot00000000000000"""HTML documentation formatter.""" import io from typing import TYPE_CHECKING, Any, Optional from cyclopts._markup import escape_html, extract_text if TYPE_CHECKING: from rich.console import Console, ConsoleOptions from cyclopts.help import HelpEntry, HelpPanel class HtmlFormatter: """HTML documentation formatter. Parameters ---------- heading_level : int Starting heading level for panels (default: 2). E.g., 2 produces "

    Commands

    ", 3 produces "

    Commands

    ". include_hidden : bool Include hidden commands/parameters in documentation (default: False). app_name : str The root application name for generating anchor IDs. command_chain : list[str] The current command chain for generating anchor IDs. """ def __init__( self, heading_level: int = 2, include_hidden: bool = False, app_name: str | None = None, command_chain: list[str] | None = None, ): self.heading_level = heading_level self.include_hidden = include_hidden self.app_name = app_name self.command_chain = command_chain or [] self._output = io.StringIO() def reset(self) -> None: """Reset the internal output buffer.""" self._output = io.StringIO() def get_output(self) -> str: """Get the accumulated HTML output. Returns ------- str The HTML documentation string. """ return self._output.getvalue() def __call__( self, console: Optional["Console"], options: Optional["ConsoleOptions"], panel: "HelpPanel", ) -> None: """Format and render a help panel as HTML. Parameters ---------- console : Optional[Console] Console for rendering (used for extracting plain text). options : Optional[ConsoleOptions] Console rendering options (unused for HTML). panel : HelpPanel Help panel to render. """ if not panel.entries: return # Write panel as a section self._output.write('
    \n') # Write panel title as heading if panel.title: title_text = escape_html(extract_text(panel.title, console)) self._output.write(f'{title_text}\n') # Write panel description if present if panel.description: desc_text = escape_html(extract_text(panel.description, console)) if desc_text: self._output.write(f'
    {desc_text}
    \n') # Format entries based on panel type if panel.format == "command": self._format_command_panel(panel.entries, console) elif panel.format == "parameter": self._format_parameter_panel(panel.entries, console) self._output.write("
    \n") def _format_command_panel(self, entries: list["HelpEntry"], console: Optional["Console"]) -> None: """Format command entries as HTML. Parameters ---------- entries : list[HelpEntry] Command entries to format. console : Optional[Console] Console for text extraction. """ if not entries: return # Use list format instead of table self._output.write('
      \n') for entry in entries: names = entry.all_options if not names: name_html = "" elif self.app_name: # Generate anchor link primary_name, aliases = names[0], names[1:] if self.command_chain: full_chain = self.command_chain + [primary_name] anchor_id = f"{self.app_name}-{'-'.join(full_chain[1:])}".lower() else: anchor_id = f"{self.app_name}-{primary_name}".lower() name_html = f'{escape_html(primary_name)}' if aliases: aliases_str = ", ".join(escape_html(n) for n in aliases) name_html = f"{name_html} ({aliases_str})" else: # Non-linked format with aliases in parentheses primary_name, aliases = names[0], names[1:] name_html = f"{escape_html(primary_name)}" if aliases: aliases_str = ", ".join(escape_html(n) for n in aliases) name_html = f"{name_html} ({aliases_str})" desc_html = escape_html(extract_text(entry.description, console)) self._output.write(f"
    • {name_html}") if desc_html: self._output.write(f": {desc_html}") self._output.write("
    • \n") self._output.write("
    \n") def _format_parameter_panel(self, entries: list["HelpEntry"], console: Optional["Console"]) -> None: """Format parameter entries as HTML. Parameters ---------- entries : list[HelpEntry] Parameter entries to format. console : Optional[Console] Console for text extraction. """ if not entries: return # Use list format instead of table self._output.write('
      \n') for entry in entries: # Format name with code tags if names := entry.all_options: name_html = ", ".join(f"{escape_html(n)}" for n in names) else: name_html = "" # Start list item (no type display) self._output.write(f"
    • {name_html}") # Add description desc = extract_text(entry.description, console) if desc: self._output.write(f": {escape_html(desc)}") # Add metadata as styled badges metadata_items = [] # Add required marker if entry.required: metadata_items.append('') # Add choices if entry.choices: choices_str = ", ".join(f"{escape_html(str(c))}" for c in entry.choices) metadata_items.append( f'' ) # Add default if entry.default is not None: default_str = extract_text(entry.default, console) metadata_items.append( f'' ) # Add environment variable if entry.env_var: env_html = ", ".join(f"{escape_html(e)}" for e in entry.env_var) metadata_items.append( f'' ) # Write metadata if metadata_items: self._output.write(f'') self._output.write("
    • \n") self._output.write("
    \n") def render_usage( self, console: Optional["Console"], options: Optional["ConsoleOptions"], usage: Any, ) -> None: """Render the usage line as HTML. Parameters ---------- console : Optional[Console] Console for text extraction. options : Optional[ConsoleOptions] Console rendering options (unused). usage : Any The usage line content. """ if usage: usage_text = escape_html(extract_text(usage, console)) if usage_text: self._output.write('
    \n') # Add "Usage:" prefix if not already present (for custom usage strings) if not usage_text.strip().startswith("Usage:"): self._output.write(f'
    Usage: {usage_text}
    \n') else: self._output.write(f'
    {usage_text}
    \n') self._output.write("
    \n") def render_description( self, console: Optional["Console"], options: Optional["ConsoleOptions"], description: Any, ) -> None: """Render the description as HTML. Parameters ---------- console : Optional[Console] Console for text extraction. options : Optional[ConsoleOptions] Console rendering options (unused). description : Any The description content. """ if description: desc_text = escape_html(extract_text(description, console)) if desc_text: self._output.write(f'
    {desc_text}
    \n') BrianPugh-cyclopts-921b1fa/cyclopts/help/formatters/markdown.py000066400000000000000000000250171517576204000250240ustar00rootroot00000000000000"""Markdown documentation formatter.""" import io from typing import TYPE_CHECKING, Any, Optional from cyclopts._markup import extract_text if TYPE_CHECKING: from rich.console import Console, ConsoleOptions from cyclopts.help import HelpEntry, HelpPanel class MarkdownFormatter: """Markdown documentation formatter. Parameters ---------- heading_level : int Starting heading level for panels (default: 2). E.g., 2 produces "## Commands", 3 produces "### Commands". table_style : str Style for parameter/command tables: "table" or "list" (default: "table"). include_hidden : bool Include hidden commands/parameters in documentation (default: False). """ def __init__( self, heading_level: int = 2, table_style: str = "table", include_hidden: bool = False, ): self.heading_level = heading_level self.table_style = table_style self.include_hidden = include_hidden self._output = io.StringIO() def reset(self) -> None: """Reset the internal output buffer.""" self._output = io.StringIO() def get_output(self) -> str: """Get the accumulated markdown output. Returns ------- str The markdown documentation string. """ return self._output.getvalue() def __call__( self, console: Optional["Console"], options: Optional["ConsoleOptions"], panel: "HelpPanel", ) -> None: """Format and render a help panel as markdown. Parameters ---------- console : Optional[Console] Console for rendering (used for extracting plain text). options : Optional[ConsoleOptions] Console rendering options (unused for markdown). panel : HelpPanel Help panel to render. """ if not panel.entries: return # Write panel title as heading if panel.title: title_text = extract_text(panel.title, console) heading = "#" * self.heading_level self._output.write(f"{heading} {title_text}\n\n") # Write panel description if present if panel.description: desc_text = extract_text(panel.description, console) if desc_text: self._output.write(f"{desc_text}\n\n") # Format entries based on panel type if panel.format == "command": self._format_command_panel(panel.entries, console) elif panel.format == "parameter": self._format_parameter_panel(panel.entries, console) self._output.write("\n") def _format_command_panel(self, entries: list["HelpEntry"], console: Optional["Console"]) -> None: """Format command entries as markdown. Parameters ---------- entries : list[HelpEntry] Command entries to format. console : Optional[Console] Console for text extraction. """ # Always use list style for Typer-like output for entry in entries: if names := entry.all_options: # Use first name as primary, show aliases in parentheses primary_name, aliases = names[0], names[1:] if aliases: name_display = f"{primary_name} ({', '.join(aliases)})" else: name_display = primary_name desc = extract_text(entry.description, console, preserve_markup=True) if desc: self._output.write(f"* `{name_display}`: {desc}") else: self._output.write(f"* `{name_display}`:") self._output.write("\n") def _format_parameter_panel(self, entries: list["HelpEntry"], console: Optional["Console"]) -> None: """Format parameter entries as markdown in Typer style. Parameters ---------- entries : list[HelpEntry] Parameter entries to format. console : Optional[Console] Console for text extraction. """ # Always use list style for Typer-like output for entry in entries: if names := entry.all_options: # Separate positional names from option names positional_names = [n for n in names if not n.startswith("-")] short_opts = [n for n in names if n.startswith("-") and not n.startswith("--")] long_opts = [n for n in names if n.startswith("--")] # Determine if this is a positional argument (required, no default) is_positional = entry.required and entry.default is None if is_positional and positional_names: # Show uppercase positional name first, then any option names parts = [positional_names[0].upper()] parts.extend(long_opts) name_str = ", ".join(parts) else: # For options, show long opts first, then short opts if short_opts: name_str = ", ".join(long_opts + short_opts) elif positional_names: # Has positional name but not required - show all parts = [positional_names[0].upper()] parts.extend(long_opts) name_str = ", ".join(parts) else: name_str = ", ".join(long_opts) # Start the entry (no type display) self._output.write(f"* `{name_str}`: ") # Add description with proper indentation for nested content desc = extract_text(entry.description, console, preserve_markup=True) if desc: import re # Split into lines and indent continuation lines to nest under the bullet lines = desc.split("\n") self._output.write(lines[0]) # First line on same line as bullet # Track what type of list context we're in for proper nesting in_numbered_list = False for line in lines[1:]: if not line.strip(): # Blank line self._output.write("\n") else: stripped = line.lstrip() existing_indent = len(line) - len(stripped) # Check if this line starts a numbered list if re.match(r"^\d+\.", stripped): in_numbered_list = True # Numbered lists need 4 spaces base indentation minimum indent = max(existing_indent + 4, 4) # Check if this is a bullet under a numbered list elif re.match(r"^\-", stripped) and in_numbered_list: # Bullets nested under numbered items need extra indentation # At least 10 spaces (4 base + 3 for "N. " + 3 more for nesting) indent = max(existing_indent + 4, 10) elif re.match(r"^\-", stripped): # Top-level bullets just need base indentation indent = max(existing_indent + 4, 4) else: # Regular content preserves relative indentation indent = existing_indent + 4 self._output.write("\n" + " " * indent + stripped) # Add metadata in brackets # Handle required separately for bold formatting is_required = False if entry.required and not is_positional: # Only show required for options, arguments show it differently is_required = True elif is_positional and entry.required: # For positional args, add [required] at the end is_required = True metadata = [] if entry.choices: choices_str = ", ".join(entry.choices) metadata.append(f"choices: {choices_str}") if entry.env_var: env_str = ", ".join(entry.env_var) metadata.append(f"env: {env_str}") if entry.default is not None: default_str = extract_text(entry.default, console) metadata.append(f"default: {default_str}") # Write required in bold and separate brackets first if is_required: self._output.write(" **[required]**") # Write each metadata item in its own brackets with italics for item in metadata: self._output.write(f" *[{item}]*") self._output.write("\n") def render_usage( self, console: Optional["Console"], options: Optional["ConsoleOptions"], usage: Any, ) -> None: """Render the usage line as markdown. Parameters ---------- console : Optional[Console] Console for text extraction. options : Optional[ConsoleOptions] Console rendering options (unused). usage : Any The usage line content. """ if usage: usage_text = extract_text(usage, console) if usage_text: # Add "Usage:" prefix if not already present (for custom usage strings) if not usage_text.strip().startswith("Usage:"): self._output.write(f"```\nUsage: {usage_text}\n```\n\n") else: self._output.write(f"```\n{usage_text}\n```\n\n") def render_description( self, console: Optional["Console"], options: Optional["ConsoleOptions"], description: Any, ) -> None: """Render the description as markdown. Parameters ---------- console : Optional[Console] Console for text extraction. options : Optional[ConsoleOptions] Console rendering options (unused). description : Any The description content. """ if description: desc_text = extract_text(description, console) if desc_text: self._output.write(f"{desc_text}\n\n") BrianPugh-cyclopts-921b1fa/cyclopts/help/formatters/plain.py000066400000000000000000000203121517576204000242760ustar00rootroot00000000000000"""Plain text help formatter for improved accessibility.""" import io import textwrap from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from rich.console import Console, ConsoleOptions from cyclopts.help import HelpEntry, HelpPanel def _to_plain_text(obj: Any, console: "Console") -> str: """Extract plain text from Rich renderables. Parameters ---------- obj : Any Object to convert to plain text. console : ~rich.console.Console Console for rendering Rich objects. Returns ------- str Plain text representation. """ if obj is None: return "" # Rich Text objects have a .plain property for plain text if hasattr(obj, "plain"): return obj.plain.rstrip() # For any Rich renderable, render without styles if hasattr(obj, "__rich_console__"): # Create a plain console that preserves layout but removes styling from rich.console import Console plain_console = Console( file=io.StringIO(), width=console.width, height=console.height, tab_size=console.tab_size, legacy_windows=console.legacy_windows, safe_box=console.safe_box, # Disable all styling force_terminal=False, no_color=True, highlight=False, markup=False, emoji=False, ) with plain_console.capture() as capture: plain_console.print(obj, end="") return capture.get().rstrip() # Fallback for non-Rich objects return str(obj).rstrip() class PlainFormatter: """Plain text formatter for improved accessibility. Parameters ---------- indent_width : int Number of spaces to indent entries (default: 2). max_width : Optional[int] Maximum line width for wrapping text. """ def __init__( self, indent_width: int = 2, max_width: int | None = None, ): self.indent_width = indent_width self.max_width = max_width self.indent = " " * indent_width def _print_plain(self, console: "Console", text: str) -> None: """Print text without any highlighting or markup.""" console.print(text, highlight=False, markup=False) def __call__( self, console: "Console", options: "ConsoleOptions", panel: "HelpPanel", ) -> None: """Format and render a single help panel as plain text. Parameters ---------- console : ~rich.console.Console Console to render to. options : ~rich.console.ConsoleOptions Console rendering options. panel : HelpPanel Help panel to render. """ if not panel.entries: return # Print panel title with appropriate formatting if panel.title: self._print_plain(console, f"{panel.title}:") # Print each entry in the panel for entry in panel.entries: desc = _to_plain_text(entry.description, console) # Format the entry line if entry.all_options: if panel.format == "parameter": self._format_parameter_entry(entry.all_options, desc, console, entry) else: # Command formatter needs separate longs/shorts for its specific layout self._format_command_entry(entry.positive_names, entry.positive_shorts, desc, console) # Add trailing newline for visual separation between panels console.print() def render_usage( self, console: "Console", options: "ConsoleOptions", usage: Any, ) -> None: """Render the usage line. Parameters ---------- console : ~rich.console.Console Console to render to. options : ~rich.console.ConsoleOptions Console rendering options. usage : Any The usage line (Text or str). """ if usage: usage_text = _to_plain_text(usage, console) if usage_text: # Add "Usage:" prefix if not already present (for custom usage strings) if not usage_text.strip().startswith("Usage:"): self._print_plain(console, f"Usage: {usage_text}") else: self._print_plain(console, usage_text) console.print() def render_description( self, console: "Console", options: "ConsoleOptions", description: Any, ) -> None: """Render the description. Parameters ---------- console : ~rich.console.Console Console to render to. options : ~rich.console.ConsoleOptions Console rendering options. description : Any The description (can be various Rich renderables). """ if description: desc_text = _to_plain_text(description, console) if desc_text: self._print_plain(console, desc_text) console.print() def _format_parameter_entry( self, options: tuple[str, ...], desc: str, console: "Console", entry: "HelpEntry", ) -> None: """Format and print a parameter entry. Parameters ---------- options : tuple[str, ...] All parameter options in display order. desc : str Parameter description. console : ~rich.console.Console Console to print to. entry : HelpEntry The full help entry with metadata fields. """ if not options: return # Build the description with metadata desc_parts = [] if desc: desc_parts.append(desc) # Add metadata fields from entry if entry.choices: choices_str = ", ".join(entry.choices) desc_parts.append(f"[choices: {choices_str}]") if entry.env_var: env_vars_str = ", ".join(entry.env_var) desc_parts.append(f"[env var: {env_vars_str}]") if entry.default is not None: desc_parts.append(f"[default: {entry.default}]") if entry.required: desc_parts.append("[required]") full_desc = " ".join(desc_parts) # Format: "option1, option2, ...: description" options_str = ", ".join(options) if full_desc: text = f"{options_str}: {full_desc}" else: text = options_str self._print_plain(console, textwrap.indent(text, self.indent)) def _format_command_entry( self, names: tuple[str, ...], shorts: tuple[str, ...], desc: str, console: "Console", ) -> None: """Format and print a command entry. Parameters ---------- names : tuple[str, ...] Command long names. shorts : tuple[str, ...] Short forms of the command. desc : str Command description. console : ~rich.console.Console Console to print to. """ # For commands, we typically want to show long names on separate lines # and shorts together if names: for i, name in enumerate(names): if i == 0: # First name gets the shorts and description parts = [name] if shorts: parts.append(", " + " ".join(shorts)) entry_name = "".join(parts) if desc: text = f"{entry_name}: {desc}" else: text = entry_name self._print_plain(console, textwrap.indent(text, self.indent)) else: # Additional names on separate lines self._print_plain(console, textwrap.indent(name, self.indent)) elif shorts: # Only short names shorts_str = " ".join(shorts) if desc: text = f"{shorts_str}: {desc}" else: text = shorts_str self._print_plain(console, textwrap.indent(text, self.indent)) BrianPugh-cyclopts-921b1fa/cyclopts/help/formatters/rst.py000066400000000000000000000221271517576204000240110ustar00rootroot00000000000000"""reStructuredText documentation formatter.""" import io from typing import TYPE_CHECKING, Any, Optional from cyclopts._markup import extract_text from cyclopts.docs.rst import make_rst_section_header if TYPE_CHECKING: from rich.console import Console, ConsoleOptions from cyclopts.help import HelpEntry, HelpPanel class RstFormatter: """reStructuredText documentation formatter. Parameters ---------- heading_level : int Starting heading level for panels (default: 2). include_hidden : bool Include hidden commands/parameters in documentation (default: False). """ def __init__( self, heading_level: int = 2, include_hidden: bool = False, ): self.heading_level = heading_level self.include_hidden = include_hidden self._output = io.StringIO() def reset(self) -> None: """Reset the internal output buffer.""" self._output = io.StringIO() def get_output(self) -> str: """Get the accumulated RST output. Returns ------- str The RST documentation string. """ return self._output.getvalue() def __call__( self, console: Optional["Console"], options: Optional["ConsoleOptions"], panel: "HelpPanel", ) -> None: """Format and render a help panel as RST. Parameters ---------- console : Optional[Console] Console for rendering (used for extracting plain text). options : Optional[ConsoleOptions] Console rendering options (unused for RST). panel : HelpPanel Help panel to render. """ if not panel.entries: return # Write panel title as heading if panel.title: title_text = extract_text(panel.title, console) header = "\n".join(make_rst_section_header(title_text, self.heading_level)) self._output.write(f"{header}\n\n") # Write panel description if present if panel.description: desc_text = extract_text(panel.description, console) if desc_text: self._output.write(f"{desc_text}\n\n") # Format entries based on panel type if panel.format == "command": self._format_command_panel(panel.entries, console) elif panel.format == "parameter": self._format_parameter_panel(panel.entries, console) self._output.write("\n") def _format_command_panel(self, entries: list["HelpEntry"], console: Optional["Console"]) -> None: """Format command entries as RST. Parameters ---------- entries : list[HelpEntry] Command entries to format. console : Optional[Console] Console for text extraction. """ for entry in entries: if names := entry.all_options: # Use first name as primary, show aliases in parentheses primary_name, aliases = names[0], names[1:] if aliases: name_display = f"{primary_name} ({', '.join(aliases)})" else: name_display = primary_name # Use definition list format self._output.write(f"``{name_display}``\n") # Check if the description has RST markup to preserve preserve_rst_markup = ( hasattr(entry.description, "primary_renderable") and hasattr(entry.description.primary_renderable, "__class__") and "RestructuredText" in entry.description.primary_renderable.__class__.__name__ ) desc = extract_text(entry.description, console, preserve_markup=preserve_rst_markup) if desc: # Join multi-line descriptions into a single paragraph for proper RST formatting # This prevents each line from being interpreted as a separate blockquote desc_text = " ".join(line.strip() for line in desc.split("\n") if line.strip()) self._output.write(f" {desc_text}\n\n") def _format_parameter_panel(self, entries: list["HelpEntry"], console: Optional["Console"]) -> None: """Format parameter entries as RST. Parameters ---------- entries : list[HelpEntry] Parameter entries to format. console : Optional[Console] Console for text extraction. """ for entry in entries: if names := entry.all_options: # Determine if we should display as positional based on requirement and default is_positional = entry.required and entry.default is None and not any(n.startswith("-") for n in names) if is_positional: # For positional arguments, show in uppercase positional_names = [n for n in names if not n.startswith("-")] name_str = positional_names[0].upper() if positional_names else names[0].upper() else: # For options, format with all forms name_str = ", ".join(names) # Use definition list format self._output.write(f"``{name_str}``\n") # Build description with metadata desc_parts = [] # Add main description # Check if the description has RST markup to preserve preserve_rst_markup = ( hasattr(entry.description, "primary_renderable") and hasattr(entry.description.primary_renderable, "__class__") and "RestructuredText" in entry.description.primary_renderable.__class__.__name__ ) desc = extract_text(entry.description, console, preserve_markup=preserve_rst_markup) if desc: desc_parts.append(desc) # Add metadata metadata = [] if is_positional and entry.required: metadata.append("**Required**") elif entry.required and not is_positional: metadata.append("**Required**") if entry.choices: choices_str = ", ".join(f"``{c}``" for c in entry.choices) metadata.append(f"Choices: {choices_str}") if entry.default is not None: default_str = extract_text(entry.default, console, preserve_markup=False) metadata.append(f"Default: ``{default_str}``") if entry.env_var: env_str = ", ".join(f"``{e}``" for e in entry.env_var) metadata.append(f"Environment variable: {env_str}") # Combine description and metadata - handle multi-line descriptions if desc_parts: # Join multi-line descriptions into a single paragraph for proper RST formatting # This prevents each line from being interpreted as a separate blockquote desc_text = " ".join(line.strip() for line in desc_parts[0].split("\n") if line.strip()) self._output.write(f" {desc_text}") if metadata: self._output.write(f" [{', '.join(metadata)}]") self._output.write("\n\n") elif metadata: self._output.write(f" {', '.join(metadata)}\n\n") def render_usage( self, console: Optional["Console"], options: Optional["ConsoleOptions"], usage: Any, ) -> None: """Render the usage line as RST. Parameters ---------- console : Optional[Console] Console for text extraction. options : Optional[ConsoleOptions] Console rendering options (unused). usage : Any The usage line content. """ if usage: usage_text = extract_text(usage, console) if usage_text: # Use literal block for usage self._output.write("::\n\n") # Add "Usage:" prefix if not already present (for custom usage strings) if not usage_text.strip().startswith("Usage:"): self._output.write(f" Usage: {usage_text}\n") else: self._output.write(f" {usage_text}\n") self._output.write("\n") def render_description( self, console: Optional["Console"], options: Optional["ConsoleOptions"], description: Any, ) -> None: """Render the description as RST. Parameters ---------- console : Optional[Console] Console for text extraction. options : Optional[ConsoleOptions] Console rendering options (unused). description : Any The description content. """ if description: desc_text = extract_text(description, console) if desc_text: self._output.write(f"{desc_text}\n\n") BrianPugh-cyclopts-921b1fa/cyclopts/help/help.py000066400000000000000000000507461517576204000217530ustar00rootroot00000000000000import inspect import sys from collections.abc import Iterable, Sequence from enum import Enum from functools import lru_cache from pathlib import Path from typing import ( TYPE_CHECKING, Any, ForwardRef, Literal, ) from attrs import define, evolve, field from cyclopts.annotations import resolve_annotated from cyclopts.core import _get_root_module_name, _iter_resolution_argument_collections from cyclopts.field_info import get_field_infos from cyclopts.group import Group from cyclopts.help.inline_text import InlineText from cyclopts.help.silent import SILENT, SilentRich from cyclopts.utils import SortHelper, frozen, is_class_and_subclass, resolve_callables if TYPE_CHECKING: from rich.console import RenderableType from cyclopts.argument import Argument, ArgumentCollection from cyclopts.core import App @lru_cache(maxsize=16) def docstring_parse(doc: str | None, format: str): """Addon to :func:`docstring_parser.parse` that supports multi-line `short_description`.""" import docstring_parser if not doc: return docstring_parser.parse("") cleaned_doc = inspect.cleandoc(doc) short_description_and_maybe_remainder = cleaned_doc.split("\n\n", 1) # Place multi-line summary into a single line. # This kind of goes against PEP-0257, but any reasonable CLI command will # have either no description, or it will have both a short and long description. short = short_description_and_maybe_remainder[0].replace("\n", " ") if len(short_description_and_maybe_remainder) == 1: cleaned_doc = short else: cleaned_doc = short + "\n\n" + short_description_and_maybe_remainder[1] res = docstring_parser.parse(cleaned_doc) # Ensure a short description exists if there's a long description assert not res.long_description or res.short_description return res def _text_factory(): from rich.text import Text return Text() def _description_converter(value: Any | None) -> Any: if value is None: return _text_factory() return value @frozen(kw_only=True) class HelpEntry: """Container for help table entry data.""" positive_names: tuple[str, ...] = () """Positive long option names (e.g., "--verbose", "--dry-run").""" positive_shorts: tuple[str, ...] = () """Positive short option names (e.g., "-v", "-n").""" negative_names: tuple[str, ...] = () """Negative long option names (e.g., "--no-verbose", "--no-dry-run").""" negative_shorts: tuple[str, ...] = () """Negative short option names (e.g., "-N"). Rarely used.""" @property def names(self) -> tuple[str, ...]: """All long option names (positive + negative). For backward compatibility.""" return self.positive_names + self.negative_names @property def shorts(self) -> tuple[str, ...]: """All short option names (positive + negative). For backward compatibility.""" return self.positive_shorts + self.negative_shorts @property def all_options(self) -> tuple[str, ...]: """All options in display order: positive longs, positive shorts, negative longs, negative shorts.""" return self.positive_names + self.positive_shorts + self.negative_names + self.negative_shorts description: Any = None """Help text description for this entry. Typically a :class:`str` or a :obj:`~rich.console.RenderableType` """ required: bool = False """Whether this parameter/command is required.""" sort_key: Any = None """Custom sorting key for ordering entries.""" type: Any | None = None """Type annotation of the parameter.""" choices: tuple[str, ...] | None = None """Available choices for this parameter.""" env_var: tuple[str, ...] | None = None """Environment variable names that can set this parameter.""" default: str | None = None """Default value for this parameter to display. None means no default to show.""" def copy(self, **kwargs): return evolve(self, **kwargs) @define class HelpPanel: """Data container for help panel information.""" format: Literal["command", "parameter"] """Panel format type.""" title: "RenderableType" """The title text displayed at the top of the help panel.""" description: Any = field( default=None, converter=_description_converter, ) """Optional description text displayed below the title. Typically a :class:`str` or a :obj:`~rich.console.RenderableType` """ entries: list[HelpEntry] = field(factory=list) """List of help entries to display (in order) in the panel.""" def copy(self, **kwargs): return evolve(self, **kwargs) def _remove_duplicates(self): seen, out = set(), [] for item in self.entries: hashable = (item.names, item.shorts) if hashable not in seen: seen.add(hashable) out.append(item) self.entries = out def _sort(self): """Sort entries in-place.""" if not self.entries: return if self.format == "command": sorted_sort_helper = SortHelper.sort( [ SortHelper( entry.sort_key, ( entry.names[0].startswith("-") if entry.names else False, entry.names[0] if entry.names else "", ), entry, ) for entry in self.entries ] ) self.entries = [x.value for x in sorted_sort_helper] else: raise NotImplementedError def _is_short(s): return not s.startswith("--") and s.startswith("-") def _categorize_keyword_arguments(argument_collection: "ArgumentCollection") -> tuple[list, list]: """Categorize keyword arguments by requirement status for usage string formatting. Parameters ---------- argument_collection : ArgumentCollection Collection of arguments to categorize. Returns ------- tuple[list, list] (required_keyword, optional_keyword) where: - required_keyword: Required keyword-only parameters - optional_keyword: Optional keyword-only parameters and VAR_KEYWORD """ required, optional = [], [] for argument in argument_collection: if not argument.show: continue if argument.field_info.kind in (argument.field_info.VAR_KEYWORD,): optional.append(argument) elif argument.field_info.is_keyword_only: if argument.required: required.append(argument) else: optional.append(argument) return required, optional def _categorize_positional_arguments(argument_collection: "ArgumentCollection") -> tuple[list, list]: """Categorize positional arguments by requirement status for usage string formatting. Parameters ---------- argument_collection : ArgumentCollection Collection of arguments to categorize. Returns ------- tuple[list, list] (required_positional, optional_positional) where: - required_positional: Required positional and VAR_POSITIONAL parameters - optional_positional: Optional positional and VAR_POSITIONAL parameters """ required, optional = [], [] for argument in argument_collection: if not argument.show: continue if argument.field_info.kind == argument.field_info.VAR_POSITIONAL: if argument.required: required.append(argument) else: optional.append(argument) elif argument.field_info.is_positional: if argument.required: required.append(argument) else: optional.append(argument) return required, optional def format_usage( app: "App", command_chain: Iterable[str], execution_path: Sequence["App"] | None = None, ): from rich.text import Text from cyclopts.annotations import get_hint_name usage = [] # If we're at the root level (no command chain), the app has a default_command, # and no explicit name was set, derive a better name from sys.argv[0] if not command_chain and app.default_command and not app._name: # Use the same logic as in App.name property for apps without default_command name = Path(sys.argv[0]).name if name == "__main__.py": name = _get_root_module_name() app_name = name else: app_name = app.name[0] usage.append(app_name) usage.extend(command_chain) for command in command_chain: app = app[command] # Check for visible non-help/version commands without resolving lazy CommandSpecs. help_version_flags = {*app.help_flags, *app.version_flags} if any(x not in help_version_flags and app._get_item(x, recurse_meta=True).show for x in app): usage.append("COMMAND") # Aggregate arguments across all apps that contribute parameters to this help page. # Shares the resolution logic with ``App._assemble_help_panels`` so the usage line and # the parameter panels always agree on which apps contribute. required_keyword_params: list = [] optional_keyword_params: list = [] required_positional_args: list = [] optional_positional_args: list = [] for _, argument_collection in _iter_resolution_argument_collections( execution_path, fallback_app=app, parse_docstring=False ): rkw, okw = _categorize_keyword_arguments(argument_collection) rpos, opos = _categorize_positional_arguments(argument_collection) required_keyword_params.extend(rkw) optional_keyword_params.extend(okw) required_positional_args.extend(rpos) optional_positional_args.extend(opos) for argument in required_keyword_params: param_name = argument.name type_name = get_hint_name(argument.hint).upper() usage.append(f"{param_name} {type_name}") if optional_keyword_params: usage.append("[OPTIONS]") for argument in required_positional_args: if argument.field_info.kind == argument.field_info.VAR_POSITIONAL: arg_name = argument.name.lstrip("-").upper() usage.append(f"{arg_name}...") else: arg_name = argument.name.lstrip("-").upper() usage.append(arg_name) if optional_positional_args: has_var_positional = any( arg.field_info.kind == arg.field_info.VAR_POSITIONAL for arg in optional_positional_args ) if has_var_positional: usage.append("[ARGS...]") else: usage.append("[ARGS]") return Text(" ".join(usage) + "\n", style="bold") def _smart_join(strings: Sequence[str]) -> str: """Joins strings with a space, unless the previous string ended in a newline.""" if not strings: return "" result = [strings[0]] for s in strings[1:]: if result[-1].endswith("\n"): result.append(s) else: result.append(" " + s) return "".join(result) def format_doc(app: "App", format: str) -> InlineText | SilentRich: raw_doc_string = app.help if not raw_doc_string: return SILENT parsed = docstring_parse(raw_doc_string, format) components: list[str] = [] if parsed.short_description: components.append(parsed.short_description + "\n") if parsed.long_description: if parsed.short_description: components.append("\n") components.append(parsed.long_description + "\n") return InlineText.from_format(_smart_join(components), format=format, force_empty_end=True) def _is_dynamic_structured_dict(argument: "Argument") -> bool: """True if ``argument`` is ``dict[str, StructuredType]`` eligible for help expansion. Covers pydantic, dataclass, attrs, TypedDict, NamedTuple via the shared ``get_field_infos`` dispatcher. Uses the same indicators as the parser's dict branch in ``Argument.__attrs_post_init__``: ``_accepts_keywords`` is set, ``_lookup`` is empty (no pre-built children — keys are dynamic), and ``_default`` is the value type with structured fields. Also matches when ``_default`` is a string/``ForwardRef`` — an unresolved self-reference from something like ``dict[str, "Node"]``. We can't walk into it, but we treat it as assumed-structured so the expansion still renders a ``.{NAME}`` layer before terminating. """ default = argument._default if not (argument._accepts_keywords and not argument._lookup and default is not None): return False if isinstance(default, (str, ForwardRef)): return True try: return bool(get_field_infos(default)) except Exception: return False def _expand_structured_dict_for_help( argument: "Argument", format: str, *, seen: frozenset[int] = frozenset(), ) -> Iterable[HelpEntry]: """Yield help entries for every leaf field of a ``dict[str, StructuredType]``. Reuses :meth:`ArgumentCollection._from_type_preview` so synthesized entries carry the full metadata (choices, defaults, env_var, required propagation, ``Parameter.help`` precedence, ``name_transform``) that the normal per-argument path produces. """ # NOTE: help output uses cyclopts' name_transform (e.g. ``my_field`` → # ``--models.{NAME}.my-field``). The parser currently only accepts the raw # snake_case form for dict-nested paths; harmonizing the two is a separate # follow-up (touches ``_argument.py`` token routing). from cyclopts.argument import ArgumentCollection from cyclopts.field_info import FieldInfo from cyclopts.parameter import Parameter value_type = argument._default negatives = set(argument.negatives) outer_long_names = tuple(o for o in argument.names if o not in negatives and not _is_short(o)) is_unresolvable = isinstance(value_type, (str, ForwardRef)) is_cycle = id(value_type) in seen if is_cycle or is_unresolvable or not outer_long_names: # Cycle or unresolved forward-ref — stop expanding, but still indicate # the next level is another ``{NAME}`` layer by appending ``.{{NAME}}`` # to the names. base = _make_help_entry(argument, format) if outer_long_names: suffixed_names = tuple(f"{n}.{{NAME}}" for n in base.positive_names) yield evolve(base, positive_names=suffixed_names) else: yield base return new_seen = seen | {id(value_type)} synthetic = FieldInfo( names=("_preview",), kind=FieldInfo.KEYWORD_ONLY, annotation=value_type, default=FieldInfo.empty, required=argument.required, ) for outer in outer_long_names: preview = ArgumentCollection._from_type( synthetic, (), Parameter(name=(f"{outer}.{{NAME}}",)), group_lookup={}, group_arguments=Group.create_default_arguments(), group_parameters=Group.create_default_parameters(), _resolve_groups=False, ) for leaf in preview.filter_by(show=True): if _is_dynamic_structured_dict(leaf): yield from _expand_structured_dict_for_help(leaf, format, seen=new_seen) else: yield _make_help_entry(leaf, format) def _make_help_entry(argument: "Argument", format: str) -> HelpEntry: """Build a single ``HelpEntry`` for one ``Argument``. Extracted from ``create_parameter_help_panel`` so it can also be applied to synthetic preview arguments (see ``_expand_structured_dict_for_help``). """ assert argument.parameter.name_transform options = list(argument.names) seen: set[str] = set() options = [x for x in options if x not in seen and not seen.add(x)] if argument.index is not None: label_source = next((o for o in options if o.startswith("--")), options[0]) arg_name = label_source.lstrip("-").upper() if arg_name != options[0]: options = [arg_name, *options] negatives = set(argument.negatives) positive_names = [o for o in options if o not in negatives and not _is_short(o)] positive_shorts = [o for o in options if o not in negatives and _is_short(o)] negative_names = [o for o in options if o in negatives and not _is_short(o)] negative_shorts = [o for o in options if o in negatives and _is_short(o)] help_description = InlineText.from_format(argument.parameter.help, format=format) choices = argument.get_choices() env_var = None if argument.parameter.show_env_var and argument.parameter.env_var: env_var = tuple(argument.parameter.env_var) default = None if argument.show_default: default_val = argument.field_info.default if is_class_and_subclass(argument.hint, Enum): default = argument.parameter.name_transform(default_val.name) elif isinstance(default_val, (list, tuple, set, frozenset)): formatted_items = [] for item in default_val: if isinstance(item, Enum): formatted_items.append(argument.parameter.name_transform(item.name)) elif isinstance(item, str): formatted_items.append(f"'{item}'") else: formatted_items.append(str(item)) if isinstance(default_val, tuple): if len(formatted_items) == 1: default = "(" + formatted_items[0] + ",)" else: default = "(" + ", ".join(formatted_items) + ")" elif isinstance(default_val, list): default = "[" + ", ".join(formatted_items) + "]" else: default = "{" + ", ".join(formatted_items) + "}" elif default_val == "": default = '""' else: default = str(default_val) if callable(argument.show_default): default = argument.show_default(default_val) return HelpEntry( positive_names=tuple(positive_names), positive_shorts=tuple(positive_shorts), negative_names=tuple(negative_names), negative_shorts=tuple(negative_shorts), description=help_description, required=argument.required, type=resolve_annotated(argument.field_info.annotation), choices=choices, env_var=env_var, default=default, ) def create_parameter_help_panel( group: "Group", argument_collection: "ArgumentCollection", format: str, ) -> HelpPanel: from rich.text import Text kwargs = { "format": "parameter", "title": group.name, "description": InlineText.from_format(group.help, format=format, force_empty_end=True) if group.help else Text(), } help_panel = HelpPanel(**kwargs) entries_positional, entries_kw = [], [] for argument in argument_collection.filter_by(show=True): if _is_dynamic_structured_dict(argument): entries_kw.extend(_expand_structured_dict_for_help(argument, format)) continue entry = _make_help_entry(argument, format) if argument.field_info.is_positional: entries_positional.append(entry) else: entries_kw.append(entry) help_panel.entries.extend(entries_positional) help_panel.entries.extend(entries_kw) return help_panel def format_command_entries(apps_with_names: Iterable, format: str) -> list[HelpEntry]: """Format command entries for help display. Parameters ---------- apps_with_names : Iterable[RegisteredCommand] Iterable of RegisteredCommand tuples. format : str Help text format. Returns ------- list[HelpEntry] List of formatted help entries. """ entries = [] for registered_command in apps_with_names: app = registered_command.app if not app.show: continue names = registered_command.names # Commands don't have negative variants, so all names are "positive" short_names, long_names = [], [] for name in names: short_names.append(name) if _is_short(name) else long_names.append(name) sort_key = resolve_callables(app.sort_key, app) entry = HelpEntry( positive_names=tuple(long_names), positive_shorts=tuple(short_names), description=InlineText.from_format(docstring_parse(app.help, format).short_description, format=format), sort_key=sort_key, ) if entry not in entries: entries.append(entry) return entries BrianPugh-cyclopts-921b1fa/cyclopts/help/inline_text.py000066400000000000000000000110251517576204000233300ustar00rootroot00000000000000"""InlineText class for rich text rendering with appended metadata.""" import sys from typing import TYPE_CHECKING if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self if TYPE_CHECKING: from rich.console import RenderableType from rich.text import Text class InlineText: def __init__(self, primary_renderable: "RenderableType", *, force_empty_end=False): self.primary_renderable = primary_renderable self.texts = [] self.force_empty_end = force_empty_end @classmethod def from_format( cls, content: str | None, format: str, *, force_empty_end: bool = False, show_errors: bool = False, ) -> Self: if content is None: from rich.text import Text primary_renderable = Text(end="") elif format == "plaintext": from rich.text import Text primary_renderable = Text(content.rstrip()) elif format in ("markdown", "md"): from rich.markdown import Markdown primary_renderable = Markdown(content) elif format in ("restructuredtext", "rst"): from rich_rst import RestructuredText from cyclopts.help.rst_preprocessor import process_sphinx_directives processed_content = process_sphinx_directives(content) primary_renderable = RestructuredText(processed_content, show_errors=show_errors) elif format == "rich": from rich.text import Text primary_renderable = Text.from_markup(content) else: raise ValueError(f'Unknown help_format "{format}"') return cls(primary_renderable, force_empty_end=force_empty_end) def append(self, text: "Text"): self.texts.append(text) def __rich_console__(self, console, options): from rich.segment import Segment from rich.text import Text if not self.primary_renderable and not self.texts: return # Group segments by line lines_of_segments, current_line = [], [] for segment in console.render(self.primary_renderable, options): if segment.text == "\n": lines_of_segments.append(current_line + [segment]) current_line = [] else: current_line.append(segment) if current_line: lines_of_segments.append(current_line) # If no content, just yield the additional texts if not lines_of_segments: if self.texts: combined_text = Text.assemble(*self.texts) yield from console.render(combined_text, options) return # Yield all but the last line unchanged for line in lines_of_segments[:-1]: for segment in line: yield segment # For the last line, concatenate all of our additional texts; # We have to re-render to properly handle textwrapping. if lines_of_segments: last_line = lines_of_segments[-1] # Check for newline at end has_newline = last_line and last_line[-1].text == "\n" newline_segment = last_line.pop() if has_newline else None # rstrip the last segment if last_line: last_segment = last_line[-1] last_segment = Segment( last_segment.text.rstrip(), style=last_segment.style, control=last_segment.control, ) last_line[-1] = last_segment # Convert last line segments to text and combine with additional text last_line_text = Text("", end="") for segment in last_line: if segment.text: last_line_text.append(segment.text, segment.style) separator = Text(" ") for text in self.texts: if last_line_text: last_line_text += separator last_line_text += text # Re-render with proper wrapping wrapped_segments = list(console.render(last_line_text, options)) if self.force_empty_end: last_segment = wrapped_segments[-1] if last_segment and not last_segment.text.endswith("\n"): wrapped_segments.append(Segment("\n")) # Add back newline if it was present if newline_segment: wrapped_segments.append(newline_segment) yield from wrapped_segments BrianPugh-cyclopts-921b1fa/cyclopts/help/protocols.py000066400000000000000000000046111517576204000230350ustar00rootroot00000000000000from typing import TYPE_CHECKING, Protocol, runtime_checkable if TYPE_CHECKING: from rich.console import Console, ConsoleOptions, RenderableType from .help import HelpEntry, HelpPanel from .specs import ColumnSpec @runtime_checkable class Renderer(Protocol): """Protocol for column renderers that transform HelpEntry to display content.""" def __call__(self, entry: "HelpEntry") -> "RenderableType": ... @runtime_checkable class ColumnSpecBuilder(Protocol): """Protocol for ColumnSpecBuilders.""" def __call__( self, console: "Console", options: "ConsoleOptions", entries: list["HelpEntry"] ) -> tuple["ColumnSpec", ...]: """Build column specifications based on console settings and entries. Parameters ---------- console : ~rich.console.Console The Rich console instance. options : ~rich.console.ConsoleOptions Console rendering options. entries : list[HelpEntry] List of help entries to be displayed. Returns ------- tuple[ColumnSpec, ...] Tuple of column specifications for table rendering. """ ... @runtime_checkable class HelpFormatter(Protocol): """Protocol for help **formatter** functions. It's the Formatter's job to transform a :class:`.HelpPanel` into rendered text on the display. Implementations may optionally provide the following methods for custom rendering of "usage" and "description". If these methods are not provided, default rendering will be used. .. code-block:: python def render_usage(self, console: Console, options: ConsoleOptions, usage: Any) -> None: \"\"\"Render the usage line.\"\"\" ... def render_description(self, console: Console, options: ConsoleOptions, description: Any) -> None: \"\"\"Render the description.\"\"\" ... """ def __call__( self, console: "Console", options: "ConsoleOptions", panel: "HelpPanel", ) -> None: """Format and render a single help panel. Parameters ---------- console : ~rich.console.Console Console to render to. options : ~rich.console.ConsoleOptions Console rendering options. panel : HelpPanel Help panel to render (commands, parameters, etc). """ ... BrianPugh-cyclopts-921b1fa/cyclopts/help/rst_preprocessor.py000066400000000000000000000341571517576204000244370ustar00rootroot00000000000000"""Preprocessing utilities for reStructuredText content. This module provides workarounds for limitations in the rich_rst library when rendering Sphinx directives. While rich_rst handles standard reStructuredText well, it doesn't support Sphinx-specific directives (versionadded, deprecated, note, warning, etc.). Since Cyclopts docstrings may contain these directives, we preprocess RST content to convert Sphinx directives into plain text annotations before passing to rich_rst. This ensures users see meaningful information in CLI help rather than raw directive syntax. This is a pragmatic workaround; ideally this functionality would be in rich_rst itself. """ import re from collections.abc import Callable def _skip_indented_block(lines: list[str], start_index: int, base_indent: int) -> int: """Skip over lines indented more than base_indent. Parameters ---------- lines : list[str] All lines in the text. start_index : int Index to start from. base_indent : int Base indentation level; lines must be indented more than this. Returns ------- int Index of the first non-matching line (or len(lines) if reached end). """ i = start_index while i < len(lines): line = lines[i] stripped = line.lstrip() indent = len(line) - len(stripped) if not stripped or indent > base_indent: i += 1 else: break return i def _is_list_item(line: str) -> bool: """Check if a line is a list item. Parameters ---------- line : str The stripped line to check. Returns ------- bool True if the line starts with a list marker. """ if not line: return False if line[0] in ("-", "*", "+"): return len(line) == 1 or line[1] in (" ", "\t") match = re.match(r"^\d+[\.\)](\s|$)", line) return match is not None def _gather_indented_block(lines: list[str], start_index: int, base_indent: int) -> tuple[list[str], int]: """Gather lines indented more than base_indent, preserving structure. This function preserves paragraph breaks (blank lines), list structure, and code block indentation while gathering indented content. List items are kept on separate lines. Lines that are indented beyond the minimum content indentation (like code blocks) preserve their relative indentation. Parameters ---------- lines : list[str] All lines in the text. start_index : int Index to start from. base_indent : int Base indentation level; lines must be indented more than this. Returns ------- content_lines : list[str] Content lines preserving paragraph structure. Each element represents either a paragraph (multiple lines joined with spaces), a single list item line, or indented code lines. end_index : int Index of the first non-matching line (or len(lines) if reached end). """ # First pass: collect all content and determine minimum indentation collected_lines = [] i = start_index min_indent = float("inf") while i < len(lines): line = lines[i] stripped = line.lstrip() indent = len(line) - len(stripped) if indent > base_indent or not stripped: if stripped: min_indent = min(min_indent, indent) collected_lines.append((line, indent, stripped)) i += 1 else: break if min_indent == float("inf"): min_indent = base_indent + 1 # Second pass: format content preserving relative indentation paragraphs = [] current_paragraph = [] last_was_empty = False for _, indent, stripped in collected_lines: if not stripped: if current_paragraph: paragraphs.append(" ".join(current_paragraph)) current_paragraph = [] last_was_empty = True else: is_list = _is_list_item(stripped) is_code_block = indent > min_indent if is_list: if current_paragraph: paragraphs.append(" ".join(current_paragraph)) current_paragraph = [] paragraphs.append(stripped) last_was_empty = False elif is_code_block: # Preserve code block indentation if current_paragraph: paragraphs.append(" ".join(current_paragraph)) current_paragraph = [] # Preserve relative indentation (subtract min_indent to normalize) relative_indent = indent - min_indent paragraphs.append(" " * relative_indent + stripped) last_was_empty = False else: if last_was_empty and current_paragraph: paragraphs.append(" ".join(current_paragraph)) current_paragraph = [] current_paragraph.append(stripped) last_was_empty = False if current_paragraph: paragraphs.append(" ".join(current_paragraph)) return paragraphs, i def _handle_version_directive( directive_name: str, directive_arg: str, lines: list[str], start_index: int, current_indent: int ) -> tuple[str, int]: """Handle versionadded and versionchanged directives. Parameters ---------- directive_name : str Name of the directive (e.g., "versionadded"). directive_arg : str Version number argument. lines : list[str] All lines in the text. start_index : int Current line index. current_indent : int Current indentation level. Returns ------- tag : str Formatted tag text. next_index : int Next line index to process. """ prefix = "Added" if directive_name == "versionadded" else "Changed" tag = f"[{prefix} in v{directive_arg}]" return tag, start_index + 1 def _handle_deprecated_directive( directive_name: str, directive_arg: str, lines: list[str], start_index: int, current_indent: int ) -> tuple[str, int]: """Handle deprecated directive with optional content. Parameters ---------- directive_name : str Name of the directive ("deprecated"). directive_arg : str Version number argument. lines : list[str] All lines in the text. start_index : int Current line index. current_indent : int Current indentation level. Returns ------- tag : str Formatted tag text with optional content. next_index : int Next line index to process. """ paragraphs, next_i = _gather_indented_block(lines, start_index + 1, current_indent) content = "\n\n".join(paragraphs).strip() tag = f"[⚠ Deprecated in v{directive_arg}]" return f"{tag} {content}" if content else tag, next_i def _handle_admonition_directive( directive_name: str, directive_arg: str, lines: list[str], start_index: int, current_indent: int ) -> tuple[str, int]: """Handle note, warning, and seealso directives. Parameters ---------- directive_name : str Name of the directive (e.g., "note", "warning", "seealso"). directive_arg : str Inline content on the directive line. lines : list[str] All lines in the text. start_index : int Current line index. current_indent : int Current indentation level. Returns ------- tag : str Formatted tag text with content. next_index : int Next line index to process. """ paragraphs = [directive_arg] if directive_arg else [] more_paragraphs, next_i = _gather_indented_block(lines, start_index + 1, current_indent) paragraphs.extend(more_paragraphs) blocks = [] current_list = [] current_code_block = [] for para in paragraphs: is_list = _is_list_item(para) is_code = para.startswith(" ") and para.strip() # Code block lines start with space if is_list: # Flush any current blocks if current_code_block: blocks.append("\n".join(current_code_block)) current_code_block = [] current_list.append(para) elif is_code: # Flush current list if any if current_list: blocks.append("\n".join(current_list)) current_list = [] current_code_block.append(para) else: # Regular paragraph if current_list: blocks.append("\n".join(current_list)) current_list = [] if current_code_block: blocks.append("\n".join(current_code_block)) current_code_block = [] blocks.append(para) # Flush any remaining blocks if current_list: blocks.append("\n".join(current_list)) if current_code_block: blocks.append("\n".join(current_code_block)) content = "\n\n".join(blocks).strip() prefix_map = { "note": "Note:", "warning": "⚠ Warning:", "seealso": "See also:", } prefix = prefix_map[directive_name] formatted = f"\n\n{prefix} {content}\n\n" if content else f"\n\n{prefix}\n\n" return formatted, next_i DirectiveHandler = Callable[[str, str, list[str], int, int], tuple[str, int]] DIRECTIVE_HANDLERS: dict[str, DirectiveHandler] = { "versionadded": _handle_version_directive, "versionchanged": _handle_version_directive, "deprecated": _handle_deprecated_directive, "note": _handle_admonition_directive, "warning": _handle_admonition_directive, "seealso": _handle_admonition_directive, } def process_sphinx_directives(text: str | None) -> str: """Process Sphinx directives in reStructuredText content for CLI help display. Converts Sphinx directives to readable format: - .. versionadded:: X -> [Added in vX] - .. versionchanged:: X -> [Changed in vX] - .. deprecated:: X -> [⚠ Deprecated in vX] - .. note:: content -> Note: content - .. warning:: content -> ⚠ Warning: content - .. seealso:: content -> See also: content Unknown directives are silently removed but logged as debug messages. Parameters ---------- text : str | None The reStructuredText content to process. Returns ------- str Processed text with directives converted to inline annotations. Returns empty string if input is None or empty. """ if not text: return "" lines = text.split("\n") result_parts = [] version_tags = [] first_inline_directive_idx = None i = 0 # Admonition directives that should appear inline at their position admonition_directives = {"note", "warning", "seealso"} while i < len(lines): line = lines[i] stripped = line.lstrip() current_indent = len(line) - len(stripped) if stripped.startswith("..") and "::" in stripped: match = re.match(r"\.\.\s+(\w+)::\s*(.*)", stripped) if match: directive_name = match.group(1) directive_arg = match.group(2).strip() handler = DIRECTIVE_HANDLERS.get(directive_name) if handler: tag, next_i = handler(directive_name, directive_arg, lines, i, current_indent) if directive_name in admonition_directives: # Track position of first inline directive if first_inline_directive_idx is None: first_inline_directive_idx = len(result_parts) # Add inline directive (strip() removes the newlines added by handler) result_parts.append(tag.strip()) else: # Collect version/deprecated directives version_tags.append(tag) i = next_i else: i = _skip_indented_block(lines, i + 1, current_indent) else: i = _skip_indented_block(lines, i + 1, current_indent) else: result_parts.append(line) i += 1 # Append version tags if version_tags: # If there are inline directives and no text after them, insert tags before first inline directive if first_inline_directive_idx is not None: # Check if there's any non-empty text after the first inline directive has_text_after = any( result_parts[i].strip() for i in range(first_inline_directive_idx + 1, len(result_parts)) ) if not has_text_after: # Insert before first inline directive _insert_version_tags_at_index(result_parts, version_tags, first_inline_directive_idx) else: # Insert at the end _insert_version_tags(result_parts, version_tags) else: # No inline directives, insert at the end _insert_version_tags(result_parts, version_tags) result = "\n".join(result_parts).strip() return result def _insert_version_tags(result_parts: list[str], version_tags: list[str]) -> None: """Insert version tags at the end of the last non-empty line.""" tags_text = " ".join(version_tags) if result_parts: # Find last non-empty line for idx in range(len(result_parts) - 1, -1, -1): if result_parts[idx].strip(): result_parts[idx] = f"{result_parts[idx]} {tags_text}" return # All lines are empty, append tags as new line result_parts.append(tags_text) else: result_parts.append(tags_text) def _insert_version_tags_at_index(result_parts: list[str], version_tags: list[str], before_index: int) -> None: """Insert version tags before the specified index, appending to the last non-empty line before that index.""" tags_text = " ".join(version_tags) # Find last non-empty line before the index for idx in range(before_index - 1, -1, -1): if result_parts[idx].strip(): result_parts[idx] = f"{result_parts[idx]} {tags_text}" return # All lines before index are empty, insert at the index result_parts.insert(before_index, tags_text) BrianPugh-cyclopts-921b1fa/cyclopts/help/silent.py000066400000000000000000000007541517576204000223130ustar00rootroot00000000000000"""Silent Rich object that renders nothing.""" from typing import TYPE_CHECKING if TYPE_CHECKING: from rich.console import Console, ConsoleOptions, RenderResult class SilentRich: """Dummy object that causes nothing to be printed.""" def __rich_console__(self, console: "Console", options: "ConsoleOptions") -> "RenderResult": # This generator yields nothing, so ``rich`` will print nothing for this object. if False: yield SILENT = SilentRich() BrianPugh-cyclopts-921b1fa/cyclopts/help/specs.py000066400000000000000000000624761517576204000221430ustar00rootroot00000000000000import math import textwrap from collections.abc import Iterable from operator import attrgetter from typing import TYPE_CHECKING, Literal, Optional, Union from attrs import evolve from cyclopts.utils import frozen if TYPE_CHECKING: from rich.box import Box from rich.console import Console, ConsoleOptions, RenderableType from rich.padding import PaddingDimensions from rich.panel import Panel from rich.style import StyleType from rich.table import Table from cyclopts.help import HelpEntry from cyclopts.help.protocols import Renderer class NameRenderer: """Renderer for parameter/command names with optional text wrapping. Parameters ---------- max_width : int | None Maximum width for wrapping. If None, no wrapping is applied. """ def __init__(self, max_width: int | None = None): """Initialize the renderer with formatting options. Parameters ---------- max_width : int | None Maximum width for wrapping. If None, no wrapping is applied. """ self.max_width = max_width def __call__(self, entry: "HelpEntry") -> "RenderableType": """Render the names column with optional text wrapping. Parameters ---------- entry : HelpEntry The table entry to render. Returns ------- ~rich.console.RenderableType Combined names and shorts, optionally wrapped. Order: positive_names, positive_shorts, negative_names, negative_shorts """ text = " ".join(entry.all_options) if self.max_width is None: return text wrapped = textwrap.wrap( text, self.max_width, subsequent_indent=" ", break_on_hyphens=False, tabsize=4, ) return "\n".join(wrapped) class CommandNameRenderer: """Renderer for command names with aliases in parentheses. Displays commands in argparse-style format: ``primary (alias1, alias2)``. Parameters ---------- max_width : int | None Maximum width for wrapping. If None, no wrapping is applied. """ def __init__(self, max_width: int | None = None): """Initialize the renderer with formatting options. Parameters ---------- max_width : int | None Maximum width for wrapping. If None, no wrapping is applied. """ self.max_width = max_width def __call__(self, entry: "HelpEntry") -> "RenderableType": """Render command name with aliases in parentheses. Parameters ---------- entry : HelpEntry The table entry to render. Returns ------- ~rich.console.RenderableType Primary command name with aliases in parentheses. """ primary = entry.all_options[0] if entry.all_options else "" aliases = list(entry.all_options[1:]) if aliases: text = f"{primary} ({', '.join(aliases)})" else: text = primary if self.max_width is None: return text wrapped = textwrap.wrap( text, self.max_width, subsequent_indent=" ", break_on_hyphens=False, tabsize=4, ) return "\n".join(wrapped) class DescriptionRenderer: """Renderer for descriptions with configurable metadata formatting. Parameters ---------- newline_metadata : bool If True, display metadata (choices, env vars, defaults) on separate lines. If False (default), display metadata inline with the description. """ def __init__(self, newline_metadata: bool = False): """Initialize the renderer with formatting options. Parameters ---------- newline_metadata : bool If True, display metadata on separate lines instead of inline. """ self.newline_metadata = newline_metadata def __call__(self, entry: "HelpEntry") -> "RenderableType": """Render parameter description with metadata annotations. Enriches the base description with choices, environment variables, default values, and required status. Parameters ---------- entry : HelpEntry The table entry to render. Returns ------- ~rich.console.RenderableType Description with appended metadata. """ from rich.text import Text from cyclopts.help.inline_text import InlineText description = entry.description if description is None: description = InlineText(Text()) elif not isinstance(description, InlineText): # Convert to InlineText if it isn't already if hasattr(entry.description, "__rich_console__"): # It's already a Rich renderable, wrap it description = InlineText(description) else: # Convert to Text first, then wrap in InlineText from rich.text import Text description = InlineText(Text(str(description))) # Collect metadata items metadata_items = [] if entry.choices: choices_str = ", ".join(entry.choices) metadata_items.append(Text(rf"[choices: {choices_str}]", "dim")) if entry.env_var: env_vars_str = ", ".join(entry.env_var) metadata_items.append(Text(rf"[env var: {env_vars_str}]", "dim")) if entry.default is not None: metadata_items.append(Text(rf"[default: {entry.default}]", "dim")) if entry.required: metadata_items.append(Text(r"[required]", "dim red")) # Apply metadata based on formatting mode if self.newline_metadata and metadata_items: # Add metadata on separate lines with indentation from rich.console import Group as RichGroup from rich.text import Text # Create a list of renderables to group renderables = [] # Add the original description first if description.primary_renderable: renderables.append(description.primary_renderable) # Add each metadata item without indentation for item in metadata_items: renderables.append(item) # Return a Rich Group that stacks these vertically return RichGroup(*renderables) if renderables else Text() else: # Original inline behavior for item in metadata_items: description.append(item) return description class AsteriskRenderer: """Renderer for required parameter asterisk indicator. A simple renderer that displays an asterisk (*) for required parameters. """ def __call__(self, entry: "HelpEntry") -> "RenderableType": """Render an asterisk for required parameters. Parameters ---------- entry : HelpEntry The table entry to render. Returns ------- ~rich.console.RenderableType An asterisk if the entry is required, empty string otherwise. """ return "*" if entry.required else "" @frozen class ColumnSpec: """Specification for a single column in a help table. Used by :class:`~cyclopts.help.formatters.default.DefaultFormatter` to define how individual columns are rendered in help tables. Each column can have its own renderer, styling, and layout properties. See Also -------- ~cyclopts.help.formatters.default.DefaultFormatter : The formatter that uses these specs. ~cyclopts.help.specs.TableSpec : Specification for the entire table. ~cyclopts.help.specs.PanelSpec : Specification for the outer panel. """ renderer: Union[str, "Renderer"] """Specifies how to extract and render cell content from a :class:`~cyclopts.help.HelpEntry`. Can be either: - A string: The attribute name to retrieve from :class:`~cyclopts.help.HelpEntry` (e.g., 'names', 'description', 'required', 'type'). The string is displayed as-is. - A callable: A function matching the :class:`~cyclopts.help.protocols.Renderer` protocol. The function receives a :class:`~cyclopts.help.HelpEntry` and should return a :class:`~rich.console.RenderableType` (str, :class:`~rich.text.Text`, or other Rich renderable). Examples:: # String renderer - get attribute directly ColumnSpec(renderer="description") # Callable renderer - custom formatting def format_names(entry: HelpEntry) -> str: return ", ".join(entry.names) if entry.names else "" ColumnSpec(renderer=format_names) """ header: str = "" """Column header text displayed at the top of the column. Example:: header="Options" renders: ┌─────────┬─────────────┐ │ Options │ Description │ ├─────────┼─────────────┤ │ --help │ Show help │ └─────────┴─────────────┘ """ footer: str = "" """Column footer text displayed at the bottom of the column. Example:: footer="Required" renders: ┌──────────┬────────────┐ │ --help │ Show help │ ├──────────┼────────────┤ │ Required │ │ └──────────┴────────────┘ """ header_style: Optional["StyleType"] = None """Style applied to the column header text. Corresponds to the ``header_style`` parameter of :meth:`rich.table.Table.add_column`. """ footer_style: Optional["StyleType"] = None """Style applied to the column footer text. Corresponds to the ``footer_style`` parameter of :meth:`rich.table.Table.add_column`. """ style: Optional["StyleType"] = None """Default style applied to all cells in this column. Corresponds to the ``style`` parameter of :meth:`rich.table.Table.add_column`. """ justify: Literal["default", "left", "center", "right", "full"] = "left" """Text justification within the column. Corresponds to the ``justify`` parameter of :meth:`rich.table.Table.add_column`. """ vertical: Literal["top", "middle", "bottom"] = "top" """Vertical alignment of text within cells. Corresponds to the ``vertical`` parameter of :meth:`rich.table.Table.add_column`. """ overflow: Literal["fold", "crop", "ellipsis", "ignore"] = "ellipsis" """How to handle text that exceeds column width. Corresponds to the ``overflow`` parameter of :meth:`rich.table.Table.add_column`. """ width: int | None = None """Fixed width for the column in characters. Corresponds to the ``width`` parameter of :meth:`rich.table.Table.add_column`. """ min_width: int | None = None """Minimum width for the column in characters. Corresponds to the ``min_width`` parameter of :meth:`rich.table.Table.add_column`. """ max_width: int | None = None """Maximum width for the column in characters. Corresponds to the ``max_width`` parameter of :meth:`rich.table.Table.add_column`. """ ratio: int | None = None """Relative width ratio compared to other columns. Corresponds to the ``ratio`` parameter of :meth:`rich.table.Table.add_column`. """ no_wrap: bool = False """Prevent text wrapping in the column. Corresponds to the ``no_wrap`` parameter of :meth:`rich.table.Table.add_column`. """ highlight: bool | None = None """Enable automatic highlighting of text in the column. Corresponds to the ``highlight`` parameter of :meth:`rich.table.Table.add_column`. """ def _render_cell(self, entry: "HelpEntry") -> "RenderableType": """Render the cell content based on the renderer type. If renderer is a string, retrieves that attribute from the entry. If renderer is callable, calls it with the entry. """ if isinstance(self.renderer, str): value = attrgetter(self.renderer)(entry) elif callable(self.renderer): value = self.renderer(entry) else: value = None return "" if value is None else value def copy(self, **kwargs): return evolve(self, **kwargs) # For Parameters: AsteriskColumn = ColumnSpec( renderer=AsteriskRenderer(), header="", justify="left", width=1, style="red bold", ) NameColumn = ColumnSpec( renderer=NameRenderer(), header="Option", justify="left", style="cyan", ) DescriptionColumn = ColumnSpec(renderer=DescriptionRenderer(), header="Description", justify="left", overflow="fold") def get_default_command_columns( console: "Console", options: "ConsoleOptions", entries: list["HelpEntry"] ) -> tuple[ColumnSpec, ...]: """Get default column specifications for command display. Parameters ---------- console : ~rich.console.Console Rich console for width calculations. options : ~rich.console.ConsoleOptions Console rendering options. entries : list[HelpEntry] Command entries to display. Returns ------- tuple[ColumnSpec, ...] Column specifications for command table. """ max_width = math.ceil(console.width * 0.35) command_column = ColumnSpec( renderer=CommandNameRenderer(max_width=max_width), header="Command", justify="left", style="cyan", max_width=max_width, ) return ( command_column, DescriptionColumn, ) def get_default_parameter_columns( console: "Console", options: "ConsoleOptions", entries: list["HelpEntry"] ) -> tuple[ColumnSpec, ...]: """Get default column specifications for parameter display. Parameters ---------- console : ~rich.console.Console Rich console for width calculations. options : ~rich.console.ConsoleOptions Console rendering options. entries : list[HelpEntry] Parameter entries to display. Returns ------- tuple[ColumnSpec, ...] Column specifications for parameter table. """ max_width = math.ceil(console.width * 0.35) name_column = ColumnSpec( renderer=NameRenderer(max_width=max_width), header="Option", justify="left", style="cyan", max_width=max_width, ) if any(x.required for x in entries): return ( AsteriskColumn, name_column, DescriptionColumn, ) else: return ( name_column, DescriptionColumn, ) @frozen class TableSpec: """Specification for table layout and styling. Used by :class:`~cyclopts.help.formatters.default.DefaultFormatter` to control the appearance of tables that display commands and parameters. This spec defines table-wide properties like borders, headers, and padding. See Also -------- ~cyclopts.help.formatters.default.DefaultFormatter : The formatter that uses these specs. ~cyclopts.help.specs.ColumnSpec : Specification for individual columns. ~cyclopts.help.specs.PanelSpec : Specification for the outer panel. """ # Intrinsic table styling/config title: str | None = None """Title text displayed above the table. Corresponds to the ``title`` parameter of :class:`~rich.table.Table`. """ caption: str | None = None """Caption text displayed below the table. Corresponds to the ``caption`` parameter of :class:`~rich.table.Table`. """ style: Optional["StyleType"] = None """Default style applied to the entire table. Corresponds to the ``style`` parameter of :class:`~rich.table.Table`. """ border_style: Optional["StyleType"] = None """Style applied to table borders. Corresponds to the ``border_style`` parameter of :class:`~rich.table.Table`. """ header_style: Optional["StyleType"] = None """Default style for all table headers (can be overridden per column). Corresponds to the ``header_style`` parameter of :class:`~rich.table.Table`. """ footer_style: Optional["StyleType"] = None """Default style for all table footers (can be overridden per column). Corresponds to the ``footer_style`` parameter of :class:`~rich.table.Table`. """ box: Optional["Box"] = None """Box drawing style for the table borders. Corresponds to the ``box`` parameter of :class:`~rich.table.Table`. See :mod:`rich.box` for available styles. """ show_header: bool = False """Whether to display column headers. Corresponds to the ``show_header`` parameter of :class:`~rich.table.Table`. """ show_footer: bool = False """Whether to display column footers. Corresponds to the ``show_footer`` parameter of :class:`~rich.table.Table`. """ show_lines: bool = False """Whether to show horizontal lines between rows. Corresponds to the ``show_lines`` parameter of :class:`~rich.table.Table`. """ show_edge: bool = True """Whether to draw a box around the outside of the table. Corresponds to the ``show_edge`` parameter of :class:`~rich.table.Table`. """ expand: bool = False """Whether the table should expand to fill available width. Corresponds to the ``expand`` parameter of :class:`~rich.table.Table`. """ pad_edge: bool = False """Whether to add padding to the table edges. Corresponds to the ``pad_edge`` parameter of :class:`~rich.table.Table`. """ padding: "PaddingDimensions" = (0, 2, 0, 0) """Padding around cell content (top, right, bottom, left). Corresponds to the ``padding`` parameter of :class:`~rich.table.Table`. """ collapse_padding: bool = False """Whether to collapse padding when adjacent cells are empty. Corresponds to the ``collapse_padding`` parameter of :class:`~rich.table.Table`. """ width: int | None = None """Fixed width for the table in characters. Corresponds to the ``width`` parameter of :class:`~rich.table.Table`. """ min_width: int | None = None """Minimum width for the table in characters. Corresponds to the ``min_width`` parameter of :class:`~rich.table.Table`. """ safe_box: bool | None = None """Whether to use ASCII-safe box characters for compatibility. Corresponds to the ``safe_box`` parameter of :class:`~rich.table.Table`. """ def build( self, columns: tuple[ColumnSpec, ...], entries: Iterable["HelpEntry"], **overrides, ) -> "Table": """Construct and populate a rich.Table. Parameters ---------- columns : tuple[ColumnSpec, ...] Column specifications defining the table structure. entries : Iterable[HelpEntry] Table entries to populate the table with. **overrides Per-render overrides for table settings. Returns ------- Table A populated Rich Table. """ # If show_header is True but all columns have empty headers, don't show the header # This prevents an empty line from appearing at the top of the table show_header = self.show_header if show_header and all(not col.header for col in columns): show_header = False opts = { "title": self.title, "caption": self.caption, "style": self.style, "border_style": self.border_style, "header_style": self.header_style, "footer_style": self.footer_style, "box": self.box, "show_header": show_header, "show_footer": self.show_footer, "show_lines": self.show_lines, "show_edge": self.show_edge, "expand": self.expand, "pad_edge": self.pad_edge, "padding": self.padding, "collapse_padding": self.collapse_padding, "width": self.width, "min_width": self.min_width, "safe_box": self.safe_box, } opts.update(overrides) from rich.table import Table table = Table(**opts) # Add columns for column in columns: col_opts = { "header": column.header, "footer": column.footer, "header_style": column.header_style, "footer_style": column.footer_style, "style": column.style, "justify": column.justify, "vertical": column.vertical, "overflow": column.overflow, "width": column.width, "min_width": column.min_width, "max_width": column.max_width, "ratio": column.ratio, "no_wrap": column.no_wrap, } if column.highlight is not None: col_opts["highlight"] = column.highlight table.add_column(**col_opts) # Add entries for e in entries: cells = [col._render_cell(e) for col in columns] table.add_row(*cells) return table def copy(self, **kwargs): return evolve(self, **kwargs) @frozen class PanelSpec: """Specification for panel (outer box) styling. Used by :class:`~cyclopts.help.formatters.default.DefaultFormatter` to control the appearance of the outer panel that wraps help sections. This spec defines the panel's border, title, subtitle, and overall styling. See Also -------- ~cyclopts.help.formatters.default.DefaultFormatter : The formatter that uses these specs. ~cyclopts.help.specs.TableSpec : Specification for the inner table. ~cyclopts.help.specs.ColumnSpec : Specification for individual columns. """ # Content-independent panel chrome title: Optional["RenderableType"] = None """Title text displayed at the top of the panel. Corresponds to the ``title`` parameter of :class:`~rich.panel.Panel`. """ subtitle: Optional["RenderableType"] = None """Subtitle text displayed at the bottom of the panel. Corresponds to the ``subtitle`` parameter of :class:`~rich.panel.Panel`. """ title_align: Literal["left", "center", "right"] = "left" """Alignment of the title text within the panel. Corresponds to the ``title_align`` parameter of :class:`~rich.panel.Panel`. """ subtitle_align: Literal["left", "center", "right"] = "center" """Alignment of the subtitle text within the panel. Corresponds to the ``subtitle_align`` parameter of :class:`~rich.panel.Panel`. """ style: Optional["StyleType"] = "none" """Style applied to the panel background. Corresponds to the ``style`` parameter of :class:`~rich.panel.Panel`. """ border_style: Optional["StyleType"] = "none" """Style applied to the panel border. Corresponds to the ``border_style`` parameter of :class:`~rich.panel.Panel`. """ box: Optional["Box"] = None # Will use ROUNDED as default when building """Box drawing style for the panel border. Corresponds to the ``box`` parameter of :class:`~rich.panel.Panel`. See :mod:`rich.box` for available styles. Defaults to ``rich.box.ROUNDED``. """ padding: "PaddingDimensions" = (0, 1) """Padding inside the panel (top/bottom, left/right) or (top, right, bottom, left). Corresponds to the ``padding`` parameter of :class:`~rich.panel.Panel`. """ expand: bool = True """Whether the panel should expand to fill available width. Corresponds to the ``expand`` parameter of :class:`~rich.panel.Panel`. """ width: int | None = None """Fixed width for the panel in characters. Corresponds to the ``width`` parameter of :class:`~rich.panel.Panel`. """ height: int | None = None """Fixed height for the panel in lines. Corresponds to the ``height`` parameter of :class:`~rich.panel.Panel`. """ safe_box: bool | None = None """Whether to use ASCII-safe box characters for compatibility. Corresponds to the ``safe_box`` parameter of :class:`~rich.panel.Panel`. """ highlight: bool = False """Enable automatic highlighting of panel contents. Corresponds to the ``highlight`` parameter of :class:`~rich.panel.Panel`. """ def build(self, renderable: "RenderableType", **overrides) -> "Panel": """Create a Panel around `renderable`. Use kwargs to override spec per render.""" # Import box here for lazy loading box = self.box if box is None: from rich.box import ROUNDED box = ROUNDED opts = { "title_align": self.title_align, "subtitle_align": self.subtitle_align, "style": self.style, "border_style": self.border_style, "box": box, "padding": self.padding, "expand": self.expand, "width": self.width, "height": self.height, "safe_box": self.safe_box, "highlight": self.highlight, } if self.title is not None: opts["title"] = self.title if self.subtitle is not None: opts["subtitle"] = self.subtitle opts.update(overrides) from rich.panel import Panel return Panel(renderable, **opts) def copy(self, **kwargs): return evolve(self, **kwargs) BrianPugh-cyclopts-921b1fa/cyclopts/loader.py000066400000000000000000000124431517576204000213310ustar00rootroot00000000000000"""Load Cyclopts App objects from Python scripts.""" import importlib.util import sys from contextlib import contextmanager from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: from cyclopts import App from cyclopts.command_spec import CommandSpec @contextmanager def _suppress_app_execution(): """Temporarily disable App.__call__ to prevent execution during module loading. This context manager replaces App.__call__ with a no-op function, allowing scripts that call app() at module level to be imported without executing. """ from cyclopts import App original_call = App.__call__ def _dummy_call(self, *args, **kwargs): """No-op replacement for App.__call__ during module loading.""" return None try: App.__call__ = _dummy_call yield finally: App.__call__ = original_call def load_app_from_script(script: str | Path) -> tuple["App", str]: """Load a Cyclopts App object from a Python script. Parameters ---------- script : str | Path Python script path, optionally with ``':app_object'`` notation to specify the :class:`App` object. If not specified, will search for :class:`App` objects in the script's global namespace. Returns ------- tuple[App, str] The loaded :class:`App` object and its name. Raises ------ SystemExit If the script cannot be loaded, no App objects are found, or multiple App objects exist without specification. """ # Avoid circular import from cyclopts import App # Parse the script path and optional app object app_name = None script_str = str(script) if ":" in script_str: # Split on the last colon script_path_str, potential_app_name = script_str.rsplit(":", 1) # Only treat it as an app name if it looks like a Python identifier # (no path separators), otherwise it may be part of a Windows path like C:\path\to\file.py if potential_app_name and not any(sep in potential_app_name for sep in ["/", "\\"]): # Looks like an app name app_name = potential_app_name script_path = Path(script_path_str) else: # It's part of the path (e.g., Windows drive letter) script_path = Path(script) else: script_path = Path(script) script_path = script_path.resolve() if not script_path.exists(): print(f"Error: Script '{script_path}' not found.", file=sys.stderr) sys.exit(1) if not script_path.suffix == ".py": print(f"Error: '{script_path}' is not a Python file.", file=sys.stderr) sys.exit(1) # Load the module spec = importlib.util.spec_from_file_location("__cyclopts_doc_module", script_path) if spec is None or spec.loader is None: print(f"Error: Could not load module from '{script_path}'.", file=sys.stderr) sys.exit(1) module = importlib.util.module_from_spec(spec) sys.modules["__cyclopts_doc_module"] = module with _suppress_app_execution(): spec.loader.exec_module(module) # Find the App object if app_name: # User specified the app object name if not hasattr(module, app_name): print(f"Error: No object named '{app_name}' found in '{script_path}'.", file=sys.stderr) sys.exit(1) app_obj = getattr(module, app_name) if not isinstance(app_obj, App): print(f"Error: '{app_name}' is not a Cyclopts App object.", file=sys.stderr) sys.exit(1) return app_obj, app_name else: # Heuristic: find App objects in the module's global namespace app_objects = [] for name in dir(module): if not name.startswith("_"): # Skip private/protected names obj = getattr(module, name) if isinstance(obj, App): app_objects.append((name, obj)) if not app_objects: print(f"Error: No Cyclopts App objects found in '{script_path}'.", file=sys.stderr) sys.exit(1) if len(app_objects) > 1: # Filter out Apps that are registered as commands to other Apps # Skip CommandSpec - those are lazy imports from other modules, not apps from this file registered_apps = [] for _, app in app_objects: if hasattr(app, "_commands"): # Only include direct App references; CommandSpec entries can't point to apps in this file registered_apps.extend(cmd for cmd in app._commands.values() if not isinstance(cmd, CommandSpec)) # Keep only Apps that are not registered to others filtered_apps = [(name, app) for name, app in app_objects if app not in registered_apps] if filtered_apps: app_objects = filtered_apps if len(app_objects) > 1: names = ", ".join(name for name, _ in app_objects) script_str = str(script) if isinstance(script, Path) else script print( f"Error: Multiple App objects found: {names}. Please specify one using '{script_str}:app_name'.", file=sys.stderr, ) sys.exit(1) name, app_obj = app_objects[0] return app_obj, name BrianPugh-cyclopts-921b1fa/cyclopts/panel.py000066400000000000000000000026631517576204000211650ustar00rootroot00000000000000"""Cyclopts panel utilities for Rich-based terminal output.""" from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from rich.panel import Panel def CycloptsPanel(message: Any, title: str = "Error", style: str = "red") -> "Panel": # noqa: N802 """Create a :class:`~rich.panel.Panel` with a consistent style. The resulting panel can be displayed using a :class:`~rich.console.Console`. .. code-block:: text ╭─ Title ──────────────────────────────────╮ │ Message content here. │ ╰──────────────────────────────────────────╯ Parameters ---------- message: Any The body of the panel will be filled with the stringified version of the message. title: str Title of the panel that appears in the top-left corner. style: str Rich `style `_ for the panel border. Returns ------- ~rich.panel.Panel Formatted panel object. """ from rich import box from rich.panel import Panel from rich.text import Text panel = Panel( Text(str(message), "default"), title=title, style=style, box=box.ROUNDED, expand=True, title_align="left", ) return panel BrianPugh-cyclopts-921b1fa/cyclopts/parameter.py000066400000000000000000000530241517576204000220430ustar00rootroot00000000000000import collections.abc import inspect import re import sys from collections.abc import Callable, Iterable, Sequence from copy import deepcopy from typing import ( # noqa: UP035 Any, List, Tuple, TypeVar, cast, get_args, get_origin, ) from attrs import define, field if sys.version_info >= (3, 11): from typing import Self else: from typing_extensions import Self import cyclopts._env_var from cyclopts.annotations import ( ITERABLE_TYPES, NoneType, is_annotated, is_nonetype, is_union, resolve, resolve_annotated, resolve_optional, ) from cyclopts.field_info import get_field_infos, signature_parameters from cyclopts.group import Group from cyclopts.utils import ( default_name_transform, frozen, optional_to_tuple_converter, record_init, to_tuple_converter, ) ITERATIVE_BOOL_IMPLICIT_VALUE = frozenset( { Iterable[bool], Sequence[bool], collections.abc.Sequence[bool], list[bool], List[bool], # noqa: UP006 tuple[bool, ...], Tuple[bool, ...], # noqa: UP006 } ) T = TypeVar("T") _NEGATIVE_FLAG_TYPES = frozenset([bool, None, NoneType, *ITERABLE_TYPES, *ITERATIVE_BOOL_IMPLICIT_VALUE]) def _not_hyphen_validator(instance, attribute, values): for value in values: if value is not None and value.startswith("-"): raise ValueError(f'{attribute.alias} value must NOT start with "-".') def _str_tuple_converter(value: str | Iterable[str] | None) -> tuple[str, ...]: return cast(tuple[str, ...], to_tuple_converter(value)) def _validator_tuple_converter( value: Callable[[Any, Any], Any] | Iterable[Callable[[Any, Any], Any]] | None, ) -> tuple[Callable[[Any, Any], Any], ...]: return cast(tuple[Callable[[Any, Any], Any], ...], to_tuple_converter(value)) def _group_tuple_converter(value: "None | Group | str | Iterable[Group | str]") -> tuple["Group | str", ...]: return cast(tuple["Group | str", ...], to_tuple_converter(value)) def _optional_str_tuple_converter(value: bool | str | Iterable[str] | None) -> tuple[str, ...] | None: return optional_to_tuple_converter(value) # type: ignore[return-value] def _default_if_none_true(value: bool | None) -> bool: return value if value is not None else True def _default_if_none_false(value: bool | None) -> bool: return value if value is not None else False def _negative_converter(default: tuple[str, ...]): def converter(value: str | Iterable[str] | None) -> tuple[str, ...]: if value is None: return default else: return to_tuple_converter(value) return converter def _consume_multiple_converter( value: bool | int | Sequence[int] | tuple[int, int | None] | None, ) -> tuple[int, int | None] | None: """Normalize consume_multiple into (min, max) or None. Returns ------- tuple[int, int | None] | None ``None`` if consume_multiple is disabled (``None`` or ``False``). ``(min, max)`` where ``max=None`` means unlimited. """ if value is None or value is False: return None if value is True: return (0, None) if isinstance(value, int): if value < 0: raise ValueError(f"consume_multiple int value must be non-negative, got {value}.") return (value, None) if isinstance(value, Sequence): if len(value) != 2: raise ValueError(f"consume_multiple sequence must have exactly 2 elements (min, max), got {len(value)}.") mn, mx = value if mx is None: # Already-normalized form (min, None); pass through. return (mn, None) if not isinstance(mn, int) or isinstance(mn, bool) or not isinstance(mx, int) or isinstance(mx, bool): raise TypeError( f"consume_multiple sequence elements must be int, got ({type(mn).__name__}, {type(mx).__name__})." ) if mn < 0 or mx < 0: raise ValueError(f"consume_multiple sequence values must be non-negative, got ({mn}, {mx}).") if mn > mx: raise ValueError(f"consume_multiple min must be <= max, got ({mn}, {mx}).") return (mn, mx) raise TypeError(f"consume_multiple must be None, bool, int, or a (min, max) sequence, got {type(value).__name__}.") def _parse_converter(value: bool | re.Pattern[str] | str | None) -> bool | re.Pattern[str] | None: """Convert string patterns to compiled regex, pass through other types. Note: re.compile() internally caches compiled patterns, so no additional caching is needed here. """ if isinstance(value, str): return re.compile(value) return value @record_init("_provided_args") @frozen class Parameter: """Cyclopts configuration for individual function parameters with :obj:`~typing.Annotated`. Example usage: .. code-block:: python from cyclopts import app, Parameter from typing import Annotated app = App() @app.default def main(foo: Annotated[int, Parameter(name="bar")]): print(foo) app() .. code-block:: console $ my-script 100 100 $ my-script --bar 100 100 """ # All attribute docstrings has been moved to ``docs/api.rst`` for greater control with attrs. # This can ONLY ever be a Tuple[str, ...] # Usually starts with "--" or "-" name: None | str | Iterable[str] = field( default=None, converter=_str_tuple_converter, ) # Accepts regular converters (type, tokens) -> Any, bound methods (tokens) -> Any, or string references converter: Callable[..., Any] | str | None = field( default=None, kw_only=True, ) # This can ONLY ever be a Tuple[Callable, ...] validator: None | Callable[[Any, Any], Any] | Iterable[Callable[[Any, Any], Any]] = field( default=(), converter=_validator_tuple_converter, kw_only=True, ) # This can ONLY ever be a Tuple[str, ...] alias: None | str | Iterable[str] = field( default=None, converter=_str_tuple_converter, kw_only=True, ) # This can ONLY ever be ``None`` or ``Tuple[str, ...]`` negative: None | str | Iterable[str] = field( default=None, converter=_optional_str_tuple_converter, kw_only=True, ) # This can ONLY ever be a Tuple[Union[Group, str], ...] group: None | Group | str | Iterable[Group | str] = field( default=None, converter=_group_tuple_converter, kw_only=True, hash=False, ) parse: bool | re.Pattern | None = field( default=None, converter=_parse_converter, kw_only=True, ) _show: bool | None = field( default=None, alias="show", kw_only=True, ) show_default: None | bool | Callable[[Any], Any] = field( default=None, kw_only=True, ) show_choices: bool = field( default=None, converter=_default_if_none_true, kw_only=True, ) help: str | None = field(default=None, kw_only=True) show_env_var: bool = field( default=None, converter=_default_if_none_true, kw_only=True, ) # This can ONLY ever be a Tuple[str, ...] env_var: None | str | Iterable[str] = field( default=None, converter=_str_tuple_converter, kw_only=True, ) env_var_split: Callable[..., Any] = field( default=cyclopts._env_var.env_var_split, kw_only=True, ) # This can ONLY ever be a Tuple[str, ...] negative_bool: None | str | Iterable[str] = field( default=None, converter=_negative_converter(("no-",)), validator=_not_hyphen_validator, kw_only=True, ) # This can ONLY ever be a Tuple[str, ...] negative_iterable: None | str | Iterable[str] = field( default=None, converter=_negative_converter(("empty-",)), validator=_not_hyphen_validator, kw_only=True, ) # This can ONLY ever be a Tuple[str, ...] negative_none: None | str | Iterable[str] = field( default=None, converter=_negative_converter(()), validator=_not_hyphen_validator, kw_only=True, ) required: bool | None = field( default=None, kw_only=True, ) allow_leading_hyphen: bool = field( default=False, kw_only=True, ) requires_equals: bool = field( default=False, kw_only=True, ) _name_transform: Callable[[str], str] | None = field( alias="name_transform", default=None, kw_only=True, ) accepts_keys: bool | None = field( default=None, kw_only=True, ) consume_multiple: None | bool | int | Sequence[int] | tuple[int, int | None] = field( default=None, converter=_consume_multiple_converter, kw_only=True, ) json_dict: bool | None = field(default=None, kw_only=True) json_list: bool | None = field(default=None, kw_only=True) count: bool = field( default=None, converter=_default_if_none_false, kw_only=True, ) allow_repeating: bool | None = field( default=None, kw_only=True, ) n_tokens: int | None = field( default=None, kw_only=True, ) # Populated by the record_attrs_init_args decorator. _provided_args: tuple[str, ...] = field(factory=tuple, init=False, eq=False) @property def show(self) -> bool | None: if self._show is not None: return self._show if self.parse is None or isinstance(self.parse, re.Pattern): return None # For regex or None, let Argument.show handle it return bool(self.parse) @property def name_transform(self): return self._name_transform if self._name_transform else default_name_transform def get_negatives(self, type_) -> tuple[str, ...]: if self.count and self.negative is None: return () type_ = resolve_annotated(type_) if is_union(type_): union_args = get_args(type_) # Sort union members by priority: non-None types first, then None/NoneType # This ensures that if bool | None both produce the same custom negative, # we only include it once from the higher-priority type (bool). sorted_args = sorted(union_args, key=lambda x: (is_nonetype(x) or x is None)) out: list[str] = [] for x in sorted_args: for neg in self.get_negatives(x): if neg not in out: out.append(neg) return tuple(out) origin = get_origin(type_) if type_ not in _NEGATIVE_FLAG_TYPES: if origin: if origin not in _NEGATIVE_FLAG_TYPES: return () else: return () out, user_negatives = [], [] if self.negative: for negative in self.negative: (out if negative.startswith("-") else user_negatives).append(negative) if not user_negatives: return tuple(out) assert isinstance(self.name, tuple) for name in self.name: if not name.startswith("--"): # Only provide negation for option-like long flags. continue name = name[2:] name_components = name.split(".") if type_ is bool or type_ in ITERATIVE_BOOL_IMPLICIT_VALUE: negative_prefixes = self.negative_bool elif is_nonetype(type_) or type_ is None: negative_prefixes = self.negative_none else: negative_prefixes = self.negative_iterable name_prefix = ".".join(name_components[:-1]) if name_prefix: name_prefix += "." assert isinstance(negative_prefixes, tuple) if self.negative is None: for negative_prefix in negative_prefixes: if negative_prefix: out.append(f"--{name_prefix}{negative_prefix}{name_components[-1]}") else: for negative in user_negatives: out.append(f"--{name_prefix}{negative}") return tuple(out) def __repr__(self): """Only shows non-default values.""" content = ", ".join( [ f"{a.alias}={getattr(self, a.name)!r}" for a in self.__attrs_attrs__ # pyright: ignore[reportAttributeAccessIssue] if a.alias in self._provided_args ] ) return f"{type(self).__name__}({content})" @classmethod def combine(cls, *parameters: "Parameter | None") -> "Parameter": """Returns a new Parameter with combined values of all provided ``parameters``. Parameters ---------- *parameters : Parameter | None Parameters who's attributes override ``self`` attributes. Ordered from least-to-highest attribute priority. """ kwargs = {} filtered = [x for x in parameters if x is not None] # In the common case of 0/1 parameters to combine, we can avoid # instantiating a new Parameter object. if len(filtered) == 1: return filtered[0] elif not filtered: return EMPTY_PARAMETER for parameter in filtered: for alias in parameter._provided_args: kwargs[alias] = getattr(parameter, _parameter_alias_to_name[alias]) return cls(**kwargs) @classmethod def default(cls) -> Self: """Create a Parameter with all Cyclopts-default values. This is different than just :class:`Parameter` because the default values will be recorded and override all upstream parameter values. """ return cls( **{a.alias: a.default for a in cls.__attrs_attrs__ if a.init} # pyright: ignore[reportAttributeAccessIssue] ) @classmethod def from_annotation(cls, type_: Any, *default_parameters: "Parameter | None") -> tuple[Any, "Parameter"]: """Resolve the immediate Parameter from a type hint.""" if type_ is inspect.Parameter.empty: if default_parameters: return type_, cls.combine(*default_parameters) else: return type_, EMPTY_PARAMETER else: type_, parameters = get_parameters(type_) return type_, cls.combine(*default_parameters, *parameters) def __call__(self, obj: T) -> T: """Decorator interface for annotating a function/class with a :class:`Parameter`. Most commonly used for directly configuring a class: .. code-block:: python @Parameter(...) class Foo: ... """ if not hasattr(obj, "__cyclopts__"): obj.__cyclopts__ = CycloptsConfig(obj=obj) # pyright: ignore[reportAttributeAccessIssue] elif obj.__cyclopts__.obj != obj: # pyright: ignore[reportAttributeAccessIssue] # Create a copy so that children class Parameter decorators don't impact the parent. obj.__cyclopts__ = deepcopy(obj.__cyclopts__) # pyright: ignore[reportAttributeAccessIssue] obj.__cyclopts__.parameters.append(self) # pyright: ignore[reportAttributeAccessIssue] return obj _parameter_alias_to_name = { p.alias: p.name for p in Parameter.__attrs_attrs__ # pyright: ignore[reportAttributeAccessIssue] if p.init } EMPTY_PARAMETER = Parameter() def validate_command(f: Callable): """Validate if a function abides by Cyclopts's rules. Raises ------ ValueError Function has naming or parameter/signature inconsistencies. """ if (f.__module__ or "").startswith("cyclopts"): # Speed optimization. return for field_info in signature_parameters(f).values(): # Speed optimization: if no annotation and no cyclopts config, skip validation field_info_is_annotated = is_annotated(field_info.annotation) if not field_info_is_annotated and not getattr(field_info.annotation, "__cyclopts__", None): # There is no annotation, so there is nothing to validate. continue # Check both annotated parameters and classes with __cyclopts__ attribute _, cparam = Parameter.from_annotation(field_info.annotation) if cparam.parse is not None and not isinstance(cparam.parse, re.Pattern) and not cparam.parse: is_keyword_only = field_info.kind is field_info.KEYWORD_ONLY has_default = field_info.default is not field_info.empty if not (is_keyword_only or has_default): raise ValueError( "Parameter.parse=False must be used with either a KEYWORD_ONLY function parameter " "or a parameter with a default value." ) # Check for Parameter(name="*") without a default value when ALL class fields are optional # This is confusing for CLI users who expect the dataclass to be instantiated automatically if ( "*" in cparam.name # pyright: ignore[reportOperatorIssue] and field_info.default is field_info.empty ): # Get field info for the class to check if all fields have defaults annotated = field_info.annotation annotated = resolve(annotated) class_field_infos = get_field_infos(annotated) all_fields_optional = all(not field_info.required for field_info in class_field_infos.values()) if all_fields_optional: param_name = field_info.names[0] if field_info.names else "" quoted_param_name = f'"{param_name}" ' if param_name else "" raise ValueError( f'Parameter {quoted_param_name}in function {f} has all optional values, uses Parameter(name="*"), but itself has no default value. ' "Consider either:\n" f' 1) If immutable, providing a default value "{param_name}: {field_info.annotation.__name__} = {field_info.annotation.__name__}()"\n' f' 2) Otherwise, declaring it optional like "{param_name}: {field_info.annotation.__name__} | None = None" and instanting the {param_name} object in the function body:\n' f" if {param_name} is None:\n" f" {param_name} = {field_info.annotation.__name__}()" ) def get_parameters(hint: T, skip_converter_params: bool = False) -> tuple[T, list[Parameter]]: """At root level, checks for cyclopts.Parameter annotations. Includes checking the ``__cyclopts__`` attribute on both the type and any converter functions. Parameters ---------- hint Type hint to extract parameters from. skip_converter_params If True, skip extracting parameters from converter's __cyclopts__. Used to prevent infinite recursion in token_count. Returns ------- hint Annotation hint with :obj:`Annotated` and :obj:`Optional` resolved. list[Parameter] List of parameters discovered, ordered by priority (lowest to highest): converter-decoration < type-decoration < annotation. """ hint = resolve_optional(hint) # Extract parameters from Annotated metadata annotated_params = [] if is_annotated(hint): inner = get_args(hint) hint = inner[0] annotated_params.extend(x for x in inner[1:] if isinstance(x, Parameter)) # Resolve Optional again after unwrapping Annotated, since hint could be Type | None hint = resolve_optional(hint) # Extract parameters from type's __cyclopts__ attribute (after unwrapping Annotated) type_cyclopts_config_params = [] if cyclopts_config := getattr(hint, "__cyclopts__", None): type_cyclopts_config_params.extend(cyclopts_config.parameters) # Check if any parameter has a converter with __cyclopts__ and extract its parameters converter_params = [] if not skip_converter_params: for param in annotated_params + type_cyclopts_config_params: if param.converter: converter = param.converter # Resolve string converters to methods on the type if isinstance(converter, str): converter = getattr(hint, converter) # Check for __cyclopts__ on the converter if hasattr(converter, "__cyclopts__"): converter_params.extend(converter.__cyclopts__.parameters) break # For bound methods from classmethods/staticmethods, access the descriptor via __self__ elif ( hasattr(converter, "__self__") and hasattr(converter, "__name__") and hasattr(converter.__self__, "__dict__") ): # Get the descriptor from the class's __dict__ descriptor = converter.__self__.__dict__.get(converter.__name__) if descriptor and hasattr(descriptor, "__cyclopts__"): converter_params.extend(descriptor.__cyclopts__.parameters) break # Return parameters in priority order (lowest to highest) # This allows Parameter.combine() to correctly prioritize later parameters parameters = converter_params + type_cyclopts_config_params + annotated_params return hint, parameters @define class CycloptsConfig: """ Intended for storing additional data to a ``__cyclopts__`` attribute via decoration. """ obj: Any = None parameters: list[Parameter] = field(factory=list, init=False) BrianPugh-cyclopts-921b1fa/cyclopts/protocols.py000066400000000000000000000003741517576204000221070ustar00rootroot00000000000000import inspect from collections.abc import Callable from typing import Any, Protocol class Dispatcher(Protocol): def __call__( self, command: Callable[..., Any], bound: inspect.BoundArguments, ignored: dict[str, Any], / ) -> Any: ... BrianPugh-cyclopts-921b1fa/cyclopts/py.typed000066400000000000000000000000001517576204000211720ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/sphinx_ext.py000066400000000000000000000014621517576204000222530ustar00rootroot00000000000000"""Backward compatibility wrapper for Sphinx extension. This module maintains backward compatibility for users who have ``cyclopts.sphinx_ext`` in their Sphinx conf.py files. The actual implementation is in :mod:`cyclopts.ext.sphinx`. .. deprecated:: 4.0 Use :mod:`cyclopts.ext.sphinx` instead. This backward-compatibility location will be removed in v5. """ import warnings warnings.warn( "Importing from 'cyclopts.sphinx_ext' is deprecated. " "Please update your Sphinx conf.py to use 'cyclopts.ext.sphinx' instead. " "This compatibility shim will be removed in Cyclopts v5.", DeprecationWarning, stacklevel=2, ) from cyclopts.ext.sphinx import ( CycloptsDirective, DirectiveOptions, setup, ) __all__ = [ "CycloptsDirective", "DirectiveOptions", "setup", ] BrianPugh-cyclopts-921b1fa/cyclopts/token.py000066400000000000000000000013561517576204000212040ustar00rootroot00000000000000from typing import Any from attrs import evolve, field from cyclopts.utils import UNSET, frozen @frozen(kw_only=True) class Token: """Tracks how a user supplied a value to the application.""" keyword: str | None = None value: str = "" source: str = "" index: int = field(default=0, kw_only=True) keys: tuple[str, ...] = field(default=(), kw_only=True) implicit_value: Any = field(default=UNSET, kw_only=True) @property def address(self) -> tuple[tuple[str, ...], int]: """Hashable subkey destination address for this token.""" return (self.keys, self.index) def evolve(self, **kwargs) -> "Token": # TODO: replace return-hint with Self cp311 return evolve(self, **kwargs) BrianPugh-cyclopts-921b1fa/cyclopts/types.py000066400000000000000000000357201517576204000212320ustar00rootroot00000000000000import json import sys from collections.abc import Sequence from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Annotated, Any from cyclopts import validators from cyclopts.parameter import Parameter if TYPE_CHECKING: from cyclopts._path_type import StdioPath as StdioPath from cyclopts.token import Token __all__ = [ # Path "StdioPath", "ExistingPath", "NonExistentPath", "ExistingFile", "NonExistentFile", "ExistingDirectory", "NonExistentDirectory", "Directory", "File", "ResolvedExistingPath", "ResolvedExistingFile", "ResolvedExistingDirectory", "ResolvedDirectory", "ResolvedFile", "ResolvedPath", # Path with extensions "BinPath", "ExistingBinPath", "NonExistentBinPath", "CsvPath", "ExistingCsvPath", "NonExistentCsvPath", "ImagePath", "ExistingImagePath", "NonExistentImagePath", "JsonPath", "ExistingJsonPath", "NonExistentJsonPath", "Mp4Path", "ExistingMp4Path", "NonExistentMp4Path", "TomlPath", "ExistingTomlPath", "NonExistentTomlPath", "TxtPath", "ExistingTxtPath", "NonExistentTxtPath", "YamlPath", "ExistingYamlPath", "NonExistentYamlPath", # Number "PositiveFloat", "NonNegativeFloat", "NegativeFloat", "NonPositiveFloat", "PositiveInt", "NonNegativeInt", "NegativeInt", "NonPositiveInt", "UInt8", "Int8", "UInt16", "Int16", "UInt32", "Int32", "UInt64", "Int64", "HexUInt", "HexUInt8", "HexUInt16", "HexUInt32", "HexUInt64", "NormFloat", "SignedNormFloat", "PercentInt", # Json, "Json", # Web "Email", "Port", "URL", ] ######## # Path # ######## def _path_resolve_converter(type_, tokens: Sequence["Token"]): assert len(tokens) == 1 return type_(tokens[0].value).resolve() ExistingPath = Annotated[Path, Parameter(validator=validators.Path(exists=True))] "A :class:`~pathlib.Path` file or directory that **must** exist." NonExistentPath = Annotated[Path, Parameter(validator=validators.Path(file_okay=False, dir_okay=False))] "A :class:`~pathlib.Path` file or directory that **must not** exist." ResolvedPath = Annotated[Path, Parameter(converter=_path_resolve_converter)] "A :class:`~pathlib.Path` file or directory. :meth:`~pathlib.Path.resolve` is invoked prior to returning the path." ResolvedExistingPath = Annotated[ExistingPath, Parameter(converter=_path_resolve_converter)] "A :class:`~pathlib.Path` file or directory that **must** exist. :meth:`~pathlib.Path.resolve` is invoked prior to returning the path." Directory = Annotated[Path, Parameter(validator=validators.Path(file_okay=False))] "A :class:`~pathlib.Path` that **must** be a directory (or not exist)." ExistingDirectory = Annotated[Path, Parameter(validator=validators.Path(exists=True, file_okay=False))] "A :class:`~pathlib.Path` directory that **must** exist." NonExistentDirectory = Annotated[Path, Parameter(validator=validators.Path(file_okay=False, dir_okay=False))] "A :class:`~pathlib.Path` directory that **must not** exist." ResolvedDirectory = Annotated[Directory, Parameter(converter=_path_resolve_converter)] "A :class:`~pathlib.Path` directory. :meth:`~pathlib.Path.resolve` is invoked prior to returning the path." ResolvedExistingDirectory = Annotated[ExistingDirectory, Parameter(converter=_path_resolve_converter)] "A :class:`~pathlib.Path` directory that **must** exist. :meth:`~pathlib.Path.resolve` is invoked prior to returning the path." File = Annotated[Path, Parameter(validator=validators.Path(dir_okay=False))] "A :class:`~pathlib.File` that **must** be a file (or not exist)." ExistingFile = Annotated[Path, Parameter(validator=validators.Path(exists=True, dir_okay=False))] "A :class:`~pathlib.Path` file that **must** exist." NonExistentFile = Annotated[Path, Parameter(validator=validators.Path(file_okay=False, dir_okay=False))] "A :class:`~pathlib.Path` file that **must not** exist." ResolvedFile = Annotated[File, Parameter(converter=_path_resolve_converter)] "A :class:`~pathlib.Path` file. :meth:`~pathlib.Path.resolve` is invoked prior to returning the path." ResolvedExistingFile = Annotated[ExistingFile, Parameter(converter=_path_resolve_converter)] "A :class:`~pathlib.Path` file that **must** exist. :meth:`~pathlib.Path.resolve` is invoked prior to returning the path." # Common path extensions BinPath = Annotated[Path, Parameter(validator=validators.Path(ext="bin", dir_okay=False))] "A :class:`~pathlib.Path` that **must** have extension ``bin``." ExistingBinPath = Annotated[Path, Parameter(validator=validators.Path(ext="bin", exists=True, dir_okay=False))] "A :class:`~pathlib.Path` that **must** exist and have extension ``bin``." NonExistentBinPath = Annotated[Path, Parameter(validator=validators.Path(ext="bin", file_okay=False, dir_okay=False))] "A :class:`~pathlib.Path` that **must not** exist and have extension ``bin``." CsvPath = Annotated[Path, Parameter(validator=validators.Path(ext="csv", dir_okay=False))] "A :class:`~pathlib.Path` that **must** have extension ``csv``." ExistingCsvPath = Annotated[Path, Parameter(validator=validators.Path(ext="csv", exists=True, dir_okay=False))] "A :class:`~pathlib.Path` that **must** exist and have extension ``csv``." NonExistentCsvPath = Annotated[Path, Parameter(validator=validators.Path(ext="csv", file_okay=False, dir_okay=False))] "A :class:`~pathlib.Path` that **must not** exist and have extension ``csv``." TxtPath = Annotated[Path, Parameter(validator=validators.Path(ext="txt", dir_okay=False))] "A :class:`~pathlib.Path` that **must** have extension ``txt``." ExistingTxtPath = Annotated[Path, Parameter(validator=validators.Path(ext="txt", exists=True, dir_okay=False))] "A :class:`~pathlib.Path` that **must** exist and have extension ``txt``." NonExistentTxtPath = Annotated[Path, Parameter(validator=validators.Path(ext="txt", file_okay=False, dir_okay=False))] "A :class:`~pathlib.Path` that **must not** exist and have extension ``txt``." ImagePath = Annotated[Path, Parameter(validator=validators.Path(ext=("png", "jpg", "jpeg"), dir_okay=False))] "A :class:`~pathlib.Path` that **must** have extension in {``png``, ``jpg``, ``jpeg``}." ExistingImagePath = Annotated[ Path, Parameter(validator=validators.Path(ext=("png", "jpg", "jpeg"), exists=True, dir_okay=False)) ] "A :class:`~pathlib.Path` that **must** exist and have extension in {``png``, ``jpg``, ``jpeg``}." NonExistentImagePath = Annotated[ Path, Parameter(validator=validators.Path(ext=("png", "jpg", "jpeg"), file_okay=False, dir_okay=False)) ] "A :class:`~pathlib.Path` that **must not** exist and have extension in {``png``, ``jpg``, ``jpeg``}." Mp4Path = Annotated[Path, Parameter(validator=validators.Path(ext="mp4", dir_okay=False))] "A :class:`~pathlib.Path` that **must** have extension ``mp4``." ExistingMp4Path = Annotated[Path, Parameter(validator=validators.Path(ext="mp4", exists=True, dir_okay=False))] "A :class:`~pathlib.Path` that **must** exist and have extension ``mp4``." NonExistentMp4Path = Annotated[Path, Parameter(validator=validators.Path(ext="mp4", file_okay=False, dir_okay=False))] "A :class:`~pathlib.Path` that **must not** exist and have extension ``mp4``." JsonPath = Annotated[Path, Parameter(validator=validators.Path(ext="json", dir_okay=False))] "A :class:`~pathlib.Path` that **must** have extension ``json``." ExistingJsonPath = Annotated[Path, Parameter(validator=validators.Path(ext="json", exists=True, dir_okay=False))] "A :class:`~pathlib.Path` that **must** exist and have extension ``json``." NonExistentJsonPath = Annotated[Path, Parameter(validator=validators.Path(ext="json", file_okay=False, dir_okay=False))] "A :class:`~pathlib.Path` that **must not** exist and have extension ``json``." TomlPath = Annotated[Path, Parameter(validator=validators.Path(ext="toml", dir_okay=False))] "A :class:`~pathlib.Path` that **must** have extension ``toml``." ExistingTomlPath = Annotated[Path, Parameter(validator=validators.Path(ext="toml", exists=True, dir_okay=False))] "A :class:`~pathlib.Path` that **must** exist and have extension ``toml``." NonExistentTomlPath = Annotated[Path, Parameter(validator=validators.Path(ext="toml", file_okay=False, dir_okay=False))] "A :class:`~pathlib.Path` that **must not** exist and have extension ``toml``." YamlPath = Annotated[Path, Parameter(validator=validators.Path(ext="yaml", dir_okay=False))] "A :class:`~pathlib.Path` that **must** have extension ``yaml``." ExistingYamlPath = Annotated[Path, Parameter(validator=validators.Path(ext="yaml", exists=True, dir_okay=False))] "A :class:`~pathlib.Path` that **must** exist and have extension ``yaml``." NonExistentYamlPath = Annotated[Path, Parameter(validator=validators.Path(ext="yaml", file_okay=False, dir_okay=False))] "A :class:`~pathlib.Path` that **must not** exist and have extension ``yaml``." ########## # Number # ########## # foo PositiveFloat = Annotated[float, Parameter(validator=validators.Number(gt=0))] "A float that **must** be ``>0``." NonNegativeFloat = Annotated[float, Parameter(validator=validators.Number(gte=0))] "A float that **must** be ``>=0``." NegativeFloat = Annotated[float, Parameter(validator=validators.Number(lt=0))] "A float that **must** be ``<0``." NonPositiveFloat = Annotated[float, Parameter(validator=validators.Number(lte=0))] "A float that **must** be ``<=0``." PositiveInt = Annotated[int, Parameter(validator=validators.Number(gt=0))] "An int that **must** be ``>0``." NonNegativeInt = Annotated[int, Parameter(validator=validators.Number(gte=0))] "An int that **must** be ``>=0``." NegativeInt = Annotated[int, Parameter(validator=validators.Number(lt=0))] "An int that **must** be ``<0``." NonPositiveInt = Annotated[int, Parameter(validator=validators.Number(lte=0))] "An int that **must** be ``<=0``." UInt8 = Annotated[int, Parameter(validator=validators.Number(gte=0, lte=255))] "An unsigned 8-bit integer." Int8 = Annotated[int, Parameter(validator=validators.Number(gte=-128, lte=127))] "A signed 8-bit integer." UInt16 = Annotated[int, Parameter(validator=validators.Number(gte=0, lte=65535))] "An unsigned 16-bit integer." Int16 = Annotated[int, Parameter(validator=validators.Number(gte=-32768, lte=32767))] "A signed 16-bit integer." UInt32 = Annotated[int, Parameter(validator=validators.Number(gte=0, lt=1 << 32))] "An unsigned 32-bit integer." Int32 = Annotated[int, Parameter(validator=validators.Number(gte=(-1 << 31), lt=(1 << 31)))] "A signed 32-bit integer." UInt64 = Annotated[int, Parameter(validator=validators.Number(gte=0, lt=1 << 64))] "An unsigned 64-bit integer." Int64 = Annotated[int, Parameter(validator=validators.Number(gte=(-1 << 63), lt=(1 << 63)))] "A signed 64-bit integer." def _hex_formatter(value: int, digits=0) -> str: return f"0x{value:X}" if digits <= 0 else f"0x{value:0{digits}X}" HexUInt = Annotated[NonNegativeInt, Parameter(show_default=_hex_formatter)] "A non-negative integer who's default value will be displayed as hexadecimal in the help-page." HexUInt8 = Annotated[UInt8, Parameter(show_default=partial(_hex_formatter, digits=2))] "An unsigned 8-bit integer who's default value will be displayed as hexadecimal in the help-page." HexUInt16 = Annotated[UInt16, Parameter(show_default=partial(_hex_formatter, digits=4))] "An unsigned 16-bit integer who's default value will be displayed as hexadecimal in the help-page." HexUInt32 = Annotated[UInt32, Parameter(show_default=partial(_hex_formatter, digits=8))] "An unsigned 32-bit integer who's default value will be displayed as hexadecimal in the help-page." HexUInt64 = Annotated[UInt64, Parameter(show_default=partial(_hex_formatter, digits=16))] "An unsigned 64-bit integer who's default value will be displayed as hexadecimal in the help-page." NormFloat = Annotated[float, Parameter(validator=validators.Number(gte=0, lte=1))] "A float in the range ``[0, 1]``." SignedNormFloat = Annotated[float, Parameter(validator=validators.Number(gte=-1, lte=1))] "A float in the range ``[-1, 1]``." PercentInt = Annotated[int, Parameter(validator=validators.Number(gte=0, lte=100))] "An int in the range ``[0, 100]``." ######## # Json # ######## def _json_converter(type_, tokens: Sequence["Token"]): assert len(tokens) == 1 out = json.loads(tokens[0].value) return out Json = Annotated[Any, Parameter(converter=_json_converter)] """ Parse a json-string from the CLI. Note: Since Cyclopts v3.6.0, all dataclass-like classes now natively attempt to parse json-strings, so practical use-case of this annotation is limited. Usage example: .. code-block:: python from cyclopts import App, types app = App() @app.default def main(json: types.Json): print(json) app() .. code-block:: console $ my-script '{"foo": 1, "bar": 2}' {'foo': 1, 'bar': 2} """ ####### # Web # ####### def _email_validator(type_: Any, value: Any): """Simplified email validation; probably good enough for CLI usage.""" if not isinstance(value, str): return if _email_validator.regex is None: # pyright: ignore[reportFunctionMemberAccess] import re _email_validator.regex = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") # pyright: ignore[reportFunctionMemberAccess] if not _email_validator.regex.match(value): # pyright: ignore[reportFunctionMemberAccess] raise ValueError(f"Invalid email: {value}") _email_validator.regex = None # pyright: ignore[reportFunctionMemberAccess] Email = Annotated[str, Parameter(validator=_email_validator)] "An email address string with simple validation." def _url_validator(type_: Any, value: Any): """Simplified URL validation; probably good enough for CLI usage.""" if not isinstance(value, str): return if _url_validator.regex is None: # pyright: ignore[reportFunctionMemberAccess] import re _url_validator.regex = re.compile( # pyright: ignore[reportFunctionMemberAccess] r"^(?:(?:https?|ftp):\/\/)?" # protocol r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|" # domain r"localhost|" # localhost r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})" # IP r"(?::\d+)?" # port r"(?:\/\S*)?$", # path, query string, fragment re.IGNORECASE, ) if not _url_validator.regex.match(value): # pyright: ignore[reportFunctionMemberAccess] raise ValueError(f"Invalid URL: {value}") _url_validator.regex = None # pyright: ignore[reportFunctionMemberAccess] URL = Annotated[str, Parameter(validator=_url_validator)] "A :class:`str` URL string with some simple validation." Port = Annotated[int, Parameter(validator=validators.Number(gte=0, lte=65535))] "An :class:`int` limited to range ``[0, 65535]``." def __getattr__(name: str): if name == "StdioPath": if sys.version_info < (3, 12): raise ImportError("StdioPath requires Python 3.12+ (Path subclassing support)") from cyclopts._path_type import StdioPath return StdioPath raise AttributeError(f"module {__name__!r} has no attribute {name!r}") BrianPugh-cyclopts-921b1fa/cyclopts/utils.py000066400000000000000000000406351517576204000212270ustar00rootroot00000000000000"""To prevent circular dependencies, this module should never import anything else from Cyclopts.""" import functools import importlib import inspect import re from collections.abc import Callable, Iterable, Iterator, Sequence from contextlib import suppress from operator import itemgetter from typing import TYPE_CHECKING, Any, Literal, TypeVar from attrs import field, frozen T = TypeVar("T") # https://threeofwands.com/attra-iv-zero-overhead-frozen-attrs-classes/ if TYPE_CHECKING: from json import JSONDecodeError from attrs import frozen from rich.console import Console from cyclopts.help.protocols import HelpFormatter else: from attrs import define frozen = functools.partial(define, unsafe_hash=True) from sys import stdlib_module_names class SentinelMeta(type): def __repr__(cls) -> str: return f"<{cls.__name__}>" def __bool__(cls) -> Literal[False]: return False class Sentinel(metaclass=SentinelMeta): def __new__(cls): raise ValueError("Sentinel objects are not intended to be instantiated. Subclass instead.") class UNSET(Sentinel): """Special sentinel value indicating that no data was provided. **Do not instantiate**.""" def record_init(target: str) -> Callable[[type[T]], type[T]]: """Class decorator that records init argument names as a tuple to ``target``.""" def decorator(cls: type[T]) -> type[T]: original_init = cls.__init__ function_signature = inspect.signature(original_init) param_names = tuple(name for name in function_signature.parameters if name != "self") @functools.wraps(original_init) def new_init(self, *args, **kwargs): original_init(self, *args, **kwargs) # Circumvent frozen protection. object.__setattr__(self, target, tuple(param_names[i] for i in range(len(args))) + tuple(kwargs)) cls.__init__ = new_init return cls return decorator def is_iterable(obj) -> bool: if isinstance(obj, list | tuple | set | dict): # Fast path for common types return True return not isinstance(obj, str) and isinstance(obj, Iterable) def is_class_and_subclass(hint, target_class) -> bool: """Safely check if a type is both a class and a subclass of target_class. Parameters ---------- hint : Any The type to check. target_class : type The target class to check subclass relationship against. Returns ------- bool True if hint is a class and is a subclass of target_class, False otherwise. """ try: return inspect.isclass(hint) and issubclass(hint, target_class) except TypeError: # issubclass() raises TypeError for non-class arguments like Union types return False def to_tuple_converter(value: None | Any | Iterable[Any]) -> tuple[Any, ...]: """Convert a single element or an iterable of elements into a tuple. Intended to be used in an ``attrs.Field``. If :obj:`None` is provided, returns an empty tuple. If a single element is provided, returns a tuple containing just that element. If an iterable is provided, converts it into a tuple. Parameters ---------- value: Any | Iterable[Any] | None An element, an iterable of elements, or None. Returns ------- tuple[Any, ...]: A tuple containing the elements. """ if value is None: return () elif is_iterable(value): return tuple(value) else: return (value,) def to_list_converter(value: None | Any | Iterable[Any]) -> list[Any]: return list(to_tuple_converter(value)) def optional_to_tuple_converter(value: None | Any | Iterable[Any]) -> tuple[Any, ...] | None: """Convert a string or Iterable or None into an Iterable or None. Intended to be used in an ``attrs.Field``. """ if value is None: return None if not value: return () return to_tuple_converter(value) def sort_key_converter(value: Any) -> Any: """Convert sort_key value, consuming generators with :func:`next`. Parameters ---------- value : Any The sort_key value to convert. Can be None, a generator, or any other value. Returns ------- Any UNSET if value is None, ``next(value)`` if generator, otherwise value unchanged. """ if value is None: return UNSET elif inspect.isgenerator(value): return next(value) else: return value def help_formatter_converter( input_value: "None | Literal['default', 'plain'] | HelpFormatter", ) -> "HelpFormatter | None": """Convert string literals to help formatter instances. Parameters ---------- input_value : None | Literal["default", "plain"] | Any The input value to convert. Can be None, "default", "plain", or a formatter instance. Returns ------- Any | None None, or a HelpFormatter instance. Notes ----- Lazily imports formatters to avoid importing Rich during normal execution. """ if input_value is None: return None elif isinstance(input_value, str): if input_value == "default": from cyclopts.help.formatters import DefaultFormatter return DefaultFormatter() elif input_value == "plain": from cyclopts.help.formatters import PlainFormatter return PlainFormatter() else: raise ValueError(f"Unknown formatter: {input_value!r}. Must be 'default' or 'plain'") else: # Assume it's already a HelpFormatter instance return input_value def _pascal_to_snake(s: str) -> str: # (Borrowed from pydantic) # Handle the sequence of uppercase letters followed by a lowercase letter snake = re.sub(r"([A-Z]+)([A-Z][a-z])", lambda m: f"{m.group(1)}_{m.group(2)}", s) # Insert an underscore between a lowercase letter and an uppercase letter snake = re.sub(r"([a-z])([A-Z])", lambda m: f"{m.group(1)}_{m.group(2)}", snake) # Insert an underscore between a digit and an uppercase letter snake = re.sub(r"([0-9])([A-Z])", lambda m: f"{m.group(1)}_{m.group(2)}", snake) return snake.lower() def default_name_transform(s: str) -> str: """Converts a python identifier into a CLI token. Performs the following operations (in order): 1. Convert PascalCase to snake_case. 2. Convert the string to all lowercase. 3. Replace ``_`` with ``-``. 4. Strip any leading/trailing ``-`` (also stripping ``_``, due to point 3). Intended to be used with :attr:`App.name_transform` and :attr:`Parameter.name_transform`. Parameters ---------- s: str Input python identifier string. Returns ------- str Transformed name. """ return _pascal_to_snake(s).lower().replace("_", "-").strip("-") def grouper(iterable: Sequence[Any], n: int) -> Iterator[tuple[Any, ...]]: """Collect data into non-overlapping fixed-length chunks or blocks. https://docs.python.org/3/library/itertools.html#itertools-recipes Parameters ---------- iterable: Sequence[Any] Some iterable sequence to group. n: int Number of elements to put in each group. """ if len(iterable) % n: raise ValueError(f"{iterable!r} is not divisible by {n}.") iterators = [iter(iterable)] * n return zip(*iterators, strict=False) def is_option_like(token: str, *, allow_numbers=False) -> bool: """Checks if a token looks like an option. Namely, negative numbers are not options, but a token like ``--foo`` is. Parameters ---------- token: str String to interpret. allow_numbers: bool If :obj:`True`, then negative numbers (e.g. ``"-2"``) will return :obj:`True`. Otherwise, numbers will be interpreted as non-option-like (:obj:`False`). Note: ``-j`` **is option-like**, even though it can represent an imaginary number. Returns ------- bool Whether or not the ``token`` is option-like. """ if not allow_numbers: with suppress(ValueError): complex(token) if token.lower() == "-j": # ``complex("-j")`` is a valid imaginary number, but more than likely # the caller meant it as a short flag. # https://github.com/BrianPugh/cyclopts/issues/328 return True return False return token.startswith("-") def is_builtin(obj: Any) -> bool: return getattr(obj, "__module__", "").split(".")[0] in stdlib_module_names def resolve_callables(t, *args, **kwargs): """Recursively resolves callable elements in a tuple. Returns an object that "looks like" the input, but with all callable's invoked and replaced with their return values. Positional and keyword elements will be passed along to each invocation. """ if isinstance(t, type(Sentinel)): return t if callable(t): return t(*args, **kwargs) elif is_iterable(t): resolved = [] for element in t: if isinstance(element, type(Sentinel)): resolved.append(element) elif callable(element): resolved.append(element(*args, **kwargs)) elif is_iterable(element): resolved.append(resolve_callables(element, *args, **kwargs)) else: resolved.append(element) return tuple(resolved) else: return t @frozen class SortHelper: """Sort a list of objects by an external key and retrieve the objects in-order.""" key: Any """Primary key to sort by. SortHelpers with ``key`` :obj:`None` or :obj:`.UNSET` go last (alphabetically). """ fallback_key: Any = field(converter=to_tuple_converter) """Secondary key to sort by. """ value: Any """Actual object that caller wants to retrieve in the sorted order.""" @staticmethod def sort(entries: Sequence["SortHelper"]) -> list["SortHelper"]: """Sorts a sequence of :class:`SortHelper`.""" from cyclopts.group import ( DEFAULT_ARGUMENTS_GROUP_SORT_MARKER, DEFAULT_COMMANDS_GROUP_SORT_MARKER, DEFAULT_PARAMETERS_GROUP_SORT_MARKER, ) default_commands_group = [] default_arguments_group = [] default_parameters_group = [] user_sort_key = [] ordered_no_user_sort_key = [] no_user_sort_key = [] for entry in entries: if entry.key is DEFAULT_COMMANDS_GROUP_SORT_MARKER: default_commands_group.append((None, entry)) elif entry.key is DEFAULT_ARGUMENTS_GROUP_SORT_MARKER: default_arguments_group.append((None, entry)) elif entry.key is DEFAULT_PARAMETERS_GROUP_SORT_MARKER: default_parameters_group.append((None, entry)) elif entry.key in (UNSET, None): no_user_sort_key.append((entry.fallback_key, entry)) elif is_iterable(entry.key) and entry.key[0] in (UNSET, None): # Items that are ordered internal to Cyclopts, but have lower order than user-provided sort_keys. # Primarily to handle :meth:`Group.create_ordered`. ordered_no_user_sort_key.append((entry.key[1:] + entry.fallback_key, entry)) else: user_sort_key.append(((entry.key, entry.fallback_key), entry)) user_sort_key.sort(key=itemgetter(0)) ordered_no_user_sort_key.sort(key=itemgetter(0)) no_user_sort_key.sort(key=itemgetter(0)) combined = ( default_commands_group + default_arguments_group + default_parameters_group + user_sort_key + ordered_no_user_sort_key + no_user_sort_key ) return [x[1] for x in combined] def json_decode_error_verbosifier(decode_error: "JSONDecodeError", context: int = 20) -> str: """Not intended to be a super robust implementation, but robust enough to be helpful. Parameters ---------- context: int Number of surrounding-character context """ lines = decode_error.doc.splitlines() line = lines[decode_error.lineno - 1] error_index = decode_error.colno - 1 # colno is 1-indexed start = error_index - context if start <= 0: start = 0 prefix_ellipsis = "" segment_error_index = error_index else: prefix_ellipsis = "... " segment_error_index = error_index - start end = error_index + context if end >= len(line): end = len(line) + 1 suffix_ellipsis = "" else: suffix_ellipsis = " ..." segment = line[start:end] carat_pointer = " " * (len(prefix_ellipsis) + segment_error_index) + "^" response = ( f"JSONDecodeError:\n {prefix_ellipsis}{segment}{suffix_ellipsis}\n {carat_pointer}\n{str(decode_error)}" ) return response def create_error_console_from_console(console: "Console") -> "Console": """Create an error console (stderr=True) that inherits settings from a source console. Parameters ---------- console : Console Source Rich Console to copy settings from. Returns ------- Console New Rich Console with stderr=True and inherited settings. """ from rich.console import Console color_system = console.color_system or "auto" return Console( stderr=True, color_system=color_system, # type: ignore[arg-type] force_terminal=getattr(console, "_force_terminal", None), force_jupyter=console.is_jupyter or None, force_interactive=console.is_interactive or None, soft_wrap=console.soft_wrap, width=console._width, height=getattr(console, "_height", None), tab_size=console.tab_size, markup=getattr(console, "_markup", True), emoji=getattr(console, "_emoji", True), emoji_variant=getattr(console, "_emoji_variant", None), highlight=getattr(console, "_highlight", True), no_color=console.no_color, legacy_windows=console.legacy_windows, safe_box=console.safe_box, _environ=getattr(console, "_environ", None), get_datetime=getattr(console, "get_datetime", None), get_time=getattr(console, "get_time", None), ) def parse_version(version_string: str) -> tuple[int, ...]: """Parse a PEP 440 version string into a tuple of ints, stripping pre-release suffixes. Parameters ---------- version_string: str A version string like ``"2.11.2"`` or ``"2.0.0b2"``. Returns ------- tuple[int, ...] Tuple of the numeric components, e.g. ``(2, 11, 2)`` or ``(2, 0, 0)``. """ return tuple(int(m.group()) for x in version_string.split(".") if (m := re.match(r"\d+", x))) def import_app(module_path: str): """Import a Cyclopts App from a module path. Parameters ---------- module_path : str Module path in format "module.name" or "module.name:app_name". If ":app_name" is omitted, auto-discovers by searching for common names (app, cli, main) or any public App instance. Returns ------- App The imported Cyclopts App instance. Raises ------ ImportError If the module cannot be imported. AttributeError If the specified app name doesn't exist or no App is found. TypeError If the specified attribute is not a Cyclopts App instance. """ from cyclopts import App if ":" in module_path: module_name, app_name = module_path.rsplit(":", 1) else: module_name, app_name = module_path, None try: module = importlib.import_module(module_name) except ImportError as e: raise ImportError(f"Cannot import module '{module_name}': {e}") from e if app_name: if not hasattr(module, app_name): raise AttributeError(f"Module '{module_name}' has no attribute '{app_name}'") app = getattr(module, app_name) if not isinstance(app, App): raise TypeError(f"'{app_name}' is not a Cyclopts App instance") return app # Auto-discovery: search for App instance for name in ["app", "cli", "main"]: obj = getattr(module, name, None) if isinstance(obj, App): return obj # Search all public attributes for name in dir(module): if not name.startswith("_"): obj = getattr(module, name) if isinstance(obj, App): return obj raise AttributeError(f"No Cyclopts App found in '{module_name}'. Specify explicitly: '{module_name}:app_name'") BrianPugh-cyclopts-921b1fa/cyclopts/validators/000077500000000000000000000000001517576204000216555ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/cyclopts/validators/__init__.py000066400000000000000000000005071517576204000237700ustar00rootroot00000000000000__all__ = [ "all_or_none", "LimitedChoice", "MutuallyExclusive", "mutually_exclusive", "Number", "Path", ] from cyclopts.validators._group import LimitedChoice, MutuallyExclusive, all_or_none, mutually_exclusive from cyclopts.validators._number import Number from cyclopts.validators._path import Path BrianPugh-cyclopts-921b1fa/cyclopts/validators/_group.py000066400000000000000000000060021517576204000235200ustar00rootroot00000000000000from typing import TYPE_CHECKING if TYPE_CHECKING: from cyclopts.argument import ArgumentCollection class LimitedChoice: def __init__( self, min: int = 0, max: int | None = None, allow_none: bool = False, ): """Group validator that limits the number of selections per group. Commonly used for enforcing mutually-exclusive parameters (default behavior). Parameters ---------- min: int The minimum (inclusive) number of CLI parameters allowed. If negative, then **all** parameters in the group must have CLI values provided. max: int | None The maximum (inclusive) number of CLI parameters allowed. Defaults to ``1`` if ``min==0``, ``min`` otherwise. allow_none: bool If :obj:`True`, also allow 0 CLI parameters (even if ``min`` is greater than 0). Defaults to :obj:`False`. """ self.min = min self.max = (self.min or 1) if max is None else max if self.max < self.min: raise ValueError("max must be >=min.") self.allow_none = allow_none def __call__(self, argument_collection: "ArgumentCollection"): group_size = len(argument_collection) populated_argument_collection = argument_collection.filter_by(value_set=True) n_arguments = len(populated_argument_collection) if self.allow_none and n_arguments == 0: return elif self.min < 0: # Require all arguments in the group to be supplied. if group_size == n_arguments: return all_names = {a.name for a in argument_collection} supplied_names = {a.name for a in populated_argument_collection} missing_names = sorted(all_names - supplied_names) if len(missing_names) == 1: raise ValueError(f"Missing argument: {missing_names[0]}") else: raise ValueError(f"Missing arguments: {missing_names}") elif self.min <= n_arguments <= self.max: return else: offenders = ( "{" + ", ".join( a.tokens[0].keyword if (a.tokens and a.tokens[0].keyword) else a.name for a in populated_argument_collection ) + "}" ) if self.min == 0 and self.max == 1: raise ValueError(f"Mutually exclusive arguments: {offenders}") else: raise ValueError( f"Received {n_arguments} arguments: {offenders}. Only [{self.min}, {self.max}] choices may be specified." ) class MutuallyExclusive(LimitedChoice): def __init__(self): """Alias for :class:`LimitedChoice` to make intentions more obvious. Only 1 argument in the group can be supplied a value. """ super().__init__() mutually_exclusive = MutuallyExclusive() all_or_none = LimitedChoice(-1, allow_none=True) BrianPugh-cyclopts-921b1fa/cyclopts/validators/_number.py000066400000000000000000000057001517576204000236600ustar00rootroot00000000000000from collections.abc import Sequence from typing import Any from cyclopts.utils import frozen @frozen(kw_only=True) class Number: """Limit input number to a value range. Example Usage: .. code-block:: python from cyclopts import App, Parameter, validators from typing import Annotated app = App() @app.default def main(age: Annotated[int, Parameter(validator=validators.Number(gte=0, lte=150))]): print(f"You are {age} years old.") app() .. code-block:: console $ my-script 100 You are 100 years old. $ my-script -1 ╭─ Error ───────────────────────────────────────────────────────╮ │ Invalid value "-1" for "AGE". Must be >= 0. │ ╰───────────────────────────────────────────────────────────────╯ $ my-script 200 ╭─ Error ───────────────────────────────────────────────────────╮ │ Invalid value "200" for "AGE". Must be <= 150. │ ╰───────────────────────────────────────────────────────────────╯ """ lt: int | float | None = None """Input value must be **less than** this value.""" lte: int | float | None = None """Input value must be **less than or equal** this value.""" gt: int | float | None = None """Input value must be **greater than** this value.""" gte: int | float | None = None """Input value must be **greater than or equal** this value.""" modulo: int | float | None = None """Input value must be a multiple of this value.""" def __call__(self, type_: Any, value: Any): if isinstance(value, Sequence): if isinstance(value, str): raise TypeError for v in value: self(type_, v) else: if not isinstance(value, int | float): return if self.lt is not None and value >= self.lt: raise ValueError(f"Must be < {self.lt}.") if self.lte is not None and value > self.lte: raise ValueError(f"Must be <= {self.lte}.") if self.gt is not None and value <= self.gt: raise ValueError(f"Must be > {self.gt}.") if self.gte is not None and value < self.gte: raise ValueError(f"Must be >= {self.gte}.") if self.modulo is not None and value % self.modulo: raise ValueError(f"Must be a multiple of {self.modulo}.") BrianPugh-cyclopts-921b1fa/cyclopts/validators/_path.py000066400000000000000000000110521517576204000233210ustar00rootroot00000000000000import pathlib from collections.abc import Iterable, Sequence from typing import Any from attrs import field from cyclopts.utils import frozen, to_tuple_converter def ext_converter(value: str | Iterable[str] | None) -> tuple[str, ...]: return tuple(e.lower().lstrip(".") for e in to_tuple_converter(value)) @frozen(kw_only=True) class Path: """Assertions on properties of :class:`pathlib.Path`. Example Usage: .. code-block:: python from cyclopts import App, Parameter, validators from pathlib import Path from typing import Annotated app = App() @app.default def main( # ``src`` must be a file that exists. src: Annotated[Path, Parameter(validator=validators.Path(exists=True, dir_okay=False))], # ``dst`` must be a path that does **not** exist. dst: Annotated[Path, Parameter(validator=validators.Path(dir_okay=False, file_okay=False))], ): "Copies src->dst." dst.write_bytes(src.read_bytes()) app() .. code-block:: console $ my-script foo.bin bar.bin # if foo.bin does not exist ╭─ Error ───────────────────────────────────────────────────────╮ │ Invalid value "foo.bin" for "SRC". "foo.bin" does not exist. │ ╰───────────────────────────────────────────────────────────────╯ $ my-script foo.bin bar.bin # if bar.bin exists ╭─ Error ───────────────────────────────────────────────────────╮ │ Invalid value "bar.bin" for "DST". "bar.bin" already exists. │ ╰───────────────────────────────────────────────────────────────╯ """ exists: bool = False """If :obj:`True`, specified path **must** exist. Defaults to :obj:`False`.""" file_okay: bool = True """ If path exists, check it's type: * If :obj:`True`, specified path may be an **existing** file. * If :obj:`False`, then **existing** files are not allowed. Defaults to :obj:`True`. """ dir_okay: bool = True """ If path exists, check it's type: * If :obj:`True`, specified path may be an **existing** directory. * If :obj:`False`, then **existing** directories are not allowed. Defaults to :obj:`True`. """ # Can only ever really be a tuple[str, ...] ext: str | Sequence[str] = field(default=None, converter=ext_converter) """ Supplied path must have this extension (case insensitive). May or may not include the ".". """ def __attrs_post_init__(self): if self.exists and not self.file_okay and not self.dir_okay: raise ValueError("(exists=True, file_okay=False, dir_okay=False) is an invalid configuration.") def __call__(self, type_: Any, path: Any): if isinstance(path, Sequence): if isinstance(path, str): raise TypeError for p in path: self(type_, p) else: if not isinstance(path, pathlib.Path): return if self.ext and path.suffix.lower().lstrip(".") not in self.ext: if len(self.ext) == 1: raise ValueError(f'"{path}" must have extension "{self.ext[0]}".') else: pretty_ext = "{" + ", ".join(f'"{x}"' for x in self.ext) + "}" raise ValueError(f'"{path}" does not match one of supported extensions {pretty_ext}.') if path.exists(): if not self.file_okay and path.is_file(): if self.dir_okay: raise ValueError(f'Only directory is allowed, but "{path}" is a file.') else: raise ValueError(f'"{path}" already exists.') if not self.dir_okay and path.is_dir(): if self.file_okay: raise ValueError(f'Only file is allowed, but "{path}" is a directory.') else: raise ValueError(f'"{path}" already exists.') elif self.exists: raise ValueError(f'"{path}" does not exist.') BrianPugh-cyclopts-921b1fa/docs/000077500000000000000000000000001517576204000165755ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/Makefile000066400000000000000000000012101517576204000202270ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= -W SPHINXBUILD ?= uv run sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) BrianPugh-cyclopts-921b1fa/docs/make.bat000066400000000000000000000014441517576204000202050ustar00rootroot00000000000000@ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=source set BUILDDIR=build %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.https://www.sphinx-doc.org/ exit /b 1 ) if "%1" == "" goto help %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% :end popd BrianPugh-cyclopts-921b1fa/docs/source/000077500000000000000000000000001517576204000200755ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/Installation.rst000066400000000000000000000010031517576204000232620ustar00rootroot00000000000000.. _Detailed Installation: ============ Installation ============ Cyclopts requires Python >=3.10 and can be installed from PyPI via: .. code-block:: console python -m pip install cyclopts To install directly from github, you can run: .. code-block:: console python -m pip install git+https://github.com/BrianPugh/cyclopts.git For Cyclopts development, its recommended to use uv: .. code-block:: console git clone https://github.com/BrianPugh/cyclopts.git cd cyclopts uv sync --all-extras BrianPugh-cyclopts-921b1fa/docs/source/_static/000077500000000000000000000000001517576204000215235ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/_static/.gitkeep000066400000000000000000000000001517576204000231420ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/_static/custom.css000066400000000000000000000013631517576204000235520ustar00rootroot00000000000000/* Have contents take up entire browser window width. */ .wy-nav-content { max-width: none !important } .wy-side-nav-search .version { color: #000000 !important; } .wy-side-nav-search>div.switch-menus>div.version-switch select { color: #000000; } /* override table width restrictions */ .wy-table-responsive table td, .wy-table-responsive table th { white-space: normal !important; } .wy-table-responsive { overflow: visible !important; } .custom-code-block { border: 1px solid #CCCCCC; background-color: #F9F9F9; padding: 10px; font-family: monospace; overflow: auto; } /* https://github.com/readthedocs/sphinx_rtd_theme/issues/1301#issuecomment-1876120817 */ .py.property { display: block !important; } BrianPugh-cyclopts-921b1fa/docs/source/api.rst000066400000000000000000002635331517576204000214140ustar00rootroot00000000000000.. _API: === API === .. autoclass:: cyclopts.App :members: default, command, version_print, help_print, interactive_shell, parse_commands, parse_known_args, parse_args, run_async, assemble_argument_collection, update, generate_docs, generate_completion, install_completion, register_install_completion_command :special-members: __call__, __getitem__, __iter__ Cyclopts Application. .. attribute:: name :type: Optional[Union[str, Iterable[str]]] :value: None Name of application, or subcommand if registering to another application. Name resolution order: 1. User specified :attr:`~.App.name` parameter. 2. If a :attr:`~.App.default` function has been registered, the name of that function. 3. If the module name is ``__main__.py``, the name of the encompassing package. 4. The value of :data:`sys.argv[0] `; i.e. the name of the python script. Multiple names can be provided in the case of a subcommand, but this is relatively unusual. Special value ``"*"`` can be used when registering sub-apps with :meth:`~.App.command` to flatten all commands from the sub-app into the parent app. See :ref:`Flattening SubCommands` for details. Example: .. code-block:: python from cyclopts import App app = App() app.command(App(name="foo")) @app["foo"].command def bar(): print("Running bar.") app() .. code-block:: console $ my-script foo bar Running bar. .. attribute:: alias :type: Optional[Union[str, Iterable[str]]] :value: None Extends :attr:`.name` with additional names. Unlike :attr:`.name`, this does not override Cyclopts-derived names. .. code-block:: python from cyclopts import App app = App() @app.command(alias="bar") def foo(): print("Running foo.") app() .. code-block:: console $ my-script foo Running bar. $ my-script bar Running bar. .. attribute:: help :type: Optional[str] :value: None Text to display on help screen. If not supplied, fallbacks to parsing the docstring of function registered with :meth:`.App.default`. .. code-block:: python from cyclopts import App app = App(help="This is my help string.") app() .. code-block:: console $ my-script --help Usage: scratch.py COMMAND This is my help string. ╭─ Commands ────────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰───────────────────────────────────────────────────────────────────────╯ .. attribute:: help_flags :type: Union[str, Iterable[str]] :value: ("--help", "-h") CLI flags that trigger :meth:`help_print`. Set to an empty list to disable this feature. Defaults to ``["--help", "-h"]``. .. attribute:: help_format :type: Optional[Literal["plaintext", "markdown", "md", "restructuredtext", "rst"]] :value: None The markup language used in function docstring. If :obj:`None`, fallback to parenting :attr:`~.App.help_format`. If no :attr:`~.App.help_format` is defined, falls back to ``"markdown"``. .. attribute:: help_formatter :type: Union[None, Literal["default", "plain"], HelpFormatter] :value: None Help formatter to use for rendering help panels. * If :obj:`None` (default), inherits from parent :class:`.App`, eventually defaulting to :class:`~cyclopts.help.DefaultFormatter`. * If ``"default"``, uses :class:`~cyclopts.help.DefaultFormatter`. * If ``"plain"``, uses :class:`~cyclopts.help.PlainFormatter` for no-frills plain text output. * If a callable (see :class:`~cyclopts.help.protocols.HelpFormatter` protocol), uses the provided formatter. Example: .. code-block:: python from cyclopts import App from cyclopts.help import DefaultFormatter, PlainFormatter, PanelSpec # Use plain text formatter app = App(help_formatter="plain") # Use default formatter with customization app = App(help_formatter=DefaultFormatter( panel_spec=PanelSpec(border_style="blue") )) See :ref:`Help Customization` for detailed examples and advanced usage. .. attribute:: help_prologue :type: Optional[str] :value: None Text to display at the beginning of the help screen, before the usage section. If :obj:`None`, no prologue is displayed. If not set, attempts to inherit from parenting :class:`.App`. The prologue supports the same formatting as :attr:`help` based on :attr:`help_format` (markdown, plaintext, restructuredtext, or rich). Example: .. code-block:: python from cyclopts import App app = App( name="myapp", help="My application help.", help_prologue=f"myapp, v1.0.0 (http://example.myapp.com)" ) app() .. code-block:: console $ my-script --help myapp, v1.0.0 (http://example.myapp.com) Usage: myapp COMMAND My application help. ╭─ Commands ────────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰───────────────────────────────────────────────────────────────────────╯ .. attribute:: help_epilogue :type: Optional[str] :value: None Text to display at the end of the help screen, after all help panels. Commonly used for version information, contact details, or additional notes. If :obj:`None`, no epilogue is displayed. If not set, attempts to inherit from parenting :class:`.App`. The epilogue supports the same formatting as :attr:`help` based on :attr:`help_format` (markdown, plaintext, restructuredtext, or rich). Example: .. code-block:: python from cyclopts import App app = App( name="myapp", help="My application help.", help_epilogue="Support: support@example.com" ) app() .. code-block:: console $ my-script --help Usage: myapp COMMAND My application help. ╭─ Commands ────────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰───────────────────────────────────────────────────────────────────────╯ Support: support@example.com .. attribute:: help_on_error :type: Optional[bool] :value: None Prints the help-page before printing an error. If not set, attempts to inherit from parenting :class:`.App`, eventually defaulting to :obj:`False`. .. attribute:: print_error :type: Optional[bool] :value: None Print a rich-formatted error on error. If not set, attempts to inherit from parenting :class:`.App`, eventually defaulting to :obj:`True`. .. attribute:: exit_on_error :type: Optional[bool] :value: None If there is an error parsing the CLI tokens, invoke :func:`sys.exit(1) `. Otherwise, continue to raise the exception. If not set, attempts to inherit from parenting :class:`.App`, eventually defaulting to :obj:`True`. .. attribute:: verbose :type: Optional[bool] :value: None Populate exception strings with more information intended for developers. If not set, attempts to inherit from parenting :class:`.App`, eventually defaulting to :obj:`False`. .. attribute:: error_formatter :type: Optional[Callable[[CycloptsError], Any]] :value: None A callable that formats :exc:`.CycloptsError` exceptions for display. The callable receives the exception and should return any Rich-printable object (string, :class:`~rich.text.Text`, :class:`~rich.panel.Panel`, etc.). If not set, attempts to inherit from parenting :class:`.App`, eventually defaulting to :func:`.CycloptsPanel`. See :ref:`Custom Error Formatting` for examples. .. attribute:: version_format :type: Optional[Literal["plaintext", "markdown", "md", "restructuredtext", "rst"]] :value: None The markup language used in the version string. If :obj:`None`, fallback to parenting :attr:`~.App.version_format`. If no :attr:`~.App.version_format` is defined, falls back to resolved :attr:`~.App.help_format`. .. attribute:: usage :type: Optional[str] :value: None Text to be displayed in lieue of the default ``Usage: app COMMAND ...`` at the beginning of the help-page. Set to an empty-string ``""`` to disable showing the default usage. .. attribute:: show :type: bool :value: True Show this **command** on the help screen. Hidden commands (``show=False``) are still executable. .. code-block:: python from cyclopts import App app = App() @app.command def foo(): print("Running foo.") @app.command(show=False) def bar(): print("Running bar.") app() .. code-block:: console $ my-script foo Running foo. $ my-script bar Running bar. $ my-script --help Usage: scratch.py COMMAND ╭─ Commands ─────────────────────────────────────────────────╮ │ foo │ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────╯ .. attribute:: sort_key :type: Any :value: None Modifies command display order on the help-page. 1. If :attr:`sort_key` is a generator, it will be consumed immediately with :func:`next` to get the actual value. 2. If :attr:`sort_key`, or any of it's contents, are ``Callable``, then invoke it ``sort_key(app)`` and apply the returned value to (3) if :obj:`None`, (4) otherwise. 3. For all commands with ``sort_key==None`` (default value), sort them alphabetically. These sorted commands will be displayed **after** ``sort_key != None`` list (see 4). 4. For all commands with ``sort_key!=None``, sort them by ``(sort_key, app.name)``. It is the user's responsibility that ``sort_key`` s are comparable. Example usage: .. code-block:: python from cyclopts import App app = App() @app.command # sort_key not specified; will be sorted AFTER bob/charlie. def alice(): """Alice help description.""" @app.command(sort_key=2) def bob(): """Bob help description.""" @app.command(sort_key=1) def charlie(): """Charlie help description.""" app() Resulting help-page: .. code-block:: text Usage: demo.py COMMAND ╭─ Commands ──────────────────────────────────────────────────╮ │ charlie Charlie help description. │ │ bob Bob help description. │ │ alice Alice help description. │ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────╯ Using generators (e.g., :func:`itertools.count`): .. code-block:: python import itertools from cyclopts import App app = App() counter = itertools.count() @app.command(sort_key=counter) def beta(): """Beta help description.""" @app.command(sort_key=counter) def alpha(): """Alpha help description.""" app() .. code-block:: text Usage: demo.py COMMAND ╭─ Commands ──────────────────────────────────────────────────╮ │ beta Beta help description. │ │ alpha Alpha help description. │ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────╯ .. attribute:: version :type: Union[None, str, Callable] :value: None Version to be displayed when a :attr:`version_flags` is parsed. Defaults to the version of the package instantiating :class:`.App`. If a :obj:`~typing.Callable`, it will be invoked with no arguments when version is queried. .. attribute:: version_flags :type: Union[str, Iterable[str]] :value: ("--version",) Token(s) that trigger :meth:`version_print`. Set to an empty list to disable version feature. Defaults to ``["--version"]``. .. attribute:: console :type: ~rich.console.Console :value: None Default :class:`~rich.console.Console` to use when displaying runtime messages. Cyclopts console resolution is as follows: #. Any explicitly passed in console to methods like :meth:`.App.__call__`, :meth:`.App.parse_args`, etc. #. The relevant subcommand's :attr:`.App.console` attribute, if not :obj:`None`. #. The parenting :attr:`.App.console` (and so on), if not :obj:`None`. #. If all values are :obj:`None`, then the default :class:`~rich.console.Console` is used. .. attribute:: error_console :type: ~rich.console.Console :value: None Default :class:`~rich.console.Console` to use when displaying error messages. Cyclopts error_console resolution is as follows: #. Any explicitly passed in error_console to methods like :meth:`.App.__call__`, :meth:`.App.parse_args`, etc. #. The relevant subcommand's :attr:`.App.error_console` attribute, if not :obj:`None`. #. The parenting :attr:`.App.error_console` (and so on), if not :obj:`None`. #. If all values are :obj:`None`, then a default :class:`~rich.console.Console` with ``stderr=True`` is used. This separation of error output from normal output follows Unix conventions, allowing users to redirect error messages independently from normal output (e.g., ``program > output.txt 2> errors.txt``). .. attribute:: default_parameter :type: Parameter :value: None Default :class:`.Parameter` configuration. Unspecified values of command-annotated :class:`.Parameter` will inherit these values. See :ref:`Default Parameter` for more details. .. attribute:: group :type: Union[None, str, Group, Iterable[Union[str, Group]]] :value: None The group(s) that :attr:`default_command` belongs to. * If :obj:`None`, defaults to the ``"Commands"`` group. * If :obj:`str`, use an existing :class:`.Group` (from neighboring sub-commands) with name, **or** create a :class:`.Group` with provided name if it does not exist. * If :class:`.Group`, directly use it. .. attribute:: group_commands :type: Group :value: Group("Commands") The default :class:`.Group` that sub-commands are assigned to. .. attribute:: group_arguments :type: Group :value: Group("Arguments") The default :class:`.Group` that positional-only parameters are assigned to. .. attribute:: group_parameters :type: Group :value: Group("Parameters") The default :class:`.Group` that non-positional-only parameters are assigned to. .. attribute:: validator :type: Union[None, Callable, list[Callable]] :value: [] A function (or list of functions) where all the converted CLI-provided variables will be **keyword-unpacked**, regardless of their positional/keyword-type in the command function signature. The python variable names will be used, which may differ from their CLI names. Example usage: .. code-block:: python def validator(**kwargs): "Raise an exception if something is invalid." This validator runs **after** :class:`.Parameter` and :class:`.Group` validators. .. attribute:: name_transform :type: Optional[Callable[[str], str]] :value: None A function that converts function names to their CLI command counterparts. The function must have signature: .. code-block:: python def name_transform(s: str) -> str: ... The returned string should be **without** a leading ``--``. If :obj:`None` (default value), uses :func:`~.default_name_transform`. Subapps inherit from the first non-:obj:`None` parent :attr:`name_transform`. .. attribute:: config :type: Union[None, Callable, Iterable[Callable]] :value: None A function or list of functions that are consecutively executed after parsing CLI tokens and environment variables. These function(s) are called **before** any conversion and validation. Each config function must have signature: .. code-block:: python def config(app: "App", commands: Tuple[str, ...], arguments: ArgumentCollection): """Modifies given mapping inplace with some injected values. Parameters ---------- app: App The current command app being executed. commands: Tuple[str, ...] The CLI strings that led to the current command function. arguments: ArgumentCollection Complete ArgumentCollection for the app. Modify this collection inplace to influence values provided to the function. """ The intended use-case of this feature is to allow users to specify functions that can load defaults from some external configuration. See :ref:`cyclopts.config ` for useful builtins and :ref:`Config Files` for examples. .. attribute:: end_of_options_delimiter :type: Optional[str] :value: None All tokens after this delimiter will be force-interpreted as positional arguments. If not set, attempts to inherit from parenting :class:`.App`, eventually defaulting to POSIX-standard ``"--"``. Set to an empty string to disable. .. attribute:: suppress_keyboard_interrupt :type: bool :value: True If the application receives a keyboard interrupt (Ctrl-C), suppress the error message and exit gracefully. Set to :obj:`False` to let :class:`KeyboardInterrupt` propagate normally. .. attribute:: backend :type: Optional[Literal["asyncio", "trio"]] :value: None The async backend to use when executing async commands. If not set, attempts to inherit from parenting :class:`.App`, eventually defaulting to ``"asyncio"``. Example: .. code-block:: python from cyclopts import App app = App(backend="asyncio") @app.default async def main(): await some_async_operation() app() The backend can also be overridden on a per-call basis: .. code-block:: python app(backend="trio") # Override the app's backend for this call .. attribute:: result_action :type: Literal["return_value", "call_if_callable", "print_non_int_return_int_as_exit_code", "print_str_return_int_as_exit_code", "print_str_return_zero", "print_non_none_return_int_as_exit_code", "print_non_none_return_zero", "return_int_as_exit_code_else_zero", "print_non_int_sys_exit", "sys_exit", "return_none", "return_zero", "print_return_zero", "sys_exit_zero", "print_sys_exit_zero"] | Callable[[Any], Any] | Iterable[Literal[...] | Callable[[Any], Any]] | None :value: None Controls how :meth:`.App.__call__` and :meth:`.App.run_async` handle command return values. By default (``"print_non_int_sys_exit"``), the app will call :func:`sys.exit` with an appropriate exit code. This default was chosen for consistent functionality between standalone scripts, and console entrypoints. Can be a predefined literal string, a custom callable that takes the result and returns a processed value, or a **sequence of actions** to be applied left-to-right in a pipeline. Each predefined mode's exact behavior is shown below: **"print_non_int_sys_exit"** (default) The default CLI mode. Prints non-int values to stdout, then calls :func:`sys.exit` with the appropriate exit code. .. code-block:: python if isinstance(result, bool): sys.exit(0 if result else 1) # i.e. True is success elif isinstance(result, int): sys.exit(result) elif result is not None: print(result) sys.exit(0) else: sys.exit(0) **"return_value"** Returns the command's value unchanged. Use for embedding Cyclopts in other Python code or testing. .. code-block:: python return result **"call_if_callable"** Calls the result if it's callable (with no arguments), otherwise returns it unchanged. Useful for the dataclass command pattern where commands return class instances with ``__call__`` methods. Intended to be used in composition with other result actions (e.g., ``["call_if_callable", "print_non_int_sys_exit"]``). .. code-block:: python return result() if callable(result) else result See :ref:`Dataclass Commands` for usage examples. **"sys_exit"** Never prints output. Calls :func:`sys.exit` with the appropriate exit code. Useful for CLI apps that handle their own output and just need exit code handling. .. code-block:: python if isinstance(result, bool): sys.exit(0 if result else 1) # i.e. True is success elif isinstance(result, int): sys.exit(result) else: sys.exit(0) **"print_non_int_return_int_as_exit_code"** Prints non-int values, returns int/bool as exit codes. Useful for testing and embedding. .. code-block:: python if isinstance(result, bool): return 0 if result else 1 # i.e. True is success elif isinstance(result, int): return result elif result is not None: print(result) return 0 else: return 0 **"print_str_return_int_as_exit_code"** Only prints string return values. Returns int/bool as exit codes, silently returns 0 for other types. .. code-block:: python if isinstance(result, str): print(result) return 0 elif isinstance(result, bool): return 0 if result else 1 # i.e. True is success elif isinstance(result, int): return result else: return 0 **"print_str_return_zero"** Only prints string return values, always returns 0. Useful for simple output-only CLIs. .. code-block:: python if isinstance(result, str): print(result) return 0 **"print_non_none_return_int_as_exit_code"** Prints all non-None values (including ints), returns int/bool as exit codes. .. code-block:: python if result is not None: print(result) if isinstance(result, bool): return 0 if result else 1 # i.e. True is success elif isinstance(result, int): return result return 0 **"print_non_none_return_zero"** Prints all non-None values (including ints), always returns 0. .. code-block:: python if result is not None: print(result) return 0 **"return_int_as_exit_code_else_zero"** Never prints output. Returns int/bool as exit codes, 0 for all other types. Useful for silent CLIs. .. code-block:: python if isinstance(result, bool): return 0 if result else 1 # i.e. True is success elif isinstance(result, int): return result else: return 0 **"return_none"** Always returns None, regardless of the command's return value. .. code-block:: python return None **"return_zero"** Always returns 0, regardless of the command's return value. .. code-block:: python return 0 **"print_return_zero"** Always prints the result (even None), then always returns 0. .. code-block:: python print(result) return 0 **"sys_exit_zero"** Always calls :func:`sys.exit(0) `, regardless of the command's return value. .. code-block:: python sys.exit(0) **"print_sys_exit_zero"** Always prints the result (even None), then calls :func:`sys.exit(0) `. .. code-block:: python print(result) sys.exit(0) **Custom Callable** Provide a function for fully custom result handling. Receives the command's return value and returns a processed value. .. code-block:: python def custom_handler(result): if result is None: return 0 elif isinstance(result, str): print(f"[OUTPUT] {result}") return 0 return result app = App(result_action=custom_handler) **Sequence of Actions** Provide a sequence (list or tuple) of actions to create a result-processing pipeline. Actions are applied left-to-right, with each action receiving the result of the previous action. .. code-block:: python def uppercase(result): return result.upper() if isinstance(result, str) else result def add_prefix(result): return f"[OUTPUT] {result}" if isinstance(result, str) else result # Pipeline: result → uppercase → add_prefix → return app = App(result_action=[uppercase, add_prefix, "return_value"]) @app.command def greet(name: str) -> str: return f"hello {name}" result = app(["greet", "world"]) # result == "[OUTPUT] HELLO WORLD" Actions in a sequence can be any combination of predefined literal strings and custom callables. Empty sequences raise a ``ValueError`` at app initialization. Example: .. code-block:: python from cyclopts import App # For CLI applications with console_scripts entry points app = App(result_action="print_non_int_return_int_as_exit_code") @app.command def greet(name: str) -> str: return f"Hello {name}!" app() See :ref:`Result Action` for detailed examples and usage patterns. .. autoclass:: cyclopts.Parameter :special-members: __call__ .. attribute:: name :type: Union[None, str, Iterable[str]] :value: None Name(s) to expose to the CLI. If not specified, cyclopts will apply :attr:`name_transform` to the python parameter name. .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def main(foo: Annotated[int, Parameter(name=("bar", "-b"))]): print(f"{foo=}") app() .. code-block:: console $ my-script --help Usage: main COMMAND [ARGS] [OPTIONS] ╭─ Commands ─────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────╮ │ * BAR --bar -b [required] │ ╰────────────────────────────────────────────────────────────────╯ $ my-script --bar 100 foo=100 $ my-script -b 100 foo=100 If specifying name in a nested data structure (e.g. a dataclass), beginning the name with a hyphen ``-`` will override any hierarchical dot-notation. .. code-block:: python from cyclopts import App, Parameter from dataclasses import dataclass from typing import Annotated app = App() @dataclass class User: id: int # default behavior email: Annotated[str, Parameter(name="--email")] # overrides pwd: Annotated[str, Parameter(name="password")] # dot-notation with parent @app.command def create(user: User): print(f"Creating {user=}") app() .. code-block:: console $ my-script create --help Usage: scratch.py create [ARGS] [OPTIONS] ╭─ Parameters ───────────────────────────────────────────────────╮ │ * USER.ID --user.id [required] │ │ * EMAIL --email [required] │ │ * USER.PASSWORD [required] │ │ --user.password │ ╰────────────────────────────────────────────────────────────────╯ .. attribute:: alias :type: Union[None, str, Iterable[str]] :value: None Additional name(s) to expose to the CLI. Unlike :attr:`.name`, this does not override Cyclopts-derived names. The following two examples are functionally equivalent: .. code-block:: python @app.default def main(foo: Annotated[int, Parameter(name=["--foo", "-f"])]): pass .. code-block:: python @app.default def main(foo: Annotated[int, Parameter(alias="-f")]): pass .. attribute:: converter :type: Optional[Callable] :value: None A function that converts tokens into an object. The converter should have signature: .. code-block:: python def converter(type_, tokens) -> Any: pass Where ``type_`` is the parameter's type hint, and ``tokens`` is either: * A ``list[cyclopts.Token]`` of CLI tokens (most commonly). .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() def converter(type_, tokens): assert type_ == tuple[int, int] return tuple(2 * int(x.value) for x in tokens) @app.default def main(coordinates: Annotated[tuple[int, int], Parameter(converter=converter)]): print(f"{coordinates=}") app() .. code-block:: console $ python my-script.py 7 12 coordinates=(14, 24) * A ``dict`` of :class:`.Token` if keys are specified in the CLI. E.g. .. code-block:: console $ python my-script.py --foo.key1=val1 would be parsed into: .. code-block:: python tokens = { "key1": ["val1"], } If not provided, defaults to Cyclopts's internal coercion engine. If a pydantic type-hint is provided, Cyclopts will disable its internal coercion engine (including this `converter` argument) and leave the coercion to pydantic. The number of tokens passed to the converter is inferred from the type hint by default, but can be explicitly controlled with :attr:`~.Parameter.n_tokens`. This is useful when the type signature doesn't match the desired CLI token consumption. When loading complex objects with multiple fields, it may also be useful to combine with :attr:`~.Parameter.accepts_keys`. **Decorating Converters:** Converter functions can be decorated with :class:`.Parameter` to define reusable conversion behavior: .. code-block:: python @Parameter(n_tokens=1, accepts_keys=False) def load_from_id(type_, tokens): """Load object from database by ID.""" return fetch_from_db(tokens[0].value) @app.default def main(obj: Annotated[MyType, Parameter(converter=load_from_id)]): # Automatically inherits n_tokens=1 and accepts_keys=False pass **Classmethod Support:** Converters can be classmethods. Use string references for class decoration or direct references in annotations. Classmethod signature should be ``(cls, tokens)`` instead of ``(type_, tokens)``: .. code-block:: python @Parameter(converter="from_env") class Config: @Parameter(n_tokens=1, accepts_keys=False) @classmethod def from_env(cls, tokens): env = tokens[0].value configs = {"dev": ("localhost", 8080), "prod": ("api.example.com", 443)} return cls(*configs[env]) .. attribute:: validator :type: Union[None, Callable, Iterable[Callable]] :value: None A function (or list of functions) that validates data returned by the :attr:`converter`. .. code-block:: python def validator(type_, value: Any) -> None: pass # Raise a TypeError, ValueError, or AssertionError here if data is invalid. .. attribute:: group :type: Union[None, str, Group, Iterable[Union[str, Group]]] :value: None The group(s) that this parameter belongs to. This can be used to better organize the help-page, and/or to add additional conversion/validation logic (such as ensuring mutually-exclusive arguments). If :obj:`None`, defaults to one of the following groups: 1. Parenting :attr:`.App.group_arguments` if the parameter is ``POSITIONAL_ONLY``. By default, this is ``Group("Arguments")``. 2. Parenting :attr:`.App.group_parameters` otherwise. By default, this is ``Group("Parameters")``. See :ref:`Groups` for examples. .. attribute:: negative :type: Union[None, str, Iterable[str]] :value: None Name(s) for empty iterables or false boolean flags. * For booleans, defaults to ``no-{name}`` (see :attr:`negative_bool`). * For iterables, defaults to ``empty-{name}`` (see :attr:`negative_iterable`). Set to an empty list or string to disable the creation of negative flags. Example usage: .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def main(*, verbose: Annotated[bool, Parameter(negative="--quiet")] = False): print(f"{verbose=}") app() .. code-block:: console $ my-script --help Usage: main COMMAND [ARGS] [OPTIONS] ╭─ Commands ─────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────╮ │ --verbose --quiet [default: False] │ ╰────────────────────────────────────────────────────────────────╯ .. attribute:: negative_bool :type: Optional[str] :value: None Prefix for negative boolean flags. Defaults to ``"no-"``. .. attribute:: negative_iterable :type: Optional[str] :value: None Prefix for empty iterables (like lists and sets) flags. Defaults to ``"empty-"``. .. attribute:: negative_none :type: Optional[str] :value: None Prefix for setting optional parameters to :obj:`None`. Not enabled by default (no prefixes set). Example: .. code-block:: python from pathlib import Path from typing import Annotated from cyclopts import App, Parameter app = App( default_parameter=Parameter(negative_none="none-") ) @app.default def default(path: Path | None = Path("data.bin")): print(f"{path=}") app() .. code-block:: console $ my-script path=PosixPath('data.bin') $ my-script --path=cat.jpeg path=PosixPath('cat.jpeg') $ my-script --none-path path=None .. attribute:: allow_leading_hyphen :type: bool :value: False Allow parsing non-numeric values that begin with a hyphen ``-``. This is disabled (:obj:`False`) by default, allowing for more helpful error messages for unknown CLI options. .. attribute:: requires_equals :type: bool :value: False Require long options to use ``=`` to separate the option name from its value (e.g., ``--option=value``). When enabled, the space-separated form ``--option value`` is rejected with a :class:`RequiresEqualsError`. * Only applies to long-form options (those starting with ``--``). * Short options (e.g., ``-o value``) are **not** affected. * Boolean flags (e.g., ``--verbose``) work regardless of this setting. * Can be set app-wide via :attr:`.App.default_parameter`. * Cannot be combined with :attr:`consume_multiple` (raises :class:`ValueError`). To provide multiple values for a list parameter, repeat the option (e.g., ``--urls=a --urls=b``). To pass an empty iterable, use the negative flag (e.g., ``--empty-urls``). .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def main(*, name: Annotated[str, Parameter(requires_equals=True)]): print(f"Hello {name}") app() .. code-block:: console $ my-script --name=alice Hello alice $ my-script --name alice ╭─ Error ───────────────────────────────────────────────────────╮ │ Parameter "--name" requires a value assigned with "=". │ │ Use "--name=VALUE". │ ╰───────────────────────────────────────────────────────────────╯ .. attribute:: parse :type: Union[None, bool, str, re.Pattern] :value: None Attempt to use this parameter while parsing. Annotated parameter **must** be keyword-only or have a default value. This is intended to be used with :ref:`meta apps ` for injecting values. * :obj:`True` - Parse this parameter from CLI tokens. * :obj:`False` - Do not parse; parameter will appear in the ``ignored`` dict from :meth:`.App.parse_args`. * :obj:`None` - Default behavior (parse). * :obj:`str` - A regex pattern; parse **if the pattern matches the parameter name**, otherwise skip. String patterns are automatically compiled to :class:`re.Pattern` for performance. * :class:`re.Pattern` - A pre-compiled regex pattern; same behavior as string patterns. Regex patterns are primarily intended for use with :attr:`.App.default_parameter` to define app-wide skip patterns. For example, if we wanted to skip all fields that begin with an underscore ``_``: .. code-block:: python import re from cyclopts import App, Parameter # Skip parsing underscore-prefixed KEYWORD_ONLY parameters (i.e. private parameters) # Both string and pre-compiled patterns are supported: app = App(default_parameter=Parameter(parse="^(?!_)")) # or: app = App(default_parameter=Parameter(parse=re.compile("^(?!_)"))) @app.default def main(visible: str, *, _injected: str = "default"): # _injected is NOT parsed from CLI; uses default value pass .. attribute:: required :type: Optional[bool] :value: None Indicates that the parameter must be supplied. Defaults to inferring from the function signature; i.e. :obj:`False` if the parameter has a default, :obj:`True` otherwise. .. attribute:: show :type: Optional[bool] :value: None Show this parameter on the help screen. Defaults to whether the parameter is :attr:`parsed <.Parameter.parse>` (usually :obj:`True`). .. attribute:: show_default :type: Union[None, bool, Callable[[Any], Any]] :value: None If a variable has a default, display the default on the help page. Defaults to :obj:`None`, similar to :obj:`True`, but will **not** display the default if it is :obj:`None`. If set to a function with signature: .. code-block:: python def formatter(value: Any) -> Any: ... Then the function will be called with the default value, and the returned value will be used as the displayed default value. Example formatting function: .. code-block:: python def hex_formatter(value: int) -> str """Will result in something like "[default: 0xFF]" instead of "[default: 255]".""" return f"0x{value:X}" .. attribute:: show_choices :type: Optional[bool] :value: True If a variable has a set of choices, display the choices on the help page. .. attribute:: help :type: Optional[str] :value: None Help string to be displayed on the help page. If not specified, defaults to the docstring. .. attribute:: show_env_var :type: Optional[bool] :value: True If a variable has :attr:`env_var` set, display the variable name on the help page. .. attribute:: env_var :type: Union[None, str, Iterable[str]] :value: None Fallback to environment variable(s) if CLI value not provided. If multiple environment variables are given, the left-most environment variable **with a set value** will be used. If no environment variable is set, Cyclopts will fallback to the function-signature default. .. attribute:: env_var_split :type: Callable :value: cyclopts.env_var_split Function that splits up the read-in :attr:`~cyclopts.Parameter.env_var` value. The function must have signature: .. code-block:: python def env_var_split(type_: type, val: str) -> list[str]: ... where ``type_`` is the associated parameter type-hint, and ``val`` is the environment value. .. attribute:: name_transform :type: Optional[Callable[[str], str]] :value: None A function that converts python parameter names to their CLI command counterparts. The function must have signature: .. code-block:: python def name_transform(s: str) -> str: ... If :obj:`None` (default value), uses :func:`cyclopts.default_name_transform`. .. attribute:: accepts_keys :type: Optional[bool] :value: None If :obj:`False`, treat the user-defined class annotation similar to a tuple. Individual class sub-parameters will not be addressable by CLI keywords. The class will consume enough tokens to populate all required positional parameters. Default behavior (``accepts_keys=True``): .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() class Image: def __init__(self, path, label): self.path = path self.label = label def __repr__(self): return f"Image(path={self.path!r}, label={self.label!r})" @app.default def main(image: Image): print(f"{image=}") app() .. code-block:: console $ my-program --help Usage: main COMMAND [ARGS] [OPTIONS] ╭─ Commands ──────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ────────────────────────────────────────────────────────╮ │ * IMAGE.PATH --image.path [required] │ │ * IMAGE.LABEL --image.label [required] │ ╰─────────────────────────────────────────────────────────────────────╯ $ my-program foo.jpg nature image=Image(path='foo.jpg', label='nature') $ my-program --image.path foo.jpg --image.label nature image=Image(path='foo.jpg', label='nature') Behavior when ``accepts_keys=False``: .. code-block:: python # Modify the default command function's signature. @app.default def main(image: Annotated[Image, Parameter(accepts_keys=False)]): print(f"{image=}") .. code-block:: console $ my-program --help Usage: main COMMAND [ARGS] [OPTIONS] ╭─ Commands ──────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ────────────────────────────────────────────────────────╮ │ * IMAGE --image [required] │ ╰─────────────────────────────────────────────────────────────────────╯ $ my-program foo.jpg nature image=Image(path='foo.jpg', label='nature') $ my-program --image foo.jpg nature image=Image(path='foo.jpg', label='nature') The ``accepts_keys=False`` option is commonly used with :attr:`~.Parameter.converter` and :attr:`~.Parameter.n_tokens`. .. attribute:: consume_multiple :type: None | bool | int | Sequence[int] :value: None Controls how many CLI tokens a list/iterable parameter consumes when specified **by keyword**. If the parameter is specified **positionally**, :attr:`.Parameter.consume_multiple` is ignored. The following value types are supported: * :obj:`False` (default) — only a single *element* worth of CLI tokens will be consumed per keyword occurrence. * :obj:`True` — all remaining CLI tokens will be consumed until the stream is exhausted or an option-like token is reached. Providing the keyword with no values creates an empty container. * :class:`int` — like :obj:`True`, but the integer specifies the **minimum** number of *elements* required. For example, ``consume_multiple=1`` requires at least one value (preventing empty lists), and ``consume_multiple=0`` is equivalent to :obj:`True`. * :class:`~collections.abc.Sequence`\[:class:`int`\] — a ``(min, max)`` pair (e.g. a tuple or list of two ints). All remaining CLI tokens are consumed greedily, and a :class:`ConsumeMultipleError` is raised if the number of elements is outside the ``(min, max)`` bounds. **Example: consume_multiple=True** .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.command def name_ext( name: str, ext: Annotated[list[str], Parameter(consume_multiple=True)], ): for extension in ext: print(f"{name}.{extension}") app() .. code-block:: console $ my-program --name "my_file" --ext "txt" "pdf" # stream is exhausted my_file.txt my_file.pdf $ my-program --ext "txt" "pdf" --name "my_file" # a keyword is reached my_file.txt my_file.pdf When ``consume_multiple=True``, providing the keyword flag without any values will create an empty container, equivalent to using the :attr:`negative_iterable` prefix (e.g., ``--empty-ext``): .. code-block:: console $ my-program --name "my_file" --ext # No output - ext is an empty list [] $ my-program --name "my_file" --empty-ext # No output - ext is an empty list [] **Example: consume_multiple=1 (require at least one value)** .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def main( urls: Annotated[list[str] | None, Parameter(consume_multiple=1)] = None, ): print(urls) app() .. code-block:: console $ my-program --urls http://a.com http://b.com ['http://a.com', 'http://b.com'] $ my-program --urls ╭─ Error ────────────────────────────────────────────╮ │ Parameter "--urls" requires an argument. │ ╰────────────────────────────────────────────────────╯ **Example: consume_multiple=(1, 3) (min/max bounds)** .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def main( files: Annotated[list[str], Parameter(consume_multiple=(1, 3))], ): print(f"Files: {files}") app() .. code-block:: console $ my-program --files a.txt b.txt Files: ['a.txt', 'b.txt'] $ my-program --files a.txt b.txt c.txt d.txt ╭─ Error ─────────────────────────────────────────╮ │ Parameter "--files" accepts at most 3 elements. │ │ Got 4. │ ╰─────────────────────────────────────────────────╯ In this example, ``--files`` raises a :class:`ConsumeMultipleError` if fewer than 1 or more than 3 values are provided. **Example: consume_multiple=False (default)** .. code-block:: python from cyclopts import App from pathlib import Path app = App() @app.default def name_ext(name: str, ext: list[str]): # same as `ext: Annotated[list[str], Parameter(consume_multiple=False)]`` for extension in ext: print(f"{name}.{extension}") app() .. code-block:: console $ my-program --name "my_file" --ext "txt" "pdf" ╭─ Error ────────────────────────────────────────────╮ │ Unused Tokens: ['pdf']. │ ╰────────────────────────────────────────────────────╯ .. attribute:: json_dict :type: Optional[bool] :value: None Allow for the parsing of json-dict-strings as data. If :obj:`None` (default behavior), acts like :obj:`True`, **unless** the annotated type is union'd with :obj:`str`. When :obj:`True`, data will be parsed as json if the following conditions are met: 1. The parameter is specified as a keyword option; e.g. ``--movie``. 2. The referenced parameter is dataclass-like. 3. The first character of the token is a ``{``. .. attribute:: json_list :type: Optional[bool] :value: None Allow for the parsing of json-list-strings as data. If :obj:`None` (default behavior), acts like :obj:`True`, **unless** the annotated type has each element type :obj:`str`. When :obj:`True`, data will be parsed as json if the following conditions are met: 1. The referenced parameter is iterable (not including :obj:`str`). 2. The first character of the token is a ``[``. .. attribute:: count :type: bool :value: False If :obj:`True`, count the number of times the flag appears instead of parsing a value. Each occurrence increments the count by 1 (e.g., ``-vvv`` results in ``3``). Requirements and behavior: * The parameter **must** have an :obj:`int` type hint (or ``Optional[int]``). * Short flags can be concatenated: ``-vvv`` is equivalent to ``-v -v -v``. * Long flags can be repeated: ``--verbose --verbose`` results in ``2``. * Negative flag variants (e.g., ``--no-verbose``) are **not** generated. Common use case: verbosity levels. .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def main(verbose: Annotated[int, Parameter(name="-v", count=True)] = 0): print(f"Verbosity level: {verbose}") app() .. code-block:: console $ my-script -vvv Verbosity level: 3 See :ref:`Coercion Rules` for more details. .. attribute:: allow_repeating :type: Optional[bool] :value: None Controls whether a keyword option can be specified multiple times on the CLI (e.g., ``--foo a --foo b``). * :obj:`None` (default): iterable types accumulate values, scalar types raise :exc:`RepeatArgumentError`. * :obj:`False`: always raise :exc:`RepeatArgumentError` on repeated options. Useful with :attr:`consume_multiple` to allow ``--foo a b c`` but disallow ``--foo a --foo b``. * :obj:`True`: always allow repeated options. Iterable types accumulate as usual. Scalar types use "last wins" semantics (the last value specified is used). .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def main( values: Annotated[list[str], Parameter(consume_multiple=True, allow_repeating=False)], ): print(values) app() .. code-block:: console $ my-script --values a b c ['a', 'b', 'c'] $ my-script --values a --values b ╭─ Error ──────────────────────────────────────────────────╮ │ Parameter "--values" was specified multiple times. │ ╰─────────────────────────────────────────────────────────╯ .. attribute:: n_tokens :type: Optional[int] :value: None Explicitly override the number of CLI tokens this parameter consumes. By default, Cyclopts infers the token count from the parameter's type hint (e.g., :obj:`int` consumes 1 token, ``tuple[int, int]`` consumes 2, :obj:`list` consumes all remaining). This attribute allows you to override that inference, which is particularly useful when: * Using custom converters that need a different token count than the type suggests. * Loading complex types from a single token (e.g., loading from a file path). * Implementing selection/lookup patterns where one token identifies an object. Values: * ``None`` (default): Infer token count from the type hint. * non-negative integer: Consume exactly that many tokens. * ``-1``: Consume all remaining tokens (similar to iterables). For ``*args`` parameters, ``n_tokens`` specifies tokens **per element**. For example, ``n_tokens=2`` with 6 tokens creates 3 elements. .. code-block:: python from cyclopts import App, Parameter from typing import Annotated class Config: def __init__(self, host: str, port: int): self.host = host self.port = port def load_config(type_, tokens): # Load config from a file path (single token) filepath = tokens[0].value # ... load from file ... return Config("example.com", 8080) app = App() @app.default def main( config: Annotated[ Config, Parameter(n_tokens=1, converter=load_config, accepts_keys=False) ] ): print(f"Connecting to {config.host}:{config.port}") app() .. code-block:: console $ my-script --config prod.conf Connecting to example.com:8080 .. automethod:: combine .. automethod:: default .. autoclass:: cyclopts.Group :members: create_ordered A group of parameters and/or commands in a CLI application. .. attribute:: name :type: str :value: "" Group name used for the help-page and for group-referenced-by-string. This is a title, so the first character should be capitalized. If a name is not specified, it will not be shown on the help-page. .. attribute:: help :type: str :value: "" Additional documentation shown on the help-page. This will be displayed inside the group's panel, above the parameters/commands. .. attribute:: show :type: Optional[bool] :value: None Show this group on the help-page. Defaults to :obj:`None`, which will only show the group if a ``name`` is provided. .. attribute:: help_formatter :type: Union[None, Literal["default", "plain"], HelpFormatter] :value: None Help formatter to use for rendering this group's help panel. * If :obj:`None` (default), inherits from the :class:`.App`'s :attr:`~.App.help_formatter`. * If ``"default"``, uses :class:`~cyclopts.help.DefaultFormatter`. * If ``"plain"``, uses :class:`~cyclopts.help.PlainFormatter` for no-frills plain text output. * If a callable (see :class:`~cyclopts.help.protocols.HelpFormatter` protocol), uses the provided formatter. This allows per-group customization of help appearance: .. code-block:: python from cyclopts import App, Group, Parameter from cyclopts.help import DefaultFormatter, PanelSpec from typing import Annotated app = App() # Using string literal simple_group = Group( "Simple Options", help_formatter="plain" ) # Using custom formatter instance custom_group = Group( "Custom Options", help_formatter=DefaultFormatter( panel_spec=PanelSpec(border_style="red") ) ) @app.default def main( opt1: Annotated[str, Parameter(group=simple_group)], opt2: Annotated[str, Parameter(group=custom_group)] ): pass See :ref:`Help Customization` for detailed examples. .. attribute:: sort_key :type: Any :value: None Modifies group-panel display order on the help-page. 1. If :attr:`sort_key` is a generator, it will be consumed immediately with :func:`next` to get the actual value. 2. If :attr:`sort_key`, or any of it's contents, are ``Callable``, then invoke it ``sort_key(group)`` and apply the rules below. 3. The :class:`.App` default groups (:attr:`.App.group_command`, :attr:`.App.group_arguments`, :attr:`.App.group_parameters`) will be displayed first. If you want to further customize the ordering of these default groups, you can define custom values and they will be treated like any other group: .. code-block:: python from cyclopts import App, Group app = App( group_parameters=Group("Parameters", sort_key=1), group_arguments=Group("Arguments", sort_key=2), group_commands=Group("Commands", sort_key=3), ) @app.default def main(foo, /, bar): pass if __name__ == "__main__": app() .. code-block:: console $ python main.py --help Usage: main [ARGS] [OPTIONS] ╭─ Parameters ──────────────────────────────────────────────────────────╮ │ * BAR --bar [required] │ ╰───────────────────────────────────────────────────────────────────────╯ ╭─ Arguments ───────────────────────────────────────────────────────────╮ │ * FOO [required] │ ╰───────────────────────────────────────────────────────────────────────╯ ╭─ Commands ────────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰───────────────────────────────────────────────────────────────────────╯ 4. For all groups with ``sort_key!=None``, sort them by ``(sort_key, group.name)``. That is, sort them by their ``sort_key``, and then break ties alphabetically. It is the user's responsibility that ``sort_key`` are comparable. 5. For all groups with ``sort_key==None`` (default value), sort them alphabetically after (4), :attr:`.App.group_commands`, :attr:`.App.group_arguments`, and :attr:`.App.group_parameters`. Example usage: .. code-block:: python from cyclopts import App, Group app = App() @app.command(group=Group("4", sort_key=5)) def cmd1(): pass @app.command(group=Group("3", sort_key=lambda x: 10)) def cmd2(): pass @app.command(group=Group("2", sort_key=lambda x: None)) def cmd3(): pass @app.command(group=Group("1")) def cmd4(): pass app() Resulting help-page: .. code-block:: text Usage: app COMMAND ╭─ 4 ────────────────────────────────────────────────────────────────╮ │ cmd1 │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ 3 ────────────────────────────────────────────────────────────────╮ │ cmd2 │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ 1 ────────────────────────────────────────────────────────────────╮ │ cmd4 │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ 2 ────────────────────────────────────────────────────────────────╮ │ cmd3 │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help,-h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ .. attribute:: default_parameter :type: Optional[Parameter] :value: None Default :class:`.Parameter` in the parameter-resolution-stack that goes between :attr:`.App.default_parameter` and the function signature's :obj:`Annotated` :class:`.Parameter`. The provided :class:`.Parameter` is not allowed to have a :attr:`~.Parameter.group` value. .. attribute:: validator :type: Optional[Callable] :value: None A function (or list of functions) that validates an :class:`.ArgumentCollection`. Example usage: .. code-block:: python def validator(argument_collection: ArgumentCollection): "Raise an exception if something is invalid." The :class:`.ArgumentCollection` will contain all arguments that belong to that group. The validator(s) will **always be invoked**, regardless if any argument within the collection has token(s). Validators are **not** invoked for command groups. .. autoclass:: cyclopts.Token .. attribute:: keyword :type: Optional[str] :value: None **Unadulterated** user-supplied keyword like ``--foo`` or ``--foo.bar.baz``; ``None`` when token was pared positionally. Could also be something like ``tool.project.foo`` if from non-cli sources. .. attribute:: value :type: str :value: "" The parsed token value (unadulterated). .. attribute:: source :type: str :value: "" Where the token came from; used for error message purposes. Cyclopts uses the string ``cli`` for cli-parsed tokens. .. attribute:: index :type: int :value: 0 The relative positional index in which the value was provided. .. attribute:: keys :type: tuple[str, ...] :value: () The additional parsed **python** variable keys from :attr:`keyword`. Only used for Arguments that take arbitrary keys. .. attribute:: implicit_value :type: Any :value: cyclopts.UNSET Final value that should be used instead of converting from :attr:`value`. Commonly used for boolean flags. Ignored if :obj:`~.UNSET`. .. autoclass:: cyclopts.field_info.FieldInfo .. autoclass:: cyclopts.Argument :members: .. autoclass:: cyclopts.ArgumentCollection :members: .. autoclass:: cyclopts.UNSET .. autofunction:: cyclopts.default_name_transform .. autofunction:: cyclopts.env_var_split .. autofunction:: cyclopts.edit .. autofunction:: cyclopts.run .. autoclass:: cyclopts.CycloptsPanel .. _API Validators: ---------- Validators ---------- Cyclopts has several builtin validators for common CLI inputs. .. autoclass:: cyclopts.validators.LimitedChoice :members: .. autoclass:: cyclopts.validators.MutuallyExclusive :members: .. autodata:: cyclopts.validators.mutually_exclusive Instantiated version of :class:`~.validators.MutuallyExclusive`. Can be used directly in group validators: .. code-block:: python import cyclopts from cyclopts import Group mutually_exclusive_group = Group(validator=cyclopts.validators.mutually_exclusive) .. autodata:: cyclopts.validators.all_or_none Group validator that enforces that either all parameters in the group must be supplied an argument, or none of them. .. autoclass:: cyclopts.validators.Number :members: .. autoclass:: cyclopts.validators.Path :members: .. _Annotated Types: ----- Types ----- Cyclopts has builtin pre-defined annotated-types for common conversion and validation configurations. Most definitions in this section are simply predefined annotations for convenience: .. code-block:: python Annotated[..., Parameter(...)] Custom classes that provide additional functionality beyond simple annotations will be noted. Due to Cyclopts's advanced :class:`.Parameter` resolution engine, these annotations can themselves be annotated to further configure behavior. E.g: .. code-block:: python Annotated[PositiveInt, Parameter(...)] .. _Annotated Path Types: ^^^^ Path ^^^^ :class:`~pathlib.Path` annotated types for checking existence, type, and performing path-resolution. All of these types will also work on sequence of paths (e.g. ``tuple[Path, Path]`` or ``list[Path]``). .. class:: cyclopts.types.StdioPath .. note:: This is a custom class, not a simple :obj:`~typing.Annotated` type alias. Requires **Python 3.12+** due to :class:`~pathlib.Path` subclassing support. A :class:`~pathlib.Path` subclass that treats ``-`` as stdin (for reading) or stdout (for writing). This follows `common Unix convention `_. :class:`StdioPath` is pre-configured with ``allow_leading_hyphen=True``, so ``-`` can be passed as an argument without being interpreted as an option. .. attribute:: STDIO_STRING :type: str :value: "-" Class attribute defining the string that triggers stdio behavior. Override in subclasses to use a different string. .. attribute:: is_stdio :type: bool Returns :obj:`True` if this path represents stdin/stdout (i.e., ``str(self) == STDIO_STRING``). Override this property in subclasses for custom matching logic (e.g., matching multiple strings). Basic usage: .. code-block:: python from cyclopts import App from cyclopts.types import StdioPath app = App() @app.default def main(input_file: StdioPath): data = input_file.read_text() print(data.upper()) app() .. code-block:: console $ echo "hello" | python my_script.py - HELLO $ python my_script.py data.txt To default to stdin/stdout when no argument is provided: .. code-block:: python @app.default def main(input_file: StdioPath = StdioPath("-")): data = input_file.read_text() print(data.upper()) See :ref:`Reading/Writing From File or Stdin/Stdout` for more examples. **Subclassing** To use a different trigger string or custom matching logic, subclass :class:`StdioPath`: .. code-block:: python from cyclopts.types import StdioPath # Simple: different trigger string class StdinPath(StdioPath): STDIO_STRING = "STDIN" class StdoutPath(StdioPath): STDIO_STRING = "STDOUT" # Advanced: match multiple strings class MultiStdioPath(StdioPath): @property def is_stdio(self) -> bool: return str(self) in ("-", "STDIN", "STDOUT") .. autodata:: cyclopts.types.ExistingPath .. autodata:: cyclopts.types.NonExistentPath .. autodata:: cyclopts.types.ResolvedPath .. autodata:: cyclopts.types.ResolvedExistingPath .. autodata:: cyclopts.types.Directory .. autodata:: cyclopts.types.ExistingDirectory .. autodata:: cyclopts.types.NonExistentDirectory .. autodata:: cyclopts.types.ResolvedDirectory .. autodata:: cyclopts.types.ResolvedExistingDirectory .. autodata:: cyclopts.types.File .. autodata:: cyclopts.types.ExistingFile .. autodata:: cyclopts.types.NonExistentFile .. autodata:: cyclopts.types.ResolvedFile .. autodata:: cyclopts.types.ResolvedExistingFile .. autodata:: cyclopts.types.BinPath .. autodata:: cyclopts.types.ExistingBinPath .. autodata:: cyclopts.types.NonExistentBinPath .. autodata:: cyclopts.types.CsvPath .. autodata:: cyclopts.types.ExistingCsvPath .. autodata:: cyclopts.types.NonExistentCsvPath .. autodata:: cyclopts.types.TxtPath .. autodata:: cyclopts.types.ExistingTxtPath .. autodata:: cyclopts.types.NonExistentTxtPath .. autodata:: cyclopts.types.ImagePath .. autodata:: cyclopts.types.ExistingImagePath .. autodata:: cyclopts.types.NonExistentImagePath .. autodata:: cyclopts.types.Mp4Path .. autodata:: cyclopts.types.ExistingMp4Path .. autodata:: cyclopts.types.NonExistentMp4Path .. autodata:: cyclopts.types.JsonPath .. autodata:: cyclopts.types.ExistingJsonPath .. autodata:: cyclopts.types.NonExistentJsonPath .. autodata:: cyclopts.types.TomlPath .. autodata:: cyclopts.types.ExistingTomlPath .. autodata:: cyclopts.types.NonExistentTomlPath .. autodata:: cyclopts.types.YamlPath .. autodata:: cyclopts.types.ExistingYamlPath .. autodata:: cyclopts.types.NonExistentYamlPath .. _Annotated Number Types: ^^^^^^ Number ^^^^^^ Annotated types for checking common int/float value constraints. All of these types will also work on sequence of numbers (e.g. ``tuple[int, int]`` or ``list[float]``). .. autodata:: cyclopts.types.PositiveFloat .. autodata:: cyclopts.types.NonNegativeFloat .. autodata:: cyclopts.types.NegativeFloat .. autodata:: cyclopts.types.NonPositiveFloat .. autodata:: cyclopts.types.NormFloat .. autodata:: cyclopts.types.SignedNormFloat .. autodata:: cyclopts.types.PositiveInt .. autodata:: cyclopts.types.NonNegativeInt .. autodata:: cyclopts.types.NegativeInt .. autodata:: cyclopts.types.NonPositiveInt .. autodata:: cyclopts.types.PercentInt .. autodata:: cyclopts.types.UInt8 .. autodata:: cyclopts.types.HexUInt8 .. autodata:: cyclopts.types.Int8 .. autodata:: cyclopts.types.UInt16 .. autodata:: cyclopts.types.HexUInt16 .. autodata:: cyclopts.types.Int16 .. autodata:: cyclopts.types.UInt32 .. autodata:: cyclopts.types.HexUInt32 .. autodata:: cyclopts.types.Int32 .. autodata:: cyclopts.types.UInt64 .. autodata:: cyclopts.types.HexUInt64 .. autodata:: cyclopts.types.Int64 ^^^^ Json ^^^^ Annotated types for parsing a json-string from the CLI. .. autodata:: cyclopts.types.Json .. _API Config: ^^^ Web ^^^ Annotated types for common web-related values. .. autodata:: cyclopts.types.Email .. autodata:: cyclopts.types.Port .. autodata:: cyclopts.types.URL --------------- Help Formatting --------------- Cyclopts provides a flexible help formatting system for customizing the help-page's appearance. .. autoclass:: cyclopts.help.protocols.HelpFormatter :members: .. autoclass:: cyclopts.help.DefaultFormatter :members: .. autoclass:: cyclopts.help.PlainFormatter :members: .. autoclass:: cyclopts.help.protocols.ColumnSpecBuilder :members: .. autoclass:: cyclopts.help.PanelSpec :members: .. autoclass:: cyclopts.help.TableSpec :members: .. autoclass:: cyclopts.help.ColumnSpec :members: .. autoclass:: cyclopts.help.NameRenderer :members: .. autoclass:: cyclopts.help.DescriptionRenderer :members: .. autoclass:: cyclopts.help.AsteriskRenderer :members: .. autoclass:: cyclopts.help.HelpPanel :members: .. autoclass:: cyclopts.help.HelpEntry :members: ------ Config ------ Cyclopts has builtin configuration classes to be used with :attr:`App.config ` for loading user-defined defaults in many common scenarios. All Cyclopts builtins index into the configuration file with the following rules: 1. Apply ``root_keys`` (if provided) to enter the project's configuration namespace. 2. Apply the command name(s) to enter the current command's configuration namespace. 3. Apply each key/value pair if CLI arguments have **not** been provided for that parameter. .. autoclass:: cyclopts.config.Toml Automatically read configuration from Toml file. .. attribute:: path :type: str | pathlib.Path Path to TOML configuration file. .. attribute:: source :type: str | None :value: None Identifier for the configuration source, used in error messages. If not provided, defaults to the absolute path of the configuration file. .. attribute:: root_keys :type: Iterable[str] :value: None The key or sequence of keys that lead to the root configuration structure for this app. For example, if referencing a ``pyproject.toml``, it is common to store all of your projects configuration under: .. code-block:: toml [tool.myproject] So, your Cyclopts :class:`~cyclopts.App` should be configured as: .. code-block:: python app = cyclopts.App(config=cyclopts.config.Toml("pyproject.toml", root_keys=("tool", "myproject"))) .. attribute:: must_exist :type: bool :value: False The configuration file MUST exist. Raises :class:`FileNotFoundError` if it does not exist. .. attribute:: search_parents :type: bool :value: False If ``path`` doesn't exist, iteratively search parenting directories for a same-named configuration file. Raises :class:`FileNotFoundError` if no configuration file is found. .. attribute:: allow_unknown :type: bool :value: False Allow for unknown keys. Otherwise, if an unknown key is provided, raises :class:`UnknownOptionError`. .. attribute:: use_commands_as_keys :type: bool :value: True Use the sequence of commands as keys into the configuration. For example, the following CLI invocation: .. code-block:: console $ python my-script.py my-command Would search into ``["my-command"]`` for values. .. autoclass:: cyclopts.config.Yaml Automatically read configuration from YAML file. .. attribute:: path :type: str | pathlib.Path Path to YAML configuration file. .. attribute:: source :type: str | None :value: None Identifier for the configuration source, used in error messages. If not provided, defaults to the absolute path of the configuration file. .. attribute:: root_keys :type: Iterable[str] :value: None The key or sequence of keys that lead to the root configuration structure for this app. For example, if referencing a common ``config.yaml`` that is shared with other applications, it is common to store your projects configuration under a key like ``myproject:``. Your Cyclopts :class:`~cyclopts.App` would be configured as: .. code-block:: python app = cyclopts.App(config=cyclopts.config.Yaml("config.yaml", root_keys="myproject")) .. attribute:: must_exist :type: bool :value: False The configuration file MUST exist. Raises :class:`FileNotFoundError` if it does not exist. .. attribute:: search_parents :type: bool :value: False If ``path`` doesn't exist, iteratively search parenting directories for a same-named configuration file. Raises :class:`FileNotFoundError` if no configuration file is found. .. attribute:: allow_unknown :type: bool :value: False Allow for unknown keys. Otherwise, if an unknown key is provided, raises :class:`UnknownOptionError`. .. attribute:: use_commands_as_keys :type: bool :value: True Use the sequence of commands as keys into the configuration. For example, the following CLI invocation: .. code-block:: console $ python my-script.py my-command Would search into ``["my-command"]`` for values. .. autoclass:: cyclopts.config.Json Automatically read configuration from Json file. .. attribute:: path :type: str | pathlib.Path Path to JSON configuration file. .. attribute:: source :type: str | None :value: None Identifier for the configuration source, used in error messages. If not provided, defaults to the absolute path of the configuration file. Can be customized to provide more descriptive error context. Example: .. code-block:: python app = cyclopts.App(config=cyclopts.config.Json("config.json", source="production-config")) .. attribute:: root_keys :type: Iterable[str] :value: None The key or sequence of keys that lead to the root configuration structure for this app. For example, if referencing a common ``config.json`` that is shared with other applications, it is common to store your projects configuration under a key like ``"myproject":``. Your Cyclopts :class:`~cyclopts.App` would be configured as: .. code-block:: python app = cyclopts.App(config=cyclopts.config.Json("config.json", root_keys="myproject")) .. attribute:: must_exist :type: bool :value: False The configuration file MUST exist. Raises :class:`FileNotFoundError` if it does not exist. .. attribute:: search_parents :type: bool :value: False If ``path`` doesn't exist, iteratively search parenting directories for a same-named configuration file. Raises :class:`FileNotFoundError` if no configuration file is found. .. attribute:: allow_unknown :type: bool :value: False Allow for unknown keys. Otherwise, if an unknown key is provided, raises :class:`UnknownOptionError`. .. attribute:: use_commands_as_keys :type: bool :value: True Use the sequence of commands as keys into the configuration. For example, the following CLI invocation: .. code-block:: console $ python my-script.py my-command Would search into ``["my-command"]`` for values. .. autoclass:: cyclopts.config.Dict Use an in-memory Python dictionary as configuration source. .. attribute:: data :type: dict[str, Any] The configuration dictionary. .. attribute:: source :type: str :value: "dict" Identifier for the configuration source, used in error messages. .. attribute:: root_keys :type: Iterable[str] :value: () The key or sequence of keys that lead to the root configuration structure for this app. .. attribute:: allow_unknown :type: bool :value: False Allow for unknown keys. Otherwise, if an unknown key is provided, raises :class:`UnknownOptionError`. .. attribute:: use_commands_as_keys :type: bool :value: True Use the sequence of commands as keys into the configuration. .. autoclass:: cyclopts.config.Env Automatically derive environment variable names to read configurations from. For example, consider the following app: .. code-block:: python import cyclopts app = cyclopts.App(config=cyclopts.config.Env("MY_SCRIPT_")) @app.command def my_command(foo, bar): print(f"{foo=} {bar=}") app() If values for ``foo`` and ``bar`` are not supplied by the command line, the app will check the environment variables ``MY_SCRIPT_MY_COMMAND_FOO`` and ``MY_SCRIPT_MY_COMMAND_BAR``, respectively: .. code-block:: console $ python my_script.py my-command 1 2 foo=1 bar=2 $ export MY_SCRIPT_MY_COMMAND_FOO=100 $ python my_script.py my-command --bar=2 foo=100 bar=2 $ python my_script.py my-command 1 2 foo=1 bar=2 .. attribute:: prefix :type: str :value: "" String to prepend to all autogenerated environment variable names. Typically ends in ``_``, and is something like ``MY_APP_``. .. attribute:: source :type: str :value: "env" Identifier for the configuration source, used in error messages and token tracking. Defaults to ``"env"``. .. attribute:: command :type: bool :value: True If :obj:`True`, add the command's name (uppercase) after :attr:`prefix`. .. attribute:: show :type: bool :value: True If :obj:`True`, then show the environment variables on the help-page. ---------- Exceptions ---------- .. autoexception:: cyclopts.CycloptsError :show-inheritance: :members: .. autoexception:: cyclopts.ValidationError :show-inheritance: :members: .. autoexception:: cyclopts.UnknownOptionError :show-inheritance: :members: .. autoexception:: cyclopts.CoercionError :show-inheritance: :members: .. autoexception:: cyclopts.UnknownCommandError :show-inheritance: :members: .. autoexception:: cyclopts.UnusedCliTokensError :show-inheritance: :members: .. autoexception:: cyclopts.MissingArgumentError :show-inheritance: :members: .. autoexception:: cyclopts.ConsumeMultipleError :show-inheritance: :members: .. autoexception:: cyclopts.RequiresEqualsError :show-inheritance: :members: .. autoexception:: cyclopts.RepeatArgumentError :show-inheritance: :members: .. autoexception:: cyclopts.MixedArgumentError :show-inheritance: :members: .. autoexception:: cyclopts.CommandCollisionError :show-inheritance: :members: .. autoexception:: cyclopts.CombinedShortOptionError :show-inheritance: :members: .. autoexception:: cyclopts.EditorError :show-inheritance: :members: .. autoexception:: cyclopts.EditorNotFoundError :show-inheritance: :members: .. autoexception:: cyclopts.EditorDidNotSaveError :show-inheritance: :members: .. autoexception:: cyclopts.EditorDidNotChangeError :show-inheritance: :members: BrianPugh-cyclopts-921b1fa/docs/source/app_calling.rst000066400000000000000000000170111517576204000231000ustar00rootroot00000000000000=========================== App Calling & Return Values =========================== In this section, we'll take a closer look at the :meth:`.App.__call__` method. ------------- Input Command ------------- Typically, a Cyclopts app looks something like: .. code-block:: python from cyclopts import App app = App() @app.command def foo(a: int, b: int, c: int): print(a + b + c) app() .. code-block:: console $ my-script 1 2 3 6 :meth:`.App.__call__` takes in an optional input that it parses into an action. If not specified, Cyclopts defaults to :data:`sys.argv[1:] `, i.e. the list of command line arguments. An explicit string or list of strings can instead be passed in. .. code-block:: python app("foo 1 2 3") # 6 app(["foo", "1", "2", "3"]) # 6 If a string is passed in, it will be internally converted into a list using `shlex.split `_. ------------ Return Value ------------ The ``app`` invocation processes the command's return value based on :attr:`.App.result_action`. By default, Cyclopts calls :func:`sys.exit` with an appropriate exit code: .. code-block:: python from cyclopts import App app = App() # Default result_action="print_non_int_sys_exit" @app.command def success(): return 0 # Exit code for success @app.command def greet(name: str) -> str: return f"Hello {name}!" # Prints and exits with 0 if __name__ == "__main__": app() # Will call sys.exit with the returned 0 error code (success). `Installed scripts `_ call :func:`sys.exit` with the returned value of the entry point. So the default Cyclopts :attr:`.App.result_action` will have consistent behavior for standalone scripts and installed apps. For embedding Cyclopts in other Python code or testing, use ``result_action="return_value"`` to get the raw command return value without calling :func:`sys.exit`: .. code-block:: python from cyclopts import App app = App(result_action="return_value") @app.command def foo(a: int, b: int, c: int): return a + b + c return_value = app("foo 1 2 3") # no longer exits! print(f"The return value was: {return_value}.") # The return value was: 6. See :ref:`Result Action` for all available modes and detailed behavior. ------------------------------ Exception Handling and Exiting ------------------------------ For the most part, Cyclopts is **hands-off** when it comes to handling exceptions and exiting the application. However, by default, if there is a **Cyclopts runtime error**, like :exc:`.CoercionError` or a :exc:`.ValidationError`, then Cyclopts will perform a :func:`sys.exit(1) `. This is to avoid displaying the unformatted, uncaught exception to the CLI user. These behaviors can be controlled via :class:`.App` attributes or method parameters: - :attr:`.App.exit_on_error` - Calls :func:`sys.exit(1) ` on errors (defaults to :obj:`True`) - :attr:`.App.print_error` - Formatted errors are printed (defaults to :obj:`True`) - :attr:`.App.help_on_error` - The help-page is printed before errors (defaults to :obj:`False`) - :attr:`.App.verbose` - Include verbose error information that might be useful for **developers** using Cyclopts (defaults to :obj:`False`) - :attr:`.App.error_formatter` - Customize how error messages are formatted (defaults to :func:`.CycloptsPanel`) These attributes are inherited by child apps and can be overridden by providing parameters to method calls. .. note:: Cyclopts separates normal output from error messages using two different consoles: - :attr:`.App.console` - Used for normal output like help messages and version information (defaults to stdout) - :attr:`.App.error_console` - Used for error messages like parsing errors and exceptions (defaults to stderr) **Setting at App Level:** .. code-block:: python # Configure error handling at the app level app = App( exit_on_error=False, # Don't exit on errors print_error=False, # Don't print formatted errors ) # Child apps inherit these settings child_app = App(name="child") app.command(child_app) **Method-Level Override:** .. code-block:: python app("this-is-not-a-registered-command") print("this will not be printed since cyclopts exited above.") # ╭─ Error ─────────────────────────────────────────────────────────────╮ # │ Unknown command "this-is-not-a-registered-command". │ # ╰─────────────────────────────────────────────────────────────────────╯ app("this-is-not-a-registered-command", exit_on_error=False, print_error=False) # Traceback (most recent call last): # File "/cyclopts/scratch.py", line 9, in # app("this-is-not-a-registered-command", exit_on_error=False, print_error=False) # File "/cyclopts/cyclopts/core.py", line 1102, in __call__ # command, bound, _ = self.parse_args( # File "/cyclopts/cyclopts/core.py", line 1037, in parse_args # command, bound, unused_tokens, ignored, argument_collection = self._parse_known_args( # File "/cyclopts/cyclopts/core.py", line 966, in _parse_known_args # raise UnknownCommandError(unused_tokens=unused_tokens) # cyclopts.exceptions.UnknownCommandError: Unknown command "this-is-not-a-registered-command". try: app("this-is-not-a-registered-command", exit_on_error=False, print_error=False) except CycloptsError: pass print("Execution continues since we caught the exception.") With ``exit_on_error=False``, the ``UnknownCommandError`` is raised the same as a normal python exception. .. _Custom Error Formatting: ----------------------- Custom Error Formatting ----------------------- By default, Cyclopts displays errors using :func:`.CycloptsPanel`, which renders a Rich panel: .. code-block:: text ╭─ Error ───────────────────────────────────────────────╮ │ Invalid value "foo" for "VALUE": unable to convert │ │ "foo" into int. │ ╰───────────────────────────────────────────────────────╯ To customize this, set :attr:`.App.error_formatter` to a callable that receives a :exc:`.CycloptsError` and returns any Rich-printable object. .. code-block:: python from cyclopts import App, CycloptsError def my_error_formatter(e: CycloptsError): return f"[bold red]error[/bold red]: {e}" app = App(error_formatter=my_error_formatter) @app.default def main(value: int): pass .. code-block:: text $ my-app foo error: Invalid value "foo" for "VALUE": unable to convert "foo" into int. The formatter receives the full :exc:`.CycloptsError` exception, which contains context like the :attr:`~.CycloptsError.command_chain`, :attr:`~.CycloptsError.argument`, and :attr:`~.CycloptsError.target`. Use ``str(e)`` for just the message text. Like other error-handling attributes, ``error_formatter`` can also be passed as a runtime override: .. code-block:: python app.parse_args("foo", error_formatter=my_error_formatter) BrianPugh-cyclopts-921b1fa/docs/source/args_and_kwargs.rst000066400000000000000000000044551517576204000237730ustar00rootroot00000000000000.. _Args & Kwargs: ============= Args & Kwargs ============= In python, a function can consume a variable number of positional and keyword arguments: .. code-block:: python def foo(normal_required_variable, *args, **kwargs): pass There is **nothing special** about the names ``args`` and ``kwargs``; the functionality is derived from the leading ``*`` and ``**``. ``args`` and ``kwargs`` are the defacto standard names for these variables. In this document, we'll usually just refer to them as ``*args`` and ``**kwargs``. Cyclopts commands may consume a variable number of positional and keyword arguments. The priority ruleset is as follows: 1. ``--keyword`` CLI arguments first get matched to normal variable parameters. 2. Unmatched keywords get consumed by ``**kwargs``, if specified. 3. All remaining tokens get consumed by ``*args``, if specified. A prevalant use-case is in a typical :ref:`Meta App`. .. _Args & Kwargs - Args: -------------------------- Args (Variable Positional) -------------------------- A variable number of positional arguments consume all remaining positional arguments from the command-line. Individual elements are converted to the annotated type. .. code-block:: python from cyclopts import App app = App() @app.command def foo(name: str, *favorite_numbers: int): print(f"{name}'s favorite numbers are: {favorite_numbers}") app() .. code-block:: console $ my-script foo Brian Brian's favorite numbers are: () $ my-script foo Brian 777 Brian's favorite numbers are: (777,) $ my-script foo Brian 777 2 Brian's favorite numbers are: (777, 2) .. _Args & Kwargs - Kwargs: -------------------------- Kwargs (Variable Keywords) -------------------------- A variable number of keyword arguments consume all remaining CLI tokens starting with ``--``. Individual values are converted to the annotated type. .. code-block:: python from cyclopts import App app = App() @app.command def add(**country_to_capitols): for country, capitol in country_to_capitols.items(): print(f"Adding {country} with capitol {capitol}.") app() .. code-block:: console $ my-script add --united-states="Washington, D.C." --canada=Ottawa Adding united-states with capitol Washington, D.C.. Adding canada with capitol Ottawa. BrianPugh-cyclopts-921b1fa/docs/source/autoregistry.rst000066400000000000000000000077301517576204000233770ustar00rootroot00000000000000============ AutoRegistry ============ AutoRegistry_ is a python library that automatically creates string-to-functionality mappings, making it trivial to instantiate classes or invoke functions from CLI parameters. Lets consider the following program that can download a file from either a GCP, AWS, or Azure bucket (without worrying about the implementation): .. code-block:: python import cyclopts from pathlib import Path from typing import Literal def _download_gcp(bucket: str, key: str, dst: Path): print("Downloading data from Google.") def _download_s3(bucket: str, key: str, dst: Path): print("Downloading data from Amazon.") def _download_azure(bucket: str, key: str, dst: Path): print("Downloading data from Azure.") _downloaders = { "gcp": _download_gcp, "s3": _download_s3, "azure": _download_azure, } app = cyclopts.App() @app.command def download(bucket: str, key: str, dst: Path, provider: Literal[tuple(_downloaders)] = "gcp"): downloader = _downloaders[provider] downloader(bucket, key, dst) app() .. code-block:: console $ my-script download --help ╭─ Parameters ────────────────────────────────────────────────────────────╮ │ * BUCKET,--bucket [required] │ │ * KEY,--key [required] │ │ * DST,--dst [required] │ │ PROVIDER,--provider [choices: gcp,s3,azure] [default: gcp] │ ╰─────────────────────────────────────────────────────────────────────────╯ $ my-script my-bucket my-key local.bin --provider=s3 Downloading data from Amazon. Not bad, but let's see how this would look with AutoRegistry. .. code-block:: python import cyclopts from autoregistry import Registry from pathlib import Path from typing import Literal _downloaders = Registry(prefix="_download_") @_downloaders def _download_gcp(bucket: str, key: str, dst: Path): print("Downloading data from Google.") @_downloaders def _download_s3(bucket: str, key: str, dst: Path): print("Downloading data from Amazon.") @_downloaders def _download_azure(bucket: str, key: str, dst: Path): print("Downloading data from Azure.") app = cyclopts.App() @app.command def download(bucket: str, key: str, dst: Path, provider: Literal[tuple(_downloaders)] = "gcp"): downloader = _downloaders[provider] downloader(bucket, key, dst) app() .. code-block:: console $ my-script download --help ╭─ Parameters ────────────────────────────────────────────────────────────╮ │ * BUCKET,--bucket [required] │ │ * KEY,--key [required] │ │ * DST,--dst [required] │ │ PROVIDER,--provider [choices: gcp,s3,azure] [default: gcp] │ ╰─────────────────────────────────────────────────────────────────────────╯ $ my-script my-bucket my-key local.bin --provider=s3 Downloading data from Amazon. Exactly the same functionality, but more terse and organized. With Autoregistry, the download providers are much more self-contained, do not require changes in other code locations, and reduce duplication. .. _AutoRegistry: https://github.com/BrianPugh/autoregistry BrianPugh-cyclopts-921b1fa/docs/source/cli_reference.rst000066400000000000000000000002731517576204000234160ustar00rootroot00000000000000============= CLI Reference ============= The ``cyclopts`` package includes a command-line interface for various development tasks. .. cyclopts:: cyclopts.cli:app :flatten-commands: BrianPugh-cyclopts-921b1fa/docs/source/command_chaining.rst000066400000000000000000000025521517576204000241110ustar00rootroot00000000000000.. _Command Chaining: ================ Command Chaining ================ Cyclopts does not natively support command chaining. This is because Cyclopts opted for more flexible and robust CLI parsing, rather than a compromised, inconsistent parsing experience. With that said, Cyclopts gives you the tools to create your own command chaining experience. In this example, we will use a special delimiter token (e.g. ``"AND"``) to separate commands. .. code-block:: python import itertools from cyclopts import App, Parameter from typing import Annotated app = App() @app.command def foo(val: int): print(f"FOO {val=}") @app.command def bar(flag: bool): print(f"BAR {flag=}") @app.meta.default def main(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]): # tokens is ``["foo", "123", "AND", "foo", "456", "AND", "bar", "--flag"]`` delimiter = "AND" groups = [list(group) for key, group in itertools.groupby(tokens, lambda x: x == delimiter) if not key] or [[]] # groups is ``[['foo', '123'], ['foo', '456'], ['bar', '--flag']]`` for group in groups: # Execute each group app(group) if __name__ == "__main__": app.meta(["foo", "123", "AND", "foo", "456", "AND", "bar", "--flag"]) # FOO val=123 # FOO val=456 # BAR flag=True BrianPugh-cyclopts-921b1fa/docs/source/commands.rst000066400000000000000000000224631517576204000224370ustar00rootroot00000000000000.. _Commands: ======== Commands ======== There are two different ways of registering functions: 1. :meth:`app.default ` - Registers an action for when no registered command is provided. This was previously demonstrated in :ref:`Getting Started`. A sub-app **cannot** be registered with :meth:`app.default `. If no ``default`` command is registered, Cyclopts will display the help-page. 2. :meth:`app.command ` - Registers a function or :class:`.App` as a command. This section will detail how to use the :meth:`@app.command ` decorator. --------------------- Registering a Command --------------------- The :meth:`@app.command ` decorator adds a **command** to a Cyclopts application. .. code-block:: python from cyclopts import App app = App() @app.command def fizz(n: int): print(f"FIZZ: {n}") @app.command def buzz(n: int): print(f"BUZZ: {n}") app() We can now control which command runs from the CLI: .. code-block:: console $ my-script fizz 3 FIZZ: 3 $ my-script buzz 4 BUZZ: 4 $ my-script fuzz ╭─ Error ────────────────────────────────────────────────────────────────────╮ │ Unknown command "fuzz". Did you mean "fizz"? │ ╰────────────────────────────────────────────────────────────────────────────╯ ------------------------ Registering a SubCommand ------------------------ The :meth:`app.command ` method can also register another Cyclopts :class:`.App` as a command. .. code-block:: python from cyclopts import App app = App() sub_app = App(name="foo") # "foo" would be a better variable name than "sub_app". # "sub_app" in this example emphasizes the name comes from name="foo". app.command(sub_app) # Registers sub_app to command "foo" # Or, as a one-liner: sub_app = app.command(App(name="foo")) @sub_app.command def bar(n: int): print(f"BAR: {n}") # Alternatively, access subapps from app like a dictionary. @app["foo"].command def baz(n: int): print(f"BAZ: {n}") app() .. code-block:: console $ my-script foo bar 3 BAR: 3 $ my-script foo baz 4 BAZ: 4 The subcommand may have their own registered ``default`` action. Cyclopts's command structure is fully recursive. .. _Flattening SubCommands: ----------------------- Flattening SubCommands ----------------------- Sometimes you want to make all commands from a sub-app directly accessible from the parent app, without requiring users to type the intermediate subcommand name. You can flatten a sub-app by registering it with the special ``name="*"``: .. code-block:: python from cyclopts import App app = App() tools_app = App(name="tools") @tools_app.command def compress(file: str): print(f"Compressing {file}") @tools_app.command def extract(file: str): print(f"Extracting {file}") # Flatten: make all tools_app commands directly accessible app.command(tools_app, name="*") app() .. code-block:: console $ my-script compress data.txt Compressing data.txt $ my-script extract archive.zip Extracting archive.zip Caveats of flattening: - Parent app commands take precedence over flattened commands if there are name collisions. - Multiple sub-apps can be flattened into the same parent app. - You cannot supply additional configuration kwargs when using ``name="*"``. - Only :class:`.App` instances can be flattened (not functions or import paths). Flattening is useful for organizing related commands into logical groups in your code while keeping the CLI interface simple and flat. ------------------------ SubCommand Configuration ------------------------ Subcommands inherit configuration from their parent apps. .. code-block:: python from cyclopts import App # Root app with specific error handling root_app = App( exit_on_error=False, print_error=False, ) # Child app inherits parent's settings child_app = root_app.command(App(name="child")) @child_app.default def child_action(): return "Child executed successfully" # Child can override parent settings if needed grandchild_app = child_app.command(App(name="grandchild", exit_on_error=True)) When ``parent_app("child ...")`` is called, the child command will use the parent's error handling settings unless explicitly overridden. .. _Command Changing Name: --------------------- Changing Command Name --------------------- By default, commands are registered to the python function's name with underscores replaced with hyphens. Any leading or trailing underscores will be stripped. For example, the function ``_foo_bar()`` will become the command ``foo-bar``. This renaming is done because CLI programs generally tend to use hyphens instead of underscores. The name transform can be configured by :attr:`App.name_transform `. For example, to make CLI command names be identical to their python function name counterparts, we can configure :class:`~cyclopts.App` as follows: .. code-block:: python from cyclopts import App app = App(name_transform=lambda s: s) @app.command def foo_bar(): # will now be "foo_bar" instead of "foo-bar" print("running function foo_bar") app() .. code-block:: console $ my-script foo_bar running function foo_bar Alternatively, the name can be **manually** changed in the :meth:`@app.command ` decorator. Manually set names are **not** subject to :attr:`App.name_transform `. .. code-block:: python from cyclopts import App app = App() @app.command(name="bar") def foo(): # function name will NOT be used. print("Hello World!") app() .. code-block:: console $ my-script bar Hello World! Finally, if you would like to register an **additional** name to the Cyclopts-derived names, you can set an :attr:`~.App.alias`: .. code-block:: python from cyclopts import App app = App() @app.command(alias="bar") def foo(): # both "foo" and "bar" will trigger this function. print("Running foo.") app() .. code-block:: console $ my-script foo Running bar. $ my-script bar Running bar. ----------- Adding Help ----------- There are a few ways to add a help string to a command: 1. If the function has a docstring, the **short description** will be used as the help string for the command. This is generally the preferred method of providing help strings. 2. If the registered command is a sub app, the sub app's :attr:`help ` field will be used. .. code-block:: python sub_app = App(name="foo", help="Help text for foo.") app.command(sub_app) 3. The :attr:`help ` field of :meth:`@app.command `. If provided, the docstring or subapp help field will **not** be used. .. code-block:: python from cyclopts import App app = App() @app.command def foo(): """Help string for foo.""" pass @app.command(help="Help string for bar.") def bar(): """This got overridden.""" app() .. code-block:: console $ my-script --help ╭─ Commands ────────────────────────────────────────────────────────────╮ │ bar Help string for bar. │ │ foo Help string for foo. │ │ --help,-h Display this message and exit. │ │ --version Display application version. │ ╰───────────────────────────────────────────────────────────────────────╯ ----- Async ----- Cyclopts also works with **async** commands; when an async command is encountered, an event loop will be automatically created using the specified ``backend`` parameter (default :mod:`asyncio`). .. code-block:: python import asyncio from cyclopts import App app = App() @app.command async def foo(): await asyncio.sleep(10) app() When calling from within an existing async context, :keyword:`await` the async method :meth:`~cyclopts.App.run_async`: .. code-block:: python async def main(): result = await app.run_async(["foo"]) # Instead of: app(["foo"]) which would raise RuntimeError -------------------------- Decorated Function Details -------------------------- Cyclopts **does not modify the decorated function in any way**. The returned function is the **exact same function** being decorated and can be used exactly as if it were not decorated by Cyclopts. -------- See Also -------- For improved CLI startup performance with large applications, see :ref:`Lazy Loading`. BrianPugh-cyclopts-921b1fa/docs/source/conf.py000066400000000000000000000144201517576204000213750ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- import importlib import inspect import sys from datetime import date from pathlib import Path import git from sphinx.application import Sphinx from sphinx.ext.autodoc import Options from cyclopts import __version__ sys.path.insert(0, str(Path("../..").absolute())) git_repo = git.Repo(".", search_parent_directories=True) # type: ignore[reportPrivateImportUsage] git_commit = git_repo.head.commit # -- Project information ----------------------------------------------------- project = "cyclopts" copyright = f"{date.today().year}, Brian Pugh" author = "Brian Pugh" # The short X.Y version. version = __version__ # The full version, including alpha/beta/rc tags release = __version__ # -- General configuration --------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "myst_parser", "sphinx_rtd_theme", "sphinx_rtd_dark_mode", "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.linkcode", "sphinx_copybutton", "cyclopts.sphinx_ext", ] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] smartquotes = False # user starts in light mode default_dark_mode = False # Myst myst_enable_extensions = [ "linkify", ] # Intersphinx intersphinx_mapping = { "pydantic": ("https://docs.pydantic.dev/latest/", None), "python": ("https://docs.python.org/3", None), "rich": ("https://rich.readthedocs.io/en/stable/", None), "typing_extensions": ("https://typing-extensions.readthedocs.io/en/latest/", None), "pytest": ("https://docs.pytest.org/en/latest", None), } # Napoleon settings napoleon_google_docstring = True napoleon_numpy_docstring = True napoleon_include_init_with_doc = False napoleon_include_private_with_doc = False napoleon_include_special_with_doc = True napoleon_use_admonition_for_examples = False napoleon_use_admonition_for_notes = False napoleon_use_admonition_for_references = False napoleon_use_ivar = False napoleon_use_param = True napoleon_use_rtype = True napoleon_preprocess_types = False napoleon_type_aliases = None napoleon_attr_annotations = True # Autodoc autodoc_default_options = { "member-order": "bysource", "undoc-members": False, "exclude-members": "__weakref__", } autoclass_content = "both" # LinkCode code_url = f"https://github.com/BrianPugh/cyclopts/blob/{git_commit}" def linkcode_resolve(domain, info): """Link code to github. Modified from: https://github.com/python-websockets/websockets/blob/778a1ca6936ac67e7a3fe1bbe585db2eafeaa515/docs/conf.py#L100-L134 """ # Non-linkable objects from the starter kit in the tutorial. if domain == "js": return if domain != "py": raise ValueError("expected only Python objects") if not info.get("module"): # Documented via py:function:: return mod = importlib.import_module(info["module"]) if "." in info["fullname"]: objname, attrname = info["fullname"].split(".") obj = getattr(mod, objname) try: # object is a method of a class obj = getattr(obj, attrname) except AttributeError: # object is an attribute of a class return None else: obj = getattr(mod, info["fullname"]) try: file = inspect.getsourcefile(obj) lines = inspect.getsourcelines(obj) except TypeError: # e.g. object is a typing.Union return None if file is None: return None file = Path(file).resolve().relative_to(git_repo.working_dir) if file.parts[0] != "cyclopts": # e.g. object is a typing.NewType return None start, end = lines[1], lines[1] + len(lines[0]) - 1 return f"{code_url}/{file}#L{start}-L{end}" # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] html_title = project html_logo = "../../assets/logo_512w.png" html_favicon = "../../assets/favicon-192.png" html_theme_options = { "logo_only": True, "version_selector": True, "prev_next_buttons_location": "bottom", "style_external_links": False, "vcs_pageview_mode": "", "style_nav_header_background": "#F7E5B9", # Toc options "collapse_navigation": True, "sticky_navigation": True, "navigation_depth": 4, "includehidden": True, "titles_only": False, } html_context = { # Github options "display_github": True, "github_user": "BrianPugh", "github_repo": "cyclopts", "github_version": "main", "conf_py_path": "/docs/source/", } html_css_files = [ "custom.css", ] # --- Other Custom Stuff def simplify_exception_signature( app: Sphinx, what: str, name: str, obj, options: Options, signature, return_annotation ): # Check if the object is an exception and modify the signature if what == "exception" and isinstance(obj, type) and issubclass(obj, BaseException): return ("", None) # Return an empty signature and no return annotation def remove_attrs_methods(app, what, name, obj, options, lines): lines[:] = [line for line in lines if not line.startswith("Method generated by attrs for")] def setup(app: Sphinx): app.connect("autodoc-process-signature", simplify_exception_signature) app.connect("autodoc-process-docstring", remove_attrs_methods) BrianPugh-cyclopts-921b1fa/docs/source/config_file.rst000066400000000000000000000173601517576204000231020ustar00rootroot00000000000000.. _Config Files: ============ Config Files ============ For more complicated CLI applications, it is common to have an external user configuration file. For example, the popular python tools ``poetry``, ``ruff``, and ``pytest`` are all configurable from a ``pyproject.toml`` file. The :attr:`App.config ` attribute accepts a `callable `_ (or list of callables) that add (or remove) values to the parsed CLI tokens. The provided callable must have signature: .. code-block:: python def config(app: "App", commands: Tuple[str, ...], arguments: ArgumentCollection): """Modifies the argument collection inplace with some injected values. Parameters ---------- app: App The current command app being executed. commands: Tuple[str, ...] The CLI strings that led to the current command function. arguments: ArgumentCollection Complete ArgumentCollection for the app. Modify this collection inplace to influence values provided to the function. """ ... The provided ``config`` does not have to be a function; all the Cyclopts builtin configs are classes that implement the ``__call__`` method. The Cyclopts builtins offer good standard functionality for common configuration files like yaml or toml. .. _TOML Example: ------------ TOML Example ------------ In this example, we create a small CLI tool that counts the number of times a given character occurs in a file. .. code-block:: python # character-counter.py import cyclopts from cyclopts import App from pathlib import Path app = App( name="character-counter", config=cyclopts.config.Toml( "pyproject.toml", # Name of the TOML File root_keys=["tool", "character-counter"], # The project's namespace in the TOML. # If "pyproject.toml" is not found in the current directory, # then iteratively search parenting directories until found. search_parents=True, ), ) @app.command def count(filename: Path, *, character="-"): print(filename.read_text().count(character)) if __name__ == "__main__": app() Running this code without a ``pyproject.toml`` present: .. code-block:: console $ python character-counter.py count README.md 70 $ python character-counter.py count README.md --character=t 380 We can have the new default character be ``t`` by adding the following to ``pyproject.toml``: .. code-block:: toml [tool.character-counter.count] character = "t" Rerunning the app without a specified ``--character`` will result in using the toml-provided value: .. code-block:: console $ python character-counter.py count README.md 380 -------------------------- User-Specified Config File -------------------------- Extending the above :ref:`TOML Example`, what if we want to allow the user to specify the toml configuration file? This can be accomplished via a :ref:`Meta App`. .. code-block:: python # character-counter.py from pathlib import Path from typing import Annotated import cyclopts from cyclopts import App, Parameter app = App(name="character-counter") @app.command def count(filename: Path, *, character="-"): print(filename.read_text().count(character)) @app.meta.default def meta( *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], config: Path = Path("pyproject.toml"), ): app.config = cyclopts.config.Toml( config, root_keys=["tool", "character-counter"], search_parents=True, ) app(tokens) if __name__ == "__main__": app.meta() ---------------------------- Environment Variable Example ---------------------------- To automatically derive and read appropriate environment variables, use the :class:`cyclopts.config.Env` class. Continuing the above TOML example: .. code-block:: python # character-counter.py import cyclopts from pathlib import Path app = cyclopts.App( name="character-counter", config=cyclopts.config.Env( "CHAR_COUNTER_", # Every environment variable will begin with this. ), ) @app.command def count(filename: Path, *, character="-"): print(filename.read_text().count(character)) app() :class:`~cyclopts.config.Env` assembles the environment variable name by joining the following components (in-order): 1. The provided ``prefix``. In this case, it is ``"CHAR_COUNTER_"``. 2. The command and subcommand(s) that lead up to the function being executed. 3. The parameter's CLI name, with the leading ``--`` stripped, and hyphens ``-`` replaced with underscores ``_``. Running this code without a specified ``--character`` results in counting the default ``-`` character. .. code-block:: console $ python character-counter.py count README.md 70 By exporting a value to ``CHAR_COUNTER_COUNT_CHARACTER``, that value will now be used as the default: .. code-block:: console $ export CHAR_COUNTER_COUNT_CHARACTER=t $ python character-counter.py count README.md 380 $ python character-counter.py count README.md --character=q 3 -------------- In-Memory Dict -------------- For configurations that come from sources other than files, use :class:`cyclopts.config.Dict`. .. code-block:: python # character-counter.py import json import cyclopts from pathlib import Path def fetch_config(): """Simulate fetching configuration from an API.""" return {"count": {"character": "e"}} config_data = fetch_config_from_api() app = cyclopts.App( name="character-counter", config=cyclopts.config.Dict( fetch_config, # Optional: provide custom source identifier for better error messages source="api", ), ) @app.command def count(filename: Path, *, character="-"): print(filename.read_text().count(character)) if __name__ == "__main__": app() --------------------------------- Combining Multiple Config Sources --------------------------------- You can combine multiple config sources in a single application by passing a sequence to :attr:`App.config `. Each configuration is applied sequentially. In the following example, we combine a TOML file and environment variables, allowing environment variables to override TOML settings: .. code-block:: python # character-counter.py import cyclopts from pathlib import Path app = cyclopts.App( name="character-counter", config=[ # Since Env comes before Toml, it has priority. cyclopts.config.Env("CHAR_COUNTER_"), cyclopts.config.Toml( "pyproject.toml", root_keys=["tool", "character-counter"], search_parents=True, ), ], ) @app.command def count(filename: Path, *, character="-"): print(filename.read_text().count(character)) if __name__ == "__main__": app() With this setup, the configuration is resolved in the following order: 1. **CLI arguments** (if provided) override everything else 2. **Environment variables** (prefixed with ``CHAR_COUNTER_``) can override TOML values 3. **TOML file** (pyproject.toml) provides the base configuration 4. **Python default** the default value ``-`` in the python code. For example, with ``pyproject.toml`` containing: .. code-block:: toml [tool.character-counter.count] character = "t" You can override it via environment variable: .. code-block:: console $ CHAR_COUNTER_COUNT_CHARACTER=a python character-counter.py count README.md Or via CLI argument: .. code-block:: console $ python character-counter.py count README.md --character=x BrianPugh-cyclopts-921b1fa/docs/source/cookbook/000077500000000000000000000000001517576204000217035ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/cookbook/app_upgrade.rst000066400000000000000000000027631517576204000247340ustar00rootroot00000000000000=========== App Upgrade =========== It's best practice for users to install python-based CLIs via pipx_, where each application gets it's own python virtual environment. Whether done via ``pipx`` or standard ``pip``, updating your application can be done via the ``upgrade`` command. i.e.: .. code-block:: console $ pipx upgrade mypackage If you would like your CLI application to be able to upgrade itself, you can add the following command to your application: .. code-block:: python import mypackage import subprocess import sys from cyclopts import App app = App() @app.command def upgrade(): """Update mypackage to latest stable version.""" old_version = mypackage.__version__ subprocess.check_output([sys.executable, "-m", "pip", "install", "--upgrade", "pip"]) subprocess.check_output([sys.executable, "-m", "pip", "install", "--upgrade", "mypackage"]) res = subprocess.run([sys.executable, "-m", "mypackage", "--version"], stdout=subprocess.PIPE, check=True) new_version = res.stdout.decode().strip() if old_version == new_version: print(f"mypackage up-to-date (v{new_version}).") else: print(f"mypackage updated from v{old_version} to v{new_version}.") app() :obj:`sys.executable` points to the currently used python interpreter's path; if your package was installed via pipx_, then it points to the python interpreter in it's respective virtual environment. .. _pipx: https://github.com/pypa/pipx BrianPugh-cyclopts-921b1fa/docs/source/cookbook/dataclass_commands.rst000066400000000000000000000032261517576204000262600ustar00rootroot00000000000000.. _Dataclass Commands: ================== Dataclass Commands ================== An alternative command syntax is to use dataclasses with a ``__call__`` method. To support this pattern, Cyclopts provides the ``"call_if_callable"`` result action, which can be composed with other result actions. Basic Example ============= Here's a simple example using the dataclass command pattern: .. code-block:: python # greeter.py from dataclasses import dataclass, KW_ONLY from cyclopts import App app = App(result_action=["call_if_callable", "print_non_int_sys_exit"]) @app.command @dataclass class Greet: """Greet someone with a message.""" name: str = "World" _: KW_ONLY formal: bool = False def __call__(self): greeting = "Hello" if self.formal else "Hey" return f"{greeting} {self.name}." if __name__ == "__main__": app() Running this application: .. code-block:: console $ python greeter.py greet Hey World. $ python greeter.py greet Alice Hey Alice. $ python greeter.py greet Bob --formal Hello Bob. How It Works ============ The ``result_action=["call_if_callable", "print_non_int_sys_exit"]`` creates a pipeline: 1. **call_if_callable**: After parsing, Cyclopts creates an instance of the ``Greet`` dataclass. This action checks if the result is callable (it is, because of ``__call__``), and calls it with no arguments. 2. **print_non_int_sys_exit**: Takes the string returned by ``__call__`` and prints it, then exits. Without ``"call_if_callable"``, the app would try to print the dataclass instance itself instead of calling it and printing the result. BrianPugh-cyclopts-921b1fa/docs/source/cookbook/file_or_stdin_stdout.rst000066400000000000000000000120321517576204000266550ustar00rootroot00000000000000.. _Reading/Writing From File or Stdin/Stdout: ========================================= Reading/Writing From File or Stdin/Stdout ========================================= In many CLI applications, it's common to be able to read from a file or stdin, and write to a file or stdout. This allows for the chaining of many CLI applications via pipes ``|``. --------- StdioPath --------- .. note:: :class:`~cyclopts.types.StdioPath` requires **Python 3.12+**. For older Python versions, see :ref:`Alternative Approach (Python < 3.12)` below. The recommended approach is to use :class:`~cyclopts.types.StdioPath`, a :class:`~pathlib.Path` subclass that treats ``-`` as stdin (for reading) or stdout (for writing). This follows `common Unix convention `_ used by many command-line tools. .. code-block:: python from cyclopts import App from cyclopts.types import StdioPath app = App() @app.default def scream(input_: StdioPath, output: StdioPath): """Uppercase all input data. Parameters ---------- input_: Input file path, or "-" for stdin. output: Output file path, or "-" for stdout. """ data = input_.read_text() output.write_text(data.upper()) if __name__ == "__main__": app() .. code-block:: console $ echo "hello cyclopts users." > demo.txt $ python scream.py demo.txt - HELLO CYCLOPTS USERS. $ python scream.py demo.txt output.txt && cat output.txt HELLO CYCLOPTS USERS. $ echo "foo" | python scream.py - - FOO :class:`~cyclopts.types.StdioPath` is pre-configured with ``allow_leading_hyphen=True``, so ``-`` can be passed as an argument without being interpreted as an option. ^^^^^^^^^^^^^^^^^^^^^^^^^^^ Defaulting to Stdin/Stdout ^^^^^^^^^^^^^^^^^^^^^^^^^^^ To make stdin/stdout the default when no argument is provided, use ``StdioPath("-")`` as the default value: .. code-block:: python from cyclopts import App from cyclopts.types import StdioPath app = App() @app.default def scream(input_: StdioPath = StdioPath("-"), output: StdioPath = StdioPath("-")): """Uppercase all input data. Parameters ---------- input_: Input file path. Defaults to stdin if not provided. output: Output file path. Defaults to stdout if not provided. """ data = input_.read_text() output.write_text(data.upper()) if __name__ == "__main__": app() .. code-block:: console $ echo "hello cyclopts users." > demo.txt $ python scream.py demo.txt HELLO CYCLOPTS USERS. $ python scream.py demo.txt output.txt && cat output.txt HELLO CYCLOPTS USERS. $ echo "foo" | python scream.py FOO ^^^^^^^^^^^^^ Binary Data ^^^^^^^^^^^^^ ``StdioPath`` also supports binary reading and writing: .. code-block:: python @app.default def process_binary(input_: StdioPath = StdioPath("-"), output: StdioPath = StdioPath("-")): data = input_.read_bytes() output.write_bytes(data) Or using the context manager interface: .. code-block:: python @app.default def process_binary(input_: StdioPath = StdioPath("-"), output: StdioPath = StdioPath("-")): with input_.open("rb") as f_in, output.open("wb") as f_out: f_out.write(f_in.read()) .. _Alternative Approach (Python < 3.12): ------------------------------------- Alternative Approach (Python < 3.12) ------------------------------------- For Python versions before 3.12, or when you prefer an ``Optional[Path]`` pattern where ``None`` indicates stdin/stdout, you can use helper functions: .. code-block:: python import sys from cyclopts import App from pathlib import Path from typing import Optional def read_str(input_: Optional[Path]) -> str: return sys.stdin.read() if input_ is None else input_.read_text() def write_str(output: Optional[Path], data: str): sys.stdout.write(data) if output is None else output.write_text(data) def read_bytes(input_: Optional[Path]) -> bytes: return sys.stdin.buffer.read() if input_ is None else input_.read_bytes() def write_bytes(output: Optional[Path], data: bytes): sys.stdout.buffer.write(data) if output is None else output.write_bytes(data) app = App() @app.default def scream(input_: Optional[Path] = None, output_: Optional[Path] = None): """Uppercase all input data. Parameters ---------- input_ : Optional[Path] If provided, read data from file. If not provided, read from stdin. output_ : Optional[Path] If provided, write data to file. If not provided, write to stdout. """ data = read_str(input_) processed = data.upper() write_str(output_, processed) if __name__ == "__main__": app() .. code-block:: console $ echo "hello cyclopts users." > demo.txt $ python scream.py demo.txt HELLO CYCLOPTS USERS. $ python scream.py demo.txt output.txt $ cat output.txt HELLO CYCLOPTS USERS. $ echo "foo" | python scream.py FOO BrianPugh-cyclopts-921b1fa/docs/source/cookbook/interactive_help.rst000066400000000000000000000104521517576204000257640ustar00rootroot00000000000000======================== Interactive Shell & Help ======================== Cyclopts has a `builtin interactive shell-like feature <../api.html#cyclopts.App.interactive_shell>`__: .. code-block:: python from cyclopts import App app = App() @app.command def foo(p1): """Foo Docstring. Parameters ---------- p1: str Foo's first parameter. """ print(f"foo {p1}") @app.command def bar(p1): """Bar Docstring. Parameters ---------- p1: str Bar's first parameter. """ print(f"bar {p1}") # A blocking call, launching an interactive shell. app.interactive_shell(prompt="cyclopts> ") To make the application still work as-expected from the CLI, it is more appropriate to set a command (or ``@app.default``) to launch the shell: .. code-block:: python @app.command def shell(): app.interactive_shell() if __name__ == "__main__": app() # Don't call ``app.interactive_shell()`` here. Special flags like ``--help`` and ``--version`` work in the shell, but could be a bit awkward for the root-help: .. code-block:: console $ python interactive-shell-demo.py Interactive shell. Press Ctrl-D to exit. cyclopts> --help Usage: interactive-shell-demo.py COMMAND ╭─ Parameters ──────────────────────────────────────────────────╮ │ --version Display application version. │ │ --help -h Display this message and exit. │ ╰───────────────────────────────────────────────────────────────╯ ╭─ Commands ────────────────────────────────────────────────────╮ │ bar Bar Docstring. │ │ foo Foo Docstring. │ ╰───────────────────────────────────────────────────────────────╯ cyclopts> foo --help Usage: interactive-shell-demo.py foo [ARGS] [OPTIONS] Foo Docstring ╭─ Parameters ──────────────────────────────────────────────────╮ │ * P1,--p1 Foo's first parameter. [required] │ ╰───────────────────────────────────────────────────────────────╯ cyclopts> To resolve this, we can explicitly add a ``help`` command: .. code-block:: python @app.command def help(): """Display the help screen.""" app.help_print() .. code-block:: console $ python interactive-shell-demo.py Interactive shell. Press Ctrl-D to exit. cyclopts> help Usage: interactive-shell-demo.py COMMAND ╭─ Parameters ──────────────────────────────────────────────────╮ │ --version Display application version. │ │ --help -h Display this message and exit. │ ╰───────────────────────────────────────────────────────────────╯ ╭─ Commands ────────────────────────────────────────────────────╮ │ bar Bar Docstring. │ │ foo Foo Docstring. │ │ help Display the help screen. │ ╰───────────────────────────────────────────────────────────────╯ BrianPugh-cyclopts-921b1fa/docs/source/cookbook/random_tips.rst000066400000000000000000000030641517576204000247570ustar00rootroot00000000000000=========== Random Tips =========== Improve discoverability by occasionally surfacing tips to users during normal CLI usage. ----------- Basic Usage ----------- Use a :ref:`Meta App` to display a random tip after each command invocation. This keeps tip logic in one place rather than repeating it in every command. .. code-block:: python import random import sys from typing import Annotated from cyclopts import App, Parameter app = App() tips = [ "Use 'my-app config --help' to see all configuration options.", "Set the MY_APP_DEBUG=1 environment variable for verbose output.", "Commands can be abbreviated: 'my-app d' matches 'my-app deploy'.", "Suppress tips by setting MY_APP_NO_TIPS=1.", ] @app.command def build(): """Build the project.""" print("Building...") @app.command def deploy(): """Deploy the project.""" print("Deploying...") @app.meta.default def launcher( *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], no_tips: Annotated[bool, Parameter(env_var="MY_APP_NO_TIPS", negative="")] = False, ): app(tokens) if not no_tips and random.random() < 0.3: print(f"\n💡 Tip: {random.choice(tips)}", file=sys.stderr) if __name__ == "__main__": app.meta() .. code-block:: console $ python my-app.py build Building... 💡 Tip: Set the MY_APP_DEBUG=1 environment variable for verbose output. $ MY_APP_NO_TIPS=1 python my-app.py build Building... BrianPugh-cyclopts-921b1fa/docs/source/cookbook/rich_formatted_exceptions.rst000066400000000000000000000114411517576204000276710ustar00rootroot00000000000000========================= Rich Formatted Exceptions ========================= Tracebacks of uncaught exceptions provide valuable feedback for debugging. This guide demonstrates how to enhance your error messages using rich formatting. ------------------------- Standard Python Traceback ------------------------- Consider the following example: .. code-block:: python from cyclopts import App app = App() @app.default def main(name: str): print(name + 3) if __name__ == "__main__": app() Running this script will produce a standard Python traceback: .. code-block:: console $ python my-script.py foo Traceback (most recent call last): File "/cyclopts/my-script.py", line 12, in app() File "/cyclopts/cyclopts/core.py", line 903, in __call__ return command(*bound.args, **bound.kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/cyclopts/my-script.py", line 8, in main print(name + 3) ~~~~~^~~ TypeError: can only concatenate str (not "int") to str ------------------------ Rich Formatted Traceback ------------------------ To create a more visually appealing and informative traceback, you can use the `Rich library's traceback handler`_. Here's how to modify your script: .. code-block:: python import sys from cyclopts import App from rich.console import Console from rich.traceback import install as install_rich_traceback error_console = Console(stderr=True) app = App(console=console, error_console=error_console) # Install rich traceback handler using the error console install_rich_traceback(console=error_console) @app.default def main(name: str): print(name + 3) if __name__ == "__main__": app() Now, running the updated script will display a rich-formatted traceback: .. code-block:: console $ python my-script.py foo ╭──────────────── Traceback (most recent call last) ─────────────────╮ │ /cyclopts/my-script.py:16 in │ │ │ │ 13 │ │ 14 if __name__ == "__main__": │ │ 15 │ try: │ │ ❱ 16 │ │ app() │ │ 17 │ except Exception: │ │ 18 │ │ console.print_exception(width=70) │ │ 19 │ │ │ │ /cyclopts/cyclopts/core.py:903 in __call__ │ │ │ │ 900 │ │ │ │ │ │ 901 │ │ │ │ return asyncio.run(command(*bound.args, **b │ │ 902 │ │ │ else: │ │ ❱ 903 │ │ │ │ return command(*bound.args, **bound.kwargs) │ │ 904 │ │ except Exception as e: │ │ 905 │ │ │ try: │ │ 906 │ │ │ │ from pydantic import ValidationError as Pyd │ │ │ │ /cyclopts/my-script.py:11 in main │ │ │ │ 8 │ │ 9 @app.default │ │ 10 def main(name: str): │ │ ❱ 11 │ print(name + 3) │ │ 12 │ │ 13 │ │ 14 if __name__ == "__main__": │ ╰────────────────────────────────────────────────────────────────────╯ This rich-formatted traceback provides a more readable and visually appealing representation of the error, but may make copy/pasting for sharing a bit more cumbersome. .. _Rich library's traceback handler: https://rich.readthedocs.io/en/stable/traceback.html#printing-tracebacks BrianPugh-cyclopts-921b1fa/docs/source/cookbook/sharing_parameters.rst000066400000000000000000000127021517576204000263150ustar00rootroot00000000000000================== Sharing Parameters ================== Many subcommands within a CLI may take the same parameters. For example, all commands for a CLI that deals with a remote server might need a ``url`` and ``port`` number. Furthermore, there might be common setup required, such as connecting to the remote server. If you are familiar with `Click`_, this would be accomplished with `contexts `_. In Cyclopts, there are 2 ways to accomplish this: 1. With a :ref:`meta app `. While powerful, it's admittantly a bit heavy-handed and clunky. 2. Via a common dataclass that is passed to each command. While less powerful than using a meta-app, it still accomplishes many of the same goals with simpler, terser code. In this section, we'll be investigating option (2) by constructing an example application that has 2 commands: 1. ``create`` - Connect to a server and send a POST command to it. 2. ``info`` - Connect to a server and GET information about a user. .. code-block:: python # demo.py from cyclopts import App, Parameter from cyclopts.types import UInt16 from dataclasses import dataclass from functools import cached_property from httpx import Client @Parameter(name="*") # Flatten the namespace; i.e. option will be "--url" instead of "--common.url" @dataclass class Common: url: str = "http://cyclopts.readthedocs.io" "URL of remote server." port: UInt16 = 8080 # an "int" that is limited to range [0, 65535] "Port of remote server." verbose: bool = False "Increased printing verbosity." def __post_init__(self): # dataclasses call this method after calling the auto-generated __init__. if self.verbose: print(f"Server: {self.base_url}") @property def base_url(self) -> str: return f"{self.url}:{self.port}" @cached_property def client(self) -> Client: return Client(base_url=self.base_url) app = App() @app.command def create(name: str, age: int, *, common: Common | None = None): """Create a user on remote server. Parameters ---------- name: str Name of the user to create. age: int Age of the user in years. """ if common is None: common = Common() json = {"name": name, "age": age} if common.verbose: print(f"Creating user: {json}") common.client.post("/users", json=json) # TODO: in a real application, we should error-check the response here. @app.command def info(name: str, *, common: Common | None = None): """List a user on remote server. Parameters ---------- name: str Name of the user to get info about. """ if common is None: common = Common() response = common.client.get("/users", params={"name": name}) user = response.json() print(f"User: {user}") if __name__ == "__main__": app() From the root help-page, we can see our two commands: .. code-block:: console $ python demo.py --help Usage: demo.py COMMAND ╭─ Commands ─────────────────────────────────────────────────────────────────╮ │ create Create a user on remote server. │ │ info List a user on remote server. │ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────────────╯ From the ``create`` help-page, we can see all of our parameters: .. code-block:: console $ python demo.py create --help Usage: demo.py create [ARGS] [OPTIONS] Create a user on remote server. ╭─ Parameters ───────────────────────────────────────────────────────────────╮ │ * NAME --name Name of the user to create. [required] │ │ * AGE --age Age of the user in years. [required] │ │ --url URL of remote server. [default: │ │ http://cyclopts.readthedocs.io] │ │ --port Port of remote server. [default: 8080] │ │ --verbose --no-verbose Increased printing verbosity. [default: False] │ ╰────────────────────────────────────────────────────────────────────────────╯ Some example command-line invocations: .. code-block:: console $ python demo.py create Alice 42 # No response from the CLI. $ python demo.py create Alice 42 --verbose Creating user: {'name': 'Alice', 'age': 42} By organizing the code this way, we can centralize shared parameters and logic between many commands. .. _Click: https://click.palletsprojects.com BrianPugh-cyclopts-921b1fa/docs/source/cookbook/unit_testing.rst000066400000000000000000000336411517576204000251600ustar00rootroot00000000000000============ Unit Testing ============ It is important to have unit-tests to verify that your CLI is behaving correctly. For unit-testing, we will be using the defacto-standard python unit-testing library, pytest_. This section demonstrates some common scenarios you may encounter when unit-testing your CLI app. Lets make a small application that checks PyPI_ if a library name is available: .. code-block:: python # pypi_checker.py import sys import urllib.error import urllib.request import cyclopts def _check_pypi_name_available(name): try: urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json") except urllib.error.HTTPError as e: if e.code == 404: return True # Package does not exist (name is available) return False # Package exists (name is not available) app = cyclopts.App( config=[ cyclopts.config.Env("PYPI_CHECKER_"), cyclopts.config.Json("config.json"), ], ) @app.default def pypi_checker(name: str, *, silent: bool = False) -> bool: """Check if a package name is available on PyPI. Returns True if available; False otherwise. Parameters ---------- name: str Name of the package to check. silent: bool Do not print anything to stdout. """ is_available = _check_pypi_name_available(name) if not silent: if is_available: print(f"{name} is available.") else: print(f"{name} is not available.") return is_available if __name__ == "__main__": app() Running the app from the console: .. code-block:: console $ python pypi_checker.py --help Usage: pypi_checker COMMAND [ARGS] [OPTIONS] Check if a package name is available on PyPI. Returns True if available; False otherwise. ╭─ Commands ────────────────────────────────────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ──────────────────────────────────────────────────────────────────────────────────────╮ │ * NAME --name Name of the package to check. [env var: PYPI_CHECKER_NAME] [required] │ │ --silent --no-silent Do not print anything to stdout. [env var: PYPI_CHECKER_SILENT] │ │ [default: False] │ ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ $ python pypi_checker.py cyclopts cyclopts is not available. $ python pypi_checker.py cyclopts --silent $ echo $? # Check the exit code of the previous command. 1 $ python pypi_checker.py the-next-big-project the-next-big-project is available. $ echo $? # Check the exit code of the previous command. 0 We will slowly introduce unit-testing concepts and build up a fairly comprehensive set of unit-tests for this application. ------- Mocking ------- First off, it's good code-hygiene to separate "business logic" from "user interface." In this example, that means putting all the actual logic of determining whether or not a package name is available into the ``_check_pypi_name_available`` function, and putting all of the CLI logic (like printing to ``stdout`` and exit-codes) in the Cyclopts-decorated function ``pypi_checker``. This makes it easier to unit-test the app because it allows us to `mock `_ out portions of our app, allowing us to isolate our CLI unit-tests to just the CLI components. We can use `pytest-mock`_ to simplify mocking ``_check_pypi_name_available``. Let's define a `fixture`_ that declares this mock. .. code-block:: python # test.py import pytest from pypi_checker import app @pytest.fixture def mock_check_pypi_name_available(mocker): return mocker.patch("pypi_checker._check_pypi_name_available") Unit tests that use this fixture can define it's return value, as well as check the arguments it was called with. This will be demonstrated in the next section. ---------- Exit Codes ---------- Our command function returns a boolean. By default, Cyclopts uses :attr:`~cyclopts.App.result_action` of ``"print_non_int_sys_exit"``, which calls :func:`sys.exit` with the appropriate code: :obj:`True` → ``0`` (success), :obj:`False` → ``1`` (failure). .. code-block:: python import pytest def test_unavailable_name_cli_behavior(mock_check_pypi_name_available): # Set the mock return_value to False (i.e. the name is NOT available). mock_check_pypi_name_available.return_value = False with pytest.raises(SystemExit) as exc_info: app("foo") # Default result_action calls sys.exit mock_check_pypi_name_available.assert_called_once_with("foo") assert exc_info.value.code == 1 # Package unavailable exits with code 1 We can then run pytest on this file: .. code-block:: console $ pytest test.py ============================== test session starts ============================== platform darwin -- Python 3.13.0, pytest-8.3.4, pluggy-1.5.0 rootdir: /cyclopts-demo configfile: pyproject.toml plugins: cov-6.0.0, anyio-4.8.0, mock-3.14.0 collected 1 item test.py . [100%] =============================== 1 passed in 0.05s =============================== --------------- Checking stdout --------------- We also want to make sure that our message is displayed to the user. The built-in `capsys`_ fixture gives us access to our application's ``stdout``. We can use this to confirm our app prints the correct statement. By passing ``result_action="return_value"`` to the app call, we can get the return value directly without :func:`sys.exit` being called: .. code-block:: python # test.py - continued from "Exit Codes" def test_unavailable_name_with_output(capsys, mock_check_pypi_name_available): mock_check_pypi_name_available.return_value = False is_available = app("foo", result_action="return_value") mock_check_pypi_name_available.assert_called_once_with("foo") assert is_available is False assert capsys.readouterr().out == "foo is not available.\n" .. note:: Normal output goes to :attr:`~cyclopts.App.console` (stdout), while errors go to :attr:`~cyclopts.App.error_console` (stderr). Use ``capsys.readouterr().err`` to check error messages, or provide a custom ``error_console`` to capture both streams together. --------------------- Environment Variables --------------------- Because we configured our :class:`.App` with :class:`cyclopts.config.Env`, we can pass arguments into our application via environment variables. The `pytest monkeypatch fixture`_ allows us to modify environment variables within the context of a unit-test. In this test, we only want to test if our environment variable is being passed in correctly. We will use :meth:`.App.parse_args`, which performs all the parsing, but doesn't actually invoke the command. .. code-block:: python # test.py def test_name_env_var(monkeypatch): from pypi_checker import pypi_checker monkeypatch.setenv("PYPI_CHECKER_NAME", "foo") command, bound, _ = app.parse_args([]) # An empty list - no CLI arguments passed in. assert command == pypi_checker assert bound.arguments['name'] == "foo" .. warning:: A common mistake is accidentally calling ``app()`` or ``app.parse_args()`` with the **intent of providing no arguments**. Calling these methods with no arguments will read from :obj:`sys.argv`, the same as in a typical application. This is rarely the intention in a unit-test, and Cyclopts **will produce a warning.** For example, this code in a unit test: .. code-block:: python app() # Wrong: will produce a warning Will generate this warning: .. code-block:: text =============================== warnings summary ================================ test.py::test_no_args /my_project/test.py:64: UserWarning: Cyclopts application invoked without tokens under unit-test framework "pytest". Did you mean "app([])"? app() The proper way to specify no CLI arguments is to provide an empty string or list: .. code-block:: python app([]) ----------- File Config ----------- To explicitly test that configurations from the :ref:`Cyclopts configuration system ` are loading properly, we can create a configuration file in a temporary directory and change our current-working-directory (cwd) to that temporary directory. The pytest built-in ``tmp_path`` fixture gives us a temporary directory, and the ``monkeypatch`` fixture allows us to change the cwd. We have to change the cwd because typically configuration files are discovered relative to the directory where the CLI was invoked. If your CLI searches other locations (such as the home directory), you will need to modify this example appropriately. .. code-block:: python # test.py import json from pypi_checker import pypi_checker @pytest.fixture(autouse=True) def chdir_to_tmp_path(tmp_path, monkeypatch): "Automatically change current directory to tmp_path" monkeypatch.chdir(tmp_path) @pytest.fixture def config_path(tmp_path): "Path to JSON configuration file in tmp_path" return tmp_path / "config.json" # same name that was provided to cyclopts.config.Json def test_config(config_path): with config_path.open("w") as f: json.dump({"name": "bar"}, f) command, bound, _ = app.parse_args([]) # An empty list - no CLI arguments passed in. assert command == pypi_checker assert bound.arguments['name'] == "bar" --------- Help Page --------- Cyclopts uses Rich_ to pretty-print messages to the console. Rich interprets the console environment, and can change how it displays text depending on the terminal's capabilities. For unit testing, we will explicitly set a lot of these parameters in a pytest fixture to make it easier to compare against known good values: .. code-block:: python @pytest.fixture def console(): from rich.console import Console return Console(width=70, force_terminal=True, highlight=False, color_system=None, legacy_windows=False) Since the help-page is just printed to ``stdout``, we will be using the `capsys`_ fixture again. .. code-block:: python import pytest from textwrap import dedent def test_help_page(capsys, console): with pytest.raises(SystemExit): app("--help", console=console) actual = capsys.readouterr().out assert actual == dedent( """\ Usage: pypi_checker COMMAND [ARGS] [OPTIONS] Check if a package name is available on PyPI. Returns True if available; False otherwise. ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * NAME --name Name of the package to check. [required] │ │ --silent --no-silent Do not print anything to stdout. │ │ [default: False] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) The :func:`textwrap.dedent` function allows us to have our expected-help-string nicely indented within our code. Alternatively, we could have used the :meth:`rich.console.Console.capture` context manager to directly capture the :class:`rich.console.Console` output. .. note:: Unit-testing the help-page is probably overkill for most projects (and may get in the way more often than it helps!). .. _PyPI: https://pypi.org .. _pytest: https://docs.pytest.org/en/stable/ .. _pytest-mock: https://pytest-mock.readthedocs.io/en/latest/ .. _fixture: https://docs.pytest.org/en/stable/explanation/fixtures.html .. _capsys: https://docs.pytest.org/en/stable/how-to/capture-stdout-stderr.html#accessing-captured-output-from-a-test-function .. _pytest monkeypatch fixture: https://docs.pytest.org/en/stable/how-to/monkeypatch.html .. _Rich: https://rich.readthedocs.io/en/stable/ BrianPugh-cyclopts-921b1fa/docs/source/default_parameter.rst000066400000000000000000000147161517576204000243240ustar00rootroot00000000000000.. _Default Parameter: ================= Default Parameter ================= The default values of :class:`.Parameter` for an app can be configured via :attr:`.App.default_parameter`. For example, to disable the :attr:`~.Parameter.negative` flag feature across your entire app: .. code-block:: python from cyclopts import App, Parameter app = App(default_parameter=Parameter(negative=())) @app.command def foo(*, flag: bool): pass app() Consequently, ``--no-flag`` is no longer an allowed flag: .. code-block:: console $ my-script foo --help Usage: my-script foo [ARGS] [OPTIONS] ╭─ Parameters ──────────────────────────────────────────────────╮ │ * --flag [required] │ ╰───────────────────────────────────────────────────────────────╯ Explicitly annotating the parameter with :attr:`~.Parameter.negative` overrides this configuration and works as expected: .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App(default_parameter=Parameter(negative=())) @app.command def foo(*, flag: Annotated[bool, Parameter(negative="--anti-flag")]): pass app() .. code-block:: console $ my-script foo --help Usage: my-script foo [ARGS] [OPTIONS] ╭─ Parameters ──────────────────────────────────────────────────╮ │ * --flag --anti-flag [required] │ ╰───────────────────────────────────────────────────────────────╯ .. _Parameter Resolution Order: ---------------- Resolution Order ---------------- When resolving what the :class:`.Parameter` values for an individual function parameter should be, explicitly set attributes of higher priority :class:`.Parameter` s override lower priority :class:`.Parameter` s. The resolution order is as follows: 1. **Highest Priority:** Parameter-annotated command function signature ``Annotated[..., Parameter()]``. 2. :attr:`.Group.default_parameter` that the **parameter** belongs to. 3. :attr:`.App.default_parameter` of the **app** that registered the command. 4. :attr:`.Group.default_parameter` of the **app** that the function belongs to. 5. **Lowest Priority:** (2-4) recursively of the parenting app call-chain. Any of Parameter's fields can be set to `None` to revert back to the true-original Cyclopts default. .. _Skipping Private Parameters: --------------------------- Skipping Private Parameters --------------------------- The :attr:`.Parameter.parse` attribute can accept a **regex pattern** to selectively skip parameters based on their name. This is useful for defining "private" parameters that are externally injected (e.g. a :ref:`Meta App`, dependency-injection framework, etc) rather than parsed from the CLI. For example, to skip all underscore-prefixed parameters: .. code-block:: python from typing import Annotated from cyclopts import App, Parameter # The regex "^(?!_)" matches names that do NOT start with underscore. app = App(default_parameter=Parameter(parse="^(?!_)")) @app.command def greet(name: str, *, _db: Database): user = _db.get_user(name) print(f"Hello {user.full_name}!") @app.meta.default def launcher(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]): # Create shared resources db = Database("myapp.db") # Parse CLI and get ignored (non-parsed) parameters command, bound, ignored = app.parse_args(tokens) # Inject ignored parameters for name, type_ in ignored.items(): if type_ is Database: bound.kwargs[name] = db return command(*bound.args, **bound.kwargs) if __name__ == "__main__": app.meta() .. code-block:: console $ my-script --help Usage: my-script COMMAND ╭─ Commands ────────────────────────────────────────────────────╮ │ greet │ │ --help,-h Display this message and exit. │ │ --version Display application version. │ ╰───────────────────────────────────────────────────────────────╯ $ my-script greet --help Usage: my-script greet [ARGS] [OPTIONS] ╭─ Parameters ──────────────────────────────────────────────────╮ │ * NAME,--name [required] │ ╰───────────────────────────────────────────────────────────────╯ Notice that ``_db`` does not appear in the help screen. Parameters that don't match the regex pattern are added to the ``ignored`` dictionary returned by :meth:`.App.parse_args`, making them available for meta-app injection. Like all other :class:`Parameter` configurations, explicitly annotating with ``parse=True`` overrides the app-level regex: .. code-block:: python from typing import Annotated from cyclopts import App, Parameter app = App(default_parameter=Parameter(parse="^(?!_)")) @app.default def main(name: str, *, _verbose: Annotated[bool, Parameter(parse=True)] = False): """_verbose IS parsed despite the underscore prefix""" .. important:: Parameters that are not parsed (either via ``parse=False`` or a non-matching regex pattern) **must** be either: * Keyword-only (defined after ``*`` in the function signature), or * Have a default value .. code-block:: python # Valid: keyword-only parameter def main(*, _context: dict): ... # Valid: has default value def main(_context: dict = None): ... # Invalid: positional without default - raises ValueError def main(_context: dict): ... BrianPugh-cyclopts-921b1fa/docs/source/getting_started.rst000066400000000000000000000217131517576204000240220ustar00rootroot00000000000000.. _Getting Started: =============== Getting Started =============== Cyclopts relies heavily on function parameter type hints. If you are new to type hints or need a refresher, `checkout the mypy cheatsheet`_. ---------------------------- A Basic Cyclopts Application ---------------------------- The most basic Cyclopts application is as follows: .. code-block:: python from cyclopts import App app = App() @app.default def main(): print("Hello World!") if __name__ == "__main__": app() Save this as ``main.py`` and execute it to see: .. code-block:: console $ python main.py Hello World! The :class:`.App` class offers various configuration options that we'll explore in more detail later. The ``app`` object has a decorator method, :meth:`default `, which registers a function as the **default action**. In this example, the ``main`` function is our default action, and is executed when no CLI command is provided. ------------------ Function Arguments ------------------ Let's add some arguments to make this program a little more interesting. .. code-block:: python from cyclopts import App app = App() @app.default def main(name): print(f"Hello {name}!") if __name__ == "__main__": app() Executing the script with the argument ``Alice`` produces the following: .. code-block:: console $ python main.py Alice Hello Alice! Code explanation: 1. The function ``main()`` was registered to ``app`` as the **default** action. 2. Calling ``app()`` at the bottom triggers the app to begin parsing CLI inputs. 3. Cyclopts identifies ``"Alice"`` as a positional argument and matches it to the parameter ``name``. In the absence of an explicit type hint, Cyclopts defaults to parsing the value as a ``str``. .. note:: Without a type annotation, Cyclopts will actually first attempt to use the **type** of the parameter's **default value**. If the parameter doesn't have a default value, it will then fallback to ``str``. See :ref:`Coercion Rules`. 4. Cyclopts calls the registered **default** function ``main("Alice")``, and the greeting is printed. ------------------ Multiple Arguments ------------------ Extending the example, lets add more arguments and type hints: .. code-block:: python from cyclopts import App app = App() @app.default def main(name: str, count: int, formal: bool = False): for _ in range(count): if formal: print(f"Hello {name}!") else: print(f"Hey {name}!") if __name__ == "__main__": app() .. code-block:: console $ python main.py Alice 3 Hey Alice! Hey Alice! Hey Alice! $ python main.py Alice 3 --formal Hello Alice! Hello Alice! Hello Alice! The command line input ``"3"`` is converted to an integer because the parameter ``count`` has the type hint :obj:`int`. Boolean parameters (e.g., ``--formal`` in this example) are interpreted as flags. Cyclopts natively handles all python builtin types (:ref:`and more! `). Cyclopts adheres to Python's argument binding rules, allowing for both positional and keyword arguments. All of the following CLI invocations are equivalent: .. code-block:: console $ python main.py Alice 3 # Supplying arguments positionally. $ python main.py --name Alice --count 3 # Supplying arguments via keywords. $ python main.py --name=Alice --count=3 # Using = for matching keywords to values is allowed. $ python main.py --count 3 --name=Alice # Keyword order does not matter. $ python main.py Alice --count 3 # Positional followed by keyword $ python main.py --count 3 Alice # Keywords can come before positional if the keyword is later in the function signature. $ python main.py --count 3 -- Alice # Using the POSIX convention to indicate the end of keywords Like calling functions in python, positional arguments cannot be specified after a **prior** argument in the function signature was specified via keyword. For example, you cannot supply the count value ``"3"`` positionally while the value for ``name`` is specified via keyword: .. code-block:: console # The following are NOT allowed. $ python main.py --name=Alice 3 # invalid python: main(name="Alice", 3) $ python main.py 3 --name=Alice # invalid python: main(3, name="Alice") ------------------ Adding a Help Page ------------------ All CLI apps need to have a help page explaining how to use the application. By default, Cyclopts adds the ``--help`` (and the shortform ``-h``) commands to your CLI. We can add application-level help documentation when creating our ``app``: .. code-block:: python from cyclopts import App app = App(help="Help string for this demo application.") @app.default def main(name: str, count: int): for _ in range(count): print(f"Hello {name}!") if __name__ == "__main__": app() .. code-block:: console $ python main.py --help Usage: main COMMAND [ARGS] [OPTIONS] Help string for this demo application. ╭─ Commands ──────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ────────────────────────────────────────────────────────╮ │ * NAME --name [required] │ │ * COUNT --count [required] │ ╰─────────────────────────────────────────────────────────────────────╯ .. note:: Help flags can be changed with :attr:`~cyclopts.App.help_flags`. Let's add some help documentation for our parameters. Cyclopts uses the function's docstring and can interpret ReST, Google, Numpydoc-style and Epydoc docstrings (shoutout to `docstring_parser `_). .. code-block:: python from cyclopts import App app = App() @app.default def main(name: str, count: int): """Help string for this demo application. Parameters ---------- name: str Name of the user to be greeted. count: int Number of times to greet. """ for _ in range(count): print(f"Hello {name}!") if __name__ == "__main__": app() .. code-block:: console $ python main.py --help Usage: main COMMAND [ARGS] [OPTIONS] Help string for this demo application. ╭─ Commands ──────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ────────────────────────────────────────────────────────╮ │ * NAME --name Name of the user to be greeted. [required] │ │ * COUNT --count Number of times to greet. [required] │ ╰─────────────────────────────────────────────────────────────────────╯ .. note:: If :attr:`.App.help` is not explicitly set, Cyclopts will fallback to the first line (short description) of the registered ``@app.default`` function's docstring. --- Run --- An alternative, terser API is available for simple applications with a single command. The :func:`.run` function takes in a single callable (usually a function) and runs it as a Cyclopts application. .. code-block:: python import cyclopts def main(name: str, count: int): for _ in range(count): print(f"Hello {name}!") if __name__ == "__main__": cyclopts.run(main) The :func:`.run` function is intentionally simple. If greater control is required, then use the conventional :class:`.App` interface. .. _checkout the mypy cheatsheet: https://mypy.readthedocs.io/en/latest/cheat_sheet_py3.html BrianPugh-cyclopts-921b1fa/docs/source/group_validators.rst000066400000000000000000000134531517576204000242210ustar00rootroot00000000000000.. _Group Validators: ================ Group Validators ================ Group validators operate on a set of parameters, :ref:`ensuring that their values are mutually compatible `. Validator(s) for a group can be set via the :attr:`.Group.validator` attribute. An individual validator is a callable object/function with signature: .. code-block:: python def validator(argument_collection: ArgumentCollection): "Raise an exception if something is invalid." Cyclopts has some builtin common group validators in the :ref:`cyclopts.validators ` module. .. _Group Validators - LimitedChoice: ------------- LimitedChoice ------------- Limits the number of specified arguments within the group. Most commonly used for mutually-exclusive arguments (default behavior). .. code-block:: python from cyclopts import App, Group, Parameter, validators from typing import Annotated app = App() vehicle = Group( "Vehicle (choose one)", default_parameter=Parameter(negative=""), # Disable "--no-" flags validator=validators.LimitedChoice(), # Mutually Exclusive Options ) @app.default def main( *, car: Annotated[bool, Parameter(group=vehicle)] = False, truck: Annotated[bool, Parameter(group=vehicle)] = False, ): if car: print("I'm driving a car.") if truck: print("I'm driving a truck.") app() .. code-block:: console $ python drive.py --help Usage: main COMMAND [OPTIONS] ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Vehicle (choose one) ─────────────────────────────────────────────╮ │ --car [default: False] │ │ --truck [default: False] │ ╰────────────────────────────────────────────────────────────────────╯ $ python drive.py --car I'm driving a car. $ python drive.py --car --truck ╭─ Error ────────────────────────────────────────────────────────────╮ │ Invalid values for group "Vehicle (choose one)". Mutually │ │ exclusive arguments: {--car, --truck} │ ╰────────────────────────────────────────────────────────────────────╯ See the :class:`.LimitedChoice` docs for more info. ----------------- MutuallyExclusive ----------------- Alias for :class:`.LimitedChoice` with default arguments. Exists primarily because the usage/implication will be more directly obvious and searchable to developers than :class:`.LimitedChoice`. Since this class takes no arguments, an already instantiated version :obj:`.mutually_exclusive` is also provided for convenience. ----------- all_or_none ----------- Group validator that enforces that either **all** parameters in the group must be supplied an argument, or **none** of them. .. code-block:: python from typing import Annotated from cyclopts import App, Group, Parameter from cyclopts.validators import all_or_none app = App() group_1 = Group(validator=all_or_none) group_2 = Group(validator=all_or_none) @app.default def default( foo: Annotated[bool, Parameter(group=group_1)] = False, bar: Annotated[bool, Parameter(group=group_1)] = False, fizz: Annotated[bool, Parameter(group=group_2)] = False, buzz: Annotated[bool, Parameter(group=group_2)] = False, ): print(f"{foo=} {bar=}") print(f"{fizz=} {buzz=}") if __name__ == "__main__": app() .. code-block:: console $ python all_or_none.py foo=False bar=False fizz=False buzz=False $ python all_or_none.py --foo ╭─ Error ──────────────────────────────────────────────────────────╮ │ Missing argument: --bar │ ╰──────────────────────────────────────────────────────────────────╯ $ python all_or_none.py --foo --bar foo=True bar=True fizz=False buzz=False $ python all_or_none.py --foo --bar --fizz ╭─ Error ────────────────────────────────────────────────────────────╮ │ Missing argument: --buzz │ ╰────────────────────────────────────────────────────────────────────╯ $ python all_or_none.py --foo --bar --fizz --buzz foo=True bar=True fizz=True buzz=True See the :obj:`.all_or_none` docs for more info. BrianPugh-cyclopts-921b1fa/docs/source/groups.rst000066400000000000000000000360361517576204000221560ustar00rootroot00000000000000.. _Groups: ====== Groups ====== Groups offer a way of organizing parameters and commands on the help-page; for example: .. code-block:: console Usage: my-script.py create [OPTIONS] ╭─ Vehicle (choose one) ───────────────────────────────────────────────────────╮ │ --car [default: False] │ │ --truck [default: False] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Engine ─────────────────────────────────────────────────────────────────────╮ │ --hp [default: 200] │ │ --cylinders [default: 6] │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Wheels ─────────────────────────────────────────────────────────────────────╮ │ --wheel-diameter [default: 18] │ │ --rims,--no-rims [default: False] │ ╰──────────────────────────────────────────────────────────────────────────────╯ They also provide an additional abstraction layer that :ref:`validators ` can operate on. Groups can be created in two ways: 1. Explicitly creating a :class:`.Group` object. 2. Implicitly with a **string**. This will implicitly create a group, ``Group(my_str_group_name)``, if it doesn't exist. If there exists a :class:`.Group` object with the same name within the command/parameter context, it will join that group. .. warning:: While convenient and terse, mistyping a group name will unintentionally create a new group! Every command and parameter belongs to at least one group. Group(s) can be provided to the ``group`` keyword argument of :meth:`app.command ` and :class:`.Parameter`. Like :class:`.Parameter`, the :class:`.Group` class itself only marks objects with metadata; the group does **not** contain direct references to it's members. This means that groups can be reused across commands. -------------- Command Groups -------------- An example of using groups to organize commands: .. code-block:: python from cyclopts import App app = App() # Change the group of "--help" and "--version" to the implicitly created "Admin" group. app["--help"].group = "Admin" app["--version"].group = "Admin" @app.command(group="Admin") def info(): """Print debugging system information.""" print("Displaying system info.") @app.command def download(path, url): """Download a file.""" print(f"Downloading {url} to {path}.") @app.command def upload(path, url): """Upload a file.""" print(f"Downloading {url} to {path}.") app() .. code-block:: console $ python my-script.py --help Usage: my-script.py COMMAND ╭─ Admin ──────────────────────────────────────────────────────────────────────╮ │ info Print debugging system information. │ │ --help,-h Display this message and exit. │ │ --version Display application version. │ ╰──────────────────────────────────────────────────────────────────────────────╯ ╭─ Commands ───────────────────────────────────────────────────────────────────╮ │ download Download a file. │ │ upload Upload a file. │ ╰──────────────────────────────────────────────────────────────────────────────╯ The default group is defined by the registering app's :attr:`.App.group_commands`, which defaults to a group named ``"Commands"``. .. _Parameter Groups: ---------------- Parameter Groups ---------------- Like commands above, parameter groups allow us to organize parameters on the help page. They also allow us to add additional inter-parameter validators (e.g. mutually-exclusive parameters). An example of using groups with parameters: .. code-block:: python from cyclopts import App, Group, Parameter, validators from typing import Annotated app = App() vehicle_type_group = Group( "Vehicle (choose one)", default_parameter=Parameter(negative=""), # Disable "--no-" flags validator=validators.MutuallyExclusive(), # Only one option is allowed to be selected. ) @app.command def create( *, # force all subsequent variables to be keyword-only # Using an explicitly created group object. car: Annotated[bool, Parameter(group=vehicle_type_group)] = False, truck: Annotated[bool, Parameter(group=vehicle_type_group)] = False, # Implicitly creating an "Engine" group. hp: Annotated[float, Parameter(group="Engine")] = 200, cylinders: Annotated[int, Parameter(group="Engine")] = 6, # You can explicitly create groups in-line. wheel_diameter: Annotated[float, Parameter(group=Group("Wheels"))] = 18, # Groups within the function signature can always be referenced with a string. rims: Annotated[bool, Parameter(group="Wheels")] = False, ): pass app() .. code-block:: console $ python my-script.py create --help Usage: my-script.py create [OPTIONS] ╭─ Engine ──────────────────────────────────────────────────────╮ │ --hp [default: 200] │ │ --cylinders [default: 6] │ ╰───────────────────────────────────────────────────────────────╯ ╭─ Vehicle (choose one) ────────────────────────────────────────╮ │ --car [default: False] │ │ --truck [default: False] │ ╰───────────────────────────────────────────────────────────────╯ ╭─ Wheels ──────────────────────────────────────────────────────╮ │ --wheel-diameter [default: 18] │ │ --rims --no-rims [default: False] │ ╰───────────────────────────────────────────────────────────────╯ $ python my-script.py create --car --truck ╭─ Error ───────────────────────────────────────────────────────╮ │ Invalid values for group "Vehicle (choose one)". Mutually │ │ exclusive arguments: {--car, --truck} │ ╰───────────────────────────────────────────────────────────────╯ In this example, we use the :class:`~.validators.MutuallyExclusive` validator to make it so the user can only specify ``--car`` or ``--truck``. The default groups are defined by the registering app: * :attr:`.App.group_arguments` for positional-only arguments, which defaults to a group named ``"Arguments"``. * :attr:`.App.group_parameters` for all other parameters, which defaults to a group named ``"Parameters"``. ---------- Validators ---------- Group validators offer a way of jointly validating group parameter members of CLI-provided values. Groups with an empty name, or with ``show=False``, are a way of using group validators without impacting the help-page. .. code-block:: python from cyclopts import App, Group, Parameter, validators from typing import Annotated app = App() mutually_exclusive = Group( # This Group has no name, so it won't impact the help page. validator=validators.MutuallyExclusive(), # show_default=False - Showing "[default: False]" isn't too meaningful for mutually-exclusive options. # negative="" - Don't create a "--no-" flag default_parameter=Parameter(show_default=False, negative=""), ) @app.command def foo( car: Annotated[bool, Parameter(group=(app.group_parameters, mutually_exclusive))] = False, truck: Annotated[bool, Parameter(group=(app.group_parameters, mutually_exclusive))] = False, ): print(f"{car=} {truck=}") app() .. code-block:: console $ python demo.py foo --help Usage: demo.py foo [ARGS] [OPTIONS] ╭─ Parameters ──────────────────────────────────────────────────────╮ │ CAR,--car │ │ TRUCK,--truck │ ╰───────────────────────────────────────────────────────────────────╯ $ python demo.py foo --car car=True truck=False $ python demo.py foo --truck car=False truck=True $ python demo.py foo --car --truck ╭─ Error ───────────────────────────────────────────────────────────╮ │ Mutually exclusive arguments: {--car, --truck} │ ╰───────────────────────────────────────────────────────────────────╯ See :attr:`.Group.validator` for details. Cyclopts has some :ref:`builtin group-validators for common use-cases.` --------- Help Page --------- Groups form titled panels on the help-page. Groups with an empty name, or with :attr:`show=False <.Group.show>`, are **not** shown on the help-page. This is useful for applying additional grouping logic (such as applying a :class:`.LimitedChoice` validator) without impacting the help-page. By default, the ordering of panels is **alphabetical**. However, the sorting can be manipulated by :attr:`.Group.sort_key`. See it's documentation for usage. The :meth:`.Group.create_ordered` convenience classmethod creates a :class:`.Group` with a :attr:`~.Group.sort_key` value drawn drawn from a global monotonically increasing counter. This means that the order in the help-page will match the order that the groups were instantiated. .. code-block:: python from cyclopts import App, Group app = App() plants = Group.create_ordered("Plants") animals = Group.create_ordered("Animals") fungi = Group.create_ordered("Fungi") @app.command(group=animals) def zebra(): pass @app.command(group=plants) def daisy(): pass @app.command(group=fungi) def portobello(): pass app() .. code-block:: console $ my-script --help Usage: scratch.py COMMAND ╭─ Plants ───────────────────────────────────────────────────────────╮ │ daisy │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Animals ──────────────────────────────────────────────────────────╮ │ zebra │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Fungi ────────────────────────────────────────────────────────────╮ │ portobello │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ Even when using :meth:`.Group.create_ordered`, a :attr:`~.Group.sort_key` can still be supplied; the global counter will only be used to break sorting ties. BrianPugh-cyclopts-921b1fa/docs/source/help.rst000066400000000000000000000364021517576204000215640ustar00rootroot00000000000000==== Help ==== A help screen is standard for every CLI application. Cyclopts by-default adds ``--help`` and ``-h`` flags to the application: .. code-block:: console $ my-application --help Usage: my-application COMMAND My application short description. ╭─ Commands ─────────────────────────────────────────────────────────╮ │ foo Foo help string. │ │ bar Bar help string. │ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ Cyclopts derives the components of the help string from a variety of sources. The source resolution order is as follows (as applicable): 1. The ``help`` field in the :meth:`@app.command ` decorator. .. code-block:: python app = cyclopts.App() @app.command(help="This is the highest precedence help-string for 'bar'.") def bar(): pass When registering an :class:`.App` object, supplying ``help`` via the :meth:`@app.command ` decorator is forbidden to reduce ambiguity and will raise a :exc:`ValueError`. See (2). 2. Via :attr:`.App.help`. .. code-block:: python app = cyclopts.App(help="This help string has highest precedence at the app-level.") sub_app = cyclopts.App(help="This is the help string for the 'foo' subcommand.") app.command(sub_app, name="foo") app.command(sub_app, name="foo", help="This is illegal and raises a ValueError.") 3. The ``__doc__`` docstring of the registered :meth:`@app.default ` command. Cyclopts parses the docstring to populate short-descriptions and long-descriptions at the command-level, as well as at the parameter-level. .. code-block:: python app = cyclopts.App() app.command(cyclopts.App(), name="foo") @app.default def bar(val1: str): """This is the primary application docstring. Parameters ---------- val1: str This will be parsed for val1 help-string. """ @app["foo"].default # You can access sub-apps like a dictionary. def foo_handler(): """This will be shown for the "foo" command.""" .. note:: Docstrings should always use the **Python variable name** from the function signature. .. code-block:: python @app.default def main(internal_name: Annotated[str, Parameter(name="external-name")]): """Command description. Parameters ---------- internal_name: # Use the Python variable name Help text here. """ This follows standard Python documentation conventions; the parameter will still appear as ``--external-name`` on the CLI. 4. This resolution order, but of the :ref:`Meta App`. .. code-block:: python app = cyclopts.App() @app.meta.default def bar(): """This is the primary application docstring.""" ------------- Markup Format ------------- While the standard markup language for docstrings in Python is reStructuredText (see `PEP-0287`_), Cyclopts defaults to Markdown for better readability and simplicity. Cyclopts mostly respects `PEP-0257`_, but has some slight differences for developer ergonomics: 1. The "summary line" (AKA short-description) may actually be multiple lines. Cyclopts will unwrap the first block of text and interpret it as the short description. The first block of text ends at the first double-newline (i.e. a single blank line) is reached. .. code-block:: python def my_command(): """ This entire sentence is part of the short description and will have all the newlines removed. This is the beginning of the long description. """ 2. If a docstring is provided with a long description, it **must** also have a short description. By default, Cyclopts parses docstring descriptions as markdown and renders it appropriately. To change the markup format, set the :attr:`.App.help_format` field accordingly. The different options are described below. Subapps inherit their parent's :attr:`.App.help_format` unless explicitly overridden. I.e. you only need to set :attr:`.App.help_format` in your main root application for all docstrings to be parsed appropriately. ^^^^^^^^^ PlainText ^^^^^^^^^ Do not perform any additional parsing, display supplied text as-is. .. code-block:: python from cyclopts import App app = App(help_format="plaintext") @app.default def default(): """My application summary. This is a pretty standard docstring; if there's a really long sentence I should probably wrap it because people don't like code that is more than 80 columns long. In this new paragraph, I would like to discuss the benefits of relaxing 80 cols to 120 cols. More text in this paragraph. Some new paragraph. """ app() .. code-block:: text Usage: default COMMAND My application summary. This is a pretty standard docstring; if there's a really long sentence I should probably wrap it because people don't like code that is more than 80 columns long. In this new paragraph, I would like to discuss the benefits of relaxing 80 cols to 120 cols. More text in this paragraph. Some new paragraph. ╭─ Commands ─────────────────────────────────────────────────────╮ │ --help,-h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────╯ Most noteworthy, is **no additional text reflow is performed**; newlines are presented as-is. ^^^^ Rich ^^^^ Displays text as `Rich Markup`_. .. note:: Newlines are interpreted literally. .. code-block:: python from cyclopts import App app = App(help_format="rich") @app.default def default(): """Rich can display colors like [red]red[/red] easily. However, I cannot be bothered to figure out how to show that in documentation. """ app() .. raw:: html
    Usage: default COMMAND
    
       Rich can display colors like red easily.
    
       ╭─ Commands ───────────────────────────────────────────────────────╮
       │ --help -h  Display this message and exit.                        │
       │ --version  Display application version.                          │
       ╰──────────────────────────────────────────────────────────────────╯
    ^^^^^^^^^^^^^^^^ ReStructuredText ^^^^^^^^^^^^^^^^ ReStructuredText can be enabled by setting `help_format` to "restructuredtext" or "rst". .. code-block:: python app = App(help_format="restructuredtext") # or "rst" @app.default def default(): """My application summary. We can do RST things like have **bold text**. More words in this paragraph. This is a new paragraph with some bulletpoints below: * bullet point 1. * bullet point 2. """ app() Resulting help: .. raw:: html
    Usage: default COMMAND
    
       My application summary.
    
       We can do RST things like have bold text. More words in this
       paragraph.
    
       This is a new paragraph with some bulletpoints below:
    
       1. bullet point 1.
       2. bullet point 2.
    
       ╭─ Commands ──────────────────────────────────────────────────────────╮
       │ --help -h  Display this message and exit.                           │
       │ --version  Display application version.                             │
       ╰─────────────────────────────────────────────────────────────────────╯
       
    Under most circumstances, plaintext (without any additional markup) looks prettier and reflows better when interpreted as restructuredtext (or markdown, for that matter). ^^^^^^^^^ Markdown ^^^^^^^^^ Markdown is the default parsing behavior of Cyclopts, so `help_format` won't need to be explicitly set. It's another popular markup language that Cyclopts can render. .. code-block:: python app = App(help_format="markdown") # or "md" # or don't supply help_format at all; markdown is default. @app.default def default(): """My application summary. We can do markdown things like have **bold text**. [Hyperlinks work as well.](https://cyclopts.readthedocs.io) """ Resulting help: .. raw:: html
    Usage: default COMMAND
    
       My application summary.
    
       We can do markdown things like have bold text. Hyperlinks work as well.
    
       ╭─ Commands ──────────────────────────────────────────────────────────╮
       │ --help -h  Display this message and exit.                           │
       │ --version  Display application version.                             │
       ╰─────────────────────────────────────────────────────────────────────╯
       
    ---------- Help Flags ---------- The default ``--help`` flags can be changed to different name(s) via the ``help_flags`` parameter. .. code-block:: python app = cyclopts.App(help_flags="--show-help") app = cyclopts.App(help_flags=["--send-help", "--send-help-plz", "-h"]) To disable the help-page entirely, set ``help_flags`` to an empty string or iterable. .. code-block:: python app = cyclopts.App(help_flags="") app = cyclopts.App(help_flags=[]) -------------------------- Help Prologue and Epilogue -------------------------- An epilogue is text displayed at the end of the help screen, after all command and parameter panels. A prologue is text display at the beginning of the help screen, before the usage section. This is commonly used for version information, support contact details, or additional notes. The epilogue is set via the :attr:`.App.help_epilogue` attribute: .. code-block:: python from cyclopts import App app = App( name="myapp", help="My application description.", help_epilogue="Support: support@example.com" ) @app.default def main(): """Main command.""" pass app() .. code-block:: console $ myapp --help Usage: myapp [ARGS] My application description. ╭─ Commands ────────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰───────────────────────────────────────────────────────────────────────╯ Support: support@example.com The prologue is set via the :attr:`.App.help_prologue` attribute: .. code-block:: python from cyclopts import App app = App( name="myapp", help="My application help.", help_prologue=f"myapp, v1.0.0 (http://example.myapp.com)" ) app() .. code-block:: console $ my-script --help myapp, v1.0.0 (http://example.myapp.com) Usage: myapp COMMAND My application help. ╭─ Commands ────────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰───────────────────────────────────────────────────────────────────────╯ Like :attr:`.App.help_format`, epilogues and prologues inherit from parent to child apps. This allows you to set a single epilogue or prologue that applies across your entire application: .. code-block:: python parent = App( name="myapp", help_epilogue="Version 1.0.0 | support@example.com" ) # Child inherits parent's epilogue child = App(name="process", help="Process data files.") parent.command(child) # Another child overrides with its own epilogue admin = App( name="admin", help="Admin commands.", help_epilogue="Admin Tools v2.0 | USE WITH CAUTION" ) parent.command(admin) parent() .. code-block:: console $ myapp process --help Usage: myapp process Process data files. Version 1.0.0 | support@example.com # Inherited from parent $ myapp admin --help Usage: myapp admin Admin commands. Admin Tools v2.0 | USE WITH CAUTION # Overridden by child To disable the epilogue or prologue for a specific subcommand, set it to an empty string: .. code-block:: python no_epilogue = App(name="internal", help_epilogue="") parent.command(no_epilogue) .. code-block:: python no_prologue = App(name="internal", help_prologue="") parent.command(no_prologue) -------------------- Help Customization -------------------- For advanced customization of help screen appearance, including custom formatters, styled panels, and dynamic column layouts, see :ref:`Help Customization`. .. _PEP-0257: https://peps.python.org/pep-0257/ .. _PEP-0287: https://peps.python.org/pep-0287/ .. _Rich Markup: https://rich.readthedocs.io/en/stable/markup.html BrianPugh-cyclopts-921b1fa/docs/source/help_customization.rst000066400000000000000000001124401517576204000245510ustar00rootroot00000000000000.. _Help Customization: ================== Help Customization ================== Cyclopts provides extensive customization options for help screen appearance and formatting through the ``help_formatter`` parameter available on both :attr:`App ` and :attr:`Group `. These parameters accept formatters that follow the :class:`~cyclopts.help.protocols.HelpFormatter` protocol. -------------------------- Setting Help Formatters -------------------------- App-Level Formatting ^^^^^^^^^^^^^^^^^^^^ The :class:`~cyclopts.App` class accepts a ``help_formatter`` parameter that controls the default formatting for all help output: .. code-block:: python from cyclopts import App from cyclopts.help import DefaultFormatter, PlainFormatter # Use a built-in formatter by name: {"default", "plain"} app = App(help_formatter="plain") # Or pass a formatter instance with custom configuration app = App( help_formatter=DefaultFormatter( # Custom configuration options ) ) # Or use a completely custom formatter; see HelpFormatter protocol. app = App(help_formatter=MyCustomFormatter()) Group-Level Formatting ^^^^^^^^^^^^^^^^^^^^^^ Individual :class:`~cyclopts.Group` instances can have their own ``help_formatter`` that overrides the app-level default: .. code-block:: python from cyclopts import App, Group from cyclopts.help import DefaultFormatter, PanelSpec from rich.box import DOUBLE # Create a group with custom formatting advanced_group = Group( "Advanced Options", help_formatter=DefaultFormatter( panel_spec=PanelSpec( border_style="red", box=DOUBLE, ) ) ) # The app can have a different default formatter app = App(help_formatter="plain") # Parameters in advanced_group will use the group's formatter, # while other parameters use the app's formatter ------------------- Built-in Formatters ------------------- DefaultFormatter ^^^^^^^^^^^^^^^^ The :class:`~cyclopts.help.DefaultFormatter` is the default help formatter that uses `Rich `_ for beautiful terminal output with colors, borders, and structured layouts. .. code-block:: python from cyclopts import App # Explicitly use the default formatter (same as not specifying) app = App(help_formatter="default") @app.default def main(name: str, count: int = 1): """A simple greeting application. Parameters ---------- name : str Person to greet. count : int Number of times to greet. """ for _ in range(count): print(f"Hello, {name}!") if __name__ == "__main__": app() Output: .. raw:: html
    Usage: my-app [ARGS] [OPTIONS]
    
       A simple greeting application.
    
       ╭─ Commands ───────────────────────────────────────────────────────────────────╮
       │ --help -h  Display this message and exit.                                    │
       │ --version  Display application version.                                      │
       ╰──────────────────────────────────────────────────────────────────────────────╯
       ╭─ Parameters ─────────────────────────────────────────────────────────────────╮
       │ *  NAME --name    Person to greet. [required]                                │
       │    COUNT --count  Number of times to greet. [default: 1]                     │
       ╰──────────────────────────────────────────────────────────────────────────────╯
    PlainFormatter ^^^^^^^^^^^^^^ The :class:`~cyclopts.help.PlainFormatter` provides accessibility-focused plain text output without colors or special characters, ideal for screen readers and simpler terminals. .. code-block:: python from cyclopts import App # Use plain text formatter for accessibility app = App(help_formatter="plain") @app.default def main(name: str, count: int = 1): """A simple greeting application. Parameters ---------- name : str Person to greet. count : int Number of times to greet. """ for _ in range(count): print(f"Hello, {name}!") if __name__ == "__main__": app() Output: .. code-block:: text Usage: demo.py [ARGS] [OPTIONS] A simple greeting application. Commands: --help, -h: Display this message and exit. --version: Display application version. Parameters: NAME, --name: Person to greet. COUNT, --count: Number of times to greet. --------------------- Basic Customization --------------------- The :class:`~cyclopts.help.DefaultFormatter` accepts several customization options through its initialization parameters. Panel Customization ^^^^^^^^^^^^^^^^^^^ The :class:`~cyclopts.help.PanelSpec` controls the outer panel appearance: .. code-block:: python from cyclopts import App from cyclopts.help import DefaultFormatter, PanelSpec from rich.box import DOUBLE app = App( help_formatter=DefaultFormatter( panel_spec=PanelSpec( box=DOUBLE, # Use double-line borders border_style="blue", # Blue border color padding=(1, 2), # (vertical, horizontal) padding expand=True, # Expand to full terminal width ) ) ) @app.default def main(path: str, verbose: bool = False): """Process a file with custom panel styling.""" print(f"Processing {path}") if __name__ == "__main__": app() Output: .. raw:: html
    Usage: demo.py [ARGS] [OPTIONS]
    
       Process a file with custom panel styling.
    
       ╔═ Commands ═══════════════════════════════════════════════════════════╗
       ║                                                                      ║
       --help -h  Display this message and exit.                           
       --version  Display application version.                             
       ║                                                                      ║
       ╚══════════════════════════════════════════════════════════════════════╝
       ╔═ Parameters ═════════════════════════════════════════════════════════╗
       ║                                                                      ║
       *  PATH --path                     [required]                       
       VERBOSE --verbose  [default: False]                              
       --no-verbose                                                   
       ║                                                                      ║
       ╚══════════════════════════════════════════════════════════════════════╝
    Table Customization ^^^^^^^^^^^^^^^^^^^ The :class:`~cyclopts.help.TableSpec` controls the table styling within panels: .. code-block:: python from cyclopts import App from cyclopts.help import DefaultFormatter, TableSpec app = App( help_formatter=DefaultFormatter( table_spec=TableSpec( show_header=True, # Show column headers show_lines=True, # Show lines between rows show_edge=False, # Remove outer table border border_style="green", # Green table elements padding=(0, 2, 0, 0), # Extra right padding box=SQUARE, # otherwise we won't see the lines ) ) ) @app.default def main(path: str, verbose: bool = False): """Process a file with custom table styling.""" print(f"Processing {path}") if __name__ == "__main__": app() Output: .. raw:: html
    Usage: test_table_custom.py [ARGS] [OPTIONS]
    
       Process a file with custom table styling.
    
       ╭─ Commands ───────────────────────────────────────────────────────────────────╮
       │ Command    Description                                                      │
       │ ───────────┼──────────────────────────────────────────────────────────────── │
       │ --help -h  Display this message and exit.                                   │
       │ ───────────┼──────────────────────────────────────────────────────────────── │
       │ --version  Display application version.                                     │
       ╰──────────────────────────────────────────────────────────────────────────────╯
       ╭─ Parameters ─────────────────────────────────────────────────────────────────╮
       │    Option             Description                                          │
       │ ───┼───────────────────┼──────────────────────────────────────────────────── │
       │ *  PATH --path        [required]                                           │
       │ ───┼───────────────────┼──────────────────────────────────────────────────── │
       │    VERBOSE --verbose  [default: False]                                     │
       │      --no-verbose                                                          │
       ╰──────────────────────────────────────────────────────────────────────────────╯
    Combining Customizations ^^^^^^^^^^^^^^^^^^^^^^^^ You can combine both panel and table specifications: .. code-block:: python from cyclopts import App from cyclopts.help import DefaultFormatter, PanelSpec, TableSpec from rich.box import ROUNDED app = App( help_formatter=DefaultFormatter( panel_spec=PanelSpec( box=ROUNDED, border_style="cyan", padding=(0, 1), ), table_spec=TableSpec( show_header=False, show_lines=False, padding=(0, 1), ) ) ) @app.default def main(path: str, verbose: bool = False): """Process a file with combined customizations.""" print(f"Processing {path}") if __name__ == "__main__": app() Output: .. raw:: html
    Usage: my-app [ARGS] [OPTIONS]
    
       Process a file with combined customizations.
    
       ╭─ Commands ──────────────────────────────────────────────────────────╮
       --help -h  Display this message and exit.                           
       --version  Display application version.                             
       ╰─────────────────────────────────────────────────────────────────────╯
       ╭─ Parameters ────────────────────────────────────────────────────────╮
       *  PATH --path       [required]                                     
       VERBOSE --verbose [default: False]                               
       ╰─────────────────────────────────────────────────────────────────────╯
    ----------------------- Group-Level Formatting ----------------------- Different parameter groups can have different formatting styles, allowing you to visually distinguish between different types of options: .. code-block:: python from cyclopts import App, Group, Parameter from cyclopts.help import DefaultFormatter, PanelSpec from rich.box import DOUBLE, MINIMAL from typing import Annotated # Create groups with different styles required_group = Group( "Required Options", help_formatter=DefaultFormatter( panel_spec=PanelSpec( box=DOUBLE, border_style="red bold", ) ) ) optional_group = Group( "Optional Settings", help_formatter=DefaultFormatter( panel_spec=PanelSpec( box=MINIMAL, border_style="green", ) ) ) app = App() @app.default def main( # Required parameters with red double border input_file: Annotated[str, Parameter(group=required_group)], output_dir: Annotated[str, Parameter(group=required_group)], # Optional parameters with green minimal border verbose: Annotated[bool, Parameter(group=optional_group)] = False, threads: Annotated[int, Parameter(group=optional_group)] = 4, ): """Process files with styled help groups.""" print(f"Processing {input_file} -> {output_dir}") if verbose: print(f"Using {threads} threads") if __name__ == "__main__": app() Output: .. raw:: html
    Usage: test_group_formatting.py [ARGS] [OPTIONS]
    
       Process files with styled help groups.
    
       ╭─ Commands ───────────────────────────────────────────────────────────────────╮
       │ --help -h  Display this message and exit.                                    │
       │ --version  Display application version.                                      │
       ╰──────────────────────────────────────────────────────────────────────────────╯
          Optional Settings                                                            
         VERBOSE --verbose  [default: False]                                           
           --no-verbose                                                                
         THREADS --threads  [default: 4]                                               
                                                                                       
       ╔═ Required Options ═══════════════════════════════════════════════════════════╗
        *  INPUT-FILE --input-file  [required]                                       
        *  OUTPUT-DIR --output-dir  [required]                                       
       ╚══════════════════════════════════════════════════════════════════════════════╝
    --------------------- Custom Column Layout --------------------- For complete control over the help table layout, you can define custom columns using :class:`~cyclopts.help.ColumnSpec`: .. code-block:: python from cyclopts import App, Group, Parameter from cyclopts.help import DefaultFormatter, ColumnSpec, TableSpec from typing import Annotated # Define custom column renderers def names_renderer(entry): """Combine parameter names and shorts.""" names = " ".join(entry.names) if entry.names else "" shorts = " ".join(entry.shorts) if entry.shorts else "" return f"{names} {shorts}".strip() def type_renderer(entry): """Show the parameter type.""" from cyclopts.annotations import get_hint_name return get_hint_name(entry.type) if entry.type else "" # Create custom columns custom_group = Group( "Custom Layout", help_formatter=DefaultFormatter( table_spec=TableSpec(show_header=True), column_specs=( ColumnSpec( renderer=lambda e: "★" if e.required else " ", header="", width=2, style="yellow bold", ), ColumnSpec( renderer=names_renderer, header="Option", style="cyan", max_width=30, ), ColumnSpec( renderer=type_renderer, header="Type", style="magenta", justify="center", ), ColumnSpec( renderer="description", # Use attribute name header="Description", overflow="fold", ), ) ) ) app = App() @app.default def main( input_path: Annotated[str, Parameter(group=custom_group, help="Input file path")], output_path: Annotated[str, Parameter(group=custom_group, help="Output file path")], count: Annotated[int, Parameter(group=custom_group, help="Number of iterations")] = 1, ): """Demo custom column layout.""" print(f"Processing {input_path} -> {output_path} ({count} times)") if __name__ == "__main__": app() Output: .. raw:: html
    Usage: test_custom_column.py [ARGS] [OPTIONS]
    
       Demo custom column layout.
    
       ╭─ Commands ───────────────────────────────────────────────────────────────────╮
       │ --help -h  Display this message and exit.                                    │
       │ --version  Display application version.                                      │
       ╰──────────────────────────────────────────────────────────────────────────────╯
       ╭─ Custom Layout ──────────────────────────────────────────────────────────────╮
       │     Option                     Type  Description                             │
       │    INPUT-PATH --input-path    str   Input file path                         │
       │    OUTPUT-PATH --output-path  str   Output file path                        │
       │     COUNT --count              int   Number of iterations                    │
       ╰──────────────────────────────────────────────────────────────────────────────╯
    Dynamic Column Builders ^^^^^^^^^^^^^^^^^^^^^^^ For even more flexibility, you can create columns dynamically based on runtime conditions: .. code-block:: python from cyclopts import App, Parameter from cyclopts.help import DefaultFormatter, ColumnSpec from typing import Annotated def dynamic_columns(console, options, entries): """Build columns based on console width and entries.""" columns = [] # Only show required indicator if there are required params if any(e.required for e in entries): columns.append(ColumnSpec( renderer=lambda e: "*" if e.required else "", width=2, style="red", )) # Adjust name column width based on console size max_width = min(40, int(console.width * 0.3)) columns.append(ColumnSpec( renderer=lambda e: " ".join(e.names + e.shorts), header="Option", max_width=max_width, style="cyan", )) # Always include description columns.append(ColumnSpec( renderer="description", header="Description", overflow="fold", )) return tuple(columns) app = App( help_formatter=DefaultFormatter( column_specs=dynamic_columns ) ) @app.default def main( input_file: str, output_file: str, verbose: bool = False, ): """Process files with dynamic columns.""" print(f"Processing {input_file} -> {output_file}") if __name__ == "__main__": app() Output (adjusts based on terminal width): .. raw:: html
    Usage: test_dynamic_columns.py [ARGS] [OPTIONS]
    
       Process files with dynamic columns.
    
       ╭─ Commands ───────────────────────────────────────────────────────────────────╮
       │ Option     Description                                                       │
       │ --help -h  Display this message and exit.                                    │
       │ --version  Display application version.                                      │
       ╰──────────────────────────────────────────────────────────────────────────────╯
       ╭─ Parameters ─────────────────────────────────────────────────────────────────╮
       │     Option                    Description                                    │
       │ *   INPUT-FILE --input-file                                                  │
       │ *   OUTPUT-FILE                                                              │
       │     --output-file                                                            │
       │     VERBOSE --verbose                                                        │
       │     --no-verbose                                                             │
       ╰──────────────────────────────────────────────────────────────────────────────╯
    -------------------------- Creating Custom Formatters -------------------------- For complete control, you can implement your own formatter by following the :class:`~cyclopts.help.protocols.HelpFormatter` protocol. The formatter methods receive the console and options first, followed by the content to render: .. code-block:: python from cyclopts import App from cyclopts.help import HelpPanel from rich.console import Console, ConsoleOptions from rich.table import Table from rich.panel import Panel class MyCustomFormatter: """A custom formatter with unique styling.""" def __call__(self, console: Console, options: ConsoleOptions, panel: HelpPanel) -> None: """Render a help panel with custom styling.""" if not panel.entries: return # Create a custom table table = Table(show_header=True, header_style="bold magenta") table.add_column("Option", style="cyan", no_wrap=True) table.add_column("Description", style="white") for entry in panel.entries: name = " ".join(entry.names + entry.shorts) # Extract plain text from description (handles InlineText, etc) desc = "" if entry.description: if hasattr(entry.description, 'plain'): desc = entry.description.plain elif hasattr(entry.description, '__rich_console__'): # Render to plain text without styles with console.capture() as capture: console.print(entry.description, end="") desc = capture.get() else: desc = str(entry.description) table.add_row(name, desc) # Wrap in a custom panel panel_title = panel.title or "Options" styled_panel = Panel( table, title=f"[bold blue]{panel_title}[/bold blue]", border_style="blue", ) console.print(styled_panel) def render_usage(self, console: Console, options: ConsoleOptions, usage) -> None: """Render the usage line.""" if usage: console.print(f"[bold green]Usage:[/bold green] {usage}") def render_description(self, console: Console, options: ConsoleOptions, description) -> None: """Render the description.""" if description: console.print(f"\n[italic]{description}[/italic]\n") # Use the custom formatter app = App(help_formatter=MyCustomFormatter()) @app.default def main(input_file: str, output_file: str, verbose: bool = False): """Process files with custom formatter.""" print(f"Processing {input_file} -> {output_file}") if __name__ == "__main__": app() Output: .. raw:: html
    Usage: test_custom_formatter.py [ARGS] [OPTIONS]
    
       Process files with custom formatter.
    
       ╭─ Commands ───────────────────────────────────────────────────────────────────╮
        ┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓                               
        Option     Description                    
        ┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩                               
        --help -h │ Display this message and exit. │                               
        --version │ Display application version.   │                               
        └───────────┴────────────────────────────────┘                               
       ╰──────────────────────────────────────────────────────────────────────────────╯
       ╭─ Parameters ─────────────────────────────────────────────────────────────────╮
        ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓                             
        Option                          Description 
        ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━┩                             
        INPUT-FILE --input-file        │             │                             
        OUTPUT-FILE --output-file      │             │                             
        VERBOSE --verbose --no-verbose │             │                             
        └────────────────────────────────┴─────────────┘                             
       ╰──────────────────────────────────────────────────────────────────────────────╯
    --------- Reference --------- For complete API documentation of help formatting components, see: * :class:`cyclopts.help.DefaultFormatter` - Rich-based formatter with full customization * :class:`cyclopts.help.PlainFormatter` - Plain text formatter for accessibility * :class:`cyclopts.help.PanelSpec` - Panel appearance specification * :class:`cyclopts.help.TableSpec` - Table styling specification * :class:`cyclopts.help.ColumnSpec` - Column definition and rendering * :class:`cyclopts.help.protocols.HelpFormatter` - Protocol for custom formatters See also: * :ref:`Help` - General help system documentation * :ref:`Groups` - Organizing parameters into groups BrianPugh-cyclopts-921b1fa/docs/source/index.rst000066400000000000000000000025531517576204000217430ustar00rootroot00000000000000======== Cyclopts ======== .. include:: ../../README.md :parser: myst_parser.sphinx_ For extensive documentation on all the features Cyclopts has to offer, checkout the :ref:`API` page. .. toctree:: :maxdepth: 1 :caption: Usage Installation.rst getting_started.rst commands.rst parameters.rst default_parameter.rst groups.rst parameter_validators.rst group_validators.rst help.rst version.rst shell_completion.rst rules.rst text_editor.rst api cli_reference.rst known_issues.rst .. toctree:: :maxdepth: 2 :caption: Advanced Usage lazy_loading.rst help_customization.rst user_classes.rst args_and_kwargs.rst config_file.rst sphinx_integration.rst mkdocs_integration.rst packaging.rst app_calling.rst meta_app.rst command_chaining.rst autoregistry .. toctree:: :maxdepth: 2 :caption: Cookbook cookbook/app_upgrade.rst cookbook/dataclass_commands.rst cookbook/interactive_help.rst cookbook/rich_formatted_exceptions.rst cookbook/sharing_parameters.rst cookbook/unit_testing.rst cookbook/file_or_stdin_stdout.rst cookbook/random_tips.rst .. toctree:: :maxdepth: 2 :caption: Migration migration/typer.rst .. toctree:: :maxdepth: 2 :caption: Alternative Libraries vs_typer/README.rst vs_fire/README.rst vs_arguably/README.rst BrianPugh-cyclopts-921b1fa/docs/source/known_issues.rst000066400000000000000000000032661517576204000233650ustar00rootroot00000000000000============ Known Issues ============ This document intends to record any known long-standing issues/limitations with Cyclopts. While this document should always be up to date, please also `visit the github-issues page `_ for more information & discussion. ``from __future__ import annotations`` ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Due to quirks in the python-typing system, Cyclopts can only support some scenarios surrounding `PEP-0563`_, the strinigization of type hints via ``from __future__ import annotations``. Notably, this can also sometimes break ``dataclass`` definitions when inheritance from multiple python modules is involved. Attempts have been made to improve Cyclopts support, but there are the following blockers: 1. CPython has `some bugs `_ around :func:`typing.get_type_hints`. It is outside the scope of Cyclopts to compensate for the complex task of type-hint scoping and resolution. 2. Particularly with dataclasses, it looks like they will be fixing these bugs, but it would only be backported to `3.13 and 3.14 `_. This limitation to very modern python versions makes a lot of PEP-0563 moot. 3. `PEP-0649`_ and `PEP-0749`_ deprecate the usage of ``from __future__ import annotations``. This suggests that it is not worth the long-term maintenance of supporting the complications of this feature. `Original discussion on GitHub. `_ .. _PEP-0563: https://peps.python.org/563 .. _PEP-0649: https://peps.python.org/649 .. _PEP-0749: https://peps.python.org/749 BrianPugh-cyclopts-921b1fa/docs/source/lazy_loading.rst000066400000000000000000000150361517576204000233100ustar00rootroot00000000000000.. _Lazy Loading: ============ Lazy Loading ============ Lazy loading allows you to register commands **using import path strings instead of direct function references**. This defers importing command modules until they are actually executed, which could significantly improve CLI startup time for large applications that have expensive per-command imports. ----------- Basic Usage ----------- Instead of importing and registering a function directly: .. code-block:: python from cyclopts import App from myapp.commands.users import create, delete, list_users # Imported immediately app = App() user_app = App(name="user") user_app.command(create) user_app.command(delete) user_app.command(list_users, name="list") app.command(user_app) Use an import path string: .. code-block:: python from cyclopts import App app = App() user_app = App(name="user") # No imports! Modules loaded only when commands execute user_app.command("myapp.commands.users:create") user_app.command("myapp.commands.users:delete") user_app.command("myapp.commands.users:list_users", name="list") app.command(user_app) The import path format is ``"module.path:function_name"``, similar to setuptools entry points. Lazy commands are resolved/imported in these situations: - **Command Execution** - When the user runs that specific command - **Subcommand Help** - When displaying ``--help`` for the specific lazy command (e.g., ``myapp lazy-cmd --help``) - **Direct Access** - When accessing via ``app["command_name"]`` Parent ``--help`` (e.g., ``myapp --help``) displays lazy commands using metadata provided at registration time (``help=``, ``group=``, ``show=``, ``sort_key=``) **without** resolving them. In order to benefit from lazy loading, you have to make sure that the files are not imported by other means when your CLI starts up. ------------------ Import Path Format ------------------ The import path string has two parts separated by a colon (``:``): **Module Path** (before the ``:``) The Python **module** to import, using dot notation (e.g., ``myapp.commands.users``). **Attribute Name** (after the ``:``) The function or App to get from the module using :func:`getattr`. Examples: .. code-block:: python # Simple function in a module app.command("myapp.commands:create_user") # Nested module path app.command("myapp.admin.database.operations:migrate") # Import an App instance, exposed to the CLI as "admin" app.command("myapp.admin:admin_app", name="admin") .. note:: The attribute name (after ``:``) is the **actual Python name**, not the CLI command name. Use the ``name`` parameter to specify the CLI command name. ---------------------- Name vs Function Name ---------------------- The ``name`` parameter specifies **how the command appears in the CLI**, while the import path specifies **what code to execute**. They can be completely different: .. code-block:: python from cyclopts import App user_app = App(name="user") # Function name: "list_users" # CLI command name: "list" user_app.command("myapp.commands.users:list_users", name="list") # Function name: "delete" # CLI command name: "remove" user_app.command("myapp.commands.users:delete", name="remove") .. code-block:: console $ myapp user list --limit 10 # Imports and runs myapp.commands.users:list_users $ myapp user remove --username alice # Imports and runs myapp.commands.users:delete If ``name`` is not specified, Cyclopts derives it from the function name with :attr:`App.name_transform ` applied (typically converting underscores to hyphens). -------------- Error Handling -------------- If an import path/configuration is invalid, the error occurs **when the command is executed**, not when it's registered: .. code-block:: python from cyclopts import App app = App() # This won't error immediately - registration succeeds app.command("nonexistent.module:func") app() .. code-block:: console $ myapp func # Now the error occurs: ImportError: Cannot import module 'nonexistent.module' To catch import errors early, you can access the command during testing: .. code-block:: python import pytest from cyclopts import App def test_lazy_commands_are_importable(): app = App() app.command("myapp.commands:create") # This will trigger the import and fail if path is wrong resolved = app["create"] assert resolved is not None ----------------------- Groups and Lazy Loading ----------------------- .. tip:: **TL;DR:** Pass ``group=`` at registration time, and define :class:`~cyclopts.Group` objects in your main CLI module, NOT in lazy-loaded modules. Lazy commands support ``group=`` at registration time, so they appear in the correct group in ``--help`` output without being resolved. However, :class:`~cyclopts.Group` objects defined **only inside** unresolved lazy modules won't be available until those modules are imported. To avoid this, define :class:`~cyclopts.Group` objects in non-lazy modules. .. code-block:: python # myapp/cli.py (always imported) from cyclopts import App, Group # Define Group objects here admin_group = Group("Admin Commands", validator=require_admin_role) db_group = Group("Database", default_parameter=Parameter(envvar_prefix="DB_")) app = App() # Lazy commands can reference the Group objects app.command("myapp.admin:create_user", group=admin_group) app.command("myapp.admin:delete_user", group=admin_group) app.command("myapp.db:migrate", group=db_group) **What to avoid:** Defining ``Group`` objects inside lazy-loaded modules: .. code-block:: python # myapp/admin.py (lazy-loaded) from cyclopts import App, Group # BAD: This Group won't be available to other commands until this module is imported admin_group = Group("Admin Commands", validator=require_admin_role) def create_user(): ... If you reference a group by string (e.g., ``group="Admin Commands"``) and the :class:`~cyclopts.Group` object with that name is only defined in an unresolved lazy module, the group won't be available until that lazy module is imported. This means that: - Validators defined on the lazy-loaded :class:`~cyclopts.Group` won't be applied to commands in other modules. - :attr:`.Group.default_parameter` and other settings won't be inherited by commands **referencing the group by string**. Once the lazy module is imported (e.g., by executing one of its commands), the :class:`~cyclopts.Group` object becomes available and subsequent operations will use it correctly. BrianPugh-cyclopts-921b1fa/docs/source/meta_app.rst000066400000000000000000000160541517576204000224230ustar00rootroot00000000000000.. _Meta App: ======== Meta App ======== What if you want more control over the application launch process? Cyclopts provides the option of launching an app from an app; a meta app! ------------ Meta Sub App ------------ Typically, a Cyclopts application is launched by calling the :class:`.App` object: .. code-block:: python from cyclopts import App app = App() # Register some commands here (not shown) app() # Run the app To change how the primary app is run, you can use the meta-app feature of Cyclopts. The meta app is a special :class:`.App` that inherits configuration from its parent and has its help-page merged with the parent app's help. .. code-block:: python from cyclopts import App, Group, Parameter from typing import Annotated app = App() # Rename the meta's "Parameter" -> "Session Parameters". # Set sort_key so it will be drawn higher up the help-page. app.meta.group_parameters = Group("Session Parameters", sort_key=0) @app.command def foo(loops: int): for i in range(loops): print(f"Looping! {i}") @app.meta.default def my_app_launcher(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], user: str): print(f"Hello {user}") app(tokens) app.meta() .. code-block:: console $ my-script --user=Bob foo 3 Hello Bob Looping! 0 Looping! 1 Looping! 2 The variable positional ``*tokens`` will aggregate all remaining tokens, including those starting with a hyphen (typically options). We can then pass them along to the primary ``app``. The ``meta`` app inherits many configuration values from its parent app and is additionally scanned when generating help screens. ``*tokens`` is annotated with ``show=False`` since we do not want this variable to show up in the help screen. .. code-block:: console $ my-script --help Usage: my-script COMMAND ╭─ Session Parameters ────────────────────────────────────────────────────╮ │ * --user [required] │ ╰─────────────────────────────────────────────────────────────────────────╯ ╭─ Commands ──────────────────────────────────────────────────────────────╮ │ foo │ │ --help,-h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────────────────╯ ------------- Meta Commands ------------- If you want a command to circumvent ``my_app_launcher``, add it as you would any other command to the meta app. .. code-block:: python @app.meta.command def info(): print("CLI didn't have to provide --user to call this.") .. code-block:: console $ my-script info CLI didn't have to provide --user to call this. $ my-script --help Usage: my-script COMMAND ╭─ Session Parameters ────────────────────────────────────────────────────╮ │ * --user [required] │ ╰─────────────────────────────────────────────────────────────────────────╯ ╭─ Commands ──────────────────────────────────────────────────────────────╮ │ foo │ │ info │ │ --help,-h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────────────────╯ Just like a standard application, the parsed ``command`` executes instead of ``default``. ------------------------- Custom Command Invocation ------------------------- The core logic of :meth:`.App.__call__` method is the following: .. code-block:: python def __call__(self, tokens=None, **kwargs): command, bound, ignored = self.parse_args(tokens, **kwargs) return command(*bound.args, **bound.kwargs) Knowing this, we can easily customize how we actually invoke actions with Cyclopts. Let's imagine that we want to instantiate an object, ``User`` in our meta app, and pass it to subsequent commands that need it. This might be useful to share an expensive-to-create object amongst commands in a single session; see :ref:`Command Chaining`. .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() class User: def __init__(self, name): self.name = name @app.command def create( age: int, *, user_obj: Annotated[User, Parameter(parse=False)], ): print(f"Creating user {user_obj.name} with age {age}.") @app.meta.default def launcher(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], user: str): additional_kwargs = {} command, bound, ignored = app.parse_args(tokens) # "ignored" is a dict mapping python-variable-name to it's type annotation for parameters with "parse=False". if "user_obj" in ignored: # 'ignored["user_obj"]' is the class "User" additional_kwargs["user_obj"] = ignored["user_obj"](user) return command(*bound.args, **bound.kwargs, **additional_kwargs) if __name__ == "__main__": app.meta() .. code-block:: console $ my-script create --user Alice 30 Creating user Alice with age 30. The ``parse=False`` configuration tells Cyclopts to not try and bind arguments to this parameter. Cyclopts will pass it along to ``ignored`` to make custom meta-app logic easier. The annotated parameter **must** be a keyword-only parameter. .. tip:: For app-wide control over which parameters are parsed, :attr:`~.Parameter.parse` can also accept a **regex pattern**. This can be useful for automatically skipping all "private" parameters (e.g., those prefixed with ``_``) with the regex pattern ``"^(?!_)"``. See :ref:`Skipping Private Parameters` for details. BrianPugh-cyclopts-921b1fa/docs/source/migration/000077500000000000000000000000001517576204000220665ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/migration/typer.rst000066400000000000000000000111051517576204000237610ustar00rootroot00000000000000==================== Migrating From Typer ==================== Much of Cyclopts's syntax is `Typer`_-inspired. Migrating from Typer should be pretty straightforward; it is recommended to first read the :ref:`Getting Started` and :ref:`Commands` sections. The below table offers a jumping off point for translating the various portions of the APIs. The :ref:`Typer Comparison` page also provides many examples comparing the APIs. .. list-table:: Typer-to-Cyclopts API Reference :widths: 30 30 40 :header-rows: 1 * - Typer - Cyclopts - Notes * - :class:`typer.Typer()` - :class:`cyclopts.App()` - Same/similar fields: + :attr:`.App.name` - Optional name of application or sub-command. Cyclopts has more user-friendly default features: + Equivalent ``no_args_is_help=True``. + Equivalent ``pretty_exceptions_enable=False``. * - :meth:`@app.command()` - :meth:`@app.command() <.App.command>` - In Cyclopts, ``@app.command`` :ref:`always results in a command. ` To define an action when no command is provided, see :meth:`@app.default <.App.default>`. * - :meth:`app.add_typer(...)` - :meth:`app.command(...)` - Sub applications and commands are registered the same way in Cyclopts. * - :meth:`@app.callback()` - :meth:`@app.default() <.App.default>` :meth:`@app.meta.default() <.App.default>` - Typer's callback always executes before executing an app. If used to provide functionality when no command was specified from the CLI, then use :meth:`@app.default() <.App.default>`. Otherwise, checkout Cyclopt's :ref:`Meta App`. * - :class:`Annotated[..., typer.Argument(...)]` :class:`Annotated[..., typer.Option(...)]` - :class:`Annotated[..., cyclopts.Parameter(...)] <.Parameter>` - In Cyclopts, Positional/Keyword arguments :ref:`are determined from the function signature. ` Some of Typer's validation fields, like ``exists`` for :class:`~pathlib.Path` types are handled in Cyclopts :ref:`by explicit validators. ` Cyclopts and Typer mostly handle type-hints the same way, but there are a few notable exceptions: .. list-table:: Typer-to-Cyclopts Type-Hints :widths: 30 70 :header-rows: 1 * - Type Annotation - Notes * - :class:`~enum.Enum` - Compared to Typer, Cyclopts handles :class:`~enum.Enum` lookups :ref:`in the reverse direction. ` Frequently, :obj:`~typing.Literal` :ref:`offers a more terse, intuitive choice option. ` * - :obj:`~typing.Union` - Typer does **not** support type unions. :ref:`Cyclopts does. ` ------------- General Steps ------------- #. Add the following import: ``from cyclopts import App, Parameter``. #. Change ``app = Typer(...)`` to just ``app = App()``. Revisit more advanced configuration later. #. Remove all ``@app.callback`` stuff. Cyclopts already provides a good ``--version`` handler for you. #. Replace all ``Annotated[..., Argument/Option]`` type-hints with :class:`Annotated[..., Parameter()] <.Parameter>`. If only supplying a :attr:`~.Parameter.help` string, :ref:`it's better to supply it via docstring. ` #. Cyclopts has similar boolean-flag handling as Typer, :ref:`but has different configuration parameters. ` .. code-block:: python ######### # Typer # ######### # Overriding the name results in no "False" flag generation. my_flag: Annotated[bool, Option("--my-custom-flag")] # However, it can be custom specified: my_flag: Annotated[bool, Option("--my-custom-flag/--disable-my-custom-flag")] ############ # Cyclopts # ############ # Overriding the name still results in "False" flag generation: # --my-custom-flag --no-my-custom-flag my_flag: Annotated[bool, Parameter("--my-custom-flag")] # Negative flag generation can be disabled: # --my-custom-flag my_flag: Annotated[bool, Parameter("--my-custom-flag", negative="")] # Or the prefix can be changed: # --my-custom-flag --disable-my-custom-flag my_flag: Annotated[bool, Parameter("--my-custom-flag", negative_bool="--disable-")] After the basic migration is done, it is recommended to read through the rest of Cyclopts's documentation to learn about some of the better functionality it has, which could result in cleaner, terser code. .. _Typer: https://typer.tiangolo.com .. _always results in a command.: https://github.com/tiangolo/typer/issues/315 BrianPugh-cyclopts-921b1fa/docs/source/mkdocs_integration.rst000066400000000000000000000265671517576204000245320ustar00rootroot00000000000000MkDocs Integration ================== Cyclopts provides builtin `MkDocs `_ support. .. warning:: The MkDocs plugin is **experimental** and may have breaking changes in future releases. If you encounter any issues or have feedback, please `report them on GitHub `_. .. contents:: Table of Contents :local: :depth: 2 Quick Start ----------- 1. Install Cyclopts with MkDocs support: .. code-block:: bash pip install cyclopts[mkdocs] 2. Add the plugin to your MkDocs configuration (``mkdocs.yml``): .. code-block:: yaml plugins: - cyclopts 3. Use the directive in your Markdown files: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app Directive Usage --------------- Basic Syntax ~~~~~~~~~~~~ The ``::: cyclopts`` directive uses YAML format and accepts a module path to your Cyclopts ``App`` object: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app Module Path Formats ~~~~~~~~~~~~~~~~~~~ The directive accepts two module path formats: 1. **Explicit format** (``module.path:app_name``): .. code-block:: markdown ::: cyclopts module: mypackage.cli:app ::: cyclopts module: myapp.commands:main_app ::: cyclopts module: src.cli:cli This explicitly specifies which ``App`` object to document. 2. **Automatic discovery** (``module.path``): .. code-block:: markdown ::: cyclopts module: mypackage.cli ::: cyclopts module: myapp.main The plugin will search the module for an ``App`` instance, looking for common names like ``app``, ``cli``, or ``main``. Directive Options ----------------- The directive supports several options to customize the generated documentation. All options use standard YAML syntax: ``module`` - Module Path (Required) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The module path to your Cyclopts App instance: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app This is the only required option. ``heading_level`` - Heading Level ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Set the starting heading level for the generated documentation (1-6, default: 2): .. code-block:: markdown ::: cyclopts module: mypackage.cli:app heading_level: 3 This is useful when you need to adjust the heading hierarchy. The default of 2 works well for most cases where the directive is placed under a page title. ``max_heading_level`` - Maximum Heading Level ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Set the maximum heading level to use (1-6, default: 6): .. code-block:: markdown ::: cyclopts module: mypackage.cli:app max_heading_level: 4 Headings deeper than this level will be capped at this value. This is useful for deeply nested command hierarchies where you want to prevent headings from becoming too small. ``recursive`` - Include Subcommands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Control whether to document subcommands recursively (default: true): .. code-block:: markdown ::: cyclopts module: mypackage.cli:app recursive: false Set to ``false`` to only document the top-level commands. ``include_hidden`` - Show Hidden Commands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Include commands marked with ``show=False`` in the documentation: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app include_hidden: true By default, hidden commands are not included in the generated documentation. ``flatten_commands`` - Generate Flat Command Hierarchy ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Generate all commands at the same heading level instead of nested hierarchy: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app flatten_commands: true This creates distinct, equally-weighted headings for each command and subcommand, making them easier to reference and navigate in the documentation. Without this option, subcommands are nested with incrementing heading levels. ``generate_toc`` - Generate Table of Contents ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Control whether to generate a table of contents for multi-command apps (default: true): .. code-block:: markdown ::: cyclopts module: mypackage.cli:app generate_toc: false This is useful when you want to suppress the automatic table of contents, especially when using multiple directives on the same page or when you have your own navigation structure. ``code_block_title`` - Render Titles as Inline Code ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Render command titles with inline code formatting (backticks) instead of plain text: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app code_block_title: true When enabled, command titles are rendered as ``#### `command-name``` instead of ``#### command-name``. This makes command names appear with monospace formatting, which can be useful for certain documentation themes or to make command names stand out visually. ``commands`` - Filter Specific Commands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Document only specific commands from your CLI application: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app commands: - init - build - deploy This will only document the specified commands. You can also use nested command paths with dot notation: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app commands: - db.migrate - db.backup - api Or use inline YAML list syntax: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app commands: [db.migrate, db.backup, api] - ``db.migrate`` - Documents only the ``migrate`` subcommand under ``db`` - ``db.backup`` - Documents only the ``backup`` subcommand under ``db`` - ``api`` - Documents the ``api`` command and all its subcommands You can use either underscore or dash notation in command names - they will be normalized automatically. ``exclude_commands`` - Exclude Specific Commands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Exclude specific commands from the documentation: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app exclude_commands: - debug - internal-test This is useful for hiding internal or debug commands from user-facing documentation. Like ``commands``, this also supports nested command paths with dot notation and inline YAML list syntax. ``skip_preamble`` - Skip Description and Usage ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Skip the description and usage sections for the target command when filtering to a single command: .. code-block:: markdown ::: cyclopts module: mypackage.cli:app commands: [deploy] skip_preamble: true When you filter to a single command using ``commands`` and provide your own section heading in the Markdown, you may not want the plugin to generate the command's description and usage block. Setting ``skip_preamble: true`` suppresses these sections while still generating the command's parameters and subcommands. This is useful when you want to write your own introduction for a command section: .. code-block:: markdown ## Deployment Deploy your application to production with these commands. ::: cyclopts module: mypackage.cli:app commands: [deploy] skip_preamble: true Without ``skip_preamble``, the output would include both your introduction and the command's docstring description, which can be redundant. Complete Example ---------------- Here's a complete example showing a CLI application and its MkDocs documentation: CLI Application (``myapp/cli.py``): ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from pathlib import Path from typing import Optional from cyclopts import App app = App( name="myapp", help="My awesome CLI application", version="1.0.0" ) @app.command def init(path: Path = Path("."), template: str = "default"): """Initialize a new project. Parameters ---------- path : Path Directory where the project will be created template : str Project template to use """ print(f"Initializing project at {path}") @app.command def build(source: Path, output: Optional[Path] = None, *, minify: bool = False): """Build the project. Parameters ---------- source : Path Source directory output : Path, optional Output directory (defaults to source/dist) minify : bool Minify the output files """ output = output or source / "dist" print(f"Building from {source} to {output}") if __name__ == "__main__": app() MkDocs Configuration (``mkdocs.yml``): ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: yaml site_name: MyApp Documentation site_description: Documentation for MyApp CLI theme: name: readthedocs plugins: - search - cyclopts nav: - Home: index.md - CLI Reference: cli-reference.md - User Guide: guide.md markdown_extensions: - admonition - codehilite - toc: permalink: true Documentation File (``docs/cli-reference.md``): ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: markdown # CLI Reference This section documents all available CLI commands. ::: cyclopts module: myapp.cli:app heading_level: 2 recursive: true The above directive will automatically generate documentation for all commands, including their parameters, types, defaults, and help text. Advanced Usage -------------- Using Flat Command Structure ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When you want each command to have its own distinct heading for better navigation: .. code-block:: markdown # CLI Command Reference ::: cyclopts module: myapp.cli:app flatten_commands: true This generates all commands at the same heading level (not nested), making it easier to navigate and reference specific commands. Selective Command Documentation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Split your CLI documentation across multiple sections or pages: .. code-block:: markdown ## Database Commands The following commands manage database operations: ::: cyclopts module: myapp.cli:app commands: [db] recursive: true ## API Management Commands for controlling the API server: ::: cyclopts module: myapp.cli:app commands: [api] recursive: true ## Development Tools Utilities for development (excluding internal debug commands): ::: cyclopts module: myapp.cli:app commands: [dev] exclude_commands: [dev.debug, dev.internal] recursive: true This approach allows you to: - Organize large CLI applications into logical sections - Document different command groups on separate pages - Exclude internal or debug commands from user documentation - Create targeted documentation for different audiences See Also -------- * :doc:`/sphinx_integration` - Sphinx integration (similar functionality) * :doc:`/help` - Customizing help output * :doc:`/commands` - Creating commands and subcommands * :doc:`/parameters` - Parameter types and validation * `MkDocs Documentation `_ - Official MkDocs documentation BrianPugh-cyclopts-921b1fa/docs/source/packaging.rst000066400000000000000000000072051517576204000225570ustar00rootroot00000000000000========= Packaging ========= Packaging is bundling up your python library so that it can be easily ``pip install`` by others. Typically this involves: 1. Bundling the code into a Built Distribution (wheel) and/or Source Distribution (sdist). 2. Uploading (publishing) the distribution(s) to python package repository, like PyPI. This section is a brief bootcamp on package **configuration** for a CLI application. This is **not** intended to be a complete tutorial on python packaging and publishing. In this tutorial, replace all instances of ``mypackage`` with your own project name. --------------- \_\_main\_\_.py --------------- In python, if you have a module ``mypackage/__main__.py``, it will be executed with the bash command ``python -m mypackage``. A pretty bare-bones Cyclopts ``mypackage/__main__.py`` will look like: .. code-block:: python # mypackage/__main__.py import cyclopts app = cyclopts.App() @app.command def foo(name: str): print(f"Hello {name}!") if __name__ == "__main__": app() .. code-block:: console $ python -m mypackage World Hello World! ----------- Entrypoints ----------- If you want your application to be callable like a standard bash executable (i.e. ``my-package`` instead of ``python -m mypackage``), we'll need to add an entrypoint_. Modern Python projects typically use ``pyproject.toml`` for configuration. The standard way to define console scripts is: .. code-block:: toml # pyproject.toml [project.scripts] my-package = "mypackage.__main__:app" This creates an executable named ``my-package`` that executes the callable ``app`` object (from the right of the colon) from the python module ``mypackage.__main__``. Note that this configuration is independent of any special naming, like ``__main__`` or ``app``. ^^^^^^^^^^^^^^^^^^^^^ Legacy Configurations ^^^^^^^^^^^^^^^^^^^^^ For older projects, you may encounter these alternative formats: **setup.py:** .. code-block:: python # setup.py from setuptools import setup setup( # There should be a lot more fields populated here. entry_points={ "console_scripts": [ "my-package = mypackage.__main__:app", ] }, ) **setup.cfg:** .. code-block:: cfg # setup.cfg [options.entry_points] console_scripts = my-package = mypackage.__main__:app **Poetry:** .. code-block:: toml # pyproject.toml [tool.poetry.scripts] my-package = "mypackage.__main__:app" The setuptools entrypoint_ documentation goes into further detail. .. _Result Action: ------------- Result Action ------------- When using Cyclopts as a CLI application, command return values are automatically handled appropriately. By default, :class:`~cyclopts.App` uses ``"print_non_int_sys_exit"`` mode, which calls :func:`sys.exit` with the appropriate exit code: - String returns are printed to stdout, then :func:`sys.exit(0) ` is called - Integer returns are passed to :func:`sys.exit(int) ` as the exit code - Boolean returns are converted: :obj:`True` → :func:`sys.exit(0) `, :obj:`False` → :func:`sys.exit(1) ` - :obj:`None` returns call :func:`sys.exit(0) ` This default behavior makes Cyclopts applications work consistently whether run directly as scripts or installed via `console_scripts entry points `_. The :attr:`~cyclopts.App.result_action` can be customized if different behavior is needed: .. _Poetry: https://python-poetry.org .. _entrypoint: https://setuptools.pypa.io/en/latest/userguide/entry_point.html#entry-points BrianPugh-cyclopts-921b1fa/docs/source/parameter_validators.rst000066400000000000000000000072671517576204000250530ustar00rootroot00000000000000.. _Parameter Validators: ==================== Parameter Validators ==================== In CLI applications, users have the freedom to input a wide range of data. This flexibility can lead to inputs the application does not expect. By coercing the input into a data type (like an :obj:`int`), we are already limiting the input to a certain degree (e.g. "foo" cannot be coerced into an integer). To further restrict the user input, you can populate the :attr:`~.Parameter.validator` field of :class:`.Parameter`. A validator is any callable object (such as a function) that has the signature: .. code-block:: python def validator(type_, value: Any) -> None: pass # Raise any exception here if ``value`` is invalid. Validation happens **after** the data converter runs. Any of :exc:`AssertionError`, :exc:`TypeError` or :exc:`ValidationError` will be promoted to a :exc:`cyclopts.ValidationError` so that the exception gets presented to the end-user in a nicer way. More than one validator can be supplied as a list to the :attr:`~.Parameter.validator` field. Cyclopts has some builtin common validators in the :ref:`cyclopts.validators ` module. See :ref:`Annotated Types` for common specific definitions provided as convenient pre-annotated types. ---- Path ---- The :class:`.Path` validator ensures certain properties of the parsed :class:`pathlib.Path` object, such as asserting the file must exist. .. code-block:: python from cyclopts import App, Parameter, validators from typing import Annotated from pathlib import Path app = App() @app.default() def foo(path: Annotated[Path, Parameter(validator=validators.Path(exists=True))]): print(f"File contents:\n{path.read_text()}") app() .. code-block:: console $ echo Hello World > my_file.txt $ my-script my_file.txt File contents: Hello World $ my-script this_file_does_not_exist.txt ╭─ Error ────────────────────────────────────────────────────────────╮ │ Invalid value "this_file_does_not_exist.txt" for "PATH". │ │ "this_file_does_not_exist.txt" does not exist. │ ╰────────────────────────────────────────────────────────────────────╯ See :ref:`Annotated Path Types ` for Annotated-Type equivalents of common Path converter/validators. ------ Number ------ The :class:`.Number` validator can set minimum and maximum input values. .. code-block:: python from cyclopts import App, Parameter, validators from typing import Annotated app = App() @app.default() def foo(n: Annotated[int, Parameter(validator=validators.Number(gte=0, lt=16))]): print(f"Your number in hex is {str(hex(n))[2]}.") app() .. code-block:: console $ my-script 0 Your number in hex is 0. $ my-script 15 Your number in hex is f. $ my-script 16 ╭─ Error ────────────────────────────────────────────────────────────╮ │ Invalid value "16" for "N". Must be < 16. │ ╰────────────────────────────────────────────────────────────────────╯ See :ref:`Annotated Number Types ` for Annotated-Type equivalents of common Number converter/validators. BrianPugh-cyclopts-921b1fa/docs/source/parameters.rst000066400000000000000000000505451517576204000230030ustar00rootroot00000000000000========== Parameters ========== Typically, Cyclopts gets all the information it needs from object names, type hints, and the function docstring: .. code-block:: python from cyclopts import App app = App(help="This is help for the root application.") @app.command def foo(value: int): # Cyclopts uses the ``value`` name and ``int`` type hint """Cyclopts uses this short description for help. Parameters ---------- value: int Cyclopts uses this description for ``value``'s help. """ app() Running the example: .. code-block:: console $ my-script --help Usage: my-script COMMAND This is help for the root application. ╭─ Commands ──────────────────────────────────────────────────────────╮ │ foo Cyclopts uses this short description for help. │ │ --help,-h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────────────╯ $ my-script foo --help Usage: my-script [ARGS] [OPTIONS] Cyclopts uses this short description for help. ╭─ Parameters ─────────────────────────────────────────────────────────────────────────╮ │ * VALUE --value Cyclopts uses this description for value's help. [required] │ ╰──────────────────────────────────────────────────────────────────────────────────────╯ This keeps the code as clean and terse as possible. However, if more control is required, we can provide additional information by `annotating `_ type hints with :class:`.Parameter`. .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.command def foo(bar: Annotated[int, Parameter(...)]): pass app() :class:`.Parameter` gives complete control on how Cyclopts processes the annotated parameter. See the :ref:`API` page for all configurable options. This page will investigate some of the more common use-cases. .. note:: :class:`.Parameter` can also be used as a decorator. This is :ref:`particularly useful for class definitions `. ------ Naming ------ Like :ref:`command names `, CLI parameter names are derived from their python counterparts. However, sometimes customization is needed. .. _Parameters - Naming - Manual Naming: ^^^^^^^^^^^^^ Manual Naming ^^^^^^^^^^^^^ Parameter names (and their short forms) can be manually specified: .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def main( *, foo: Annotated[str, Parameter(name=["--foo", "-f"])], # Adding a short-form # Equivalently, you could have done Parameter(alias="-f") bar: Annotated[str, Parameter(name="--something-else")], ): pass app() .. code-block:: console $ my-script --help Usage: main COMMAND [OPTIONS] ╭─ Commands ──────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────╯ ╭─ Parameters ────────────────────────────────────────────╮ │ * --foo -f [required] │ │ * --something-else [required] │ ╰─────────────────────────────────────────────────────────╯ Manually set names via :attr:`Parameter.name ` are not subject to :attr:`Parameter.name_transform `. Alternatively, additional names can be added to the Cyclopts-derived names (instead of completely overriding them) with :attr:`Parameter.alias `. .. note:: Docstrings should always use the **Python variable name** from the function signature. .. code-block:: python @app.default def main(internal_name: Annotated[str, Parameter(name="external-name")]): """Command description. Parameters ---------- internal_name: # Use the Python variable name Help text here. """ This follows standard Python documentation conventions; the parameter will still appear as ``--external-name`` on the CLI. ^^^^^^^^^^^^^^ Name Transform ^^^^^^^^^^^^^^ The name transform function that converts the python variable name to it's CLI counterpart can be configured by setting :attr:`Parameter.name_transform ` (defaults to :func:`.default_name_transform`). .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() def name_transform(s: str) -> str: return s.upper() @app.default def main( *, foo: Annotated[str, Parameter(name_transform=name_transform)], bar: Annotated[str, Parameter(name_transform=name_transform)], ): pass app() .. code-block:: console $ my-script --help Usage: main COMMAND [OPTIONS] ╭─ Commands ──────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────╯ ╭─ Parameters ────────────────────────────────────────────╮ │ * --FOO [required] │ │ * --BAR [required] │ ╰─────────────────────────────────────────────────────────╯ Notice how the parameter is now ``--FOO`` instead of the standard ``--foo``. .. note:: The returned string is **before** the standard ``--`` is prepended. Generally, it is not very useful to set the name transform on **individual** parameters; it would be easier/clearer :ref:`to manually specify the name `. However, we can change the default name transform for the **entire app** by configuring the app's :ref:`default_parameter `. To change the :attr:`~cyclopts.Parameter.name_transform` across your entire app, add the following to your :class:`~cyclopts.App` configuration: .. code-block:: python app = App( default_parameter=Parameter(name_transform=my_custom_name_transform), ) ---- Help ---- It is recommended to use docstrings for your parameter help, but if necessary, you can explicitly set a help string: .. code-block:: python @app.command def foo(value: Annotated[int, Parameter(help="THIS IS USED.")]): """ Parameters ---------- value: int This description is not used; got overridden. """ .. code-block:: console $ my-script foo --help ╭─ Parameters ──────────────────────────────────────────────────╮ │ * VALUE,--value THIS IS USED. [required] │ ╰───────────────────────────────────────────────────────────────╯ .. _Converters: ---------- Converters ---------- Cyclopts has a powerful coercion engine that automatically converts CLI string tokens to the types hinted in a function signature. However, sometimes a custom :attr:`~.Parameter.converter` is required to transform the input string tokens into the desired type. Lets consider a case where we want the user to specify a file size, and we want to allows suffixes like `"MB"`. .. code-block:: python from cyclopts import App, Parameter, Token from typing import Annotated, Sequence from pathlib import Path app = App() mapping = { "kb": 1024, "mb": 1024 * 1024, "gb": 1024 * 1024 * 1024, } def byte_units(type_, tokens: Sequence[Token]) -> int: # type_ is ``int``, value = tokens[0].value.lower() try: return type_(value) # If this works, it didn't have a suffix. except ValueError: pass number, suffix = value[:-2], value[-2:] return int(number) * mapping[suffix] @app.command def zero(file: Path, size: Annotated[int, Parameter(converter=byte_units)]): """Creates a file of all-zeros.""" print(f"Writing {size} zeros to {file}.") file.write_bytes(bytes(size)) app() .. code-block:: console $ my-script zero out.bin 100 Writing 100 zeros to out.bin. $ my-script zero out.bin 1kb Writing 1024 zeros to out.bin. $ my-script zero out.bin 3mb Writing 3145728 zeros to out.bin. The converter function gets the annotated type, and the :class:`.Token` s parsed for this argument. Tokens are Cyclopt's way of bookkeeping user inputs; in the last command the ``tokens`` object would look like: .. code-block:: python # tokens is a length-1 tuple. The variable "size" only takes in 1 token: tuple( Token( keyword=None, # "3mb" was provided positionally, not by keyword value='3mb', # The string from the command line source='cli', # The value came from the command line, as opposed to other Cyclopts mechanisms. index=0, # For the variable "size", this is the first (0th) token. ), ) ^^^^^^^^^^^^^^^^^^^^^^^^ Controlling Token Count ^^^^^^^^^^^^^^^^^^^^^^^^ By default, Cyclopts infers how many tokens a parameter should consume from its type hint. For example, :obj:`int` consumes 1 token, ``tuple[int, int]`` consumes 2, and ``list[int]`` consumes all remaining tokens. When using custom converters, you may need to override this inference with :attr:`.Parameter.n_tokens`: .. code-block:: python from cyclopts import App, Parameter from typing import Annotated class Point: def __init__(self, x: int, y: int): self.x = x self.y = y def parse_point(type_, tokens): """Parse a coordinate string like '10,20' into a Point.""" x, y = tokens[0].value.split(",") return Point(int(x), int(y)) app = App() @app.default def main(pos: Annotated[Point, Parameter(n_tokens=1, converter=parse_point, accepts_keys=False)]): """Without n_tokens=1, Cyclopts would expect 2 tokens based on Point's __init__ signature.""" print(f"Position: ({pos.x}, {pos.y})") app() .. code-block:: console $ my-script --pos 10,20 Position: (10, 20) The :attr:`.Parameter.accepts_keys` parameter prevents Cyclopts from generating nested options like ``--pos.x`` and ``--pos.y``. Alternative to the above syntax, you can directly **decorate the converter function** itself with :class:`.Parameter` to define its behavior. This keeps all the information organized in a single location. .. code-block:: python from cyclopts import App, Parameter from typing import Annotated class Point: def __init__(self, x: int, y: int): self.x = x self.y = y @Parameter(n_tokens=1, accepts_keys=False) def parse_point(type_, tokens): """Parse a coordinate string like '10,20' into a Point.""" x, y = tokens[0].value.split(",") return Point(int(x), int(y)) app = App() @app.default def main(pos: Annotated[Point, Parameter(converter=parse_point)]): """The converter's n_tokens and accepts_keys are automatically inherited.""" print(f"Position: ({pos.x}, {pos.y})") app() .. code-block:: console $ my-script --pos 10,20 Position: (10, 20) You can also decorate classes directly with the converter: .. code-block:: python @Parameter(converter=parse_point) class Point: def __init__(self, x: int, y: int): self.x = x self.y = y @app.default def main(pos: Point): """No Annotated wrapper needed - converter is part of the class definition.""" print(f"Position: ({pos.x}, {pos.y})") ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Using Classmethods as Converters ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Converter functions are often closely associated with the class they create, making classmethods a natural choice. Cyclopts supports using classmethods as converters through forward string references (for class decoration) or direct references (for function annotations): .. code-block:: python from cyclopts import App, Parameter from typing import Annotated # Decorate the classmethod to configure n_tokens and accepts_keys # (decorator must go above @classmethod) @Parameter(converter="parse") class Point: def __init__(self, x: int, y: int): self.x = x self.y = y @Parameter(n_tokens=1, accepts_keys=False) @classmethod def parse(cls, tokens): """Parse a coordinate string like '10,20' into a Point. Note: classmethod signature is (cls, tokens), not (type_, tokens) """ x, y = tokens[0].value.split(",") return cls(int(x), int(y)) app = App() @app.default def main(pos: Point): """The classmethod's n_tokens and accepts_keys are automatically inherited.""" print(f"Position: ({pos.x}, {pos.y})") app() .. code-block:: console $ my-script --pos 10,20 Position: (10, 20) Alternatively, you can reference the classmethod directly in function annotations: .. code-block:: python class Point: def __init__(self, x: int, y: int): self.x = x self.y = y @classmethod def parse(cls, tokens): x, y = tokens[0].value.split(",") return cls(int(x), int(y)) @app.default def main(pos: Annotated[Point, Parameter(converter=Point.parse, n_tokens=1, accepts_keys=False)]): print(f"Position: ({pos.x}, {pos.y})") **Note on classmethod signatures**: Classmethods used as converters should have the signature ``(cls, tokens)`` rather than ``(type_, tokens)``. Cyclopts automatically detects bound methods and calls them with just the ``tokens`` parameter, since ``cls`` is already bound. ---------------- Validating Input ---------------- Just because data is of the correct type, doesn't mean it's valid. If we had a program that accepts integer user age as an input, ``-1`` is an integer, but not a valid age. .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() def validate_age(type_, value): if value < 0: raise ValueError("Negative ages not allowed.") if value > 150: raise ValueError("You are too old to be using this application.") @app.default def allowed_to_buy_alcohol(age: Annotated[int, Parameter(validator=validate_age)]): print("Under 21: prohibited." if age < 21 else "Good to go!") app() .. code-block:: console $ my-script 30 Good to go! $ my-script 10 Under 21: prohibited. $ my-script -1 ╭─ Error ──────────────────────────────────────────────────────────────────────╮ │ Invalid value "-1" for "AGE". Negative ages not allowed. │ ╰──────────────────────────────────────────────────────────────────────────────╯ $ my-script 200 ╭─ Error ──────────────────────────────────────────────────────────────────────╮ │ Invalid value "200" for "AGE". You are too old to be using this application. │ ╰──────────────────────────────────────────────────────────────────────────────╯ Certain builtin error types (:exc:`ValueError`, :exc:`TypeError`, :exc:`AssertionError`) will be re-interpreted by Cyclopts and formatted into a prettier message for the application user. Cyclopts has some :ref:`builtin validators ` for common situations We can create a similar app as above: .. code-block:: python from cyclopts import App, Parameter, validators from typing import Annotated app = App() @app.default def allowed_to_buy_alcohol(age: Annotated[int, Parameter(validator=validators.Number(gte=0, lte=150))]): # gte - greater than or equal to # lte - less than or equal to print("Under 21: prohibited." if age < 21 else "Good to go!") app() Taking this one step further, Cyclopts has some :ref:`builtin convenience types `. If we didn't care about the upper age bound, we could simplify the application to: .. code-block:: python from cyclopts import App from cyclopts.types import NonNegativeInt app = App() @app.default def allowed_to_buy_alcohol(age: NonNegativeInt): print("Under 21: prohibited." if age < 21 else "Good to go!") app() -------------------- Parameter Resolution -------------------- Cyclopts can combine multiple :class:`.Parameter` annotations together. Say you want to define a new :obj:`int` type that uses the :ref:`byte-centric converter from above`. We can define the type: .. code-block:: python ByteSize = Annotated[int, Parameter(converter=byte_units)] We can then either directly annotate a function parameter with this: .. code-block:: python @app.command def zero(size: ByteSize): pass or even stack annotations to add additional features, like a validator: .. code-block:: python def must_be_multiple_of_4096(type_, value): assert value % 4096 == 0, "Size must be a multiple of 4096" @app.command def zero(size: Annotated[ByteSize, Parameter(validator=must_be_multiple_of_4096)]): pass Python automatically flattens out annotations, so this is interpreted as: .. code-block:: python Annotated[ByteSize, Parameter(converter=byte_units), Parameter(validator=must_be_multiple_of_4096)] Cyclopts will search **right-to-left** for **set** parameter attributes until one is found. I.e. right-most parameter attributes have the highest priority. .. code-block:: console $ my-script 1234 ╭─ Error ──────────────────────────────────────────────────────────────────────╮ │ Invalid value "1234" for "SIZE". Size must be a multiple of 4096 │ ╰──────────────────────────────────────────────────────────────────────────────╯ See :ref:`Parameter Resolution Order` for more details. BrianPugh-cyclopts-921b1fa/docs/source/rules.rst000066400000000000000000001020651517576204000217650ustar00rootroot00000000000000.. _Coercion Rules: ============== Coercion Rules ============== This page intends to serve as a terse set of type coercion rules that Cyclopts follows. Automatic coercion can always be overridden by the :attr:`.Parameter.converter` field. Typically, the :attr:`~.Parameter.converter` function will receive a single token, but it may receive multiple tokens if the annotated type is iterable (e.g. :class:`list`, :class:`set`). The number of tokens can be explicitly controlled with :attr:`~.Parameter.n_tokens`, which is useful when the type signature doesn't match the desired CLI token consumption. ******* No Hint ******* If no explicit type hint is provided: * If the parameter has a **non-None** default value, interpret the type as ``type(default_value)``. .. code-block:: python from cyclopts import App app = App() @app.default def default(value=5): print(f"{value=} {type(value)=}") app() .. code-block:: console $ my-program 3 value=3 type(value)= * Otherwise, :ref:`interpret the type as string `. .. code-block:: python from cyclopts import App app = App() @app.default def default(value): print(f"{value=} {type(value)=}") app() .. code-block:: console $ my-program foo value='foo' type(value)= *** Any *** A standalone ``Any`` type hint is equivalent to `No Hint`_ .. _Coercion Rules - Str: *** Str *** No operation is performed, CLI tokens are natively strings. .. code-block:: python from cyclopts import App app = App() @app.default def default(value: str): print(f"{value=} {type(value)=}") app() .. code-block:: console $ my-program foo value='foo' type(value)= *** Int *** For convenience, Cyclopts provides a richer feature-set of parsing integers than just naively calling ``int``. * Accepts vanilla decimal values (e.g. ``123``, ``3.1415``). Floating-point values will be rounded prior to casting to an ``int``. * Accepts binary values (strings starting with ``0b``) * Accepts octal values (strings starting with ``0o``) * Accepts hexadecimal values (strings starting with ``0x``). ^^^^^^^^^^^^^^ Counting Flags ^^^^^^^^^^^^^^ For parameters that need to track the number of times a flag appears (e.g., verbosity levels like ``-vvv``), use :attr:`.Parameter.count` with an :obj:`int` type hint. .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def main(verbose: Annotated[int, Parameter(alias="-v", count=True)] = 0): print(f"Verbosity level: {verbose}") app() .. code-block:: console $ my-program Verbosity level: 0 $ my-program -v Verbosity level: 1 $ my-program -vvv Verbosity level: 3 $ my-program --verbose --verbose Verbosity level: 2 $ my-program -v --verbose -vv Verbosity level: 4 ***** Float ***** Token gets cast as ``float(token)``. For example, ``float("3.14")``. ******* Complex ******* Token gets cast as ``complex(token)``. For example, ``complex("3+5j")`` **** Bool **** 1. If specified as a **keyword**, booleans are interpreted flags that take no parameter. The default **false-like** flag are ``--no-FLAG-NAME``. See :attr:`.Parameter.negative` for more about this feature. Example: .. code-block:: python from cyclopts import App app = App() @app.command def foo(my_flag: bool): print(my_flag) app() .. code-block:: console $ my-program foo --my-flag True $ my-program foo --no-my-flag False 2. If specified as a **positional** argument, a case-insensitive lookup is performed: * If the token is a **true-like value** ``{"yes", "y", "1", "true", "t"}``, then it is parsed as :obj:`True`. * If the token is a **false-like value** ``{"no", "n", "0", "false", "f"}``, then it is parsed as :obj:`False`. * Otherwise, a :exc:`.CoercionError` will be raised. Cyclopts is stricter than traditional :class:`bool` casting; the provided value **must** be one of the above. For example, ``2`` is **not** considered a true-like value and will raise an error. .. code-block:: console $ my-program foo 1 True $ my-program foo 0 False $ my-program foo 2 ╭─ Error ───────────────────────────────────────╮ │ Invalid value for "--my-flag": unable to │ │ convert "2" into bool. │ ╰───────────────────────────────────────────────╯ $ my-program foo not-a-true-or-false-value ╭─ Error ─────────────────────────────────────────────────╮ │ Invalid value for "--my-flag": unable to convert │ │ "not-a-true-or-false-value" into bool. │ ╰─────────────────────────────────────────────────────────╯ 3. If specified as a keyword with a value attached with an ``=``, then the provided value will be parsed according to positional argument rules above (2). .. code-block:: python from cyclopts import App app = App() @app.command def foo(my_flag: bool): print(my_flag) app() .. code-block:: console $ my-program foo --my-flag=true True $ my-program foo --my-flag=false False $ my-program foo --no-my-flag=true False $ my-program foo --no-my-flag=false True **** List **** Unlike more simple types like :obj:`str` and :obj:`int`, lists use different parsing rules depending on whether the values are provided positionally or by keyword. ^^^^^^^^^^ Positional ^^^^^^^^^^ When arguments are provided positionally: * If :attr:`.Parameter.allow_leading_hyphen` is :obj:`False` (default behavior), reaching an option-like token will stop parsing for this parameter. If the number of consumed tokens is not a multiple of the required number of tokens to create an element of the list, a :exc:`.MissingArgumentError` will be raised. .. code-block:: python from cyclopts import App app = App() @app.command def foo(values: list[int]): # 1 CLI token per element print(values) @app.command def bar(values: list[tuple[int, str]]): # 2 CLI tokens per element print(values) app() .. code-block:: console $ my-program foo 1 2 3 [1, 2, 3] $ my-program bar 1 one 2 two [(1, 'one'), (2, 'two')] $ my-program bar 1 one 2 ╭─ Error ─────────────────────────────────────────────────────╮ │ Command "bar" parameter "--values" requires 2 arguments. │ │ Only got 1. │ ╰─────────────────────────────────────────────────────────────╯ * If :attr:`.Parameter.allow_leading_hyphen` is :obj:`True`, CLI tokens will be consumed unconditionally until exhausted. .. code-block:: python from cyclopts import App, Parameter from pathlib import Path from typing import Annotated app = App() @app.default def main( files: Annotated[list[Path], Parameter(allow_leading_hyphen=True)], some_flag: bool = False, ): print(f"{some_flag=}") print(f"Analyzing files {files}") app() .. code-block:: console $ my-program foo.bin bar.bin --fizz.bin buzz.bin --some-flag some_flag=True Analyzing files [PosixPath('foo.bin'), PosixPath('bar.bin'), PosixPath('--fizz.bin'), PosixPath('buzz.bin')] Known keyword arguments are parsed first (in this case, ``--some-flag``). To unambiguously pass in values positionally, provide them after a bare ``--``: .. code-block:: console $ my-program -- foo.bin bar.bin --fizz.bin buzz.bin --some-flag some_flag=False Analyzing files [PosixPath('foo.bin'), PosixPath('bar.bin'), PosixPath('--fizz.bin'), PosixPath('buzz.bin'), PosixPath('--some-flag')] ^^^^^^^ Keyword ^^^^^^^ When arguments are provided by keyword: * Tokens will be consumed until enough data is collected to form the type-hinted object. * The keyword can be specified multiple times. * If :attr:`.Parameter.allow_leading_hyphen` is :obj:`False` (default behavior), reaching an option-like token will raise :exc:`.MissingArgumentError` if insufficient tokens have been parsed. .. code-block:: python from cyclopts import App app = App() @app.command def foo(values: list[int]): # 1 CLI token per element print(values) @app.command def bar(values: list[tuple[int, str]]): # 2 CLI tokens per element print(values) app() .. code-block:: console $ my-program foo --values 1 --values 2 --values 3 [1, 2, 3] $ my-program bar --values 1 one --values 2 two [(1, 'one'), (2, 'two')] $ my-program bar --values 1 --values 2 ╭─ Error ─────────────────────────────────────────────────────╮ │ Command "bar" parameter "--values" requires 2 arguments. │ │ Only got 1. │ ╰─────────────────────────────────────────────────────────────╯ * If :attr:`.Parameter.consume_multiple` is :obj:`True`, all remaining tokens will be consumed (until an option-like token is reached if :attr:`.Parameter.allow_leading_hyphen` is :obj:`False`). :attr:`.Parameter.consume_multiple` also accepts an :class:`int` (minimum element count) or a ``tuple[int, int]`` for ``(min, max)`` bounds. See the :attr:`.Parameter.consume_multiple` API docs for details. .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def foo(values: Annotated[list[int], Parameter(consume_multiple=True)]): # 1 CLI token per element print(values) app() .. code-block:: console $ my-program foo --values 1 2 3 [1, 2, 3] * If :attr:`.Parameter.allow_repeating` is :obj:`False`, a keyword option cannot be specified more than once. This is especially useful in combination with :attr:`.Parameter.consume_multiple` to allow ``--foo a b c`` but reject ``--foo a --foo b``. If :attr:`.Parameter.allow_repeating` is :obj:`True`, scalar types use "last wins" semantics instead of raising an error. .. code-block:: python from cyclopts import App, Parameter from typing import Annotated app = App() @app.default def foo(values: Annotated[list[int], Parameter(consume_multiple=True, allow_repeating=False)]): print(values) app() .. code-block:: console $ my-program --values 1 2 3 [1, 2, 3] $ my-program --values 1 --values 2 ╭─ Error ──────────────────────────────────────────────────╮ │ Parameter "--values" was specified multiple times. │ ╰─────────────────────────────────────────────────────────╯ ^^^^^^^^^^ Empty List ^^^^^^^^^^ Commonly, if we want a default list for a parameter in a function, we set the default value to ``None`` in the signature and then set it to the actual list in the function body: .. code-block:: python def foo(extensions: Optional[list] = None): if extensions is None: extensions = [".png", ".jpg"] We do this because mutable defaults is a `common unexpected source of bugs in python `_. However, sometimes we actually want to specify an empty list. To get an empty list pass in the flag ``--empty-MY-LIST-NAME``. .. code-block:: python from cyclopts import App app = App() @app.default def main(extensions: list | None = None): if extensions is None: extensions = [".png", ".jpg"] print(f"{extensions=}") app() .. code-block:: console $ my-program extensions=['.png', '.jpg'] $ my-program --empty-extensions extensions=[] See :attr:`.Parameter.negative` for more about this feature. ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Positional Only With Subsequent Parameters ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ When a list is **positional-only**, it will consume tokens such that it leaves enough tokens for subsequent positional-only parameters. .. code-block:: python from pathlib import Path from cyclopts import App app = App() @app.default def main(srcs: list[Path], dst: Path, /): # "/" makes all prior parameters POSITIONAL_ONLY print(f"Processing files {srcs!r} to {dst!r}.") app() .. code-block:: console $ my-program foo.bin bar.bin output.bin Processing files [PosixPath('foo.bin'), PosixPath('bar.bin')] to PosixPath('output.bin'). The console wildcard ``*`` is expanded by the console, so this example will naturally work with wildcards. .. code-block:: console $ ls foo buzz.bin fizz.bin $ my-program foo/*.bin output.bin Processing files [PosixPath('foo/buzz.bin'), PosixPath('foo/fizz.bin')] to PosixPath('output.bin'). ******** Iterable ******** Follows the same rules as `List`_. The passed in data will be a :class:`list`. ******** Sequence ******** Follows the same rules as `List`_. The passed in data will be a :class:`list`. *** Set *** Follows the same rules as `List`_, but the resulting datatype is a :class:`set`. ********* Frozenset ********* Follows the same rules as `Set`_, but the resulting datatype is a :class:`frozenset`. ***** Tuple ***** * The inner type hint(s) will be applied independently to each element. Enough CLI tokens will be consumed to populate the inner types. * Nested fixed-length tuples are allowed: E.g. ``tuple[tuple[int, str], str]`` will consume 3 CLI tokens. * Indeterminite-size tuples ``tuple[type, ...]`` are only supported at the root-annotation level and behave similarly to `List`_. .. code-block:: python from cyclopts import App app = App() @app.default def default(coordinates: tuple[float, float, str]): print(f"{coordinates=}") app() And invoke our script: .. code-block:: console $ my-program --coordinates 3.14 2.718 my-coord-name coordinates=(3.14, 2.718, 'my-coord-name') .. _Coercion Rules - Union: **** Dict **** Cyclopts can populate dictionaries using keyword dot-notation: .. code-block:: python from cyclopts import App app = App() @app.default def default(message: str, *, mapping: dict[str, str] | None = None): if mapping: for find, replace in mapping.items(): message = message.replace(find, replace) print(message) app() .. code-block:: console $ my_program 'Hello Cyclopts users!' Hello Cyclopts users! $ my_program 'Hello Cyclopts users!' --mapping.Hello Hey Hey Cyclopts users! $ my_program 'Hello Cyclopts users!' --mapping.Hello Hey --mapping.users developers Hey Cyclopts developers! Due to the way of specifying keys, it is recommended to make dict parameters keyword-only; dicts **cannot** be populated positionally. If you do not wish for the user to be able to specify arbitrary keys, see `User-Defined Classes`_. For specifying arbitrary keywords at the root level, see :ref:`kwargs `. ***** Union ***** The unioned types will be iterated **left-to-right** until a successful coercion is performed. :obj:`None` type hints are ignored. .. code-block:: python from cyclopts import App from typing import Union app = App() @app.default def default(a: Union[None, int, str]): print(type(a)) app() .. code-block:: console $ my-program 10 $ my-program bar ******** Optional ******** ``Optional[...]`` is syntactic sugar for ``Union[..., None]``. See Union_ rules. .. _Coercion Rules - Literal: ******* Literal ******* The :obj:`~typing.Literal` type is a good option for limiting user input to a set of choices. Like Union_, the :obj:`~typing.Literal` options will be iterated **left-to-right** until a successful coercion is performed. Cyclopts attempts to coerce the input token into the **type** of each :obj:`~typing.Literal` option. .. code-block:: python from cyclopts import App from typing import Literal app = App() @app.default def default(value: Literal["foo", "bar", 3]): print(f"{value=} {type(value)=}") app() .. code-block:: console $ my-program foo value='foo' type(value)= $ my-program bar value='bar' type(value)= $ my-program 3 value=3 type(value)= $ my-program fizz ╭─ Error ─────────────────────────────────────────────────╮ │ Invalid value for "VALUE": unable to convert "fizz" │ │ into one of {'foo', 'bar', 3}. │ ╰─────────────────────────────────────────────────────────╯ **** Enum **** While `Literal`_ is the recommended way of providing the user a set of choices, another method is using :class:`~enum.Enum`. The :attr:`Parameter.name_transform ` gets applied to all :class:`~enum.Enum` names, as well as the CLI provided token. By default,this means that a **case-insensitive name** lookup is performed. If an enum name contains an underscore, the CLI parameter **may** instead contain a hyphen, ``-``. Leading/Trailing underscores will be stripped. If coming from Typer_, **Cyclopts Enum handling is the reverse of Typer**. Typer attempts to match the token to an Enum **value**; Cyclopts attempts to match the token to an Enum **name**. This is done because generally the **name** of the enum is meant to be human readable, while the **value** has some program/machine significance. As a real-world example, the PNG image format supports `5 different color-types `_, which gets encoded into a `1-byte int in the image header `_. .. code-block:: python from cyclopts import App from enum import IntEnum app = App() class ColorType(IntEnum): GRAYSCALE = 0 RGB = 2 PALETTE = 3 GRAYSCALE_ALPHA = 4 RGBA = 6 @app.default def default(color_type: ColorType = ColorType.RGB): print(f"Writing color-type value: {color_type} to the image header.") app() .. code-block:: console $ my-program Writing color-type value: 2 to the image header. $ my-program grayscale-alpha Writing color-type value: 4 to the image header. **** Flag **** :class:`~enum.Flag` enums (and by extension, :class:`~enum.IntFlag`) are treated as a collection of boolean flags. The :attr:`Parameter.name_transform ` gets applied to all :class:`~enum.Flag` names, as well as the CLI provided token. By default, this means that a **case-insensitive name** lookup is performed. If an enum name contains an underscore, the CLI parameter **may** instead contain a hyphen, ``-``. Leading/Trailing underscores will be stripped. .. code-block:: python from cyclopts import App from enum import Flag, auto app = App() class Permission(Flag): READ = auto() WRITE = auto() EXECUTE = auto() @app.default def default(permissions: Permission = Permission.READ): print(f"Permissions: {permissions}") app() .. code-block:: console $ my-program Permissions: Permission.READ $ my-program write Permissions: Permission.WRITE $ my-program read write Permissions: Permission.READ|WRITE $ my-program --permissions.write Permissions: Permission.WRITE $ my-program --permissions.write --permissions.read Permissions: Permission.READ|WRITE .. note:: If you want to directly expose the flags as booleans (e.g. ``--read``), then see :ref:`Namespace Flattening `. .. _Coercion Rules - Dataclasses: ******** date ******** Cyclopts supports parsing dates into a :class:`~datetime.date` object. It uses :meth:`~datetime.date.fromisoformat` under the hood, so the only supported format is ``%Y-%m-%d`` (e.g. 1956-01-31). However, if you use newer Python (>= 3.11), it also supports other formats such as ``%Y%m%d`` (e.g., 20191204), 2021-W01-1, etc, defined by ISO 8601. ******** datetime ******** Cyclopts supports parsing timestamps into a :class:`~datetime.datetime` object. The supplied time must be in one of the following formats: - ``%Y-%m-%d`` (e.g. 1956-01-31) - ``%Y-%m-%dT%H:%M:%S`` (e.g. 1956-01-31T10:00:00) - ``%Y-%m-%d %H:%M:%S`` (e.g. 1956-01-31 10:00:00) - ``%Y-%m-%dT%H:%M:%S%z`` (e.g. 1956-01-31T10:00:00+0000) - ``%Y-%m-%dT%H:%M:%S.%f`` (e.g. 1956-01-31T10:00:00.123456) - ``%Y-%m-%dT%H:%M:%S.%f%z`` (e.g. 1956-01-31T10:00:00.123456+0000) ********* timedelta ********* Cyclopts supports parsing time durations into a :class:`~datetime.timedelta` object. The supplied time must be in one of the following formats: - ``30s`` - 30 seconds - ``5m`` - 5 minutes - ``2h`` - 2 hours - ``1d`` - 1 day - ``3w`` - 3 weeks - ``6M`` - 6 months (approximate) - ``1y`` - 1 year (approximate) Combining durations is also supported: - "1h30m" - 1 hour and 30 minutes - "1d12h" - 1 day and 12 hours ******************** User-Defined Classes ******************** Cyclopts supports classically defined user classes, as well as classes defined by the following dataclass-like libraries: * `attrs `_ * `dataclass `_ * `NamedTuple `_ * `pydantic `_ * `TypedDict `_ .. note:: For ``pydantic`` classes, Cyclopts will *not* internally perform type conversions and instead relies on pydantic's coercion engine. Subkey parsing allows for assigning values positionally and by keyword with a dot-separator. .. code-block:: python from cyclopts import App from dataclasses import dataclass from typing import Literal app = App() @dataclass class User: name: str age: int region: Literal["us", "ca"] = "us" @app.default def main(user: User): print(user) app() .. code-block:: console $ my-program --help Usage: main COMMAND [ARGS] [OPTIONS] ╭─ Commands ──────────────────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ────────────────────────────────────────────────────────────────────╮ │ * USER.NAME --user.name [required] │ │ * USER.AGE --user.age [required] │ │ USER.REGION --user.region [choices: us, ca] [default: us] │ ╰─────────────────────────────────────────────────────────────────────────────────╯ $ my-program 'Bob Smith' 30 User(name='Bob Smith', age=30, region='us') $ my-program --user.name 'Bob Smith' --user.age 30 User(name='Bob Smith', age=30, region='us') $ my-program --user.name 'Bob Smith' 30 --user.region=ca User(name='Bob Smith', age=30, region='ca') Cyclopts will recursively search for :class:`~.Parameter` annotations and respect them: .. code-block:: python from cyclopts import App, Parameter from dataclasses import dataclass from typing import Annotated app = App() @dataclass class User: # Beginning with "--" will completely override the parenting parameter name. name: Annotated[str, Parameter(name="--nickname")] # Not beginning with "--" will tack it on to the parenting parameter name. age: Annotated[int, Parameter(name="years-young")] @app.default def main(user: Annotated[User, Parameter(name="player")]): print(user) app() .. code-block:: console $ my-program --help Usage: main COMMAND [ARGS] [OPTIONS] ╭─ Commands ────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰───────────────────────────────────────────────────────────╯ ╭─ Parameters ──────────────────────────────────────────────╮ │ * NICKNAME --nickname [required] │ │ * PLAYER.YEARS-YOUNG [required] │ │ --player.years-young │ ╰───────────────────────────────────────────────────────────╯ ^^^^^^^^^^^^^^^^^^^^ Namespace Flattening ^^^^^^^^^^^^^^^^^^^^ The special parameter name ``"*"`` will remove the immediate parameter's name from the dotted-hierarchal name: .. code-block:: python from cyclopts import App, Parameter from dataclasses import dataclass from typing import Annotated app = App() @dataclass class User: name: str age: int @app.default def main(user: Annotated[User, Parameter(name="*")]): print(user) app() .. code-block:: console $ my-program --help Usage: main COMMAND [ARGS] [OPTIONS] ╭─ Commands ─────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────╮ │ * NAME --name [required] │ │ * AGE --age [required] │ ╰────────────────────────────────────────────────────────╯ This can be used to conveniently share parameters between commands, and to create a global config object. See :ref:`Sharing Parameters`. ^^^^^^^^^^ Docstrings ^^^^^^^^^^ Docstrings from the class are used for the help page. Docstrings from the command have priority over class docstrings, if supplied: .. code-block:: python from cyclopts import App from dataclasses import dataclass app = App() @dataclass class User: name: str "First and last name of the user." age: int "Age in years of the user." @app.default def main(user: User): """A short summary of what this program does. Parameters ---------- user.age: int User's age docstring from the command docstring. """ print(user) app() .. code-block:: console $ my-program --help Usage: main COMMAND [ARGS] [OPTIONS] A short summary of what this program does. ╭─ Commands ──────────────────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ────────────────────────────────────────────────────────────────────╮ │ * USER.NAME --user.name First and last name of the user. [required] │ │ * USER.AGE --user.age User's age docstring from the command docstring. │ │ [required] │ ╰─────────────────────────────────────────────────────────────────────────────────╯ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Parameter(accepts_keys=False) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ If the class is annotated with ``Parameter(accepts_keys=False)``, then no dot-notation subkeys are exported. The class parameter will consume enough tokens to populate the **required positional** arguments. .. code-block:: python from cyclopts import App, Parameter from dataclasses import dataclass from typing import Annotated, Literal app = App() @dataclass class User: name: str age: int region: Literal["us", "ca"] = "us" @app.default def main(user: Annotated[User, Parameter(accepts_keys=False)]): print(user) app() .. code-block:: console $ my-program --help Usage: main COMMAND [ARGS] [OPTIONS] ╭─ Commands ─────────────────────────────────────────────────────────────────────╮ │ --help -h Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────────────────╮ │ * USER --user [required] │ ╰────────────────────────────────────────────────────────────────────────────────╯ $ my-program 'Bob Smith' 27 User(name='Bob Smith', age=27, region='us') $ my-program 'Bob Smith' ╭─ Error ────────────────────────────────────────────────────────────────────────╮ │ Parameter "--user" requires 2 arguments. Only got 1. │ ╰────────────────────────────────────────────────────────────────────────────────╯ In this example, we are unable to change the ``region`` parameter of ``User`` from the CLI. .. _Typer: https://typer.tiangolo.com BrianPugh-cyclopts-921b1fa/docs/source/shell_completion.rst000066400000000000000000000113101517576204000241630ustar00rootroot00000000000000================ Shell Completion ================ Cyclopts provides shell completion (tab completion) for bash, zsh, and fish shells. Development & Standalone Scripts ================================== Shell completion systems (bash, zsh, fish) can only provide completion for **installed commands** (executables in your ``$PATH``), not for arbitrary Python scripts like ``python myapp.py``. This is a fundamental limitation of how shells work. To work around this during development, Cyclopts provides a ``cyclopts run`` command that acts as a wrapper: .. code-block:: console $ cyclopts run myapp.py --help $ cyclopts run myapp.py:app --verbose Since ``cyclopts`` itself is an installed command, the shell can provide completion for it. The ``cyclopts run`` command then loads and executes your script, giving you completion for your development scripts without needing to package and install them. **Script Path Format:** - ``cyclopts run script.py`` - Auto-detects the App object. If an App object cannot be determined, it will raise an error. - ``cyclopts run script.py:app`` - Explicitly specifies the App object to run This is particularly useful during development before packaging your application. **Virtual Environment Behavior:** ``cyclopts run`` imports your script directly into the **same Python process** (no subprocess is created). This means: - It uses whatever Python interpreter is currently running ``cyclopts`` - Your script has access to all packages installed in the current environment - You must install ``cyclopts`` in your project's virtual environment - To use: activate your venv, then run ``cyclopts run script.py`` .. code-block:: console $ source .venv/bin/activate # or your venv activation method $ cyclopts run myapp.py .. note:: Completion for your script's commands comes through the ``cyclopts`` CLI completion. Install it once with: ``cyclopts --install-completion`` .. warning:: **Performance:** ``cyclopts run`` uses **dynamic completion**, which imports your script and calls Python on **every tab press**. This can be slow if your script has heavy imports. To mitigate slow imports during development, consider using :ref:`Lazy Loading` for your commands. For production or frequent use, install **static completion** using the methods below. Static completion is pre-generated and does not call Python, making it instantaneous. To install completion specifically for your standalone script (without using ``cyclopts run``), you can use the Manual Installation approach below with your script's App object. Installation ============ Programmatic Installation (Recommended) ---------------------------------------- Add completion installation to your CLI application using :meth:`App.register_install_completion_command `: .. code-block:: python from cyclopts import App app = App(name="myapp") app.register_install_completion_command() # Your commands here... if __name__ == "__main__": app() Users can then install completion by running: .. code-block:: console myapp --install-completion Manual Installation ------------------- For programmatic control, use :meth:`App.install_completion ` directly: .. code-block:: python from cyclopts import App from pathlib import Path app = App(name="myapp") # Install for current shell install_path = app.install_completion() print(f"Installed completion to {install_path}") # Install for specific shell install_path = app.install_completion(shell="zsh") # Install to custom location install_path = app.install_completion( shell="bash", output=Path("/custom/path/completion.sh"), ) Default Installation Paths --------------------------- - **Zsh**: ``~/.zsh/completions/_`` - **Bash**: ``~/.local/share/bash-completion/completions/`` - **Fish**: ``~/.config/fish/completions/.fish`` Script Generation ================= To generate a completion script without installing it, use :meth:`App.generate_completion `: .. code-block:: python from cyclopts import App app = App(name="myapp") script = app.generate_completion(shell="zsh") print(script) Shell Configuration =================== By default, Cyclopts modifies your shell RC file to enable completion: - **Zsh**: Adds to ``~/.zshrc`` - **Bash**: Adds to ``~/.bashrc`` - **Fish**: No modification needed (automatically loads from ``~/.config/fish/completions/``) After installation, restart your shell or source the RC file. To install without modifying shell RC files, use: .. code-block:: python app.register_install_completion_command(add_to_startup=False) BrianPugh-cyclopts-921b1fa/docs/source/sphinx_integration.rst000066400000000000000000000273231517576204000245520ustar00rootroot00000000000000Sphinx Integration ================== Cyclopts provides builtin `Sphinx `_ support. .. contents:: Table of Contents :local: :depth: 2 Quick Start ----------- 1. Add the extension to your Sphinx configuration (``docs/conf.py``): .. code-block:: python extensions = [ 'cyclopts.sphinx_ext', # Add this line # ... your other extensions ] 2. Use the directive in your RST files: .. code-block:: rst .. cyclopts:: mypackage.cli:app Directive Usage --------------- Basic Syntax ~~~~~~~~~~~~ The ``cyclopts`` directive accepts a module path to your Cyclopts ``App`` object: .. code-block:: rst .. cyclopts:: mypackage.cli:app Module Path Formats ~~~~~~~~~~~~~~~~~~~ The directive accepts two module path formats: 1. **Explicit format** (``module.path:app_name``): .. code-block:: rst .. cyclopts:: mypackage.cli:app .. cyclopts:: myapp.commands:main_app .. cyclopts:: src.cli:cli This explicitly specifies which ``App`` object to document. 2. **Automatic discovery** (``module.path``): .. code-block:: rst .. cyclopts:: mypackage.cli .. cyclopts:: myapp.main The extension will search the module for an ``App`` instance, looking for common names like ``app``, ``cli``, or ``main``. Directive Options ----------------- The directive supports several options to customize the generated documentation: ``:heading-level:`` - Heading Level ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Set the starting heading level for the generated documentation (1-6, default: 2): .. code-block:: rst .. cyclopts:: mypackage.cli:app :heading-level: 3 This is useful when you need to adjust the heading hierarchy. The default of 2 works well for most cases where the directive is placed under a page title. ``:max-heading-level:`` - Maximum Heading Level ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Set the maximum heading level to use (1-6, default: 6): .. code-block:: rst .. cyclopts:: mypackage.cli:app :max-heading-level: 4 Headings deeper than this level will be capped at this value. This is useful for deeply nested command hierarchies where you want to prevent headings from becoming too small. ``:no-recursive:`` - Exclude Subcommands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Disable recursive documentation of subcommands (by default, subcommands are included): .. code-block:: rst .. cyclopts:: mypackage.cli:app :no-recursive: When this flag is present, only the top-level commands are documented. ``:include-hidden:`` - Show Hidden Commands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Include commands marked with ``show=False`` in the documentation: .. code-block:: rst .. cyclopts:: mypackage.cli:app :include-hidden: true By default, hidden commands are not included in the generated documentation. ``:flatten-commands:`` - Generate Flat Command Hierarchy ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Generate all commands at the same heading level instead of nested hierarchy: .. code-block:: rst .. cyclopts:: mypackage.cli:app :flatten-commands: This creates distinct, equally-weighted headings for each command and subcommand, making them easier to reference and navigate in the documentation. Without this option, subcommands are nested with incrementing heading levels. ``:code-block-title:`` - Render Titles as Inline Code ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Render command titles with inline code formatting instead of plain text: .. code-block:: rst .. cyclopts:: mypackage.cli:app :code-block-title: When this flag is present, command titles are rendered with monospace formatting, which can be useful for certain documentation themes or to make command names stand out visually. ``:commands:`` - Filter Specific Commands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Document only specific commands from your CLI application: .. code-block:: rst .. cyclopts:: mypackage.cli:app :commands: init, build, deploy This will only document the specified commands. You can also use nested command paths with dot notation: .. code-block:: rst .. cyclopts:: mypackage.cli:app :commands: db.migrate, db.backup, api - ``db.migrate`` - Documents only the ``migrate`` subcommand under ``db`` - ``db.backup`` - Documents only the ``backup`` subcommand under ``db`` - ``api`` - Documents the ``api`` command and all its subcommands You can use either underscore or dash notation in command names - they will be normalized automatically. ``:exclude-commands:`` - Exclude Specific Commands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Exclude specific commands from the documentation: .. code-block:: rst .. cyclopts:: mypackage.cli:app :exclude-commands: debug, internal-test This is useful for hiding internal or debug commands from user-facing documentation. Like ``:commands:``, this also supports nested command paths with dot notation. ``:skip-preamble:`` - Skip Description and Usage ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Skip the description and usage sections for the target command when filtering to a single command: .. code-block:: rst .. cyclopts:: mypackage.cli:app :commands: deploy :skip-preamble: When you filter to a single command using ``:commands:`` and provide your own section heading in the RST, you may not want the directive to generate the command's description and usage block. Adding ``:skip-preamble:`` suppresses these sections while still generating the command's parameters and subcommands. This is useful when you want to write your own introduction for a command section: .. code-block:: rst Deployment ========== Deploy your application to production with these commands. .. cyclopts:: mypackage.cli:app :commands: deploy :skip-preamble: Without ``:skip-preamble:``, the output would include both your introduction and the command's docstring description, which can be redundant. ``:usage-name:`` - Override the Command Shown in Usage Lines ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Replace the app name shown in ``Usage:`` lines of the generated documentation with a custom invocation string: .. code-block:: rst .. cyclopts:: mypackage.cli:app :usage-name: uv run cli This is useful when the documented invocation differs from the app's configured name. For example, an ``App(name="cli", ...)`` installed as a project entry point might typically be invoked as ``uv run cli``; setting ``:usage-name: uv run cli`` renders that in every ``Usage:`` block while section headings, anchors, and the table of contents continue to reference the plain ``cli`` name. Automatic Reference Labels ~~~~~~~~~~~~~~~~~~~~~~~~~~~ The Sphinx directive automatically generates RST reference labels for all commands, enabling cross-referencing throughout your documentation. The anchor format is ``cyclopts-{app-name}-{command-path}``, which prevents naming conflicts when documenting multiple CLIs. For example: - Root application: ``cyclopts-myapp`` - Subcommand: ``cyclopts-myapp-deploy`` - Nested subcommand: ``cyclopts-myapp-deploy-production`` You can reference these commands elsewhere in your documentation using ``:ref:`cyclopts-myapp-deploy```. Complete Example ---------------- Here's a complete example showing a CLI application and its Sphinx documentation: CLI Application (``myapp/cli.py``): ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python from pathlib import Path from typing import Optional from cyclopts import App app = App( name="myapp", help="My awesome CLI application", version="1.0.0" ) @app.command def init(path: Path = Path("."), template: str = "default"): """Initialize a new project. Parameters ---------- path : Path Directory where the project will be created template : str Project template to use """ print(f"Initializing project at {path}") @app.command def build(source: Path, output: Optional[Path] = None, *, minify: bool = False): """Build the project. Parameters ---------- source : Path Source directory output : Path, optional Output directory (defaults to source/dist) minify : bool Minify the output files """ output = output or source / "dist" print(f"Building from {source} to {output}") if __name__ == "__main__": app() Sphinx Configuration (``docs/conf.py``): ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python import sys from pathlib import Path # Add your package to the path sys.path.insert(0, str(Path(__file__).parent.parent)) # Extensions extensions = [ 'cyclopts.sphinx_ext', 'sphinx.ext.autodoc', # For API docs 'sphinx.ext.napoleon', # For NumPy-style docstrings ] # Project info project = 'MyApp' author = 'Your Name' version = '1.0.0' # HTML theme html_theme = 'sphinx_rtd_theme' Documentation File (``docs/cli.rst``): ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: rst CLI Reference ============= This section documents all available CLI commands. .. cyclopts:: myapp.cli:app The above directive will automatically generate documentation for all commands, including their parameters, types, defaults, and help text. Advanced Usage -------------- Using Distinct Command Headings ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When you want each command to have its own distinct heading for better navigation and referencing: .. code-block:: rst CLI Command Reference ===================== .. cyclopts:: myapp.cli:app :flatten-commands: :code-block-title: This generates: - All commands at the same heading level (not nested) - Command titles with monospace formatting - Automatic reference labels for cross-linking You can then reference specific commands: See :ref:`cyclopts-myapp-deploy` for deployment options. The :ref:`cyclopts-myapp-init` command sets up your project. Selective Command Documentation ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Split your CLI documentation across multiple sections or pages: .. code-block:: rst Database Commands ================= The following commands manage database operations: .. cyclopts:: myapp.cli:app :commands: db API Management ============== Commands for controlling the API server: .. cyclopts:: myapp.cli:app :commands: api Development Tools ================= Utilities for development (excluding internal debug commands): .. cyclopts:: myapp.cli:app :commands: dev :exclude-commands: dev.debug, dev.internal This approach allows you to: - Organize large CLI applications into logical sections - Document different command groups on separate pages - Exclude internal or debug commands from user documentation - Create targeted documentation for different audiences Output Formats -------------- While the Sphinx directive uses RST internally, you can also generate documentation programmatically in multiple formats: .. code-block:: python from myapp.cli import app # Generate reStructuredText rst_docs = app.generate_docs(output_format="rst") # Generate Markdown md_docs = app.generate_docs(output_format="markdown") # Generate HTML html_docs = app.generate_docs(output_format="html") This is useful for generating documentation outside of Sphinx, such as for GitHub README files or other documentation systems. See Also -------- * :doc:`/help` - Customizing help output * :doc:`/commands` - Creating commands and subcommands * :doc:`/parameters` - Parameter types and validation * `Sphinx Documentation `_ - Official Sphinx documentation BrianPugh-cyclopts-921b1fa/docs/source/text_editor.rst000066400000000000000000000032741517576204000231670ustar00rootroot00000000000000=========== Text Editor =========== Some CLI programs require users to edit more complex fields in a text editor. For example, ``git`` may open a text editor for the user when rebasing or editing a commit message. While not directly related to CLI command parsing, Cyclopts provides :func:`cyclopts.edit` to satisfy this common need. Here is an example application that mimics ``git commit`` functionality. .. code-block:: python # git.py import cyclopts from textwrap import dedent import sys app = cyclopts.App(name="git") @app.command def commit(): try: response = cyclopts.edit( # blocks until text editor is closed. dedent( # removes the leading 4-tab indentation. """\ # Please enter the commit message for your changes.Lines starting # with '#' will be ignored, and an empty message aborts the commit. """ ) ) except (cyclopts.EditorDidNotSaveError, cyclopts.EditorDidNotChangeError): print("Aborting commit due to empty commit message.") sys.exit(1) filtered = "\n".join(x for x in response.split("\n") if not x.startswith("#")) filtered = filtered.strip() # remove leading/trailing whitespace. print(f"Your commit message: {filtered}") if __name__ == "__main__": app() Running ``python git.py commit`` will bring up a text editor with the pre-defined text, and then return the contents of the file. For more interactive CLI prompting, we recommend using the questionary_ package. See :func:`.edit` API page for more advanced usage. .. _questionary: https://github.com/tmbo/questionary BrianPugh-cyclopts-921b1fa/docs/source/user_classes.rst000066400000000000000000000236341517576204000233320ustar00rootroot00000000000000============ User Classes ============ Cyclopts supports classically defined user classes, as well as classes defined by the following dataclass-like libraries: * `attrs `_ * `dataclass `_ * `pydantic `_ * `NamedTuple `_ * `TypedDict `_ Basic Example ^^^^^^^^^^^^^ As an example, let's consider using the builtin :obj:`~dataclasses.dataclass` to make a CLI that manages a movie collection. .. code-block:: python from cyclopts import App from dataclasses import dataclass app = App(name="movie-maintainer") @dataclass class Movie: title: str year: int @app.command def add(movie: Movie): print(f"Adding movie: {movie}") app() .. code-block:: console $ movie-maintainer add --help Usage: movie-maintainer add [ARGS] [OPTIONS] ╭─ Parameters ────────────────────────────────────────────────╮ │ * MOVIE.TITLE [required] │ │ --movie.title │ │ * MOVIE.YEAR --movie.year [required] │ ╰─────────────────────────────────────────────────────────────╯ $ movie-maintainer add 'Mad Max: Fury Road' 2015 Adding movie: Movie(title='Mad Max: Fury Road', year=2015) $ movie-maintainer add --movie.title 'Furiosa: A Mad Max Saga' --movie.year 2024 Adding movie: Movie(title='Furiosa: A Mad Max Saga', year=2024) In most circumstances, Cyclopts will also parse a json-string for a dataclass-like parameter: .. code-block:: console $ movie-maintainer add --movie='{"title": "Mad Max: Fury Road", "year": 2024}' Adding movie: Movie(title='Mad Max: Fury Road', year=2024) JSON Dict Parsing ^^^^^^^^^^^^^^^^^ JSON dict parsing will be performed when: 1. The parameter is specified as a keyword option; e.g. ``--movie``. 2. The referenced parameter type has various sub-arguments (is dataclass-like). 3. The referenced parameter is **not** union'd with a ``str``. 4. The first character is a ``{``. This behavior can be configured via :attr:`.Parameter.json_dict`. .. code-block:: python from cyclopts import App from dataclasses import dataclass app = App(name="movie-manager") @dataclass class Movie: title: str year: int rating: float = 8.0 @app.command def add(movie: Movie): print(f"Adding: {movie}") app() .. code-block:: console $ movie-manager add --movie '{"title": "Mad Max: Fury Road", "year": 2015, "rating": 8.1}' Adding: Movie(title='Mad Max: Fury Road', year=2015, rating=8.1) $ movie-manager add --movie '{"title": "Furiosa", "year": 2024}' Adding: Movie(title='Furiosa', year=2024, rating=8.0) Note that JSON parsing only works when using the keyword option format (``--movie``). The traditional positional argument format still works with individual fields: .. code-block:: console $ movie-manager add --movie.title "Dune" --movie.year 2021 --movie.rating 8.5 Adding: Movie(title='Dune', year=2021, rating=8.5) JSON List Parsing ^^^^^^^^^^^^^^^^^ Cyclopts also supports JSON parsing for lists of dataclasses. This allows you to pass multiple structured objects via JSON: .. code-block:: python from cyclopts import App from dataclasses import dataclass app = App(name="movie-collection") @dataclass class Movie: title: str year: int @app.command def add_batch(movies: list[Movie]): for movie in movies: print(f"Adding: {movie}") app() You can provide the list in several ways: 1. JSON Array - Multiple objects in a single argument: .. code-block:: console $ movie-collection add-batch --movies '[{"title": "Mad Max", "year": 2015}, {"title": "Furiosa", "year": 2024}]' Adding: Movie(title='Mad Max', year=2015) Adding: Movie(title='Furiosa', year=2024) 2. Individual JSON - Each object as a separate argument: .. code-block:: console $ movie-collection add-batch --movies '{"title": "Mad Max", "year": 2015}' --movies '{"title": "Furiosa", "year": 2024}' Adding: Movie(title='Mad Max', year=2015) Adding: Movie(title='Furiosa', year=2024) 3. Mixed - Combining arrays and individual objects: .. code-block:: console $ movie-collection add-batch --movies '{"title": "Mad Max", "year": 2015}' --movies '[{"title": "Furiosa", "year": 2024}, {"title": "Dune", "year": 2021}]' Adding: Movie(title='Mad Max', year=2015) Adding: Movie(title='Furiosa', year=2024) Adding: Movie(title='Dune', year=2021) JSON list parsing is automatically enabled for ``list`` types containing dataclasses. The same rules apply as for dict parsing: - The element type cannot be union'd with ``str`` - JSON objects must start with ``{`` or be arrays starting with ``[`` This behavior can be configured via :attr:`.Parameter.json_list`. .. _Namespace Flattening: Namespace Flattening ^^^^^^^^^^^^^^^^^^^^ It is likely that the actual movie class/object is not important to the CLI user, and the parameter names like ``--movie.title`` are unnecessarily verbose. We can remove ``movie`` from the name by giving the ``Movie`` type annotation the special name ``"*"``. .. code-block:: python from cyclopts import App, Parameter from dataclasses import dataclass from typing import Annotated app = App(name="movie-maintainer") @dataclass class Movie: title: str year: int @app.command def add(movie: Annotated[Movie, Parameter(name="*")]): print(f"Adding movie: {movie}") app() .. code-block:: console $ movie-maintainer add --help Usage: movie-maintainer add [ARGS] [OPTIONS] ╭─ Parameters ────────────────────────────────────────────────╮ │ * TITLE --title [required] │ │ * YEAR --year [required] │ ╰─────────────────────────────────────────────────────────────╯ An alternative way of supplying the :class:`.Parameter` configuration is via a decorator. This way can be cleaner and terser in many scenarios. The :class:`.Parameter` configuration will also be inherited by subclasses. .. code-block:: python from cyclopts import App, Parameter from dataclasses import dataclass app = App(name="movie-maintainer") @Parameter(name="*") @dataclass class Movie: title: str year: int @app.command def add(movie: Movie): print(f"Adding movie: {movie}") app() .. _Sharing Parameters: Sharing Parameters ^^^^^^^^^^^^^^^^^^ A flattened dataclass provides a natural way of easily sharing a set of parameters between commands. .. code-block:: python from cyclopts import App, Parameter from dataclasses import dataclass app = App(name="movie-maintainer") @Parameter(name="*") @dataclass class Config: user: str server: str = "media.sqlite" @dataclass class Movie: title: str year: int @app.command def add(movie: Movie, *, config: Config): print(f"Config: {config}") print(f"Adding movie: {movie}") @app.command def remove(movie: Movie, *, config: Config): print(f"Config: {config}") print(f"Removing movie: {movie}") app() .. code-block:: console $ movie-maintainer remove --help Usage: movie-maintainer remove [ARGS] [OPTIONS] ╭─ Parameters ────────────────────────────────────────────────╮ │ * MOVIE.TITLE [required] │ │ --movie.title │ │ * MOVIE.YEAR --movie.year [required] │ │ * --user [required] │ │ --server [default: media.sqlite] │ ╰─────────────────────────────────────────────────────────────╯ $ movie-maintainer remove 'Mad Max: Fury Road' 2015 --user Guido Config: Config(user='Guido', server='media.sqlite') Removing movie: Movie(title='Mad Max: Fury Road', year=2015) Config File ^^^^^^^^^^^ Having the user specify ``--user`` every single call is a bit cumbersome, especially if they're always going to provide the same value. We can have Cyclopts fallback to a :ref:`toml configuration file `. Consider the following toml data saved to ``config.toml``: .. code-block:: toml # config.toml user = "Guido" We can update our app to fill in missing CLI parameters from this file: .. code-block:: python from cyclopts import App, Parameter, config from dataclasses import dataclass from typing import Annotated app = App( name="movie-maintainer", config=config.Toml("config.toml", use_commands_as_keys=False), ) @Parameter(name="*") @dataclass class Config: user: str server: str = "media.sqlite" @dataclass class Movie: title: str year: int @app.command def add(movie: Movie, *, config: Config): print(f"Config: {config}") print(f"Adding movie: {movie}") app() .. code-block:: console $ movie-maintainer add 'Mad Max: Fury Road' 2015 Config: Config(user='Guido', server='media.sqlite') Adding movie: Movie(title='Mad Max: Fury Road', year=2015) BrianPugh-cyclopts-921b1fa/docs/source/version.rst000066400000000000000000000046631517576204000223250ustar00rootroot00000000000000======= Version ======= All CLI applications should have the basic ability to check the installed version; i.e.: .. code-block:: console $ my-application --version 7.5.8 By default, Cyclopts adds a command, :meth:`--version `:, that does exactly this. Cyclopts try's to reasonably figure out your package's version by itself. The resolution order for determining the version string is as follows: 1. An explicitly supplied version string or callable to the root Cyclopts application: .. code-block:: python from cyclopts import App app = App(version="7.5.8") app() If a callable is provided, it will be invoked when running the ``--version`` command: .. code-block:: python from cyclopts import App def get_my_application_version() -> str: return "7.5.8" app = App(version=get_my_application_version) app() 2. The invoking-package's `Distribution Package's Version Number`_ via `importlib.metadata.version`_. Cyclopts attempts to derive the package module that instantiated the :class:`.App` object by traversing the call stack. 3. The invoking-package's `defacto PEP8 standard`_ ``__version__`` string. Cyclopts attempts to derive the package module that instantiated the :class:`.App` object by traversing the call stack. .. code-block:: python # mypackage/__init__.py __version__ = "7.5.8" # mypackage/__main__.py # ``App`` will use ``mypackage.__version__``. app = cyclopts.App() 4. The default version string ``"0.0.0"`` will be displayed. In short, if your CLI application is a properly structured python package, Cyclopts will automatically derive the correct version. The ``--version`` flag can be changed to a different name(s) via the ``version_flags`` parameter. .. code-block:: python app = cyclopts.App(version_flags="--show-version") app = cyclopts.App(version_flags=["--version", "-v"]) To disable the ``--version`` flag, set ``version_flags`` to an empty string or iterable. .. code-block:: python app = cyclopts.App(version_flags="") app = cyclopts.App(version_flags=[]) .. _Distribution Package's Version Number: https://packaging.python.org/en/latest/glossary/#term-Distribution-Package .. _importlib.metadata.version: https://docs.python.org/3.12/library/importlib.metadata.html#distribution-versions .. _defacto PEP8 standard: https://peps.python.org/pep-0008/#module-level-dunder-names BrianPugh-cyclopts-921b1fa/docs/source/vs_arguably/000077500000000000000000000000001517576204000224135ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_arguably/README.rst000066400000000000000000000014201517576204000240770ustar00rootroot00000000000000=================== Arguably Comparison =================== Arguably_ is another Typer-inspired type-annotation-based CLI library. Arguably was created in response to the overly intrusive nature of Typer, with the goal of minimizing clutter and maintaining code simplicity. Like Cyclopts, Arguably mostly skirts using :obj:`Annotated` by interpreting as much data as possible from the function docstring. Unlike the :ref:`Typer comparison `, many of the topics in this section are simply comparing/contrasting with Arguably, rather than claiming to be strictly better. .. toctree:: :maxdepth: 1 :caption: Topics global_state/README.rst subcommands/README.rst .. _Arguably: https://treykeown.github.io/arguably/ .. _Typer: https://typer.tiangolo.com BrianPugh-cyclopts-921b1fa/docs/source/vs_arguably/global_state/000077500000000000000000000000001517576204000250535ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_arguably/global_state/README.rst000066400000000000000000000035661517576204000265540ustar00rootroot00000000000000============ Global State ============ Unlike Cyclopts or Typer, with ``arguably`` you directly jump into decorating functions: .. code-block:: python import arguably @arguably.command def some_function(required, not_required=2, *others: int, option: float = 3.14): """ this function is on the command line! Args: required: a required argument not_required: this one isn't required, since it has a default value *others: all the other positional arguments go here option: [-x] keyword-only args are options, short name is in brackets """ print(f"{required=}, {not_required=}, {others=}, {option=}") if __name__ == "__main__": arguably.run() With Arguably, no application object is created. This immediately becomes an issue if you use a library that uses arguably on import. Lets consider the following file: .. code-block:: python # library_using_arguably.py import arguably @arguably.command def some_library_function(name): print(f"{name=}") if __name__ == "__main__": arguably.run() .. code-block:: console $ python library_using_arguably.py foo name='foo' So this by itself works fine, but lets create another script that imports this library: .. code-block:: python import arguably import library_using_arguably @arguably.command def my_function(name): print(f"{name=}") if __name__ == "__main__": arguably.run() Now, lets check the help screen: .. code-block:: console $ python my-script.py --help usage: my-script.py [-h] command ... positional arguments: command some-library-function my-function options: -h, --help show this help message and exit The two CLI applications got combined into one, making Arguably dangerous for CLIs that are also libraries. BrianPugh-cyclopts-921b1fa/docs/source/vs_arguably/global_state/library_using_arguably.py000066400000000000000000000002131517576204000321600ustar00rootroot00000000000000import arguably @arguably.command def some_library_function(name): print(f"{name=}") if __name__ == "__main__": arguably.run() BrianPugh-cyclopts-921b1fa/docs/source/vs_arguably/global_state/my-script.py000066400000000000000000000002551517576204000273560ustar00rootroot00000000000000import arguably import library_using_arguably # noqa: F401 @arguably.command def my_function(name): print(f"{name=}") if __name__ == "__main__": arguably.run() BrianPugh-cyclopts-921b1fa/docs/source/vs_arguably/subcommands/000077500000000000000000000000001517576204000247265ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_arguably/subcommands/README.rst000066400000000000000000000041711517576204000264200ustar00rootroot00000000000000=========== Subcommands =========== Arguably parses the command tree based on ``__`` delimited function names. .. code-block:: python import arguably @arguably.command def ec2__start_instances(*instances): """Start instances. Args: *instances: {instance}s to start """ for inst in instances: print(f"Starting {inst}") @arguably.command def ec2__stop_instances(*instances): """Stop instances. Args: *instances: {instance}s to stop """ for inst in instances: print(f"Stopping {inst}") if __name__ == "__main__": arguably.run() .. code-block:: console $ python main.py ec2 --help positional arguments: command start-instances start instances. stop-instances stop instances. Cyclopts handles the command tree by creating and registering recursive :class:`App ` objects: .. code-block:: python from cyclopts import App app = App() ec2 = app.command(App(name="ec2")) @ec2.command def start_instances(*instances): """Start instances. Args: *instances: {instance}s to start """ for inst in instances: print(f"Starting {inst}") @ec2.command def stop_instances(*instances): """Stop instances. Args: *instances: {instance}s to stop """ for inst in instances: print(f"Stopping {inst}") if __name__ == "__main__": app() .. code-block:: console $ python main.py ec2 --help ╭─ Commands ───────────────────────────────────────────────────────────╮ │ start-instances start instances. │ │ stop-instances stop instances. │ ╰──────────────────────────────────────────────────────────────────────╯ BrianPugh-cyclopts-921b1fa/docs/source/vs_arguably/subcommands/main.py000066400000000000000000000017031517576204000262250ustar00rootroot00000000000000import arguably @arguably.command def ec2__start_instances(*instances): """Start instances. Args: *instances: {instance}s to start """ for inst in instances: print(f"Starting {inst}") @arguably.command def ec2__stop_instances(*instances): """Stop instances. Args: *instances: {instance}s to stop """ for inst in instances: print(f"Stopping {inst}") if __name__ == "__main__": arguably.run() from cyclopts import App app = App() ec2 = app.command(App(name="ec2")) @ec2.command def start_instances(*instances): """Start instances. Args: *instances: {instance}s to start """ for inst in instances: print(f"Starting {inst}") @ec2.command def stop_instances(*instances): """Stop instances. Args: *instances: {instance}s to stop """ for inst in instances: print(f"Stopping {inst}") if __name__ == "__main__": app() BrianPugh-cyclopts-921b1fa/docs/source/vs_fire/000077500000000000000000000000001517576204000215325ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_fire/README.rst000066400000000000000000000037321517576204000232260ustar00rootroot00000000000000=============== Fire Comparison =============== Fire_ is a CLI parsing library by Google that attempts to generate a CLI from any Python object. To that end, I think Fire definitely achieves its goal. However, I think Fire has too much magic, and not enough control. From the `Fire documentation`_: The types of the arguments are determined by their values, rather than by the function signature where they're used. You can pass any Python literal from the command line: numbers, strings, tuples, lists, dictionaries, (sets are only supported in some versions of Python). You can also nest the collections arbitrarily as long as they only contain literals. Essentially, Fire ignores type hints and parses CLI parameters as if they were python expressions. .. code-block:: python import fire def hello(name: str = "World"): print(f"{name=} {type(name)=}") if __name__ == "__main__": fire.Fire(hello) .. code-block:: console $ my-script foo name='foo' type(name)= $ my-script 100 name=100 type(name)= $ my-script true name='true' type(name)= $ my-script True name=True type(name)= The equivalent in Cyclopts: .. code-block:: python import cyclopts app = cyclopts.App() @app.default def hello(name: str = "World"): print(f"{name=} {type(name)=}") if __name__ == "__main__": app() .. code-block:: console $ my-script foo name='foo' type(name)= $ my-script 100 name='100' type(name)= $ my-script true name='true' type(name)= $ my-script True name='True' type(name)= Fire is fine for some quick prototyping, but is not suitable for a serious CLI. Therefore, I wouldn't say Fire is a direct competitor to Cyclopts. .. _Fire: https://github.com/google/python-fire .. _Fire documentation: https://github.com/google/python-fire/blob/master/docs/guide.md#argument-parsing BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/000077500000000000000000000000001517576204000217505ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/README.rst000066400000000000000000000034201517576204000234360ustar00rootroot00000000000000.. _Typer Comparison: ================ Typer Comparison ================ Much of Cyclopts was inspired by the excellent `Typer`_ library. Despite its popularity, Typer has some traits that I (and others) find less than ideal. Part of this stems from Typer's age, with its first release in late 2019, soon after Python 3.8's release. Because of this, most of its API was initially designed around assigning proxy default values to function parameters. This made the decorated command functions difficult to use outside of Typer. With the introduction of :obj:`~.typing.Annotated` in python3.9, type-hints were able to be directly annotated, allowing for the removal of these proxy defaults. Additionally, Typer is built on top of `Click`_. This makes it difficult for newcomers to figure out which elements are Typer-related and which elements are click-related. It's also hard to tell whether the following criticisms stem from Typer, or the underlying Click. For better-or-worse, Cyclopts uses its own internal parsing strategy, gaining complete control over the process. This section was originally written about Typer ``v0.9.0`` (May 2023). Some criticisms have been addressed in later Typer versions; updates are noted in the respective sections below. .. toctree:: :maxdepth: 1 :caption: Topics argument_vs_option/README.rst positional_or_keyword/README.rst choices/README.rst default_command/README.rst docstring/README.rst decorator_parentheses/README.rst optional_list/README.rst keyword_multiple_values/README.rst flag_negation/README.rst help_defaults/README.rst validation/README.rst union_support/README.rst version_flag/README.rst documentation/README.rst .. _Typer: https://typer.tiangolo.com .. _Click: https://click.palletsprojects.com BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/argument_vs_option/000077500000000000000000000000001517576204000256725ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/argument_vs_option/README.rst000066400000000000000000000034471517576204000273710ustar00rootroot00000000000000.. _Typer Argument vs Option: ================== Argument vs Option ================== In Typer, there are two primary classes for providing CLI parameter configuration: * ``Argument`` results in a positional CLI argument. * ``Option`` results in a keyword CLI argument, preceded with a ``--``. With more modern python type annotations, this distinction is unnecessary, because parameters (positional or keyword) can be determined directly from the function signature. Consider the following function signatures: .. code-block:: python def pos_or_keyword(a, b): pass def pos_only(a, b, /): pass def keyword_only(*, a, b=2): pass def mixture(a, /, b, *, c=3): pass If you aren't familiar with these declarations, refer to the official PEP570_, or `a more user-friendly tutorial`_. From these function signatures, we can deduce: 1. Which parameters are position-only, keyword-only, or both. 2. Which parameters are required, by their lack of defaults. Because of these builtin python mechanisms, Cyclopts has a single :class:`Parameter ` class used for providing additional parameter metadata. I believe that Typer's separate ``Argument`` and ``Option`` classes are a relic from when they must be supplied as a parameter's proxy default value. .. code-block:: python app = typer.Typer() @app.command() def foo(a=Argument(), b=Option(default=2)): pass When used as such, we lose the ability to define the function signature with position-only or keyword-only markers. We also lose the ability to directly inspect which parameters are optional by having "real" defaults and which ones are required. .. _PEP570: https://peps.python.org/pep-0570/ .. _a more user-friendly tutorial: https://realpython.com/lessons/positional-only-arguments/ BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/argument_vs_option/main.py000066400000000000000000000000001517576204000271560ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/choices/000077500000000000000000000000001517576204000233655ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/choices/README.rst000066400000000000000000000044261517576204000250620ustar00rootroot00000000000000.. _Typer Choices: ======= Choices ======= ---- Enum ---- Frequently, a CLI will want to limit values provided to a parameter to a specific set of choices. With Typer, this is accomplished via declaring an :class:`~enum.Enum`. .. code-block:: python import typer from enum import Enum class Environment(str, Enum): # Values end in "_value" to avoid confusion in this example. DEV = "dev_value" STAGING = "staging_value" PROD = "prod_value" typer_app = typer.Typer() @typer_app.command def foo(env: Environment = Environment.DEV): print(f"Using: {env.name}") print("Typer (Enum):") typer_app(["--env", "staging_value"]) # Using: STAGING Typer looks for the CLI-provided *value*, and supplies the function with the enum member. IMHO, this is backwards; typically the enum name (e.g. ``DEV``) is intended to be more human-friendly, while the value (e.g. ``dev_value``) more frequently has a programmatic-meaning. **When using enums, Cyclopts will do the opposite of Typer**, performing a **case-insensitive** lookup by **name**. .. code-block:: python import cyclopts cyclopts_app = cyclopts.App() @cyclopts_app.default def foo(env: Environment = Environment.DEV): print(f"Using: {env.name}") print("Cyclopts (Enum):") cyclopts_app(["--env", "staging"]) # Using: STAGING ------- Literal ------- Enums don't work well with everyone's workflow. Many people prefer to directly use strings for their functions' options. The much more intuitive, convenient method of doing this is with the :obj:`~typing.Literal` type annotation. .. note:: Typer added support for :obj:`~typing.Literal` in version 0.19.0 (September 2025), resolving `a feature request from early 2020`_. Cyclopts has builtin support for :obj:`~typing.Literal`, see :ref:`Coercion Rules - Literal `. .. code-block:: python import cyclopts from typing import Literal cyclopts_app = cyclopts.App() @cyclopts_app.default def foo(env: Literal["dev", "staging", "prod"] = "staging"): print(f"Using: {env}") print("Cyclopts (Literal):") cmd = ["--env", "staging"] print(cmd) cyclopts_app(cmd) # Using: staging .. _a feature request from early 2020: https://github.com/tiangolo/typer/issues/76 BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/choices/main.py000066400000000000000000000016721517576204000246710ustar00rootroot00000000000000from enum import Enum from typing import Literal import typer import cyclopts class Environment(str, Enum): DEV = "dev_value" STAGING = "staging_value" PROD = "prod_value" typer_app = typer.Typer() @typer_app.command() def foo(env: Environment = Environment.DEV): env = env.name print(f"Using: {env}") print("Typer (Enum):") cmd = ["--env", "staging_value"] print(cmd) typer_app(cmd, standalone_mode=False) # Using: STAGING cyclopts_app = cyclopts.App() @cyclopts_app.default() def foo(env: Environment = Environment.DEV): env = env.name print(f"Using: {env}") print("Cyclopts (Enum):") cmd = ["--env", "staging"] print(cmd) cyclopts_app(cmd) # Using: STAGING cyclopts_app = cyclopts.App() @cyclopts_app.default() def foo(env: Literal["dev", "staging", "prod"] = "staging"): print(f"Using: {env}") print("Cyclopts (Literal):") cmd = ["--env", "staging"] print(cmd) cyclopts_app(cmd) # Using: staging BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/decorator_parentheses/000077500000000000000000000000001517576204000263335ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/decorator_parentheses/README.rst000066400000000000000000000010731517576204000300230ustar00rootroot00000000000000===================== Decorator Parentheses ===================== A minor nitpick, but all of Typer's decorators require parentheses. .. code-block:: python import typer typer_app = typer.Typer() # This doesn't work! Missing () @typer_app.command def foo(): pass Cyclopts works with and without parentheses. .. code-block:: python import cyclopts cyclopts_app = cyclopts.App() # This works! Missing () @cyclopts_app.command def foo(): pass # This also works. @cyclopts_app.command() def bar(): pass BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/default_command/000077500000000000000000000000001517576204000250725ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/default_command/README.rst000066400000000000000000000036241517576204000265660ustar00rootroot00000000000000.. _Typer Default Command: =============== Default Command =============== Typer has an annoying design quirk where if you register a single command, it **won't** expect you to provide the command name in the CLI. For example: .. code-block:: python import typer typer_app = typer.Typer() @typer_app.command() def foo(): print("FOO") typer_app([], standalone_mode=False) # FOO typer_app(["foo"], standalone_mode=False) # raises exception: Got unexpected extra argument (foo) Once you add a second command, then the CLI expects the command to be provided: .. code-block:: python typer_app(["foo"], standalone_mode=False) # FOO typer_app(["bar"], standalone_mode=False) # BAR `This behavior catches many people off guard.`_ If you want a single command, you have to unintuitively declare a ``callback``. Github user `ajlive's callback solution`_ is copied below. .. code-block:: python @app.callback() def dummy_to_force_subcommand() -> None: """ This function exists because Typer won't let you force a single subcommand. Since we know we will add other subcommands in the future and don't want to break the interface, we have to use this workaround. Delete this when a second subcommand is added. """ pass To avoid this confusion, Cyclopts has two ways of registering a function: 1. :meth:`@app.command <.App.command>` - Register a function as a command. 2. :meth:`@app.default <.App.default>` - Invoked if no registered command can be parsed from the CLI. .. code-block:: python import cyclopts cyclopts_app = cyclopts.App() @cyclopts_app.command def foo(): print("FOO") cyclopts_app(["foo"]) # FOO .. _This behavior catches many people off guard.: https://github.com/tiangolo/typer/issues/315 .. _ajlive's callback solution: https://github.com/tiangolo/typer/issues/315#issuecomment-1142593959 BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/default_command/main.py000066400000000000000000000012121517576204000263640ustar00rootroot00000000000000import typer import cyclopts typer_app = typer.Typer() @typer_app.command() def foo(): print("FOO") typer_app([], standalone_mode=False) # FOO try: typer_app(["foo"], standalone_mode=False) except Exception as e: print(f"EXCEPTION: {e}") # EXCEPTION: Got unexpected extra argument (foo) @typer_app.command() def bar(): print("BAR") typer_app(["foo"], standalone_mode=False) # FOO typer_app(["bar"], standalone_mode=False) # BAR cyclopts_app = cyclopts.App() @cyclopts_app.command def foo(): print("FOO") cyclopts_app(["foo"]) # FOO @cyclopts_app.command def bar(): print("BAR") cyclopts_app(["bar"]) # BAR BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/docstring/000077500000000000000000000000001517576204000237445ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/docstring/README.rst000066400000000000000000000104701517576204000254350ustar00rootroot00000000000000.. _Typer Docstring Parsing: ================= Docstring Parsing ================= Typer performs no docstring parsing. Frequently, Typer's Argument/Option is only used to provide a ``help`` string. However, this ``help`` string commonly mirrors the function's docstring. Consider the following Typer program: .. code-block:: python import typer typer_app = typer.Typer() @typer_app.callback() def dummy(): # So that ``foo`` is considered a command. pass @typer_app.command() def foo(bar): """Foo Docstring. Parameters ---------- bar: str Bar parameter docstring. """ typer_app() .. code-block:: console $ my-script --help ╭─ Commands ────────────────────────────────────────────────────────────╮ │ foo Foo Docstring. │ ╰───────────────────────────────────────────────────────────────────────╯ $ my-script foo --help Foo Docstring. Parameters ---------- bar: str Bar parameter docstring. ╭─ Arguments ───────────────────────────────────────────────────────────╮ │ * bar TEXT [default: None] [required] │ ╰───────────────────────────────────────────────────────────────────────╯ The ``foo`` command's short description was properly parsed from the docstring. However, it mangles the Numpy-style docstring (or any docstring format for that matter) and doesn't correctly display ``bar``'s help. Typer just displays the entire docstring. To achieve the desired result with Typer, we have to explicitly annotate the parameter ``bar``: .. code-block:: python @typer_app.command() def foo(bar: Annotated[str, Argument(help="Bar parameter docstring.")]): ... For any serious application, this means that every function parameter must be annotated this way, significantly bloating the function signature. Compare this to Cyclopts: .. code-block:: python import cyclopts cyclopts_app = cyclopts.App() @cyclopts_app.command() def foo(bar): """Foo Docstring. Parameters ---------- bar: str Bar parameter docstring. """ cyclopts_app() .. code-block:: console $ my-script --help ╭─ Commands ────────────────────────────────────────────────────────────╮ │ foo Foo Docstring. │ ╰───────────────────────────────────────────────────────────────────────╯ $ my-script foo --help Foo Docstring. ╭─ Parameters ──────────────────────────────────────────────────────────╮ │ * BAR,--bar Bar parameter docstring. [required] │ ╰───────────────────────────────────────────────────────────────────────╯ Cyclopts did not mangle the docstring into the long description, and it correctly parsed ``bar``'s help. This ends up significantly simplifying function signatures in the common situation where just a help string needs to be added. The common case in Cyclopts does not require the lengthy ``Annotated[str, Parameter(help="Bar parameter docstring")]``. Internally, Cyclopts uses the excellent `docstring_parser`_ library for parsing docstrings. Check their project out! .. _docstring_parser: https://github.com/rr-/docstring_parser BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/docstring/main.py000066400000000000000000000053251517576204000252470ustar00rootroot00000000000000import typer import cyclopts typer_app = typer.Typer() @typer_app.callback() def dummy(): # So that ``foo`` is considered a command. pass @typer_app.command() def foo(bar): """Foo Docstring. Parameters ---------- bar: str Bar parameter docstring. """ pass print("Typer:") # Typer correctly parses the docstring short description. typer_app(["--help"], standalone_mode=False) # ╭─ Commands ─────────────────────────────────────────────────────╮ # │ foo Foo Docstring. │ # ╰────────────────────────────────────────────────────────────────╯ # However, it fails at parsing the rest of the docstring. typer_app(["foo", "--help"], standalone_mode=False) # Foo Docstring. # Parameters ---------- bar: str Bar parameter docstring. # # ╭─ Arguments ────────────────────────────────────────────────────╮ # │ * bar TEXT [default: None] [required] │ # ╰────────────────────────────────────────────────────────────────╯ cyclopts_app = cyclopts.App() @cyclopts_app.command() def foo(bar): """Foo Docstring. Parameters ---------- bar: str Bar parameter docstring. """ pass print("Cyclopts:") # Cyclopts also properly parses the short description. cyclopts_app(["--help"]) # ╭─ Commands ─────────────────────────────────────────────────────╮ # │ foo Foo Docstring. │ # ╰────────────────────────────────────────────────────────────────╯ cyclopts_app(["foo", "--help"]) # Foo Docstring. # # ╭─ Parameters ───────────────────────────────────────────────────╮ # │ * BAR,--bar Bar parameter docstring. [required] │ # ╰────────────────────────────────────────────────────────────────╯ BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/documentation/000077500000000000000000000000001517576204000246215ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/documentation/README.rst000066400000000000000000000010531517576204000263070ustar00rootroot00000000000000============= Documentation ============= Documentation is a major component of any library. Typer's documentation contains many good tutorials and demonstrations on how to use the library, **but has very little information on the API itself**. Frequently the only way to discover options and behavior is to dive into the source code. This becomes further confusing as the lines of where Typer ends and Click begins is quite blurred. Cyclopts has a full :ref:`API` page, containing all the configurable options and defined behaviors in a single place. BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/flag_negation/000077500000000000000000000000001517576204000245455ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/flag_negation/README.rst000066400000000000000000000065611517576204000262440ustar00rootroot00000000000000.. _Typer Flag Negation: ============= Flag Negation ============= For boolean parameters, Typer adds a ``--no-MY-FLAG-NAME`` to specify a ``False`` argument. .. code-block:: python import typer typer_app = typer.Typer() @typer_app.command() def foo(my_flag: bool = False): print(f"{my_flag=}") typer_app(["--my-flag"], standalone_mode=False) # my_flag=True typer_app(["--no-my-flag"], standalone_mode=False) # my_flag=False Overriding the option's name will disable Typer's negative-flag generation logic: .. code-block:: python import typer from typing import Annotated typer_app = typer.Typer() @typer_app.command() def foo(my_flag: Annotated[bool, Option("--my-flag")] = False): print(f"{my_flag=}") typer_app(["--my-flag"], standalone_mode=False) # my_flag=True typer_app(["--no-my-flag"], standalone_mode=False) # NoSuchOption: No such option: --no-my-flag This is not the worst, but there is a tiny bit of duplication. To use a different negative flag, you can supply the name after a slash in your option-name-string. .. code-block:: python import typer typer_app = typer.Typer() @typer_app.command() def foo(my_flag: Annotated[bool, Option("--my-flag/--your-flag")] = False): print(f"{my_flag=}") typer_app(["--my-flag"], standalone_mode=False) # my_flag=True typer_app(["--your-flag"], standalone_mode=False) # my_flag=False Cyclopts's :class:`~.Parameter` takes in an optional :attr:`~.Parameter.negative` flag. To suppress the negative-flag generation, set this argument to either an empty string or list. .. code-block:: python import cyclopts from typing import Annotated cyclopts_app = cyclopts.App() @cyclopts_app.default def foo(my_flag: Annotated[bool, cyclopts.Parameter(negative="")] = False): print(f"{my_flag=}") print("Cyclopts:") cyclopts_app(["--my-flag"]) # my_flag=True cyclopts_app(["--your-flag"], exit_on_error=False) # ╭─ Error ─────────────────────────────────────────────────────────────────────╮ # │ Error converting value "--your-flag" to for "--my-flag". │ # ╰─────────────────────────────────────────────────────────────────────────────╯ # CoercionError: Error converting value "--your-flag" to for "--my-flag". To define your own custom negative flag, just provide it as a string or list of strings. .. code-block:: python @cyclopts_app.default def foo(my_flag: Annotated[bool, cyclopts.Parameter(negative="--your-flag")] = False): print(f"{my_flag=}") print("Cyclopts:") cyclopts_app(["--my-flag"]) # my_flag=True cyclopts_app(["--your-flag"]) # my_flag=False The default ``--no-`` negation prefix can also be customized with :attr:`~.Parameter.negative_bool`. .. code-block:: python @cyclopts_app.default def foo(my_flag: Annotated[bool, cyclopts.Parameter(negative_bool="--disable-")] = False): print(f"{my_flag=}") print("Cyclopts:") cyclopts_app(["--my-flag"]) # my_flag=True cyclopts_app(["--disable-my-flag"]) # my_flag=False BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/flag_negation/main.py000066400000000000000000000014611517576204000260450ustar00rootroot00000000000000from typing import Annotated import typer import cyclopts typer_app = typer.Typer() @typer_app.command() def foo(my_flag: bool = False): print(f"{my_flag=}") print("Typer:") typer_app(["--my-flag"], standalone_mode=False) typer_app(["--no-my-flag"], standalone_mode=False) # my_flag=True # my_flag=False cyclopts_app = cyclopts.App() @cyclopts_app.default def foo(my_flag: bool = False): print(f"{my_flag=}") print("Cyclopts:") cyclopts_app(["--my-flag"]) cyclopts_app(["--no-my-flag"]) # my_flag=True # my_flag=False cyclopts_app = cyclopts.App() @cyclopts_app.default def foo(my_flag: Annotated[bool, cyclopts.Parameter(negative="--your-flag")] = False): print(f"{my_flag=}") print("Cyclopts:") cyclopts_app(["--my-flag"]) cyclopts_app(["--your-flag"]) # my_flag=True # my_flag=False BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/help_defaults/000077500000000000000000000000001517576204000245675ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/help_defaults/README.rst000066400000000000000000000072511517576204000262630ustar00rootroot00000000000000============= Help Defaults ============= In Typer's ``--help`` display, default values are unhelpfully shown for required arguments. .. note:: This was fixed in Typer 0.17.2 (August 2025) when using Rich for help display. .. code-block:: python import typer typer_app = typer.Typer() @typer_app.command() def compress( src: Annotated[Path, typer.Argument(help="File to compress.")], dst: Annotated[Path, typer.Argument(help="Path to save compressed data to.")] = Path("out.zip"), ): print(f"Compressing data from {src} to {dst}") print("Typer positional:") typer_app(["--help"], standalone_mode=False) # ╭─ Arguments ───────────────────────────────────────────────────────────────╮ # │ * src PATH File to compress. [default: None] [required] │ # │ dst [DST] Path to save compressed data to. [default: out.zip] │ # ╰───────────────────────────────────────────────────────────────────────────╯ It doesn't make any sense to show a default for a parameter that is required and has no default. Cyclopts fixes this: .. code-block:: python import cyclopts cyclopts_app = cyclopts.App() @cyclopts_app.default() def compress( src: Annotated[Path, cyclopts.Parameter(help="File to compress.")], dst: Annotated[Path, cyclopts.Parameter(help="Path to save compressed data to.")] = Path("out.zip"), ): print(f"Compressing data from {src} to {dst}") cyclopts_app(["--help"]) # ╭─ Parameters ───────────────────────────────────────────────────────╮ # │ * SRC,--src File to compress. [required] │ # │ DST,--dst Path to save compressed data to. [default: out.zip] │ # ╰────────────────────────────────────────────────────────────────────╯ Additionally, if the default value is :obj:`None`, cyclopts's default configuration will **not** display ``[default: None]``. Doing so doesn't convey much meaning to the end-user. Typically :obj:`None` is a sentinel value who's true value gets set inside the function. Additionally, the cleaner, docstring-centric way of writing this program with Cyclopts would be: .. code-block:: python import cyclopts from pathlib import Path cyclopts_app = cyclopts.App() @cyclopts_app.default() def compress(src: Path, dst: Path = Path("out.zip")): """Compress a file. Parameters ---------- src: Path File to compress. dst: Path Path to save compressed data to. """ print(f"Compressing data from {src} to {dst}") cyclopts_app(["--help"]) # ╭─ Parameters ───────────────────────────────────────────────────────╮ # │ * SRC,--src File to compress. [required] │ # │ DST,--dst Path to save compressed data to. [default: out.zip] │ # ╰────────────────────────────────────────────────────────────────────╯ BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/help_defaults/main.py000066400000000000000000000036311517576204000260700ustar00rootroot00000000000000from pathlib import Path from typing import Annotated import typer import cyclopts typer_app = typer.Typer() @typer_app.command() def compress( src: Annotated[Path, typer.Argument(help="File to compress.")], dst: Annotated[Path, typer.Argument(help="Path to save compressed data to.")] = Path("out.zip"), ): print(f"Compressing data from {src} to {dst}") print("Typer positional:") typer_app(["--help"], standalone_mode=False) # ╭─ Arguments ───────────────────────────────────────────────────────────────╮ # │ * src PATH File to compress. [default: None] [required] │ # │ dst [DST] Path to save compressed data to. [default: out.zip] │ # ╰───────────────────────────────────────────────────────────────────────────╯ cyclopts_app = cyclopts.App() @cyclopts_app.default() def compress( src: Annotated[Path, cyclopts.Parameter(help="File to compress.")], dst: Annotated[Path, cyclopts.Parameter(help="Path to save compressed data to.")] = Path("out.zip"), ): print(f"Compressing data from {src} to {dst}") cyclopts_app(["--help"]) # ╭─ Parameters ───────────────────────────────────────────────────────╮ # │ * SRC,--src File to compress. [required] │ # │ DST,--dst Path to save compressed data to. [default: out.zip] │ # ╰────────────────────────────────────────────────────────────────────╯ BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/keyword_multiple_values/000077500000000000000000000000001517576204000267265ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/keyword_multiple_values/README.rst000066400000000000000000000046161517576204000304240ustar00rootroot00000000000000======================= Keyword Multiple Values ======================= In some applications, it is desirable to supply multiple values to a keyword argument. For example, lets consider an application where we want to specify multiple input files. We want our application to look like the following: .. code-block:: console $ my-program output.bin --input input1.bin input2.bin input3.bin Interpreted as: .. code-block:: python output=PosixPath('output.bin') input=[PosixPath('input1.bin'), PosixPath('input2.bin'), PosixPath('input3.bin')] In Typer, `it is impossible to accomplish this `_. With Typer, the keyword must be specified before each value: .. code-block:: console $ my-program output.bin --input input1.bin --input input2.bin --input input3.bin By default, Cyclopts behavior mimics Typer, where a single element worth of CLI tokens are consumed. However, by setting :attr:`.Parameter.consume_multiple` to :obj:`True`, multiple elements worth of CLI tokens will be consumed. Consider the following example program with a single output path, and multiple input paths. .. code-block:: python from cyclopts import App, Parameter from pathlib import Path from typing import Annotated app = App() @app.default def main(output: Path, input: Annotated[list[Path], Parameter(consume_multiple=True)]): print(f"{input=} {output=}") if __name__ == "__main__": app() All of the following invocations are equivalent: .. code-block:: console $ my-program output.bin input1.bin input2.bin input3.bin # Supplying arguments positionally. $ my-program output.bin --input input1.bin --input input2.bin --input input3.bin # Supplying input arguments via multiple keywords. $ my-program output.bin --input input1.bin input2.bin input3.bin # Supplying input arguments via a single keyword. $ my-program --input input1.bin input2.bin input3.bin --output output.bin # Supplying all arguments via keywords. $ my-program --input input1.bin input2.bin input3.bin -- output.bin # Using the POSIX convention to indicate the end of keywords To set this configuration for your entire application, supply it to your root :attr:`.App.default_parameter`: .. code-block:: python from cyclopts import App, Parameter app = App(default_parameter=Parameter(consume_multiple=True)) BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/optional_list/000077500000000000000000000000001517576204000246305ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/optional_list/README.rst000066400000000000000000000047401517576204000263240ustar00rootroot00000000000000.. _Typer Optional Lists: ============== Optional Lists ============== .. note:: This issue has been addressed in `Typer v0.10.0`_. Typer does not handle optional lists particularly well. In Typer, if a list argument is not provided via the CLI, an empty list is passed to the command by default. While this might be acceptable in some scenarios, it can be unexpected and differs semantically from the default value. Because lists are mutable, and `setting mutable defaults is strongly discouraged`_, setting list parameters' default to :obj:`None` is common practice. This approach can also help differentiate between the intention of using a default list and explicitly requesting an empty list. Consider the following Typer example: .. code-block:: python import typer typer_app = typer.Typer() @typer_app.command() def foo(favorite_numbers: Optional[list[int]] = None): if favorite_numbers is None: favorite_numbers = [1, 2, 3] print(f"My favorite numbers are: {favorite_numbers}") typer_app(["--favorite-numbers", "100", "--favorite-numbers", "200"], standalone_mode=False) # My favorite numbers are: [100, 200] typer_app([], standalone_mode=False) # My favorite numbers are: [] In this example, we expect the default list ``[1, 2, 3]`` to be used when no input is provided. However, Typer supplies an empty list instead of :obj:`None`. Cyclopts has a more intuitive solution. If no CLI option is specified, no argument is bound, so the parameter's default value :obj:`None` is used. If we wish to pass an empty iterable (e.g. :class:`set` or :class:`list`), Cyclopts provides an ``--empty-*`` flag for each iterable parameter. This feature is configurable via :attr:`.Parameter.negative_iterable`. .. code-block:: python import cyclopts cyclopts_app = cyclopts.App() @cyclopts_app.default() def foo(favorite_numbers: Optional[list[int]] = None): if favorite_numbers is None: favorite_numbers = [1, 2, 3] print(f"My favorite numbers are: {favorite_numbers}") cyclopts_app(["--favorite-numbers", "100", "--favorite-numbers", "200"]) # My favorite numbers are: [100, 200] cyclopts_app([]) # My favorite numbers are: [1, 2, 3] cyclopts_app(["--empty-favorite-numbers"]) # My favorite numbers are: [] .. _setting mutable defaults is strongly discouraged: https://docs.python-guide.org/writing/gotchas/#mutable-default-arguments .. _Typer v0.10.0: https://github.com/tiangolo/typer/releases/tag/0.10.0 BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/optional_list/main.py000066400000000000000000000021461517576204000261310ustar00rootroot00000000000000import typer import cyclopts typer_app = typer.Typer() @typer_app.command() def foo(favorite_numbers: list[int] | None = None): if favorite_numbers is None: favorite_numbers = [1, 2, 3] print(f"My favorite numbers are: {favorite_numbers}") print("Typer with arguments:") typer_app(["--favorite-numbers", "100", "--favorite-numbers", "200"], standalone_mode=False) # My favorite numbers are: [100, 200] print("Typer without arguments:") typer_app([], standalone_mode=False) # My favorite numbers are: [] cyclopts_app = cyclopts.App() @cyclopts_app.default() def foo(favorite_numbers: list[int] | None = None): if favorite_numbers is None: favorite_numbers = [1, 2, 3] print(f"My favorite numbers are: {favorite_numbers}") print("Cyclopts with arguments:") cyclopts_app(["--favorite-numbers", "100", "--favorite-numbers", "200"]) # My favorite numbers are: [100, 200] print("Cyclopts without arguments:") cyclopts_app([]) # My favorite numbers are: [1, 2, 3] print("Cyclopts with --empty-favorite-numbers:") cyclopts_app(["--empty-favorite-numbers"]) # My favorite numbers are: [] BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/positional_or_keyword/000077500000000000000000000000001517576204000263755ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/positional_or_keyword/README.rst000066400000000000000000000021011517576204000300560ustar00rootroot00000000000000=============================== Positional or Keyword Arguments =============================== A limitation of Typer is that a parameter cannot be both positional and keyword. For example, lets say we want to implement a ``mv``\-like program that takes in a source path, and a destination path: .. code-block:: python typer_app = typer.Typer() @typer_app.command() def mv(src, dst): print(f"Moving {src} -> {dst}") typer_app(["foo", "bar"], standalone_mode=False) # Moving foo -> bar The code works when supplying the inputs as positional arguments, but fails when trying to specify them as keywords. .. code-block:: python print("Typer keyword:") typer_app(["--src", "foo", "--dst", "bar"], standalone_mode=False) # No such option: --src Cyclopts handles both situations: .. code-block:: python cyclopts_app = cyclopts.App() @cyclopts_app.default() def mv(src, dst): print(f"Moving {src} -> {dst}") cyclopts_app(["foo", "bar"]) # Moving foo -> bar cyclopts_app(["--src", "foo", "--dst", "bar"]) # Moving foo -> bar BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/positional_or_keyword/main.py000066400000000000000000000013151517576204000276730ustar00rootroot00000000000000import typer import cyclopts typer_app = typer.Typer() @typer_app.command() def mv(src, dst): print(f"Moving {src} -> {dst}") print("Typer positional:") typer_app(["foo", "bar"], standalone_mode=False) # Moving foo -> bar print("Typer keyword:") try: typer_app(["--src", "foo", "--dst", "bar"], standalone_mode=False) except Exception as e: print("EXCEPTION: " + str(e)) # EXCEPTION: No such option: --src cyclopts_app = cyclopts.App() @cyclopts_app.default() def mv(src, dst): print(f"Moving {src} -> {dst}") print("Cyclopts positional:") cyclopts_app(["foo", "bar"]) # Moving foo -> bar print("Cyclopts keyword:") cyclopts_app(["--src", "foo", "--dst", "bar"]) # Moving foo -> bar BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/union_support/000077500000000000000000000000001517576204000246745ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/union_support/README.rst000066400000000000000000000021461517576204000263660ustar00rootroot00000000000000====================== Union/Optional Support ====================== Currently, Typer does not support :obj:`~typing.Union` type annotations. .. code-block:: python import typer typer_app = typer.Typer() @typer_app.command() def foo(value: Union[int, str] = "default_str"): print(f"{type(value)=} {value=}") typer_app(["123"]) # AssertionError: Typer Currently doesn't support Union types Cyclopts fully supports :obj:`~typing.Union` annotations. Cyclopt's :ref:`Coercion Rules ` iterate left-to-right over the unioned types until a coercion can be performed without error. .. code-block:: python import cyclopts cyclopts_app = cyclopts.App() @cyclopts_app.default def foo(value: Union[int, str] = "default_str"): print(f"{type(value)=} {value=}") print("Cyclopts:") cyclopts_app(["123"]) # type(value)= value=123 cyclopts_app(["bar"]) # type(value)= value='bar' Naturally, Cyclopts also supports :obj:`~typing.Optional` types, since :obj:`~typing.Optional` is syntactic sugar for ``Union[..., None]``. BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/union_support/main.py000066400000000000000000000011371517576204000261740ustar00rootroot00000000000000import typer import cyclopts typer_app = typer.Typer() @typer_app.command() def foo(value: int | str = "default_str"): print(f"{type(value)=} {value=}") print("Typer:") try: typer_app(["123"], standalone_mode=False) except Exception as e: print(e) # AssertionError: Typer Currently doesn't support Union types cyclopts_app = cyclopts.App() @cyclopts_app.default def foo(value: int | str = "default_str"): print(f"{type(value)=} {value=}") print("Cyclopts:") cyclopts_app(["123"]) # type(value)= value=123 cyclopts_app(["bar"]) # type(value)= value='bar' BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/validation/000077500000000000000000000000001517576204000241025ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/validation/README.rst000066400000000000000000000061751517576204000256020ustar00rootroot00000000000000========== Validation ========== Typer has builtin argument validation for certain type annotations. .. code-block:: python import typer typer_app = typer.Typer() @typer_app.command() def foo(age: Annotated[int, typer.Argument(min=0)]): pass This works for a select few builtins, but the Typer solution doesn't abstract out validation properly. Why does the generic ``typer.Argument`` have fields that only have meaning if the annotated type is a number? The ``typer.Argument`` signature has a ridiculous number of fields that only apply for certain types. .. code-block:: python def Argument( # Parameter default: Optional[Any] = ..., *, callback: Optional[Callable[..., Any]] = None, metavar: Optional[str] = None, expose_value: bool = True, is_eager: bool = False, envvar: Optional[Union[str, List[str]]] = None, shell_complete: Optional[ Callable[ [click.Context, click.Parameter, str], Union[List["click.shell_completion.CompletionItem"], List[str]], ] ] = None, autocompletion: Optional[Callable[..., Any]] = None, # Custom type parser: Optional[Callable[[str], Any]] = None, # TyperArgument show_default: Union[bool, str] = True, show_choices: bool = True, show_envvar: bool = True, help: Optional[str] = None, hidden: bool = False, # Choice case_sensitive: bool = True, # Numbers min: Optional[Union[int, float]] = None, max: Optional[Union[int, float]] = None, clamp: bool = False, # DateTime formats: Optional[List[str]] = None, # File mode: Optional[str] = None, encoding: Optional[str] = None, errors: Optional[str] = "strict", lazy: Optional[bool] = None, atomic: bool = False, # Path exists: bool = False, file_okay: bool = True, dir_okay: bool = True, writable: bool = False, readable: bool = True, resolve_path: bool = False, allow_dash: bool = False, path_type: Union[None, Type[str], Type[bytes]] = None, # Rich settings rich_help_panel: Union[str, None] = None, ) -> Any: ... Cyclopts has an explicit :attr:`~.Parameter.validator` field that accepts a function: .. code-block:: python from cyclopts import App, parameter from typing import Annotated cyclopts_app = App() def age_validator(type_, value: int): if value < 0: raise ValueError @cyclopts_app.command() def foo(age: Annotated[int, Parameter(validator=age_validator)]): pass cyclopts_app() This solution is similar to how other libraries, like Attrs_ or Pydantic_, perform validation. Cyclopts has builtin validators for common use-cases. .. code-block:: python # Typer typer.Argument(file_okay=True, exists=True) # Cyclopts cyclopts.Parameter(validator=cyclopts.validators.Path(file_okay=True, exists=True)) .. _Attrs: https://www.attrs.org/en/stable/examples.html#validators .. _Pydantic: https://docs.pydantic.dev/latest/concepts/validators/ BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/version_flag/000077500000000000000000000000001517576204000244265ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/version_flag/README.rst000066400000000000000000000034031517576204000261150ustar00rootroot00000000000000===================== Adding a Version Flag ===================== It's common to check a CLI app's version via a ``--version`` flag. Concretely, we want the following behavior: .. code-block:: console $ mypackage --version 1.2.3 To achieve this in Typer, we need the following `bulky implementation`_: .. code-block:: python import typer from typing import Annotated typer_app = typer.Typer() def version_callback(value: bool): if value: print("1.2.3") raise typer.Exit() @typer_app.callback() def common( version: Annotated[ bool, typer.Option( "--version", callback=version_callback, help="Print version.", ), ] = False, ): print("Callback body executed.") print("Typer:") typer_app(["--version"]) # 1.2.3 Not only is this a lot of boilerplate, but it also has some nasty side-effects, such as impacting `whether or not you need to specify the command in a single-command program.`_ On top of that, it's not very intuitive. Would you expect ``"Callback body executed."`` to be printed? When does ``version_callback`` get called? What is ``value``? With Cyclopts, the version is automatically detected by checking the version of the package instantiating :class:`App `. If you prefer explicitness, :attr:`~.App.version` can also be explicitly supplied to :class:`App `. .. code-block:: python import cyclopts cyclopts_app = cyclopts.App(version="1.2.3") cyclopts_app(["--version"]) # 1.2.3 .. _bulky implementation: https://github.com/tiangolo/typer/issues/52 .. _whether or not you need to specify the command in a single-command program.: ../default_command/README.html BrianPugh-cyclopts-921b1fa/docs/source/vs_typer/version_flag/main.py000066400000000000000000000015601517576204000257260ustar00rootroot00000000000000from typing import Annotated import typer import cyclopts typer_app = typer.Typer() def version_callback(value: bool): if not value: return print(typer.__version__) raise typer.Exit() @typer_app.callback() def common( version: Annotated[ bool, typer.Option( "--version", "-v", callback=version_callback, help="Print version.", ), ] = False, ): print("Callback body executed.") print("Typer:") typer_app(["--version"], standalone_mode=False) # 0.9.0 # If ``version`` is not specified, Cyclopts will attempt to use # ``your_library.__version__`` based on the module ``App`` is instantiated in. # If the discovery fails, Cyclopts will fallback to ``0.0.0`` cyclopts_app = cyclopts.App(version=typer.__version__) print("Cyclopts:") cyclopts_app(["--version"]) # 0.9.0 BrianPugh-cyclopts-921b1fa/pyproject.toml000066400000000000000000000135771517576204000205760ustar00rootroot00000000000000[build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" [project] name = "cyclopts" dynamic = ["version"] description = "Intuitive, easy CLIs based on type hints." readme = "README.md" license = "Apache-2.0" authors = [{ name = "Brian Pugh" }] requires-python = ">=3.10" classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ] dependencies = [ "attrs>=23.1.0", "rich>=13.6.0", "docstring-parser>=0.15,<4.0", "rich-rst>=1.3.1,<2.0.0", "typing-extensions>=4.8.0; python_version<'3.11'", "tomli>=2.0.0; python_version<'3.11'", ] [project.optional-dependencies] toml = ["tomli>=2.0.0; python_version<'3.11'"] trio = ["trio>=0.10.0"] yaml = ["pyyaml>=6.0.1"] docs = [ "sphinx>=7.4.7,<8.2.0", "sphinx_rtd_theme>=3.0.0,<4.0.0", "gitpython>=3.1.31", "sphinx-copybutton>=0.5,<1.0", "myst-parser[linkify]>=3.0.1,<5.0.0", "sphinx-autodoc-typehints>=1.25.2,<4.0.0", "sphinx-rtd-dark-mode>=1.3.0,<2.0.0", ] mkdocs = [ "mkdocs>=1.4.0", "markdown>=3.3", "pymdown-extensions>=10.0", ] dev = [ "coverage[toml]>=5.1", "pre_commit>=2.16.0", "pytest>=8.2.0", "pytest-cov>=3.0.0", "pytest-mock>=3.7.0", "pydantic>=2.11.2,<3.0.0", "syrupy>=4.0.0", "toml>=0.10.2,<1.0.0", "trio>=0.10.0", "pyyaml>=6.0.1", "mkdocs>=1.4.0", "pexpect>=4.9.0; sys_platform != 'win32'", ] debug = ["ipdb>=0.13.9", "line_profiler>=3.5.1"] [project.scripts] cyclopts = "cyclopts.cli:app" [project.entry-points."mkdocs.plugins"] cyclopts = "cyclopts.ext.mkdocs:CycloptsPlugin" [project.urls] Homepage = "https://github.com/BrianPugh/cyclopts" Repository = "https://github.com/BrianPugh/cyclopts" [tool.hatch.version] source = "vcs" [tool.hatch.build.hooks.vcs] version-file = "cyclopts/_version.py" [tool.hatch.build.targets.sdist] include = ["/cyclopts"] [tool.hatch.build.targets.wheel] packages = ["cyclopts"] artifacts = ["cyclopts/*.so", "cyclopts/*.pyd"] [tool.coverage.run] branch = true omit = ["tests/*"] source = ["cyclopts"] [tool.coverage.report] exclude_lines = [ # Have to re-enable the standard pragma "pragma: no cover", # Don't complain about missing debug-only code: "def __repr__", "if self.debug:", "if debug:", "if DEBUG:", # Don't complain if tests don't hit defensive assertion code: "raise AssertionError", "raise NotImplementedError", "raise TypeError", # Don't complain if non-runnable code isn't run: "if 0:", "if False:", "if __name__ == .__main__.:", "if TYPE_CHECKING:", "from typing import Annotated", "except ImportError:", # Overloads can't have coverage: "@overload", ] omit = ["cyclopts/protocols.py"] [tool.pyright] venvPath = "." venv = ".venv" extraPaths = ["tests/apps/complex-demo"] ignore = ["docs/", "tests/py312/", "cyclopts/_path_type.py"] reportUnsupportedDunderAll = "none" [tool.ruff] target-version = 'py310' line-length = 120 exclude = [ "migrations", "__pycache__", "manage.py", "settings.py", "env", ".env", "venv", ".venv", "tests/py312/", ] [tool.ruff.format] docstring-code-format = true [tool.ruff.lint] select = [ "B", # flake8-bugbear "C4", # flake8-comprehensions "D", # pydocstyle "E", # Error "F", # pyflakes "I", # isort "N", # pep8-naming "PGH", # pygrep-hooks "PTH", # flake8-use-pathlib "Q", # flake8-quotes "TRY", # tryceratops "UP", # pyupgrade "W", # Warning "YTT", # flake8-2020 ] ignore = [ "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "D200", "D401", "E402", "E501", "PGH003", # Use specific rule codes when ignoring type issues "TRY003", # Avoid specifying messages outside exception class; overly strict, especially for ValueError "TRY300", # Consider moving this statement to an `else` block ] [tool.ruff.lint.flake8-bugbear] extend-immutable-calls = ["chr", "typer.Argument", "typer.Option"] [tool.ruff.lint.pydocstyle] convention = "numpy" [tool.ruff.lint.per-file-ignores] "tests/*.py" = [ "B011", # Do not `assert False` "D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "D205", "D400", "D404", "N806", # Variable in function should be lowercase "S102", # use of "exec" "S106", # possible hardcoded password. "PGH001", # use of "eval" "UP007", # Use `X | Y` for type annotations; many tests specifically test Union/Optional "UP045", # Use `X | None` for type annotations ] "docs/*.py" = [ "F811", # redefinition ] "cyclopts/config/*.py" = [ "PTH123", # `open()` should be replaced by `Path.open()`. Pyright doesn't understand that it must be a Path. ] [tool.ruff.lint.pep8-naming] staticmethod-decorators = ["pydantic.validator", "pydantic.root_validator"] [tool.pytest.ini_options] markers = [ "slow: marks tests as slow (deselected by default, use --run-slow to include)", ] addopts = "-m 'not slow'" [tool.codespell] skip = 'uv.lock,tests/test_help.py' [tool.typos.files] extend-exclude = ["tests/test_help.py"] [tool.creosote] venvs = [".venv"] paths = ["cyclopts"] deps-file = "pyproject.toml" sections = ["project.dependencies"] exclude-deps = [ "typing-extensions", "docstring-parser", # Not detected due to deferred import. "rich-rst", # Not detected due to deferred import. "rich", # Not detected due to deferred import. "tomli", # Not detected due to optional feature. "trio", # Not detected due to optional feature. ] BrianPugh-cyclopts-921b1fa/tests/000077500000000000000000000000001517576204000170075ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/README.md000066400000000000000000000133551517576204000202750ustar00rootroot00000000000000# Cyclopts Test Suite This directory contains the test suite for cyclopts, including unit tests, integration tests, and snapshot tests. ## Directory Structure ``` tests/ ├── __snapshots__/ # Snapshot files (auto-generated by syrupy) │ └── test_docs_snapshots/ # Individual .md and .rst snapshot files ├── apps/ # Test applications │ ├── complex-demo/ # Comprehensive CLI for doc testing │ └── cyclopts-demo/ # Simple demo app ├── cli/ # CLI-specific tests ├── completion/ # Shell completion tests ├── config/ # Configuration file tests ├── py312/ # Python 3.12+ specific tests ├── types/ # Type handling tests ├── validators/ # Validator tests ├── conftest.py # Shared fixtures ├── test_docs_e2e.py # End-to-end documentation build tests ├── test_docs_snapshots.py # Snapshot tests for doc generation ├── test_mkdocs_ext.py # MkDocs plugin tests ├── test_sphinx_ext.py # Sphinx extension tests └── ... (other test files) ``` ## Running Tests ```bash # Run all tests uv run pytest # Run with coverage uv run pytest --cov=cyclopts --cov-report=term # Run specific test file uv run pytest tests/test_mkdocs_ext.py -v # Run tests matching a pattern uv run pytest -k "mkdocs" -v ``` ## Documentation Plugin Tests The documentation plugins (MkDocs and Sphinx) have three levels of testing: ### 1. Unit Tests (`test_mkdocs_ext.py`, `test_sphinx_ext.py`) Test individual components like directive parsing, import mechanisms, and filtering logic. ```bash uv run pytest tests/test_mkdocs_ext.py tests/test_sphinx_ext.py -v ``` ### 2. Snapshot Tests (`test_docs_snapshots.py`) Capture the exact markdown/RST output generated by the plugins to detect unintended changes. ```bash # Run snapshot tests uv run pytest tests/test_docs_snapshots.py -v # Update snapshots after intentional changes uv run pytest tests/test_docs_snapshots.py --snapshot-update ``` **What's snapshotted:** - Generated markdown documentation (various filtering/nesting scenarios) - Generated RST documentation - MkDocs directive processing output - Sphinx directive processing output **When to update snapshots:** - After intentionally changing documentation output format - After adding new features that affect output - After fixing bugs that change output **Reviewing snapshot changes:** ```bash # See what changed (snapshots are individual .md/.rst files) git diff tests/__snapshots__/ # Preview a snapshot directly open tests/__snapshots__/test_docs_snapshots/TestMarkdownSnapshots.test_full_app_markdown.md # Accept changes uv run pytest tests/test_docs_snapshots.py --snapshot-update git add tests/__snapshots__/ ``` ### 3. End-to-End Build Tests (`test_docs_e2e.py`) Actually run `mkdocs build` and `sphinx-build` to verify the full documentation pipeline works. ```bash uv run pytest tests/test_docs_e2e.py -v ``` These tests: - Build documentation using the complex-demo app - Verify the output HTML contains expected content - Test that nested commands, dataclass flattening, etc. work in real builds ## Test Applications ### `apps/complex-demo/` A comprehensive CLI application designed to test all edge cases: - **Dataclass parameter flattening** - `@Parameter(name="*")` - **Nested dataclasses** - Dataclass containing other dataclasses - **Pydantic models** - BaseModel parameters (if pydantic installed) - **attrs classes** - @attrs.define parameters (if attrs installed) - **4-level nested commands** - `admin → users → permissions → roles` - **Custom groups** - `Group.create_ordered()` - **Complex union types** - `int | Literal["auto"]` - **Count parameters** - `-v`, `-vv`, `-vvv` - **Validators** - Number, Path validators - **Hidden commands/parameters** - **Multiple docstring formats** - NumPy, Google, Sphinx See `apps/complex-demo/README.md` for full details. ### `apps/cyclopts-demo/` A simpler demo app used for basic MkDocs plugin testing. ## Writing New Tests ### Adding Snapshot Tests 1. Add a new test method in `test_docs_snapshots.py` 2. Use the `snapshot` fixture from syrupy 3. Assert output equals snapshot: `assert output == snapshot` 4. Run with `--snapshot-update` to generate initial snapshot ```python def test_my_new_feature(self, ensure_complex_demo_importable, snapshot): """Snapshot test for my new feature.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs(app, my_new_option=True) assert markdown == snapshot ``` ### Adding E2E Tests 1. Add test methods to `test_docs_e2e.py` 2. Use the build fixtures to run mkdocs/sphinx 3. Verify output files contain expected content ### Adding to complex-demo If you need to test a new cyclopts feature: 1. Add the feature usage to `apps/complex-demo/complex_app.py` 2. Add documentation directives to `apps/complex-demo/mkdocs_docs/` and `apps/complex-demo/docs/source/` 3. Add snapshot tests for the new output 4. Add E2E assertions if needed ## Test Fixtures Key fixtures from `conftest.py`: - `app` - Basic cyclopts App instance - `console` - Rich console for output capture - `assert_parse_args` - Helper to verify argument parsing - `chdir_to_tmp_path` - Auto-fixture that runs tests in temp directory ## Continuous Integration Tests run on multiple Python versions (3.10-3.14) and operating systems (Ubuntu, macOS, Windows). Some tests are skipped on certain configurations: - `py312/` tests require Python 3.12+ - Pydantic/attrs tests gracefully degrade if not installed - MkDocs/Sphinx build tests skip if tools not available BrianPugh-cyclopts-921b1fa/tests/__snapshots__/000077500000000000000000000000001517576204000216255ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots/000077500000000000000000000000001517576204000257165ustar00rootroot00000000000000TestMarkdownSnapshots.test_admin_commands_markdown.md000066400000000000000000000137011517576204000404210ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshotsAdministrative commands for system management. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli admin status ```console complex-cli admin status [OPTIONS] [ARGS] ``` Show system status. **Parameters**: * `SERVICES, --services`: Specific services to check (all if not specified). * `--watch, -w`: Continuously watch status. *[default: False]* * `--interval`: Refresh interval in seconds when watching. *[default: 5]* ### complex-cli admin config-cmd ```console complex-cli admin config-cmd [OPTIONS] ``` Configure database settings. **Parameters**: * `--host`: Database server hostname. *[default: localhost]* * `--port`: Database server port number. *[default: 5432]* * `--username`: Authentication username. *[default: admin]* * `--password`: Authentication password (optional). * `--ssl-mode`: SSL connection mode. *[choices: disable, prefer, require, verify-full]* *[default: prefer]* * `--pool-size`: Connection pool size. *[default: 10]* ### complex-cli admin users User management commands. **Commands**: * [`create`](#complex-cli-admin-users-create): Create a new user. * [`delete`](#complex-cli-admin-users-delete): Delete a user. * [`list-users`](#complex-cli-admin-users-list-users): List all users. * [`permissions`](#complex-cli-admin-users-permissions): Permission management for users. #### complex-cli admin users list-users ```console complex-cli admin users list-users [ARGS] ``` List all users. **Parameters**: * `ACTIVE-ONLY, --active-only, --no-active-only`: Show only active users. *[default: False]* * `ROLE, --role`: Filter by user role. *[choices: admin, user, guest]* * `LIMIT, --limit`: Maximum number of users to display. *[default: 100]* * `FORMAT, --format`: Output format. *[choices: json, yaml, table, csv]* *[default: table]* #### complex-cli admin users create ```console complex-cli admin users create [OPTIONS] USERNAME EMAIL ``` Create a new user. **Arguments**: * `USERNAME`: Unique username for the new user. **[required]** * `EMAIL`: Email address for the new user. **[required]** **Parameters**: * `--role`: User role assignment. *[choices: admin, user, guest]* *[default: user]* * `--permissions.none, --permissions.no-none`: Initial permission flags. *[default: False]* * `--permissions.read, --permissions.no-read`: Initial permission flags. *[default: False]* * `--permissions.write, --permissions.no-write`: Initial permission flags. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Initial permission flags. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Initial permission flags. *[default: False]* * `--send-welcome, --no-send-welcome`: Send welcome email after creation. *[default: True]* #### complex-cli admin users delete ```console complex-cli admin users delete [OPTIONS] USERNAME ``` Delete a user. **Arguments**: * `USERNAME`: Username to delete. **[required]** **Parameters**: * `--force, --no-force, -f`: Skip confirmation prompt. *[default: False]* * `--backup, --no-backup`: Create backup before deletion. *[default: True]* #### complex-cli admin users permissions Permission management for users. ##### complex-cli admin users permissions grant ```console complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION ``` Grant permissions to a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to grant. **[required]** *[choices: none, read, write, execute, admin]* **Parameters**: * `--resource`: Specific resource to grant access to. * `--expires`: Expiration date (ISO format). ##### complex-cli admin users permissions revoke ```console complex-cli admin users permissions revoke USERNAME PERMISSION ``` Revoke permissions from a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to revoke. **[required]** *[choices: none, read, write, execute, admin]* ##### complex-cli admin users permissions audit ```console complex-cli admin users permissions audit [ARGS] ``` Audit permission changes. **Parameters**: * `USERNAME, --username`: Filter by username (all users if not specified). * `DAYS, --days`: Number of days to look back. *[default: 30]* * `FORMAT, --format`: Output format for audit report. *[choices: json, yaml, table, csv]* *[default: table]* ##### complex-cli admin users permissions roles Role template management. **Commands**: * [`create-role`](#complex-cli-admin-users-permissions-roles-create-role): Create a new role template. * [`list-roles`](#complex-cli-admin-users-permissions-roles-list-roles): List all role templates. ###### complex-cli admin users permissions roles list-roles ```console complex-cli admin users permissions roles list-roles [ARGS] ``` List all role templates. **Parameters**: * `INCLUDE-SYSTEM, --include-system, --no-include-system`: Include built-in system roles. *[default: False]* ###### complex-cli admin users permissions roles create-role ```console complex-cli admin users permissions roles create-role [OPTIONS] NAME ``` Create a new role template. **Arguments**: * `NAME`: Role name. **[required]** **Parameters**: * `--permissions.none, --permissions.no-none`: Default permissions for this role. *[default: False]* * `--permissions.read, --permissions.no-read`: Default permissions for this role. *[default: False]* * `--permissions.write, --permissions.no-write`: Default permissions for this role. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Default permissions for this role. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Default permissions for this role. *[default: False]* * `--description`: Role description. *[default: ""]* TestMarkdownSnapshots.test_data_commands_markdown.md000066400000000000000000000063471517576204000402520ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshotsData processing commands. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli data process ```console complex-cli data process [OPTIONS] INPUT_FILES ``` Process data files with configurable options. This command demonstrates dataclass parameter flattening where all fields from ProcessingConfig and PathConfig become CLI options. **Arguments**: * `INPUT_FILES`: Input files to process **[required]** **Parameters**: * `--batch-size`: Number of items to process per batch. *[default: 32]* * `--num-workers`: Number of parallel workers. Use "auto" for automatic detection. *[choices: auto]* *[default: auto]* * `--quality-level`: Processing quality level. Higher values mean better quality but slower. *[choices: high, medium, low]* *[default: high]* * `--device`: Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. *[choices: cuda, cpu, auto]* *[default: auto]* * `--output-formats, --empty-output-formats`: List of output formats to generate. *[choices: json, yaml, table, csv]* *[default: [json]]* * `--input-dir`: Input data directory. *[default: data/input]* * `--output-dir`: Output results directory. *[default: data/output]* * `--cache-dir`: Cache directory for intermediate files. * `--log-dir`: Directory for log files. *[default: logs]* ### complex-cli data pipeline ```console complex-cli data pipeline [OPTIONS] ``` Run a complete data pipeline. Demonstrates nested dataclass flattening (PipelineConfig contains PathConfig and ProcessingConfig). **Parameters**: * `--name`: Pipeline name for identification. *[default: default-pipeline]* * `--input-dir`: Input data directory. *[default: data/input]* * `--output-dir`: Output results directory. *[default: data/output]* * `--cache-dir`: Cache directory for intermediate files. * `--log-dir`: Directory for log files. *[default: logs]* * `--batch-size`: Number of items to process per batch. *[default: 32]* * `--num-workers`: Number of parallel workers. Use "auto" for automatic detection. *[choices: auto]* *[default: auto]* * `--quality-level`: Processing quality level. Higher values mean better quality but slower. *[choices: high, medium, low]* *[default: high]* * `--device`: Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. *[choices: cuda, cpu, auto]* *[default: auto]* * `--output-formats, --empty-output-formats`: List of output formats to generate. *[choices: json, yaml, table, csv]* *[default: [json]]* * `--dry-run, --no-dry-run`: If True, simulate execution without making changes. *[default: False]* ### complex-cli data validate ```console complex-cli data validate [OPTIONS] INPUT_PATH ``` Validate data files against schema. **Arguments**: * `INPUT_PATH`: Path to validate. **[required]** **Parameters**: * `--strict, --no-strict`: Enable strict validation mode. *[default: False]* * `--schema-file`: Custom schema file (must exist). * `--ignore-patterns, --empty-ignore-patterns`: Patterns to ignore during validation. TestMarkdownSnapshots.test_exclude_commands_markdown.md000066400000000000000000000137011517576204000407620ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshotsAdministrative commands for system management. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli admin status ```console complex-cli admin status [OPTIONS] [ARGS] ``` Show system status. **Parameters**: * `SERVICES, --services`: Specific services to check (all if not specified). * `--watch, -w`: Continuously watch status. *[default: False]* * `--interval`: Refresh interval in seconds when watching. *[default: 5]* ### complex-cli admin config-cmd ```console complex-cli admin config-cmd [OPTIONS] ``` Configure database settings. **Parameters**: * `--host`: Database server hostname. *[default: localhost]* * `--port`: Database server port number. *[default: 5432]* * `--username`: Authentication username. *[default: admin]* * `--password`: Authentication password (optional). * `--ssl-mode`: SSL connection mode. *[choices: disable, prefer, require, verify-full]* *[default: prefer]* * `--pool-size`: Connection pool size. *[default: 10]* ### complex-cli admin users User management commands. **Commands**: * [`create`](#complex-cli-admin-users-create): Create a new user. * [`delete`](#complex-cli-admin-users-delete): Delete a user. * [`list-users`](#complex-cli-admin-users-list-users): List all users. * [`permissions`](#complex-cli-admin-users-permissions): Permission management for users. #### complex-cli admin users list-users ```console complex-cli admin users list-users [ARGS] ``` List all users. **Parameters**: * `ACTIVE-ONLY, --active-only, --no-active-only`: Show only active users. *[default: False]* * `ROLE, --role`: Filter by user role. *[choices: admin, user, guest]* * `LIMIT, --limit`: Maximum number of users to display. *[default: 100]* * `FORMAT, --format`: Output format. *[choices: json, yaml, table, csv]* *[default: table]* #### complex-cli admin users create ```console complex-cli admin users create [OPTIONS] USERNAME EMAIL ``` Create a new user. **Arguments**: * `USERNAME`: Unique username for the new user. **[required]** * `EMAIL`: Email address for the new user. **[required]** **Parameters**: * `--role`: User role assignment. *[choices: admin, user, guest]* *[default: user]* * `--permissions.none, --permissions.no-none`: Initial permission flags. *[default: False]* * `--permissions.read, --permissions.no-read`: Initial permission flags. *[default: False]* * `--permissions.write, --permissions.no-write`: Initial permission flags. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Initial permission flags. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Initial permission flags. *[default: False]* * `--send-welcome, --no-send-welcome`: Send welcome email after creation. *[default: True]* #### complex-cli admin users delete ```console complex-cli admin users delete [OPTIONS] USERNAME ``` Delete a user. **Arguments**: * `USERNAME`: Username to delete. **[required]** **Parameters**: * `--force, --no-force, -f`: Skip confirmation prompt. *[default: False]* * `--backup, --no-backup`: Create backup before deletion. *[default: True]* #### complex-cli admin users permissions Permission management for users. ##### complex-cli admin users permissions grant ```console complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION ``` Grant permissions to a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to grant. **[required]** *[choices: none, read, write, execute, admin]* **Parameters**: * `--resource`: Specific resource to grant access to. * `--expires`: Expiration date (ISO format). ##### complex-cli admin users permissions revoke ```console complex-cli admin users permissions revoke USERNAME PERMISSION ``` Revoke permissions from a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to revoke. **[required]** *[choices: none, read, write, execute, admin]* ##### complex-cli admin users permissions audit ```console complex-cli admin users permissions audit [ARGS] ``` Audit permission changes. **Parameters**: * `USERNAME, --username`: Filter by username (all users if not specified). * `DAYS, --days`: Number of days to look back. *[default: 30]* * `FORMAT, --format`: Output format for audit report. *[choices: json, yaml, table, csv]* *[default: table]* ##### complex-cli admin users permissions roles Role template management. **Commands**: * [`create-role`](#complex-cli-admin-users-permissions-roles-create-role): Create a new role template. * [`list-roles`](#complex-cli-admin-users-permissions-roles-list-roles): List all role templates. ###### complex-cli admin users permissions roles list-roles ```console complex-cli admin users permissions roles list-roles [ARGS] ``` List all role templates. **Parameters**: * `INCLUDE-SYSTEM, --include-system, --no-include-system`: Include built-in system roles. *[default: False]* ###### complex-cli admin users permissions roles create-role ```console complex-cli admin users permissions roles create-role [OPTIONS] NAME ``` Create a new role template. **Arguments**: * `NAME`: Role name. **[required]** **Parameters**: * `--permissions.none, --permissions.no-none`: Default permissions for this role. *[default: False]* * `--permissions.read, --permissions.no-read`: Default permissions for this role. *[default: False]* * `--permissions.write, --permissions.no-write`: Default permissions for this role. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Default permissions for this role. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Default permissions for this role. *[default: False]* * `--description`: Role description. *[default: ""]* TestMarkdownSnapshots.test_flattened_commands_markdown.md000066400000000000000000000121111517576204000412710ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots## complex-cli admin Administrative commands for system management. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ## complex-cli admin users User management commands. **Commands**: * [`create`](#complex-cli-admin-users-create): Create a new user. * [`delete`](#complex-cli-admin-users-delete): Delete a user. * [`list-users`](#complex-cli-admin-users-list-users): List all users. * [`permissions`](#complex-cli-admin-users-permissions): Permission management for users. ## complex-cli admin users list-users ```console complex-cli admin users list-users [ARGS] ``` List all users. **Parameters**: * `ACTIVE-ONLY, --active-only, --no-active-only`: Show only active users. *[default: False]* * `ROLE, --role`: Filter by user role. *[choices: admin, user, guest]* * `LIMIT, --limit`: Maximum number of users to display. *[default: 100]* * `FORMAT, --format`: Output format. *[choices: json, yaml, table, csv]* *[default: table]* ## complex-cli admin users create ```console complex-cli admin users create [OPTIONS] USERNAME EMAIL ``` Create a new user. **Arguments**: * `USERNAME`: Unique username for the new user. **[required]** * `EMAIL`: Email address for the new user. **[required]** **Parameters**: * `--role`: User role assignment. *[choices: admin, user, guest]* *[default: user]* * `--permissions.none, --permissions.no-none`: Initial permission flags. *[default: False]* * `--permissions.read, --permissions.no-read`: Initial permission flags. *[default: False]* * `--permissions.write, --permissions.no-write`: Initial permission flags. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Initial permission flags. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Initial permission flags. *[default: False]* * `--send-welcome, --no-send-welcome`: Send welcome email after creation. *[default: True]* ## complex-cli admin users delete ```console complex-cli admin users delete [OPTIONS] USERNAME ``` Delete a user. **Arguments**: * `USERNAME`: Username to delete. **[required]** **Parameters**: * `--force, --no-force, -f`: Skip confirmation prompt. *[default: False]* * `--backup, --no-backup`: Create backup before deletion. *[default: True]* ## complex-cli admin users permissions Permission management for users. ## complex-cli admin users permissions grant ```console complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION ``` Grant permissions to a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to grant. **[required]** *[choices: none, read, write, execute, admin]* **Parameters**: * `--resource`: Specific resource to grant access to. * `--expires`: Expiration date (ISO format). ## complex-cli admin users permissions revoke ```console complex-cli admin users permissions revoke USERNAME PERMISSION ``` Revoke permissions from a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to revoke. **[required]** *[choices: none, read, write, execute, admin]* ## complex-cli admin users permissions audit ```console complex-cli admin users permissions audit [ARGS] ``` Audit permission changes. **Parameters**: * `USERNAME, --username`: Filter by username (all users if not specified). * `DAYS, --days`: Number of days to look back. *[default: 30]* * `FORMAT, --format`: Output format for audit report. *[choices: json, yaml, table, csv]* *[default: table]* ## complex-cli admin users permissions roles Role template management. **Commands**: * [`create-role`](#complex-cli-admin-users-permissions-roles-create-role): Create a new role template. * [`list-roles`](#complex-cli-admin-users-permissions-roles-list-roles): List all role templates. ## complex-cli admin users permissions roles list-roles ```console complex-cli admin users permissions roles list-roles [ARGS] ``` List all role templates. **Parameters**: * `INCLUDE-SYSTEM, --include-system, --no-include-system`: Include built-in system roles. *[default: False]* ## complex-cli admin users permissions roles create-role ```console complex-cli admin users permissions roles create-role [OPTIONS] NAME ``` Create a new role template. **Arguments**: * `NAME`: Role name. **[required]** **Parameters**: * `--permissions.none, --permissions.no-none`: Default permissions for this role. *[default: False]* * `--permissions.read, --permissions.no-read`: Default permissions for this role. *[default: False]* * `--permissions.write, --permissions.no-write`: Default permissions for this role. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Default permissions for this role. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Default permissions for this role. *[default: False]* * `--description`: Role description. *[default: ""]* TestMarkdownSnapshots.test_full_app_markdown.md000066400000000000000000000504741517576204000372620ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots# complex-cli ```console complex-cli COMMAND [OPTIONS] ``` Complex CLI application for comprehensive documentation testing. ## Table of Contents - [`version`](#complex-cli-version) - [`info`](#complex-cli-info) - [`admin`](#complex-cli-admin) - [`status`](#complex-cli-admin-status) - [`config-cmd`](#complex-cli-admin-config-cmd) - [`users`](#complex-cli-admin-users) - [`list-users`](#complex-cli-admin-users-list-users) - [`create`](#complex-cli-admin-users-create) - [`delete`](#complex-cli-admin-users-delete) - [`permissions`](#complex-cli-admin-users-permissions) - [`grant`](#complex-cli-admin-users-permissions-grant) - [`revoke`](#complex-cli-admin-users-permissions-revoke) - [`audit`](#complex-cli-admin-users-permissions-audit) - [`roles`](#complex-cli-admin-users-permissions-roles) - [`list-roles`](#complex-cli-admin-users-permissions-roles-list-roles) - [`create-role`](#complex-cli-admin-users-permissions-roles-create-role) - [`data`](#complex-cli-data) - [`process`](#complex-cli-data-process) - [`pipeline`](#complex-cli-data-pipeline) - [`validate`](#complex-cli-data-validate) - [`server`](#complex-cli-server) - [`start`](#complex-cli-server-start) - [`stop`](#complex-cli-server-stop) - [`restart`](#complex-cli-server-restart) - [`cache`](#complex-cli-cache) - [`configure`](#complex-cli-cache-configure) - [`clear`](#complex-cli-cache-clear) - [`stats`](#complex-cli-cache-stats) - [`complex-types`](#complex-cli-complex-types) - [`numpy-style`](#complex-cli-numpy-style) - [`google-style`](#complex-cli-google-style) - [`sphinx-style`](#complex-cli-sphinx-style) - [`secret-feature`](#complex-cli-secret-feature) **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* **Subcommands**: * [`admin`](#complex-cli-admin): Administrative commands for system management. * [`data`](#complex-cli-data): Data processing commands. * [`server`](#complex-cli-server): Server management commands. **Utilities**: * [`cache`](#complex-cli-cache): Cache management commands. * [`complex-types`](#complex-cli-complex-types): Demonstrate complex type annotations. * [`google-style`](#complex-cli-google-style): Command with Google-style docstring. * [`info`](#complex-cli-info): Show application information. * [`numpy-style`](#complex-cli-numpy-style): Command with NumPy-style docstring. * [`sphinx-style`](#complex-cli-sphinx-style): Command with Sphinx-style docstring. * [`version`](#complex-cli-version): Show version information. ## complex-cli version ```console complex-cli version ``` Show version information. Displays the application version and system information. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ## complex-cli info ```console complex-cli info [ARGS] ``` Show application information. **Parameters**: * `DETAILED, --detailed, --no-detailed`: Show detailed information including dependencies. *[default: False]* * `FORMAT, --format`: Output format for the information. *[choices: json, yaml, table, csv]* *[default: table]* **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ## complex-cli admin Administrative commands for system management. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli admin status ```console complex-cli admin status [OPTIONS] [ARGS] ``` Show system status. **Parameters**: * `SERVICES, --services`: Specific services to check (all if not specified). * `--watch, -w`: Continuously watch status. *[default: False]* * `--interval`: Refresh interval in seconds when watching. *[default: 5]* ### complex-cli admin config-cmd ```console complex-cli admin config-cmd [OPTIONS] ``` Configure database settings. **Parameters**: * `--host`: Database server hostname. *[default: localhost]* * `--port`: Database server port number. *[default: 5432]* * `--username`: Authentication username. *[default: admin]* * `--password`: Authentication password (optional). * `--ssl-mode`: SSL connection mode. *[choices: disable, prefer, require, verify-full]* *[default: prefer]* * `--pool-size`: Connection pool size. *[default: 10]* ### complex-cli admin users User management commands. **Commands**: * [`create`](#complex-cli-admin-users-create): Create a new user. * [`delete`](#complex-cli-admin-users-delete): Delete a user. * [`list-users`](#complex-cli-admin-users-list-users): List all users. * [`permissions`](#complex-cli-admin-users-permissions): Permission management for users. #### complex-cli admin users list-users ```console complex-cli admin users list-users [ARGS] ``` List all users. **Parameters**: * `ACTIVE-ONLY, --active-only, --no-active-only`: Show only active users. *[default: False]* * `ROLE, --role`: Filter by user role. *[choices: admin, user, guest]* * `LIMIT, --limit`: Maximum number of users to display. *[default: 100]* * `FORMAT, --format`: Output format. *[choices: json, yaml, table, csv]* *[default: table]* #### complex-cli admin users create ```console complex-cli admin users create [OPTIONS] USERNAME EMAIL ``` Create a new user. **Arguments**: * `USERNAME`: Unique username for the new user. **[required]** * `EMAIL`: Email address for the new user. **[required]** **Parameters**: * `--role`: User role assignment. *[choices: admin, user, guest]* *[default: user]* * `--permissions.none, --permissions.no-none`: Initial permission flags. *[default: False]* * `--permissions.read, --permissions.no-read`: Initial permission flags. *[default: False]* * `--permissions.write, --permissions.no-write`: Initial permission flags. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Initial permission flags. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Initial permission flags. *[default: False]* * `--send-welcome, --no-send-welcome`: Send welcome email after creation. *[default: True]* #### complex-cli admin users delete ```console complex-cli admin users delete [OPTIONS] USERNAME ``` Delete a user. **Arguments**: * `USERNAME`: Username to delete. **[required]** **Parameters**: * `--force, --no-force, -f`: Skip confirmation prompt. *[default: False]* * `--backup, --no-backup`: Create backup before deletion. *[default: True]* #### complex-cli admin users permissions Permission management for users. ##### complex-cli admin users permissions grant ```console complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION ``` Grant permissions to a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to grant. **[required]** *[choices: none, read, write, execute, admin]* **Parameters**: * `--resource`: Specific resource to grant access to. * `--expires`: Expiration date (ISO format). ##### complex-cli admin users permissions revoke ```console complex-cli admin users permissions revoke USERNAME PERMISSION ``` Revoke permissions from a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to revoke. **[required]** *[choices: none, read, write, execute, admin]* ##### complex-cli admin users permissions audit ```console complex-cli admin users permissions audit [ARGS] ``` Audit permission changes. **Parameters**: * `USERNAME, --username`: Filter by username (all users if not specified). * `DAYS, --days`: Number of days to look back. *[default: 30]* * `FORMAT, --format`: Output format for audit report. *[choices: json, yaml, table, csv]* *[default: table]* ##### complex-cli admin users permissions roles Role template management. **Commands**: * [`create-role`](#complex-cli-admin-users-permissions-roles-create-role): Create a new role template. * [`list-roles`](#complex-cli-admin-users-permissions-roles-list-roles): List all role templates. ###### complex-cli admin users permissions roles list-roles ```console complex-cli admin users permissions roles list-roles [ARGS] ``` List all role templates. **Parameters**: * `INCLUDE-SYSTEM, --include-system, --no-include-system`: Include built-in system roles. *[default: False]* ###### complex-cli admin users permissions roles create-role ```console complex-cli admin users permissions roles create-role [OPTIONS] NAME ``` Create a new role template. **Arguments**: * `NAME`: Role name. **[required]** **Parameters**: * `--permissions.none, --permissions.no-none`: Default permissions for this role. *[default: False]* * `--permissions.read, --permissions.no-read`: Default permissions for this role. *[default: False]* * `--permissions.write, --permissions.no-write`: Default permissions for this role. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Default permissions for this role. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Default permissions for this role. *[default: False]* * `--description`: Role description. *[default: ""]* ## complex-cli data Data processing commands. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli data process ```console complex-cli data process [OPTIONS] INPUT_FILES ``` Process data files with configurable options. This command demonstrates dataclass parameter flattening where all fields from ProcessingConfig and PathConfig become CLI options. **Arguments**: * `INPUT_FILES`: Input files to process **[required]** **Parameters**: * `--batch-size`: Number of items to process per batch. *[default: 32]* * `--num-workers`: Number of parallel workers. Use "auto" for automatic detection. *[choices: auto]* *[default: auto]* * `--quality-level`: Processing quality level. Higher values mean better quality but slower. *[choices: high, medium, low]* *[default: high]* * `--device`: Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. *[choices: cuda, cpu, auto]* *[default: auto]* * `--output-formats, --empty-output-formats`: List of output formats to generate. *[choices: json, yaml, table, csv]* *[default: [json]]* * `--input-dir`: Input data directory. *[default: data/input]* * `--output-dir`: Output results directory. *[default: data/output]* * `--cache-dir`: Cache directory for intermediate files. * `--log-dir`: Directory for log files. *[default: logs]* ### complex-cli data pipeline ```console complex-cli data pipeline [OPTIONS] ``` Run a complete data pipeline. Demonstrates nested dataclass flattening (PipelineConfig contains PathConfig and ProcessingConfig). **Parameters**: * `--name`: Pipeline name for identification. *[default: default-pipeline]* * `--input-dir`: Input data directory. *[default: data/input]* * `--output-dir`: Output results directory. *[default: data/output]* * `--cache-dir`: Cache directory for intermediate files. * `--log-dir`: Directory for log files. *[default: logs]* * `--batch-size`: Number of items to process per batch. *[default: 32]* * `--num-workers`: Number of parallel workers. Use "auto" for automatic detection. *[choices: auto]* *[default: auto]* * `--quality-level`: Processing quality level. Higher values mean better quality but slower. *[choices: high, medium, low]* *[default: high]* * `--device`: Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. *[choices: cuda, cpu, auto]* *[default: auto]* * `--output-formats, --empty-output-formats`: List of output formats to generate. *[choices: json, yaml, table, csv]* *[default: [json]]* * `--dry-run, --no-dry-run`: If True, simulate execution without making changes. *[default: False]* ### complex-cli data validate ```console complex-cli data validate [OPTIONS] INPUT_PATH ``` Validate data files against schema. **Arguments**: * `INPUT_PATH`: Path to validate. **[required]** **Parameters**: * `--strict, --no-strict`: Enable strict validation mode. *[default: False]* * `--schema-file`: Custom schema file (must exist). * `--ignore-patterns, --empty-ignore-patterns`: Patterns to ignore during validation. ## complex-cli server Server management commands. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli server start ```console complex-cli server start [OPTIONS] ``` Start the server with configuration. Demonstrates Pydantic model support for CLI parameters. **Parameters**: * `--server.host`: Server bind address. *[default: 0.0.0.0]* * `--server.port`: Server port number. *[default: 8000]* * `--server.workers`: Number of worker processes. *[default: 4]* * `--server.timeout`: Request timeout in seconds. *[default: 30.0]* * `--server.debug, --server.no-debug`: Enable debug mode. *[default: False]* * `--auth.provider`: Authentication provider type. *[choices: oauth2, jwt, basic, none]* *[default: jwt]* * `--auth.token-expiry`: Token expiration time in seconds. *[default: 3600]* * `--auth.refresh-enabled, --auth.no-refresh-enabled`: Enable token refresh. *[default: True]* * `--auth.allowed-origins, --auth.empty-allowed-origins`: List of allowed CORS origins. ### complex-cli server stop ```console complex-cli server stop [OPTIONS] ``` Stop the server. **Parameters**: * `--graceful, --no-graceful`: Perform graceful shutdown. *[default: True]* * `--timeout`: Shutdown timeout in seconds. *[default: 30]* * `--force, --no-force, -f`: Force immediate shutdown. *[default: False]* ### complex-cli server restart ```console complex-cli server restart [ARGS] ``` Restart the server. **Parameters**: * `ROLLING, --rolling, --no-rolling`: Perform rolling restart (zero downtime). *[default: False]* * `DELAY, --delay`: Delay between worker restarts in seconds. *[default: 5]* ## complex-cli cache Cache management commands. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli cache configure ```console complex-cli cache configure [OPTIONS] ``` Configure cache settings. Demonstrates attrs class support for CLI parameters. **Parameters**: * `--config.backend`: Cache backend type. *[choices: memory, redis, memcached, disk]* *[default: memory]* * `--config.ttl`: Time-to-live in seconds. *[default: 300]* * `--config.max-size`: Maximum cache size in MB. *[default: 1024]* * `--config.compression, --config.no-compression`: Enable compression. *[default: False]* ### complex-cli cache clear ```console complex-cli cache clear [ARGS] ``` Clear cache entries. **Parameters**: * `PATTERN, --pattern`: Pattern to match cache keys. *[default: *]* * `DRY-RUN, --dry-run, --no-dry-run`: Show what would be cleared without actually clearing. *[default: False]* ### complex-cli cache stats ```console complex-cli cache stats [ARGS] ``` Show cache statistics. **Parameters**: * `DETAILED, --detailed, --no-detailed`: Show detailed statistics. *[default: False]* * `FORMAT, --format`: Output format. *[choices: json, yaml, table, csv]* *[default: table]* ## complex-cli complex-types ```console complex-cli complex-types [ARGS] ``` Demonstrate complex type annotations. This command showcases various complex type patterns that the documentation system needs to handle correctly. **Parameters**: * `WORKER-COUNT, --worker-count`: Number of workers or "auto" for automatic detection. *[choices: auto]* *[default: auto]* * `QUALITY, --quality`: Quality preset or "custom" for manual configuration. *[choices: low, medium, high, custom]* *[default: medium]* * `TAGS, --tags, --empty-tags`: Optional list of tags. * `FORMATS, --formats, --empty-formats`: List of output formats. *[choices: json, yaml, table, csv]* *[default: [json]]* * `THRESHOLDS, --thresholds, --empty-thresholds`: Threshold values or "default" for defaults. *[choices: default]* *[default: default]* * `CONFIG-PATH, --config-path`: Optional config file path (must exist if provided). **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ## complex-cli numpy-style ```console complex-cli numpy-style NAME [ARGS] ``` Command with NumPy-style docstring. This command demonstrates NumPy docstring format which is the default for cyclopts. **Parameters**: * `NAME, --name`: The name parameter. **[required]** * `COUNT, --count`: The count parameter, by default 1. *[default: 1]* **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ## complex-cli google-style ```console complex-cli google-style NAME [ARGS] ``` Command with Google-style docstring. This command demonstrates Google docstring format. **Parameters**: * `NAME, --name`: The name parameter. **[required]** * `COUNT, --count`: The count parameter. Defaults to 1. *[default: 1]* **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ## complex-cli sphinx-style ```console complex-cli sphinx-style NAME [ARGS] ``` Command with Sphinx-style docstring. This command demonstrates Sphinx/reST docstring format. **Parameters**: * `NAME, --name`: The name parameter. **[required]** * `COUNT, --count`: The count parameter. *[default: 1]* **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ## complex-cli secret-feature ```console complex-cli secret-feature [ARGS] ``` Secret feature command. This command has a hidden parameter. **Parameters**: * `ENABLE, --enable, --no-enable`: Enable the secret feature. *[default: False]* **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* TestMarkdownSnapshots.test_hidden_commands_markdown.md000066400000000000000000000056471517576204000405760ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots## complex-cli ```console complex-cli COMMAND [OPTIONS] ``` Complex CLI application for comprehensive documentation testing. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* **Subcommands**: * [`admin`](#complex-cli-admin): Administrative commands for system management. * [`data`](#complex-cli-data): Data processing commands. * [`server`](#complex-cli-server): Server management commands. **Utilities**: * [`cache`](#complex-cli-cache): Cache management commands. * [`complex-types`](#complex-cli-complex-types): Demonstrate complex type annotations. * [`google-style`](#complex-cli-google-style): Command with Google-style docstring. * [`info`](#complex-cli-info): Show application information. * [`numpy-style`](#complex-cli-numpy-style): Command with NumPy-style docstring. * [`sphinx-style`](#complex-cli-sphinx-style): Command with Sphinx-style docstring. * [`version`](#complex-cli-version): Show version information. ### complex-cli version ```console complex-cli version ``` Show version information. Displays the application version and system information. ### complex-cli info ```console complex-cli info [ARGS] ``` Show application information. ### complex-cli debug-internal ```console complex-cli debug-internal ``` Internal debug command (hidden from help). This command is for internal debugging purposes only. ### complex-cli admin Administrative commands for system management. ### complex-cli data Data processing commands. ### complex-cli server Server management commands. ### complex-cli cache Cache management commands. ### complex-cli complex-types ```console complex-cli complex-types [ARGS] ``` Demonstrate complex type annotations. This command showcases various complex type patterns that the documentation system needs to handle correctly. ### complex-cli numpy-style ```console complex-cli numpy-style NAME [ARGS] ``` Command with NumPy-style docstring. This command demonstrates NumPy docstring format which is the default for cyclopts. ### complex-cli google-style ```console complex-cli google-style NAME [ARGS] ``` Command with Google-style docstring. This command demonstrates Google docstring format. ### complex-cli sphinx-style ```console complex-cli sphinx-style NAME [ARGS] ``` Command with Sphinx-style docstring. This command demonstrates Sphinx/reST docstring format. ### complex-cli internal-maintenance ```console complex-cli internal-maintenance ``` Internal maintenance command. This command is hidden from the main help but can still be invoked. ### complex-cli secret-feature ```console complex-cli secret-feature [ARGS] ``` Secret feature command. This command has a hidden parameter. TestMarkdownSnapshots.test_nested_permissions_markdown.md000066400000000000000000000063011517576204000413630ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots### complex-cli admin Administrative commands for system management. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* #### complex-cli admin users User management commands. **Commands**: * [`permissions`](#complex-cli-admin-users-permissions): Permission management for users. ##### complex-cli admin users permissions Permission management for users. ###### complex-cli admin users permissions grant ```console complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION ``` Grant permissions to a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to grant. **[required]** *[choices: none, read, write, execute, admin]* **Parameters**: * `--resource`: Specific resource to grant access to. * `--expires`: Expiration date (ISO format). ###### complex-cli admin users permissions revoke ```console complex-cli admin users permissions revoke USERNAME PERMISSION ``` Revoke permissions from a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to revoke. **[required]** *[choices: none, read, write, execute, admin]* ###### complex-cli admin users permissions audit ```console complex-cli admin users permissions audit [ARGS] ``` Audit permission changes. **Parameters**: * `USERNAME, --username`: Filter by username (all users if not specified). * `DAYS, --days`: Number of days to look back. *[default: 30]* * `FORMAT, --format`: Output format for audit report. *[choices: json, yaml, table, csv]* *[default: table]* ###### complex-cli admin users permissions roles Role template management. **Commands**: * [`create-role`](#complex-cli-admin-users-permissions-roles-create-role): Create a new role template. * [`list-roles`](#complex-cli-admin-users-permissions-roles-list-roles): List all role templates. ###### complex-cli admin users permissions roles list-roles ```console complex-cli admin users permissions roles list-roles [ARGS] ``` List all role templates. **Parameters**: * `INCLUDE-SYSTEM, --include-system, --no-include-system`: Include built-in system roles. *[default: False]* ###### complex-cli admin users permissions roles create-role ```console complex-cli admin users permissions roles create-role [OPTIONS] NAME ``` Create a new role template. **Arguments**: * `NAME`: Role name. **[required]** **Parameters**: * `--permissions.none, --permissions.no-none`: Default permissions for this role. *[default: False]* * `--permissions.read, --permissions.no-read`: Default permissions for this role. *[default: False]* * `--permissions.write, --permissions.no-write`: Default permissions for this role. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Default permissions for this role. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Default permissions for this role. *[default: False]* * `--description`: Role description. *[default: ""]* TestMarkdownSnapshots.test_server_commands_markdown.md000066400000000000000000000035521517576204000406420ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshotsServer management commands. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli server start ```console complex-cli server start [OPTIONS] ``` Start the server with configuration. Demonstrates Pydantic model support for CLI parameters. **Parameters**: * `--server.host`: Server bind address. *[default: 0.0.0.0]* * `--server.port`: Server port number. *[default: 8000]* * `--server.workers`: Number of worker processes. *[default: 4]* * `--server.timeout`: Request timeout in seconds. *[default: 30.0]* * `--server.debug, --server.no-debug`: Enable debug mode. *[default: False]* * `--auth.provider`: Authentication provider type. *[choices: oauth2, jwt, basic, none]* *[default: jwt]* * `--auth.token-expiry`: Token expiration time in seconds. *[default: 3600]* * `--auth.refresh-enabled, --auth.no-refresh-enabled`: Enable token refresh. *[default: True]* * `--auth.allowed-origins, --auth.empty-allowed-origins`: List of allowed CORS origins. ### complex-cli server stop ```console complex-cli server stop [OPTIONS] ``` Stop the server. **Parameters**: * `--graceful, --no-graceful`: Perform graceful shutdown. *[default: True]* * `--timeout`: Shutdown timeout in seconds. *[default: 30]* * `--force, --no-force, -f`: Force immediate shutdown. *[default: False]* ### complex-cli server restart ```console complex-cli server restart [ARGS] ``` Restart the server. **Parameters**: * `ROLLING, --rolling, --no-rolling`: Perform rolling restart (zero downtime). *[default: False]* * `DELAY, --delay`: Delay between worker restarts in seconds. *[default: 5]* TestMarkdownSnapshots.test_utilities_markdown.md000066400000000000000000000111431517576204000374610ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots## complex-cli ```console complex-cli COMMAND [OPTIONS] ``` Complex CLI application for comprehensive documentation testing. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* **Utilities**: * [`cache`](#complex-cli-cache): Cache management commands. * [`complex-types`](#complex-cli-complex-types): Demonstrate complex type annotations. * [`info`](#complex-cli-info): Show application information. * [`version`](#complex-cli-version): Show version information. ### complex-cli version ```console complex-cli version ``` Show version information. Displays the application version and system information. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli info ```console complex-cli info [ARGS] ``` Show application information. **Parameters**: * `DETAILED, --detailed, --no-detailed`: Show detailed information including dependencies. *[default: False]* * `FORMAT, --format`: Output format for the information. *[choices: json, yaml, table, csv]* *[default: table]* **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli cache Cache management commands. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* #### complex-cli cache configure ```console complex-cli cache configure [OPTIONS] ``` Configure cache settings. Demonstrates attrs class support for CLI parameters. **Parameters**: * `--config.backend`: Cache backend type. *[choices: memory, redis, memcached, disk]* *[default: memory]* * `--config.ttl`: Time-to-live in seconds. *[default: 300]* * `--config.max-size`: Maximum cache size in MB. *[default: 1024]* * `--config.compression, --config.no-compression`: Enable compression. *[default: False]* #### complex-cli cache clear ```console complex-cli cache clear [ARGS] ``` Clear cache entries. **Parameters**: * `PATTERN, --pattern`: Pattern to match cache keys. *[default: *]* * `DRY-RUN, --dry-run, --no-dry-run`: Show what would be cleared without actually clearing. *[default: False]* #### complex-cli cache stats ```console complex-cli cache stats [ARGS] ``` Show cache statistics. **Parameters**: * `DETAILED, --detailed, --no-detailed`: Show detailed statistics. *[default: False]* * `FORMAT, --format`: Output format. *[choices: json, yaml, table, csv]* *[default: table]* ### complex-cli complex-types ```console complex-cli complex-types [ARGS] ``` Demonstrate complex type annotations. This command showcases various complex type patterns that the documentation system needs to handle correctly. **Parameters**: * `WORKER-COUNT, --worker-count`: Number of workers or "auto" for automatic detection. *[choices: auto]* *[default: auto]* * `QUALITY, --quality`: Quality preset or "custom" for manual configuration. *[choices: low, medium, high, custom]* *[default: medium]* * `TAGS, --tags, --empty-tags`: Optional list of tags. * `FORMATS, --formats, --empty-formats`: List of output formats. *[choices: json, yaml, table, csv]* *[default: [json]]* * `THRESHOLDS, --thresholds, --empty-thresholds`: Threshold values or "default" for defaults. *[choices: default]* *[default: default]* * `CONFIG-PATH, --config-path`: Optional config file path (must exist if provided). **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* TestMkDocsDirectiveSnapshots.test_filtered_directive_output.md000066400000000000000000000121701517576204000422560ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots# Admin Commands ## complex-cli admin Administrative commands for system management. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli admin users User management commands. **Commands**: * [`create`](#complex-cli-admin-users-create): Create a new user. * [`delete`](#complex-cli-admin-users-delete): Delete a user. * [`list-users`](#complex-cli-admin-users-list-users): List all users. * [`permissions`](#complex-cli-admin-users-permissions): Permission management for users. #### complex-cli admin users list-users ```console complex-cli admin users list-users [ARGS] ``` List all users. **Parameters**: * `ACTIVE-ONLY, --active-only, --no-active-only`: Show only active users. *[default: False]* * `ROLE, --role`: Filter by user role. *[choices: admin, user, guest]* * `LIMIT, --limit`: Maximum number of users to display. *[default: 100]* * `FORMAT, --format`: Output format. *[choices: json, yaml, table, csv]* *[default: table]* #### complex-cli admin users create ```console complex-cli admin users create [OPTIONS] USERNAME EMAIL ``` Create a new user. **Arguments**: * `USERNAME`: Unique username for the new user. **[required]** * `EMAIL`: Email address for the new user. **[required]** **Parameters**: * `--role`: User role assignment. *[choices: admin, user, guest]* *[default: user]* * `--permissions.none, --permissions.no-none`: Initial permission flags. *[default: False]* * `--permissions.read, --permissions.no-read`: Initial permission flags. *[default: False]* * `--permissions.write, --permissions.no-write`: Initial permission flags. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Initial permission flags. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Initial permission flags. *[default: False]* * `--send-welcome, --no-send-welcome`: Send welcome email after creation. *[default: True]* #### complex-cli admin users delete ```console complex-cli admin users delete [OPTIONS] USERNAME ``` Delete a user. **Arguments**: * `USERNAME`: Username to delete. **[required]** **Parameters**: * `--force, --no-force, -f`: Skip confirmation prompt. *[default: False]* * `--backup, --no-backup`: Create backup before deletion. *[default: True]* #### complex-cli admin users permissions Permission management for users. ##### complex-cli admin users permissions grant ```console complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION ``` Grant permissions to a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to grant. **[required]** *[choices: none, read, write, execute, admin]* **Parameters**: * `--resource`: Specific resource to grant access to. * `--expires`: Expiration date (ISO format). ##### complex-cli admin users permissions revoke ```console complex-cli admin users permissions revoke USERNAME PERMISSION ``` Revoke permissions from a user. **Arguments**: * `USERNAME`: Target username. **[required]** * `PERMISSION`: Permission flags to revoke. **[required]** *[choices: none, read, write, execute, admin]* ##### complex-cli admin users permissions audit ```console complex-cli admin users permissions audit [ARGS] ``` Audit permission changes. **Parameters**: * `USERNAME, --username`: Filter by username (all users if not specified). * `DAYS, --days`: Number of days to look back. *[default: 30]* * `FORMAT, --format`: Output format for audit report. *[choices: json, yaml, table, csv]* *[default: table]* ##### complex-cli admin users permissions roles Role template management. **Commands**: * [`create-role`](#complex-cli-admin-users-permissions-roles-create-role): Create a new role template. * [`list-roles`](#complex-cli-admin-users-permissions-roles-list-roles): List all role templates. ###### complex-cli admin users permissions roles list-roles ```console complex-cli admin users permissions roles list-roles [ARGS] ``` List all role templates. **Parameters**: * `INCLUDE-SYSTEM, --include-system, --no-include-system`: Include built-in system roles. *[default: False]* ###### complex-cli admin users permissions roles create-role ```console complex-cli admin users permissions roles create-role [OPTIONS] NAME ``` Create a new role template. **Arguments**: * `NAME`: Role name. **[required]** **Parameters**: * `--permissions.none, --permissions.no-none`: Default permissions for this role. *[default: False]* * `--permissions.read, --permissions.no-read`: Default permissions for this role. *[default: False]* * `--permissions.write, --permissions.no-write`: Default permissions for this role. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Default permissions for this role. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Default permissions for this role. *[default: False]* * `--description`: Role description. *[default: ""]* TestMkDocsDirectiveSnapshots.test_multiple_directives_output.md000066400000000000000000000122111517576204000424720ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots# CLI Reference ## Data Commands Data processing commands. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli data process ```console complex-cli data process [OPTIONS] INPUT_FILES ``` Process data files with configurable options. This command demonstrates dataclass parameter flattening where all fields from ProcessingConfig and PathConfig become CLI options. **Arguments**: * `INPUT_FILES`: Input files to process **[required]** **Parameters**: * `--batch-size`: Number of items to process per batch. *[default: 32]* * `--num-workers`: Number of parallel workers. Use "auto" for automatic detection. *[choices: auto]* *[default: auto]* * `--quality-level`: Processing quality level. Higher values mean better quality but slower. *[choices: high, medium, low]* *[default: high]* * `--device`: Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. *[choices: cuda, cpu, auto]* *[default: auto]* * `--output-formats, --empty-output-formats`: List of output formats to generate. *[choices: json, yaml, table, csv]* *[default: [json]]* * `--input-dir`: Input data directory. *[default: data/input]* * `--output-dir`: Output results directory. *[default: data/output]* * `--cache-dir`: Cache directory for intermediate files. * `--log-dir`: Directory for log files. *[default: logs]* ### complex-cli data pipeline ```console complex-cli data pipeline [OPTIONS] ``` Run a complete data pipeline. Demonstrates nested dataclass flattening (PipelineConfig contains PathConfig and ProcessingConfig). **Parameters**: * `--name`: Pipeline name for identification. *[default: default-pipeline]* * `--input-dir`: Input data directory. *[default: data/input]* * `--output-dir`: Output results directory. *[default: data/output]* * `--cache-dir`: Cache directory for intermediate files. * `--log-dir`: Directory for log files. *[default: logs]* * `--batch-size`: Number of items to process per batch. *[default: 32]* * `--num-workers`: Number of parallel workers. Use "auto" for automatic detection. *[choices: auto]* *[default: auto]* * `--quality-level`: Processing quality level. Higher values mean better quality but slower. *[choices: high, medium, low]* *[default: high]* * `--device`: Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. *[choices: cuda, cpu, auto]* *[default: auto]* * `--output-formats, --empty-output-formats`: List of output formats to generate. *[choices: json, yaml, table, csv]* *[default: [json]]* * `--dry-run, --no-dry-run`: If True, simulate execution without making changes. *[default: False]* ### complex-cli data validate ```console complex-cli data validate [OPTIONS] INPUT_PATH ``` Validate data files against schema. **Arguments**: * `INPUT_PATH`: Path to validate. **[required]** **Parameters**: * `--strict, --no-strict`: Enable strict validation mode. *[default: False]* * `--schema-file`: Custom schema file (must exist). * `--ignore-patterns, --empty-ignore-patterns`: Patterns to ignore during validation. ## Server Commands Server management commands. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* ### complex-cli server start ```console complex-cli server start [OPTIONS] ``` Start the server with configuration. Demonstrates Pydantic model support for CLI parameters. **Parameters**: * `--server.host`: Server bind address. *[default: 0.0.0.0]* * `--server.port`: Server port number. *[default: 8000]* * `--server.workers`: Number of worker processes. *[default: 4]* * `--server.timeout`: Request timeout in seconds. *[default: 30.0]* * `--server.debug, --server.no-debug`: Enable debug mode. *[default: False]* * `--auth.provider`: Authentication provider type. *[choices: oauth2, jwt, basic, none]* *[default: jwt]* * `--auth.token-expiry`: Token expiration time in seconds. *[default: 3600]* * `--auth.refresh-enabled, --auth.no-refresh-enabled`: Enable token refresh. *[default: True]* * `--auth.allowed-origins, --auth.empty-allowed-origins`: List of allowed CORS origins. ### complex-cli server stop ```console complex-cli server stop [OPTIONS] ``` Stop the server. **Parameters**: * `--graceful, --no-graceful`: Perform graceful shutdown. *[default: True]* * `--timeout`: Shutdown timeout in seconds. *[default: 30]* * `--force, --no-force, -f`: Force immediate shutdown. *[default: False]* ### complex-cli server restart ```console complex-cli server restart [ARGS] ``` Restart the server. **Parameters**: * `ROLLING, --rolling, --no-rolling`: Perform rolling restart (zero downtime). *[default: False]* * `DELAY, --delay`: Delay between worker restarts in seconds. *[default: 5]* TestMkDocsDirectiveSnapshots.test_nested_directive_output.md000066400000000000000000000040371517576204000417450ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots# Permissions ### complex-cli admin Administrative commands for system management. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* #### complex-cli admin users User management commands. **Commands**: * [`permissions`](#complex-cli-admin-users-permissions): Permission management for users. ##### complex-cli admin users permissions Permission management for users. ###### complex-cli admin users permissions roles Role template management. **Commands**: * [`create-role`](#complex-cli-admin-users-permissions-roles-create-role): Create a new role template. * [`list-roles`](#complex-cli-admin-users-permissions-roles-list-roles): List all role templates. ###### complex-cli admin users permissions roles list-roles ```console complex-cli admin users permissions roles list-roles [ARGS] ``` List all role templates. **Parameters**: * `INCLUDE-SYSTEM, --include-system, --no-include-system`: Include built-in system roles. *[default: False]* ###### complex-cli admin users permissions roles create-role ```console complex-cli admin users permissions roles create-role [OPTIONS] NAME ``` Create a new role template. **Arguments**: * `NAME`: Role name. **[required]** **Parameters**: * `--permissions.none, --permissions.no-none`: Default permissions for this role. *[default: False]* * `--permissions.read, --permissions.no-read`: Default permissions for this role. *[default: False]* * `--permissions.write, --permissions.no-write`: Default permissions for this role. *[default: False]* * `--permissions.execute, --permissions.no-execute`: Default permissions for this role. *[default: False]* * `--permissions.admin, --permissions.no-admin`: Default permissions for this role. *[default: False]* * `--description`: Role description. *[default: ""]* TestMkDocsDirectiveSnapshots.test_simple_directive_output.md000066400000000000000000000050641517576204000417550ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots# CLI Reference ```console complex-cli COMMAND [OPTIONS] ``` Complex CLI application for comprehensive documentation testing. **Global Options**: * `--verbose, -v`: Verbosity level (-v, -vv, -vvv). *[default: 0]* * `--quiet, --no-quiet, -q`: Suppress non-essential output. *[default: False]* * `--log-level`: Logging level. *[choices: debug, info, warning, error, critical]* *[default: info]* * `--no-color, --no-no-color`: Disable colored output *[default: False]* **Subcommands**: * [`admin`](#complex-cli-admin): Administrative commands for system management. * [`data`](#complex-cli-data): Data processing commands. * [`server`](#complex-cli-server): Server management commands. **Utilities**: * [`cache`](#complex-cli-cache): Cache management commands. * [`complex-types`](#complex-cli-complex-types): Demonstrate complex type annotations. * [`google-style`](#complex-cli-google-style): Command with Google-style docstring. * [`info`](#complex-cli-info): Show application information. * [`numpy-style`](#complex-cli-numpy-style): Command with NumPy-style docstring. * [`sphinx-style`](#complex-cli-sphinx-style): Command with Sphinx-style docstring. * [`version`](#complex-cli-version): Show version information. ## complex-cli version ```console complex-cli version ``` Show version information. Displays the application version and system information. ## complex-cli info ```console complex-cli info [ARGS] ``` Show application information. ## complex-cli admin Administrative commands for system management. ## complex-cli data Data processing commands. ## complex-cli server Server management commands. ## complex-cli cache Cache management commands. ## complex-cli complex-types ```console complex-cli complex-types [ARGS] ``` Demonstrate complex type annotations. This command showcases various complex type patterns that the documentation system needs to handle correctly. ## complex-cli numpy-style ```console complex-cli numpy-style NAME [ARGS] ``` Command with NumPy-style docstring. This command demonstrates NumPy docstring format which is the default for cyclopts. ## complex-cli google-style ```console complex-cli google-style NAME [ARGS] ``` Command with Google-style docstring. This command demonstrates Google docstring format. ## complex-cli sphinx-style ```console complex-cli sphinx-style NAME [ARGS] ``` Command with Sphinx-style docstring. This command demonstrates Sphinx/reST docstring format. ## complex-cli secret-feature ```console complex-cli secret-feature [ARGS] ``` Secret feature command. This command has a hidden parameter. TestRstSnapshots.test_admin_commands_rst.rst000066400000000000000000000156441517576204000366150ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: complex-cli ----------- :: complex-cli COMMAND [OPTIONS] Complex CLI application for comprehensive documentation testing. .. contents:: Table of Contents :local: :depth: 6 **Global Options:** ``--verbose, -v`` Verbosity level (-v, -vv, -vvv). [Default: ``0``] ``--quiet, -q, --no-quiet`` Suppress non-essential output. [Default: ``False``] ``--log-level`` Logging level. [Choices: ``debug``, ``info``, ``warning``, ``error``, ``critical``, Default: ``info``] ``--no-color, --no-no-color`` Disable colored output [Default: ``False``] **Subcommands:** ``admin`` Administrative commands for system management. .. _cyclopts-complex-cli-admin: admin ^^^^^ Administrative commands for system management. **Commands:** ``config-cmd`` Configure database settings. ``status`` Show system status. ``users`` User management commands. .. _cyclopts-complex-cli-admin-status: status """""" :: complex-cli admin status [OPTIONS] [ARGS] Show system status. **Parameters:** ``SERVICES, --services`` Specific services to check (all if not specified). ``--watch, -w`` Continuously watch status. [Default: ``False``] ``--interval`` Refresh interval in seconds when watching. [Default: ``5``] .. _cyclopts-complex-cli-admin-config-cmd: config-cmd """""""""" :: complex-cli admin config-cmd [OPTIONS] Configure database settings. **Parameters:** ``--host`` Database server hostname. [Default: ``localhost``] ``--port`` Database server port number. [Default: ``5432``] ``--username`` Authentication username. [Default: ``admin``] ``--password`` Authentication password (optional). ``--ssl-mode`` SSL connection mode. [Choices: ``disable``, ``prefer``, ``require``, ``verify-full``, Default: ``prefer``] ``--pool-size`` Connection pool size. [Default: ``10``] .. _cyclopts-complex-cli-admin-users: users """"" User management commands. **Commands:** ``create`` Create a new user. ``delete`` Delete a user. ``list-users`` List all users. ``permissions`` Permission management for users. .. _cyclopts-complex-cli-admin-users-list-users: list-users '''''''''' :: complex-cli admin users list-users [ARGS] List all users. **Parameters:** ``ACTIVE-ONLY, --active-only, --no-active-only`` Show only active users. [Default: ``False``] ``ROLE, --role`` Filter by user role. [Choices: ``admin``, ``user``, ``guest``] ``LIMIT, --limit`` Maximum number of users to display. [Default: ``100``] ``FORMAT, --format`` Output format. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-create: create '''''' :: complex-cli admin users create [OPTIONS] USERNAME EMAIL Create a new user. **Arguments:** ``USERNAME`` Unique username for the new user. [**Required**] ``EMAIL`` Email address for the new user. [**Required**] **Parameters:** ``--role`` User role assignment. [Choices: ``admin``, ``user``, ``guest``, Default: ``user``] ``--permissions.none, --permissions.no-none`` Initial permission flags. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Initial permission flags. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Initial permission flags. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Initial permission flags. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Initial permission flags. [Default: ``False``] ``--send-welcome, --no-send-welcome`` Send welcome email after creation. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-delete: delete '''''' :: complex-cli admin users delete [OPTIONS] USERNAME Delete a user. **Arguments:** ``USERNAME`` Username to delete. [**Required**] **Parameters:** ``--force, -f, --no-force`` Skip confirmation prompt. [Default: ``False``] ``--backup, --no-backup`` Create backup before deletion. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-permissions: permissions ''''''''''' Permission management for users. **Commands:** ``audit`` Audit permission changes. ``grant`` Grant permissions to a user. ``revoke`` Revoke permissions from a user. ``roles`` Role template management. .. _cyclopts-complex-cli-admin-users-permissions-grant: grant ~~~~~ :: complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION Grant permissions to a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to grant. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] **Parameters:** ``--resource`` Specific resource to grant access to. ``--expires`` Expiration date (ISO format). .. _cyclopts-complex-cli-admin-users-permissions-revoke: revoke ~~~~~~ :: complex-cli admin users permissions revoke USERNAME PERMISSION Revoke permissions from a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to revoke. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] .. _cyclopts-complex-cli-admin-users-permissions-audit: audit ~~~~~ :: complex-cli admin users permissions audit [ARGS] Audit permission changes. **Parameters:** ``USERNAME, --username`` Filter by username (all users if not specified). ``DAYS, --days`` Number of days to look back. [Default: ``30``] ``FORMAT, --format`` Output format for audit report. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-permissions-roles: roles ~~~~~ Role template management. **Commands:** ``create-role`` Create a new role template. ``list-roles`` List all role templates. .. _cyclopts-complex-cli-admin-users-permissions-roles-list-roles: list-roles ~~~~~~~~~~ :: complex-cli admin users permissions roles list-roles [ARGS] List all role templates. **Parameters:** ``INCLUDE-SYSTEM, --include-system, --no-include-system`` Include built-in system roles. [Default: ``False``] .. _cyclopts-complex-cli-admin-users-permissions-roles-create-role: create-role ~~~~~~~~~~~ :: complex-cli admin users permissions roles create-role [OPTIONS] NAME Create a new role template. **Arguments:** ``NAME`` Role name. [**Required**] **Parameters:** ``--permissions.none, --permissions.no-none`` Default permissions for this role. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Default permissions for this role. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Default permissions for this role. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Default permissions for this role. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Default permissions for this role. [Default: ``False``] ``--description`` Role description. [Default: ``""``] TestRstSnapshots.test_data_commands_rst.rst000066400000000000000000000077411517576204000364350ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: complex-cli ----------- :: complex-cli COMMAND [OPTIONS] Complex CLI application for comprehensive documentation testing. .. contents:: Table of Contents :local: :depth: 6 **Global Options:** ``--verbose, -v`` Verbosity level (-v, -vv, -vvv). [Default: ``0``] ``--quiet, -q, --no-quiet`` Suppress non-essential output. [Default: ``False``] ``--log-level`` Logging level. [Choices: ``debug``, ``info``, ``warning``, ``error``, ``critical``, Default: ``info``] ``--no-color, --no-no-color`` Disable colored output [Default: ``False``] **Subcommands:** ``data`` Data processing commands. .. _cyclopts-complex-cli-data: data ^^^^ Data processing commands. **Commands:** ``pipeline`` Run a complete data pipeline. ``process`` Process data files with configurable options. ``validate`` Validate data files against schema. .. _cyclopts-complex-cli-data-process: process """"""" :: complex-cli data process [OPTIONS] INPUT_FILES Process data files with configurable options. This command demonstrates dataclass parameter flattening where all fields from ProcessingConfig and PathConfig become CLI options. **Arguments:** ``INPUT_FILES`` Input files to process [**Required**] **Parameters:** ``--batch-size`` Number of items to process per batch. [Default: ``32``] ``--num-workers`` Number of parallel workers. Use "auto" for automatic detection. [Choices: ``auto``, Default: ``auto``] ``--quality-level`` Processing quality level. Higher values mean better quality but slower. [Choices: ``high``, ``medium``, ``low``, Default: ``high``] ``--device`` Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. [Choices: ``cuda``, ``cpu``, ``auto``, Default: ``auto``] ``--output-formats, --empty-output-formats`` List of output formats to generate. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``[json]``] ``--input-dir`` Input data directory. [Default: ``data/input``] ``--output-dir`` Output results directory. [Default: ``data/output``] ``--cache-dir`` Cache directory for intermediate files. ``--log-dir`` Directory for log files. [Default: ``logs``] .. _cyclopts-complex-cli-data-pipeline: pipeline """""""" :: complex-cli data pipeline [OPTIONS] Run a complete data pipeline. Demonstrates nested dataclass flattening (PipelineConfig contains PathConfig and ProcessingConfig). **Parameters:** ``--name`` Pipeline name for identification. [Default: ``default-pipeline``] ``--input-dir`` Input data directory. [Default: ``data/input``] ``--output-dir`` Output results directory. [Default: ``data/output``] ``--cache-dir`` Cache directory for intermediate files. ``--log-dir`` Directory for log files. [Default: ``logs``] ``--batch-size`` Number of items to process per batch. [Default: ``32``] ``--num-workers`` Number of parallel workers. Use "auto" for automatic detection. [Choices: ``auto``, Default: ``auto``] ``--quality-level`` Processing quality level. Higher values mean better quality but slower. [Choices: ``high``, ``medium``, ``low``, Default: ``high``] ``--device`` Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. [Choices: ``cuda``, ``cpu``, ``auto``, Default: ``auto``] ``--output-formats, --empty-output-formats`` List of output formats to generate. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``[json]``] ``--dry-run, --no-dry-run`` If True, simulate execution without making changes. [Default: ``False``] .. _cyclopts-complex-cli-data-validate: validate """""""" :: complex-cli data validate [OPTIONS] INPUT_PATH Validate data files against schema. **Arguments:** ``INPUT_PATH`` Path to validate. [**Required**] **Parameters:** ``--strict, --no-strict`` Enable strict validation mode. [Default: ``False``] ``--schema-file`` Custom schema file (must exist). ``--ignore-patterns, --empty-ignore-patterns`` Patterns to ignore during validation. TestRstSnapshots.test_flattened_commands_rst.rst000066400000000000000000000151011517576204000374570ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: complex-cli ----------- :: complex-cli COMMAND [OPTIONS] Complex CLI application for comprehensive documentation testing. .. contents:: Table of Contents :local: :depth: 6 **Global Options:** ``--verbose, -v`` Verbosity level (-v, -vv, -vvv). [Default: ``0``] ``--quiet, -q, --no-quiet`` Suppress non-essential output. [Default: ``False``] ``--log-level`` Logging level. [Choices: ``debug``, ``info``, ``warning``, ``error``, ``critical``, Default: ``info``] ``--no-color, --no-no-color`` Disable colored output [Default: ``False``] **Subcommands:** ``admin`` Administrative commands for system management. .. _cyclopts-complex-cli-admin: complex-cli admin ----------------- Administrative commands for system management. **Commands:** ``users`` User management commands. .. _cyclopts-complex-cli-admin-users: complex-cli admin users ----------------------- User management commands. **Commands:** ``create`` Create a new user. ``delete`` Delete a user. ``list-users`` List all users. ``permissions`` Permission management for users. .. _cyclopts-complex-cli-admin-users-list-users: complex-cli admin users list-users ---------------------------------- :: complex-cli admin users list-users [ARGS] List all users. **Parameters:** ``ACTIVE-ONLY, --active-only, --no-active-only`` Show only active users. [Default: ``False``] ``ROLE, --role`` Filter by user role. [Choices: ``admin``, ``user``, ``guest``] ``LIMIT, --limit`` Maximum number of users to display. [Default: ``100``] ``FORMAT, --format`` Output format. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-create: complex-cli admin users create ------------------------------ :: complex-cli admin users create [OPTIONS] USERNAME EMAIL Create a new user. **Arguments:** ``USERNAME`` Unique username for the new user. [**Required**] ``EMAIL`` Email address for the new user. [**Required**] **Parameters:** ``--role`` User role assignment. [Choices: ``admin``, ``user``, ``guest``, Default: ``user``] ``--permissions.none, --permissions.no-none`` Initial permission flags. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Initial permission flags. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Initial permission flags. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Initial permission flags. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Initial permission flags. [Default: ``False``] ``--send-welcome, --no-send-welcome`` Send welcome email after creation. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-delete: complex-cli admin users delete ------------------------------ :: complex-cli admin users delete [OPTIONS] USERNAME Delete a user. **Arguments:** ``USERNAME`` Username to delete. [**Required**] **Parameters:** ``--force, -f, --no-force`` Skip confirmation prompt. [Default: ``False``] ``--backup, --no-backup`` Create backup before deletion. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-permissions: complex-cli admin users permissions ----------------------------------- Permission management for users. **Commands:** ``audit`` Audit permission changes. ``grant`` Grant permissions to a user. ``revoke`` Revoke permissions from a user. ``roles`` Role template management. .. _cyclopts-complex-cli-admin-users-permissions-grant: complex-cli admin users permissions grant ----------------------------------------- :: complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION Grant permissions to a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to grant. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] **Parameters:** ``--resource`` Specific resource to grant access to. ``--expires`` Expiration date (ISO format). .. _cyclopts-complex-cli-admin-users-permissions-revoke: complex-cli admin users permissions revoke ------------------------------------------ :: complex-cli admin users permissions revoke USERNAME PERMISSION Revoke permissions from a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to revoke. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] .. _cyclopts-complex-cli-admin-users-permissions-audit: complex-cli admin users permissions audit ----------------------------------------- :: complex-cli admin users permissions audit [ARGS] Audit permission changes. **Parameters:** ``USERNAME, --username`` Filter by username (all users if not specified). ``DAYS, --days`` Number of days to look back. [Default: ``30``] ``FORMAT, --format`` Output format for audit report. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-permissions-roles: complex-cli admin users permissions roles ----------------------------------------- Role template management. **Commands:** ``create-role`` Create a new role template. ``list-roles`` List all role templates. .. _cyclopts-complex-cli-admin-users-permissions-roles-list-roles: complex-cli admin users permissions roles list-roles ---------------------------------------------------- :: complex-cli admin users permissions roles list-roles [ARGS] List all role templates. **Parameters:** ``INCLUDE-SYSTEM, --include-system, --no-include-system`` Include built-in system roles. [Default: ``False``] .. _cyclopts-complex-cli-admin-users-permissions-roles-create-role: complex-cli admin users permissions roles create-role ----------------------------------------------------- :: complex-cli admin users permissions roles create-role [OPTIONS] NAME Create a new role template. **Arguments:** ``NAME`` Role name. [**Required**] **Parameters:** ``--permissions.none, --permissions.no-none`` Default permissions for this role. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Default permissions for this role. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Default permissions for this role. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Default permissions for this role. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Default permissions for this role. [Default: ``False``] ``--description`` Role description. [Default: ``""``] TestRstSnapshots.test_full_app_rst.rst000066400000000000000000000414051517576204000354400ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: =========== complex-cli =========== :: complex-cli COMMAND [OPTIONS] Complex CLI application for comprehensive documentation testing. .. contents:: Table of Contents :local: :depth: 6 **Global Options:** ``--verbose, -v`` Verbosity level (-v, -vv, -vvv). [Default: ``0``] ``--quiet, -q, --no-quiet`` Suppress non-essential output. [Default: ``False``] ``--log-level`` Logging level. [Choices: ``debug``, ``info``, ``warning``, ``error``, ``critical``, Default: ``info``] ``--no-color, --no-no-color`` Disable colored output [Default: ``False``] **Subcommands:** ``admin`` Administrative commands for system management. ``data`` Data processing commands. ``server`` Server management commands. **Utilities:** ``cache`` Cache management commands. ``complex-types`` Demonstrate complex type annotations. ``google-style`` Command with Google-style docstring. ``info`` Show application information. ``numpy-style`` Command with NumPy-style docstring. ``sphinx-style`` Command with Sphinx-style docstring. ``version`` Show version information. .. _cyclopts-complex-cli-version: version ------- :: complex-cli version Show version information. Displays the application version and system information. .. _cyclopts-complex-cli-info: info ---- :: complex-cli info [ARGS] Show application information. **Parameters:** ``DETAILED, --detailed, --no-detailed`` Show detailed information including dependencies. [Default: ``False``] ``FORMAT, --format`` Output format for the information. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin: admin ----- Administrative commands for system management. **Commands:** ``config-cmd`` Configure database settings. ``status`` Show system status. ``users`` User management commands. .. _cyclopts-complex-cli-admin-status: status ^^^^^^ :: complex-cli admin status [OPTIONS] [ARGS] Show system status. **Parameters:** ``SERVICES, --services`` Specific services to check (all if not specified). ``--watch, -w`` Continuously watch status. [Default: ``False``] ``--interval`` Refresh interval in seconds when watching. [Default: ``5``] .. _cyclopts-complex-cli-admin-config-cmd: config-cmd ^^^^^^^^^^ :: complex-cli admin config-cmd [OPTIONS] Configure database settings. **Parameters:** ``--host`` Database server hostname. [Default: ``localhost``] ``--port`` Database server port number. [Default: ``5432``] ``--username`` Authentication username. [Default: ``admin``] ``--password`` Authentication password (optional). ``--ssl-mode`` SSL connection mode. [Choices: ``disable``, ``prefer``, ``require``, ``verify-full``, Default: ``prefer``] ``--pool-size`` Connection pool size. [Default: ``10``] .. _cyclopts-complex-cli-admin-users: users ^^^^^ User management commands. **Commands:** ``create`` Create a new user. ``delete`` Delete a user. ``list-users`` List all users. ``permissions`` Permission management for users. .. _cyclopts-complex-cli-admin-users-list-users: list-users """""""""" :: complex-cli admin users list-users [ARGS] List all users. **Parameters:** ``ACTIVE-ONLY, --active-only, --no-active-only`` Show only active users. [Default: ``False``] ``ROLE, --role`` Filter by user role. [Choices: ``admin``, ``user``, ``guest``] ``LIMIT, --limit`` Maximum number of users to display. [Default: ``100``] ``FORMAT, --format`` Output format. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-create: create """""" :: complex-cli admin users create [OPTIONS] USERNAME EMAIL Create a new user. **Arguments:** ``USERNAME`` Unique username for the new user. [**Required**] ``EMAIL`` Email address for the new user. [**Required**] **Parameters:** ``--role`` User role assignment. [Choices: ``admin``, ``user``, ``guest``, Default: ``user``] ``--permissions.none, --permissions.no-none`` Initial permission flags. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Initial permission flags. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Initial permission flags. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Initial permission flags. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Initial permission flags. [Default: ``False``] ``--send-welcome, --no-send-welcome`` Send welcome email after creation. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-delete: delete """""" :: complex-cli admin users delete [OPTIONS] USERNAME Delete a user. **Arguments:** ``USERNAME`` Username to delete. [**Required**] **Parameters:** ``--force, -f, --no-force`` Skip confirmation prompt. [Default: ``False``] ``--backup, --no-backup`` Create backup before deletion. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-permissions: permissions """"""""""" Permission management for users. **Commands:** ``audit`` Audit permission changes. ``grant`` Grant permissions to a user. ``revoke`` Revoke permissions from a user. ``roles`` Role template management. .. _cyclopts-complex-cli-admin-users-permissions-grant: grant ''''' :: complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION Grant permissions to a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to grant. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] **Parameters:** ``--resource`` Specific resource to grant access to. ``--expires`` Expiration date (ISO format). .. _cyclopts-complex-cli-admin-users-permissions-revoke: revoke '''''' :: complex-cli admin users permissions revoke USERNAME PERMISSION Revoke permissions from a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to revoke. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] .. _cyclopts-complex-cli-admin-users-permissions-audit: audit ''''' :: complex-cli admin users permissions audit [ARGS] Audit permission changes. **Parameters:** ``USERNAME, --username`` Filter by username (all users if not specified). ``DAYS, --days`` Number of days to look back. [Default: ``30``] ``FORMAT, --format`` Output format for audit report. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-permissions-roles: roles ''''' Role template management. **Commands:** ``create-role`` Create a new role template. ``list-roles`` List all role templates. .. _cyclopts-complex-cli-admin-users-permissions-roles-list-roles: list-roles ~~~~~~~~~~ :: complex-cli admin users permissions roles list-roles [ARGS] List all role templates. **Parameters:** ``INCLUDE-SYSTEM, --include-system, --no-include-system`` Include built-in system roles. [Default: ``False``] .. _cyclopts-complex-cli-admin-users-permissions-roles-create-role: create-role ~~~~~~~~~~~ :: complex-cli admin users permissions roles create-role [OPTIONS] NAME Create a new role template. **Arguments:** ``NAME`` Role name. [**Required**] **Parameters:** ``--permissions.none, --permissions.no-none`` Default permissions for this role. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Default permissions for this role. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Default permissions for this role. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Default permissions for this role. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Default permissions for this role. [Default: ``False``] ``--description`` Role description. [Default: ``""``] .. _cyclopts-complex-cli-data: data ---- Data processing commands. **Commands:** ``pipeline`` Run a complete data pipeline. ``process`` Process data files with configurable options. ``validate`` Validate data files against schema. .. _cyclopts-complex-cli-data-process: process ^^^^^^^ :: complex-cli data process [OPTIONS] INPUT_FILES Process data files with configurable options. This command demonstrates dataclass parameter flattening where all fields from ProcessingConfig and PathConfig become CLI options. **Arguments:** ``INPUT_FILES`` Input files to process [**Required**] **Parameters:** ``--batch-size`` Number of items to process per batch. [Default: ``32``] ``--num-workers`` Number of parallel workers. Use "auto" for automatic detection. [Choices: ``auto``, Default: ``auto``] ``--quality-level`` Processing quality level. Higher values mean better quality but slower. [Choices: ``high``, ``medium``, ``low``, Default: ``high``] ``--device`` Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. [Choices: ``cuda``, ``cpu``, ``auto``, Default: ``auto``] ``--output-formats, --empty-output-formats`` List of output formats to generate. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``[json]``] ``--input-dir`` Input data directory. [Default: ``data/input``] ``--output-dir`` Output results directory. [Default: ``data/output``] ``--cache-dir`` Cache directory for intermediate files. ``--log-dir`` Directory for log files. [Default: ``logs``] .. _cyclopts-complex-cli-data-pipeline: pipeline ^^^^^^^^ :: complex-cli data pipeline [OPTIONS] Run a complete data pipeline. Demonstrates nested dataclass flattening (PipelineConfig contains PathConfig and ProcessingConfig). **Parameters:** ``--name`` Pipeline name for identification. [Default: ``default-pipeline``] ``--input-dir`` Input data directory. [Default: ``data/input``] ``--output-dir`` Output results directory. [Default: ``data/output``] ``--cache-dir`` Cache directory for intermediate files. ``--log-dir`` Directory for log files. [Default: ``logs``] ``--batch-size`` Number of items to process per batch. [Default: ``32``] ``--num-workers`` Number of parallel workers. Use "auto" for automatic detection. [Choices: ``auto``, Default: ``auto``] ``--quality-level`` Processing quality level. Higher values mean better quality but slower. [Choices: ``high``, ``medium``, ``low``, Default: ``high``] ``--device`` Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. [Choices: ``cuda``, ``cpu``, ``auto``, Default: ``auto``] ``--output-formats, --empty-output-formats`` List of output formats to generate. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``[json]``] ``--dry-run, --no-dry-run`` If True, simulate execution without making changes. [Default: ``False``] .. _cyclopts-complex-cli-data-validate: validate ^^^^^^^^ :: complex-cli data validate [OPTIONS] INPUT_PATH Validate data files against schema. **Arguments:** ``INPUT_PATH`` Path to validate. [**Required**] **Parameters:** ``--strict, --no-strict`` Enable strict validation mode. [Default: ``False``] ``--schema-file`` Custom schema file (must exist). ``--ignore-patterns, --empty-ignore-patterns`` Patterns to ignore during validation. .. _cyclopts-complex-cli-server: server ------ Server management commands. **Commands:** ``restart`` Restart the server. ``start`` Start the server with configuration. ``stop`` Stop the server. .. _cyclopts-complex-cli-server-start: start ^^^^^ :: complex-cli server start [OPTIONS] Start the server with configuration. Demonstrates Pydantic model support for CLI parameters. **Parameters:** ``--server.host`` Server bind address. [Default: ``0.0.0.0``] ``--server.port`` Server port number. [Default: ``8000``] ``--server.workers`` Number of worker processes. [Default: ``4``] ``--server.timeout`` Request timeout in seconds. [Default: ``30.0``] ``--server.debug, --server.no-debug`` Enable debug mode. [Default: ``False``] ``--auth.provider`` Authentication provider type. [Choices: ``oauth2``, ``jwt``, ``basic``, ``none``, Default: ``jwt``] ``--auth.token-expiry`` Token expiration time in seconds. [Default: ``3600``] ``--auth.refresh-enabled, --auth.no-refresh-enabled`` Enable token refresh. [Default: ``True``] ``--auth.allowed-origins, --auth.empty-allowed-origins`` List of allowed CORS origins. .. _cyclopts-complex-cli-server-stop: stop ^^^^ :: complex-cli server stop [OPTIONS] Stop the server. **Parameters:** ``--graceful, --no-graceful`` Perform graceful shutdown. [Default: ``True``] ``--timeout`` Shutdown timeout in seconds. [Default: ``30``] ``--force, -f, --no-force`` Force immediate shutdown. [Default: ``False``] .. _cyclopts-complex-cli-server-restart: restart ^^^^^^^ :: complex-cli server restart [ARGS] Restart the server. **Parameters:** ``ROLLING, --rolling, --no-rolling`` Perform rolling restart (zero downtime). [Default: ``False``] ``DELAY, --delay`` Delay between worker restarts in seconds. [Default: ``5``] .. _cyclopts-complex-cli-cache: cache ----- Cache management commands. **Commands:** ``clear`` Clear cache entries. ``configure`` Configure cache settings. ``stats`` Show cache statistics. .. _cyclopts-complex-cli-cache-configure: configure ^^^^^^^^^ :: complex-cli cache configure [OPTIONS] Configure cache settings. Demonstrates attrs class support for CLI parameters. **Parameters:** ``--config.backend`` Cache backend type. [Choices: ``memory``, ``redis``, ``memcached``, ``disk``, Default: ``memory``] ``--config.ttl`` Time-to-live in seconds. [Default: ``300``] ``--config.max-size`` Maximum cache size in MB. [Default: ``1024``] ``--config.compression, --config.no-compression`` Enable compression. [Default: ``False``] .. _cyclopts-complex-cli-cache-clear: clear ^^^^^ :: complex-cli cache clear [ARGS] Clear cache entries. **Parameters:** ``PATTERN, --pattern`` Pattern to match cache keys. [Default: ``*``] ``DRY-RUN, --dry-run, --no-dry-run`` Show what would be cleared without actually clearing. [Default: ``False``] .. _cyclopts-complex-cli-cache-stats: stats ^^^^^ :: complex-cli cache stats [ARGS] Show cache statistics. **Parameters:** ``DETAILED, --detailed, --no-detailed`` Show detailed statistics. [Default: ``False``] ``FORMAT, --format`` Output format. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-complex-types: complex-types ------------- :: complex-cli complex-types [ARGS] Demonstrate complex type annotations. This command showcases various complex type patterns that the documentation system needs to handle correctly. **Parameters:** ``WORKER-COUNT, --worker-count`` Number of workers or "auto" for automatic detection. [Choices: ``auto``, Default: ``auto``] ``QUALITY, --quality`` Quality preset or "custom" for manual configuration. [Choices: ``low``, ``medium``, ``high``, ``custom``, Default: ``medium``] ``TAGS, --tags, --empty-tags`` Optional list of tags. ``FORMATS, --formats, --empty-formats`` List of output formats. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``[json]``] ``THRESHOLDS, --thresholds, --empty-thresholds`` Threshold values or "default" for defaults. [Choices: ``default``, Default: ``default``] ``CONFIG-PATH, --config-path`` Optional config file path (must exist if provided). .. _cyclopts-complex-cli-numpy-style: numpy-style ----------- :: complex-cli numpy-style NAME [ARGS] Command with NumPy-style docstring. This command demonstrates NumPy docstring format which is the default for cyclopts. **Parameters:** ``NAME, --name`` The name parameter. [**Required**] ``COUNT, --count`` The count parameter, by default 1. [Default: ``1``] .. _cyclopts-complex-cli-google-style: google-style ------------ :: complex-cli google-style NAME [ARGS] Command with Google-style docstring. This command demonstrates Google docstring format. **Parameters:** ``NAME, --name`` The name parameter. [**Required**] ``COUNT, --count`` The count parameter. Defaults to 1. [Default: ``1``] .. _cyclopts-complex-cli-sphinx-style: sphinx-style ------------ :: complex-cli sphinx-style NAME [ARGS] Command with Sphinx-style docstring. This command demonstrates Sphinx/reST docstring format. **Parameters:** ``NAME, --name`` The name parameter. [**Required**] ``COUNT, --count`` The count parameter. [Default: ``1``] .. _cyclopts-complex-cli-secret-feature: secret-feature -------------- :: complex-cli secret-feature [ARGS] Secret feature command. This command has a hidden parameter. **Parameters:** ``ENABLE, --enable, --no-enable`` Enable the secret feature. [Default: ``False``] TestRstSnapshots.test_hidden_commands_rst.rst000066400000000000000000000021641517576204000367510ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: complex-cli ----------- :: complex-cli COMMAND [OPTIONS] Complex CLI application for comprehensive documentation testing. .. contents:: Table of Contents :local: :depth: 6 **Global Options:** ``--verbose, -v`` Verbosity level (-v, -vv, -vvv). [Default: ``0``] ``--quiet, -q, --no-quiet`` Suppress non-essential output. [Default: ``False``] ``--log-level`` Logging level. [Choices: ``debug``, ``info``, ``warning``, ``error``, ``critical``, Default: ``info``] ``--no-color, --no-no-color`` Disable colored output [Default: ``False``] **Subcommands:** ``admin`` Administrative commands for system management. ``data`` Data processing commands. ``server`` Server management commands. **Utilities:** ``cache`` Cache management commands. ``complex-types`` Demonstrate complex type annotations. ``google-style`` Command with Google-style docstring. ``info`` Show application information. ``numpy-style`` Command with NumPy-style docstring. ``sphinx-style`` Command with Sphinx-style docstring. ``version`` Show version information. TestRstSnapshots.test_nested_permissions_rst.rst000066400000000000000000000076261517576204000375620ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: complex-cli ----------- :: complex-cli COMMAND [OPTIONS] Complex CLI application for comprehensive documentation testing. .. contents:: Table of Contents :local: :depth: 6 **Global Options:** ``--verbose, -v`` Verbosity level (-v, -vv, -vvv). [Default: ``0``] ``--quiet, -q, --no-quiet`` Suppress non-essential output. [Default: ``False``] ``--log-level`` Logging level. [Choices: ``debug``, ``info``, ``warning``, ``error``, ``critical``, Default: ``info``] ``--no-color, --no-no-color`` Disable colored output [Default: ``False``] **Subcommands:** ``admin`` Administrative commands for system management. .. _cyclopts-complex-cli-admin: admin ^^^^^ Administrative commands for system management. **Commands:** ``users`` User management commands. .. _cyclopts-complex-cli-admin-users: users """"" User management commands. **Commands:** ``permissions`` Permission management for users. .. _cyclopts-complex-cli-admin-users-permissions: permissions ''''''''''' Permission management for users. **Commands:** ``audit`` Audit permission changes. ``grant`` Grant permissions to a user. ``revoke`` Revoke permissions from a user. ``roles`` Role template management. .. _cyclopts-complex-cli-admin-users-permissions-grant: grant ~~~~~ :: complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION Grant permissions to a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to grant. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] **Parameters:** ``--resource`` Specific resource to grant access to. ``--expires`` Expiration date (ISO format). .. _cyclopts-complex-cli-admin-users-permissions-revoke: revoke ~~~~~~ :: complex-cli admin users permissions revoke USERNAME PERMISSION Revoke permissions from a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to revoke. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] .. _cyclopts-complex-cli-admin-users-permissions-audit: audit ~~~~~ :: complex-cli admin users permissions audit [ARGS] Audit permission changes. **Parameters:** ``USERNAME, --username`` Filter by username (all users if not specified). ``DAYS, --days`` Number of days to look back. [Default: ``30``] ``FORMAT, --format`` Output format for audit report. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-permissions-roles: roles ~~~~~ Role template management. **Commands:** ``create-role`` Create a new role template. ``list-roles`` List all role templates. .. _cyclopts-complex-cli-admin-users-permissions-roles-list-roles: list-roles ~~~~~~~~~~ :: complex-cli admin users permissions roles list-roles [ARGS] List all role templates. **Parameters:** ``INCLUDE-SYSTEM, --include-system, --no-include-system`` Include built-in system roles. [Default: ``False``] .. _cyclopts-complex-cli-admin-users-permissions-roles-create-role: create-role ~~~~~~~~~~~ :: complex-cli admin users permissions roles create-role [OPTIONS] NAME Create a new role template. **Arguments:** ``NAME`` Role name. [**Required**] **Parameters:** ``--permissions.none, --permissions.no-none`` Default permissions for this role. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Default permissions for this role. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Default permissions for this role. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Default permissions for this role. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Default permissions for this role. [Default: ``False``] ``--description`` Role description. [Default: ``""``] TestRstSnapshots.test_server_commands_rst.rst000066400000000000000000000047311517576204000370260ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: complex-cli ----------- :: complex-cli COMMAND [OPTIONS] Complex CLI application for comprehensive documentation testing. .. contents:: Table of Contents :local: :depth: 6 **Global Options:** ``--verbose, -v`` Verbosity level (-v, -vv, -vvv). [Default: ``0``] ``--quiet, -q, --no-quiet`` Suppress non-essential output. [Default: ``False``] ``--log-level`` Logging level. [Choices: ``debug``, ``info``, ``warning``, ``error``, ``critical``, Default: ``info``] ``--no-color, --no-no-color`` Disable colored output [Default: ``False``] **Subcommands:** ``server`` Server management commands. .. _cyclopts-complex-cli-server: server ^^^^^^ Server management commands. **Commands:** ``restart`` Restart the server. ``start`` Start the server with configuration. ``stop`` Stop the server. .. _cyclopts-complex-cli-server-start: start """"" :: complex-cli server start [OPTIONS] Start the server with configuration. Demonstrates Pydantic model support for CLI parameters. **Parameters:** ``--server.host`` Server bind address. [Default: ``0.0.0.0``] ``--server.port`` Server port number. [Default: ``8000``] ``--server.workers`` Number of worker processes. [Default: ``4``] ``--server.timeout`` Request timeout in seconds. [Default: ``30.0``] ``--server.debug, --server.no-debug`` Enable debug mode. [Default: ``False``] ``--auth.provider`` Authentication provider type. [Choices: ``oauth2``, ``jwt``, ``basic``, ``none``, Default: ``jwt``] ``--auth.token-expiry`` Token expiration time in seconds. [Default: ``3600``] ``--auth.refresh-enabled, --auth.no-refresh-enabled`` Enable token refresh. [Default: ``True``] ``--auth.allowed-origins, --auth.empty-allowed-origins`` List of allowed CORS origins. .. _cyclopts-complex-cli-server-stop: stop """" :: complex-cli server stop [OPTIONS] Stop the server. **Parameters:** ``--graceful, --no-graceful`` Perform graceful shutdown. [Default: ``True``] ``--timeout`` Shutdown timeout in seconds. [Default: ``30``] ``--force, -f, --no-force`` Force immediate shutdown. [Default: ``False``] .. _cyclopts-complex-cli-server-restart: restart """"""" :: complex-cli server restart [ARGS] Restart the server. **Parameters:** ``ROLLING, --rolling, --no-rolling`` Perform rolling restart (zero downtime). [Default: ``False``] ``DELAY, --delay`` Delay between worker restarts in seconds. [Default: ``5``] TestSphinxDirectiveSnapshots.test_filtered_directive_rst.rst000066400000000000000000000124511517576204000420310ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: Complex CLI application for comprehensive documentation testing. .. _cyclopts-complex-cli-admin: Administrative commands for system management. **Commands:** ``users`` User management commands. .. _cyclopts-complex-cli-admin-users: users ^^^^^ User management commands. **Commands:** ``create`` Create a new user. ``delete`` Delete a user. ``list-users`` List all users. ``permissions`` Permission management for users. .. _cyclopts-complex-cli-admin-users-list-users: list-users """""""""" :: complex-cli admin users list-users [ARGS] List all users. **Parameters:** ``ACTIVE-ONLY, --active-only, --no-active-only`` Show only active users. [Default: ``False``] ``ROLE, --role`` Filter by user role. [Choices: ``admin``, ``user``, ``guest``] ``LIMIT, --limit`` Maximum number of users to display. [Default: ``100``] ``FORMAT, --format`` Output format. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-create: create """""" :: complex-cli admin users create [OPTIONS] USERNAME EMAIL Create a new user. **Arguments:** ``USERNAME`` Unique username for the new user. [**Required**] ``EMAIL`` Email address for the new user. [**Required**] **Parameters:** ``--role`` User role assignment. [Choices: ``admin``, ``user``, ``guest``, Default: ``user``] ``--permissions.none, --permissions.no-none`` Initial permission flags. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Initial permission flags. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Initial permission flags. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Initial permission flags. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Initial permission flags. [Default: ``False``] ``--send-welcome, --no-send-welcome`` Send welcome email after creation. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-delete: delete """""" :: complex-cli admin users delete [OPTIONS] USERNAME Delete a user. **Arguments:** ``USERNAME`` Username to delete. [**Required**] **Parameters:** ``--force, -f, --no-force`` Skip confirmation prompt. [Default: ``False``] ``--backup, --no-backup`` Create backup before deletion. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-permissions: permissions """"""""""" Permission management for users. **Commands:** ``audit`` Audit permission changes. ``grant`` Grant permissions to a user. ``revoke`` Revoke permissions from a user. ``roles`` Role template management. .. _cyclopts-complex-cli-admin-users-permissions-grant: grant ''''' :: complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION Grant permissions to a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to grant. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] **Parameters:** ``--resource`` Specific resource to grant access to. ``--expires`` Expiration date (ISO format). .. _cyclopts-complex-cli-admin-users-permissions-revoke: revoke '''''' :: complex-cli admin users permissions revoke USERNAME PERMISSION Revoke permissions from a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to revoke. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] .. _cyclopts-complex-cli-admin-users-permissions-audit: audit ''''' :: complex-cli admin users permissions audit [ARGS] Audit permission changes. **Parameters:** ``USERNAME, --username`` Filter by username (all users if not specified). ``DAYS, --days`` Number of days to look back. [Default: ``30``] ``FORMAT, --format`` Output format for audit report. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-permissions-roles: roles ''''' Role template management. **Commands:** ``create-role`` Create a new role template. ``list-roles`` List all role templates. .. _cyclopts-complex-cli-admin-users-permissions-roles-list-roles: list-roles ~~~~~~~~~~ :: complex-cli admin users permissions roles list-roles [ARGS] List all role templates. **Parameters:** ``INCLUDE-SYSTEM, --include-system, --no-include-system`` Include built-in system roles. [Default: ``False``] .. _cyclopts-complex-cli-admin-users-permissions-roles-create-role: create-role ~~~~~~~~~~~ :: complex-cli admin users permissions roles create-role [OPTIONS] NAME Create a new role template. **Arguments:** ``NAME`` Role name. [**Required**] **Parameters:** ``--permissions.none, --permissions.no-none`` Default permissions for this role. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Default permissions for this role. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Default permissions for this role. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Default permissions for this role. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Default permissions for this role. [Default: ``False``] ``--description`` Role description. [Default: ``""``]TestSphinxDirectiveSnapshots.test_flattened_commands_rst.rst000066400000000000000000000121631517576204000420240ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: Complex CLI application for comprehensive documentation testing. .. _cyclopts-complex-cli-admin: Administrative commands for system management. **Commands:** ``users`` User management commands. .. _cyclopts-complex-cli-admin-users: User management commands. **Commands:** ``create`` Create a new user. ``delete`` Delete a user. ``list-users`` List all users. ``permissions`` Permission management for users. .. _cyclopts-complex-cli-admin-users-list-users: :: complex-cli admin users list-users [ARGS] List all users. **Parameters:** ``ACTIVE-ONLY, --active-only, --no-active-only`` Show only active users. [Default: ``False``] ``ROLE, --role`` Filter by user role. [Choices: ``admin``, ``user``, ``guest``] ``LIMIT, --limit`` Maximum number of users to display. [Default: ``100``] ``FORMAT, --format`` Output format. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-create: :: complex-cli admin users create [OPTIONS] USERNAME EMAIL Create a new user. **Arguments:** ``USERNAME`` Unique username for the new user. [**Required**] ``EMAIL`` Email address for the new user. [**Required**] **Parameters:** ``--role`` User role assignment. [Choices: ``admin``, ``user``, ``guest``, Default: ``user``] ``--permissions.none, --permissions.no-none`` Initial permission flags. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Initial permission flags. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Initial permission flags. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Initial permission flags. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Initial permission flags. [Default: ``False``] ``--send-welcome, --no-send-welcome`` Send welcome email after creation. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-delete: :: complex-cli admin users delete [OPTIONS] USERNAME Delete a user. **Arguments:** ``USERNAME`` Username to delete. [**Required**] **Parameters:** ``--force, -f, --no-force`` Skip confirmation prompt. [Default: ``False``] ``--backup, --no-backup`` Create backup before deletion. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-permissions: Permission management for users. **Commands:** ``audit`` Audit permission changes. ``grant`` Grant permissions to a user. ``revoke`` Revoke permissions from a user. ``roles`` Role template management. .. _cyclopts-complex-cli-admin-users-permissions-grant: :: complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION Grant permissions to a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to grant. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] **Parameters:** ``--resource`` Specific resource to grant access to. ``--expires`` Expiration date (ISO format). .. _cyclopts-complex-cli-admin-users-permissions-revoke: :: complex-cli admin users permissions revoke USERNAME PERMISSION Revoke permissions from a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to revoke. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] .. _cyclopts-complex-cli-admin-users-permissions-audit: :: complex-cli admin users permissions audit [ARGS] Audit permission changes. **Parameters:** ``USERNAME, --username`` Filter by username (all users if not specified). ``DAYS, --days`` Number of days to look back. [Default: ``30``] ``FORMAT, --format`` Output format for audit report. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-permissions-roles: Role template management. **Commands:** ``create-role`` Create a new role template. ``list-roles`` List all role templates. .. _cyclopts-complex-cli-admin-users-permissions-roles-list-roles: :: complex-cli admin users permissions roles list-roles [ARGS] List all role templates. **Parameters:** ``INCLUDE-SYSTEM, --include-system, --no-include-system`` Include built-in system roles. [Default: ``False``] .. _cyclopts-complex-cli-admin-users-permissions-roles-create-role: :: complex-cli admin users permissions roles create-role [OPTIONS] NAME Create a new role template. **Arguments:** ``NAME`` Role name. [**Required**] **Parameters:** ``--permissions.none, --permissions.no-none`` Default permissions for this role. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Default permissions for this role. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Default permissions for this role. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Default permissions for this role. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Default permissions for this role. [Default: ``False``] ``--description`` Role description. [Default: ``""``]TestSphinxDirectiveSnapshots.test_nested_commands_rst.rst000066400000000000000000000057501517576204000413440ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: Complex CLI application for comprehensive documentation testing. .. _cyclopts-complex-cli-admin: admin ^^^^^ Administrative commands for system management. **Commands:** ``users`` User management commands. .. _cyclopts-complex-cli-admin-users: users """"" User management commands. **Commands:** ``permissions`` Permission management for users. .. _cyclopts-complex-cli-admin-users-permissions: permissions ''''''''''' Permission management for users. **Commands:** ``audit`` Audit permission changes. ``grant`` Grant permissions to a user. ``revoke`` Revoke permissions from a user. ``roles`` Role template management. .. _cyclopts-complex-cli-admin-users-permissions-grant: grant ~~~~~ Grant permissions to a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to grant. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] **Parameters:** ``--resource`` Specific resource to grant access to. ``--expires`` Expiration date (ISO format). .. _cyclopts-complex-cli-admin-users-permissions-revoke: revoke ~~~~~~ Revoke permissions from a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to revoke. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] .. _cyclopts-complex-cli-admin-users-permissions-audit: audit ~~~~~ Audit permission changes. **Parameters:** ``USERNAME, --username`` Filter by username (all users if not specified). ``DAYS, --days`` Number of days to look back. [Default: ``30``] ``FORMAT, --format`` Output format for audit report. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-permissions-roles: roles ~~~~~ Role template management. **Commands:** ``create-role`` Create a new role template. ``list-roles`` List all role templates. .. _cyclopts-complex-cli-admin-users-permissions-roles-list-roles: list-roles ~~~~~~~~~~ List all role templates. **Parameters:** ``INCLUDE-SYSTEM, --include-system, --no-include-system`` Include built-in system roles. [Default: ``False``] .. _cyclopts-complex-cli-admin-users-permissions-roles-create-role: create-role ~~~~~~~~~~~ Create a new role template. **Arguments:** ``NAME`` Role name. [**Required**] **Parameters:** ``--permissions.none, --permissions.no-none`` Default permissions for this role. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Default permissions for this role. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Default permissions for this role. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Default permissions for this role. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Default permissions for this role. [Default: ``False``] ``--description`` Role description. [Default: ``""``]TestSphinxDirectiveSnapshots.test_simple_directive_rst.rst000066400000000000000000000370201517576204000415230ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/__snapshots__/test_docs_snapshots.. _cyclopts-complex-cli: Complex CLI application for comprehensive documentation testing. .. _cyclopts-complex-cli-version: :: complex-cli version Show version information. Displays the application version and system information. .. _cyclopts-complex-cli-info: :: complex-cli info [ARGS] Show application information. **Parameters:** ``DETAILED, --detailed, --no-detailed`` Show detailed information including dependencies. [Default: ``False``] ``FORMAT, --format`` Output format for the information. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin: Administrative commands for system management. **Commands:** ``config-cmd`` Configure database settings. ``status`` Show system status. ``users`` User management commands. .. _cyclopts-complex-cli-admin-status: status ^^^^^^ :: complex-cli admin status [OPTIONS] [ARGS] Show system status. **Parameters:** ``SERVICES, --services`` Specific services to check (all if not specified). ``--watch, -w`` Continuously watch status. [Default: ``False``] ``--interval`` Refresh interval in seconds when watching. [Default: ``5``] .. _cyclopts-complex-cli-admin-config-cmd: config-cmd ^^^^^^^^^^ :: complex-cli admin config-cmd [OPTIONS] Configure database settings. **Parameters:** ``--host`` Database server hostname. [Default: ``localhost``] ``--port`` Database server port number. [Default: ``5432``] ``--username`` Authentication username. [Default: ``admin``] ``--password`` Authentication password (optional). ``--ssl-mode`` SSL connection mode. [Choices: ``disable``, ``prefer``, ``require``, ``verify-full``, Default: ``prefer``] ``--pool-size`` Connection pool size. [Default: ``10``] .. _cyclopts-complex-cli-admin-users: users ^^^^^ User management commands. **Commands:** ``create`` Create a new user. ``delete`` Delete a user. ``list-users`` List all users. ``permissions`` Permission management for users. .. _cyclopts-complex-cli-admin-users-list-users: list-users """""""""" :: complex-cli admin users list-users [ARGS] List all users. **Parameters:** ``ACTIVE-ONLY, --active-only, --no-active-only`` Show only active users. [Default: ``False``] ``ROLE, --role`` Filter by user role. [Choices: ``admin``, ``user``, ``guest``] ``LIMIT, --limit`` Maximum number of users to display. [Default: ``100``] ``FORMAT, --format`` Output format. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-create: create """""" :: complex-cli admin users create [OPTIONS] USERNAME EMAIL Create a new user. **Arguments:** ``USERNAME`` Unique username for the new user. [**Required**] ``EMAIL`` Email address for the new user. [**Required**] **Parameters:** ``--role`` User role assignment. [Choices: ``admin``, ``user``, ``guest``, Default: ``user``] ``--permissions.none, --permissions.no-none`` Initial permission flags. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Initial permission flags. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Initial permission flags. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Initial permission flags. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Initial permission flags. [Default: ``False``] ``--send-welcome, --no-send-welcome`` Send welcome email after creation. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-delete: delete """""" :: complex-cli admin users delete [OPTIONS] USERNAME Delete a user. **Arguments:** ``USERNAME`` Username to delete. [**Required**] **Parameters:** ``--force, -f, --no-force`` Skip confirmation prompt. [Default: ``False``] ``--backup, --no-backup`` Create backup before deletion. [Default: ``True``] .. _cyclopts-complex-cli-admin-users-permissions: permissions """"""""""" Permission management for users. **Commands:** ``audit`` Audit permission changes. ``grant`` Grant permissions to a user. ``revoke`` Revoke permissions from a user. ``roles`` Role template management. .. _cyclopts-complex-cli-admin-users-permissions-grant: grant ''''' :: complex-cli admin users permissions grant [OPTIONS] USERNAME PERMISSION Grant permissions to a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to grant. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] **Parameters:** ``--resource`` Specific resource to grant access to. ``--expires`` Expiration date (ISO format). .. _cyclopts-complex-cli-admin-users-permissions-revoke: revoke '''''' :: complex-cli admin users permissions revoke USERNAME PERMISSION Revoke permissions from a user. **Arguments:** ``USERNAME`` Target username. [**Required**] ``PERMISSION`` Permission flags to revoke. [**Required**, Choices: ``none``, ``read``, ``write``, ``execute``, ``admin``] .. _cyclopts-complex-cli-admin-users-permissions-audit: audit ''''' :: complex-cli admin users permissions audit [ARGS] Audit permission changes. **Parameters:** ``USERNAME, --username`` Filter by username (all users if not specified). ``DAYS, --days`` Number of days to look back. [Default: ``30``] ``FORMAT, --format`` Output format for audit report. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-admin-users-permissions-roles: roles ''''' Role template management. **Commands:** ``create-role`` Create a new role template. ``list-roles`` List all role templates. .. _cyclopts-complex-cli-admin-users-permissions-roles-list-roles: list-roles ~~~~~~~~~~ :: complex-cli admin users permissions roles list-roles [ARGS] List all role templates. **Parameters:** ``INCLUDE-SYSTEM, --include-system, --no-include-system`` Include built-in system roles. [Default: ``False``] .. _cyclopts-complex-cli-admin-users-permissions-roles-create-role: create-role ~~~~~~~~~~~ :: complex-cli admin users permissions roles create-role [OPTIONS] NAME Create a new role template. **Arguments:** ``NAME`` Role name. [**Required**] **Parameters:** ``--permissions.none, --permissions.no-none`` Default permissions for this role. [Default: ``False``] ``--permissions.read, --permissions.no-read`` Default permissions for this role. [Default: ``False``] ``--permissions.write, --permissions.no-write`` Default permissions for this role. [Default: ``False``] ``--permissions.execute, --permissions.no-execute`` Default permissions for this role. [Default: ``False``] ``--permissions.admin, --permissions.no-admin`` Default permissions for this role. [Default: ``False``] ``--description`` Role description. [Default: ``""``] .. _cyclopts-complex-cli-data: Data processing commands. **Commands:** ``pipeline`` Run a complete data pipeline. ``process`` Process data files with configurable options. ``validate`` Validate data files against schema. .. _cyclopts-complex-cli-data-process: process ^^^^^^^ :: complex-cli data process [OPTIONS] INPUT_FILES Process data files with configurable options. This command demonstrates dataclass parameter flattening where all fields from ProcessingConfig and PathConfig become CLI options. **Arguments:** ``INPUT_FILES`` Input files to process [**Required**] **Parameters:** ``--batch-size`` Number of items to process per batch. [Default: ``32``] ``--num-workers`` Number of parallel workers. Use "auto" for automatic detection. [Choices: ``auto``, Default: ``auto``] ``--quality-level`` Processing quality level. Higher values mean better quality but slower. [Choices: ``high``, ``medium``, ``low``, Default: ``high``] ``--device`` Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. [Choices: ``cuda``, ``cpu``, ``auto``, Default: ``auto``] ``--output-formats, --empty-output-formats`` List of output formats to generate. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``[json]``] ``--input-dir`` Input data directory. [Default: ``data/input``] ``--output-dir`` Output results directory. [Default: ``data/output``] ``--cache-dir`` Cache directory for intermediate files. ``--log-dir`` Directory for log files. [Default: ``logs``] .. _cyclopts-complex-cli-data-pipeline: pipeline ^^^^^^^^ :: complex-cli data pipeline [OPTIONS] Run a complete data pipeline. Demonstrates nested dataclass flattening (PipelineConfig contains PathConfig and ProcessingConfig). **Parameters:** ``--name`` Pipeline name for identification. [Default: ``default-pipeline``] ``--input-dir`` Input data directory. [Default: ``data/input``] ``--output-dir`` Output results directory. [Default: ``data/output``] ``--cache-dir`` Cache directory for intermediate files. ``--log-dir`` Directory for log files. [Default: ``logs``] ``--batch-size`` Number of items to process per batch. [Default: ``32``] ``--num-workers`` Number of parallel workers. Use "auto" for automatic detection. [Choices: ``auto``, Default: ``auto``] ``--quality-level`` Processing quality level. Higher values mean better quality but slower. [Choices: ``high``, ``medium``, ``low``, Default: ``high``] ``--device`` Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. [Choices: ``cuda``, ``cpu``, ``auto``, Default: ``auto``] ``--output-formats, --empty-output-formats`` List of output formats to generate. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``[json]``] ``--dry-run, --no-dry-run`` If True, simulate execution without making changes. [Default: ``False``] .. _cyclopts-complex-cli-data-validate: validate ^^^^^^^^ :: complex-cli data validate [OPTIONS] INPUT_PATH Validate data files against schema. **Arguments:** ``INPUT_PATH`` Path to validate. [**Required**] **Parameters:** ``--strict, --no-strict`` Enable strict validation mode. [Default: ``False``] ``--schema-file`` Custom schema file (must exist). ``--ignore-patterns, --empty-ignore-patterns`` Patterns to ignore during validation. .. _cyclopts-complex-cli-server: Server management commands. **Commands:** ``restart`` Restart the server. ``start`` Start the server with configuration. ``stop`` Stop the server. .. _cyclopts-complex-cli-server-start: start ^^^^^ :: complex-cli server start [OPTIONS] Start the server with configuration. Demonstrates Pydantic model support for CLI parameters. **Parameters:** ``--server.host`` Server bind address. [Default: ``0.0.0.0``] ``--server.port`` Server port number. [Default: ``8000``] ``--server.workers`` Number of worker processes. [Default: ``4``] ``--server.timeout`` Request timeout in seconds. [Default: ``30.0``] ``--server.debug, --server.no-debug`` Enable debug mode. [Default: ``False``] ``--auth.provider`` Authentication provider type. [Choices: ``oauth2``, ``jwt``, ``basic``, ``none``, Default: ``jwt``] ``--auth.token-expiry`` Token expiration time in seconds. [Default: ``3600``] ``--auth.refresh-enabled, --auth.no-refresh-enabled`` Enable token refresh. [Default: ``True``] ``--auth.allowed-origins, --auth.empty-allowed-origins`` List of allowed CORS origins. .. _cyclopts-complex-cli-server-stop: stop ^^^^ :: complex-cli server stop [OPTIONS] Stop the server. **Parameters:** ``--graceful, --no-graceful`` Perform graceful shutdown. [Default: ``True``] ``--timeout`` Shutdown timeout in seconds. [Default: ``30``] ``--force, -f, --no-force`` Force immediate shutdown. [Default: ``False``] .. _cyclopts-complex-cli-server-restart: restart ^^^^^^^ :: complex-cli server restart [ARGS] Restart the server. **Parameters:** ``ROLLING, --rolling, --no-rolling`` Perform rolling restart (zero downtime). [Default: ``False``] ``DELAY, --delay`` Delay between worker restarts in seconds. [Default: ``5``] .. _cyclopts-complex-cli-cache: Cache management commands. **Commands:** ``clear`` Clear cache entries. ``configure`` Configure cache settings. ``stats`` Show cache statistics. .. _cyclopts-complex-cli-cache-configure: configure ^^^^^^^^^ :: complex-cli cache configure [OPTIONS] Configure cache settings. Demonstrates attrs class support for CLI parameters. **Parameters:** ``--config.backend`` Cache backend type. [Choices: ``memory``, ``redis``, ``memcached``, ``disk``, Default: ``memory``] ``--config.ttl`` Time-to-live in seconds. [Default: ``300``] ``--config.max-size`` Maximum cache size in MB. [Default: ``1024``] ``--config.compression, --config.no-compression`` Enable compression. [Default: ``False``] .. _cyclopts-complex-cli-cache-clear: clear ^^^^^ :: complex-cli cache clear [ARGS] Clear cache entries. **Parameters:** ``PATTERN, --pattern`` Pattern to match cache keys. [Default: ``*``] ``DRY-RUN, --dry-run, --no-dry-run`` Show what would be cleared without actually clearing. [Default: ``False``] .. _cyclopts-complex-cli-cache-stats: stats ^^^^^ :: complex-cli cache stats [ARGS] Show cache statistics. **Parameters:** ``DETAILED, --detailed, --no-detailed`` Show detailed statistics. [Default: ``False``] ``FORMAT, --format`` Output format. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``table``] .. _cyclopts-complex-cli-complex-types: :: complex-cli complex-types [ARGS] Demonstrate complex type annotations. This command showcases various complex type patterns that the documentation system needs to handle correctly. **Parameters:** ``WORKER-COUNT, --worker-count`` Number of workers or "auto" for automatic detection. [Choices: ``auto``, Default: ``auto``] ``QUALITY, --quality`` Quality preset or "custom" for manual configuration. [Choices: ``low``, ``medium``, ``high``, ``custom``, Default: ``medium``] ``TAGS, --tags, --empty-tags`` Optional list of tags. ``FORMATS, --formats, --empty-formats`` List of output formats. [Choices: ``json``, ``yaml``, ``table``, ``csv``, Default: ``[json]``] ``THRESHOLDS, --thresholds, --empty-thresholds`` Threshold values or "default" for defaults. [Choices: ``default``, Default: ``default``] ``CONFIG-PATH, --config-path`` Optional config file path (must exist if provided). .. _cyclopts-complex-cli-numpy-style: :: complex-cli numpy-style NAME [ARGS] Command with NumPy-style docstring. This command demonstrates NumPy docstring format which is the default for cyclopts. **Parameters:** ``NAME, --name`` The name parameter. [**Required**] ``COUNT, --count`` The count parameter, by default 1. [Default: ``1``] .. _cyclopts-complex-cli-google-style: :: complex-cli google-style NAME [ARGS] Command with Google-style docstring. This command demonstrates Google docstring format. **Parameters:** ``NAME, --name`` The name parameter. [**Required**] ``COUNT, --count`` The count parameter. Defaults to 1. [Default: ``1``] .. _cyclopts-complex-cli-sphinx-style: :: complex-cli sphinx-style NAME [ARGS] Command with Sphinx-style docstring. This command demonstrates Sphinx/reST docstring format. **Parameters:** ``NAME, --name`` The name parameter. [**Required**] ``COUNT, --count`` The count parameter. [Default: ``1``] .. _cyclopts-complex-cli-secret-feature: :: complex-cli secret-feature [ARGS] Secret feature command. This command has a hidden parameter. **Parameters:** ``ENABLE, --enable, --no-enable`` Enable the secret feature. [Default: ``False``]BrianPugh-cyclopts-921b1fa/tests/apps/000077500000000000000000000000001517576204000177525ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/apps/README.md000066400000000000000000000002631517576204000212320ustar00rootroot00000000000000# Test Apps Each file in this folder contains a (more or less) complete application aimed at testing cyclopts in "real life" situations. Essentially, these are integration tests. BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/000077500000000000000000000000001517576204000223435ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/.gitignore000066400000000000000000000002111517576204000243250ustar00rootroot00000000000000# MkDocs build site/ # Sphinx build docs/build/ # Python __pycache__/ *.pyc *.pyo # IDE .idea/ .vscode/ # Environment .venv/ uv.lock BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/README.md000066400000000000000000000075101517576204000236250ustar00rootroot00000000000000# Complex Demo Application A comprehensive test application for cyclopts documentation plugins. This application covers all known edge cases for documentation generation with both MkDocs and Sphinx. ## Features Tested ### Type System - **Dataclass parameter flattening** - `@Parameter(name="*")` with frozen and mutable dataclasses - **Nested dataclasses** - Dataclass containing other dataclasses - **Pydantic models** - BaseModel subclasses (if pydantic installed) - **attrs classes** - @attrs.define classes (if attrs installed) - **Complex union types** - `int | Literal["auto"]`, `int | Literal["high", "medium", "low"]` - **Enums and Flags** - Standard enum.Enum and enum.Flag types - **Optional types** - `Path | None = None` - **List types** - `list[str]`, `list[Path]`, `list[OutputFormat]` ### Command Structure - **4-level nested apps** - `admin → users → permissions → roles` - **Custom groups** - `Group.create_ordered()` for organized help - **Hidden commands** - Commands with `show=False` - **Hidden parameters** - Parameters with `show=False` - **meta.default pattern** - Global option interceptor ### Parameter Features - **Count parameters** - `-v`, `-vv`, `-vvv` - **parse=False parameters** - Config file paths not parsed from CLI - **allow_leading_hyphen** - Token forwarding - **Validators** - `validators.Number()`, `validators.Path()` - **Positional-only** - `/,` separator - **Keyword-only** - `*,` separator - **Aliases** - `-f`/`--force` style aliases ### Docstring Formats - NumPy style (default) - Google style - Sphinx/reST style ## Directory Structure ``` complex-demo/ ├── complex_app.py # Main application with all edge cases ├── mkdocs.yml # MkDocs configuration ├── mkdocs_docs/ # MkDocs documentation source │ ├── index.md │ └── cli/ │ ├── index.md # Full reference with TOC │ ├── admin.md # Nested commands, filtering │ ├── data.md # Dataclass flattening │ ├── server.md # Pydantic models │ ├── utilities.md # Various features │ └── full.md # Flattened + hidden commands ├── docs/source/ # Sphinx documentation source │ ├── conf.py │ ├── index.rst │ └── cli/ │ ├── index.rst │ ├── admin.rst │ ├── data.rst │ ├── server.rst │ ├── utilities.rst │ └── full.rst └── pyproject.toml ``` ## Building Documentation ### MkDocs ```bash cd tests/apps/complex-demo mkdocs build mkdocs serve # For development ``` ### Sphinx ```bash cd tests/apps/complex-demo sphinx-build -b html docs/source docs/build/html ``` ## Running Tests The e2e tests in `tests/test_docs_e2e.py` exercise this demo application: ```bash # Run all e2e documentation tests uv run pytest tests/test_docs_e2e.py -v # Run just MkDocs tests uv run pytest tests/test_docs_e2e.py::TestMkDocsBuild -v # Run just Sphinx tests uv run pytest tests/test_docs_e2e.py::TestSphinxBuild -v ``` ## Comparison with darts-nextgen This demo replicates patterns found in darts-nextgen: | Pattern | darts-nextgen | complex-demo | |---------|---------------|--------------| | `@Parameter(name="*")` on dataclass | ✅ | ✅ | | Frozen dataclasses | ✅ | ✅ | | Nested dataclasses | ✅ | ✅ | | `Group.create_ordered()` | ✅ | ✅ | | `meta.default` interceptor | ✅ | ✅ | | Count parameters (`-v`, `-vv`) | ✅ | ✅ | | `parse=False` parameters | ✅ | ✅ | | 3+ level nesting | ✅ | ✅ (4 levels) | | Complex Literal unions | ✅ | ✅ | | Multiple doc sections with filters | ✅ | ✅ | Additional patterns in complex-demo: - Pydantic model support - attrs class support - enum.Flag types - Multiple docstring format examples - Hidden command/parameter testing BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/complex_app.py000066400000000000000000000576151517576204000252420ustar00rootroot00000000000000"""Complex Demo Application - Comprehensive edge case testing for documentation generation. This application tests all known edge cases for cyclopts documentation plugins: - Dataclass parameter flattening with @Parameter(name="*") - Pydantic model support - attrs class support - 3+ level nested command hierarchies - Complex union types (int | Literal[...]) - Custom groups with Group.create_ordered() - meta.default pattern for global options - Count parameters, parse=False, allow_leading_hyphen - Validators - Hidden commands/parameters - Multiple docstring formats - Enums and Flags """ from __future__ import annotations import sys from dataclasses import dataclass, field from enum import Enum, Flag, auto from pathlib import Path from typing import Annotated, Literal import cyclopts from cyclopts import App, Group, Parameter, validators # ============================================================================ # Enums and Flags # ============================================================================ class LogLevel(Enum): """Log level enumeration.""" DEBUG = "debug" INFO = "info" WARNING = "warning" ERROR = "error" CRITICAL = "critical" class OutputFormat(Enum): """Output format options.""" JSON = "json" YAML = "yaml" TABLE = "table" CSV = "csv" class Permission(Flag): """Permission flags for access control.""" NONE = 0 READ = auto() WRITE = auto() EXECUTE = auto() ADMIN = READ | WRITE | EXECUTE # ============================================================================ # Dataclasses with Parameter(name="*") for flattening # ============================================================================ @cyclopts.Parameter(name="*") @dataclass(frozen=True) class DatabaseConfig: """Database connection configuration. Parameters ---------- host Database server hostname. port Database server port number. username Authentication username. password Authentication password (optional). ssl_mode SSL connection mode. pool_size Connection pool size. """ host: str = "localhost" port: int = 5432 username: str = "admin" password: str | None = None ssl_mode: Literal["disable", "prefer", "require", "verify-full"] = "prefer" pool_size: Annotated[int, Parameter(validator=validators.Number(gte=1, lte=100))] = 10 @cyclopts.Parameter(name="*") @dataclass class ProcessingConfig: """Data processing configuration. Parameters ---------- batch_size Number of items to process per batch. num_workers Number of parallel workers. Use "auto" for automatic detection. quality_level Processing quality level. Higher values mean better quality but slower. device Computing device to use. Can be "cuda", "cpu", "auto", or a GPU index. output_formats List of output formats to generate. """ batch_size: Annotated[int, Parameter(validator=validators.Number(gt=0))] = 32 num_workers: int | Literal["auto"] = "auto" quality_level: int | Literal["high", "medium", "low"] = "high" device: Literal["cuda", "cpu", "auto"] | int = "auto" output_formats: list[OutputFormat] = field(default_factory=lambda: [OutputFormat.JSON]) @cyclopts.Parameter(name="*") @dataclass(frozen=True) class PathConfig: """Path configuration for input/output directories. Parameters ---------- input_dir Input data directory. output_dir Output results directory. cache_dir Cache directory for intermediate files. log_dir Directory for log files. """ input_dir: Path = Path("data/input") output_dir: Path = Path("data/output") cache_dir: Path | None = None log_dir: Path = Path("logs") # Nested dataclass (dataclass containing another dataclass) @cyclopts.Parameter(name="*") @dataclass class PipelineConfig: """Complete pipeline configuration combining multiple configs. Parameters ---------- name Pipeline name for identification. paths Path configuration. processing Processing configuration. dry_run If True, simulate execution without making changes. """ name: str = "default-pipeline" paths: PathConfig = field(default_factory=PathConfig) processing: ProcessingConfig = field(default_factory=ProcessingConfig) dry_run: bool = False # ============================================================================ # Pydantic Models # ============================================================================ from pydantic import BaseModel, Field class ServerConfig(BaseModel): """Server configuration using Pydantic. Parameters ---------- host Server bind address. port Server port number. workers Number of worker processes. timeout Request timeout in seconds. debug Enable debug mode. """ host: str = "0.0.0.0" port: int = Field(default=8000, ge=1, le=65535) workers: int = Field(default=4, ge=1) timeout: float = 30.0 debug: bool = False class AuthConfig(BaseModel): """Authentication configuration. Parameters ---------- provider Authentication provider type. token_expiry Token expiration time in seconds. refresh_enabled Enable token refresh. allowed_origins List of allowed CORS origins. """ provider: Literal["oauth2", "jwt", "basic", "none"] = "jwt" token_expiry: int = 3600 refresh_enabled: bool = True allowed_origins: list[str] = Field(default_factory=lambda: ["*"]) # ============================================================================ # attrs Classes # ============================================================================ import attrs @attrs.define class CacheConfig: """Cache configuration using attrs. Parameters ---------- backend Cache backend type. ttl Time-to-live in seconds. max_size Maximum cache size in MB. compression Enable compression. """ backend: Literal["memory", "redis", "memcached", "disk"] = "memory" ttl: int = 300 max_size: int = 1024 compression: bool = False # ============================================================================ # Main Application with Groups # ============================================================================ # Create ordered groups for better organization # Note: Use create_ordered() consistently for all groups to avoid sort_key type conflicts # create_ordered() generates tuple sort_keys like (sort_key, count) while plain Group() uses raw values global_group = Group.create_ordered("Global Options", sort_key=0) subcommands_group = Group.create_ordered("Subcommands", sort_key=1) utilities_group = Group.create_ordered("Utilities", sort_key=2) hidden_group = Group.create_ordered("Hidden", sort_key=99, show=False) app = App( name="complex-cli", help="Complex CLI application for comprehensive documentation testing.", version="1.0.0", ) app.meta.group_parameters = global_group # ============================================================================ # Meta Default - Global option interceptor # ============================================================================ @app.meta.default def main_launcher( *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], config_file: Annotated[Path | None, Parameter(parse=False, group=global_group)] = None, verbose: Annotated[int, Parameter(name=["-v", "--verbose"], count=True, group=global_group)] = 0, quiet: Annotated[bool, Parameter(name=["-q", "--quiet"], group=global_group)] = False, log_level: Annotated[LogLevel, Parameter(group=global_group)] = LogLevel.INFO, no_color: Annotated[bool, Parameter(group=global_group, help="Disable colored output")] = False, ): """Global option handler that intercepts all commands. This demonstrates the meta.default pattern for handling global options that apply to all subcommands. Parameters ---------- tokens Command tokens to forward. config_file Configuration file path (not parsed from CLI). verbose Verbosity level (-v, -vv, -vvv). quiet Suppress non-essential output. log_level Logging level. no_color Disable colored output. """ # In real usage, this would set up logging, load config, etc. app(tokens) # ============================================================================ # Simple Commands # ============================================================================ @app.command(alias=["ver", "v"], group=utilities_group) def version(): """Show version information. Displays the application version and system information. """ print("complex-cli version 1.0.0") print(f"Python {sys.version}") @app.command(alias="i", group=utilities_group) def info( detailed: bool = False, format: OutputFormat = OutputFormat.TABLE, ): """Show application information. Parameters ---------- detailed Show detailed information including dependencies. format Output format for the information. """ print(f"Info: detailed={detailed}, format={format.value}") @app.command(group=hidden_group, show=False) def debug_internal(): """Internal debug command (hidden from help). This command is for internal debugging purposes only. """ print("Debug internal command executed") # ============================================================================ # Level 1: Admin App # ============================================================================ admin_app = App( name="admin", help="Administrative commands for system management.", group=subcommands_group, default_parameter=Parameter(negative=""), ) app.command(admin_app) @admin_app.command def status( services: list[str] | None = None, *, watch: Annotated[bool, Parameter(name=["-w", "--watch"])] = False, interval: Annotated[int, Parameter(validator=validators.Number(gte=1))] = 5, ): """Show system status. Parameters ---------- services Specific services to check (all if not specified). watch Continuously watch status. interval Refresh interval in seconds when watching. """ print(f"Status: services={services}, watch={watch}, interval={interval}") @admin_app.command def config_cmd( *, db: DatabaseConfig = DatabaseConfig(), # noqa: B008 ): """Configure database settings. Parameters ---------- db Database configuration options. """ print(f"Config: db={db}") # ============================================================================ # Level 2: Users App (nested under admin) # ============================================================================ users_app = App( name="users", help="User management commands.", ) admin_app.command(users_app) @users_app.command def list_users( active_only: bool = False, role: Literal["admin", "user", "guest"] | None = None, limit: Annotated[int, Parameter(validator=validators.Number(gte=1, lte=1000))] = 100, format: OutputFormat = OutputFormat.TABLE, ): """List all users. Parameters ---------- active_only Show only active users. role Filter by user role. limit Maximum number of users to display. format Output format. """ print(f"List users: active_only={active_only}, role={role}, limit={limit}") @users_app.command def create( username: str, email: str, /, *, role: Literal["admin", "user", "guest"] = "user", permissions: Permission = Permission.READ, send_welcome: bool = True, ): """Create a new user. Parameters ---------- username Unique username for the new user. email Email address for the new user. role User role assignment. permissions Initial permission flags. send_welcome Send welcome email after creation. """ print(f"Create user: {username}, {email}, role={role}, permissions={permissions}") @users_app.command def delete( username: str, /, *, force: Annotated[bool, Parameter(name=["-f", "--force"])] = False, backup: bool = True, ): """Delete a user. Parameters ---------- username Username to delete. force Skip confirmation prompt. backup Create backup before deletion. """ print(f"Delete user: {username}, force={force}, backup={backup}") # ============================================================================ # Level 3: Permissions App (nested under users) # ============================================================================ permissions_app = App( name="permissions", help="Permission management for users.", ) users_app.command(permissions_app) @permissions_app.command def grant( username: str, permission: Permission, /, *, resource: str | None = None, expires: str | None = None, ): """Grant permissions to a user. Parameters ---------- username Target username. permission Permission flags to grant. resource Specific resource to grant access to. expires Expiration date (ISO format). """ print(f"Grant: {username}, {permission}, resource={resource}") @permissions_app.command def revoke( username: str, permission: Permission, /, ): """Revoke permissions from a user. Parameters ---------- username Target username. permission Permission flags to revoke. """ print(f"Revoke: {username}, {permission}") @permissions_app.command def audit( username: str | None = None, days: int = 30, format: OutputFormat = OutputFormat.TABLE, ): """Audit permission changes. Parameters ---------- username Filter by username (all users if not specified). days Number of days to look back. format Output format for audit report. """ print(f"Audit: username={username}, days={days}, format={format}") # ============================================================================ # Level 4: Roles App (nested under permissions) - 4 levels deep! # ============================================================================ roles_app = App( name="roles", help="Role template management.", ) permissions_app.command(roles_app) @roles_app.command def list_roles( include_system: bool = False, ): """List all role templates. Parameters ---------- include_system Include built-in system roles. """ print(f"List roles: include_system={include_system}") @roles_app.command def create_role( name: str, /, *, permissions: Permission = Permission.READ, description: str = "", ): """Create a new role template. Parameters ---------- name Role name. permissions Default permissions for this role. description Role description. """ print(f"Create role: {name}, permissions={permissions}") # ============================================================================ # Data Processing App with Dataclass Flattening # ============================================================================ data_app = App( name="data", help="Data processing commands.", group=subcommands_group, ) app.command(data_app) @data_app.command def process( input_files: Annotated[list[Path], Parameter(help="Input files to process")], /, *, config: ProcessingConfig = ProcessingConfig(), # noqa: B008 paths: PathConfig = PathConfig(), # noqa: B008 ): """Process data files with configurable options. This command demonstrates dataclass parameter flattening where all fields from ProcessingConfig and PathConfig become CLI options. Parameters ---------- input_files List of input files to process. config Processing configuration. paths Path configuration. """ print(f"Process: files={input_files}, config={config}, paths={paths}") @data_app.command def pipeline( *, config: PipelineConfig = PipelineConfig(), # noqa: B008 ): """Run a complete data pipeline. Demonstrates nested dataclass flattening (PipelineConfig contains PathConfig and ProcessingConfig). Parameters ---------- config Complete pipeline configuration. """ print(f"Pipeline: config={config}") @data_app.command def validate( input_path: Path, /, *, strict: bool = False, schema_file: Annotated[Path | None, Parameter(validator=validators.Path(exists=True))] = None, ignore_patterns: list[str] | None = None, ): """Validate data files against schema. Parameters ---------- input_path Path to validate. strict Enable strict validation mode. schema_file Custom schema file (must exist). ignore_patterns Patterns to ignore during validation. """ print(f"Validate: {input_path}, strict={strict}") # ============================================================================ # Server App # ============================================================================ server_app = App( name="server", help="Server management commands.", group=subcommands_group, ) app.command(server_app) @server_app.command def start( *, server: ServerConfig = ServerConfig(), # noqa: B008 auth: AuthConfig = AuthConfig(), # noqa: B008 ): """Start the server with configuration. Demonstrates Pydantic model support for CLI parameters. Parameters ---------- server Server configuration. auth Authentication configuration. """ print(f"Start server: {server}, auth={auth}") @server_app.command def stop( *, graceful: bool = True, timeout: Annotated[int, Parameter(validator=validators.Number(gte=0))] = 30, force: Annotated[bool, Parameter(name=["-f", "--force"])] = False, ): """Stop the server. Parameters ---------- graceful Perform graceful shutdown. timeout Shutdown timeout in seconds. force Force immediate shutdown. """ print(f"Stop server: graceful={graceful}, timeout={timeout}, force={force}") @server_app.command def restart( rolling: bool = False, delay: int = 5, ): """Restart the server. Parameters ---------- rolling Perform rolling restart (zero downtime). delay Delay between worker restarts in seconds. """ print(f"Restart server: rolling={rolling}, delay={delay}") # ============================================================================ # Cache App # ============================================================================ cache_app = App( name="cache", help="Cache management commands.", group=utilities_group, ) app.command(cache_app) @cache_app.command def configure( *, config: CacheConfig = CacheConfig(), # noqa: B008 ): """Configure cache settings. Demonstrates attrs class support for CLI parameters. Parameters ---------- config Cache configuration. """ print(f"Configure cache: {config}") @cache_app.command def clear( pattern: str = "*", dry_run: bool = False, ): """Clear cache entries. Parameters ---------- pattern Pattern to match cache keys. dry_run Show what would be cleared without actually clearing. """ print(f"Clear cache: pattern={pattern}, dry_run={dry_run}") @cache_app.command def stats( detailed: bool = False, format: OutputFormat = OutputFormat.TABLE, ): """Show cache statistics. Parameters ---------- detailed Show detailed statistics. format Output format. """ print(f"Cache stats: detailed={detailed}, format={format}") # ============================================================================ # Complex Type Examples # ============================================================================ @app.command(group=utilities_group) def complex_types( # Union with Literal and int worker_count: int | Literal["auto"] = "auto", # Union with multiple Literals quality: Literal["low", "medium", "high"] | Literal["custom"] = "medium", # Optional list tags: list[str] | None = None, # List of enums formats: list[OutputFormat] = [OutputFormat.JSON], # noqa: B006 # Complex nested type thresholds: list[float] | Literal["default"] = "default", # Path that may or may not exist config_path: Annotated[Path | None, Parameter(validator=validators.Path(exists=True))] = None, ): """Demonstrate complex type annotations. This command showcases various complex type patterns that the documentation system needs to handle correctly. Parameters ---------- worker_count Number of workers or "auto" for automatic detection. quality Quality preset or "custom" for manual configuration. tags Optional list of tags. formats List of output formats. thresholds Threshold values or "default" for defaults. config_path Optional config file path (must exist if provided). """ print(f"Complex types: worker_count={worker_count}, quality={quality}") # ============================================================================ # Commands with Various Docstring Styles # ============================================================================ @app.command(group=utilities_group) def numpy_style( name: str, count: int = 1, ): """Command with NumPy-style docstring. This command demonstrates NumPy docstring format which is the default for cyclopts. Parameters ---------- name : str The name parameter. count : int, optional The count parameter, by default 1. Returns ------- None This function doesn't return anything. Raises ------ ValueError If name is empty. Examples -------- >>> numpy_style("test", count=5) """ print(f"NumPy style: name={name}, count={count}") @app.command(group=utilities_group) def google_style( name: str, count: int = 1, ): """Command with Google-style docstring. This command demonstrates Google docstring format. Args: name: The name parameter. count: The count parameter. Defaults to 1. Returns ------- None: This function doesn't return anything. Raises ------ ValueError: If name is empty. Example: >>> google_style("test", count=5) """ print(f"Google style: name={name}, count={count}") @app.command(group=utilities_group) def sphinx_style( name: str, count: int = 1, ): """Command with Sphinx-style docstring. This command demonstrates Sphinx/reST docstring format. :param name: The name parameter. :type name: str :param count: The count parameter. :type count: int :returns: None :rtype: None :raises ValueError: If name is empty. """ print(f"Sphinx style: name={name}, count={count}") # ============================================================================ # Hidden and Show=False Examples # ============================================================================ @app.command(show=False) def internal_maintenance(): """Internal maintenance command. This command is hidden from the main help but can still be invoked. """ print("Running internal maintenance...") @app.command(group=hidden_group) def secret_feature( enable: bool = False, code: Annotated[str, Parameter(show=False)] = "default", ): """Secret feature command. This command has a hidden parameter. Parameters ---------- enable Enable the secret feature. code Secret activation code (hidden from help). """ print(f"Secret feature: enable={enable}, code={code}") if __name__ == "__main__": app() BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/000077500000000000000000000000001517576204000232735ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/source/000077500000000000000000000000001517576204000245735ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/source/cli/000077500000000000000000000000001517576204000253425ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/source/cli/admin.rst000066400000000000000000000016341517576204000271700ustar00rootroot00000000000000Admin Commands ============== Administrative commands for system management. This page demonstrates: * **Nested command documentation** (4 levels deep) * **Command filtering** using the ``:commands:`` option * **Dataclass parameter flattening** in the ``config`` command admin ----- .. cyclopts:: complex_app:app :heading-level: 3 :recursive: :commands: admin User Management Deep Dive ------------------------- The user management system supports 4 levels of command nesting: 1. ``admin`` - Top-level administrative commands 2. ``admin users`` - User management 3. ``admin users permissions`` - Permission management 4. ``admin users permissions roles`` - Role templates Just the Permissions Commands ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ This section shows only the permissions subcommand and its children: .. cyclopts:: complex_app:app :heading-level: 4 :recursive: :commands: admin.users.permissions BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/source/cli/data.rst000066400000000000000000000021421517576204000270040ustar00rootroot00000000000000Data Processing Commands ======================== Data processing commands that demonstrate **dataclass parameter flattening**. When using ``@Parameter(name="*")`` on a dataclass, all its fields become individual CLI options. This page shows how this appears in documentation. Data Commands ------------- .. cyclopts:: complex_app:app :heading-level: 3 :recursive: :commands: data Understanding Dataclass Flattening ---------------------------------- The ``process`` command accepts two dataclass parameters: * ``ProcessingConfig`` - Controls batch size, workers, quality, device, etc. * ``PathConfig`` - Controls input/output directories Instead of requiring complex nested syntax, these become flat CLI options:: $ complex-cli data process file1.txt file2.txt \ --batch-size 64 \ --num-workers auto \ --quality-level high \ --input-dir ./input \ --output-dir ./output Nested Dataclasses ------------------ The ``pipeline`` command demonstrates **nested dataclass flattening** where ``PipelineConfig`` contains both ``PathConfig`` and ``ProcessingConfig``. BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/source/cli/full.rst000066400000000000000000000006251517576204000270410ustar00rootroot00000000000000Full CLI Reference ================== Complete reference for all commands with flattened heading structure. This page demonstrates: * ``:flatten-commands:`` - All commands at same heading level * ``:include-hidden:`` - Include hidden commands * Full recursive documentation Commands -------- .. cyclopts:: complex_app:app :heading-level: 3 :recursive: :flatten-commands: :include-hidden: BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/source/cli/index.rst000066400000000000000000000003071517576204000272030ustar00rootroot00000000000000CLI Reference Overview ====================== This page provides an overview of all available commands. All Commands ------------ .. cyclopts:: complex_app:app :heading-level: 3 :recursive: BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/source/cli/server.rst000066400000000000000000000013231517576204000274010ustar00rootroot00000000000000Server Commands =============== Server management commands demonstrating **Pydantic model support**. .. note:: If Pydantic is not installed, the server commands will use simpler parameter definitions as a fallback. Server Management ----------------- .. cyclopts:: complex_app:app :heading-level: 3 :recursive: :commands: server Pydantic Integration -------------------- When Pydantic is available, the ``start`` command accepts two Pydantic models: * ``ServerConfig`` - Server bind address, port, workers, timeout * ``AuthConfig`` - Authentication provider, token settings, CORS origins Pydantic's field validators are respected, providing automatic validation of port numbers, worker counts, etc. BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/source/cli/utilities.rst000066400000000000000000000016761517576204000301210ustar00rootroot00000000000000Utility Commands ================ Various utility commands demonstrating different features. Cache Management ---------------- .. cyclopts:: complex_app:app :heading-level: 3 :recursive: :commands: cache Complex Type Examples --------------------- The ``complex-types`` command demonstrates various advanced type annotations: .. cyclopts:: complex_app:app :heading-level: 3 :commands: complex-types Docstring Format Examples ------------------------- These commands demonstrate different docstring formats: NumPy Style ~~~~~~~~~~~ .. cyclopts:: complex_app:app :heading-level: 4 :commands: numpy-style Google Style ~~~~~~~~~~~~ .. cyclopts:: complex_app:app :heading-level: 4 :commands: google-style Sphinx Style ~~~~~~~~~~~~ .. cyclopts:: complex_app:app :heading-level: 4 :commands: sphinx-style Other Utilities --------------- .. cyclopts:: complex_app:app :heading-level: 3 :commands: version, info BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/source/conf.py000066400000000000000000000010561517576204000260740ustar00rootroot00000000000000"""Sphinx configuration for Complex CLI documentation.""" import sys from pathlib import Path # Add the complex-demo directory to sys.path so we can import complex_app sys.path.insert(0, str(Path(__file__).parent.parent.parent)) project = "Complex CLI" copyright = "2024, Cyclopts" author = "Cyclopts" extensions = [ "cyclopts.ext.sphinx", "sphinx.ext.autodoc", ] templates_path = ["_templates"] exclude_patterns = [] html_theme = "alabaster" html_static_path = ["_static"] # Suppress warnings about missing static paths html_static_path = [] BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/docs/source/index.rst000066400000000000000000000017171517576204000264420ustar00rootroot00000000000000Complex CLI Documentation ========================= Welcome to the Complex CLI documentation. This application demonstrates comprehensive coverage of cyclopts features for documentation testing. Features -------- This demo includes: * **Dataclass parameter flattening** - Using ``@Parameter(name="*")`` * **Pydantic model support** - If pydantic is installed * **attrs class support** - If attrs is installed * **4-level nested commands** - ``admin → users → permissions → roles`` * **Complex union types** - ``int | Literal["auto"]`` patterns * **Custom groups** - Organized command structure * **Validators** - Number and Path validators * **Hidden commands** - Commands excluded from help * **Multiple docstring formats** - NumPy, Google, Sphinx styles .. toctree:: :maxdepth: 2 :caption: Contents: cli/index cli/admin cli/data cli/server cli/utilities cli/full Indices and tables ================== * :ref:`genindex` * :ref:`search` BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/mkdocs.yml000066400000000000000000000012031517576204000243420ustar00rootroot00000000000000site_name: Complex CLI Documentation site_description: Comprehensive test documentation for cyclopts MkDocs plugin. theme: name: mkdocs palette: primary: blue accent: blue plugins: - search - cyclopts: default_heading_level: 2 nav: - Home: index.md - CLI Reference: - Overview: cli/index.md - Admin Commands: cli/admin.md - Data Commands: cli/data.md - Server Commands: cli/server.md - Utilities: cli/utilities.md - Full Reference: cli/full.md docs_dir: mkdocs_docs markdown_extensions: - admonition - pymdownx.details - pymdownx.superfences - attr_list - md_in_html - tables BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/mkdocs_docs/000077500000000000000000000000001517576204000246335ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/mkdocs_docs/cli/000077500000000000000000000000001517576204000254025ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/mkdocs_docs/cli/admin.md000066400000000000000000000016411517576204000270160ustar00rootroot00000000000000# Admin Commands Administrative commands for system management. This page demonstrates: - **Nested command documentation** (4 levels deep) - **Command filtering** using the `commands` option - **Dataclass parameter flattening** in the `config` command ## admin ::: cyclopts module: complex_app:app heading_level: 3 recursive: true commands: [admin] generate_toc: false ## User Management Deep Dive The user management system supports 4 levels of command nesting: 1. `admin` - Top-level administrative commands 2. `admin users` - User management 3. `admin users permissions` - Permission management 4. `admin users permissions roles` - Role templates ### Just the Permissions Commands This section shows only the permissions subcommand and its children: ::: cyclopts module: complex_app:app heading_level: 4 recursive: true commands: [admin.users.permissions] generate_toc: false BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/mkdocs_docs/cli/data.md000066400000000000000000000020401517576204000266310ustar00rootroot00000000000000# Data Processing Commands Data processing commands that demonstrate **dataclass parameter flattening**. When using `@Parameter(name="*")` on a dataclass, all its fields become individual CLI options. This page shows how this appears in documentation. ## Data Commands ::: cyclopts module: complex_app:app heading_level: 3 recursive: true commands: [data] generate_toc: false ## Understanding Dataclass Flattening The `process` command accepts two dataclass parameters: - `ProcessingConfig` - Controls batch size, workers, quality, device, etc. - `PathConfig` - Controls input/output directories Instead of requiring complex nested syntax, these become flat CLI options: ```console $ complex-cli data process file1.txt file2.txt \ --batch-size 64 \ --num-workers auto \ --quality-level high \ --input-dir ./input \ --output-dir ./output ``` ## Nested Dataclasses The `pipeline` command demonstrates **nested dataclass flattening** where `PipelineConfig` contains both `PathConfig` and `ProcessingConfig`. BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/mkdocs_docs/cli/full.md000066400000000000000000000006631517576204000266730ustar00rootroot00000000000000# Full CLI Reference Complete reference for all commands with flattened heading structure. This page demonstrates: - `flatten_commands: true` - All commands at same heading level - `include_hidden: true` - Include hidden commands - Full recursive documentation ## Commands ::: cyclopts module: complex_app:app heading_level: 3 recursive: true flatten_commands: true include_hidden: true generate_toc: true BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/mkdocs_docs/cli/index.md000066400000000000000000000003621517576204000270340ustar00rootroot00000000000000# CLI Reference Overview This page provides an overview of all available commands with a generated table of contents. ## All Commands ::: cyclopts module: complex_app:app heading_level: 3 recursive: true generate_toc: true BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/mkdocs_docs/cli/server.md000066400000000000000000000013101517576204000272250ustar00rootroot00000000000000# Server Commands Server management commands demonstrating **Pydantic model support**. !!! note If Pydantic is not installed, the server commands will use simpler parameter definitions as a fallback. ## Server Management ::: cyclopts module: complex_app:app heading_level: 3 recursive: true commands: [server] generate_toc: false ## Pydantic Integration When Pydantic is available, the `start` command accepts two Pydantic models: - `ServerConfig` - Server bind address, port, workers, timeout - `AuthConfig` - Authentication provider, token settings, CORS origins Pydantic's field validators are respected, providing automatic validation of port numbers, worker counts, etc. BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/mkdocs_docs/cli/utilities.md000066400000000000000000000020611517576204000277360ustar00rootroot00000000000000# Utility Commands Various utility commands demonstrating different features. ## Cache Management ::: cyclopts module: complex_app:app heading_level: 3 recursive: true commands: [cache] generate_toc: false ## Complex Type Examples The `complex-types` command demonstrates various advanced type annotations: ::: cyclopts module: complex_app:app heading_level: 3 commands: [complex-types] generate_toc: false ## Docstring Format Examples These commands demonstrate different docstring formats: ### NumPy Style ::: cyclopts module: complex_app:app heading_level: 4 commands: [numpy-style] generate_toc: false ### Google Style ::: cyclopts module: complex_app:app heading_level: 4 commands: [google-style] generate_toc: false ### Sphinx Style ::: cyclopts module: complex_app:app heading_level: 4 commands: [sphinx-style] generate_toc: false ## Other Utilities ::: cyclopts module: complex_app:app heading_level: 3 commands: [version, info] generate_toc: false BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/mkdocs_docs/index.md000066400000000000000000000021011517576204000262560ustar00rootroot00000000000000# Complex CLI Documentation Welcome to the Complex CLI documentation. This application demonstrates comprehensive coverage of cyclopts features for documentation testing. ## Features This demo includes: - **Dataclass parameter flattening** - Using `@Parameter(name="*")` - **Pydantic model support** - If pydantic is installed - **attrs class support** - If attrs is installed - **4-level nested commands** - `admin → users → permissions → roles` - **Complex union types** - `int | Literal["auto"]` patterns - **Custom groups** - Organized command structure - **Validators** - Number and Path validators - **Hidden commands** - Commands excluded from help - **Multiple docstring formats** - NumPy, Google, Sphinx styles ## Quick Start ```console $ complex-cli --help ``` ## Navigation - [CLI Reference](cli/index.md) - Complete command documentation - [Admin Commands](cli/admin.md) - Administrative operations - [Data Commands](cli/data.md) - Data processing commands - [Server Commands](cli/server.md) - Server management - [Utilities](cli/utilities.md) - Utility commands BrianPugh-cyclopts-921b1fa/tests/apps/complex-demo/pyproject.toml000066400000000000000000000006471517576204000252660ustar00rootroot00000000000000[project] name = "complex-demo" version = "1.0.0" description = "Complex demo application for cyclopts documentation testing" requires-python = ">=3.10" dependencies = [ "cyclopts", ] [project.optional-dependencies] docs = [ "mkdocs", "sphinx", ] pydantic = [ "pydantic>=2.0", ] attrs = [ "attrs", ] all = [ "complex-demo[docs,pydantic,attrs]", ] [project.scripts] complex-cli = "complex_app:app" BrianPugh-cyclopts-921b1fa/tests/apps/config.toml000066400000000000000000000001011517576204000221040ustar00rootroot00000000000000[create.burger] mayo=false custom=["sweet-chili", "house-sauce"] BrianPugh-cyclopts-921b1fa/tests/apps/cyclopts-demo/000077500000000000000000000000001517576204000225345ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/apps/cyclopts-demo/.gitignore000066400000000000000000000000071517576204000245210ustar00rootroot00000000000000/site/ BrianPugh-cyclopts-921b1fa/tests/apps/cyclopts-demo/README.md000066400000000000000000000013061517576204000240130ustar00rootroot00000000000000# Cyclopts Demo Demo CLI application for testing and demonstrating Cyclopts features. ## Purpose This app is designed for: - Manual testing of shell completion - Testing edge cases that are hard to unit test - Demonstrating Cyclopts features in documentation - Serving as a reference implementation ## Quick Start ### Run the CLI ```bash cd tests/apps/cyclopts-demo # Show help python cyclopts_demo.py --help # Try commands python cyclopts_demo.py files ls --help python cyclopts_demo.py database migrate --help ``` ### Build and View MkDocs Documentation ```bash # Build the documentation uv run mkdocs build # Serve the documentation locally uv run mkdocs serve # Opens http://127.0.0.1:8000 ``` BrianPugh-cyclopts-921b1fa/tests/apps/cyclopts-demo/cyclopts_demo.py000077500000000000000000000353411517576204000257630ustar00rootroot00000000000000#!/usr/bin/env python """Cyclopts Demo Application - Testing completion features.""" from pathlib import Path from typing import Annotated, Literal from cyclopts import App, Parameter, validators app = App( name="cyclopts-demo", help="Demo application for testing Cyclopts completion features.", ) app.register_install_completion_command() @app.default def main( verbose: bool = False, config: Path | None = None, ): """Main command. Parameters ---------- verbose : bool Enable verbose output. config : Path, optional Configuration file path. """ print(f"Main command: verbose={verbose}, config={config}") files_app = App(name="files", help="File operations commands.") app.command(files_app) @files_app.command def cp( source: Path, destination: Path, /, *, recursive: Annotated[bool, Parameter(help="Copy directories recursively")] = False, preserve: Annotated[bool, Parameter(help="Preserve file attributes")] = False, verbose: bool = False, ): """Copy files or directories. Parameters ---------- source : Path Source file or directory. destination : Path Destination file or directory. recursive : bool Copy directories recursively. preserve : bool Preserve file attributes (timestamps, permissions). verbose : bool Show verbose output. """ print(f"Copy: {source} -> {destination} (recursive={recursive}, preserve={preserve}, verbose={verbose})") @files_app.command def mv( source: Path, destination: Path, /, *, force: Annotated[bool, Parameter(help="Force overwrite if destination exists")] = False, backup: Annotated[bool, Parameter(help="Create backup of existing destination")] = False, verbose: bool = False, ): """Move files or directories. Parameters ---------- source : Path Source file or directory. destination : Path Destination file or directory. force : bool Force overwrite if destination exists. backup : bool Create backup of existing destination files. verbose : bool Show verbose output. """ print(f"Move: {source} -> {destination} (force={force}, backup={backup}, verbose={verbose})") @files_app.command def ls( path: Path = Path(), /, *, all: Annotated[bool, Parameter(help="Show hidden files")] = False, long: Annotated[bool, Parameter(help="Use long listing format")] = False, sort_by: Literal["name", "size", "time", "extension"] = "name", reverse: bool = False, ): """List directory contents. Parameters ---------- path : Path Directory path to list (defaults to current directory). all : bool Show hidden files and directories. long : bool Use long listing format with details. sort_by : Literal["name", "size", "time", "extension"] Sort order. reverse : bool Reverse sort order. """ print(f"List: {path} (all={all}, long={long}, sort_by={sort_by}, reverse={reverse})") @files_app.command def find( pattern: str, /, *, path: Path = Path(), type: Literal["file", "directory", "symlink", "any"] = "any", case_sensitive: bool = True, max_depth: int | None = None, ): """Find files matching a pattern. Parameters ---------- pattern : str Search pattern (supports wildcards). path : Path Root directory to search from. type : Literal["file", "directory", "symlink", "any"] Type of filesystem entry to find. case_sensitive : bool Case-sensitive pattern matching. max_depth : int, optional Maximum search depth (None for unlimited). """ print(f"Find: pattern={pattern}, path={path}, type={type}, case_sensitive={case_sensitive}, max_depth={max_depth}") @app.command def positional_choice(param: Literal["foo", "bar", "baz"], /): """Test positional-only parameter with Literal choices. Parameters ---------- param : Literal["foo", "bar", "baz"] Choose one: foo, bar, or baz. """ print(f"Called with: {param}") @app.command def multi_positional( first: Literal["alpha", "beta", "gamma"], second: Literal["red", "green", "blue"], /, ): """Test multiple positional-only parameters with Literal choices. Parameters ---------- first : Literal["alpha", "beta", "gamma"] First choice: Greek letters. second : Literal["red", "green", "blue"] Second choice: colors. """ print(f"Called with: first={first}, second={second}") @app.command def deploy( environment: Literal["dev", "staging", "production"], region: Literal["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"] = "us-east-1", dry_run: bool = False, skip_tests: bool = False, confirm: bool = True, ): """Deploy application. Parameters ---------- environment : Literal["dev", "staging", "production"] Target environment. region : Literal["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"] AWS region. dry_run : bool Perform a dry run without making changes. skip_tests : bool Skip running tests. confirm : bool Require confirmation before deploying. """ print(f"Deploy: env={environment}, region={region}, dry_run={dry_run}, skip_tests={skip_tests}, confirm={confirm}") @app.command def process( items: list[str], count: Annotated[int, Parameter(validator=validators.Number(gt=0, lte=100))] = 1, threshold: float = 0.5, tags: list[str] | None = None, exclude: list[str] | None = None, ): """Process items. Parameters ---------- items : list[str] Items to process. count : int Number of iterations (1-100). threshold : float Processing threshold. tags : list[str], optional Tags to apply. exclude : list[str] Items to exclude. """ if exclude is None: exclude = [] print(f"Process: items={items}, count={count}, threshold={threshold}, tags={tags}, exclude={exclude}") @app.command def concat( files: Annotated[list[Path], Parameter(help="Files to concatenate")], output: Annotated[Path | None, Parameter(help="Output file (defaults to stdout)")] = None, add_separators: bool = False, ): """Concatenate multiple files. This command demonstrates list[Path] completion (issue #654). Try tab-completing after --files to see file completion work! Parameters ---------- files : list[Path] Files to concatenate together. output : Path, optional Output file path. If not specified, prints to stdout. add_separators : bool Add separator lines between files. """ print(f"Concat: files={files}, output={output}, add_separators={add_separators}") @app.command def image_convert( input_file: Annotated[Path, Parameter(help="Input image file")], output_file: Annotated[Path, Parameter(help="Output image file")], # Image Settings Group width: Annotated[int | None, Parameter(group="Image Settings", help="Output width in pixels")] = None, height: Annotated[int | None, Parameter(group="Image Settings", help="Output height in pixels")] = None, quality: Annotated[int, Parameter(group="Image Settings", help="Output quality (1-100)")] = 90, # Processing Options Group grayscale: Annotated[bool, Parameter(group="Processing Options", help="Convert to grayscale")] = False, rotate: Annotated[int, Parameter(group="Processing Options", help="Rotation angle (degrees)")] = 0, flip: Annotated[ Literal["none", "horizontal", "vertical", "both"], Parameter(group="Processing Options", help="Flip direction"), ] = "none", # Format Options Group format: Annotated[ Literal["auto", "jpeg", "png", "webp"], Parameter(group="Format Options", help="Output format"), ] = "auto", progressive: Annotated[bool, Parameter(group="Format Options", help="Use progressive encoding")] = False, ): """Convert and transform images. This command demonstrates parameter grouping for better organization of related options. Parameters ---------- input_file : Path Path to the input image file. output_file : Path Path where the converted image will be saved. width : int, optional Desired output width in pixels. Maintains aspect ratio if height not specified. height : int, optional Desired output height in pixels. Maintains aspect ratio if width not specified. quality : int JPEG/WebP quality level from 1 (lowest) to 100 (highest). grayscale : bool Convert the image to grayscale. rotate : int Rotation angle in degrees (clockwise). flip : Literal["none", "horizontal", "vertical", "both"] Flip the image horizontally, vertically, both, or none. format : Literal["auto", "jpeg", "png", "webp"] Output format. Auto-detects from file extension if set to 'auto'. progressive : bool Use progressive/interlaced encoding for web optimization. """ print( f"Image Convert: {input_file} -> {output_file}, " f"size={width}x{height}, quality={quality}, " f"grayscale={grayscale}, rotate={rotate}, flip={flip}, " f"format={format}, progressive={progressive}" ) database_app = App(name="database", help="Database commands.") app.command(database_app) @database_app.command def connect( host: str = "localhost", port: int = 5432, username: str = "admin", password: str | None = None, ssl: bool = True, ): """Connect to database. Parameters ---------- host : str Database host. port : int Database port. username : str Username. password : str, optional Password. ssl : bool Use SSL connection. """ print(f"DB Connect: host={host}, port={port}, user={username}, ssl={ssl}") @database_app.command def migrate( direction: Literal["up", "down"] = "up", steps: int = 1, target: str | None = None, force: bool = False, ): """Run database migrations. Parameters ---------- direction : Literal["up", "down"] Migration direction. steps : int Number of migration steps. target : str, optional Target migration version. force : bool Force migration even with warnings. """ print(f"DB Migrate: direction={direction}, steps={steps}, target={target}, force={force}") @database_app.command def backup( output: Path, tables: list[str] | None = None, compress: bool = True, encryption: Literal["none", "aes256", "rsa"] = "none", ): """Backup database. Parameters ---------- output : Path Backup output file. tables : list[str], optional Specific tables to backup. compress : bool Compress backup. encryption : Literal["none", "aes256", "rsa"] Encryption method. """ print(f"DB Backup: output={output}, tables={tables}, compress={compress}, encryption={encryption}") server_app = App(name="server", help="Server management commands.") app.command(server_app) @server_app.command def start( port: int = 8000, host: str = "0.0.0.0", workers: int = 4, reload: bool = False, log_level: Literal["debug", "info", "warning", "error", "critical"] = "info", ): """Start the server. Parameters ---------- port : int Server port. host : str Server host. workers : int Number of worker processes. reload : bool Enable auto-reload. log_level : Literal["debug", "info", "warning", "error", "critical"] Logging level. """ print(f"Server Start: host={host}, port={port}, workers={workers}, reload={reload}, log_level={log_level}") @server_app.command def stop( graceful: bool = True, timeout: int = 30, ): """Stop the server. Parameters ---------- graceful : bool Graceful shutdown. timeout : int Shutdown timeout in seconds. """ print(f"Server Stop: graceful={graceful}, timeout={timeout}") @server_app.command def status( detailed: bool = False, format: Literal["text", "json", "table"] = "text", ): """Show server status. Parameters ---------- detailed : bool Show detailed status. format : Literal["text", "json", "table"] Output format. """ print(f"Server Status: detailed={detailed}, format={format}") # Register with colon in name - this is the key test case for issue #715 @app.command(name="utility:ping") def utility_ping_action( host: Annotated[str, Parameter(help="Host to ping")] = "localhost", port: Annotated[int, Parameter(help="Port number")] = 8080, timeout: Annotated[int, Parameter(help="Timeout in seconds")] = 5, count: Annotated[int, Parameter(help="Number of pings")] = 3, ): """Ping a utility service to check connectivity. Parameters ---------- host : str Hostname or IP address of the service. port : int Port number to connect to. timeout : int Connection timeout in seconds. count : int Number of ping attempts. """ print(f"Pinging utility at {host}:{port} (timeout={timeout}s, count={count})") @app.command(name="utility:status") def utility_status_action( foo: Literal["beep", "boop"], # for testing if positional completion still works. /, service: Annotated[str, Parameter(help="Service name")] = "all", verbose: Annotated[bool, Parameter(help="Show detailed status")] = False, format: Annotated[Literal["text", "json", "yaml"], Parameter(help="Output format")] = "text", ): """Check the status of utility services. Parameters ---------- service : str Name of the service to check, or 'all' for all services. verbose : bool Show detailed status information. format : Literal["text", "json", "yaml"] Output format for the status report. """ print(f"Utility status: service={service}, verbose={verbose}, format={format}") @app.command def dump_markdown(): """Generate markdown documentation and save to intermediate.md. This is a debug command to inspect the intermediate markdown output that gets generated for the cyclopts-demo app. """ from cyclopts.docs.markdown import generate_markdown_docs # Generate markdown for this app markdown = generate_markdown_docs( app, recursive=True, heading_level=1, generate_toc=True, ) # Write to intermediate.md in current directory with Path("intermediate.md").open("w") as f: f.write(markdown) print("✓ Markdown documentation written to intermediate.md") print(f" ({len(markdown)} characters, {len(markdown.splitlines())} lines)") if __name__ == "__main__": app() BrianPugh-cyclopts-921b1fa/tests/apps/cyclopts-demo/docs/000077500000000000000000000000001517576204000234645ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/apps/cyclopts-demo/docs/index.md000066400000000000000000000004461517576204000251210ustar00rootroot00000000000000# Cyclopts Demo Application Welcome to the Cyclopts Demo application! ## CLI Reference The following documentation is automatically generated from the Cyclopts demo application: ::: cyclopts module: cyclopts_demo:app heading_level: 3 recursive: true usage_name: foo bar baz BrianPugh-cyclopts-921b1fa/tests/apps/cyclopts-demo/mkdocs.yml000066400000000000000000000007031517576204000245370ustar00rootroot00000000000000site_name: Cyclopts Demo site_description: Cyclopts Demo Description. theme: name: mkdocs palette: primary: indigo accent: indigo features: - navigation.sections - navigation.expand - content.code.copy plugins: - search - cyclopts nav: - Home: index.md markdown_extensions: - pymdownx.highlight: anchor_linenums: true - pymdownx.superfences - admonition - pymdownx.details - attr_list - md_in_html BrianPugh-cyclopts-921b1fa/tests/apps/cyclopts-demo/pyproject.toml000066400000000000000000000007741517576204000254600ustar00rootroot00000000000000[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "cyclopts-demo" version = "0.1.0" description = "Demo application for testing Cyclopts completion features" authors = [{name = "Test", email = "test@example.com"}] requires-python = ">=3.10" dependencies = [ "cyclopts", ] [project.scripts] cyclopts-demo = "cyclopts_demo:app" [tool.hatch.build.targets.wheel] packages = ["cyclopts_demo.py"] [tool.uv.sources] cyclopts = { path = "../../..", editable = true } BrianPugh-cyclopts-921b1fa/tests/apps/draw.py000066400000000000000000000065631517576204000212730ustar00rootroot00000000000000from dataclasses import KW_ONLY, dataclass # pyright: ignore[reportAttributeAccessIssue] from pathlib import Path from typing import Annotated, Literal, NamedTuple import cyclopts from cyclopts import App, Parameter from cyclopts.types import UInt8 toml_path = Path("draw.toml") toml_data = """\ [tool.draw] units = "meters" no_exit_on_error = false [tool.draw.line] units = "feet" """ toml_path.write_text(toml_data) app = App( help="Demo drawing app.", config=( cyclopts.config.Toml(toml_path, root_keys=("tool", "draw")), cyclopts.config.Toml(toml_path, root_keys=("tool", "draw"), use_commands_as_keys=False), ), ) class Coordinate(NamedTuple): x: float "X coordinate." y: float "Y coordinate." @Parameter(name="*") @dataclass class Config: _: KW_ONLY units: Literal["meters", "feet"] = "meters" "Drawing units." color: tuple[UInt8, UInt8, UInt8] = (0x00, 0x00, 0x00) "RGB uint8 triple." @app.command def line( start: Coordinate, end: Coordinate, *, config: Config | None = None, ): """Draw a line. Parameters ---------- start: Coordinate Start of line. end: Coordinate End of line. """ if config is None: config = Config() print(f"Drawing a line with from {start} to {end} {config.units} in {config.color=}.") @app.command def elliptic_curve( start_point: Coordinate, end_point: Coordinate, r1: float, r2: float, *, config: Config | None = None, ): """Draw a elliptical curve.""" @app.command def circle( center: Coordinate, radius: Literal["unit"] | float, *, config: Config | None = None, ): """Draw a circle. Parameters ---------- center: Literal["origin"] | Coordinate Center of the circle to be drawn. center.x: float Circle center's X position. center.y: float Circle center's Y position. radius: float Radius of the circle. """ if config is None: config = Config() if radius == "unit": radius = 1.0 print(f"Drawing a circle with {radius=} {config.units} at {center=}") @app.command def polygon(*vertices: Annotated[Coordinate, Parameter(required=True)], config: Config | None = None): """Draw a polygon. Parameters ---------- vertices: Coordinate List of (x, y) coordinates that make up the polygon. """ if config is None: config = Config() print(f"Drawing a polygon with {vertices=} {config.units} in {config.color=}.") @app.command def polygon2(vertices: list[Coordinate], /, *, config: Config | None = None): """Draw a polygon (alternative implementation). Parameters ---------- vertices: Coordinate List of (x, y) coordinates that make up the polygon. """ if config is None: config = Config() print(f"Drawing a polygon with {vertices=} {config.units} in {config.color=}.") @app.meta.default def meta( *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], no_exit_on_error: Annotated[bool, Parameter(negative=())] = False, ): """ Parameters ---------- exit_on_error: bool Exit on error. """ app(tokens, exit_on_error=not no_exit_on_error, print_error=not no_exit_on_error) toml_path.unlink() if __name__ == "__main__": app.meta(print_error=False, exit_on_error=False) BrianPugh-cyclopts-921b1fa/tests/apps/test_burgery.py000066400000000000000000000124051517576204000230440ustar00rootroot00000000000000from pathlib import Path from textwrap import dedent from typing import Annotated, Literal import cyclopts from cyclopts import App, Parameter, validators config_file = Path(__file__).parent / "config.toml" app = App( name="burgery", help="Welcome to Cyclopts Burgery!", config=cyclopts.config.Toml(config_file), result_action="return_value", # For testing, return actual values ) app.command(create := App(name="create")) @create.command def burger( variety: Literal["classic", "double"], quantity: Annotated[int, Parameter(validator=validators.Number(gt=0))] = 1, /, *, lettuce: Annotated[bool, Parameter(name="--iceberg", group="Toppings")] = True, tomato: Annotated[bool, Parameter(group="Toppings")] = True, onion: Annotated[bool, Parameter(group="Toppings")] = True, mustard: Annotated[bool, Parameter(group="Condiments")] = True, ketchup: Annotated[bool, Parameter(group="Condiments")] = True, mayo: Annotated[bool, Parameter(group="Condiments")] = True, custom: Annotated[list[str] | None, Parameter(group="Condiments")] = None, ): """Create a burger. Parameters ---------- variety: Literal["classic", "double"] Type of burger to create quantity: int lettuce: bool Add lettuce. tomato: bool Add tomato. onion: bool Add onion. mustard: bool Add mustard. ketchup: bool Add ketchup. """ return locals() def test_create_burger_help(console): with console.capture() as capture: app("create burger --help", console=console) actual = capture.get() expected = dedent( """\ Usage: burgery create burger [OPTIONS] VARIETY [ARGS] Create a burger. ╭─ Arguments ────────────────────────────────────────────────────────╮ │ * VARIETY Type of burger to create [choices: classic, double] │ │ [required] │ │ QUANTITY [default: 1] │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Condiments ───────────────────────────────────────────────────────╮ │ --mustard --no-mustard Add mustard. [default: True] │ │ --ketchup --no-ketchup Add ketchup. [default: True] │ │ --mayo --no-mayo [default: True] │ │ --custom --empty-custom │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Toppings ─────────────────────────────────────────────────────────╮ │ --iceberg --no-iceberg Add lettuce. [default: True] │ │ --tomato --no-tomato Add tomato. [default: True] │ │ --onion --no-onion Add onion. [default: True] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_create_burger_1(): """Tests generic functionality. Detailed: * that config-file overrides (mayo, custom) work. * typical boolean flags work. """ actual = app("create burger classic --iceberg --no-onion --no-ketchup --custom sriracha --custom egg") assert actual == { "variety": "classic", "quantity": 1, "lettuce": True, "tomato": True, "onion": False, "ketchup": False, "mustard": True, "mayo": False, # Set from config file. "custom": ["sriracha", "egg"], } def test_create_burger_2(): """Tests that the list from the toml file correctly populates.""" actual = app("create burger classic") assert actual == { "variety": "classic", "quantity": 1, "lettuce": True, "tomato": True, "onion": True, "ketchup": True, "mustard": True, "mayo": False, # Set from config file. "custom": ["sweet-chili", "house-sauce"], # Set from config file. } def test_create_burger_3(): """Tests the --empty- config override.""" actual = app("create burger classic --empty-custom") assert actual == { "variety": "classic", "quantity": 1, "lettuce": True, "tomato": True, "onion": True, "ketchup": True, "mustard": True, "mayo": False, # Set from config file. "custom": [], } if __name__ == "__main__": app() BrianPugh-cyclopts-921b1fa/tests/cli/000077500000000000000000000000001517576204000175565ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/cli/__init__.py000066400000000000000000000000001517576204000216550ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/cli/test_complete.py000066400000000000000000000117111517576204000230000ustar00rootroot00000000000000"""Tests for the hidden '_complete' command for dynamic completion.""" from textwrap import dedent from unittest.mock import patch from cyclopts.cli import app as cyclopts_cli def test_complete_run_subcommand(tmp_path, capsys): """Test completion for 'run' subcommand.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="testapp") @app.command def build(): '''Build the project.''' pass @app.command def deploy(): '''Deploy the app.''' pass """ ) ) with patch("sys.exit"): cyclopts_cli(["_complete", "run", str(script)]) captured = capsys.readouterr() assert "build" in captured.out assert "deploy" in captured.out def test_complete_run_with_options(tmp_path, capsys): """Test completion includes options.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="testapp") @app.default def main(verbose: bool = False, count: int = 1): '''Main command.''' pass """ ) ) with patch("sys.exit"): cyclopts_cli(["_complete", "run", str(script)]) captured = capsys.readouterr() assert "--verbose" in captured.out assert "--count" in captured.out def test_complete_run_invalid_script_silent(tmp_path, capsys): """Test that completion silently fails for invalid scripts.""" nonexistent = tmp_path / "nonexistent.py" with patch("sys.exit"): cyclopts_cli(["_complete", "run", str(nonexistent)]) captured = capsys.readouterr() assert captured.out == "" def test_complete_run_nested_commands(tmp_path, capsys): """Test completion for nested command structure.""" script = tmp_path / "nested.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="testapp") db = App(name="db", help="Database commands") @db.command def migrate(): '''Run migrations.''' pass app.command(db) """ ) ) with patch("sys.exit"): cyclopts_cli(["_complete", "run", str(script)]) captured = capsys.readouterr() assert "db" in captured.out def test_complete_run_with_words(tmp_path, capsys): """Test completion with current command line words.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="testapp") db = App(name="db") @db.command def migrate(): '''Run migrations.''' pass @db.command def backup(): '''Backup database.''' pass app.command(db) """ ) ) with patch("sys.exit"): cyclopts_cli(["_complete", "run", str(script), "db"]) captured = capsys.readouterr() assert "migrate" in captured.out assert "backup" in captured.out def test_complete_non_run_subcommand_silent(tmp_path, capsys): """Test that completion is silent for non-run subcommands.""" script = tmp_path / "app.py" script.write_text("from cyclopts import App\napp = App()") with patch("sys.exit"): cyclopts_cli(["_complete", "generate-docs", str(script)]) captured = capsys.readouterr() assert captured.out == "" def test_complete_run_script_with_syntax_error_silent(tmp_path, capsys): """Test that completion silently handles scripts with syntax errors.""" script = tmp_path / "broken.py" script.write_text("this is not valid python $$$ @@@") with patch("sys.exit"): cyclopts_cli(["_complete", "run", str(script)]) captured = capsys.readouterr() assert captured.out == "" def test_complete_shows_help_for_default_command(tmp_path, capsys): """Test that options from default command are shown.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App from typing import Annotated from cyclopts import Parameter app = App(name="testapp") @app.default def main( verbose: Annotated[bool, Parameter(help="Enable verbose mode")] = False, debug: Annotated[bool, Parameter(help="Enable debug mode")] = False, ): pass """ ) ) with patch("sys.exit"): cyclopts_cli(["_complete", "run", str(script)]) captured = capsys.readouterr() assert "--verbose" in captured.out assert "--debug" in captured.out assert "Enable verbose mode" in captured.out or "verbose mode" in captured.out.lower() BrianPugh-cyclopts-921b1fa/tests/cli/test_generate_docs.py000066400000000000000000000154011517576204000237720ustar00rootroot00000000000000"""Tests for the 'cyclopts generate-docs' command.""" from textwrap import dedent from unittest.mock import patch import pytest from cyclopts.cli import app as cyclopts_cli def test_generate_docs_to_stdout(tmp_path, capsys): """Test generating docs to stdout with explicit format.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="myapp", help="Test application") @app.default def main(name: str = "World"): '''Greet someone. Parameters ---------- name : str Name to greet. ''' pass """ ) ) with patch("sys.exit"): cyclopts_cli(["generate-docs", str(script), "--format", "markdown"]) captured = capsys.readouterr() assert "# myapp" in captured.out assert "Test application" in captured.out def test_generate_docs_to_file(tmp_path, capsys): """Test generating docs to a file.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="myapp", help="Test app") @app.default def main(): '''Main command.''' pass """ ) ) output_file = tmp_path / "docs.md" with patch("sys.exit"): cyclopts_cli(["generate-docs", str(script), "--output", str(output_file)]) assert output_file.exists() content = output_file.read_text() assert "# myapp" in content assert "Test app" in content def test_generate_docs_format_inference_md(tmp_path): """Test format inference from .md extension.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="myapp", help="Test") """ ) ) output_file = tmp_path / "docs.md" with patch("sys.exit"): cyclopts_cli(["generate-docs", str(script), "-o", str(output_file)]) assert output_file.exists() content = output_file.read_text() assert "# myapp" in content def test_generate_docs_explicit_format_overrides_extension(tmp_path): """Test that explicit format overrides file extension.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="myapp", help="Test") """ ) ) output_file = tmp_path / "docs.txt" with patch("sys.exit"): cyclopts_cli(["generate-docs", str(script), "-o", str(output_file), "--format", "markdown"]) assert output_file.exists() content = output_file.read_text() assert "# myapp" in content def test_generate_docs_no_format_no_output_error(capsys): """Test error when neither format nor output is specified.""" with pytest.raises((SystemExit, ValueError)): cyclopts_cli(["generate-docs", "dummy.py"]) def test_generate_docs_invalid_extension_error(tmp_path, capsys): """Test error when output file has invalid extension.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="myapp") """ ) ) output_file = tmp_path / "docs.pdf" with pytest.raises((SystemExit, ValueError)): cyclopts_cli(["generate-docs", str(script), "-o", str(output_file)]) def test_generate_docs_with_include_hidden(tmp_path, capsys): """Test generating docs with hidden commands included.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="myapp") @app.command(show=False) def hidden(): '''Hidden command.''' pass """ ) ) with patch("sys.exit"): cyclopts_cli(["generate-docs", str(script), "--format", "markdown", "--include-hidden"]) captured = capsys.readouterr() assert "hidden" in captured.out.lower() def test_generate_docs_with_heading_level(tmp_path, capsys): """Test custom heading level.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="myapp", help="Test") @app.command def cmd(): '''Command.''' pass """ ) ) with patch("sys.exit"): cyclopts_cli(["generate-docs", str(script), "--format", "markdown", "--heading-level", "2"]) captured = capsys.readouterr() assert "## myapp" in captured.out def test_generate_docs_with_app_notation(tmp_path, capsys): """Test generating docs with :app notation.""" script = tmp_path / "multi.py" script.write_text( dedent( """\ from cyclopts import App app1 = App(name="app1", help="First app") app2 = App(name="app2", help="Second app") """ ) ) with patch("sys.exit"): cyclopts_cli(["generate-docs", f"{script}:app2", "--format", "markdown"]) captured = capsys.readouterr() assert "# app2" in captured.out assert "Second app" in captured.out def test_generate_docs_script_not_found(tmp_path, capsys): """Test error when script not found.""" with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["generate-docs", str(tmp_path / "nonexistent.py"), "--format", "markdown"]) assert exc_info.value.code == 1 captured = capsys.readouterr() assert "not found" in captured.err.lower() def test_generate_docs_cli_usage_name_override(tmp_path, capsys): """--usage-name replaces the app name in Usage: lines of stdout output.""" script = tmp_path / "app.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="cli", help="Test application") @app.default def main(name: str = "World"): '''Greet. Parameters ---------- name : str Name to greet. ''' pass """ ) ) with patch("sys.exit"): cyclopts_cli( [ "generate-docs", str(script), "--format", "markdown", "--usage-name", "uv run cli", ] ) captured = capsys.readouterr() # Heading still uses plain app name assert "# cli" in captured.out # Usage block shows the override assert "uv run cli" in captured.out BrianPugh-cyclopts-921b1fa/tests/cli/test_run.py000066400000000000000000000221001517576204000217660ustar00rootroot00000000000000"""Tests for the 'cyclopts run' command.""" import sys from textwrap import dedent import pytest from cyclopts.cli import app as cyclopts_cli def test_run_simple_script(tmp_path, capsys): """Test running a simple script with default app.""" script = tmp_path / "simple.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="simple") @app.default def main(name: str = "World"): print(f"Hello, {name}!") if __name__ == "__main__": app() """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(script)]) assert exc_info.value.code == 0 captured = capsys.readouterr() assert "Hello, World!" in captured.out def test_run_script_with_args(tmp_path, capsys): """Test running a script with command-line arguments.""" script = tmp_path / "greet.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="greet") @app.default def main(name: str, greeting: str = "Hello"): print(f"{greeting}, {name}!") """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(script), "--name", "Alice", "--greeting", "Hi"]) assert exc_info.value.code == 0 captured = capsys.readouterr() assert "Hi, Alice!" in captured.out def test_run_script_with_positional_args(tmp_path, capsys): """Test running a script with positional arguments.""" script = tmp_path / "echo.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="echo") @app.default def main(message: str): print(message) """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(script), "Hello World"]) assert exc_info.value.code == 0 captured = capsys.readouterr() assert "Hello World" in captured.out def test_run_script_with_app_notation(tmp_path, capsys): """Test running a script with ':app_name' notation.""" script = tmp_path / "multi.py" script.write_text( dedent( """\ from cyclopts import App app1 = App(name="app1") app2 = App(name="app2") @app1.default def main1(): print("App 1") @app2.default def main2(): print("App 2") """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", f"{script}:app2"]) assert exc_info.value.code == 0 captured = capsys.readouterr() assert "App 2" in captured.out def test_run_script_with_subcommands(tmp_path, capsys): """Test running a script with subcommands.""" script = tmp_path / "commands.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="commands") @app.command def build(): print("Building...") @app.command def deploy(): print("Deploying...") """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(script), "build"]) assert exc_info.value.code == 0 captured = capsys.readouterr() assert "Building..." in captured.out def test_run_script_not_found(tmp_path, capsys): """Test error when script file not found.""" nonexistent = tmp_path / "nonexistent.py" with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(nonexistent)]) assert exc_info.value.code == 1 captured = capsys.readouterr() assert "not found" in captured.err.lower() def test_run_not_python_file(tmp_path, capsys): """Test error when file is not a Python file.""" textfile = tmp_path / "notpython.txt" textfile.write_text("This is not Python") with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(textfile)]) assert exc_info.value.code == 1 captured = capsys.readouterr() assert "not a python file" in captured.err.lower() def test_run_no_app_found(tmp_path, capsys): """Test error when no App object found in script.""" script = tmp_path / "noapp.py" script.write_text( dedent( """\ # No App object here def main(): print("No app") """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(script)]) assert exc_info.value.code == 1 captured = capsys.readouterr() assert "no cyclopts app" in captured.err.lower() def test_run_multiple_apps_error(tmp_path, capsys): """Test error when multiple App objects found without specification.""" script = tmp_path / "multiapp.py" script.write_text( dedent( """\ from cyclopts import App app1 = App(name="app1") app2 = App(name="app2") @app1.default def main1(): pass @app2.default def main2(): pass """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(script)]) assert exc_info.value.code == 1 captured = capsys.readouterr() assert "multiple app objects" in captured.err.lower() def test_run_specified_app_not_found(tmp_path, capsys): """Test error when specified app object not found.""" script = tmp_path / "test.py" script.write_text( dedent( """\ from cyclopts import App app = App() """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", f"{script}:nonexistent"]) assert exc_info.value.code == 1 captured = capsys.readouterr() assert "no object named" in captured.err.lower() def test_run_specified_object_not_app(tmp_path, capsys): """Test error when specified object is not an App.""" script = tmp_path / "notapp.py" script.write_text( dedent( """\ from cyclopts import App app = App() some_string = "not an app" """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", f"{script}:some_string"]) assert exc_info.value.code == 1 captured = capsys.readouterr() assert "not a cyclopts app" in captured.err.lower() def test_run_with_variadic_args(tmp_path, capsys): """Test running script with variadic arguments.""" script = tmp_path / "variadic.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="variadic") @app.default def main(*files: str): for f in files: print(f"Processing: {f}") """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(script), "file1.txt", "file2.txt", "file3.txt"]) assert exc_info.value.code == 0 captured = capsys.readouterr() assert "Processing: file1.txt" in captured.out assert "Processing: file2.txt" in captured.out assert "Processing: file3.txt" in captured.out def test_run_with_leading_hyphen(tmp_path, capsys): """Test that script path can start with hyphen.""" script = tmp_path / "-special.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="special") @app.default def main(): print("Special script") """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(script)]) assert exc_info.value.code == 0 captured = capsys.readouterr() assert "Special script" in captured.out def test_run_script_with_import_error(tmp_path, capsys): """Test handling script with import errors.""" script = tmp_path / "broken.py" script.write_text( dedent( """\ import nonexistent_module from cyclopts import App app = App() """ ) ) with pytest.raises((SystemExit, ImportError)): cyclopts_cli(["run", str(script)]) @pytest.mark.skipif(sys.platform == "win32", reason="Windows path handling differs") def test_run_script_with_windows_path_colon(tmp_path, capsys): """Test that Windows paths with drive letters are handled correctly.""" script = tmp_path / "winpath.py" script.write_text( dedent( """\ from cyclopts import App app = App(name="winpath") @app.default def main(): print("Windows path") """ ) ) with pytest.raises(SystemExit) as exc_info: cyclopts_cli(["run", str(script.resolve())]) assert exc_info.value.code == 0 captured = capsys.readouterr() assert "Windows path" in captured.out BrianPugh-cyclopts-921b1fa/tests/completion/000077500000000000000000000000001517576204000211605ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/completion/__init__.py000066400000000000000000000000601517576204000232650ustar00rootroot00000000000000"""Tests for shell completion functionality.""" BrianPugh-cyclopts-921b1fa/tests/completion/apps.py000066400000000000000000000133201517576204000224740ustar00rootroot00000000000000from enum import Enum from pathlib import Path from typing import Annotated, Literal from cyclopts import App, Parameter from cyclopts.types import ExistingFile app_basic = App(name="basic") @app_basic.default def main( verbose: Annotated[bool, Parameter(help="Enable verbose output")] = False, count: Annotated[int, Parameter(help="Number of items")] = 1, ): """Basic app for testing.""" pass @app_basic.command def deploy( env: Annotated[Literal["dev", "staging", "prod"], Parameter(help="Environment")], force: bool = False, ): """Deploy to environment.""" pass class Speed(Enum): FAST = "fast" SLOW = "slow" app_enum = App(name="enumapp") @app_enum.default def enum_main(speed: Speed = Speed.FAST): """App with enum parameter.""" pass app_nested = App(name="nested") config_app = App(name="config") @config_app.command def get(key: str): """Get config value.""" pass @config_app.command def set(key: str, value: str): """Set config value.""" pass app_nested.command(config_app) app_path = App(name="pathapp") @app_path.default def path_main( input_file: Annotated[Path, Parameter(help="Input file")], output: Annotated[Path | None, Parameter(help="Output file")] = None, ): """App with path parameters.""" pass app_negative = App(name="negapp") @app_negative.default def negative_main( verbose: Annotated[bool, Parameter(help="Enable verbose")] = False, colors: Annotated[bool, Parameter(negative="no-colors")] = True, ): """App with negative flags.""" pass app_markup = App(name="markupapp") @app_markup.default def markup_main( verbose: Annotated[bool, Parameter(help="Enable **verbose** output with `extra` details")] = False, mode: Annotated[ Literal["fast", "slow"], Parameter(help="Choose *execution* mode: **fast** or **slow**"), ] = "fast", ): """App with **markdown** markup in help text. This tests that markup is properly stripped in completions. """ pass @app_markup.command def deploy_markup( env: Annotated[str, Parameter(help="Target `environment` like **dev** or **prod**")], ): """Deploy to `environment`.""" pass app_rst = App(name="rstapp", help_format="rst") @app_rst.default def rst_main( verbose: Annotated[bool, Parameter(help="Enable **verbose** output with ``code`` samples")] = False, mode: Annotated[ Literal["fast", "slow"], Parameter(help="Choose *execution* mode: **fast** or **slow**"), ] = "fast", ): """App with **RST** markup in help text. This tests that RST markup is properly stripped in completions. """ pass app_positional_literal = App(name="poslit") @app_positional_literal.command def command(param: Literal["foo", "bar", "baz"], /): """Simple command with positional literal. Parameters ---------- param : Literal["foo", "bar", "baz"] Literal param. """ pass app_multiple_positionals = App(name="multipos") @app_multiple_positionals.command def command_multi( first: Literal["red", "blue"], second: Literal["cat", "dog"], /, ): """Command with multiple positionals with distinct choices. Parameters ---------- first : Literal["red", "blue"] Color choice. second : Literal["cat", "dog"] Animal choice. """ pass app_deploy = App(name="deploy") @app_deploy.command def deploy_project( project: Literal["web", "api", "worker"], /, *, environment: Literal["dev", "staging", "prod"], branch: str = "main", ): """Deploy a project to an environment. Parameters ---------- project : Literal["web", "api", "worker"] Project to deploy. environment : Literal["dev", "staging", "prod"] Target environment. branch : str Git branch. """ pass app_positional_path = App(name="pathpos") @app_positional_path.command def process( input_file: Path, /, ): """Process a file. Parameters ---------- input_file : Path Input file to process. """ pass app_list_path = App(name="listpath") @app_list_path.default def list_path_main( files: Annotated[list[Path], Parameter(help="List of files")], ): """App with list[Path] parameter.""" pass app_list_annotated_path = App(name="listannotatedpath") @app_list_annotated_path.default def list_annotated_path_main( files: list[ExistingFile], ): """App with list[ExistingFile] parameter.""" pass app_disabled_negative = App(name="disabledneg", default_parameter=Parameter(negative="")) @app_disabled_negative.command def build(param: list[Literal["apple", "banana", "cherry"]]): """Build command with list parameter.""" pass app_three_positionals = App(name="multipos3") @app_three_positionals.command def command_multi3( first: Literal["red", "blue"], second: Literal["cat", "dog"], third: Literal["small", "large"], /, ): """Command with three positional literals. Parameters ---------- first : Literal["red", "blue"] Color choice. second : Literal["cat", "dog"] Animal choice. third : Literal["small", "large"] Size choice. """ pass # Two iterable positionals back-to-back. Exercises the "first iterable wins # the rest position" rule that prevents two rest specs from being emitted # (regressions here would surface as either invalid scripts or wrong # completions at the rest position). app_two_iterables = App(name="twoiter") @app_two_iterables.command def collect( paths: list[Path], tags: list[str], ): """Collect paths and tags. Parameters ---------- paths : list[Path] Paths to collect. tags : list[str] Optional tags. """ pass BrianPugh-cyclopts-921b1fa/tests/completion/conftest.py000066400000000000000000000367051517576204000233720ustar00rootroot00000000000000r"""Shell-completion test harness. Each shell exposes two capabilities: * ``validate_script_syntax()`` - runs ``{bash,zsh,fish} -n`` on the generated script. Already non-interactive. * ``get_completions(partial)`` - returns the list of suggestions a user would see for a partial command line. * **Bash** - spawn ``bash -c``, source the completion script, populate ``COMP_WORDS`` using a ``COMP_WORDBREAKS``-aware tokenizer (default ``WORDBREAKS`` includes ``=`` and ``:``, which matters for ``--opt=value`` and ``host:port`` forms), invoke the registered ``_`` function, and print ``COMPREPLY``. No TTY, no timing. Bash 3.2-safe (the mac-stock version). * **Fish** - use fish's built-in ``complete -C ''`` flag. Prints ``completion\tdescription`` one per line. Deterministic since fish 3.0. * **Zsh** - drive an interactive ``zsh -i`` via ``pexpect`` and ask it to *list* matches without modifying the line by sending the ``list-choices`` widget (``\e\C-d``). Matches are screen-scraped between two prompt sentinels. Skipped if ``pexpect`` is unavailable. """ import os import re import subprocess import tempfile from abc import ABC, abstractmethod from pathlib import Path import pytest class CompletionTesterBase(ABC): """Base class for shell completion testers.""" def __init__(self, completion_script: str, prog_name: str): self.completion_script = completion_script self.prog_name = prog_name @abstractmethod def validate_script_syntax(self) -> bool: """Return True if the generated script is syntactically valid for this shell.""" @abstractmethod def get_completions(self, partial_command: str) -> list[str]: """Return the completion suggestions a real shell would offer for ``partial_command``.""" # --- Bash -------------------------------------------------------------------- _BASH_FUNC_RE = re.compile(r"^complete -F (\S+) ", re.MULTILINE) class BashCompletionTester(CompletionTesterBase): """Bash completion tester using a non-interactive ``bash -c`` driver.""" def validate_script_syntax(self) -> bool: with tempfile.TemporaryDirectory() as tmpdir: comp_file = Path(tmpdir) / f"{self.prog_name}_completion.bash" comp_file.write_text(self.completion_script) result = subprocess.run( ["bash", "-n", str(comp_file)], capture_output=True, text=True, timeout=5, ) return result.returncode == 0 def get_completions(self, partial_command: str) -> list[str]: match = _BASH_FUNC_RE.search(self.completion_script) if not match: raise RuntimeError("Could not locate 'complete -F ' line in generated bash script") func_name = match.group(1) with tempfile.TemporaryDirectory() as tmpdir: comp_file = Path(tmpdir) / f"{self.prog_name}_completion.bash" comp_file.write_text(self.completion_script) # Tokenize the line the way real interactive bash does: split on # whitespace AND on every char in ``COMP_WORDBREAKS`` (default: # space tab newline " ' < > = ; | & ( :). Each break char becomes # its own word. Without this the driver can't probe ``--opt=value`` # or ``host:port`` style completions. driver = ( 'source "$1"\n' 'COMP_LINE="$2"\n' "COMP_POINT=${#COMP_LINE}\n" "COMP_WORDS=()\n" "COMPREPLY=()\n" "_word=''\n" "for ((_k=0; _k<${#COMP_LINE}; _k++)); do\n" ' _ch="${COMP_LINE:$_k:1}"\n' ' case "$_ch" in\n' " ' '|$'\\t'|$'\\n')\n" ' [[ -n "$_word" ]] && { COMP_WORDS+=("$_word"); _word=""; }\n' " ;;\n" " '\"'|\"'\"|'<'|'>'|'='|';'|'|'|'&'|'('|':')\n" ' [[ -n "$_word" ]] && { COMP_WORDS+=("$_word"); _word=""; }\n' ' COMP_WORDS+=("$_ch")\n' " ;;\n" " *)\n" ' _word+="$_ch"\n' " ;;\n" " esac\n" "done\n" # Final word: trailing whitespace means there's an empty # "current word" the user is about to type; otherwise the # accumulated word is the current word. 'if [[ "${COMP_LINE: -1}" == " " || "${COMP_LINE: -1}" == $\'\\t\' ]]; then\n' ' [[ -n "$_word" ]] && COMP_WORDS+=("$_word")\n' ' COMP_WORDS+=("")\n' "else\n" ' COMP_WORDS+=("$_word")\n' "fi\n" "COMP_CWORD=$(( ${#COMP_WORDS[@]} - 1 ))\n" f'"{func_name}" "$3" "${{COMP_WORDS[COMP_CWORD]}}" "${{COMP_WORDS[COMP_CWORD-1]:-}}" >/dev/null\n' 'printf "%s\\n" "${COMPREPLY[@]}"\n' ) # Inherit the caller's cwd (don't pin to ``tmpdir``): tests that # ``os.chdir()`` into a target directory before calling # ``get_completions()`` rely on path completion seeing that # directory. The comp-script itself is sourced via an absolute # path, so cwd is otherwise irrelevant. result = subprocess.run( ["bash", "-c", driver, "_", str(comp_file), partial_command, self.prog_name], capture_output=True, text=True, timeout=5, ) if result.returncode != 0: raise RuntimeError( f"bash driver failed (exit {result.returncode}): {result.stderr.strip() or result.stdout.strip()}" ) return [line for line in result.stdout.splitlines() if line] def _check_bash_available() -> bool: try: result = subprocess.run(["bash", "--version"], capture_output=True, text=True, timeout=2) return result.returncode == 0 and "bash" in result.stdout.lower() except (subprocess.SubprocessError, FileNotFoundError): return False @pytest.fixture(scope="session") def bash_available(): return _check_bash_available() @pytest.fixture def bash_tester(bash_available): if not bash_available: pytest.skip("bash not available") def _make_tester(app, prog_name="testapp"): script = app.generate_completion(prog_name=prog_name, shell="bash") return BashCompletionTester(script, prog_name) return _make_tester # --- Fish -------------------------------------------------------------------- class FishCompletionTester(CompletionTesterBase): """Fish completion tester using the built-in non-interactive ``complete -C`` flag.""" def validate_script_syntax(self) -> bool: with tempfile.TemporaryDirectory() as tmpdir: comp_file = Path(tmpdir) / f"{self.prog_name}.fish" comp_file.write_text(self.completion_script) result = subprocess.run( ["fish", "-n", str(comp_file)], capture_output=True, text=True, timeout=5, ) return result.returncode == 0 def get_completions(self, partial_command: str) -> list[str]: with tempfile.TemporaryDirectory() as tmpdir: comp_file = Path(tmpdir) / f"{self.prog_name}.fish" comp_file.write_text(self.completion_script) # Pass the completion file and partial line as argv items so fish # treats them as data — manual single-quote escaping breaks for # partials containing backslashes or other shell metacharacters. script = "source $argv[1]; complete -C $argv[2]" # Inherit the caller's cwd so path completion reflects whatever # directory the test ``os.chdir()``-ed into. The comp-script is # sourced via an absolute path. result = subprocess.run( ["fish", "-c", script, str(comp_file), partial_command], capture_output=True, text=True, timeout=5, ) if result.returncode != 0: raise RuntimeError( f"fish driver failed (exit {result.returncode}): {result.stderr.strip() or result.stdout.strip()}" ) # `complete -C` prints "completion\tdescription" per line (description optional). completions = [] for line in result.stdout.splitlines(): if not line: continue completions.append(line.split("\t", 1)[0]) return completions def _check_fish_available() -> bool: try: result = subprocess.run(["fish", "--version"], capture_output=True, text=True, timeout=2) return result.returncode == 0 and "fish" in result.stdout.lower() except (subprocess.SubprocessError, FileNotFoundError): return False @pytest.fixture(scope="session") def fish_available(): return _check_fish_available() @pytest.fixture def fish_tester(fish_available): if not fish_available: pytest.skip("fish not available") def _make_tester(app, prog_name="testapp"): script = app.generate_completion(prog_name=prog_name, shell="fish") return FishCompletionTester(script, prog_name) return _make_tester # --- Zsh --------------------------------------------------------------------- _ZSH_ANSI_RE = re.compile(r"\x1b\[[\d;?]*[a-zA-Z]|\x1b[=>()][a-zA-Z0-9]?|\x1b[=>]|\r|\x07|\x0e|\x0f") # zsh's listing widget separates *columns* with two-or-more spaces and uses a # single backslash to escape characters within a match value (so # ``hello world`` is rendered ``hello\ world``, not split). Splitting on # single spaces would merge those into the column gap. _ZSH_COLUMN_GAP = re.compile(r" {2,}") def _zsh_unescape_match(token: str) -> str: r"""Reverse the ``\X``-style display escape zsh applies to listed matches.""" out: list[str] = [] i = 0 while i < len(token): if token[i] == "\\" and i + 1 < len(token): out.append(token[i + 1]) i += 2 else: out.append(token[i]) i += 1 return "".join(out) class ZshCompletionTester(CompletionTesterBase): r"""Zsh completion tester driven via ``pexpect``. zsh's completion helpers (``_arguments``, ``_describe``, ``_files``) only run inside a real zle widget context, so the driver spawns an interactive ``zsh -i`` in a pty. To get matches without zsh inserting / cycling through them, the partial line is followed by the ``list-choices`` widget (``\e\C-d``), which lists matches in place and leaves the buffer untouched. Output is screen-scraped between two prompt sentinels and stripped of ANSI. Notes ----- * Skipped if ``pexpect`` is not importable (e.g. Windows). * The TERM is forced to ``dumb`` to minimize control-byte noise. * ``ZDOTDIR`` is pointed at a temp dir so the host's ``.zshrc`` cannot perturb completion (custom widgets, ``zstyle`` settings, oh-my-zsh). """ def validate_script_syntax(self) -> bool: with tempfile.TemporaryDirectory() as tmpdir: comp_file = Path(tmpdir) / f"_{self.prog_name}" comp_file.write_text(self.completion_script) result = subprocess.run( ["zsh", "-n", str(comp_file)], capture_output=True, text=True, timeout=5, ) return result.returncode == 0 def get_completions(self, partial_command: str) -> list[str]: try: import pexpect except ImportError: pytest.skip("pexpect not available") with tempfile.TemporaryDirectory() as tmpdir: td = Path(tmpdir) (td / f"_{self.prog_name}").write_text(self.completion_script) (td / ".zshrc").write_text( "PROMPT='ZTEST> '\n" f"fpath=({td} $fpath)\n" "autoload -Uz compinit && compinit -u\n" # Force "list, don't insert" semantics so we always see the # full set of matches even when there's only one. "unsetopt MENU_COMPLETE AUTO_MENU AUTO_LIST\n" "setopt NO_BEEP NO_LIST_BEEP\n" # Suppress descriptions/headers so each line is just matches. "zstyle ':completion:*' format ''\n" "zstyle ':completion:*:descriptions' format ''\n" "zstyle ':completion:*:messages' format ''\n" "zstyle ':completion:*:warnings' format ''\n" "zstyle ':completion:*' verbose no\n" "zstyle ':completion:*' group-name ''\n" "zstyle ':completion:*' list-prompt ''\n" ) # Inherit the parent PATH so zsh (and any tools it shells out to) # can be located when installed outside ``/usr/bin:/bin:/usr/local/bin`` # — Homebrew on Apple Silicon, nix, asdf, pyenv shims, etc. We # deliberately *don't* inherit the rest of ``os.environ``: stray # ``ZSH_*``/``LC_*``/locale vars from the host can leak into the # listing widget output and break screen-scraping. env_vars = { "HOME": str(td), "ZDOTDIR": str(td), "PATH": os.environ.get("PATH", "/usr/bin:/bin:/usr/local/bin"), "TERM": "dumb", } child = pexpect.spawn( "zsh -i", encoding="utf-8", env=env_vars, # pyright: ignore[reportArgumentType] timeout=5, dimensions=(40, 400), ) try: child.expect("ZTEST> ", timeout=4) child.send(partial_command) child.send("\x1b\x04") # list-choices widget (Esc Ctrl-D) try: child.expect("ZTEST> ", timeout=1.5) except pexpect.TIMEOUT: pass output = child.before or "" finally: child.close(force=True) cleaned = _ZSH_ANSI_RE.sub("", output) # First line is the user's typed partial echoed back; subsequent # lines are the listed matches. Columns are separated by 2+ spaces; # matches themselves may contain single-space-with-escape sequences # like ``hello\ world`` for a value that contains a literal space. lines = cleaned.split("\n") matches: list[str] = [] for line in lines[1:]: line = line.rstrip() if not line: continue for token in _ZSH_COLUMN_GAP.split(line): token = token.strip() if token: matches.append(_zsh_unescape_match(token)) return matches def _check_zsh_available() -> bool: try: result = subprocess.run(["zsh", "--version"], capture_output=True, text=True, timeout=2) return result.returncode == 0 and "zsh" in result.stdout.lower() except (subprocess.SubprocessError, FileNotFoundError): return False @pytest.fixture(scope="session") def zsh_available(): return _check_zsh_available() @pytest.fixture def zsh_tester(zsh_available): if not zsh_available: pytest.skip("zsh not available") def _make_tester(app, prog_name="testapp"): script = app.generate_completion(prog_name=prog_name, shell="zsh") return ZshCompletionTester(script, prog_name) return _make_tester BrianPugh-cyclopts-921b1fa/tests/completion/test_bash.py000066400000000000000000001110141517576204000235040ustar00rootroot00000000000000"""Tests for bash completion script generation.""" from typing import Annotated, Literal import pytest from cyclopts import App, Parameter from cyclopts.completion.bash import generate_completion_script from .apps import ( app_basic, app_deploy, app_disabled_negative, app_enum, app_list_annotated_path, app_list_path, app_multiple_positionals, app_negative, app_nested, app_path, app_positional_literal, app_positional_path, ) def test_generate_completion_script_invalid_prog_name(): """Test that invalid prog_name raises ValueError.""" with pytest.raises(ValueError, match="Invalid prog_name"): generate_completion_script(app_basic, "invalid prog") with pytest.raises(ValueError, match="Invalid prog_name"): generate_completion_script(app_basic, "") with pytest.raises(ValueError, match="Invalid prog_name"): generate_completion_script(app_basic, "prog$name") def test_generate_completion_script_creates_script(): """Test that bash completion generates a script.""" script = generate_completion_script(app_basic, "basic") assert script assert "# Bash completion for basic" in script assert "complete -F _basic basic" in script def test_basic_option_completion(bash_tester): """Test basic option name completion.""" tester = bash_tester(app_basic, "basic") assert "--verbose" in tester.completion_script assert "--count" in tester.completion_script assert "--help" in tester.completion_script def test_command_completion(bash_tester): """Test command completion.""" tester = bash_tester(app_basic, "basic") assert "deploy" in tester.completion_script def test_literal_value_completion(bash_tester): """Test Literal type value completion.""" tester = bash_tester(app_basic, "basic") assert "dev" in tester.completion_script assert "staging" in tester.completion_script assert "prod" in tester.completion_script def test_enum_value_completion(bash_tester): """Test Enum type value completion.""" tester = bash_tester(app_enum, "enumapp") assert "fast" in tester.completion_script assert "slow" in tester.completion_script def test_nested_subcommand_completion(bash_tester): """Test nested subcommand completion.""" tester = bash_tester(app_nested, "nested") assert "config" in tester.completion_script assert "get" in tester.completion_script assert "set" in tester.completion_script def test_path_completion_action(bash_tester): """Test that Path types trigger file completion.""" tester = bash_tester(app_path, "pathapp") assert tester.validate_script_syntax() def test_negative_flag_completion(bash_tester): """Test negative flag handling.""" tester = bash_tester(app_negative, "negapp") assert "--verbose" in tester.completion_script assert "--no-verbose" in tester.completion_script assert "--colors" in tester.completion_script assert "--no-colors" in tester.completion_script def test_disabled_negative_flag_completion(bash_tester): """Test that negative flags are not generated when disabled via App default_parameter.""" tester = bash_tester(app_disabled_negative, "disabledneg") assert "--param" in tester.completion_script assert "--empty-param" not in tester.completion_script def test_script_syntax_validation(bash_tester): """Test that generated script has valid bash syntax.""" tester = bash_tester(app_basic, "basic") assert tester.validate_script_syntax() def test_completion_function_naming(bash_tester): """Test that completion function uses correct naming convention.""" tester = bash_tester(app_basic, "myapp") assert "_myapp" in tester.completion_script or "_myapp_completion" in tester.completion_script assert "complete -F" in tester.completion_script def test_special_characters_in_choices(bash_tester): """Test that special characters in choices are properly escaped.""" app = App(name="special") @app.default def main( value: Annotated[ Literal["normal", "with space", "with'quote", "with$dollar"], Parameter(help="Test value"), ] = "normal", ): """Test app with special characters.""" pass tester = bash_tester(app, "special") assert tester.validate_script_syntax() def test_help_descriptions(bash_tester): """Test that completion script contains relevant option and command names. Note: Bash completion uses a minimal format (compgen -W) without descriptions, unlike zsh/fish which support inline descriptions. """ tester = bash_tester(app_basic, "basic") assert "--verbose" in tester.completion_script assert "deploy" in tester.completion_script def test_description_escaping(bash_tester): """Test that descriptions with special chars are properly escaped.""" app = App(name="escape_test") @app.default def main( param1: Annotated[str, Parameter(help="Test 'single' quotes")] = "", param2: Annotated[str, Parameter(help='Test "double" quotes')] = "", param3: Annotated[str, Parameter(help="Test $variable and `backticks`")] = "", ): """Test app.""" tester = bash_tester(app, "escape_test") assert tester.validate_script_syntax() def test_special_chars_in_literal_choices(bash_tester): """Test that Literal choices with special characters are properly escaped.""" app = App(name="special_choices") @app.default def main( choice: Annotated[Literal["foo bar", "baz()", "test[1]"], Parameter()] = "foo bar", ): """Test app with special chars in choices.""" tester = bash_tester(app, "special_choices") assert tester.validate_script_syntax() def test_unicode_in_descriptions(bash_tester): """Test that apps with Unicode descriptions generate valid bash syntax. Note: Bash completion doesn't include descriptions, so we only verify syntax validity and presence of the options. """ app = App(name="unicode_test") @app.default def main( emoji: Annotated[str, Parameter(help="Enable 🚀 rocket mode")] = "", chinese: Annotated[str, Parameter(help="中文描述")] = "", arabic: Annotated[str, Parameter(help="وصف بالعربية")] = "", ): """Test app with Unicode.""" tester = bash_tester(app, "unicode_test") assert "--emoji" in tester.completion_script assert tester.validate_script_syntax() def test_deeply_nested_commands(bash_tester): """Test completion for deeply nested commands (3+ levels).""" root = App(name="root") level1 = App(name="level1") level2 = App(name="level2") level3 = App(name="level3") @level3.command def action(value: str): """Perform action at level 3.""" pass level2.command(level3) level1.command(level2) root.command(level1) tester = bash_tester(root, "root") assert "level1" in tester.completion_script assert "level2" in tester.completion_script assert "level3" in tester.completion_script assert "action" in tester.completion_script assert tester.validate_script_syntax() def test_nested_command_disambiguation(bash_tester): """Test that nested commands are properly disambiguated. This test verifies that the helper function correctly distinguishes between commands with overlapping names (e.g., 'config get' vs 'admin get'). """ root = App(name="myapp") config = App(name="config") admin = App(name="admin") @config.command(name="get") def config_get(key: Annotated[str, Parameter(help="Config key")] = ""): """Get config value.""" pass @config.command(name="set") def config_set(key: str = "", value: str = ""): """Set config value.""" pass @admin.command(name="get") def admin_get(user: Annotated[str, Parameter(help="Username")] = ""): """Get admin user.""" pass root.command(config) root.command(admin) tester = bash_tester(root, "myapp") script = tester.completion_script assert "cmd_path" in script, "Should use command path detection" config_lines = [line for line in script.split("\n") if "config" in line.lower()] admin_lines = [line for line in script.split("\n") if "admin" in line.lower()] assert any("config" in line for line in config_lines), "Should have config-specific completions" assert any("admin" in line for line in admin_lines), "Should have admin-specific completions" assert tester.validate_script_syntax() def test_helper_function_generation(bash_tester): """Test that command path detection logic works correctly. Note: Bash always generates cmd_path detection logic (even for root-only apps) unlike fish which conditionally generates helper functions. This is fine since the logic is minimal and doesn't hurt performance. """ root_only = App(name="rootonly") @root_only.default def main(verbose: bool = False): """Root only app.""" pass tester_root = bash_tester(root_only, "rootonly") assert tester_root.validate_script_syntax() nested = App(name="nested") sub = App(name="sub") @sub.default def action(): """Sub action.""" pass nested.command(sub) tester_nested = bash_tester(nested, "nested") assert "cmd_path" in tester_nested.completion_script assert tester_nested.validate_script_syntax() def test_optional_path_completion(bash_tester): """Test that Optional[Path] and Path | None generate file completion.""" tester = bash_tester(app_path, "pathapp") assert "compgen -f" in tester.completion_script or "compgen -d" in tester.completion_script def test_no_file_completion_for_strings(bash_tester): """Test that string options don't default to file completion.""" app = App(name="strtest") @app.default def main( name: Annotated[str, Parameter(help="Name")] = "default", ): """Test app.""" tester = bash_tester(app, "strtest") assert tester.validate_script_syntax() def test_empty_iterable_flag_completion(bash_tester): """Test that --empty-* flags for list parameters are treated as flags. Regression test for issue where --empty-items on list[str] parameters would expect a value instead of being treated as a flag. """ app = App(name="listapp") @app.command def process( items: Annotated[list[str], Parameter(help="Items to process")], tags: Annotated[list[str] | None, Parameter(help="Optional tags")] = None, ): """Process items.""" pass tester = bash_tester(app, "listapp") assert "--items" in tester.completion_script assert "--empty-items" in tester.completion_script assert "--tags" in tester.completion_script assert "--empty-tags" in tester.completion_script lines = tester.completion_script.split("\n") options_line = None for line in lines: if "options_with_values" in line: options_line = line break if options_line: assert "--empty-items" not in options_line, "Empty flags should not be in options_with_values" assert "--empty-tags" not in options_line, "Empty flags should not be in options_with_values" assert tester.validate_script_syntax() def test_helper_function_skips_option_values(bash_tester): """Test that helper function correctly identifies and skips option values. Critical bug fix test: ensures that when building command path, the helper function skips values for options that take arguments. Without this, 'myapp --config file.yaml subcommand' would incorrectly extract [file.yaml, subcommand] instead of [subcommand]. """ app = App(name="myapp") sub = App(name="sub") @sub.default def action(): """Subcommand action.""" pass @app.default def main( config: Annotated[str, Parameter(help="Config file path")], verbose: bool = False, ): """Main app.""" pass app.command(sub) tester = bash_tester(app, "myapp") script = tester.completion_script assert "options_with_values" in script, "Helper should track options that take values" assert "--config" in script or "config" in script lines = script.split("\n") options_line = None for line in lines: if "options_with_values" in line and "local" in line: options_line = line break assert options_line is not None, "Should have a line setting options_with_values" assert "--config" in options_line or "config" in options_line, "Should include --config in options_with_values" assert "--verbose" not in options_line, "Flags should not be in options_with_values" skip_check = [line for line in lines if "skip_next" in line] assert len(skip_check) > 0, "Helper should use skip_next logic to skip option values" assert tester.validate_script_syntax() def test_positional_literal_completion(bash_tester): """Test that positional Literal arguments generate completions. Regression test for issue #605: positional Literal arguments should suggest their choices in bash completion. """ tester = bash_tester(app_positional_literal, "poslit") assert "foo" in tester.completion_script assert "bar" in tester.completion_script assert "baz" in tester.completion_script assert tester.validate_script_syntax() def test_multiple_positional_literal_position_aware(bash_tester): """Test that multiple positional Literals complete position-aware choices. When a command has multiple positional arguments with distinct choice sets, the completion should only suggest choices for the current position, not all choices from all positionals. This test validates that the generated bash script uses a case statement to provide position-aware completion rather than combining all choices. """ tester = bash_tester(app_multiple_positionals, "multipos") script = tester.completion_script # Should use positional_count variable for position tracking assert "positional_count" in script # Should use case statement for position-aware completion assert "case ${positional_count} in" in script # Position 0 should offer first parameter choices assert "0)" in script # Should have first positional choices in position 0 lines = script.split("\n") in_case_0 = False found_first_choices = False for i, line in enumerate(lines): if "case ${positional_count}" in line: in_case_0 = True elif in_case_0 and "0)" in line: # Check lines until we hit ";;" case_content = [] for j in range(i + 1, min(i + 10, len(lines))): if ";;" in lines[j]: break case_content.append(lines[j]) case_str = "\n".join(case_content) if "red" in case_str and "blue" in case_str: found_first_choices = True # Make sure cat/dog aren't in the same case assert "cat" not in case_str assert "dog" not in case_str break assert found_first_choices, "First positional choices not found in position 0" # Position 1 should offer second parameter choices assert "1)" in script in_case_1 = False found_second_choices = False for i, line in enumerate(lines): if in_case_1 and "1)" in line: # Check lines until we hit ";;" case_content = [] for j in range(i + 1, min(i + 10, len(lines))): if ";;" in lines[j]: break case_content.append(lines[j]) case_str = "\n".join(case_content) if "cat" in case_str and "dog" in case_str: found_second_choices = True # Make sure red/blue aren't in the same case assert "red" not in case_str assert "blue" not in case_str break elif "case ${positional_count}" in line: in_case_1 = True assert found_second_choices, "Second positional choices not found in position 1" assert tester.validate_script_syntax() def test_positional_with_keyword_options(bash_tester): """Test positional completion when command also has keyword-only options. Regression test: When a command has both positional arguments and keyword-only options that take values (like --environment), completion after the command name should suggest the positional choices, not empty. For example: - 'deploy deploy ' should suggest ['web', 'api', 'worker'] - 'deploy deploy --environment ' should suggest ['dev', 'staging', 'prod'] This ensures the case "$prev" default (*) case handles positionals correctly. """ tester = bash_tester(app_deploy, "deploy") script = tester.completion_script # Should have a case statement that switches on the previous word. # The dispatch key is now ``$_value_prev`` (resolved through ``=`` for # ``--opt=value`` form), but it still represents the same prev-word # logic. assert 'case "$_value_prev"' in script or 'case "${prev}"' in script # Should suggest positional choices in the script assert "web" in script assert "api" in script assert "worker" in script # Should also have environment option completion assert "--environment" in script assert "dev" in script assert "staging" in script assert "prod" in script assert tester.validate_script_syntax() def test_positional_not_treated_as_command(bash_tester): """Test that positional argument values are not mistaken for subcommands. Regression test: When a command takes positional arguments, those argument values should not be added to cmd_path (command hierarchy). For example, 'deploy production us-east' should be recognized as the 'deploy' command with two positionals, NOT as command path ['deploy', 'production']. This was a critical bug where the second positional would fail to complete because 'production' was incorrectly treated as a subcommand. """ import subprocess from typing import Literal app = App(name="deploy") @app.command def deploy( environment: Literal["production", "staging"], region: Literal["us-east", "us-west", "eu"], /, ): """Deploy to environment and region.""" pass tester = bash_tester(app, "cyclopts-demo") script = tester.completion_script # Should have all_commands list that only includes "deploy" assert "all_commands" in script assert "deploy" in script # Test that 'production' and 'staging' are NOT in all_commands # (they're positional values, not commands) lines = script.split("\n") all_commands_line = None for line in lines: if "local all_commands=" in line: all_commands_line = line break assert all_commands_line is not None assert "production" not in all_commands_line assert "staging" not in all_commands_line assert "us-east" not in all_commands_line # Verify the completion actually works with a bash subprocess test test_script = f""" source /dev/stdin << 'COMPLETION_SCRIPT' {script} COMPLETION_SCRIPT # Simulate: cyclopts-demo deploy production COMP_WORDS=(cyclopts-demo deploy production "") COMP_CWORD=3 _cyclopts_demo # Should get region completions if [ ${{#COMPREPLY[@]}} -eq 0 ]; then echo "FAIL: No completions" exit 1 fi # Check that we got the expected completions found_useast=0 found_uswest=0 found_eu=0 for item in "${{COMPREPLY[@]}}"; do if [ "$item" = "us-east" ]; then found_useast=1; fi if [ "$item" = "us-west" ]; then found_uswest=1; fi if [ "$item" = "eu" ]; then found_eu=1; fi done if [ $found_useast -eq 1 ] && [ $found_uswest -eq 1 ] && [ $found_eu -eq 1 ]; then exit 0 else echo "FAIL: Missing expected completions" exit 1 fi """ result = subprocess.run( ["bash", "-c", test_script], capture_output=True, text=True, ) assert result.returncode == 0, f"Completion test failed: {result.stdout}\n{result.stderr}" assert tester.validate_script_syntax() def test_positional_path_completion(bash_tester): """Test that positional Path arguments generate file completion. Regression test: positional Path arguments should use file completion (compgen -f) instead of empty completion. """ tester = bash_tester(app_positional_path, "pathpos") script = tester.completion_script # Should have file completion flag for positional Path assert "compgen -f" in script assert tester.validate_script_syntax() def test_literal_with_show_choices_false(bash_tester): """Test that Literal with show_choices=False still provides completions. Regression test: When show_choices=False is set on a Literal parameter, the choices should still be available for shell completion, even though they are hidden from the help text. """ app = App(name="deploy") @app.default def main( env: Annotated[ Literal["dev", "staging", "prod"], Parameter(help="Environment to deploy to", show_choices=False), ], ): """Deploy to environment.""" pass tester = bash_tester(app, "deploy") script = tester.completion_script # Choices should be in completion script even with show_choices=False assert "dev" in script assert "staging" in script assert "prod" in script def test_command_with_multiple_names_and_aliases(bash_tester): """Test that commands registered with multiple names/aliases all appear in completions. Regression test for groups_from_app() deduplication - ensures all registered names are included in completion scripts. """ app = App(name="myapp") sub = App() @sub.default def action(value: str = ""): """Perform an action.""" pass app.command(sub, name="foo", alias=["bar", "baz"]) tester = bash_tester(app, "myapp") script = tester.completion_script assert "foo" in script, "Primary name should be in completion script" assert "bar" in script, "First alias should be in completion script" assert "baz" in script, "Second alias should be in completion script" assert tester.validate_script_syntax() def test_list_path_completion(bash_tester): """Test that list[Path] arguments generate file completion. Regression test for issue #654: list[Path] arguments should use file completion (compgen -f) just like Path arguments. """ tester = bash_tester(app_list_path, "listpath") script = tester.completion_script assert "compgen -f" in script, "list[Path] should generate file completion" assert tester.validate_script_syntax() def test_list_annotated_path_completion(bash_tester): """Test that list[Annotated[Path, ...]] arguments generate file completion.""" tester = bash_tester(app_list_annotated_path, "listannotatedpath") script = tester.completion_script assert "compgen -f" in script, "list[ExistingFile] should generate file completion" assert tester.validate_script_syntax() def test_list_path_multi_positional_default_case(bash_tester): """Test that list[Path] positional uses file completion at every later position. When a ``list[Path]`` follows a scalar positional, position 0 takes the scalar's completion and *every* later position is owned by the iterable (collapsed onto the ``*)`` default — there's no separate numbered case for the iterable). End-to-end: position 1, 2, … all offer files. """ import os import tempfile from pathlib import Path as _Path from typing import Literal app = App(name="testapp") @app.command def cmd(first: Literal["a", "b"], files: list[_Path], /): pass tester = bash_tester(app, "testapp") assert tester.validate_script_syntax() with tempfile.TemporaryDirectory() as td: (_Path(td) / "x.txt").write_text("x") cwd = _Path.cwd() try: os.chdir(td) pos0 = tester.get_completions("testapp cmd ") pos1 = tester.get_completions("testapp cmd a ") pos2 = tester.get_completions("testapp cmd a x.txt ") finally: os.chdir(cwd) assert {"a", "b"} <= set(pos0) assert pos1, f"expected file completion at position 1, got {pos1!r}" assert pos2, f"expected file completion at position 2, got {pos2!r}" def test_colon_in_command_name(bash_tester): """Test that colons in command names work correctly in bash completion. Unlike zsh where colons are special in _describe format, bash handles colons without special escaping in compgen -W word lists. """ app = App(name="myapp") sub = App() @sub.default def action(value: str = ""): """Perform an action.""" pass # Register command with colon in name app.command(sub, name="utility:ping") tester = bash_tester(app, "myapp") script = tester.completion_script # Command name should appear in the script assert "utility:ping" in script, "Command name with colon should appear in script" assert tester.validate_script_syntax() def test_glob_chars_in_command_name(bash_tester): """Test that glob characters in command names are properly escaped. Regression test: Command names containing glob characters like * ? [ ] should be escaped in case patterns to prevent glob matching. """ app = App(name="myapp") sub1 = App() @sub1.default def action1(): """Action with brackets.""" pass # Register command with brackets in name (unusual but possible) app.command(sub1, name="test[1]") tester = bash_tester(app, "myapp") script = tester.completion_script # Brackets should be escaped in case patterns # The pattern should be "test\[1\]" not "test[1]" assert r"test\[1\]" in script, "Brackets in command name should be escaped in case patterns" assert tester.validate_script_syntax() # --- Choice values containing whitespace / shell metacharacters -------------- # # Regression tests for the bug where ``compgen -W ''`` whitespace- # tokenized the choice list (so ``"hello world"`` became two completions) # and the surrounding ``$(...)`` re-parsed backticks even inside single # quotes. Choices now go through an array + prefix-match loop, which keeps # every value intact. def test_choice_with_whitespace(bash_tester): """A choice value containing a space stays a single completion.""" app = App(name="ws") @app.default def m(x: Literal["hello world", "normal"] = "normal", /): """Whitespace in choice.""" tester = bash_tester(app, "ws") assert tester.validate_script_syntax() completions = tester.get_completions("ws ") assert "hello world" in completions assert "hello" not in completions assert "world" not in completions def test_choice_with_single_quote(bash_tester): """A choice value containing a single quote round-trips intact.""" app = App(name="sq") @app.default def m(x: Literal["a'b", "normal"] = "normal", /): """Single quote in choice.""" tester = bash_tester(app, "sq") assert tester.validate_script_syntax() completions = tester.get_completions("sq ") assert "a'b" in completions def test_choice_with_backtick(bash_tester): r"""A choice value containing a backtick does not break the script. Previously ``compgen -W 'a`b ...'`` inside ``$(...)`` raised ``bad substitution: no closing "\`"`` because ``$(...)`` re-scans for backticks even inside single quotes. """ app = App(name="btk") @app.default def m(x: Literal["a`b", "normal"] = "normal", /): """Backtick in choice.""" tester = bash_tester(app, "btk") assert tester.validate_script_syntax() completions = tester.get_completions("btk ") assert "a`b" in completions def test_choice_with_dollar(bash_tester): """A choice value containing $ is not parameter-expanded.""" app = App(name="dol") @app.default def m(x: Literal["$home", "normal"] = "normal", /): """Dollar in choice.""" tester = bash_tester(app, "dol") assert tester.validate_script_syntax() completions = tester.get_completions("dol ") assert "$home" in completions def test_choice_value_after_option(bash_tester): """Tricky choices also survive the ``--opt `` value-completion path.""" app = App(name="optchoice") @app.default def m(env: Annotated[Literal["a b", "c'd", "e`f"], Parameter(help="env")] = "a b"): """Tricky choices behind a keyword option.""" tester = bash_tester(app, "optchoice") assert tester.validate_script_syntax() completions = tester.get_completions("optchoice --env ") assert {"a b", "c'd", "e`f"} <= set(completions) # --- --opt=value form ------------------------------------------------------- # # In real interactive bash, ``--env=dev`` tokenizes through COMP_WORDBREAKS # to ``--env`` ``=`` ``dev`` with ``$prev == "="``. Cyclopts now resolves # through the equals sign to the actual option name two slots back, so the # same case-statement dispatch covers both ``--opt val`` and ``--opt=val``. def test_eq_form_literal_choices(bash_tester): """``--env=`` should offer the same Literal choices as ``--env ``.""" tester = bash_tester(app_basic, "basic") completions = tester.get_completions("basic deploy --env=") assert {"dev", "staging", "prod"} <= set(completions) def test_eq_form_literal_choices_partial(bash_tester): """``--env=d`` should narrow the choices to those starting with ``d``.""" tester = bash_tester(app_basic, "basic") completions = tester.get_completions("basic deploy --env=d") assert "dev" in completions assert "staging" not in completions assert "prod" not in completions def test_eq_form_path_completion(bash_tester): """``--input-file=`` should still offer file completion.""" import os import tempfile from pathlib import Path as _Path app = App(name="ekw") @app.default def m(input_file: Annotated[_Path, Parameter(help="In")] = _Path()): """Path keyword.""" tester = bash_tester(app, "ekw") with tempfile.TemporaryDirectory() as td: (_Path(td) / "sample.txt").write_text("x") cwd = _Path.cwd() try: os.chdir(td) completions = tester.get_completions("ekw --input-file=") finally: os.chdir(cwd) assert completions, f"expected file completion, got {completions!r}" def test_eq_form_space_form_still_works(bash_tester): """The ``--env `` form must remain functional after the eq fix.""" tester = bash_tester(app_basic, "basic") completions = tester.get_completions("basic deploy --env ") assert {"dev", "staging", "prod"} <= set(completions) # --- Repeatable value-options ---------------------------------------------- # # Bash already re-offers used flags / values by design, but lock the # behavior in via tests so it stays consistent with zsh after Group D. def test_value_option_repeatable_space_form(bash_tester): """``--env dev --env`` should still offer choices.""" tester = bash_tester(app_basic, "basic") completions = tester.get_completions("basic deploy --env dev --env ") assert {"dev", "staging", "prod"} <= set(completions) def test_value_option_repeatable_eq_form(bash_tester): """``--env=dev --env=`` should still offer choices.""" tester = bash_tester(app_basic, "basic") completions = tester.get_completions("basic deploy --env=dev --env=") assert {"dev", "staging", "prod"} <= set(completions) # --- Naked-TAB at no-arg subcommand: known per-shell divergence ------------- # # See the doc note at the top of test_behavior.py. Bash gates ``--*`` # suggestions behind ``cur == -*`` so naked-TAB at a no-arg subcommand # returns nothing rather than mixing flags into the suggestion list. zsh's # ``_arguments`` always offers help / version. We treat the bash behavior # as the right default and lock it in here. def test_naked_tab_at_no_arg_subcommand_returns_nothing(bash_tester): """Pressing TAB after a no-arg subcommand + space should be empty.""" app = App(name="probe") @app.command def noarg(): """No-arg subcommand.""" tester = bash_tester(app, "probe") completions = tester.get_completions("probe noarg ") assert completions == [] def test_dash_tab_at_no_arg_subcommand_offers_help(bash_tester): """Typing ``-`` at the same point still surfaces ``--help``.""" app = App(name="probe") @app.command def noarg(): """No-arg subcommand.""" tester = bash_tester(app, "probe") completions = tester.get_completions("probe noarg -") assert "--help" in completions # --- Multi-iterable positionals: rest-owner collapse ------------------------ # # An iterable positional (``list[X]`` / ``set[X]`` / ``*args``) greedily # consumes all positions starting at its index. Sibling positional-or-keyword # args declared after it still need their ``--name`` keyword forms but should # never get their own positional case branch. Bash isn't fatal here (unlike # zsh's "doubled rest argument" error), but emitting per-position branches # for those siblings would surface wrong completions when the iterables have # a completable type (Path / Literal / Enum). def test_multi_iterable_positionals_rest_owner_collapse(bash_tester): """Multiple iterable positionals collapse to a single rest-owner. The first iterable (``envs``) owns position 0 and the ``*)`` default; the second iterable (``regions``) is keyword-only-via-default and must not get its own ``1)`` case, otherwise position 1 would offer ``us / eu`` when ``envs`` has actually consumed it as ``dev / prod``. """ app = App(name="multi_iter") @app.command def deploy( envs: list[Literal["dev", "prod"]], regions: list[Literal["us", "eu"]] | None = None, ): """Deploy. Parameters ---------- envs Environments. regions Regions. """ tester = bash_tester(app, "multi_iter") assert tester.validate_script_syntax() # Position 0 offers envs; position 1 must continue offering envs (not # regions), because envs greedily owns positions ≥ 0. pos0 = tester.get_completions("multi_iter deploy ") assert {"dev", "prod"} <= set(pos0) pos1 = tester.get_completions("multi_iter deploy dev ") assert {"dev", "prod"} <= set(pos1) assert "us" not in pos1 and "eu" not in pos1 # ``--regions`` keyword form still works. via_kw = tester.get_completions("multi_iter deploy dev --regions ") assert {"us", "eu"} <= set(via_kw) def test_multi_iterable_after_scalar_positional(bash_tester): """Scalar positionals before the first iterable keep their own cases.""" app = App(name="mixed_iter") @app.command def cmd( kind: Literal["a", "b"], items: list[Literal["x", "y"]], ): """Mixed scalar + iterable. Parameters ---------- kind Kind. items Items. """ tester = bash_tester(app, "mixed_iter") assert tester.validate_script_syntax() pos0 = tester.get_completions("mixed_iter cmd ") assert {"a", "b"} <= set(pos0) pos1 = tester.get_completions("mixed_iter cmd a ") assert {"x", "y"} <= set(pos1) pos2 = tester.get_completions("mixed_iter cmd a x ") assert {"x", "y"} <= set(pos2) def test_bash_path_completion_reflects_caller_cwd(bash_tester): """Path completion must inspect the caller's cwd, not the harness tmpdir. Reproduces the Copilot finding: ``BashCompletionTester.get_completions()`` forces ``cwd=`` to the temporary directory holding the comp-script, so a test that ``os.chdir()``-es into a target directory before calling ``get_completions()`` has the chdir overridden by the driver. Path completion then lists files from the comp-script's tmpdir instead of the intended directory. The driver should inherit the caller's cwd. """ import os import tempfile from pathlib import Path as _Path app = App(name="pcwd") @app.default def m(output: Annotated[_Path, Parameter(help="Out")] = _Path()): """Path keyword.""" tester = bash_tester(app, "pcwd") with tempfile.TemporaryDirectory() as td: (_Path(td) / "myfile.txt").write_text("x") cwd = _Path.cwd() try: os.chdir(td) completions = tester.get_completions("pcwd --output ") finally: os.chdir(cwd) assert "myfile.txt" in completions, ( f"expected 'myfile.txt' from caller cwd, got {completions!r} (driver likely overrode cwd to its own tmpdir)" ) BrianPugh-cyclopts-921b1fa/tests/completion/test_behavior.py000066400000000000000000000214561517576204000244000ustar00rootroot00000000000000"""Cross-shell behavioral contract tests. Most tests in this directory assert on the **text of the generated completion script** (string containment). That is fast and catches regressions in the generators, but it does not answer the real question: *when the user presses TAB, does the shell actually offer the right suggestions?* This file runs the same behavioral scenarios across bash, zsh, and fish via the non-interactive drivers in ``conftest.py``. Any per-shell divergence surfaces immediately as a single failing parametrization. Assertions use subset / exclusion (not exact equality) because shells legitimately differ in ordering and in whether helper entries like ``--help`` surface at every depth. Known divergences (intentional, not bugs): * **Naked-TAB at a no-arg subcommand**: bash returns nothing; zsh's ``_arguments`` always lists ``--help`` / ``--version``. Bash's ``cur == -*`` gate is the right convention — typing space-TAB usually means "show me commands or positional values," not "show me flags," and mixing flags into that suggestion list clutters the UI. The current per-shell behavior is locked in by ``test_naked_tab_*`` in ``test_bash.py`` / ``test_zsh.py``. * **``--opt=value`` form** is now supported in both shells (Group C), but the cross-shell scenario list below excludes it because each shell reaches it through a different code path and the *space* form alone exercises the binding logic well enough. """ from __future__ import annotations import pytest from .apps import ( app_basic, app_disabled_negative, app_enum, app_list_path, app_multiple_positionals, app_negative, app_nested, app_positional_literal, app_positional_path, app_three_positionals, app_two_iterables, ) # Each scenario: # id - human-readable name (used as pytest id) # app - App object # prog_name - program name (matches what the generator was invoked with) # partial - the command-line fragment the user has typed # contains - suggestions that MUST appear (subset check) # excludes - suggestions that MUST NOT appear (exclusion check) SCENARIOS = [ # 1. Root-level subcommands listed. ( "root-subcommands", app_basic, "basic", "basic ", {"deploy"}, set(), ), # 2. Subcommand prefix filters correctly. ( "subcommand-prefix", app_basic, "basic", "basic de", {"deploy"}, set(), ), # 3. Long-flag prefix. ( "flag-prefix", app_basic, "basic", "basic --v", {"--verbose"}, set(), ), # 4. All top-level options when user has typed just "-". ( "all-flags", app_basic, "basic", "basic -", {"--verbose", "--count"}, set(), ), # 5. Negative form of a bool flag. ( "negative-flag", app_negative, "negapp", "negapp --no-", {"--no-colors"}, set(), ), # 6. Literal choices after an option that takes a value. ( "literal-option-value", app_basic, "basic", "basic deploy --env ", {"dev", "staging", "prod"}, set(), ), # 7. Literal positional (positional-only). ( "literal-positional", app_positional_literal, "poslit", "poslit command ", {"foo", "bar", "baz"}, set(), ), # 8. Position-aware multi-positional: second arg has different choices. ( "multi-positional-second", app_multiple_positionals, "multipos", "multipos command-multi red ", {"cat", "dog"}, {"red", "blue"}, ), # 9. Nested subcommand discovery. ( "nested-subcommand", app_nested, "nested", "nested config ", {"get", "set"}, set(), ), # 10. Path-typed positional triggers file completion (shell-specific). # We can't assert specific filenames, so we only assert the driver # returns *some* non-empty result when a file exists in cwd. ( "path-positional", app_positional_path, "pathpos", "pathpos process ", None, # special-cased in the test body set(), ), # 11. Enum values. ( "enum-value", app_enum, "enumapp", "enumapp --speed ", {"fast", "slow"}, set(), ), # 12. Disabled-negatives + list-of-literal. ( "list-of-literal", app_disabled_negative, "disabledneg", "disabledneg build --param ", {"apple", "banana", "cherry"}, set(), ), # 13. --help is always available at the root. ( "help-at-root", app_basic, "basic", "basic --h", {"--help"}, set(), ), # 14. --opt=value form. Each shell reaches value-completion through a # different code path (bash: COMP_WORDBREAK splits on '=' so the script # walks back through ``$prev``; zsh: ``_arguments`` parses ``--env=`` as # a single token; fish: ``complete -C`` does the right thing natively). # Excluded historically out of caution — this is exactly where per-shell # divergence regressions hide. ( "equals-form-option-value", app_basic, "basic", "basic deploy --env=p", {"prod"}, set(), ), # 15. --help works at a nested command depth, not just the root. ( "help-at-nested-depth", app_nested, "nested", "nested config --h", {"--help"}, set(), ), # 16. Position-aware multi-positional: third slot has its own choices. # Scenario #8 only verifies the second slot — this catches off-by-one # regressions deeper in the cycle. ( "multi-positional-third", app_three_positionals, "multipos3", "multipos3 command-multi3 red cat ", {"small", "large"}, {"red", "blue", "cat", "dog"}, ), # 17. Iterable positional (``list[Path]``) keeps offering completions # past the first slot — a regression net for the rest-owner logic. # Path completion is shell-specific, so we only assert non-empty (same # convention as scenario #10). ( "iterable-positional-rest", app_list_path, "listpath", "listpath a.txt ", None, set(), ), # 18. Two iterable positionals back-to-back. The "first iterable wins # the rest spec" rule (commits 1bc65e8 / 3302dc7) prevents a doubled # rest spec from being emitted. With the bug present, scripts either # fail to parse or silently produce wrong completions at the rest slot. ( "two-iterables-rest-owner", app_two_iterables, "twoiter", "twoiter collect a.txt b.txt ", None, set(), ), ] @pytest.fixture(params=["bash", "zsh", "fish"]) def shell_tester_factory(request): """Pick the right tester factory for this shell parametrization. Resolves the target shell's fixture lazily via ``getfixturevalue`` so a missing shell (e.g. fish absent locally) skips only its own parametrizations, not every row in the matrix. """ return request.getfixturevalue(f"{request.param}_tester") @pytest.mark.parametrize( ("scenario_id", "app", "prog_name", "partial", "contains", "excludes"), SCENARIOS, ids=[s[0] for s in SCENARIOS], ) def test_behavior( scenario_id, app, prog_name, partial, contains, excludes, shell_tester_factory, ): tester = shell_tester_factory(app, prog_name) # Path-completion scenarios opt in with ``contains=None``. Different # shells format file completions differently (fish: full path, bash: # basename, zsh: a mix), so exact content checks would be brittle — # we only assert the driver returns a non-empty result with a real # file in cwd. if contains is None: import os import tempfile from pathlib import Path as _Path with tempfile.TemporaryDirectory() as tmpdir: (_Path(tmpdir) / "sample.txt").write_text("x") cwd = _Path.cwd() try: os.chdir(tmpdir) results = tester.get_completions(partial) finally: os.chdir(cwd) assert results, f"[{scenario_id}] expected some file-completion result for {partial!r}, got nothing" return results = tester.get_completions(partial) missing = contains - set(results) assert not missing, ( f"[{scenario_id}] expected completions {missing} missing from shell output. partial={partial!r} got={results!r}" ) leaked = excludes & set(results) assert not leaked, ( f"[{scenario_id}] completions {leaked} should NOT have been offered. partial={partial!r} got={results!r}" ) BrianPugh-cyclopts-921b1fa/tests/completion/test_detect.py000066400000000000000000000131731517576204000240460ustar00rootroot00000000000000"""Tests for shell detection functionality.""" import subprocess from unittest.mock import Mock import pytest from cyclopts.completion import ShellDetectionError, detect_shell @pytest.fixture def clean_shell_env(monkeypatch): """Remove all shell version environment variables to ensure clean test environment.""" monkeypatch.delenv("ZSH_VERSION", raising=False) monkeypatch.delenv("BASH_VERSION", raising=False) monkeypatch.delenv("FISH_VERSION", raising=False) @pytest.mark.parametrize( ("env_var", "version", "expected"), [ ("ZSH_VERSION", "5.8", "zsh"), ("BASH_VERSION", "5.0.0", "bash"), ("FISH_VERSION", "3.3.0", "fish"), ], ) def test_detect_shell_via_version_variable(monkeypatch, env_var, version, expected): """Test detection of shell via version environment variables.""" monkeypatch.setenv(env_var, version) assert detect_shell() == expected @pytest.mark.parametrize( ("primary_var", "primary_version", "secondary_var", "secondary_version", "expected"), [ ("ZSH_VERSION", "5.8", "BASH_VERSION", "5.0.0", "zsh"), ("BASH_VERSION", "5.0.0", "FISH_VERSION", "3.3.0", "bash"), ], ) def test_version_variable_priority( monkeypatch, primary_var, primary_version, secondary_var, secondary_version, expected ): """Test that higher priority version variables take precedence.""" monkeypatch.setenv(primary_var, primary_version) monkeypatch.setenv(secondary_var, secondary_version) assert detect_shell() == expected def _mock_failed_subprocess(monkeypatch): """Helper to mock failed subprocess for testing fallback behavior.""" mock_result = Mock() mock_result.returncode = 1 mock_result.stdout = "" monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: mock_result) def test_detect_shell_no_detection_methods_available(clean_shell_env, monkeypatch): """Test that ShellDetectionError is raised when no detection methods succeed.""" monkeypatch.delenv("SHELL", raising=False) _mock_failed_subprocess(monkeypatch) with pytest.raises(ShellDetectionError) as exc_info: detect_shell() assert "Unable to detect shell type" in str(exc_info.value) def test_detect_shell_empty_string_not_detected(monkeypatch): """Test that empty string environment variables don't trigger detection.""" monkeypatch.setenv("ZSH_VERSION", "") monkeypatch.setenv("BASH_VERSION", "") monkeypatch.setenv("FISH_VERSION", "") monkeypatch.delenv("SHELL", raising=False) _mock_failed_subprocess(monkeypatch) with pytest.raises(ShellDetectionError): detect_shell() @pytest.mark.parametrize( ("shell_path", "expected"), [ ("/bin/zsh", "zsh"), ("/usr/bin/bash", "bash"), ("/usr/local/bin/fish", "fish"), ("/bin/ZSH", "zsh"), ("/opt/homebrew/bin/fish", "fish"), ], ) def test_detect_shell_fallback_to_shell_variable(clean_shell_env, monkeypatch, shell_path, expected): """Test fallback to SHELL variable for various paths.""" monkeypatch.setenv("SHELL", shell_path) _mock_failed_subprocess(monkeypatch) assert detect_shell() == expected def test_version_variable_takes_priority_over_shell(monkeypatch): """Test that version variables take priority over SHELL variable.""" monkeypatch.setenv("BASH_VERSION", "5.0.0") monkeypatch.setenv("SHELL", "/bin/zsh") assert detect_shell() == "bash" def test_detect_shell_fallback_unsupported_shell(clean_shell_env, monkeypatch): """Test that unsupported shells in SHELL variable raise error.""" monkeypatch.setenv("SHELL", "/bin/ksh") _mock_failed_subprocess(monkeypatch) with pytest.raises(ShellDetectionError): detect_shell() @pytest.mark.parametrize( ("parent_process_name", "expected"), [ ("zsh", "zsh"), ("/bin/bash", "bash"), ("/usr/local/bin/fish", "fish"), ("-zsh", "zsh"), ("login_bash", "bash"), ], ) def test_detect_shell_via_parent_process(clean_shell_env, monkeypatch, parent_process_name, expected): """Test detection via parent process when version variables are not available.""" monkeypatch.delenv("SHELL", raising=False) mock_result = Mock() mock_result.returncode = 0 mock_result.stdout = parent_process_name monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: mock_result) assert detect_shell() == expected def test_detect_shell_subprocess_timeout_falls_back_to_shell_var(clean_shell_env, monkeypatch): """Test that subprocess timeout falls back to SHELL variable.""" monkeypatch.setenv("SHELL", "/bin/zsh") def mock_subprocess_run(*args, **kwargs): raise subprocess.TimeoutExpired(cmd=args[0], timeout=1) monkeypatch.setattr(subprocess, "run", mock_subprocess_run) assert detect_shell() == "zsh" def test_detect_shell_subprocess_error_falls_back_to_shell_var(clean_shell_env, monkeypatch): """Test that subprocess errors fall back to SHELL variable.""" monkeypatch.setenv("SHELL", "/bin/bash") def mock_subprocess_run(*args, **kwargs): raise FileNotFoundError("ps command not found") monkeypatch.setattr(subprocess, "run", mock_subprocess_run) assert detect_shell() == "bash" def test_detect_shell_subprocess_returns_unsupported_shell_falls_back(clean_shell_env, monkeypatch): """Test that unsupported parent process name falls back to SHELL variable.""" monkeypatch.setenv("SHELL", "/bin/fish") mock_result = Mock() mock_result.returncode = 0 mock_result.stdout = "ksh" # Unsupported shell monkeypatch.setattr(subprocess, "run", lambda *args, **kwargs: mock_result) assert detect_shell() == "fish" BrianPugh-cyclopts-921b1fa/tests/completion/test_fish.py000066400000000000000000000523011517576204000235230ustar00rootroot00000000000000"""Tests for fish completion script generation.""" from typing import Annotated, Literal import pytest from cyclopts import App, Parameter from cyclopts.completion.fish import generate_completion_script from .apps import ( app_basic, app_disabled_negative, app_enum, app_list_path, app_markup, app_negative, app_nested, app_path, app_rst, ) def test_generate_completion_script_invalid_prog_name(): """Test that invalid prog_name raises ValueError.""" with pytest.raises(ValueError, match="Invalid prog_name"): generate_completion_script(app_basic, "invalid prog") with pytest.raises(ValueError, match="Invalid prog_name"): generate_completion_script(app_basic, "") with pytest.raises(ValueError, match="Invalid prog_name"): generate_completion_script(app_basic, "prog$name") def test_generate_completion_script_creates_script(): """Test that fish completion generates a script.""" script = generate_completion_script(app_basic, "basic") assert script assert "# Fish completion for basic" in script assert "complete -c basic" in script def test_basic_option_completion(fish_tester): """Test basic option name completion.""" tester = fish_tester(app_basic, "basic") assert "--verbose" in tester.completion_script or "-l verbose" in tester.completion_script assert "--count" in tester.completion_script or "-l count" in tester.completion_script assert "--help" in tester.completion_script or "-l help" in tester.completion_script def test_command_completion(fish_tester): """Test command completion.""" tester = fish_tester(app_basic, "basic") assert "deploy" in tester.completion_script def test_literal_value_completion(fish_tester): """Test Literal type value completion.""" tester = fish_tester(app_basic, "basic") assert "dev" in tester.completion_script assert "staging" in tester.completion_script assert "prod" in tester.completion_script def test_enum_value_completion(fish_tester): """Test Enum type value completion.""" tester = fish_tester(app_enum, "enumapp") assert "fast" in tester.completion_script assert "slow" in tester.completion_script def test_nested_subcommand_completion(fish_tester): """Test nested subcommand completion.""" tester = fish_tester(app_nested, "nested") assert "config" in tester.completion_script assert "get" in tester.completion_script assert "set" in tester.completion_script def test_path_completion_action(fish_tester): """Test that Path types trigger file completion.""" tester = fish_tester(app_path, "pathapp") assert tester.validate_script_syntax() assert "-F" in tester.completion_script or "__fish_complete_directories" in tester.completion_script def test_negative_flag_completion(fish_tester): """Test negative flag handling.""" tester = fish_tester(app_negative, "negapp") script = tester.completion_script assert "--verbose" in script or "-l verbose" in script assert "--no-verbose" in script or "-l no-verbose" in script assert "--colors" in script or "-l colors" in script assert "--no-colors" in script or "-l no-colors" in script def test_disabled_negative_flag_completion(fish_tester): """Test that negative flags are not generated when disabled via App default_parameter.""" tester = fish_tester(app_disabled_negative, "disabledneg") script = tester.completion_script assert "--param" in script or "-l param" in script assert "--empty-param" not in script assert "-l empty-param" not in script def test_script_syntax_validation(fish_tester): """Test that generated script has valid fish syntax.""" tester = fish_tester(app_basic, "basic") assert tester.validate_script_syntax() def test_completion_command_format(fish_tester): """Test that completion uses correct fish command format.""" tester = fish_tester(app_basic, "myapp") assert "complete -c myapp" in tester.completion_script def test_special_characters_in_choices(fish_tester): """Test that special characters in choices are properly escaped.""" app = App(name="special") @app.default def main( value: Annotated[ Literal["normal", "with space", "with'quote", "with$dollar"], Parameter(help="Test value"), ] = "normal", ): """Test app with special characters.""" pass tester = fish_tester(app, "special") assert tester.validate_script_syntax() def test_help_descriptions(fish_tester): """Test that help descriptions appear in completions.""" tester = fish_tester(app_basic, "basic") assert "Enable verbose output" in tester.completion_script assert "Deploy to environment" in tester.completion_script def test_subcommand_conditions(fish_tester): """Test that subcommands use proper fish conditions.""" tester = fish_tester(app_nested, "nested") assert ( "__fish_use_subcommand" in tester.completion_script or "__fish_seen_subcommand_from" in tester.completion_script ) def test_description_escaping(fish_tester): """Test that descriptions with special chars are properly escaped.""" app = App(name="escape_test") @app.default def main( param1: Annotated[str, Parameter(help="Test 'single' quotes")] = "", param2: Annotated[str, Parameter(help='Test "double" quotes')] = "", param3: Annotated[str, Parameter(help="Test $variable and `backticks`")] = "", ): """Test app.""" tester = fish_tester(app, "escape_test") assert tester.validate_script_syntax() def test_special_chars_in_literal_choices(fish_tester): """Test that Literal choices with special characters are properly escaped.""" app = App(name="special_choices") @app.default def main( choice: Annotated[Literal["foo bar", "baz()", "test[1]"], Parameter()] = "foo bar", ): """Test app with special chars in choices.""" tester = fish_tester(app, "special_choices") assert tester.validate_script_syntax() def test_unicode_in_descriptions(fish_tester): """Test that Unicode characters in descriptions are handled properly.""" app = App(name="unicode_test") @app.default def main( emoji: Annotated[str, Parameter(help="Enable 🚀 rocket mode")] = "", chinese: Annotated[str, Parameter(help="中文描述")] = "", arabic: Annotated[str, Parameter(help="وصف بالعربية")] = "", ): """Test app with Unicode.""" tester = fish_tester(app, "unicode_test") assert "🚀" in tester.completion_script or "rocket mode" in tester.completion_script assert tester.validate_script_syntax() def test_deeply_nested_commands(fish_tester): """Test completion for deeply nested commands (3+ levels).""" root = App(name="root") level1 = App(name="level1") level2 = App(name="level2") level3 = App(name="level3") @level3.command def action(value: str): """Perform action at level 3.""" pass level2.command(level3) level1.command(level2) root.command(level1) tester = fish_tester(root, "root") assert "level1" in tester.completion_script assert "level2" in tester.completion_script assert "level3" in tester.completion_script assert "action" in tester.completion_script assert tester.validate_script_syntax() def test_nested_command_disambiguation(fish_tester): """Test that nested commands are properly disambiguated. This test verifies that the helper function correctly distinguishes between commands with overlapping names (e.g., 'config get' vs 'admin get'). """ root = App(name="myapp") config = App(name="config") admin = App(name="admin") @config.command(name="get") def config_get(key: Annotated[str, Parameter(help="Config key")] = ""): """Get config value.""" pass @config.command(name="set") def config_set(key: str = "", value: str = ""): """Set config value.""" pass @admin.command(name="get") def admin_get(user: Annotated[str, Parameter(help="Username")] = ""): """Get admin user.""" pass root.command(config) root.command(admin) tester = fish_tester(root, "myapp") script = tester.completion_script assert "__fish_myapp_using_command" in script, "Helper function should be generated" assert "'__fish_myapp_using_command config'" in script, "Should use helper for config path" assert "'__fish_myapp_using_command admin'" in script, "Should use helper for admin path" config_get_lines = [line for line in script.split("\n") if "config" in line.lower() and "get" in line.lower()] admin_get_lines = [line for line in script.split("\n") if "admin" in line.lower() and "get" in line.lower()] assert any("config key" in line.lower() or "config" in line for line in config_get_lines), ( "Config get should have config-specific completions" ) assert any("username" in line.lower() or "admin" in line for line in admin_get_lines), ( "Admin get should have admin-specific completions" ) assert tester.validate_script_syntax() def test_helper_function_generation(fish_tester): """Test that helper function is only generated when needed.""" root_only = App(name="rootonly") @root_only.default def main(verbose: bool = False): """Root only app.""" pass tester_root = fish_tester(root_only, "rootonly") assert "__fish_rootonly_using_command" not in tester_root.completion_script, "No helper for root-only app" nested = App(name="nested") sub = App(name="sub") @sub.default def action(): """Sub action.""" pass nested.command(sub) tester_nested = fish_tester(nested, "nested") assert "__fish_nested_using_command" in tester_nested.completion_script, "Helper should be generated for nested app" def test_flag_vs_option_distinction(fish_tester): """Test that flags and options are distinguished correctly. Flags should not have -r (require parameter), while options should. """ app = App(name="flagtest") @app.default def main( verbose: Annotated[bool, Parameter(help="Enable verbose")] = False, count: Annotated[int, Parameter(help="Count")] = 1, ): """Test app.""" tester = fish_tester(app, "flagtest") lines = tester.completion_script.split("\n") verbose_lines = [line for line in lines if "verbose" in line.lower()] count_lines = [line for line in lines if "count" in line] has_verbose_flag_only = any("-l verbose" in line and "-r" not in line for line in verbose_lines) has_count_with_r = any("-l count" in line and "-r" in line for line in count_lines) assert has_verbose_flag_only, "Verbose flag should not require parameter" assert has_count_with_r, "Count option should require parameter" def test_optional_path_completion(fish_tester): """Test that Optional[Path] and Path | None generate file completion.""" tester = fish_tester(app_path, "pathapp") assert "-F" in tester.completion_script or "__fish_complete_directories" in tester.completion_script def test_no_file_completion_for_strings(fish_tester): """Test that string options don't default to file completion.""" app = App(name="strtest") @app.default def main( name: Annotated[str, Parameter(help="Name")] = "default", ): """Test app.""" tester = fish_tester(app, "strtest") assert tester.validate_script_syntax() def test_help_version_flags_in_subcommands(fish_tester): """Test that help and version flags appear in subcommand completions.""" tester = fish_tester(app_basic, "basic") script_lines = tester.completion_script.split("\n") deploy_section = [] in_deploy = False for line in script_lines: if "deploy" in line: in_deploy = True if in_deploy: deploy_section.append(line) if "__fish_seen_subcommand_from deploy" in line: for i in range(len(script_lines)): if i > script_lines.index(line): deploy_section.append(script_lines[i]) if "__fish_seen_subcommand_from" in script_lines[i] and "deploy" not in script_lines[i]: break break deploy_text = "\n".join(deploy_section) assert "--help" in deploy_text or "-l help" in deploy_text def test_empty_iterable_flag_completion(fish_tester): """Test that --empty-* flags for list parameters are treated as flags. Regression test for issue where --empty-items on list[str] parameters would expect a value instead of being treated as a flag. """ app = App(name="listapp") @app.command def process( items: Annotated[list[str], Parameter(help="Items to process")], tags: Annotated[list[str] | None, Parameter(help="Optional tags")] = None, ): """Process items.""" pass tester = fish_tester(app, "listapp") assert "--items" in tester.completion_script or "-l items" in tester.completion_script assert "--empty-items" in tester.completion_script or "-l empty-items" in tester.completion_script assert "--tags" in tester.completion_script or "-l tags" in tester.completion_script assert "--empty-tags" in tester.completion_script or "-l empty-tags" in tester.completion_script lines = tester.completion_script.split("\n") empty_items_lines = [line for line in lines if "empty-items" in line] for line in empty_items_lines: if "-l empty-items" in line: assert "-r" not in line, "Negative flags should not require parameter (-r)" assert tester.validate_script_syntax() def test_helper_function_skips_option_values(fish_tester): """Test that helper function correctly identifies and skips option values. Critical bug fix test: ensures that when building command path, the helper function skips values for options that take arguments. Without this, 'myapp --config file.yaml subcommand' would incorrectly extract [file.yaml, subcommand] instead of [subcommand]. """ app = App(name="myapp") sub = App(name="sub") @sub.default def action(): """Subcommand action.""" pass @app.default def main( config: Annotated[str, Parameter(help="Config file path")], verbose: bool = False, ): """Main app.""" pass app.command(sub) tester = fish_tester(app, "myapp") script = tester.completion_script assert "options_with_values" in script, "Helper should track options that take values" assert "--config" in script or "config" in script lines = script.split("\n") options_line = None for line in lines: if "options_with_values" in line and "set -l" in line: options_line = line break assert options_line is not None, "Should have a line setting options_with_values" assert "--config" in options_line or "config" in options_line, "Should include --config in options_with_values" assert "--verbose" not in options_line, "Flags should not be in options_with_values" skip_check = [line for line in lines if "skip_next" in line] assert len(skip_check) > 0, "Helper should use skip_next logic to skip option values" assert tester.validate_script_syntax() def test_markdown_markup_stripped_from_descriptions(fish_tester): """Test that markdown markup is stripped from help descriptions. Ensures that **bold**, *italic*, `code`, and other markdown syntax is properly removed from completion descriptions. """ tester = fish_tester(app_markup, "markupapp") script = tester.completion_script assert "Enable verbose output with extra details" in script, "Should contain plain text version" assert "**verbose**" not in script, "Should not contain markdown bold syntax" assert "`extra`" not in script, "Should not contain markdown code syntax" assert "Choose execution mode: fast or slow" in script, "Should contain plain text version" assert "*execution*" not in script, "Should not contain markdown italic syntax" assert "**fast**" not in script, "Should not contain markdown bold in mode description" assert "**slow**" not in script, "Should not contain markdown bold in mode description" assert "Target environment like dev or prod" in script, "Should contain plain text version" assert "`environment`" not in script, "Should not contain markdown code in env description" assert "**dev**" not in script, "Should not contain markdown bold in env description" assert "**prod**" not in script, "Should not contain markdown bold in env description" assert "Deploy to environment" in script, "Should contain plain text command description" assert "`environment`" not in script, "Should not contain markdown code in command description" assert tester.validate_script_syntax() def test_rst_markup_stripped_from_descriptions(fish_tester): """Test that RST markup is stripped from help descriptions. Ensures that **bold**, ``code``, and other RST syntax is properly removed from completion descriptions. """ tester = fish_tester(app_rst, "rstapp") script = tester.completion_script assert "Enable verbose output with code samples" in script, "Should contain plain text version" assert "**verbose**" not in script, "Should not contain RST bold syntax" assert "``code``" not in script, "Should not contain RST code syntax (double backticks)" assert "Choose execution mode: fast or slow" in script, "Should contain plain text version" assert "*execution*" not in script, "Should not contain RST italic syntax" assert "**fast**" not in script, "Should not contain RST bold in mode description" assert "**slow**" not in script, "Should not contain RST bold in mode description" assert tester.validate_script_syntax() def test_literal_with_show_choices_false(fish_tester): """Test that Literal with show_choices=False still provides completions. Regression test: When show_choices=False is set on a Literal parameter, the choices should still be available for shell completion, even though they are hidden from the help text. """ app = App(name="deploy") @app.default def main( env: Annotated[ Literal["dev", "staging", "prod"], Parameter(help="Environment to deploy to", show_choices=False), ], ): """Deploy to environment.""" pass tester = fish_tester(app, "deploy") script = tester.completion_script # Choices should be in completion script even with show_choices=False assert "dev" in script assert "staging" in script assert "prod" in script def test_command_with_multiple_names_and_aliases(fish_tester): """Test that commands registered with multiple names/aliases all appear in completions. Regression test for groups_from_app() deduplication - ensures all registered names are included in completion scripts. """ app = App(name="myapp") sub = App() @sub.default def action(value: str = ""): """Perform an action.""" pass app.command(sub, name="foo", alias=["bar", "baz"]) tester = fish_tester(app, "myapp") script = tester.completion_script assert "foo" in script, "Primary name should be in completion script" assert "bar" in script, "First alias should be in completion script" assert "baz" in script, "Second alias should be in completion script" assert tester.validate_script_syntax() def test_list_path_completion(fish_tester): """Test that list[Path] arguments generate file completion. Regression test for issue #654: list[Path] arguments should use file completion (-F) just like Path arguments. """ tester = fish_tester(app_list_path, "listpath") script = tester.completion_script assert "-F" in script, "list[Path] should generate file completion" assert tester.validate_script_syntax() def test_fish_path_completion_reflects_caller_cwd(fish_tester): """Path completion must inspect the caller's cwd, not the harness tmpdir. Reproduces the Copilot finding: ``FishCompletionTester.get_completions()`` forces ``cwd=`` to the temporary directory holding the comp-script, so a test that ``os.chdir()``-es into a target directory before calling ``get_completions()`` has the chdir overridden by the driver. Path completion then lists files from the comp-script's tmpdir instead of the intended directory. The driver should inherit the caller's cwd. """ import os import tempfile from pathlib import Path as _Path app = App(name="pcwd") @app.default def m(output: Annotated[_Path, Parameter(help="Out")] = _Path()): """Path keyword.""" tester = fish_tester(app, "pcwd") with tempfile.TemporaryDirectory() as td: (_Path(td) / "myfile.txt").write_text("x") cwd = _Path.cwd() try: os.chdir(td) completions = tester.get_completions("pcwd --output ") finally: os.chdir(cwd) # Fish may return either bare basenames or full paths; accept either. assert any("myfile.txt" in c for c in completions), ( f"expected 'myfile.txt' from caller cwd, got {completions!r} (driver likely overrode cwd to its own tmpdir)" ) BrianPugh-cyclopts-921b1fa/tests/completion/test_install.py000066400000000000000000000354061517576204000242470ustar00rootroot00000000000000"""Tests for completion installation functionality.""" import sys from unittest.mock import patch import pytest from cyclopts import App @pytest.fixture def temp_home(tmp_path, monkeypatch): """Create a temporary home directory for testing.""" if sys.platform == "win32": monkeypatch.setenv("USERPROFILE", str(tmp_path)) else: monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.delenv("ZSH", raising=False) return tmp_path @pytest.fixture def omz_dir(temp_home, monkeypatch): """Create a fake oh-my-zsh installation with $ZSH set.""" omz = temp_home / ".oh-my-zsh" (omz / "custom").mkdir(parents=True) monkeypatch.setenv("ZSH", str(omz)) return omz def test_install_completion_bash_add_to_startup_true(temp_home): """Test that add_to_startup=True adds source line to bashrc.""" app = App(name="testapp") bashrc = temp_home / ".bashrc" install_path = app.install_completion(shell="bash", add_to_startup=True) assert install_path.exists() assert bashrc.exists() bashrc_content = bashrc.read_text() assert "# Load testapp completion" in bashrc_content assert f'[ -f "{install_path}" ] && . "{install_path}"' in bashrc_content def test_install_completion_bash_add_to_startup_false(temp_home): """Test that add_to_startup=False does not modify bashrc.""" app = App(name="testapp") bashrc = temp_home / ".bashrc" install_path = app.install_completion(shell="bash", add_to_startup=False) assert install_path.exists() assert not bashrc.exists() def test_install_completion_bash_add_to_startup_idempotent(temp_home): """Test that running install_completion multiple times doesn't duplicate bashrc entries.""" app = App(name="testapp") bashrc = temp_home / ".bashrc" app.install_completion(shell="bash", add_to_startup=True) first_content = bashrc.read_text() app.install_completion(shell="bash", add_to_startup=True) second_content = bashrc.read_text() assert first_content == second_content assert first_content.count("# Load testapp completion") == 1 def test_install_completion_bash_add_to_startup_preserves_existing(temp_home): """Test that add_to_startup=True preserves existing bashrc content.""" app = App(name="testapp") bashrc = temp_home / ".bashrc" existing_content = "# Existing config\nexport PATH=/usr/local/bin:$PATH\n" bashrc.write_text(existing_content) app.install_completion(shell="bash", add_to_startup=True) bashrc_content = bashrc.read_text() assert existing_content in bashrc_content assert "# Load testapp completion" in bashrc_content def test_install_completion_zsh_add_to_startup_true(temp_home): """Test that add_to_startup=True adds fpath line to zshrc.""" app = App(name="testapp") zshrc = temp_home / ".zshrc" install_path = app.install_completion(shell="zsh", add_to_startup=True) assert install_path.exists() assert zshrc.exists() zshrc_content = zshrc.read_text() completion_dir = install_path.parent assert "# testapp completions" in zshrc_content assert f"fpath=({completion_dir} $fpath)" in zshrc_content def test_install_completion_zsh_add_to_startup_false(temp_home): """Test that add_to_startup=False does not modify zshrc.""" app = App(name="testapp") zshrc = temp_home / ".zshrc" install_path = app.install_completion(shell="zsh", add_to_startup=False) assert install_path.exists() assert not zshrc.exists() def test_install_completion_zsh_add_to_startup_idempotent(temp_home): """Test that running install_completion multiple times doesn't duplicate zshrc entries.""" app = App(name="testapp") zshrc = temp_home / ".zshrc" app.install_completion(shell="zsh", add_to_startup=True) first_content = zshrc.read_text() app.install_completion(shell="zsh", add_to_startup=True) second_content = zshrc.read_text() assert first_content == second_content assert first_content.count("# testapp completions") == 1 def test_install_completion_custom_output_path(temp_home): """Test that custom output path works with add_to_startup.""" app = App(name="testapp") custom_path = temp_home / "custom" / "completion.sh" bashrc = temp_home / ".bashrc" install_path = app.install_completion(shell="bash", output=custom_path, add_to_startup=True) assert install_path == custom_path assert install_path.exists() assert bashrc.exists() bashrc_content = bashrc.read_text() assert str(custom_path) in bashrc_content def test_register_install_completion_command_default_add_to_startup(temp_home): """Test that register_install_completion_command defaults to add_to_startup=True.""" app = App(name="testapp") app.register_install_completion_command() bashrc = temp_home / ".bashrc" with patch("sys.exit"): try: app(["--install-completion", "--shell", "bash"], exit_on_error=False) except SystemExit: pass assert bashrc.exists() bashrc_content = bashrc.read_text() assert "# Load testapp completion" in bashrc_content def test_register_install_completion_command_add_to_startup_false(temp_home): """Test that register_install_completion_command respects add_to_startup=False.""" app = App(name="testapp") app.register_install_completion_command(add_to_startup=False) bashrc = temp_home / ".bashrc" with patch("sys.exit"): try: app(["--install-completion", "--shell", "bash"], exit_on_error=False) except SystemExit: pass assert not bashrc.exists() def test_install_completion_path_with_spaces(temp_home): """Test that paths with spaces are properly quoted in RC file.""" app = App(name="testapp") custom_path = temp_home / "my scripts" / "completion.sh" bashrc = temp_home / ".bashrc" install_path = app.install_completion(shell="bash", output=custom_path, add_to_startup=True) assert install_path.exists() assert bashrc.exists() bashrc_content = bashrc.read_text() assert f'[ -f "{custom_path}" ] && . "{custom_path}"' in bashrc_content def test_register_install_completion_command_custom_help(): """Test that register_install_completion_command respects custom help parameter.""" app = App(name="testapp") custom_help = "My custom installation help text." app.register_install_completion_command(help=custom_help) # Get the registered command install_cmd_app = app["--install-completion"] assert install_cmd_app.help == custom_help def test_install_completion_fish(temp_home): """Test that fish completion installs correctly.""" app = App(name="testapp") install_path = app.install_completion(shell="fish", add_to_startup=False) assert install_path.exists() assert install_path.name == "testapp.fish" assert install_path.parent == temp_home / ".config" / "fish" / "completions" def test_install_completion_command_shell_detection_error(temp_home, monkeypatch, capsys): """Test that install-completion command handles shell detection errors.""" from cyclopts.completion.detect import ShellDetectionError app = App(name="testapp") app.register_install_completion_command() def mock_detect_shell(): raise ShellDetectionError("Cannot detect shell") monkeypatch.setattr("cyclopts.completion.detect.detect_shell", mock_detect_shell) with pytest.raises(SystemExit) as exc_info: app(["--install-completion"], exit_on_error=False) assert exc_info.value.code == 1 captured = capsys.readouterr() assert "Could not auto-detect shell" in captured.err assert "Please specify --shell explicitly" in captured.err def test_install_completion_command_zsh_with_add_to_startup(temp_home, monkeypatch, capsys): """Test that install-completion command prints zsh instructions with add_to_startup.""" app = App(name="testapp") app.register_install_completion_command(add_to_startup=True) monkeypatch.setattr("cyclopts.completion.detect.detect_shell", lambda: "zsh") with patch("sys.exit"): try: app(["--install-completion"], exit_on_error=False) except SystemExit: pass captured = capsys.readouterr() assert "Completion script installed" in captured.out assert "fpath" in captured.out assert ".zshrc" in captured.out assert "exec zsh" in captured.out def test_install_completion_command_zsh_without_add_to_startup(temp_home, monkeypatch, capsys): """Test that install-completion command prints zsh instructions without add_to_startup.""" app = App(name="testapp") app.register_install_completion_command(add_to_startup=False) monkeypatch.setattr("cyclopts.completion.detect.detect_shell", lambda: "zsh") with patch("sys.exit"): try: app(["--install-completion"], exit_on_error=False) except SystemExit: pass captured = capsys.readouterr() assert "Completion script installed" in captured.out assert "ensure" in captured.out.lower() and "$fpath" in captured.out assert "fpath=" in captured.out assert "autoload -Uz compinit" in captured.out assert "exec zsh" in captured.out def test_install_completion_command_bash_with_add_to_startup(temp_home, monkeypatch, capsys): """Test that install-completion command prints bash instructions with add_to_startup.""" app = App(name="testapp") app.register_install_completion_command(add_to_startup=True) monkeypatch.setattr("cyclopts.completion.detect.detect_shell", lambda: "bash") with patch("sys.exit"): try: app(["--install-completion"], exit_on_error=False) except SystemExit: pass captured = capsys.readouterr() assert "Completion script installed" in captured.out assert "Added completion loader to" in captured.out assert ".bashrc" in captured.out assert "source ~/.bashrc" in captured.out def test_install_completion_command_bash_without_add_to_startup(temp_home, monkeypatch, capsys): """Test that install-completion command prints bash instructions without add_to_startup.""" app = App(name="testapp") app.register_install_completion_command(add_to_startup=False) monkeypatch.setattr("cyclopts.completion.detect.detect_shell", lambda: "bash") with patch("sys.exit"): try: app(["--install-completion"], exit_on_error=False) except SystemExit: pass captured = capsys.readouterr() assert "Completion script installed" in captured.out assert "automatically loaded by bash-completion" in captured.out assert "bash-completion is installed" in captured.out assert "exec bash" in captured.out def test_install_completion_command_fish(temp_home, monkeypatch, capsys): """Test that install-completion command prints fish instructions.""" app = App(name="testapp") app.register_install_completion_command() monkeypatch.setattr("cyclopts.completion.detect.detect_shell", lambda: "fish") with patch("sys.exit"): try: app(["--install-completion"], exit_on_error=False) except SystemExit: pass captured = capsys.readouterr() assert "Completion script installed" in captured.out assert "automatically loaded in fish" in captured.out assert "source ~/.config/fish/config.fish" in captured.out def test_install_completion_zsh_ohmyzsh_default_path(omz_dir): """Test that $ZSH set with valid dir installs to $ZSH/custom/completions/_testapp.""" app = App(name="testapp") install_path = app.install_completion(shell="zsh", add_to_startup=True) assert install_path == omz_dir / "custom" / "completions" / "_testapp" assert install_path.exists() def test_install_completion_zsh_ohmyzsh_zsh_custom_env(omz_dir, temp_home, monkeypatch): """Test that $ZSH_CUSTOM takes precedence over $ZSH/custom.""" custom_dir = temp_home / "my-custom-omz" custom_dir.mkdir() monkeypatch.setenv("ZSH_CUSTOM", str(custom_dir)) app = App(name="testapp") install_path = app.install_completion(shell="zsh", add_to_startup=True) assert install_path == custom_dir / "completions" / "_testapp" assert install_path.exists() def test_install_completion_zsh_ohmyzsh_no_zshrc_modification(omz_dir, temp_home): """Test that .zshrc is not created/modified when oh-my-zsh detected.""" zshrc = temp_home / ".zshrc" app = App(name="testapp") app.install_completion(shell="zsh", add_to_startup=True) assert not zshrc.exists() def test_install_completion_zsh_ohmyzsh_dir_missing(temp_home, monkeypatch): """Test that $ZSH pointing to nonexistent dir falls back to vanilla path.""" monkeypatch.setenv("ZSH", str(temp_home / "nonexistent")) app = App(name="testapp") install_path = app.install_completion(shell="zsh", add_to_startup=False) expected = temp_home / ".zsh" / "completions" / "_testapp" assert install_path == expected assert install_path.exists() def test_install_completion_zsh_add_to_startup_prepends(temp_home): """Test that for vanilla zsh, fpath line appears before existing .zshrc content.""" app = App(name="testapp") zshrc = temp_home / ".zshrc" existing_content = "# Existing config\nautoload -Uz compinit && compinit\n" zshrc.write_text(existing_content) app.install_completion(shell="zsh", add_to_startup=True) zshrc_content = zshrc.read_text() fpath_pos = zshrc_content.index("fpath=") existing_pos = zshrc_content.index("# Existing config") assert fpath_pos < existing_pos, "fpath line should be prepended before existing content" def test_install_completion_zsh_ohmyzsh_message(omz_dir, monkeypatch, capsys): """Test that printed output mentions oh-my-zsh and doesn't mention .zshrc.""" app = App(name="testapp") app.register_install_completion_command(add_to_startup=True) monkeypatch.setattr("cyclopts.completion.detect.detect_shell", lambda: "zsh") with patch("sys.exit"): try: app(["--install-completion"], exit_on_error=False) except SystemExit: pass captured = capsys.readouterr() assert "oh-my-zsh" in captured.out assert ".zshrc" not in captured.out assert "exec zsh" in captured.out def test_install_completion_bash_add_to_startup_appends(temp_home): """Regression test: bash still appends to .bashrc.""" app = App(name="testapp") bashrc = temp_home / ".bashrc" existing_content = "# Existing bash config\nexport PATH=/usr/local/bin:$PATH\n" bashrc.write_text(existing_content) app.install_completion(shell="bash", add_to_startup=True) bashrc_content = bashrc.read_text() existing_pos = bashrc_content.index("# Existing bash config") completion_pos = bashrc_content.index("# Load testapp completion") assert existing_pos < completion_pos, "bash completion should be appended after existing content" BrianPugh-cyclopts-921b1fa/tests/completion/test_zsh.py000066400000000000000000001312221517576204000233760ustar00rootroot00000000000000import re from pathlib import Path from typing import Annotated, Literal import pytest from cyclopts import App, Parameter from cyclopts.completion.zsh import generate_completion_script from .apps import ( app_basic, app_disabled_negative, app_enum, app_list_annotated_path, app_list_path, app_markup, app_negative, app_nested, app_path, app_rst, ) def test_basic_option_completion(zsh_tester): """Test basic option name completion.""" tester = zsh_tester(app_basic, "basic") assert "--verbose" in tester.completion_script assert "--count" in tester.completion_script assert "--help" in tester.completion_script def test_command_completion(zsh_tester): """Test command completion.""" tester = zsh_tester(app_basic, "basic") assert "deploy" in tester.completion_script def test_literal_value_completion(zsh_tester): """Test Literal type value completion.""" tester = zsh_tester(app_basic, "basic") assert "dev" in tester.completion_script assert "staging" in tester.completion_script assert "prod" in tester.completion_script def test_enum_value_completion(zsh_tester): """Test Enum type value completion.""" tester = zsh_tester(app_enum, "enumapp") assert "fast" in tester.completion_script assert "slow" in tester.completion_script def test_nested_subcommand_completion(zsh_tester): """Test nested subcommand completion.""" tester = zsh_tester(app_nested, "nested") assert "config" in tester.completion_script assert "get" in tester.completion_script assert "set" in tester.completion_script def test_negative_flag_completion(zsh_tester): """Test negative flag completion.""" tester = zsh_tester(app_negative, "negapp") assert "--no-verbose" in tester.completion_script assert "--no-colors" in tester.completion_script def test_disabled_negative_flag_completion(zsh_tester): """Test that negative flags are not generated when disabled via App default_parameter.""" tester = zsh_tester(app_disabled_negative, "disabledneg") assert "--param" in tester.completion_script assert "--empty-param" not in tester.completion_script def test_help_descriptions(zsh_tester): """Test that help descriptions appear in completions.""" tester = zsh_tester(app_basic, "basic") assert "Enable verbose output" in tester.completion_script assert "Deploy to environment" in tester.completion_script def test_script_syntax_valid(zsh_tester): """Test that generated script has valid zsh syntax.""" tester = zsh_tester(app_basic, "basic") assert tester.validate_script_syntax() def test_compdef_header(zsh_tester): """Test that script has proper #compdef header.""" tester = zsh_tester(app_basic, "basic") assert tester.completion_script.startswith("#compdef basic") def test_path_completion(zsh_tester): """Test that Path types generate file completion.""" tester = zsh_tester(app_path, "pathapp") assert "_files" in tester.completion_script def test_optional_path_completion(zsh_tester): """Test that Optional[Path] and Path | None generate file completion. Value-bearing options carry a ``*`` prefix so the option can repeat (required for collection-typed options like ``list[Path]``). The option name has no ``=`` suffix by default — TAB-completion inserts ``--opt`` plus a space rather than ``--opt=``. ``Parameter(requires_equals=True)`` flips this on and is exercised separately. """ tester = zsh_tester(app_path, "pathapp") assert "'*--output[Output file]:output:_files'" in tester.completion_script def test_nested_command_uses_correct_word_index(zsh_tester): """Test that nested commands use $words[1] for subcommand dispatch.""" tester = zsh_tester(app_nested, "nested") script_lines = tester.completion_script.split("\n") words_checks = [line for line in script_lines if "case $words[1] in" in line] assert len(words_checks) >= 2, "Should have multiple case $words[1] checks for nested commands" def test_invalid_prog_name(): """Test that invalid prog names raise ValueError.""" with pytest.raises(ValueError, match="Invalid prog_name"): generate_completion_script(app_basic, "foo bar") with pytest.raises(ValueError, match="Invalid prog_name"): generate_completion_script(app_basic, "test;rm -rf /") with pytest.raises(ValueError, match="Invalid prog_name"): generate_completion_script(app_basic, "") def test_description_escaping(zsh_tester): """Test that descriptions with special chars are properly escaped. Note: Backticks are now treated as markdown code syntax and stripped, so they no longer appear in the completion script. """ app = App(name="escape_test") @app.default def main( param1: Annotated[str, Parameter(help="Test 'single' quotes")] = "", param2: Annotated[str, Parameter(help='Test "double" quotes')] = "", param3: Annotated[str, Parameter(help="Test $variable and `backticks`")] = "", param4: Annotated[str, Parameter(help="Test [brackets] here")] = "", ): """Test app.""" tester = zsh_tester(app, "escape_test") assert r"'\'' " in tester.completion_script or "'\\''" in tester.completion_script assert "\\$" in tester.completion_script # Backticks are now stripped as markdown code syntax assert "backticks" in tester.completion_script assert "\\`" not in tester.completion_script assert r"\[" in tester.completion_script assert r"\]" in tester.completion_script def test_special_chars_in_literal_choices(zsh_tester): """Test that Literal choices with special characters are properly escaped. Choice-bearing specs are emitted with double-quoted outer strings (so a literal ``'`` can be embedded) which means each backslash from the inner choice-list-parser layer is doubled by the outer DQ layer. The runtime behavior is verified separately in ``test_choice_with_*`` below. """ app = App(name="special_choices") @app.default def main( choice: Annotated[Literal["foo bar", "baz()", "test[1]", "back\\slash"], Parameter()] = "foo bar", ): """Test app with special chars in choices.""" tester = zsh_tester(app, "special_choices") # After the two-layer escape ("" outer + choice-list parser): every # backslash from layer 1 is doubled. assert r"foo\\ bar" in tester.completion_script assert r"baz\\(\\)" in tester.completion_script assert r"test\\[1\\]" in tester.completion_script # User-supplied backslash gets layer-1 ``\\\\`` then DQ-doubled to ``\\\\\\\\`` assert r"back\\\\slash" in tester.completion_script def test_unicode_in_descriptions(zsh_tester): """Test that Unicode characters in descriptions are handled properly.""" app = App(name="unicode_test") @app.default def main( emoji: Annotated[str, Parameter(help="Enable 🚀 rocket mode")] = "", chinese: Annotated[str, Parameter(help="中文描述")] = "", arabic: Annotated[str, Parameter(help="وصف بالعربية")] = "", ): """Test app with Unicode.""" tester = zsh_tester(app, "unicode_test") assert "🚀" in tester.completion_script or "rocket mode" in tester.completion_script assert tester.validate_script_syntax() def test_deeply_nested_commands(zsh_tester): """Test completion for deeply nested commands (3+ levels).""" root = App(name="root") level1 = App(name="level1") level2 = App(name="level2") level3 = App(name="level3") @level3.command def action(value: str): """Perform action at level 3.""" pass level2.command(level3) level1.command(level2) root.command(level1) tester = zsh_tester(root, "root") assert "level1" in tester.completion_script assert "level2" in tester.completion_script assert "level3" in tester.completion_script assert "action" in tester.completion_script assert tester.validate_script_syntax() def test_no_trailing_colons_in_specs(zsh_tester): """Test that argument specs don't have trailing colons when action is empty. Regression test for issue where specs like '1:description:' or '*:args:' would cause zsh eval errors. When there's no completion action, the spec should be '1:description' or '*:args' (no trailing colon). """ app = App(name="notrail") @app.command def run( pos1: Annotated[str, Parameter(help="First positional")], pos2: Annotated[int, Parameter(help="Second positional")], *args: Annotated[str, Parameter(help="Variadic args")], ): """Command with positionals.""" pass @app.default def main( flag: Annotated[str, Parameter(help="A non-path option")], ): """Main command.""" pass tester = zsh_tester(app, "notrail") # Check for problematic trailing colons (but not in file/directory actions) for line in tester.completion_script.split("\n"): stripped = line.strip() # Skip comments and lines with valid actions if stripped.startswith("#") or ":_files" in line or ":_directories" in line: continue # Check for trailing :' patterns (ignoring quote escaping) if line.rstrip().endswith(":'") or line.rstrip().endswith(":' \\"): if "'\\'':" not in line: # Not part of quote escaping in descriptions raise AssertionError(f"Found trailing colon in spec: {line}") assert tester.validate_script_syntax() def test_colon_escaping_in_descriptions(zsh_tester): """Test that colons in descriptions are escaped to prevent field separator issues. Regression test for issue where colons in positional argument descriptions like ':app_object' would be treated as field separators in specs like '1:message:action', causing unmatched quote errors. """ app = App(name="colontest") @app.command def run( script: Annotated[str, Parameter(help="Path with ':app' notation")], ): """Command with colon in description.""" pass tester = zsh_tester(app, "colontest") assert r"\:" in tester.completion_script assert tester.validate_script_syntax() def test_run_command_only_special_for_cyclopts(zsh_tester): """Test that 'run' command only gets dynamic completion for cyclopts CLI, not user apps. Regression test for issue where any app with a 'run' command would get dynamic completion instead of normal static completion. """ app = App(name="myapp") @app.command def run( script: Annotated[str, Parameter(help="Script to execute")], verbose: Annotated[bool, Parameter(help="Verbose mode")] = False, ): """Run a script.""" pass tester = zsh_tester(app, "myapp") # Should generate normal static completion, not dynamic completion # Dynamic completion has "local script_path", "local -a completions", etc. assert "local script_path" not in tester.completion_script assert "_complete run" not in tester.completion_script # Should have normal argument specs for the run command assert "--verbose" in tester.completion_script or "verbose" in tester.completion_script.lower() assert tester.validate_script_syntax() def test_cyclopts_run_command_has_dynamic_completion(zsh_tester): """Test that cyclopts CLI's 'run' command gets dynamic completion.""" from cyclopts.cli import app as cyclopts_app tester = zsh_tester(cyclopts_app, "cyclopts") # Should have dynamic completion for the run command assert "local script_path" in tester.completion_script assert "_complete run" in tester.completion_script assert tester.validate_script_syntax() def test_empty_iterable_flag_completion(zsh_tester): """Test that --empty-* flags for list parameters are treated as flags. Regression test for issue where --empty-items on list[str] parameters would expect a value instead of being treated as a flag. """ app = App(name="listapp") @app.command def process( items: Annotated[list[str], Parameter(help="Items to process")], tags: Annotated[list[str] | None, Parameter(help="Optional tags")] = None, count: Annotated[int, Parameter(help="Count")] = 1, ): """Process items.""" pass tester = zsh_tester(app, "listapp") # Both positive and negative flags should be present assert "--items" in tester.completion_script assert "--empty-items" in tester.completion_script assert "--tags" in tester.completion_script assert "--empty-tags" in tester.completion_script assert "--count" in tester.completion_script # Negative flags should be formatted as flags (no trailing :action) # They should have the format '--empty-items[description]' not '--empty-items[description]:empty-items' assert "'--empty-items[Items to process]'" in tester.completion_script assert "'--empty-tags[Optional tags]'" in tester.completion_script # Positive names should expect values (have :action or :name suffix). # Match both the plain and ``*``-prefixed (repeatable) spec forms. lines_with_items = [ line for line in tester.completion_script.split("\n") if "--items[" in line and "empty" not in line ] assert any(":items:" in line or ":items'" in line for line in lines_with_items), ( "Positive --items flag should expect a value" ) assert tester.validate_script_syntax() def test_positional_or_keyword_literal_completion(zsh_tester): """Test that POSITIONAL_OR_KEYWORD Literal arguments generate positional completion. Regression test for issue where 'cyclopts-demo deploy d' should complete to 'dev' but no completions were shown. The environment parameter is POSITIONAL_OR_KEYWORD (not positional-only) and has Literal choices. """ from typing import Literal app = App(name="testapp") @app.command def deploy( environment: Literal["dev", "staging", "production"], region: Literal["us-east-1", "us-west-2"] = "us-east-1", ): """Deploy to environment. Parameters ---------- environment : Literal["dev", "staging", "production"] Target environment. region : Literal["us-east-1", "us-west-2"] AWS region. """ pass tester = zsh_tester(app, "testapp") # In nested context, positionals are now included as specs in _arguments # Positions are '1:' and '2:' (not '2:' and '3:') because after *::arg:->args, # $words[1] is the subcommand and positionals start at position 1. # Choice-bearing specs use double-quoted outer to allow embedding ``'``. assert '"1:Target environment.:(dev staging production)"' in tester.completion_script assert '"2:AWS region.:(us-east-1 us-west-2)"' in tester.completion_script # Should have choices for both positionals assert "dev" in tester.completion_script assert "staging" in tester.completion_script assert "production" in tester.completion_script assert "us-east-1" in tester.completion_script assert "us-west-2" in tester.completion_script # Should also have keyword spec for --environment assert "--environment" in tester.completion_script assert tester.validate_script_syntax() def test_help_version_flags_in_subcommands(zsh_tester): """Test that help and version flags appear in subcommand completions. Regression test for issue where --help and --version were only available in the root command but not in subcommands. """ tester = zsh_tester(app_basic, "basic") script_lines = tester.completion_script.split("\n") # Walk from the deploy case label to its closing ``;;``. Track # nested ``case ... in / esac`` so the eq-form pre-pass's inner # ``;;`` markers don't terminate the scan early. in_deploy = False case_depth = 0 deploy_section = [] for line in script_lines: if "deploy)" in line and not in_deploy: in_deploy = True if in_deploy: deploy_section.append(line) if re.search(r"\bcase\b.*\bin\b", line): case_depth += 1 elif re.search(r"\besac\b", line): case_depth -= 1 elif ";;" in line and case_depth == 0: break deploy_text = "\n".join(deploy_section) assert "--help[Display this message and exit.]" in deploy_text assert "-h[Display this message and exit.]" in deploy_text assert "--version[Display application version.]" in deploy_text def test_nested_command_disambiguation(zsh_tester): """Test that nested commands are properly disambiguated. This test verifies that the helper function correctly distinguishes between commands with overlapping names (e.g., 'config get' vs 'admin get'). """ root = App(name="myapp") config = App(name="config") admin = App(name="admin") @config.command(name="get") def config_get(key: Annotated[str, Parameter(help="Config key")] = ""): """Get config value.""" pass @config.command(name="set") def config_set(key: str = "", value: str = ""): """Set config value.""" pass @admin.command(name="get") def admin_get(user: Annotated[str, Parameter(help="Username")] = ""): """Get admin user.""" pass root.command(config) root.command(admin) tester = zsh_tester(root, "myapp") script = tester.completion_script config_lines = [line for line in script.split("\n") if "config" in line.lower()] admin_lines = [line for line in script.split("\n") if "admin" in line.lower()] assert any("config" in line for line in config_lines), "Should have config-specific completions" assert any("admin" in line for line in admin_lines), "Should have admin-specific completions" assert tester.validate_script_syntax() def test_helper_function_generation(zsh_tester): """Test that command path detection logic is generated when needed.""" root_only = App(name="rootonly") @root_only.default def main(verbose: bool = False): """Root only app.""" pass tester_root = zsh_tester(root_only, "rootonly") script_root = tester_root.completion_script nested = App(name="nested") sub = App(name="sub") @sub.default def action(): """Sub action.""" pass nested.command(sub) tester_nested = zsh_tester(nested, "nested") script_nested = tester_nested.completion_script assert len(script_nested) > len(script_root), "Nested app should have more complex completion logic" def test_no_file_completion_for_strings(zsh_tester): """Test that string options don't default to file completion.""" app = App(name="strtest") @app.default def main( name: Annotated[str, Parameter(help="Name")] = "default", ): """Test app.""" tester = zsh_tester(app, "strtest") assert tester.validate_script_syntax() def test_helper_function_skips_option_values(zsh_tester): """Test that helper function correctly identifies and skips option values. Critical bug fix test: ensures that when building command path, the helper function skips values for options that take arguments. Without this, 'myapp --config file.yaml subcommand' would incorrectly extract [file.yaml, subcommand] instead of [subcommand]. """ app = App(name="myapp") sub = App(name="sub") @sub.default def action(): """Subcommand action.""" pass @app.default def main( config: Annotated[str, Parameter(help="Config file path")], verbose: bool = False, ): """Main app.""" pass app.command(sub) tester = zsh_tester(app, "myapp") script = tester.completion_script assert "--config" in script or "config" in script assert tester.validate_script_syntax() def test_markdown_markup_stripped_from_descriptions(zsh_tester): """Test that markdown markup is stripped from help descriptions. Ensures that **bold**, *italic*, `code`, and other markdown syntax is properly removed from completion descriptions. """ tester = zsh_tester(app_markup, "markupapp") script = tester.completion_script assert "Enable verbose output with extra details" in script, "Should contain plain text version" assert "**verbose**" not in script, "Should not contain markdown bold syntax" assert "`extra`" not in script, "Should not contain markdown code syntax" # Note: zsh escapes colons with backslashes in descriptions assert "Choose execution mode" in script and "fast or slow" in script, "Should contain plain text version" assert "*execution*" not in script, "Should not contain markdown italic syntax" assert "**fast**" not in script, "Should not contain markdown bold in mode description" assert "**slow**" not in script, "Should not contain markdown bold in mode description" assert "Target environment like dev or prod" in script, "Should contain plain text version" assert "`environment`" not in script, "Should not contain markdown code in env description" assert "**dev**" not in script, "Should not contain markdown bold in env description" assert "**prod**" not in script, "Should not contain markdown bold in env description" assert "Deploy to environment" in script, "Should contain plain text command description" assert tester.validate_script_syntax() def test_rst_markup_stripped_from_descriptions(zsh_tester): """Test that RST markup is stripped from help descriptions. Ensures that **bold**, ``code``, and other RST syntax is properly removed from completion descriptions. """ tester = zsh_tester(app_rst, "rstapp") script = tester.completion_script assert "Enable verbose output with code samples" in script, "Should contain plain text version" assert "**verbose**" not in script, "Should not contain RST bold syntax" assert "``code``" not in script, "Should not contain RST code syntax (double backticks)" assert "Choose execution mode" in script and "fast or slow" in script, "Should contain plain text version" assert "*execution*" not in script, "Should not contain RST italic syntax" assert "**fast**" not in script, "Should not contain RST bold in mode description" assert "**slow**" not in script, "Should not contain RST bold in mode description" assert tester.validate_script_syntax() def test_no_direct_function_call(zsh_tester): """Test that completion script doesn't call the completion function directly. Regression test for issue where the script ended with '_progname "$@"' which caused "_arguments:comparguments:327: can only be called from completion function" error when the completion file was sourced during shell startup. The #compdef directive is sufficient to register the completion function; a direct call is unnecessary and causes errors since _arguments can only be called within a completion context. """ tester = zsh_tester(app_basic, "basic") script = tester.completion_script # Should not have a direct call to the completion function assert '_basic "$@"' not in script, "Should not directly call the completion function" # Script should end with the closing brace (no compdef needed - #compdef directive suffices) lines = script.rstrip().split("\n") assert lines[-1] == "}", "Script should end with closing brace" # Should have the #compdef directive for autoload compatibility assert script.startswith("#compdef basic"), "Should have #compdef directive" assert tester.validate_script_syntax() def test_literal_with_show_choices_false(zsh_tester): """Test that Literal with show_choices=False still provides completions. Regression test: When show_choices=False is set on a Literal parameter, the choices should still be available for shell completion, even though they are hidden from the help text. """ app = App(name="deploy") @app.default def main( env: Annotated[ Literal["dev", "staging", "prod"], Parameter(help="Environment to deploy to", show_choices=False), ], ): """Deploy to environment.""" pass tester = zsh_tester(app, "deploy") script = tester.completion_script # Choices should be in completion script even with show_choices=False assert "dev" in script assert "staging" in script assert "prod" in script def test_command_with_multiple_names_and_aliases(zsh_tester): """Test that commands registered with multiple names/aliases all appear in completions. Regression test for groups_from_app() deduplication - ensures all registered names are included in completion scripts. """ app = App(name="myapp") sub = App() @sub.default def action(value: str = ""): """Perform an action.""" pass app.command(sub, name="foo", alias=["bar", "baz"]) tester = zsh_tester(app, "myapp") script = tester.completion_script assert "foo" in script, "Primary name should be in completion script" assert "bar" in script, "First alias should be in completion script" assert "baz" in script, "Second alias should be in completion script" assert tester.validate_script_syntax() def test_nested_variadic_positional_completion(zsh_tester): """Test that variadic positionals work in nested command contexts. Variadic positionals (*args) should accept completion at multiple positions. """ app = App(name="testapp") @app.command def process( *files: Annotated[Path, Parameter(help="Files to process")], ): """Process multiple files. Parameters ---------- files : Path Files to process. """ pass tester = zsh_tester(app, "testapp") script = tester.completion_script # Should use _files completion for Path positionals assert "_files" in script, "Path positionals should use _files completion" # For variadic, should have '*:desc:_files' spec assert "'*:" in script, "Variadic positionals should use * spec" assert tester.validate_script_syntax() def test_list_path_completion(zsh_tester): """Test that list[Path] arguments generate file completion. Regression test for issue #654: list[Path] arguments should use file completion (_files) just like Path arguments. """ tester = zsh_tester(app_list_path, "listpath") script = tester.completion_script assert "_files" in script, "list[Path] should generate file completion" assert tester.validate_script_syntax() def test_list_path_positional_variadic(zsh_tester): """Test that list[Path] positional generates variadic '*' spec, not just '1:'. A list parameter that can consume multiple positional values should offer file completion for ALL positions, not just the first. """ tester = zsh_tester(app_list_path, "listpath") script = tester.completion_script assert "'*:" in script, "list[Path] positional should use variadic '*' spec for all positions" assert "_files" in script assert tester.validate_script_syntax() def test_list_annotated_path_completion(zsh_tester): """Test that list[Annotated[Path, ...]] arguments generate file completion. When a type like ExistingFile (Annotated[Path, Parameter(...)]) is used inside a collection like list[], the Annotated wrapper must be unwrapped to detect the underlying Path type for completion. """ tester = zsh_tester(app_list_annotated_path, "listannotatedpath") script = tester.completion_script assert "_files" in script, "list[ExistingFile] should generate file completion" assert tester.validate_script_syntax() def test_colon_in_command_name(zsh_tester): """Test that colons in command names are properly escaped. Regression test for issue #715: Command names containing colons like 'utility:ping' should have the colon escaped in zsh completion scripts so that the full name is displayed, not just the part before the colon. Colons need escaping both in _describe format and in case patterns because zsh's completion system treats them specially when populating the $words array. """ app = App(name="myapp") sub = App() @sub.default def action(value: str = ""): """Perform an action.""" pass # Register command with colon in name app.command(sub, name="utility:ping") tester = zsh_tester(app, "myapp") script = tester.completion_script # The colon should be escaped in the _describe format # 'utility\:ping:description' instead of 'utility:ping:description' assert r"utility\:ping" in script, "Colon in command name should be escaped" # The case pattern should also have the colon escaped for proper $words matching assert r"utility\:ping)" in script, "Colon should be escaped in case pattern for $words matching" assert tester.validate_script_syntax() def test_special_chars_in_command_name(zsh_tester): """Test that special characters in command names are properly escaped. Tests escaping for various special characters that could appear in command names and cause issues in zsh completion scripts. """ app = App(name="myapp") sub1 = App() @sub1.default def action1(): """Action with brackets.""" pass # Register command with brackets in name (unusual but possible) app.command(sub1, name="test[1]") tester = zsh_tester(app, "myapp") script = tester.completion_script # Brackets should be escaped in the _describe format assert r"test\[1\]" in script, "Brackets in command name should be escaped" # In case patterns, brackets should also be escaped assert r"test\[1\])" in script, "Brackets in case pattern should be escaped" assert tester.validate_script_syntax() def test_positional_without_help_uses_name_fallback(zsh_tester): """Test that positional arguments without help text use parameter name as description. Regression test: zsh _arguments requires non-empty descriptions for positional specs to provide completions. When a parameter has no help text, the completion script should fall back to using the parameter name as the description. Without this fallback, '1::(choices)' is generated which silently breaks completion, while '1:param_name:(choices)' works correctly. """ app = App(name="testapp") @app.command def action( choice: Literal["foo", "bar"], # No Parameter() annotation, no help text /, ): """Do something.""" pass tester = zsh_tester(app, "testapp") script = tester.completion_script # Should NOT have empty description '1::(foo bar)' or "1::(foo bar)" assert "'1::(foo bar)'" not in script, "Should not have empty description in positional spec" assert '"1::(foo bar)"' not in script, "Should not have empty description in positional spec" # Should have parameter name as fallback. Choice-bearing specs use # double-quoted outer; the format is `"1:description:(choices)"`. positional_spec = re.search(r'"1:([^:]+):\(foo bar\)"', script) assert positional_spec is not None, "Should have positional spec with choices" description = positional_spec.group(1) assert description, "Description should not be empty" assert len(description) > 0, "Description should have content" assert tester.validate_script_syntax() # --- Choice values containing whitespace / shell metacharacters -------------- # # End-to-end regression tests for tricky choice values surviving zsh's # completion machinery. zsh's ``_arguments`` puts choices through *two* # parsers: the outer shell quoting and the inner choice-list parser. The # generator now uses double-quoted outer specs so a literal ``'`` can be # embedded as ``\'``. Without that, ``Literal["a'b"]`` produced ``(eval):1: # unmatched '`` at completion time. def test_choice_with_whitespace(zsh_tester): """A choice value containing a space stays a single completion.""" app = App(name="ws") @app.default def m(x: Literal["hello world", "normal"] = "normal", /): """Whitespace in choice.""" tester = zsh_tester(app, "ws") assert tester.validate_script_syntax() completions = tester.get_completions("ws ") assert "hello world" in completions assert "hello" not in completions assert "world" not in completions def test_choice_with_single_quote(zsh_tester): r"""A choice value containing a single quote does not break the script. Regression test: previously ``Literal["a'b"]`` produced ``'1:X:(... a'\\''b)'``. After the outer single-quote-end-restart trick, ``_arguments``' choice-list parser saw an unbalanced ``'`` and failed with ``(eval):1: unmatched '``. """ app = App(name="sq") @app.default def m(x: Literal["a'b", "normal"] = "normal", /): """Single quote in choice.""" tester = zsh_tester(app, "sq") assert tester.validate_script_syntax() completions = tester.get_completions("sq ") assert "a'b" in completions assert not any("unmatched" in c for c in completions) def test_choice_with_backtick(zsh_tester): """A choice value containing a backtick does not break the script.""" app = App(name="btk") @app.default def m(x: Literal["a`b", "normal"] = "normal", /): """Backtick in choice.""" tester = zsh_tester(app, "btk") assert tester.validate_script_syntax() completions = tester.get_completions("btk ") assert "a`b" in completions def test_choice_with_dollar(zsh_tester): """A choice value containing $ is not parameter-expanded.""" app = App(name="dol") @app.default def m(x: Literal["$home", "normal"] = "normal", /): """Dollar in choice.""" tester = zsh_tester(app, "dol") assert tester.validate_script_syntax() completions = tester.get_completions("dol ") assert "$home" in completions def test_choice_value_after_keyword_option(zsh_tester): """Tricky choices also survive the ``--opt `` value-completion path.""" app = App(name="optchoice") @app.default def m(env: Annotated[Literal["a b", "c'd", "e`f"], Parameter(help="env")] = "a b"): """Tricky choices behind a keyword option.""" tester = zsh_tester(app, "optchoice") assert tester.validate_script_syntax() completions = tester.get_completions("optchoice --env ") assert {"a b", "c'd", "e`f"} <= set(completions) # --- --opt=value form: gated on Parameter.requires_equals ------------------ # # The ``=`` suffix on a zsh ``_arguments`` option spec is load-bearing in # two ways at once: it enables ``--opt=value`` value-completion AND it # changes the *name* TAB-insert from ``--opt `` (trailing space) to # ``--opt=`` (no space). zsh has no native syntax that decouples these. # # Because forced ``=`` insertion surprises most users and the space form # is the more common across CLIs, the default (``requires_equals=False``) # emits the plain spec — at the cost of ``--opt=value`` # value-completion silently doing nothing. ``requires_equals=True`` opts # back into the eq spec so completion mirrors what the parser accepts. # # Bash is unaffected by this trade-off — its eq-form completion goes # through ``_value_prev`` hopping over ``=`` and works regardless. def test_default_no_eq_in_spec_name(zsh_tester): """Default: no ``=`` suffix on the option name in the spec. The ``--env`` option has Literal choices, so its spec uses a double-quoted outer string. The assertion checks for the ``=``-less form regardless of which quote style wraps the spec. """ tester = zsh_tester(app_basic, "basic") script = tester.completion_script # Plain spec — TAB on the name will insert ``--env`` plus a space. assert '"*--env[' in script or "'*--env[" in script assert '"*--env=[' not in script assert "'*--env=[" not in script def test_default_space_form_value_completion(zsh_tester): """The space form must offer value completion regardless of ``requires_equals``.""" tester = zsh_tester(app_basic, "basic") completions = tester.get_completions("basic deploy --env ") assert {"dev", "staging", "prod"} <= set(completions) def test_default_eq_form_value_completion_via_prepass(zsh_tester): """``--opt=value`` works in the default case via the compset pre-pass. Even with the plain (no-``=``) ``_arguments`` spec, a user who explicitly types ``--env=`` should still see value completions: the pre-pass intercepts the pattern, strips the ``--env=`` prefix, and dispatches to the choice list. """ tester = zsh_tester(app_basic, "basic") completions = tester.get_completions("basic deploy --env=") assert {"dev", "staging", "prod"} <= set(completions) def test_default_eq_form_value_completion_partial(zsh_tester): """``--opt=d`` narrows via the pre-pass.""" tester = zsh_tester(app_basic, "basic") completions = tester.get_completions("basic deploy --env=d") assert "dev" in completions assert "staging" not in completions assert "prod" not in completions def test_default_eq_form_path_completion_via_prepass(zsh_tester): """Path-typed options also dispatch to ``_files`` from the pre-pass.""" import os import tempfile from pathlib import Path as _Path app = App(name="ekw") @app.default def m(input_file: Annotated[_Path, Parameter(help="In")] = _Path()): """Path keyword.""" tester = zsh_tester(app, "ekw") with tempfile.TemporaryDirectory() as td: (_Path(td) / "sample.txt").write_text("x") cwd = _Path.cwd() try: os.chdir(td) completions = tester.get_completions("ekw --input-file=") finally: os.chdir(cwd) assert completions, f"expected file completion via pre-pass, got {completions!r}" def test_eq_form_prepass_compadd_no_stray_backslashes(zsh_tester): r"""Eq-form prepass ``compadd`` args must be POSIX-safe single-quoted. Reproduces the Copilot review finding: choices are passed through ``_escape_completion_choice`` (which inserts ``\\`` escapes designed for ``_describe``'s inner parser) and then wrapped in *single quotes* for ``compadd``. Inside single quotes, backslashes are literal — so a choice like ``"foo bar"`` ends up as ``'foo\\ bar'`` and the user sees a completion containing a stray backslash. The fix is proper single-quote escaping (only ``'`` needs handling, via the ``'\\''`` end/restart trick). """ app = App(name="eqp") @app.default def m(env: Annotated[Literal["foo bar", "baz qux"], Parameter(help="E")] = "foo bar"): """Eq-form choices with spaces.""" tester = zsh_tester(app, "eqp") script = tester.completion_script # Locate the prepass case branch for ``--env`` and the inner ``compadd`` line. lines = script.splitlines() in_env_case = False compadd_line = None for line in lines: stripped = line.strip() if stripped == "--env=*)": in_env_case = True continue if in_env_case: if stripped.startswith("compadd "): compadd_line = stripped break if stripped == ";;": break assert compadd_line is not None, "no ``compadd`` line found in --env=*) prepass branch" # Single-quoted compadd args must not contain literal backslash-escape # sequences — those would render as visible backslashes in the user's # completion menu. assert "\\ " not in compadd_line, ( f"single-quoted compadd args contain literal '\\ ' (backslash-space): {compadd_line!r}" ) def test_requires_equals_emits_eq_spec(zsh_tester): """``Parameter(requires_equals=True)`` opts back into the eq spec.""" app = App(name="rqeq") @app.default def m(env: Annotated[Literal["dev", "prod"], Parameter(requires_equals=True)] = "dev"): """Eq-required.""" tester = zsh_tester(app, "rqeq") script = tester.completion_script assert "*--env=[" in script # The pre-pass should NOT include this option — its eq-form is already # handled by the spec. assert "--env=*)" not in script def test_requires_equals_eq_form_value_completion(zsh_tester): """``--opt=`` value completion works when ``requires_equals=True``.""" app = App(name="rqeq") @app.default def m(env: Annotated[Literal["dev", "prod"], Parameter(requires_equals=True)] = "dev"): """Eq-required.""" tester = zsh_tester(app, "rqeq") completions = tester.get_completions("rqeq --env=") assert {"dev", "prod"} <= set(completions) # --- Repeatable value-options ---------------------------------------------- # # zsh's ``_arguments`` defaults each spec to single-use, so once an option # has been consumed it disappears from suggestions and its value can't be # completed again. Cyclopts now prefixes value-bearing specs with ``*`` so # the option may repeat — required for collection-typed options # (``list[X]``) and matches bash's behavior. Bool flags stay non-repeating. def test_value_option_repeatable_space_form(zsh_tester): """``--env dev --env`` should still offer choices.""" tester = zsh_tester(app_basic, "basic") completions = tester.get_completions("basic deploy --env dev --env ") assert {"dev", "staging", "prod"} <= set(completions) def test_collection_option_repeats(zsh_tester): """A ``list[Path]`` keyword option must accept repeated ``--file``. Without ``*`` prefix on the spec, zsh would refuse to complete a second ``--file`` value, breaking the natural ``--file a --file b --file c`` usage of collection-typed parameters. """ import os import tempfile from pathlib import Path as _Path app = App(name="files") @app.default def m(file: Annotated[list[_Path], Parameter(help="files")] = []): # noqa: B006 """Files.""" tester = zsh_tester(app, "files") with tempfile.TemporaryDirectory() as td: (_Path(td) / "first.txt").write_text("x") cwd = _Path.cwd() try: os.chdir(td) completions = tester.get_completions("files --file first.txt --file ") finally: os.chdir(cwd) assert completions, f"expected file completion on second --file, got {completions!r}" def test_multiple_iterable_positionals_emit_one_rest_spec(zsh_tester): """Functions with multiple ``list[X]`` positional-or-keyword params. Regression test for ``_arguments:comparguments:327: doubled rest argument definition``: zsh allows at most one ``*:`` rest spec per command, but each iterable-typed positional was emitting one. Cyclopts now collapses them to a single rest spec (the first iterable, or the ``*args`` var-positional if present); the others remain available via their ``--name`` keyword forms. """ app = App(name="multi_iter") @app.command def process( items: list[str], tags: list[str] | None = None, exclude: list[str] | None = None, ): """Process items. Parameters ---------- items Items to process. tags Tags to apply. exclude Items to exclude. """ tester = zsh_tester(app, "multi_iter") script = tester.completion_script assert tester.validate_script_syntax() # Exactly one rest-arg spec inside the process block. process_block = re.search(r"process\)(.*?);;", script, re.DOTALL) assert process_block is not None rest_specs = re.findall(r"'\*:[^']*'|\"\*:[^\"]*\"", process_block.group(1)) assert len(rest_specs) == 1, f"expected one rest spec, found {rest_specs!r}" # The collapsed-out positionals are still available as keyword options. assert "--tags[" in script assert "--exclude[" in script # Real zsh accepts the script and offers ``--tags`` after ``--ta``. completions = tester.get_completions("multi_iter process --ta") assert "--tags" in completions def test_bool_flag_not_repeated(zsh_tester): """Bool flags stay single-use in zsh (matches zsh convention).""" tester = zsh_tester(app_basic, "basic") script = tester.completion_script # No ``*`` prefix on the verbose spec. assert "'--verbose[" in script assert "'*--verbose[" not in script # --- Naked-TAB at no-arg subcommand: known per-shell divergence ------------- # # See the doc note at the top of test_behavior.py. zsh's ``_arguments`` # always lists ``--help`` / ``--version`` whether or not the user has # typed a leading ``-``; bash gates them behind ``cur == -*``. The current # behavior is intentional and locked in here. def test_naked_tab_at_no_arg_subcommand_offers_help(zsh_tester): """Zsh always surfaces ``--help`` / ``--version`` via ``_arguments``.""" app = App(name="probe") @app.command def noarg(): """No-arg subcommand.""" tester = zsh_tester(app, "probe") completions = tester.get_completions("probe noarg ") assert "--help" in completions assert "--version" in completions BrianPugh-cyclopts-921b1fa/tests/config/000077500000000000000000000000001517576204000202545ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/config/test_common.py000066400000000000000000000233101517576204000231540ustar00rootroot00000000000000from dataclasses import dataclass from pathlib import Path from typing import Annotated, Any import pytest from cyclopts.argument import Token from cyclopts.config._common import ConfigFromFile from cyclopts.exceptions import CycloptsError from cyclopts.parameter import Parameter class DummyErrorConfigNoMsg(ConfigFromFile): def _load_config(self, path: Path) -> dict[str, Any]: raise ValueError class DummyErrorConfigMsg(ConfigFromFile): def _load_config(self, path: Path) -> dict[str, Any]: raise ValueError("My exception's message.") class Dummy(ConfigFromFile): def _load_config(self, path: Path) -> dict[str, Any]: return { "key1": "foo1", "key2": "foo2", "function1": { "key1": "bar1", "key2": "bar2", }, "meta_param": 123, } class DummyRootKeys(ConfigFromFile): def _load_config(self, path: Path) -> dict[str, Any]: return { "tool": { "cyclopts": { "key1": "foo1", "key2": "foo2", "function1": { "key1": "bar1", "key2": "bar2", }, } } } class DummySubKeys(ConfigFromFile): def _load_config(self, path: Path) -> dict[str, Any]: return { "key1": { "subkey1": ["subkey1val1", "subkey1val2"], "subkey2": ["subkey2val1", "subkey2val2"], }, "key2": "foo2", } def function1(key1, key2): pass @pytest.fixture def config(tmp_path): return Dummy(tmp_path / "cyclopts-config-test-file.dummy") @pytest.fixture def config_root_keys(tmp_path): return DummyRootKeys(tmp_path / "cyclopts-config-test-file.dummy") @pytest.fixture def config_sub_keys(tmp_path): return DummySubKeys(tmp_path / "cyclopts-config-test-file.dummy") @pytest.fixture def configured_app(app): @app.command def function1(): pass @app.default def foo(key1, key2): pass @app.meta.default def meta( *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], meta_param: Annotated[int, Parameter(negative=())] = 42, ): pass return app def test_config_common_root_keys_empty(configured_app, config): config.path.touch() configured_app.config = config _, _, _, _, argument_collection = configured_app._parse_known_args("--key1 cli1") assert len(argument_collection) == 2 assert argument_collection[0].tokens == [Token(keyword="--key1", value="cli1", source="cli")] assert argument_collection[1].tokens == [Token(keyword="[key2]", value="foo2", source=str(config.path))] def test_config_common_root_keys_populated(configured_app, config_root_keys): configured_app.config = config_root_keys config_root_keys.path.touch() config_root_keys.root_keys = ["tool", "cyclopts"] _, _, _, _, argument_collection = configured_app._parse_known_args("--key1 cli1") assert len(argument_collection) == 2 assert argument_collection[0].tokens == [Token(keyword="--key1", value="cli1", source="cli")] assert argument_collection[1].tokens == [ Token(keyword="[tool][cyclopts][key2]", value="foo2", source=str(config_root_keys.path)) ] def test_config_common_must_exist_false(config, mocker): """If ``must_exist==False``, then the specified file is allowed to not exist. If the file does not exist, then have an empty config. """ spy_load_config = mocker.spy(config, "_load_config") config.must_exist = False _ = config.config # does NOT raise a FileNotFoundError assert config.config == {} spy_load_config.assert_not_called() def test_config_common_must_exist_true(config): """If ``must_exist==True``, then the specified file must exist.""" config.must_exist = True with pytest.raises(FileNotFoundError): _ = config.config @pytest.mark.parametrize("must_exist", [True, False]) def test_config_common_search_parents_absolute_true_exists(tmp_path, must_exist, config, mocker): """Tests finding an existing parent if path is absolute.""" spy_load_config = mocker.spy(config, "_load_config") original_path = config.path original_path.touch() config.path = tmp_path / "folder1" / "folder2" / "folder3" / "folder4" / config.path.name config.must_exist = must_exist config.search_parents = True _ = config.config spy_load_config.assert_called_once_with(original_path) def test_config_common_search_parents_relative_true_exists(tmp_path, mocker, monkeypatch): """Tests finding an existing parent if path is relative.""" config_path = tmp_path / "cyclopts-config-test-file.dummy" config_path.touch() config = Dummy("cyclopts-config-test-file.dummy", search_parents=True) spy_load_config = mocker.spy(config, "_load_config") deep_dir = tmp_path / "foo" / "bar" / "baz" deep_dir.mkdir(parents=True) monkeypatch.chdir(deep_dir) _ = config.config spy_load_config.assert_called_once_with(config_path.resolve()) def test_config_common_must_exist_true_search_parents_true_missing(tmp_path, config, mocker): """Tests finding a missing parent.""" spy_load_config = mocker.spy(config, "_load_config") config.path = tmp_path / "folder1" / "folder2" / "folder3" / "folder4" / config.path.name config.must_exist = True config.search_parents = True with pytest.raises(FileNotFoundError): _ = config.config spy_load_config.assert_not_called() def test_config_common_must_exist_false_search_parents_true_missing(tmp_path, config, mocker): """Tests finding a missing parent.""" spy_load_config = mocker.spy(config, "_load_config") config.path = tmp_path / "folder1" / "folder2" / "folder3" / "folder4" / config.path.name config.must_exist = False config.search_parents = True assert config.config == {} spy_load_config.assert_not_called() def test_config_common_kwargs(app, config): """Make sure that we don't look for the string "kwargs" as a key.""" app.config = config config.path.touch() @app.default def foo(key1, **kwargs): pass # Define these commands so that their corresponding keys in the config do not get interpreted for kwargs. @app.command def function1(): pass @app.meta.default def meta( *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], meta_param: Annotated[int, Parameter(negative=())] = 42, ): pass _, _, _, _, argument_collection = app._parse_known_args("--key1 foo1") # Don't attempt to parse the key ``"kwargs"`` from config. assert len(argument_collection) == 2 assert argument_collection[-1].tokens == [ Token(keyword="[key2]", value="foo2", source=str(config.path.absolute()), index=0, keys=("key2",)), ] def test_config_common_subkeys(app, config_sub_keys): config_sub_keys.path.touch() app.config = config_sub_keys @dataclass class Example: subkey1: list[str] subkey2: list[str] @app.default def foo(key1: Example, key2): pass _, _, _, _, argument_collection = app._parse_known_args("") assert len(argument_collection) == 4 assert len(argument_collection[0].tokens) == 0 assert len(argument_collection[1].tokens) == 2 assert argument_collection[1].tokens[0].keyword == "[key1][subkey1]" assert argument_collection[1].tokens[0].value == "subkey1val1" assert argument_collection[1].tokens[0].index == 0 assert argument_collection[1].tokens[0].keys == () assert argument_collection[1].tokens[0].source.endswith("cyclopts-config-test-file.dummy") assert argument_collection[1].tokens[1].keyword == "[key1][subkey1]" assert argument_collection[1].tokens[1].value == "subkey1val2" assert argument_collection[1].tokens[1].index == 1 assert argument_collection[1].tokens[1].keys == () assert argument_collection[1].tokens[1].source.endswith("cyclopts-config-test-file.dummy") assert len(argument_collection[2].tokens) == 2 assert argument_collection[2].tokens[0].keyword == "[key1][subkey2]" assert argument_collection[2].tokens[0].value == "subkey2val1" assert argument_collection[2].tokens[0].index == 0 assert argument_collection[2].tokens[0].keys == () assert argument_collection[2].tokens[0].source.endswith("cyclopts-config-test-file.dummy") assert argument_collection[2].tokens[1].keyword == "[key1][subkey2]" assert argument_collection[2].tokens[1].value == "subkey2val2" assert argument_collection[2].tokens[1].index == 1 assert argument_collection[2].tokens[1].keys == () assert argument_collection[2].tokens[1].source.endswith("cyclopts-config-test-file.dummy") assert len(argument_collection[3].tokens) == 1 assert argument_collection[3].tokens[0].keyword == "[key2]" assert argument_collection[3].tokens[0].value == "foo2" assert argument_collection[3].tokens[0].index == 0 assert argument_collection[3].tokens[0].keys == () assert argument_collection[3].tokens[0].source.endswith("cyclopts-config-test-file.dummy") def test_config_exception_during_load_config_no_msg(tmp_path): path = tmp_path / "config" path.touch() dummy_error_config = DummyErrorConfigNoMsg(path) with pytest.raises(CycloptsError) as e: _ = dummy_error_config.config assert str(e.value) == "ValueError" def test_config_exception_during_load_config_msg(tmp_path): path = tmp_path / "config" path.touch() dummy_error_config = DummyErrorConfigMsg(path) with pytest.raises(CycloptsError) as e: _ = dummy_error_config.config assert str(e.value) == "ValueError: My exception's message." BrianPugh-cyclopts-921b1fa/tests/config/test_dict.py000066400000000000000000000674551517576204000226310ustar00rootroot00000000000000import pytest from cyclopts import App from cyclopts.config import Dict def test_config_dict_basic(): """Test basic Dict config functionality.""" app = App(config=Dict({"name": "Alice", "age": 30}), result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." result = app([]) assert result == "Alice is 30 years old." def test_config_dict_with_commands(): """Test Dict config with commands.""" config_data = { "create": {"name": "Alice", "age": 30}, "update": {"name": "Bob", "age": 40}, } app = App(config=Dict(config_data), result_action="return_value") @app.command def create(name: str, age: int): return f"Created: {name} is {age} years old." @app.command def update(name: str, age: int): return f"Updated: {name} is {age} years old." result = app("create") assert result == "Created: Alice is 30 years old." result = app("update") assert result == "Updated: Bob is 40 years old." def test_config_dict_with_root_keys(): """Test Dict config with root_keys navigation.""" config_data = { "production": { "database": { "host": "prod.example.com", "port": 5432, } } } app = App( config=Dict(config_data, root_keys=["production", "database"]), result_action="return_value", ) @app.default def main(host: str, port: int): return f"Connecting to {host}:{port}" result = app([]) assert result == "Connecting to prod.example.com:5432" def test_config_dict_use_commands_as_keys_false(): """Test Dict config with use_commands_as_keys=False.""" config_data = {"name": "Alice", "age": 30} app = App(config=Dict(config_data, use_commands_as_keys=False), result_action="return_value") @app.command def create(name: str, age: int): return f"{name} is {age} years old." result = app("create") assert result == "Alice is 30 years old." def test_config_dict_use_commands_as_keys_false_with_sibling_commands(): """Test Dict config filters sibling command sections when use_commands_as_keys=False. Regression test for issue where config filtering used command_app instead of parent app, causing sibling command sections in the config to not be properly filtered out. This reproduces the bug fixed in core.py where executing "polygon 1 2 3 4" would fail because the "line" section in the config wasn't being filtered correctly. """ from dataclasses import KW_ONLY, dataclass from typing import Annotated from cyclopts import Parameter config_data = { "units": "meters", "line": {"color": "red"}, "polygon": {"color": "blue"}, "circle": {"color": "green"}, } app = App(config=Dict(config_data, use_commands_as_keys=False), result_action="return_value") @Parameter(name="*") @dataclass class DrawConfig: _: KW_ONLY units: str = "meters" color: str = "black" @app.command def line(start: tuple[float, float], end: tuple[float, float], config: DrawConfig | None = None): if config is None: config = DrawConfig() return f"line: {start} to {end} in {config.units}, color={config.color}" @app.command def polygon(*vertices: Annotated[tuple[float, float], Parameter(required=True)], config: DrawConfig | None = None): if config is None: config = DrawConfig() return f"polygon: {len(vertices)} vertices in {config.units}, color={config.color}" @app.command def circle(center: tuple[float, float], radius: float, config: DrawConfig | None = None): if config is None: config = DrawConfig() return f"circle: center={center} radius={radius} in {config.units}, color={config.color}" result = app("line 0 0 10 10") assert result == "line: (0.0, 0.0) to (10.0, 10.0) in meters, color=black" result = app("polygon 1 2 3 4 5 6") assert result == "polygon: 3 vertices in meters, color=black" result = app("circle 0 0 5") assert result == "circle: center=(0.0, 0.0) radius=5.0 in meters, color=black" def test_config_dict_nested_commands_with_use_commands_as_keys_true(): """Test Dict config with nested commands using default use_commands_as_keys=True. Regression test to ensure that when use_commands_as_keys=True (default), the filtering uses command_app (not root_app) so that subcommand sections at the command's config level are properly filtered out. Without the correct fix, this would fail with "Unknown option" errors because cmd1/cmd2 sections wouldn't be filtered when executing the sub default command. """ config_data = { "sub": { "shared_param": "shared_value", "cmd1": {"cmd1_param": "cmd1_value"}, "cmd2": {"cmd2_param": "cmd2_value"}, } } app = App(config=Dict(config_data), result_action="return_value") sub_app = App() app.command(sub_app, name="sub") @sub_app.default def sub_default(shared_param: str): return f"sub_default: {shared_param}" @sub_app.command def cmd1(cmd1_param: str): return f"cmd1: {cmd1_param}" @sub_app.command def cmd2(cmd2_param: str): return f"cmd2: {cmd2_param}" result = app("sub") assert result == "sub_default: shared_value" result = app("sub cmd1") assert result == "cmd1: cmd1_value" result = app("sub cmd2") assert result == "cmd2: cmd2_value" def test_config_dict_nested_commands_with_use_commands_as_keys_false(): """Test Dict config with nested App commands using use_commands_as_keys=False. With use_commands_as_keys=False, config stays at root level and is shared across all commands, including nested App commands and their subcommands. """ config_data = { "global_setting": "shared_value", } app = App(config=Dict(config_data, use_commands_as_keys=False), result_action="return_value") sub_app = App() app.command(sub_app, name="sub") @sub_app.default def sub_default(global_setting: str): return f"sub_default: {global_setting}" @sub_app.command def cmd1(global_setting: str): return f"cmd1: {global_setting}" result = app("sub") assert result == "sub_default: shared_value" result = app("sub cmd1") assert result == "cmd1: shared_value" def test_config_dict_deeply_nested_with_use_commands_as_keys_false(): """Test Dict config with deeply nested commands and use_commands_as_keys=False. Ensures that even with multiple levels of nesting, flat config is shared across all command levels. """ config_data = { "shared": "value", "another": 42, } app = App(config=Dict(config_data, use_commands_as_keys=False), result_action="return_value") level1_app = App() level2_app = App() app.command(level1_app, name="level1") level1_app.command(level2_app, name="level2") @level2_app.default def deeply_nested(shared: str, another: int): return f"deeply_nested: {shared}, {another}" result = app("level1 level2") assert result == "deeply_nested: value, 42" def test_config_dict_mixed_nested_and_flat_commands(): """Test Dict config with mix of nested apps and flat config. This tests that both simple commands and nested App commands can share the same flat config when use_commands_as_keys=False. """ config_data = { "base_value": "base", "count": 10, } app = App(config=Dict(config_data, use_commands_as_keys=False), result_action="return_value") @app.command def simple(base_value: str, count: int): return f"simple: {base_value}, count={count}" nested_app = App() app.command(nested_app, name="nested") @nested_app.default def nested_default(base_value: str, count: int): return f"nested: {base_value}, count={count}" result = app("simple") assert result == "simple: base, count=10" result = app("nested") assert result == "nested: base, count=10" def test_config_dict_use_commands_as_keys_true_filters_subcommands(): """Test that use_commands_as_keys=True correctly filters sibling subcommands. When navigating into a command's config section, sibling command sections at that level should be filtered using the command's app (not root app). This ensures that "create" and "delete" sections are filtered when executing each other. """ config_data = { "db": { "timeout": 30, "create": {"timeout": 10, "operation": "create_op"}, "delete": {"timeout": 20, "operation": "delete_op"}, } } app = App(config=Dict(config_data, use_commands_as_keys=True), result_action="return_value") db_app = App() app.command(db_app, name="db") @db_app.default def db_default(timeout: int): return f"db: timeout={timeout}" @db_app.command def create(timeout: int, operation: str): return f"create: timeout={timeout}, operation={operation}" @db_app.command def delete(timeout: int, operation: str): return f"delete: timeout={timeout}, operation={operation}" result = app("db") assert result == "db: timeout=30" result = app("db create") assert result == "create: timeout=10, operation=create_op" result = app("db delete") assert result == "delete: timeout=20, operation=delete_op" def test_config_dict_allow_unknown(): """Test Dict config with allow_unknown=True.""" config_data = { "name": "Alice", "age": 30, "unknown_field": "should_be_ignored", } app = App(config=Dict(config_data, allow_unknown=True), result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." result = app([]) assert result == "Alice is 30 years old." def test_config_dict_allow_unknown_nested_user_class(): """Test that unknown nested keys are ignored with user classes when allow_unknown=True. Regression test for https://github.com/BrianPugh/cyclopts/issues/731 """ from dataclasses import dataclass @dataclass class ConnectionParameter: timeout: float config_data = { "p": {"timeout": 3}, "np": {"timeout": 4}, # Unknown, should be ignored } app = App( config=Dict(config_data, allow_unknown=True, use_commands_as_keys=False), result_action="return_value", ) @app.default def connect(*, p: ConnectionParameter): return p.timeout result = app([]) assert result == 3.0 @pytest.mark.parametrize( "config_data", [ {"p": {"timeout": 5}}, {"timeout": 5}, ], ) def test_config_dict_remapped_nested_parameter(config_data): """Test that nested config paths don't match remapped parameters. When a nested parameter has Parameter(name="--timeout") (remapped to root level), config should use {"timeout": 5}, not {"p": {"timeout": 5}}. The nested path should raise UnknownOptionError because it doesn't match the remapped CLI name. """ from dataclasses import dataclass from typing import Annotated from cyclopts import Parameter @dataclass class ConnectionParameter: timeout: Annotated[float, Parameter(name="--timeout")] app = App( config=Dict(config_data, use_commands_as_keys=False), result_action="return_value", ) @app.default def connect(*, p: ConnectionParameter): return p.timeout app([], exit_on_error=False) def test_config_dict_partial_override(): """Test that CLI args override Dict config values.""" config_data = {"name": "Alice", "age": 30} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." result = app("--name Bob") assert result == "Bob is 30 years old." def test_config_dict_empty(): """Test Dict config with empty dict.""" app = App(config=Dict({}), result_action="return_value") @app.default def main(name: str = "Default", age: int = 0): return f"{name} is {age} years old." result = app([]) assert result == "Default is 0 years old." def test_config_dict_nested_structure(): """Test Dict config with nested dataclass-like structures.""" from dataclasses import dataclass @dataclass class Database: host: str port: int config_data = { "database": { "host": "localhost", "port": 5432, } } app = App(config=Dict(config_data), result_action="return_value") @app.default def main(database: Database): return f"{database.host}:{database.port}" result = app([]) assert result == "localhost:5432" def test_config_dict_source(): """Test that Dict.source returns 'dict' by default.""" config = Dict({"key": "value"}) assert config.source == "dict" def test_config_dict_custom_source(): """Test that Dict.source can be customized.""" config = Dict({"key": "value"}, source="api") assert config.source == "api" config_network = Dict({"key": "value"}, source="network") assert config_network.source == "network" def test_config_dict_source_setter(): """Test that Dict.source can be modified via setter.""" config = Dict({"key": "value"}) assert config.source == "dict" config.source = "api" assert config.source == "api" config.source = "network-response" assert config.source == "network-response" def test_config_dict_with_subcommands(): """Test that Dict config correctly filters out subcommand keys.""" config_data = { "global_flag": True, "subcommand": { "sub_value": 123, }, } app = App(config=Dict(config_data), result_action="return_value") @app.default def main(global_flag: bool = False): return f"global_flag={global_flag}" @app.command def subcommand(sub_value: int): return f"sub_value={sub_value}" result = app([]) assert result == "global_flag=True" result = app("subcommand") assert result == "sub_value=123" def test_config_dict_unknown_field_error(): """Test that unknown fields raise error when allow_unknown=False.""" import pytest from cyclopts.exceptions import UnknownOptionError config_data = { "name": "Alice", "age": 30, "unknown_field": "should_error", } app = App(config=Dict(config_data, allow_unknown=False), result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." with pytest.raises(UnknownOptionError): app([], exit_on_error=False) def test_config_dict_multiple_configs(): """Test using multiple Dict configs in a list.""" config1 = Dict({"name": "Alice"}) config2 = Dict({"age": 30}) app = App(config=[config1, config2], result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." result = app([]) assert result == "Alice is 30 years old." def test_config_dict_with_env(): """Test combining Dict config with Env config.""" import os from cyclopts.config import Env config_data = {"name": "Alice"} app = App(config=[Dict(config_data), Env("TEST_")], result_action="return_value") @app.default def main(name: str, age: int = 0): return f"{name} is {age} years old." os.environ["TEST_AGE"] = "30" try: result = app([]) assert result == "Alice is 30 years old." finally: del os.environ["TEST_AGE"] def test_config_dict_modification_after_creation(): """Test modifying Dict config after app creation.""" config = Dict({"name": "Alice", "age": 30}) app = App(config=config, result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." result = app([]) assert result == "Alice is 30 years old." config.data["name"] = "Bob" result = app([]) assert result == "Bob is 30 years old." def test_config_dict_with_meta_app(): """Test Dict config with meta app.""" from typing import Annotated from cyclopts import Parameter app = App(result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." @app.meta.default def meta(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], config_source: str = "dict"): app.config = Dict({"name": "Alice", "age": 30}, source=config_source) return app(tokens) result = app.meta([]) assert result == "Alice is 30 years old." def test_config_dict_deep_nesting(): """Test Dict config with deep root_keys nesting.""" config_data = {"level1": {"level2": {"level3": {"level4": {"name": "Alice", "age": 30}}}}} app = App( config=Dict(config_data, root_keys=["level1", "level2", "level3", "level4"]), result_action="return_value", ) @app.default def main(name: str, age: int): return f"{name} is {age} years old." result = app([]) assert result == "Alice is 30 years old." def test_config_dict_cli_priority(): """Test that CLI arguments have priority over Dict config.""" config_data = {"name": "Alice", "age": 30} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." result = app("--name Bob --age 40") assert result == "Bob is 40 years old." def test_config_dict_with_optional(): """Test Dict config with Optional parameters.""" from typing import Any config_data: dict[str, Any] = {"name": "Alice"} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(name: str, age: int | None = None): if age is None: return f"{name} has no age" return f"{name} is {age} years old." result = app([]) assert result == "Alice has no age" config_data["age"] = 30 result = app([]) assert result == "Alice is 30 years old." def test_config_dict_with_list(): """Test Dict config with list parameters.""" config_data = {"names": ["Alice", "Bob", "Charlie"]} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(names: list[str]): return ", ".join(names) result = app([]) assert result == "Alice, Bob, Charlie" def test_config_dict_with_typed_dict(): """Test Dict config with TypedDict.""" from typing import TypedDict class UserConfig(TypedDict): name: str age: int config_data = {"user": {"name": "Alice", "age": 30}} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(user: UserConfig): return f"{user['name']} is {user['age']} years old." result = app([]) assert result == "Alice is 30 years old." def test_config_dict_immutability(): """Test that Dict.config returns the actual data (not a copy).""" config_data = {"name": "Alice", "age": 30} config = Dict(config_data) assert config.config is config.data config.config["name"] = "Bob" assert config.data["name"] == "Bob" def test_config_dict_missing_root_key(): """Test Dict config when root_keys don't exist in data.""" config_data = {"production": {"name": "Alice"}} app = App( config=Dict(config_data, root_keys=["development", "database"]), result_action="return_value", ) @app.default def main(name: str = "Default", age: int = 0): return f"{name} is {age} years old." result = app([]) assert result == "Default is 0 years old." def test_config_dict_reassignment(): """Test reassigning app.config with new Dict.""" app = App(config=Dict({"name": "Alice", "age": 30}), result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." result = app([]) assert result == "Alice is 30 years old." app.config = Dict({"name": "Bob", "age": 40}) result = app([]) assert result == "Bob is 40 years old." def test_config_dict_with_boolean_flags(): """Test Dict config with boolean flags.""" config_data = {"verbose": True, "quiet": False, "debug": True} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(verbose: bool = False, quiet: bool = False, debug: bool = False): flags = [] if verbose: flags.append("verbose") if quiet: flags.append("quiet") if debug: flags.append("debug") return ", ".join(flags) if flags else "no flags" result = app([]) assert result == "verbose, debug" def test_config_dict_nested_commands(): """Test Dict config with nested command structure.""" config_data = { "db": { "create": {"name": "testdb", "size": 100}, "delete": {"name": "olddb", "force": True}, } } app = App(config=Dict(config_data), result_action="return_value") db_app = App() app.command(db_app, name="db") @db_app.command def create(name: str, size: int): return f"Creating {name} with size {size}" @db_app.command def delete(name: str, force: bool = False): return f"Deleting {name} (force={force})" result = app("db create") assert result == "Creating testdb with size 100" result = app("db delete") assert result == "Deleting olddb (force=True)" def test_config_dict_error_message_with_custom_source(): """Test that custom source appears in error messages.""" import pytest from cyclopts.exceptions import MissingArgumentError config_data = {"age": 30} app = App(config=Dict(config_data, source="api-response"), result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." with pytest.raises(MissingArgumentError) as exc_info: app([], exit_on_error=False) assert "name" in str(exc_info.value).lower() def test_config_dict_with_default_values(): """Test Dict config interacts correctly with function default values.""" config_data = {"name": "Alice"} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(name: str = "Default", age: int = 99, city: str = "Unknown"): return f"{name}, {age}, {city}" result = app([]) assert result == "Alice, 99, Unknown" def test_config_dict_with_enum(): """Test Dict config with Enum parameters.""" from enum import Enum class Color(Enum): RED = "red" GREEN = "green" BLUE = "blue" config_data = {"color": "red"} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(color: Color): return f"Color is {color.value}" result = app([]) assert result == "Color is red" def test_config_dict_with_complex_types(): """Test Dict config with complex nested types.""" from dataclasses import dataclass @dataclass class Address: street: str city: str @dataclass class Person: name: str address: Address config_data = { "person": { "name": "Alice", "address": {"street": "123 Main St", "city": "Springfield"}, } } app = App(config=Dict(config_data), result_action="return_value") @app.default def main(person: Person): return f"{person.name} lives at {person.address.street}, {person.address.city}" result = app([]) assert result == "Alice lives at 123 Main St, Springfield" def test_config_dict_with_union_types(): """Test Dict config with Union types.""" from typing import Any config_data: dict[str, Any] = {"value": 42} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(value: int | str): return f"Value is {value} (type: {type(value).__name__})" result = app([]) assert result == "Value is 42 (type: int)" config_data["value"] = "hello" result = app([]) assert result == "Value is hello (type: str)" def test_config_dict_empty_root_keys(): """Test Dict config with empty root_keys tuple.""" config_data = {"name": "Alice", "age": 30} app = App(config=Dict(config_data, root_keys=()), result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." result = app([]) assert result == "Alice is 30 years old." def test_config_dict_single_root_key(): """Test Dict config with single root_key.""" config_data = {"production": {"name": "Alice", "age": 30}} app = App(config=Dict(config_data, root_keys=["production"]), result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." result = app([]) assert result == "Alice is 30 years old." def test_config_dict_with_attrs_class(): """Test Dict config with attrs class.""" from attrs import define @define class User: name: str age: int config_data = {"user": {"name": "Alice", "age": 30}} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(user: User): return f"{user.name} is {user.age} years old." result = app([]) assert result == "Alice is 30 years old." def test_config_dict_comparison_with_json_file(): """Test that Dict produces same result as Json for equivalent data.""" import json from pathlib import Path from cyclopts.config import Json config_data = {"count": {"character": "t"}} dict_app = App(config=Dict(config_data), result_action="return_value") @dict_app.command def count(character: str): # noqa: F811 # pyright: ignore[reportRedeclaration] return f"Character: {character}" dict_result = dict_app("count") json_app = App(result_action="return_value") @json_app.command def count(character: str): # noqa: F811 # pyright: ignore[reportRedeclaration] return f"Character: {character}" tmp_file = Path("temp_test.json") tmp_file.write_text(json.dumps(config_data)) try: json_app.config = Json(tmp_file) json_result = json_app("count") assert dict_result == json_result finally: tmp_file.unlink() def test_config_dict_repr(): """Test Dict has a useful repr.""" config = Dict({"key": "value"}, source="api") repr_str = repr(config) assert "Dict" in repr_str assert "key" in repr_str or "value" in repr_str def test_config_dict_with_tuple_type(): """Test Dict config with tuple parameters.""" config_data = {"coordinates": [1, 2, 3]} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(coordinates: tuple[int, int, int]): return f"Coordinates: {coordinates}" result = app([]) assert result == "Coordinates: (1, 2, 3)" def test_config_dict_with_set_type(): """Test Dict config with set parameters.""" config_data = {"tags": ["python", "cli", "config"]} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(tags: set[str]): return f"Tags: {sorted(tags)}" result = app([]) assert result == "Tags: ['cli', 'config', 'python']" def test_config_dict_with_negative_numbers(): """Test Dict config with negative numbers.""" config_data = {"offset": -10, "temperature": -5.5} app = App(config=Dict(config_data), result_action="return_value") @app.default def main(offset: int, temperature: float): return f"offset={offset}, temperature={temperature}" result = app([]) assert result == "offset=-10, temperature=-5.5" BrianPugh-cyclopts-921b1fa/tests/config/test_end2end.py000066400000000000000000000055001517576204000232040ustar00rootroot00000000000000from textwrap import dedent from typing import Annotated from cyclopts import Parameter from cyclopts.config import Env, Toml def test_config_end2end(app, tmp_path, assert_parse_args): config_fn = tmp_path / "config.toml" config_fn.write_text( dedent( """\ [tool.cyclopts] key1 = "foo1" key2 = "foo2" list1 = [] [tool.cyclopts.function1] key3 = "bar1" key4 = "bar2" """ ) ) app.config = Toml(config_fn, root_keys=["tool", "cyclopts"]) @app.default def default(key1, key2, *, list1: list[int]): pass @app.command def function1(key3, key4): pass assert_parse_args(default, "foo", key1="foo", key2="foo2", list1=[]) assert_parse_args(default, "foo --key2=fizz", key1="foo", key2="fizz", list1=[]) assert_parse_args(default, "foo --list1 1", key1="foo", key2="foo2", list1=[1]) assert_parse_args(function1, "function1 --key4=fizz", key3="bar1", key4="fizz") def test_config_env_repeated(app, monkeypatch, assert_parse_args): monkeypatch.setenv("FOO", "bar") app.config = Env() @app.default def default(foo: Annotated[str, Parameter(env_var="FOO")]): pass assert_parse_args(default, "", "bar") def test_config_env_help(app, assert_parse_args, console): """Special-case for :class:`.config.Env` to get added to help-page.""" app.config = Env() @app.default def default(foo: Annotated[str, Parameter(env_var="BAR")]): pass with console.capture() as capture: app(["--help"], console=console) actual = capture.get() expected = dedent( """\ Usage: test_end2end FOO ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help (-h) Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * FOO --foo [env var: BAR, FOO] [required] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected BrianPugh-cyclopts-921b1fa/tests/config/test_env.py000066400000000000000000000110001517576204000224450ustar00rootroot00000000000000from dataclasses import dataclass import pytest from cyclopts.argument import ArgumentCollection from cyclopts.config import Env from cyclopts.token import Token @pytest.fixture def apps(): """App is only used as a dictionary in these tests.""" return [{"function1": None}] def test_config_env_default(apps, monkeypatch): def foo(bar: int): pass argument_collection = ArgumentCollection._from_callable(foo) monkeypatch.setenv("CYCLOPTS_TEST_APP_BAR", "100") monkeypatch.setenv("CYCLOPTS_TEST_APP_SOMETHING_ELSE", "100") Env("CYCLOPTS_TEST_APP_", command=False)(apps, (), argument_collection) assert len(argument_collection[0].tokens) == 1 assert argument_collection[0].tokens[0].keyword == "CYCLOPTS_TEST_APP_BAR" assert argument_collection[0].tokens[0].value == "100" assert argument_collection[0].tokens[0].source == "env" assert argument_collection[0].tokens[0].index == 0 assert argument_collection[0].tokens[0].keys == () def test_config_env_default_already_populated(apps, monkeypatch): def foo(bar: int): pass argument_collection = ArgumentCollection._from_callable(foo) argument_collection[0].append(Token(keyword="--bar", value="500", source="cli")) monkeypatch.setenv("CYCLOPTS_TEST_APP_BAR", "100") monkeypatch.setenv("CYCLOPTS_TEST_APP_SOMETHING_ELSE", "100") Env("CYCLOPTS_TEST_APP_", command=False)(apps, (), argument_collection) assert len(argument_collection[0].tokens) == 1 assert argument_collection[0].tokens[0].keyword == "--bar" assert argument_collection[0].tokens[0].value == "500" assert argument_collection[0].tokens[0].source == "cli" assert argument_collection[0].tokens[0].index == 0 assert argument_collection[0].tokens[0].keys == () def test_config_env_command_true(apps, monkeypatch): def foo(bar: int): pass argument_collection = ArgumentCollection._from_callable(foo) monkeypatch.setenv("CYCLOPTS_TEST_APP_FOO_BAR", "100") Env("CYCLOPTS_TEST_APP_", command=True)(apps, ("foo",), argument_collection) assert len(argument_collection[0].tokens) == 1 assert argument_collection[0].tokens[0].keyword == "CYCLOPTS_TEST_APP_FOO_BAR" assert argument_collection[0].tokens[0].value == "100" assert argument_collection[0].tokens[0].source == "env" assert argument_collection[0].tokens[0].index == 0 assert argument_collection[0].tokens[0].keys == () def test_config_env_dict(apps, monkeypatch): def foo(bar_bar: dict): pass ac = ArgumentCollection._from_callable(foo) monkeypatch.setenv("CYCLOPTS_TEST_APP_BAR_BAR_BUZZ", "100") monkeypatch.setenv("CYCLOPTS_TEST_APP_BAR_BAR_FIZZ", "200") Env("CYCLOPTS_TEST_APP_", command=False)(apps, (), ac) assert len(ac[0].tokens) == 2 assert ac[0].tokens[0].keyword == "CYCLOPTS_TEST_APP_BAR_BAR_BUZZ" assert ac[0].tokens[0].value == "100" assert ac[0].tokens[0].source == "env" assert ac[0].tokens[0].index == 0 assert ac[0].tokens[0].keys == ("buzz",) assert ac[0].tokens[1].keyword == "CYCLOPTS_TEST_APP_BAR_BAR_FIZZ" assert ac[0].tokens[1].value == "200" assert ac[0].tokens[1].source == "env" assert ac[0].tokens[1].index == 0 assert ac[0].tokens[1].keys == ("fizz",) def test_config_env_dataclass(apps, monkeypatch): @dataclass class User: fizz_fizz: int buzz_buzz: int def foo(bar_bar: User): pass ac = ArgumentCollection._from_callable(foo) monkeypatch.setenv("CYCLOPTS_TEST_APP_BAR_BAR_BUZZ_BUZZ", "100") monkeypatch.setenv("CYCLOPTS_TEST_APP_BAR_BAR_FIZZ_FIZZ", "200") Env("CYCLOPTS_TEST_APP_", command=False)(apps, (), ac) assert len(ac) == 3 assert len(ac[1].tokens) == 1 assert len(ac[2].tokens) == 1 assert ac[1].tokens[0].keyword == "CYCLOPTS_TEST_APP_BAR_BAR_FIZZ_FIZZ" assert ac[1].tokens[0].value == "200" assert ac[1].tokens[0].source == "env" assert ac[1].tokens[0].index == 0 assert ac[1].tokens[0].keys == () assert ac[2].tokens[0].keyword == "CYCLOPTS_TEST_APP_BAR_BAR_BUZZ_BUZZ" assert ac[2].tokens[0].value == "100" assert ac[2].tokens[0].source == "env" assert ac[2].tokens[0].index == 0 assert ac[2].tokens[0].keys == () def test_config_env_kwargs(app, assert_parse_args, monkeypatch): @app.default def default(a: str, **kwargs): pass monkeypatch.setenv("CYCLOPTS_TEST_APP_TWO_WORDS", "test value") app.config = Env("CYCLOPTS_TEST_APP_") assert_parse_args(default, "a_value", a="a_value", two_words="test value") BrianPugh-cyclopts-921b1fa/tests/config/test_json.py000066400000000000000000000070331517576204000226410ustar00rootroot00000000000000import json from pathlib import Path from textwrap import dedent import pytest from cyclopts import App, CycloptsError from cyclopts.config._json import Json def test_config_json(tmp_path): fn = tmp_path / "test.yaml" fn.write_text( dedent( """\ { "foo": { "key1": "foo1", "key2": "foo2", "function1": { "key1": "bar1", "key2": "bar2" } } } """ ) ) config = Json(fn) assert config.config == { "foo": { "key1": "foo1", "key2": "foo2", "function1": { "key1": "bar1", "key2": "bar2", }, } } """ Test file-caching and chdir after app has been instantiated. See discussion: https://github.com/BrianPugh/cyclopts/issues/309 """ app = App(config=Json("config.json"), result_action="return_value") @app.command def create(name: str, age: int): print(f"{name} is {age} years old.") @pytest.fixture(autouse=True) def chdir_to_tmp_path(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @pytest.fixture def config_path(tmp_path): return tmp_path / "config.json" def test_config_1(config_path, capsys, mocker): with config_path.open("w") as f: json.dump({"create": {"name": "Alice", "age": 30}}, f) json_config = app.config[0] spy_load_config = mocker.patch.object(json_config, "_load_config", wraps=json_config._load_config) # pyright: ignore[reportAttributeAccessIssue] app("create") assert capsys.readouterr().out == "Alice is 30 years old.\n" assert spy_load_config.call_count == 1 # Ensure that it doesn't get called again because the file hasn't changed. app("create") assert capsys.readouterr().out == "Alice is 30 years old.\n" assert spy_load_config.call_count == 1 # If we modify the file, then it should get loaded again. with config_path.open("w") as f: json.dump({"create": {"name": "Bob", "age": 40}}, f) app("create") assert capsys.readouterr().out == "Bob is 40 years old.\n" assert spy_load_config.call_count == 2 def test_config_2(config_path, capsys): with config_path.open("w") as f: json.dump({"create": {"name": "Bob", "age": 40}}, f) app("create") assert capsys.readouterr().out == "Bob is 40 years old.\n" def test_config_invalid_json(tmp_path, console): Path("config.json").write_text('{"this is": broken}') with pytest.raises(CycloptsError), console.capture() as capture: app("create", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ JSONDecodeError: │ │ {"this is": broken} │ │ ^ │ │ Expecting value: line 1 column 13 (char 12) │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected BrianPugh-cyclopts-921b1fa/tests/config/test_source_parameter.py000066400000000000000000000124131517576204000252260ustar00rootroot00000000000000"""Test that all config classes support the source parameter.""" import json import os import pytest from cyclopts import App from cyclopts.config import Dict, Env, Json, Toml, Yaml from cyclopts.exceptions import MissingArgumentError def test_json_default_source(tmp_path): """Test that Json uses file path as default source.""" config_file = tmp_path / "config.json" config_file.write_text(json.dumps({"name": "Alice"})) config = Json(config_file) assert config.source == str(config_file.absolute()) def test_json_custom_source(tmp_path): """Test that Json accepts custom source parameter.""" config_file = tmp_path / "config.json" config_file.write_text(json.dumps({"name": "Alice"})) config = Json(config_file, source="my-custom-source") assert config.source == "my-custom-source" def test_json_custom_source_in_error(tmp_path): """Test that custom source appears in error messages.""" config_file = tmp_path / "config.json" config_file.write_text(json.dumps({"age": 30})) app = App(config=Json(config_file, source="api-config"), result_action="return_value") @app.default def main(name: str, age: int): return f"{name} is {age} years old." with pytest.raises(MissingArgumentError): app([], exit_on_error=False) def test_json_source_setter(tmp_path): """Test that Json.source can be modified via setter.""" config_file = tmp_path / "config.json" config_file.write_text(json.dumps({"name": "Alice"})) config = Json(config_file) original_source = config.source config.source = "updated-source" assert config.source == "updated-source" config.source = original_source assert config.source == original_source def test_toml_default_source(tmp_path): """Test that Toml uses file path as default source.""" config_file = tmp_path / "config.toml" config_file.write_text('[main]\nname = "Alice"') config = Toml(config_file) assert config.source == str(config_file.absolute()) def test_toml_custom_source(tmp_path): """Test that Toml accepts custom source parameter.""" config_file = tmp_path / "config.toml" config_file.write_text('[main]\nname = "Alice"') config = Toml(config_file, source="my-toml-source") assert config.source == "my-toml-source" def test_yaml_default_source(tmp_path): """Test that Yaml uses file path as default source.""" config_file = tmp_path / "config.yaml" config_file.write_text("main:\n name: Alice") config = Yaml(config_file) assert config.source == str(config_file.absolute()) def test_yaml_custom_source(tmp_path): """Test that Yaml accepts custom source parameter.""" config_file = tmp_path / "config.yaml" config_file.write_text("main:\n name: Alice") config = Yaml(config_file, source="my-yaml-source") assert config.source == "my-yaml-source" def test_env_default_source(): """Test that Env uses 'env' as default source.""" config = Env("TEST_") assert config.source == "env" def test_env_custom_source(): """Test that Env accepts custom source parameter.""" config = Env("TEST_", source="environment-variables") assert config.source == "environment-variables" def test_env_custom_source_in_tokens(): """Test that Env custom source is used when creating tokens.""" app = App(config=Env("TEST_", source="custom-env"), result_action="return_value") @app.default def main(name: str = "default"): return name os.environ["TEST_NAME"] = "Alice" try: result = app([]) assert result == "Alice" finally: del os.environ["TEST_NAME"] def test_dict_default_source(): """Test that Dict uses 'dict' as default source.""" config = Dict({"name": "Alice"}) assert config.source == "dict" def test_dict_custom_source(): """Test that Dict accepts custom source parameter.""" config = Dict({"name": "Alice"}, source="api-response") assert config.source == "api-response" def test_multiple_configs_with_custom_sources(tmp_path): """Test using multiple configs with custom sources.""" json_file = tmp_path / "config.json" json_file.write_text(json.dumps({"name": "Alice"})) app = App( config=[ Json(json_file, source="json-config"), Dict({"age": 30}, source="dict-config"), Env("TEST_", source="env-config"), ], result_action="return_value", ) @app.default def main(name: str, age: int, city: str = "Unknown"): return f"{name}, {age}, {city}" os.environ["TEST_CITY"] = "Springfield" try: result = app([]) assert result == "Alice, 30, Springfield" finally: del os.environ["TEST_CITY"] def test_source_preserved_across_app_calls(tmp_path): """Test that custom source is preserved across multiple app calls.""" config_file = tmp_path / "config.json" config_file.write_text(json.dumps({"value": 42})) config = Json(config_file, source="persistent-source") app = App(config=config, result_action="return_value") @app.default def main(value: int): return value result1 = app([]) assert result1 == 42 assert config.source == "persistent-source" result2 = app([]) assert result2 == 42 assert config.source == "persistent-source" BrianPugh-cyclopts-921b1fa/tests/config/test_toml.py000066400000000000000000000031251517576204000226410ustar00rootroot00000000000000from textwrap import dedent from typing import Annotated import pytest from cyclopts import App, Parameter from cyclopts.config import Toml def test_config_toml(tmp_path): fn = tmp_path / "test.toml" fn.write_text( dedent( """\ [foo] key1 = "foo1" key2 = "foo2" [foo.function1] key1 = "bar1" key2 = "bar2" """ ) ) config = Toml(fn) assert config.config == { "foo": { "key1": "foo1", "key2": "foo2", "function1": { "key1": "bar1", "key2": "bar2", }, } } @pytest.fixture def config_path(tmp_path): """Path to JSON configuration file in tmp_path""" return tmp_path / "config.toml" # same name that was provided to cyclopts.config.Json def test_duplicate_config_toml_with_meta(config_path): config_path.write_text( dedent( """\ [this-test] name = "Alice" """ ) ) app = App( config=( Toml("config.toml", root_keys=("this-test",)), Toml("config.toml", root_keys=("this-test",)), # Duplicate configs should still work. ), result_action="return_value", ) @app.meta.default def meta( *tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)], ): return app(tokens, exit_on_error=False) @app.default def main(name: str): return name assert app.meta([], exit_on_error=False) == "Alice" BrianPugh-cyclopts-921b1fa/tests/config/test_toml_dataclass_list.py000066400000000000000000000134461517576204000257220ustar00rootroot00000000000000"""Test TOML configuration with lists of dataclasses and TypedDict. This addresses GitHub issue #507 for TOML config files. """ from dataclasses import dataclass from textwrap import dedent from typing import Literal, TypedDict from cyclopts.config import Toml @dataclass class User: name: str age: int region: Literal["us", "ca"] = "us" class Config(TypedDict): name: str value: int def test_toml_list_of_dataclasses(app, tmp_path, assert_parse_args): """Test list of dataclasses from TOML config.""" config_fn = tmp_path / "config.toml" config_fn.write_text( dedent( """\ [[users]] name = "alice" age = 22 region = "us" [[users]] name = "bob" age = 33 region = "ca" """ ) ) app.config = Toml(config_fn) @app.default def main(users: list[User]): pass # Parse with empty args, config should provide the users assert_parse_args(main, "", [User("alice", 22, "us"), User("bob", 33, "ca")]) def test_toml_list_of_dataclasses_with_cli_override(app, tmp_path, assert_parse_args): """Test that CLI arguments override TOML config for list of dataclasses.""" config_fn = tmp_path / "config.toml" config_fn.write_text( dedent( """\ [[users]] name = "alice" age = 22 region = "us" """ ) ) app.config = Toml(config_fn) @app.default def main(users: list[User]): pass # CLI should override config assert_parse_args( main, '--users \'{"name": "charlie", "age": 40, "region": "ca"}\'', [User("charlie", 40, "ca")], ) def test_toml_empty_list_of_dataclasses(app, tmp_path, assert_parse_args): """Test empty list of dataclasses from TOML.""" config_fn = tmp_path / "config.toml" config_fn.write_text( dedent( """\ users = [] """ ) ) app.config = Toml(config_fn) @app.default def main(users: list[User] = None): # pyright: ignore pass assert_parse_args(main, "", []) def test_toml_single_dataclass_in_list(app, tmp_path, assert_parse_args): """Test single dataclass item in TOML list.""" config_fn = tmp_path / "config.toml" config_fn.write_text( dedent( """\ [[users]] name = "alice" age = 30 region = "us" """ ) ) app.config = Toml(config_fn) @app.default def main(users: list[User]): pass assert_parse_args(main, "", [User("alice", 30, "us")]) def test_toml_list_of_typeddict(app, tmp_path, assert_parse_args): """Test list of TypedDict from TOML config.""" config_fn = tmp_path / "config.toml" config_fn.write_text( dedent( """\ [[configs]] name = "config1" value = 10 [[configs]] name = "config2" value = 20 [[configs]] name = "config3" value = 30 """ ) ) app.config = Toml(config_fn) @app.default def main(configs: list[Config]): pass assert_parse_args( main, "", [ {"name": "config1", "value": 10}, {"name": "config2", "value": 20}, {"name": "config3", "value": 30}, ], ) def test_toml_nested_dataclass_structure(app, tmp_path, assert_parse_args): """Test nested dataclass structure from TOML.""" @dataclass class Address: street: str city: str country: str = "US" @dataclass class Person: name: str age: int address: Address config_fn = tmp_path / "config.toml" config_fn.write_text( dedent( """\ [[people]] name = "alice" age = 25 [people.address] street = "123 Main St" city = "New York" country = "US" [[people]] name = "bob" age = 30 [people.address] street = "456 Oak Ave" city = "Toronto" country = "CA" """ ) ) app.config = Toml(config_fn) @app.default def main(people: list[Person]): pass expected = [ Person("alice", 25, Address("123 Main St", "New York", "US")), Person("bob", 30, Address("456 Oak Ave", "Toronto", "CA")), ] assert_parse_args(main, "", expected) def test_toml_mixed_config_and_cli(app, tmp_path, assert_parse_args): """Test mixing TOML config with additional CLI arguments.""" config_fn = tmp_path / "config.toml" config_fn.write_text( dedent( """\ [[users]] name = "alice" age = 22 region = "us" """ ) ) app.config = Toml(config_fn) @app.default def main(users: list[User], verbose: bool = False): pass # Config provides users, CLI provides verbose flag assert_parse_args(main, "--verbose", users=[User("alice", 22, "us")], verbose=True) def test_toml_inline_table_list(app, tmp_path, assert_parse_args): """Test TOML inline table syntax for list of dataclasses.""" config_fn = tmp_path / "config.toml" config_fn.write_text( dedent( """\ users = [ { name = "alice", age = 22, region = "us" }, { name = "bob", age = 33, region = "ca" } ] """ ) ) app.config = Toml(config_fn) @app.default def main(users: list[User]): pass assert_parse_args(main, "", [User("alice", 22, "us"), User("bob", 33, "ca")]) BrianPugh-cyclopts-921b1fa/tests/config/test_yaml.py000066400000000000000000000011621517576204000226270ustar00rootroot00000000000000from textwrap import dedent from cyclopts.config._yaml import Yaml def test_config_yaml(tmp_path): fn = tmp_path / "test.yaml" fn.write_text( dedent( """\ foo: key1: foo1 key2: foo2 function1: key1: bar1 key2: bar2 """ ) ) config = Yaml(fn) assert config.config == { "foo": { "key1": "foo1", "key2": "foo2", "function1": { "key1": "bar1", "key2": "bar2", }, } } BrianPugh-cyclopts-921b1fa/tests/config/test_yaml_dataclass_list.py000066400000000000000000000122141517576204000257010ustar00rootroot00000000000000"""Test YAML configuration with lists of dataclasses and TypedDict. This addresses GitHub issue #507. """ from dataclasses import dataclass from textwrap import dedent from typing import Literal, TypedDict from cyclopts.config import Yaml @dataclass class User: name: str age: int region: Literal["us", "ca"] = "us" class Config(TypedDict): name: str value: int def test_yaml_list_of_dataclasses(app, tmp_path, assert_parse_args): """Test the exact scenario from GitHub issue #507 with YAML config.""" config_fn = tmp_path / "config.yaml" config_fn.write_text( dedent( """\ users: - name: alice age: 22 region: us - name: bob age: 33 region: ca """ ) ) app.config = Yaml(config_fn) @app.default def main(users: list[User]): pass # Parse with empty args, config should provide the users assert_parse_args(main, "", [User("alice", 22, "us"), User("bob", 33, "ca")]) def test_yaml_list_of_dataclasses_with_cli_override(app, tmp_path, assert_parse_args): """Test that CLI arguments override YAML config for list of dataclasses.""" config_fn = tmp_path / "config.yaml" config_fn.write_text( dedent( """\ users: - name: alice age: 22 region: us """ ) ) app.config = Yaml(config_fn) @app.default def main(users: list[User]): pass # CLI should override config assert_parse_args( main, '--users \'{"name": "charlie", "age": 40, "region": "ca"}\'', [User("charlie", 40, "ca")], ) def test_yaml_empty_list_of_dataclasses(app, tmp_path, assert_parse_args): """Test empty list of dataclasses from YAML.""" config_fn = tmp_path / "config.yaml" config_fn.write_text( dedent( """\ users: [] """ ) ) app.config = Yaml(config_fn) @app.default def main(users: list[User] = None): # pyright: ignore pass assert_parse_args(main, "", []) def test_yaml_single_dataclass_in_list(app, tmp_path, assert_parse_args): """Test single dataclass item in YAML list.""" config_fn = tmp_path / "config.yaml" config_fn.write_text( dedent( """\ users: - name: alice age: 30 region: us """ ) ) app.config = Yaml(config_fn) @app.default def main(users: list[User]): pass assert_parse_args(main, "", [User("alice", 30, "us")]) def test_yaml_list_of_typeddict(app, tmp_path, assert_parse_args): """Test list of TypedDict from YAML config.""" config_fn = tmp_path / "config.yaml" config_fn.write_text( dedent( """\ configs: - name: config1 value: 10 - name: config2 value: 20 - name: config3 value: 30 """ ) ) app.config = Yaml(config_fn) @app.default def main(configs: list[Config]): pass assert_parse_args( main, "", [ {"name": "config1", "value": 10}, {"name": "config2", "value": 20}, {"name": "config3", "value": 30}, ], ) def test_yaml_nested_dataclass_structure(app, tmp_path, assert_parse_args): """Test nested dataclass structure from YAML.""" @dataclass class Address: street: str city: str country: str = "US" @dataclass class Person: name: str age: int address: Address config_fn = tmp_path / "config.yaml" config_fn.write_text( dedent( """\ people: - name: alice age: 25 address: street: 123 Main St city: New York country: US - name: bob age: 30 address: street: 456 Oak Ave city: Toronto country: CA """ ) ) app.config = Yaml(config_fn) @app.default def main(people: list[Person]): pass expected = [ Person("alice", 25, Address("123 Main St", "New York", "US")), Person("bob", 30, Address("456 Oak Ave", "Toronto", "CA")), ] assert_parse_args(main, "", expected) def test_yaml_mixed_config_and_cli(app, tmp_path, assert_parse_args): """Test mixing YAML config with additional CLI arguments.""" config_fn = tmp_path / "config.yaml" config_fn.write_text( dedent( """\ users: - name: alice age: 22 region: us """ ) ) app.config = Yaml(config_fn) @app.default def main(users: list[User], verbose: bool = False): pass # Config provides users, CLI provides verbose flag assert_parse_args(main, "--verbose", users=[User("alice", 22, "us")], verbose=True) BrianPugh-cyclopts-921b1fa/tests/conftest.py000066400000000000000000000107601517576204000212120ustar00rootroot00000000000000import inspect import sys from pathlib import Path import pytest from rich.console import Console from syrupy.extensions.single_file import SingleFileSnapshotExtension, WriteMode import cyclopts from cyclopts import Group, Parameter def pytest_addoption(parser): parser.addoption( "--run-slow", action="store_true", default=False, help="Run slow tests (e.g., documentation build tests)", ) def pytest_configure(config): # If --run-slow is passed, remove the default "-m 'not slow'" filter if config.getoption("--run-slow"): # Override the marker expression to include all tests config.option.markexpr = "" class MarkdownSnapshotExtension(SingleFileSnapshotExtension): _write_mode = WriteMode.TEXT file_extension = "md" class RstSnapshotExtension(SingleFileSnapshotExtension): _write_mode = WriteMode.TEXT file_extension = "rst" @pytest.fixture def md_snapshot(snapshot): return snapshot.use_extension(MarkdownSnapshotExtension) @pytest.fixture def rst_snapshot(snapshot): return snapshot.use_extension(RstSnapshotExtension) def pytest_ignore_collect(collection_path): for minor in range(8, 20): if sys.version_info < (3, minor) and collection_path.stem.startswith(f"test_py3{minor}_"): return True # Ignore py312/ directory on Python < 3.12 if sys.version_info < (3, 12) and "py312" in collection_path.parts: return True @pytest.fixture(autouse=True) def patch_sys_argv(request, monkeypatch): """Ensure consistent sys.argv[0] regardless of how pytest is invoked. When tests run, cyclopts derives app names from sys.argv[0]. This can vary based on how pytest is invoked: - `pytest`: sys.argv[0] is the pytest executable path - `python -m pytest`: sys.argv[0] is the pytest module's __main__.py We patch it to always be the test module's __main__.py to trigger consistent stack-based name derivation. """ monkeypatch.setattr(sys, "argv", ["__main__.py"] + sys.argv[1:]) @pytest.fixture(autouse=True) def chdir_to_tmp_path(tmp_path, monkeypatch): """Automatically change current directory to tmp_path""" monkeypatch.chdir(tmp_path) @pytest.fixture def app(): return cyclopts.App(result_action="return_value") @pytest.fixture def console(): return Console(width=70, force_terminal=True, highlight=False, color_system=None, legacy_windows=False) @pytest.fixture def normalize_trailing_whitespace(): """Remove trailing whitespace from each line while preserving line breaks. Useful for comparing console output where text wrapping may add trailing spaces. """ def _normalize(text: str) -> str: return "\n".join(line.rstrip() for line in text.split("\n")) return _normalize @pytest.fixture def default_function_groups(): return (Parameter(), Group("Arguments"), Group("Parameters")) @pytest.fixture def assert_parse_args(app): def inner(f, cmd: str, *args, **kwargs): signature = inspect.signature(f) expected_bind = signature.bind(*args, **kwargs) actual_command, actual_bind, _ = app.parse_args(cmd, print_error=False, exit_on_error=False) assert actual_command == f assert actual_bind == expected_bind return inner @pytest.fixture def assert_parse_args_config(app): def inner(config: dict, f, cmd: str, *args, **kwargs): signature = inspect.signature(f) expected_bind = signature.bind(*args, **kwargs) actual_command, actual_bind, _ = app.parse_args(cmd, print_error=False, exit_on_error=False, **config) assert actual_command == f assert actual_bind == expected_bind return inner @pytest.fixture def assert_parse_args_partial(app): def inner(f, cmd: str, *args, **kwargs): signature = inspect.signature(f) expected_bind = signature.bind_partial(*args, **kwargs) actual_command, actual_bind, _ = app.parse_args(cmd, print_error=False, exit_on_error=False) assert actual_command == f assert actual_bind == expected_bind return inner @pytest.fixture def convert(): """Function that performs a conversion for a given type/cmd pair. Goes through the whole app stack. """ def inner(type_, cmd): app = cyclopts.App(result_action="return_value") if isinstance(cmd, Path): cmd = cmd.as_posix() @app.default def target(arg1: type_): # pyright: ignore return arg1 return app(cmd, exit_on_error=False) return inner BrianPugh-cyclopts-921b1fa/tests/empty_app.py000066400000000000000000000001641517576204000213600ustar00rootroot00000000000000from cyclopts import App app = App() @app.command() def check(): pass if __name__ == "__main__": app() BrianPugh-cyclopts-921b1fa/tests/py312/000077500000000000000000000000001517576204000176655ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/py312/__init__.py000066400000000000000000000000001517576204000217640ustar00rootroot00000000000000BrianPugh-cyclopts-921b1fa/tests/py312/test_stdio_path.py000066400000000000000000000240541517576204000234410ustar00rootroot00000000000000"""Tests for StdioPath, a Path subclass that treats "-" as stdin/stdout.""" import sys from io import BytesIO, StringIO from pathlib import Path from typing import Annotated from cyclopts import Parameter from cyclopts.types import StdioPath # Basic StdioPath construction and properties def test_stdio_path_dash_is_stdio(): p = StdioPath("-") assert p.is_stdio is True def test_stdio_path_regular_is_not_stdio(): p = StdioPath("/tmp/test.txt") assert p.is_stdio is False def test_stdio_path_isinstance_path(): p = StdioPath("-") assert isinstance(p, Path) def test_stdio_path_str_dash(): p = StdioPath("-") assert str(p) == "-" def test_stdio_path_str_regular(): path_str = "/tmp/test.txt" p = StdioPath(path_str) # Path normalizes separators on different platforms assert str(p) == str(Path(path_str)) def test_stdio_path_repr_dash(): p = StdioPath("-") assert repr(p) == "StdioPath('-')" def test_stdio_path_repr_regular(): path_str = "/tmp/test.txt" p = StdioPath(path_str) # Path normalizes separators on different platforms assert repr(p) == f"StdioPath({str(Path(path_str))!r})" def test_stdio_path_fspath_dash(): import os p = StdioPath("-") assert os.fspath(p) == "-" def test_stdio_path_fspath_regular(): import os path_str = "/tmp/test.txt" p = StdioPath(path_str) # Path normalizes separators on different platforms assert os.fspath(p) == os.fspath(Path(path_str)) # exists() behavior def test_stdio_path_exists_always_true_for_dash(): p = StdioPath("-") assert p.exists() is True def test_stdio_path_exists_false_for_nonexistent(tmp_path): p = StdioPath(tmp_path / "nonexistent.txt") assert p.exists() is False def test_stdio_path_exists_true_for_existing(tmp_path): f = tmp_path / "existing.txt" f.touch() p = StdioPath(f) assert p.exists() is True # open() behavior def test_stdio_path_open_read_text_from_stdin(monkeypatch): p = StdioPath("-") fake_stdin = type("FakeStdin", (), {"buffer": BytesIO(b"hello world")})() monkeypatch.setattr(sys, "stdin", fake_stdin) with p.open("r") as f: assert f.read() == "hello world" def test_stdio_path_open_write_text_to_stdout(capfdbinary): p = StdioPath("-") with p.open("w") as f: f.write("hello world") assert capfdbinary.readouterr().out == b"hello world" def test_stdio_path_open_read_binary_from_stdin(monkeypatch): p = StdioPath("-") fake_stdin = type("FakeStdin", (), {"buffer": BytesIO(b"binary data")})() monkeypatch.setattr(sys, "stdin", fake_stdin) with p.open("rb") as f: assert f.read() == b"binary data" def test_stdio_path_open_write_binary_to_stdout(capfdbinary): p = StdioPath("-") with p.open("wb") as f: f.write(b"binary data") assert capfdbinary.readouterr().out == b"binary data" def test_stdio_path_open_regular_file_read(tmp_path): f = tmp_path / "test.txt" f.write_text("file content") p = StdioPath(f) with p.open("r") as fh: assert fh.read() == "file content" def test_stdio_path_open_regular_file_write(tmp_path): f = tmp_path / "test.txt" p = StdioPath(f) with p.open("w") as fh: fh.write("new content") assert f.read_text() == "new content" # read_text, read_bytes, write_text, write_bytes def test_stdio_path_read_text_from_stdin(monkeypatch): p = StdioPath("-") fake_stdin = type("FakeStdin", (), {"buffer": BytesIO(b"hello world")})() monkeypatch.setattr(sys, "stdin", fake_stdin) assert p.read_text() == "hello world" def test_stdio_path_read_text_from_stdin_with_encoding(monkeypatch): p = StdioPath("-") fake_stdin = type("FakeStdin", (), {"buffer": BytesIO("café".encode("latin-1"))})() monkeypatch.setattr(sys, "stdin", fake_stdin) assert p.read_text(encoding="latin-1") == "café" def test_stdio_path_read_bytes_from_stdin(monkeypatch): p = StdioPath("-") fake_stdin = type("FakeStdin", (), {"buffer": BytesIO(b"binary data")})() monkeypatch.setattr(sys, "stdin", fake_stdin) assert p.read_bytes() == b"binary data" def test_stdio_path_write_text_to_stdout(capfdbinary): p = StdioPath("-") p.write_text("hello world") assert capfdbinary.readouterr().out == b"hello world" def test_stdio_path_write_text_to_stdout_with_encoding(capfdbinary): p = StdioPath("-") p.write_text("café", encoding="latin-1") assert capfdbinary.readouterr().out == "café".encode("latin-1") def test_stdio_path_write_bytes_to_stdout(capfdbinary): p = StdioPath("-") p.write_bytes(b"binary data") assert capfdbinary.readouterr().out == b"binary data" def test_stdio_path_read_text_regular_file(tmp_path): f = tmp_path / "test.txt" f.write_text("file content") p = StdioPath(f) assert p.read_text() == "file content" def test_stdio_path_read_bytes_regular_file(tmp_path): f = tmp_path / "test.bin" f.write_bytes(b"binary content") p = StdioPath(f) assert p.read_bytes() == b"binary content" def test_stdio_path_write_text_regular_file(tmp_path): f = tmp_path / "test.txt" p = StdioPath(f) p.write_text("new content") assert f.read_text() == "new content" def test_stdio_path_write_bytes_regular_file(tmp_path): f = tmp_path / "test.bin" p = StdioPath(f) p.write_bytes(b"binary content") assert f.read_bytes() == b"binary content" # StdioPath integration with cyclopts def test_stdio_path_allow_leading_hyphen(app, assert_parse_args): """StdioPath should accept '-' as a positional argument.""" @app.default def main(path: StdioPath): pass assert_parse_args(main, "-", StdioPath("-")) def test_stdio_path_regular_path(app, assert_parse_args, tmp_path): """StdioPath should accept regular paths.""" f = tmp_path / "test.txt" @app.default def main(path: StdioPath): pass assert_parse_args(main, [str(f)], StdioPath(f)) def test_stdio_path_with_annotated(app, assert_parse_args): """StdioPath should work when wrapped in Annotated.""" @app.default def main(path: Annotated[StdioPath, Parameter(help="Input path")]): pass assert_parse_args(main, "-", StdioPath("-")) def test_stdio_path_multiple_args(app, assert_parse_args, tmp_path): """Multiple StdioPath arguments should work.""" f = tmp_path / "out.txt" @app.default def main( input_path: Annotated[StdioPath, Parameter(help="Input")], output_path: Annotated[StdioPath, Parameter(help="Output")], ): pass assert_parse_args(main, ["-", str(f)], StdioPath("-"), StdioPath(f)) def test_stdio_path_keyword_args(app, assert_parse_args, tmp_path): """StdioPath should accept '-' as a keyword argument.""" f = tmp_path / "out.txt" @app.default def main( input_path: Annotated[StdioPath, Parameter(help="Input")], output_path: Annotated[StdioPath, Parameter(help="Output")], ): pass assert_parse_args(main, ["--input-path", "-", "--output-path", str(f)], StdioPath("-"), StdioPath(f)) assert_parse_args(main, ["--input-path=-", f"--output-path={f}"], StdioPath("-"), StdioPath(f)) def test_stdio_path_optional_without_annotated(app, assert_parse_args): """StdioPath | None with default value should accept '-' as positional argument. Regression test for issue #740. """ @app.default def main( input_: StdioPath, output: StdioPath | None = None, ): pass assert_parse_args(main, ["-", "-"], StdioPath("-"), StdioPath("-")) def test_stdio_path_annotated_without_default(app, assert_parse_args): """Annotated StdioPath without default should accept '-' as positional argument. Regression test for issue #740. """ @app.default def main( input_: StdioPath, output: Annotated[StdioPath, Parameter(name=["--output", "-o"])], ): pass assert_parse_args(main, ["-", "-"], StdioPath("-"), StdioPath("-")) assert_parse_args(main, ["-", "--output", "-"], StdioPath("-"), StdioPath("-")) assert_parse_args(main, ["-", "-o", "-"], StdioPath("-"), StdioPath("-")) def test_stdio_path_annotated_optional_with_default(app, assert_parse_args): """Annotated StdioPath | None with default should accept '-' as positional argument. Regression test for issue #740. This is the specific combination that was broken. """ @app.default def main( input_: StdioPath, output: Annotated[StdioPath | None, Parameter(name=["--output", "-o"])] = None, ): pass assert_parse_args(main, ["-", "-"], StdioPath("-"), StdioPath("-")) assert_parse_args(main, ["-", "--output", "-"], StdioPath("-"), StdioPath("-")) assert_parse_args(main, ["-", "-o", "-"], StdioPath("-"), StdioPath("-")) # Subclassing tests def test_stdio_path_subclass_custom_string(): """Subclasses can override STDIO_STRING for a different trigger.""" class StdinPath(StdioPath): STDIO_STRING = "STDIN" class StdoutPath(StdioPath): STDIO_STRING = "STDOUT" p_in = StdinPath("STDIN") assert p_in.is_stdio is True assert str(p_in) == "STDIN" assert repr(p_in) == "StdinPath('STDIN')" p_out = StdoutPath("STDOUT") assert p_out.is_stdio is True assert str(p_out) == "STDOUT" assert repr(p_out) == "StdoutPath('STDOUT')" # "-" should not trigger stdio for these subclasses assert StdinPath("-").is_stdio is False assert StdoutPath("-").is_stdio is False # Regular paths should not be stdio assert StdinPath("/tmp/test.txt").is_stdio is False assert StdoutPath("/tmp/test.txt").is_stdio is False def test_stdio_path_subclass_custom_matching(): """Subclasses can override is_stdio for custom matching logic.""" class MultiStdioPath(StdioPath): @property def is_stdio(self) -> bool: return str(self) in ("-", "STDIN", "STDOUT") assert MultiStdioPath("-").is_stdio is True assert MultiStdioPath("STDIN").is_stdio is True assert MultiStdioPath("STDOUT").is_stdio is True assert MultiStdioPath("/tmp/test.txt").is_stdio is False BrianPugh-cyclopts-921b1fa/tests/py312/test_type_alias_parameter.py000066400000000000000000000061021517576204000254670ustar00rootroot00000000000000"""Tests for Python 3.12+ TypeAliasType with Parameter metadata. Regression tests for https://github.com/BrianPugh/cyclopts/issues/669 """ from typing import Annotated, Optional import pytest from cyclopts import Parameter from cyclopts.annotations import resolve, resolve_annotated, resolve_optional # Define type aliases using Python 3.12 'type' statement type IntWithAlias = Annotated[int, Parameter(alias="-f")] type OptionalIntWithAlias = Optional[Annotated[int, Parameter(alias="-f")]] type BoolAlias = bool type AnnotatedIntWithMetadata = Annotated[int, "metadata"] type OptionalInt = Optional[int] @pytest.mark.parametrize( "cmd_str,expected", [ ("--foo 10", 10), ("-f 10", 10), ], ) def test_type_alias_with_parameter_alias(app, assert_parse_args, cmd_str, expected): """Test that type statement (TypeAliasType) works with Parameter alias.""" @app.default def main(foo: IntWithAlias): pass assert_parse_args(main, cmd_str, foo=expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("--foo 10", 10), ("-f 10", 10), ], ) def test_type_alias_optional_with_parameter_alias(app, assert_parse_args, cmd_str, expected): """Test that Optional TypeAliasType works with Parameter alias.""" @app.default def main(foo: OptionalIntWithAlias = None): pass assert_parse_args(main, cmd_str, foo=expected) def test_parameter_get_negatives_with_type_alias(): """Test that Parameter.get_negatives works with TypeAliasType.""" p = Parameter(name=("--foo", "--bar")) assert ("--no-foo", "--no-bar") == p.get_negatives(BoolAlias) def test_parameter_from_annotation_type_alias(): """Test that Parameter.from_annotation works with TypeAliasType.""" result_type, result_param = Parameter.from_annotation(IntWithAlias, Parameter()) assert result_type is int assert result_param.alias == ("-f",) @pytest.mark.parametrize( "type_alias,resolve_func,expected", [ (AnnotatedIntWithMetadata, resolve, int), (OptionalInt, resolve, int), (AnnotatedIntWithMetadata, resolve_annotated, int), (OptionalInt, resolve_optional, int), ], ids=[ "resolve(Annotated[int])", "resolve(Optional[int])", "resolve_annotated(Annotated[int])", "resolve_optional(Optional[int])", ], ) def test_resolve_functions_with_type_alias(type_alias, resolve_func, expected): """Test that resolve functions handle TypeAliasType automatically.""" res = resolve_func(type_alias) assert res is expected def test_type_alias_in_help(capsys): """Test that TypeAliasType appears correctly in help text.""" from cyclopts import App app = App() @app.default def main(foo: IntWithAlias): """Test command. Parameters ---------- foo : int A parameter with alias. """ pass try: app(["--help"]) except SystemExit: pass captured = capsys.readouterr() help_text = captured.out assert "--foo" in help_text assert "-f" in help_text BrianPugh-cyclopts-921b1fa/tests/py312/test_type_alias_type.py000066400000000000000000000042301517576204000244700ustar00rootroot00000000000000from enum import Enum from typing import Annotated, Literal, TypeAlias import pytest from cyclopts import Parameter from cyclopts.argument import get_choices_from_hint FontSize: TypeAlias = Literal[10, 12, 16] type BoxSize = Literal[10, 12, 16] def test_py312_type_alias_type(app, assert_parse_args): """Support for python3.12 :obj:`TypeAliasType`. https://github.com/BrianPugh/cyclopts/issues/190 """ @app.default def main( font_size: FontSize, box_size: BoxSize, box_size_2: Annotated[BoxSize, "foo"], ): pass assert_parse_args(main, "10 12 16", 10, 12, 16) class CompSciProblem(Enum): fizz = "bleep bloop blop" buzz = "blop bleep bloop" type AnnotatedEnum = Annotated[CompSciProblem, Parameter(name="foo")] type AnnotatedOptionalEnum = Annotated[CompSciProblem | None, Parameter(name="foo")] type FontSingleFormat = Literal["otf", "woff2", "ttf", "bdf", "pcf"] type FontCollectionFormat = Literal["otc", "ttc"] FontPixelFormat: TypeAlias = Literal["bmp"] @pytest.mark.parametrize( "type_, expected", [ (AnnotatedEnum, ["fizz", "buzz"]), (AnnotatedOptionalEnum, ["fizz", "buzz"]), (FontPixelFormat, ["bmp"]), (FontSingleFormat, ["otf", "woff2", "ttf", "bdf", "pcf"]), (FontSingleFormat | FontPixelFormat, ["otf", "woff2", "ttf", "bdf", "pcf", "bmp"]), (FontSingleFormat | FontCollectionFormat, ["otf", "woff2", "ttf", "bdf", "pcf", "otc", "ttc"]), (FontSingleFormat | FontCollectionFormat | None, ["otf", "woff2", "ttf", "bdf", "pcf", "otc", "ttc"]), (list[FontSingleFormat | FontCollectionFormat] | None, ["otf", "woff2", "ttf", "bdf", "pcf", "otc", "ttc"]), ], ) def test_py312_type_alias_type_help_get_choices(type_, expected): assert expected == get_choices_from_hint(type_, lambda x: x) type Numbers = tuple[int, str] def test_py312_type_alias_type_tuple_token_count(app, assert_parse_args): """Ensure that TypeAliasType can handle multi-token types. https://github.com/BrianPugh/cyclopts/issues/413 """ @app.default def main(foo: Numbers): pass assert_parse_args(main, "1 one", (1, "one")) BrianPugh-cyclopts-921b1fa/tests/test_annotations.py000066400000000000000000000071441517576204000227630ustar00rootroot00000000000000import inspect from collections import namedtuple from collections.abc import Iterable, Sequence from pathlib import Path from typing import Annotated, Any, Literal, Optional, Union import pytest from cyclopts.annotations import contains_hint, get_hint_name, is_iterable_type, resolve def test_resolve_annotated(): type_ = Annotated[Literal["foo", "bar"], "fizz"] res = resolve(type_) assert res == Literal["foo", "bar"] def test_resolve_empty(): res = resolve(inspect.Parameter.empty) assert res is str def test_get_hint_name_string(): assert get_hint_name("str") == "str" def test_get_hint_name_any(): assert get_hint_name(Any) == "Any" def test_get_hint_name_union(): assert get_hint_name(Union[int, str]) == "int|str" def test_get_hint_name_class_with_name(): class TestClass: pass assert get_hint_name(TestClass) == "TestClass" def test_get_hint_name_typing_with_name(): assert get_hint_name(list) == "list" def test_get_hint_name_generic_type(): assert get_hint_name(list[int]) == "list[int]" def test_get_hint_name_nested_generic_type(): assert get_hint_name(dict[str, list[int]]) == "dict[str, list[int]]" def test_get_hint_name_optional_type(): assert get_hint_name(Optional[int]) == "int|None" def test_get_hint_name_namedtuple(): TestTuple = namedtuple("TestTuple", ["field1", "field2"]) assert get_hint_name(TestTuple) == "TestTuple" def test_get_hint_name_complex_union(): complex_type = Union[int, str, list[dict[str, Any]]] assert get_hint_name(complex_type) == "int|str|list[dict[str, Any]]" def test_get_hint_name_fallback_str(): class NoNameClass: def __str__(self): return "NoNameClass" assert get_hint_name(NoNameClass()) == "NoNameClass" class CustomStr(str): """Dummy subclass of ``str``.""" @pytest.mark.parametrize( "hint,target_type,expected", [ (str, str, True), (CustomStr, str, True), (Union[int, str], str, True), (Annotated[int | str, 1], str, True), (Annotated[Annotated[int, 1] | Annotated[str, 1], 1], str, True), (int, str, False), ], ) def test_contains_hint(hint, target_type, expected): assert contains_hint(hint, target_type) == expected @pytest.mark.parametrize( "hint,expected", [ # Basic collection types (list[str], True), (set[int], True), (tuple[str, ...], True), (frozenset[str], True), # Abstract collection types (Sequence[str], True), (Iterable[str], True), # Annotated wrappers (Annotated[list[str], "metadata"], True), # Optional wrappers (Optional[list[str]], True), # Nested Annotated inside collection (list[Annotated[Path, "metadata"]], True), # Non-iterable types (str, False), (int, False), (Path, False), (dict[str, str], True), (Annotated[str, "metadata"], False), (Optional[str], False), ], ) def test_is_iterable_type(hint, expected): assert is_iterable_type(hint) == expected def test_get_annotated_discriminator_rejects_non_annotated(): from cyclopts.annotations import get_annotated_discriminator class Decoy: discriminator = "type" assert get_annotated_discriminator(list[Decoy]) is None assert get_annotated_discriminator(int) is None def test_get_annotated_discriminator_positive(): from types import SimpleNamespace from cyclopts.annotations import get_annotated_discriminator assert get_annotated_discriminator(Annotated[int, SimpleNamespace(discriminator="type")]) == "type" BrianPugh-cyclopts-921b1fa/tests/test_app_attribute_inheritance.py000066400000000000000000000147151517576204000256440ustar00rootroot00000000000000import pytest from cyclopts import App from cyclopts.exceptions import UnknownOptionError def test_app_has_extra_attributes_as_attributes(): """Test that extra attributes are App attributes with defaults None.""" app = App() assert app.print_error is None assert app.exit_on_error is None assert app.help_on_error is None assert app.verbose is None assert app.end_of_options_delimiter is None assert app.result_action is None def test_app_attributes_can_be_set(): """Test that extra attributes can be set as App attributes.""" app = App( print_error=False, exit_on_error=False, help_on_error=True, verbose=True, end_of_options_delimiter="--", ) assert app.print_error is False assert app.exit_on_error is False assert app.help_on_error is True assert app.verbose is True assert app.end_of_options_delimiter == "--" def test_call_overrides_app_attributes(): """Test that parameters in __call__ override the stored values in attributes.""" app = App(exit_on_error=False) @app.default def main(): return "success" with pytest.raises(UnknownOptionError): app(["--unknown-flag"]) with pytest.raises(SystemExit): app(["--unknown-flag"], exit_on_error=True) def test_parse_args_overrides_app_attributes(): """Test that parameters in parse_args override the stored values in attributes.""" app = App( exit_on_error=True, verbose=False, ) @app.default def main(): return "success" with pytest.raises(UnknownOptionError): app.parse_args(["--unknown-flag"], exit_on_error=False) with pytest.raises(SystemExit): app.parse_args(["--unknown-flag"]) def test_parse_known_args_overrides_app_attributes(): """Test that parameters in parse_known_args override the stored values in attributes.""" app = App(end_of_options_delimiter="===") @app.default def main(arg: str = "default"): return arg # Override the end_of_options_delimiter command, bound, unused_tokens, ignored = app.parse_known_args( ["((", "after_delimiter"], end_of_options_delimiter="((" ) # With the override, the standard "--" should work, not "===" assert command == main assert bound.arguments["arg"] == "after_delimiter" assert unused_tokens == [] def test_app_stack_inheritance_simple(): """Test that child apps inherit extra attributes from parent apps via AppStack.""" parent_app = App( print_error=False, exit_on_error=False, help_on_error=True, verbose=True, ) parent_app.command(child_app := App(name="child")) @child_app.default def child_command(): return "child_success" # Child should inherit parent's settings with pytest.raises(UnknownOptionError): # This will raise UnknownOptionError due to unknown flag, but should not exit due to inherited exit_on_error=False parent_app("child --unknown-flag", exit_on_error=None) # None means use inherited def test_app_stack_inheritance_override(): """Test that child apps can override parent app extra attributes.""" parent_app = App( print_error=True, exit_on_error=True, verbose=False, ) child_app = App( name="child", print_error=False, exit_on_error=False, verbose=True, result_action="return_value", ) parent_app.command(child_app) @child_app.default def child_command(): return "child_success" # Child's settings should override parent's child_app([], exit_on_error=False) def test_app_stack_resolution_none_values(): """Test that None values in child apps allow parent values to be used.""" parent_app = App( print_error=False, exit_on_error=False, help_on_error=True, verbose=True, end_of_options_delimiter="--parent--", ) # Child has all None values, should inherit from parent child_app = App(name="child") parent_app.command(child_app) @child_app.default def child_command(): return "child_success" # Test that child inherits parent's end_of_options_delimiter through AppStack command, bound, unused_tokens, ignored = parent_app.parse_known_args( ["child", "--parent--", "after_delimiter"], end_of_options_delimiter=None, # Should use app's setting ) # The child should have inherited the parent's delimiter assert command == child_command assert unused_tokens == ["after_delimiter"] def test_meta_app_inheritance(): """Test that meta apps also participate in extra attribute inheritance.""" parent_app = App( print_error=False, exit_on_error=False, verbose=True, ) # Set up meta app with different settings parent_app.meta.print_error = True parent_app.meta.verbose = False child_app = App(name="child", result_action="return_value") parent_app.command(child_app) @child_app.default def child_command(): return "child_success" # Child should inherit from the closest parent in the stack child_app([]) def test_signature_parameter_override_precedence(): """Test that signature parameters have highest precedence over app attributes.""" app = App( print_error=True, exit_on_error=True, help_on_error=False, verbose=False, end_of_options_delimiter="--app--", ) @app.default def main(args, /): return args # All signature parameters should override app attributes command, bound, ignored = app.parse_args( ["--signature--", "foo"], end_of_options_delimiter="--signature--", ) assert command == main assert bound.args == ("foo",) assert not ignored def test_true_inheritance_without_fallback_override(): """Test that parent app attributes are inherited without being overridden by method defaults.""" parent_app = App( exit_on_error=False, ) parent_app.command(child_app := App(name="child")) @child_app.default def child_command(): return "child_success" with pytest.raises(UnknownOptionError): # This should not raise a SystemExit due to the root parent_app exit_on_error=False parent_app("child --unknown-flag") with pytest.raises(SystemExit): # The immediately supplied argument should have highest priority. parent_app("child --unknown-flag", exit_on_error=True) BrianPugh-cyclopts-921b1fa/tests/test_app_name_derivation.py000066400000000000000000000011471517576204000244270ustar00rootroot00000000000000import pytest from cyclopts import App @pytest.fixture def mock_get_root_module_name(mocker): return mocker.patch("cyclopts.core._get_root_module_name", return_value="mock_module_name") def test_app_name_derivation_main_module(mocker, mock_get_root_module_name): mocker.patch("cyclopts.core.sys.argv", ["__main__.py"]) app = App() assert app.name == ("mock_module_name",) mock_get_root_module_name.assert_called() def test_app_name_derivation_not_main_module(mocker): mocker.patch("cyclopts.core.sys.argv", ["my-script.py"]) app = App() assert app.name == ("my-script.py",) BrianPugh-cyclopts-921b1fa/tests/test_app_stack_overrides.py000066400000000000000000000072701517576204000244550ustar00rootroot00000000000000"""Tests for AppStack override functionality.""" from typing import Annotated import pytest from cyclopts import App, Parameter, UnknownOptionError def test_meta_app_override_propagation(): """Test that overrides propagate from parent app to meta app calls.""" results = [] app = App(result_action="return_value") @app.meta.default def meta(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]): results.append("meta") try: app(tokens, exit_on_error=False) except UnknownOptionError: results.append("caught") @app.command def foo(*, flag: bool = False): results.append(f"foo {flag}") # This should not exit even though there's an unknown option app.meta(["foo", "--unknown"], exit_on_error=False) assert results == ["meta", "caught"] def test_nested_app_override_propagation(): """Test that overrides propagate through nested app invocations.""" results = [] root_app = App(result_action="return_value") sub_app = App(name="sub", result_action="return_value") @root_app.meta.default def root_meta(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]): results.append("root_meta") root_app(tokens, exit_on_error=False) @sub_app.meta.default def sub_meta(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]): results.append("sub_meta") sub_app(tokens) # Should inherit exit_on_error=False @sub_app.command def cmd(*, flag: bool = False): results.append(f"cmd {flag}") root_app.command(sub_app.meta, name="sub") # Test that exit_on_error=False propagates through the chain try: root_app.meta(["sub", "cmd", "--unknown"], exit_on_error=False) except UnknownOptionError: results.append("caught") assert "root_meta" in results assert "sub_meta" in results assert "caught" in results def test_parse_args_override_propagation(): """Test that parse_args properly stores and uses overrides.""" app = App(result_action="return_value") @app.default def main(value: int): return value # Test with exit_on_error=False with pytest.raises(UnknownOptionError): app.parse_args(["--unknown"], exit_on_error=False) # Test that overrides are properly cleaned up after parse_args assert len(app.app_stack.overrides_stack) == 1 assert app.app_stack.overrides_stack[0] == {} def test_call_override_propagation(): """Test that __call__ properly stores and uses overrides.""" app = App(result_action="return_value") results = [] @app.default def main(value: int = 0): results.append(value) return value # Test with exit_on_error=False with pytest.raises(UnknownOptionError): app(["--unknown"], exit_on_error=False) # Normal call should work app(["--value", "5"]) assert results == [5] # Test that overrides are properly cleaned up assert len(app.app_stack.overrides_stack) == 1 assert app.app_stack.overrides_stack[0] == {} def test_multiple_override_parameters(): """Test that all override parameters are properly handled.""" app = App(result_action="return_value") @app.default def main(value: int): return value # Test multiple overrides at once with pytest.raises(UnknownOptionError) as exc_info: app.parse_args(["--unknown"], exit_on_error=False, print_error=False, verbose=True, help_on_error=False) # Check that verbose was applied assert exc_info.value.verbose is True # Check cleanup assert len(app.app_stack.overrides_stack) == 1 assert app.app_stack.overrides_stack[0] == {} BrianPugh-cyclopts-921b1fa/tests/test_app_utils.py000066400000000000000000000050771517576204000224310ustar00rootroot00000000000000import subprocess import warnings from pathlib import Path import pytest import cyclopts.core from cyclopts import App from cyclopts.core import _log_framework_warning @pytest.fixture(autouse=True) def clear_cache(): # Setup _log_framework_warning.cache_clear() yield # Teardown _log_framework_warning.cache_clear() def test_app_iter(app): """Like a dictionary, __iter__ of an App should yield keys (command names).""" @app.command def foo(): pass @app.command def bar(): pass actual = list(app) assert actual == ["--help", "-h", "--version", "foo", "bar"] def test_app_iter_with_meta(app): @app.command def foo(): pass @app.command def bar(): pass @app.meta.command def fizz(): pass actual = list(app) assert actual == ["--help", "-h", "--version", "foo", "bar"] actual = list(app.meta) assert actual == ["--help", "-h", "--version", "fizz", "foo", "bar"] def test_app_update(): app1 = App() app2 = App() @app1.command def foo(): pass @app2.command def bar(): pass app1.update(app2) assert list(app1) == ["--help", "-h", "--version", "foo", "bar"] def test_log_framework_warning_unknown(): # Should not generate a warning for UNKNOWN framework with warnings.catch_warnings(): warnings.simplefilter("error") # Convert warnings to errors _log_framework_warning(cyclopts.core.TestFramework.UNKNOWN) # Should not raise def test_log_framework_warning_pytest(app): # Should generate a warning when called from non-cyclopts module with pytest.warns(UserWarning) as warning_records: try: app(exit_on_error=False) except Exception: pass assert len(warning_records) == 1 warning_msg = str(warning_records[0].message) assert ( warning_msg == 'Cyclopts application invoked without tokens under unit-test framework "pytest". Did you mean "app([])"?' ) @pytest.mark.skip(reason="code-coverage injects pytest into subprocess. Otherwise works.") def test_log_framework_warning_pytest_subprocess(): current_dir = Path(__file__).parent # Construct the path to the script relative to the test file script_path = current_dir / "empty_app.py" # Run the script using the constructed path result = subprocess.run( ["python", str(script_path)], capture_output=True, text=True, ) assert "Cyclopts application invoked without tokens under unit-test framework" not in result.stderr BrianPugh-cyclopts-921b1fa/tests/test_argument.py000066400000000000000000000560041517576204000222470ustar00rootroot00000000000000from collections import namedtuple from typing import Annotated, Dict, Optional, TypedDict, Union # noqa: UP035 import pytest from cyclopts.annotations import is_typeddict from cyclopts.argument import ( Argument, ArgumentCollection, _resolve_groups_from_callable, resolve_parameter_name, ) from cyclopts.group import Group from cyclopts.parameter import Parameter from cyclopts.token import Token from cyclopts.utils import UNSET Case = namedtuple("TestCase", ["args", "expected"]) def test_argument_collection_no_annotation_no_default(): def foo(a, b): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 2 assert collection[0].field_info.name == "a" assert collection[0].hint is str assert collection[0].keys == () assert collection[0]._accepts_keywords is False assert collection[1].field_info.name == "b" assert collection[1].hint is str assert collection[1].keys == () assert collection[1]._accepts_keywords is False def test_argument_collection_no_annotation_default(): def foo(a="foo", b=100): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 2 assert collection[0].field_info.name == "a" assert collection[0].hint is str assert collection[0].keys == () assert collection[0]._accepts_keywords is False assert collection[1].field_info.name == "b" assert collection[1].hint is int assert collection[1].keys == () assert collection[1]._accepts_keywords is False def test_argument_collection_basic_annotation(): def foo(a: str, b: int): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 2 assert collection[0].field_info.name == "a" assert collection[0].hint is str assert collection[0].keys == () assert collection[0]._accepts_keywords is False assert collection[1].field_info.name == "b" assert collection[1].hint is int assert collection[1].keys == () assert collection[1]._accepts_keywords is False @pytest.mark.parametrize("type_", [dict, Dict]) # noqa: UP006 def test_argument_collection_bare_dict(type_): def foo(a: type_, b: int): # pyright: ignore pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 2 assert collection[0].field_info.name == "a" assert collection[0].parameter.name == ("--a",) assert collection[0].hint is type_ assert collection[0].keys == () assert collection[0]._accepts_keywords is True assert collection[0]._accepts_arbitrary_keywords is True assert collection[1].field_info.name == "b" assert collection[1].parameter.name == ("--b",) assert collection[1].hint is int assert collection[1].keys == () assert collection[1]._accepts_keywords is False def test_argument_collection_typing_dict(): def foo(a: dict[str, int], b: int): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 2 assert collection[0].field_info.name == "a" assert collection[0].hint == dict[str, int] assert collection[0].keys == () assert collection[0]._accepts_keywords is True assert collection[0]._accepts_arbitrary_keywords is True assert collection[1].field_info.name == "b" assert collection[1].hint is int assert collection[1].keys == () assert collection[1]._accepts_keywords is False def test_argument_collection_typeddict(): class ExampleTypedDict(TypedDict): foo: str bar: int def foo(a: ExampleTypedDict, b: int): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 4 assert collection[0].field_info.name == "a" assert collection[0].parameter.name == ("--a",) assert collection[0].hint is ExampleTypedDict assert collection[0].keys == () assert collection[0]._accepts_keywords is True assert collection[0].children assert collection[1].field_info.name == "foo" assert collection[1].parameter.name == ("--a.foo",) assert collection[1].hint is str assert collection[1].keys == ("foo",) assert collection[1]._accepts_keywords is False assert not collection[1].children assert collection[2].field_info.name == "bar" assert collection[2].parameter.name == ("--a.bar",) assert collection[2].hint is int assert collection[2].keys == ("bar",) assert collection[2]._accepts_keywords is False assert not collection[2].children assert collection[3].field_info.name == "b" assert collection[3].parameter.name == ("--b",) assert collection[3].hint is int assert collection[3].keys == () assert collection[3]._accepts_keywords is False assert not collection[3].children def test_argument_collection_typeddict_nested(): class Inner(TypedDict): fizz: float buzz: Annotated[complex, Parameter(name="bazz")] class ExampleTypedDict(TypedDict): foo: Inner bar: int def foo(a: ExampleTypedDict, b: int): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 6 assert collection[0].field_info.name == "a" assert collection[0].parameter.name == ("--a",) assert collection[0].hint is ExampleTypedDict assert collection[0].keys == () assert collection[0]._accepts_keywords is True assert collection[0].children assert collection[1].field_info.name == "foo" assert collection[1].parameter.name == ("--a.foo",) assert collection[1].hint is Inner assert collection[1].keys == ("foo",) assert collection[1]._accepts_keywords is True assert collection[1].children assert collection[2].field_info.name == "fizz" assert collection[2].parameter.name == ("--a.foo.fizz",) assert collection[2].hint is float assert collection[2].keys == ("foo", "fizz") assert collection[2]._accepts_keywords is False assert not collection[2].children assert collection[3].field_info.name == "buzz" assert collection[3].parameter.name == ("--a.foo.bazz",) assert collection[3].hint is complex assert collection[3].keys == ("foo", "buzz") assert collection[3]._accepts_keywords is False assert not collection[3].children assert collection[4].field_info.name == "bar" assert collection[4].parameter.name == ("--a.bar",) assert collection[4].hint is int assert collection[4].keys == ("bar",) assert collection[4]._accepts_keywords is False assert not collection[4].children assert collection[5].field_info.name == "b" assert collection[5].parameter.name == ("--b",) assert collection[5].hint is int assert collection[5].keys == () assert collection[5]._accepts_keywords is False assert not collection[5].children def test_argument_collection_typeddict_annotated_keys_name_change(): class ExampleTypedDict(TypedDict): foo: Annotated[str, Parameter(name="fizz")] bar: Annotated[int, Parameter(name="buzz")] def foo(a: ExampleTypedDict, b: int): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 4 assert collection[0].field_info.name == "a" assert collection[0].parameter.name == ("--a",) assert collection[0].hint is ExampleTypedDict assert collection[0].keys == () assert collection[0]._accepts_keywords is True assert collection[0].children assert collection[1].field_info.name == "foo" assert collection[1].parameter.name == ("--a.fizz",) assert collection[1].hint is str assert collection[1].keys == ("foo",) assert collection[1]._accepts_keywords is False assert not collection[1].children assert collection[2].field_info.name == "bar" assert collection[2].parameter.name == ("--a.buzz",) assert collection[2].hint is int assert collection[2].keys == ("bar",) assert collection[2]._accepts_keywords is False assert not collection[2].children assert collection[3].field_info.name == "b" assert collection[3].parameter.name == ("--b",) assert collection[3].hint is int assert collection[3].keys == () assert collection[3]._accepts_keywords is False assert not collection[3].children def test_argument_collection_typeddict_annotated_keys_name_override(): class ExampleTypedDict(TypedDict): foo: Annotated[str, Parameter(name="--fizz")] bar: Annotated[int, Parameter(name="--buzz")] def foo(a: ExampleTypedDict, b: int): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 4 assert collection[0].field_info.name == "a" assert collection[0].parameter.name == ("--a",) assert collection[0].hint is ExampleTypedDict assert collection[0].keys == () assert collection[0]._accepts_keywords is True assert collection[0].children assert collection[1].field_info.name == "foo" assert collection[1].parameter.name == ("--fizz",) assert collection[1].hint is str assert collection[1].keys == ("foo",) assert collection[1]._accepts_keywords is False assert not collection[1].children assert collection[2].field_info.name == "bar" assert collection[2].parameter.name == ("--buzz",) assert collection[2].hint is int assert collection[2].keys == ("bar",) assert collection[2]._accepts_keywords is False assert not collection[2].children assert collection[3].field_info.name == "b" assert collection[3].parameter.name == ("--b",) assert collection[3].hint is int assert collection[3].keys == () assert collection[3]._accepts_keywords is False assert not collection[3].children def test_argument_collection_typeddict_flatten_root(): class ExampleTypedDict(TypedDict): foo: str bar: int def foo(a: Annotated[ExampleTypedDict, Parameter(name="*")], b: int): pass collection = ArgumentCollection._from_callable(foo) assert collection[0].field_info.name == "a" assert collection[0].parameter.name == ("*",) assert collection[0].hint is ExampleTypedDict assert collection[0].keys == () assert collection[0]._accepts_keywords is True assert collection[0].children assert collection[1].field_info.name == "foo" assert collection[1].parameter.name == ("--foo",) assert collection[1].hint is str assert collection[1].keys == ("foo",) assert collection[1]._accepts_keywords is False assert not collection[1].children assert collection[2].field_info.name == "bar" assert collection[2].parameter.name == ("--bar",) assert collection[2].hint is int assert collection[2].keys == ("bar",) assert collection[2]._accepts_keywords is False assert not collection[2].children assert collection[3].field_info.name == "b" assert collection[3].parameter.name == ("--b",) assert collection[3].hint is int assert collection[3].keys == () assert collection[3]._accepts_keywords is False assert not collection[3].children def test_argument_collection_var_positional(): def foo(a: int, *b: float): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 2 assert collection[0].parameter.name == ("--a",) assert collection[0].hint is int assert collection[0].keys == () assert collection[0]._accepts_keywords is False assert collection[1].field_info.name == "b" assert collection[1].parameter.name == ("B",) assert collection[1].hint == tuple[float, ...] assert collection[1].keys == () assert collection[1]._accepts_keywords is False def test_argument_collection_var_keyword(): def foo(a: int, **b: float): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 2 assert collection[0].field_info.name == "a" assert collection[0].parameter.name == ("--a",) assert collection[0].hint is int assert collection[0].keys == () assert collection[0]._accepts_keywords is False assert collection[1].field_info.name == "b" assert collection[1].parameter.name == ("--[KEYWORD]",) assert collection[1].hint == dict[str, float] assert collection[1].keys == () assert collection[1]._accepts_keywords is True def test_argument_collection_var_keyword_named(): def foo(a: int, **b: Annotated[float, Parameter(name=("--foo", "--bar"))]): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 2 assert collection[0].field_info.name == "a" assert collection[0].parameter.name == ("--a",) assert collection[0].hint is int assert collection[0].keys == () assert collection[0]._accepts_keywords is False assert collection[1].field_info.name == "b" assert collection[1].parameter.name == ("--foo", "--bar") assert collection[1].hint == dict[str, float] assert collection[1].keys == () assert collection[1]._accepts_keywords is True def test_argument_collection_var_keyword_match(): def foo(a: int, **b: float): pass collection = ArgumentCollection._from_callable(foo) argument, keys, _ = collection.match("--fizz") assert keys == ("fizz",) assert argument.field_info.name == "b" @pytest.mark.parametrize( "args, expected", [ Case(args=(), expected=()), Case(args=(("foo",),), expected=("--foo",)), Case(args=(("--foo",),), expected=("--foo",)), Case(args=(("--foo", "--bar"),), expected=("--foo", "--bar")), Case(args=(("--foo",), ("--bar",)), expected=("--bar",)), Case(args=(("--foo",), ("baz",)), expected=("--foo.baz",)), Case(args=(("--foo",), ("--bar", "baz")), expected=("--bar", "--foo.baz")), Case(args=(("--foo", "--bar"), ("baz",)), expected=("--foo.baz", "--bar.baz")), Case(args=(("*",), ("bar",)), expected=("--bar",)), Case(args=(("--foo", "*"), ("bar",)), expected=("--foo.bar", "--bar")), Case(args=(("--foo",), ("*",), ("bar",)), expected=("--foo.bar",)), Case(args=(("foo",), ("--bar",)), expected=("--bar",)), Case(args=(("foo",), ("bar",)), expected=("--foo.bar",)), ], ) def test_resolve_parameter_name(args, expected): assert resolve_parameter_name(*args) == expected def test_resolve_groups_from_callable(): class User(TypedDict): name: Annotated[str, Parameter(group="Inside Typed Dict")] age: Annotated[int, Parameter(group="Inside Typed Dict")] height: float def build( config1: str, config2: Annotated[str, Parameter()], flag1: Annotated[bool, Parameter(group="Flags")] = False, flag2: Annotated[bool, Parameter(group=("Flags", "Other Flags"))] = False, user: User | None = None, ): pass actual = _resolve_groups_from_callable(build) assert actual == [ Group.create_default_parameters(), Group("Flags"), Group("Other Flags"), Group("Inside Typed Dict"), ] def test_argument_convert(): argument = Argument( hint=list[int], tokens=[ Token(value="42", source="test"), Token(value="70", source="test"), ], ) assert argument.convert() == [42, 70] def test_argument_convert_dict(): def foo(bar: dict[str, int]): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 1 argument = collection[0] # Sanity check the match method assert argument.match("--bar.buzz") == (("buzz",), UNSET) argument.append(Token(value="7", source="test", keys=("fizz",))) argument.append(Token(value="12", source="test", keys=("buzz",))) assert argument.convert() == {"fizz": 7, "buzz": 12} def test_argument_convert_var_keyword(): def foo(**kwargs: int): pass collection = ArgumentCollection._from_callable(foo) assert len(collection) == 1 argument = collection[0] # Sanity check the match method assert argument.match("--fizz") == (("fizz",), UNSET) argument.append(Token(value="7", source="test", keys=("fizz",))) argument.append(Token(value="12", source="test", keys=("buzz",))) assert argument.convert() == {"fizz": 7, "buzz": 12} def test_argument_convert_cparam_provided(): def my_converter(type_, tokens): return f"my_converter_{tokens[0].value}" argument = Argument( hint=str, tokens=[Token(value="my_value", source="test")], parameter=Parameter( converter=my_converter, ), ) assert argument.convert() == "my_converter_my_value" class ExampleTypedDict(TypedDict): foo: str bar: int @pytest.mark.parametrize( "hint", [ ExampleTypedDict, Optional[ExampleTypedDict], Annotated[ExampleTypedDict, "foo"], # A union including a Typed Dict is allowed. Union[ExampleTypedDict, str, int], ], ) def test_is_typed_dict_true(hint): assert is_typeddict(hint) @pytest.mark.parametrize( "hint", [ list, dict, dict, dict[str, int], ], ) def test_is_typed_dict_false(hint): assert not is_typeddict(hint) def test_assemble_argument_collection_no_default_command(): from cyclopts import App app = App() @app.command def foo(loops: int): pass with pytest.raises(ValueError, match="Cannot assemble argument collection: no default command is registered"): app.assemble_argument_collection() def test_argument_collection_getitem_by_string(): def foo(alpha: int, beta: str): pass collection = ArgumentCollection._from_callable(foo) assert collection["--alpha"].field_info.name == "alpha" assert collection["--alpha"].hint is int assert collection["--beta"].field_info.name == "beta" assert collection["--beta"].hint is str @pytest.mark.parametrize( "slice_obj, expected_names", [ (slice(1, 3), ["beta", "gamma"]), (slice(5, 10), []), (slice(-2, None), ["beta", "gamma"]), ], ) def test_argument_collection_getitem_by_slice(slice_obj, expected_names): def foo(alpha: int, beta: str, gamma: float): pass collection = ArgumentCollection._from_callable(foo) subset = collection[slice_obj] assert isinstance(subset, list) assert [arg.field_info.name for arg in subset] == expected_names def test_argument_collection_getitem_by_int(): def foo(alpha: int, beta: str, gamma: float): pass collection = ArgumentCollection._from_callable(foo) assert collection[0].field_info.name == "alpha" assert collection[1].field_info.name == "beta" assert collection[2].field_info.name == "gamma" @pytest.mark.parametrize( "term, default, expected", [ ("--nonexistent", "my_default", "my_default"), (10, "my_default", "my_default"), ], ) def test_argument_collection_get_with_default(term, default, expected): def foo(alpha: int, beta: str): pass collection = ArgumentCollection._from_callable(foo) assert collection.get(term, default=default) == expected @pytest.mark.parametrize( "term, exception_type, match_pattern", [ ("--nonexistent", KeyError, "No such Argument: --nonexistent"), (10, IndexError, "Argument index 10 out of range"), ], ) def test_argument_collection_get_not_found(term, exception_type, match_pattern): def foo(alpha: int, beta: str): pass collection = ArgumentCollection._from_callable(foo) with pytest.raises(exception_type, match=match_pattern): collection.get(term) def test_argument_collection_getitem_not_found(): def foo(alpha: int, beta: str): pass collection = ArgumentCollection._from_callable(foo) with pytest.raises(KeyError, match="No such Argument: --nonexistent"): _ = collection["--nonexistent"] with pytest.raises(IndexError): _ = collection[10] def test_argument_collection_contains_with_string(): """Test that 'in' operator works with string argument names.""" def foo(alpha: int, beta: str): pass collection = ArgumentCollection._from_callable(foo) # Test with full option names assert "--alpha" in collection assert "--beta" in collection # Test with non-existent option assert "--nonexistent" not in collection assert "--gamma" not in collection def test_argument_collection_contains_with_alias(): """Test that 'in' operator works with argument aliases.""" def foo( alpha: Annotated[int, Parameter(name="--foo", alias="-f")], beta: Annotated[str, Parameter(name="--bar", alias=("-b", "--baz"))], ): pass collection = ArgumentCollection._from_callable(foo) # Test with primary names assert "--foo" in collection assert "--bar" in collection # Test with aliases assert "-f" in collection assert "-b" in collection assert "--baz" in collection # Test that original parameter names don't match assert "--alpha" not in collection assert "--beta" not in collection def test_argument_collection_contains_with_object(): """Test that 'in' operator works with Argument objects (backward compatibility).""" def foo(alpha: int, beta: str): pass collection = ArgumentCollection._from_callable(foo) # Test with actual Argument objects from the collection assert collection[0] in collection assert collection[1] in collection # Test with a new Argument object not in the collection new_arg = Argument(parameter=Parameter(name="--gamma"), hint=float) assert new_arg not in collection def test_argument_collection_contains_with_filtered(): """Test that 'in' operator works with filtered collections (common validator pattern).""" collection = ArgumentCollection( [ Argument( tokens=[Token(keyword="--foo", value="100", source="test")], parameter=Parameter(name="--foo"), value=100, ), Argument( parameter=Parameter(name="--bar"), ), Argument( tokens=[Token(keyword="--baz", value="text", source="test")], parameter=Parameter(name="--baz"), value="text", ), ] ) # All arguments exist in the full collection assert "--foo" in collection assert "--bar" in collection assert "--baz" in collection # Only arguments with values exist in the filtered collection populated = collection.filter_by(value_set=True) assert "--foo" in populated assert "--bar" not in populated # Has no value assert "--baz" in populated def test_argument_collection_contains_with_keys(): """Test that 'in' operator works with nested argument keys (dataclass fields).""" from dataclasses import dataclass @dataclass class Config: host: str port: int def foo(config: Config): pass collection = ArgumentCollection._from_callable(foo) # Check for nested fields assert "--config.host" in collection assert "--config.port" in collection # Check that parent also exists (for accepting dict/JSON) assert "--config" in collection # Check non-existent nested fields assert "--config.username" not in collection BrianPugh-cyclopts-921b1fa/tests/test_async.py000066400000000000000000000134431517576204000215420ustar00rootroot00000000000000import asyncio from typing import Annotated import sniffio from cyclopts import App, Parameter def test_async_handler(app): @app.command(name="command") async def async_handler(): assert sniffio.current_async_library() == "asyncio" return "Async handler works" assert app("command") == "Async handler works" def test_async_handler_with_subcommand_works(app): sub_app = App(name="foo") app.command(sub_app) @sub_app.command(name="bar") async def async_handler(): assert sniffio.current_async_library() == "asyncio" return "Async handler works" assert app("foo bar") == "Async handler works" def test_handler(app): @app.command(name="command") def sync_handler(): return "Sync handler works" assert app("command") == "Sync handler works" def test_async_meta_with_async_command(app): results = [] @app.command async def async_command(value: int): await asyncio.sleep(0) # Simulate async work result = f"Async command executed with {value}" results.append(result) return result @app.meta.default async def launcher(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]): await asyncio.sleep(0) # Simulate async initialization results.append("Meta initialized") result = await app.run_async(tokens) # Use run_async when inside an async context results.append("Meta finished") return result result = app.meta(["async-command", "42"]) assert result == "Async command executed with 42" assert results == ["Meta initialized", "Async command executed with 42", "Meta finished"] def test_async_meta_with_sync_command(app): results = [] @app.command def sync_command(value: int): result = f"Sync command executed with {value}" results.append(result) return result @app.meta.default async def launcher(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]): await asyncio.sleep(0) # Simulate async initialization results.append("Meta initialized") # Synchronous commands should also work from async meta result = await app.run_async(tokens) # Use run_async when inside an async context results.append("Meta finished") return result result = app.meta(["sync-command", "42"]) assert result == "Sync command executed with 42" assert results == ["Meta initialized", "Sync command executed with 42", "Meta finished"] def test_async_meta_with_nested_async(app): results = [] @app.default async def default_handler(): await asyncio.sleep(0) results.append("Default handler") return "Default result" @app.meta.default async def meta(*tokens: Annotated[str, Parameter(show=False, allow_leading_hyphen=True)]): await asyncio.sleep(0) results.append("Meta handler") result = await app.run_async(tokens) # Use run_async when inside an async context results.append("Meta complete") return result result = app.meta([]) assert result == "Default result" assert results == ["Meta handler", "Default handler", "Meta complete"] def test_run_async_inherits_app_parameters(): """Test that run_async inherits parameters from App instance when not specified.""" app = App( print_error=False, exit_on_error=False, verbose=True, backend="asyncio", result_action="return_value", ) @app.default async def handler(): return "test" # These should inherit from the app instance async def run_test(): result = await app.run_async([]) return result result = asyncio.run(run_test()) assert result == "test" def test_run_async_overrides_app_parameters(): """Test that run_async can override App instance parameters.""" app = App( print_error=True, exit_on_error=True, verbose=False, result_action="return_value", ) @app.default async def handler(): return "test" # Override the app-level settings async def run_test(): result = await app.run_async( [], print_error=False, exit_on_error=False, verbose=True, ) return result result = asyncio.run(run_test()) assert result == "test" def test_run_async_backend_parameter(app): """Test that run_async accepts and respects backend parameter.""" @app.default async def handler(): # Verify we're using asyncio assert sniffio.current_async_library() == "asyncio" return "test" async def run_test(): # Explicitly specify asyncio backend result = await app.run_async([], backend="asyncio") return result result = asyncio.run(run_test()) assert result == "test" def test_run_async_with_none_parameters(app): """Test that run_async handles None parameters correctly.""" @app.default async def handler(): return "test" async def run_test(): # All None parameters should inherit from app defaults result = await app.run_async( [], print_error=None, exit_on_error=None, verbose=None, backend=None, ) return result result = asyncio.run(run_test()) assert result == "test" def test_run_async_with_result_action(app): """Test that run_async properly handles result_action parameter.""" @app.default async def handler(): return 42 async def run_test(): # With result_action="return_value", should return the value directly result = await app.run_async([], result_action="return_value") return result result = asyncio.run(run_test()) assert result == 42 BrianPugh-cyclopts-921b1fa/tests/test_bind_attrs.py000066400000000000000000000207111517576204000225520ustar00rootroot00000000000000from textwrap import dedent from typing import Annotated import pytest from attrs import define, field from cyclopts import Parameter from cyclopts.exceptions import MissingArgumentError, UnknownOptionError @define class Outfit: body: str head: str @define class User: id: int name: str = "John Doe" tastes: dict[str, int] = field(factory=dict) outfit: Outfit | None = None admin: Annotated[bool, Parameter(negative="not-admin")] = False vip: Annotated[bool, Parameter(negative="--not-vip")] = False staff: Annotated[bool, Parameter(parse=False)] = False def test_bind_attrs(app, assert_parse_args, console): @app.command def foo(user: User): pass assert_parse_args( foo, "foo --user.id=123 --user.tastes.wine=9 --user.tastes.cheese=7 --user.tastes.cabbage=1 --user.outfit.body=t-shirt --user.outfit.head=baseball-cap --user.admin", User( id=123, tastes={"wine": 9, "cheese": 7, "cabbage": 1}, outfit=Outfit(body="t-shirt", head="baseball-cap"), admin=True, ), ) with console.capture() as capture: app("foo --help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_attrs foo USER.ID [ARGS] ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * USER.ID --user.id [required] │ │ USER.NAME --user.name [default: John Doe] │ │ --user.tastes │ │ --user.outfit.body │ │ --user.outfit.head │ │ --user.admin [default: False] │ │ --user.not-admin │ │ --user.vip --not-vip [default: False] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_attrs_flatten(app, assert_parse_args, console): @app.command def foo(user: Annotated[User, Parameter(name="*")]): pass assert_parse_args( foo, "foo --id=123 --tastes.wine=9 --tastes.cheese=7 --tastes.cabbage=1 --outfit.body=t-shirt --outfit.head=baseball-cap --admin", User( id=123, tastes={"wine": 9, "cheese": 7, "cabbage": 1}, outfit=Outfit(body="t-shirt", head="baseball-cap"), admin=True, ), ) with console.capture() as capture: app("foo --help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_attrs foo ID [ARGS] ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * ID --id [required] │ │ NAME --name [default: John Doe] │ │ --tastes │ │ --outfit.body │ │ --outfit.head │ │ --admin --not-admin [default: False] │ │ --vip --not-vip [default: False] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_attrs_accepts_keys_false(app, assert_parse_args, console): @define class SimpleClass: value: int name: str @app.command def foo(example: Annotated[SimpleClass, Parameter(accepts_keys=False)]): pass assert_parse_args(foo, "foo 5 foo", SimpleClass(5, "foo")) assert_parse_args(foo, "foo --example=5 foo", SimpleClass(5, "foo")) with console.capture() as capture: app("foo --help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_attrs foo EXAMPLE ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * EXAMPLE --example [required] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_attrs_kw_only(app, assert_parse_args): @define class Engine: cylinders: int volume: float power: Annotated[float, Parameter(name="--power")] = field(kw_only=True) @app.default def default(engine: Engine): pass assert_parse_args(default, "4 100 --power=200", Engine(4, 100, power=200)) assert_parse_args(default, "--power=200 4 100", Engine(4, 100, power=200)) assert_parse_args(default, "4 --power=200 100", Engine(4, 100, power=200)) with pytest.raises(MissingArgumentError): app.parse_args("4 100 200", exit_on_error=False) def test_bind_attrs_unknown_option(app, assert_parse_args): @define class Engine: cylinders: int volume: float @app.default def default(engine: Engine): pass with pytest.raises(UnknownOptionError): app("--engine.cylinders 4 --this-parameter-does-not-exist 100", exit_on_error=False) def test_bind_attrs_alias(app, assert_parse_args): @define class Engine: cylinders: int volume: float = field(alias="cc") @app.default def default(engine: Engine): pass assert_parse_args(default, "--engine.cylinders 4 --engine.cc 100", Engine(cylinders=4, cc=100.0)) with pytest.raises(UnknownOptionError): app("--engine.cylinders 4 --engine.volume 100", exit_on_error=False) def test_attrs_field_metadata_help(app, console): """Test that attrs field metadata={"help": "..."} is used for help text.""" @define class Config: name: str = field(default="default", metadata={"help": "Help from metadata."}) age: Annotated[int, Parameter(help="Parameter help takes precedence.")] = field( default=25, metadata={"help": "This metadata help is ignored."} ) count: int = field(default=10) """Docstring for count.""" size: int = field(default=5, metadata={"help": "Metadata help overrides docstring."}) """This docstring is ignored.""" @app.default def main(config: Config): pass with console.capture() as capture: app("--help", console=console) actual = capture.get() assert "Help from metadata." in actual assert "Parameter help takes precedence." in actual assert "This metadata help is ignored." not in actual assert "Docstring for count." in actual assert "Metadata help overrides docstring." in actual assert "This docstring is ignored." not in actual def test_attrs_inheritance_simple(app, console): """Test that docstrings from base attrs class are inherited by derived class.""" @define class BaseClass: """Base class.""" some_arg: int = 42 """BaseClass.some_arg docstring.""" @define class DerivedClass(BaseClass): """Derived class.""" some_other_arg: str = "some_other_arg default value" """DerivedClass.some_other_arg docstring.""" @app.default def main(params: DerivedClass): pass with console.capture() as capture: app("--help", console=console) actual = capture.get() # Check that both base and derived docstrings are present assert "BaseClass.some_arg docstring." in actual assert "DerivedClass.some_other_arg docstring." in actual BrianPugh-cyclopts-921b1fa/tests/test_bind_basic.py000066400000000000000000000755411517576204000225110ustar00rootroot00000000000000from functools import partial from typing import Annotated, Any import pytest from cyclopts import Parameter from cyclopts.exceptions import ( ArgumentOrderError, CoercionError, MissingArgumentError, RepeatArgumentError, UnknownCommandError, UnknownOptionError, UnusedCliTokensError, ) from cyclopts.group import Group def test_parse_known_args(app): @app.command def foo(a: int, b: int): pass command, _, unused_tokens, ignored = app.parse_known_args("foo 1 2 --bar 100") assert ignored == {} assert command == foo assert unused_tokens == ["--bar", "100"] @pytest.mark.parametrize( "cmd_str", [ "foo 1 2 3", "foo 1 2 --c=3", "foo --a 1 --b 2 --c 3", "foo --c 3 --b=2 --a 1", ], ) def test_basic_1(app, cmd_str, assert_parse_args): @app.command def foo(a: int, b: int, c: int): pass assert_parse_args(foo, cmd_str, 1, 2, 3) @pytest.mark.parametrize( "cmd_str", [ "foo 1 2 3 --d 10 --some-flag", "foo --some-flag 1 --b=2 --c 3 --d 10", "foo 1 2 --some-flag 3 --d 10", ], ) def test_basic_2(app, cmd_str, assert_parse_args): @app.command def foo(a: int, b: int, c: int, d: int = 5, *, some_flag: bool = False): pass assert_parse_args(foo, cmd_str, 1, 2, 3, d=10, some_flag=True) def test_functools_partial_default(app, assert_parse_args): def foo(a: int, b: int, c: int): pass foo_partial = partial(foo, c=3) app.default(foo_partial) assert_parse_args(foo_partial, "1 2", a=1, b=2) def test_functools_partial_command(app, assert_parse_args): def foo(a: int, b: int, c: int): pass foo_partial = partial(foo, c=3) app.command(foo_partial) assert_parse_args(foo_partial, "foo 1 2", a=1, b=2) def test_basic_allow_hyphen_or_underscore(app, assert_parse_args): @app.default def default(foo_bar): pass assert_parse_args(default, "--foo-bar=bazz", "bazz") assert_parse_args(default, "--foo_bar=bazz", "bazz") def test_out_of_order_mixed_positional_or_keyword(app, assert_parse_args): @app.command def foo(a, b, c): pass with pytest.raises(ArgumentOrderError): app.parse_args("foo --b=5 1 2", print_error=False, exit_on_error=False) def test_command_rename(app, assert_parse_args): @app.command(name="bar") def foo(): pass assert_parse_args(foo, "bar") def test_command_delete(app, assert_parse_args): @app.command def foo(): pass del app["foo"] with pytest.raises(UnknownCommandError): assert_parse_args(foo, "foo") def test_command_multiple_alias(app, assert_parse_args): @app.command(name=["bar", "baz"]) def foo(): pass assert_parse_args(foo, "bar") assert_parse_args(foo, "baz") @pytest.mark.parametrize( "cmd_str", [ "foo --age 10", "foo --duration 10", "foo -a 10", ], ) def test_multiple_names(app, cmd_str, assert_parse_args): @app.command def foo(age: Annotated[int, Parameter(name=["--age", "--duration", "-a"])]): pass assert_parse_args(foo, cmd_str, age=10) @pytest.mark.parametrize( "cmd_str", [ "foo --age 10", "foo --duration 10", "foo -a 10", ], ) def test_alias(app, cmd_str, assert_parse_args): @app.command def foo(age: Annotated[int, Parameter(alias=["--duration", "-a"])]): pass assert_parse_args(foo, cmd_str, age=10) @pytest.mark.parametrize( "cmd_str", [ "foo --age 10", "foo --duration 10", "foo -a 10", ], ) def test_name_and_alias(app, cmd_str, assert_parse_args): """Weird use-case, but should be handled.""" @app.command def foo(age: Annotated[int, Parameter(name="--age", alias=["--duration", "-a"])]): pass assert_parse_args(foo, cmd_str, age=10) @pytest.mark.parametrize( "cmd_str", [ "--job-name foo", "-j foo", ], ) def test_short_name_j(app, cmd_str, assert_parse_args): """ "-j" previously didn't work as a short-name because it's a valid complex value. https://github.com/BrianPugh/cyclopts/issues/328 """ @app.default def main( *, job_name: Annotated[str, Parameter(name=["--job-name", "-j"], negative=False)], ): pass assert_parse_args(main, cmd_str, job_name="foo") def test_short_flag_combining(app, assert_parse_args): @app.default def main( foo: Annotated[bool, Parameter(name=("--foo", "-f"))] = False, bar: Annotated[bool, Parameter(name=("--bar", "-b"))] = False, my_list: Annotated[list | None, Parameter(negative=("--empty-my-list", "-e"))] = None, ): pass # Note: ``my_list`` is explicitly getting an empty list. assert_parse_args(main, "-bfe", foo=True, bar=True, my_list=[]) def test_short_flag_combining_unknown_flag(app, assert_parse_args): @app.default def main( foo: Annotated[bool, Parameter(name=("--foo", "-f"))] = False, bar: Annotated[bool, Parameter(name=("--bar", "-b"))] = False, ): pass with pytest.raises(UnknownOptionError): # The flag "-e" is unknown app("-be", exit_on_error=False) def test_short_flag_combining_with_short_option(app, assert_parse_args): @app.default def main( *, foo: Annotated[bool, Parameter(name=("--foo", "-f"))] = False, bar: Annotated[str, Parameter(name=("--bar", "-b"))], ): pass # With GNU-style support, -fb is now valid: -f (flag) + -b (needs value from next token) # Since no next token exists, should raise MissingArgumentError with pytest.raises(MissingArgumentError): app("-fb", exit_on_error=False) # But -fb with a value should work assert_parse_args(main, "-fb value", foo=True, bar="value") def test_short_integer_flag(app, assert_parse_args): @app.default def main( foo: Annotated[int, Parameter(name=("--foo", "-f"))], *, bar: Annotated[bool, Parameter(name=("--bar", "-1"))], ): pass assert_parse_args(main, "-1 -2", -2, bar=True) @pytest.mark.parametrize( "cmd_str,expected", [ ("-9", -9), ("-10", -10), # Multi-digit negatives should NOT be treated as combined short flags ("-100", -100), ("-3.14", -3.14), ("-1e5", -1e5), ("-1.5e-3", -1.5e-3), ], ) def test_negative_number_not_combined_short_flags(app, cmd_str, expected, assert_parse_args): """Negative numbers should be parsed as values, not as combined short flags. Regression test for issue where -10 was incorrectly interpreted as combined short flags -1 and -0, while -9 worked because len("-9") == 2 doesn't trigger combined flag parsing (which requires len > 2). """ @app.default def main(value: float): pass assert_parse_args(main, cmd_str, expected) @pytest.mark.parametrize( "cmd_str", [ "foo --age 10", "foo --duration 10", "foo -a 10", ], ) def test_multiple_names_no_hyphen(app, cmd_str, assert_parse_args): @app.command def foo(age: Annotated[int, Parameter(name=["age", "duration", "-a"])]): pass assert_parse_args(foo, cmd_str, age=10) @pytest.mark.parametrize( "cmd_str", [ "foo 1", "foo --a=1", "foo --a 1", ], ) @pytest.mark.parametrize("annotated", [False, True]) def test_optional_nonrequired_implicit_coercion(app, cmd_str, annotated, assert_parse_args): """ For a union without an explicit coercion, the first non-None type annotation should be used. In this case, it's ``int``. """ if annotated: @app.command def foo(a: Annotated[int | None, Parameter(help="help for a")] = None): pass else: @app.command def foo(a: int | None = None): pass assert_parse_args(foo, cmd_str, 1) @pytest.mark.parametrize( "cmd_str", [ "foo 1", "foo --a=1", "foo --a 1", ], ) @pytest.mark.parametrize("annotated", [False, True]) def test_optional_nonrequired_implicit_coercion_python310_syntax(app, cmd_str, annotated, assert_parse_args): """ For a union without an explicit coercion, the first non-None type annotation should be used. In this case, it's ``int``. """ if annotated: @app.command def foo(a: Annotated[int | None, Parameter(help="help for a")] = None): # pyright: ignore pass else: @app.command def foo(a: int | None = None): # pyright: ignore pass assert_parse_args(foo, cmd_str, 1) @pytest.mark.parametrize( "cmd_str", [ "--foo val1 --foo val2", ], ) def test_exception_repeat_argument(app, cmd_str): @app.default def default(foo: str): pass with pytest.raises(RepeatArgumentError): app.parse_args(cmd_str, print_error=False, exit_on_error=False) @pytest.mark.parametrize( "cmd_str", [ "--foo val1 --foo val2", ], ) def test_exception_repeat_argument_kwargs(app, cmd_str): @app.default def default(**kwargs: str): pass with pytest.raises(RepeatArgumentError): app.parse_args(cmd_str, print_error=False, exit_on_error=False) @pytest.mark.parametrize("type_hint", [list[str], str]) def test_allow_repeating_false(app, type_hint): @app.default def default(foo: Annotated[type_hint, Parameter(allow_repeating=False)]): # pyright: ignore[reportInvalidTypeForm] pass with pytest.raises(RepeatArgumentError): app.parse_args("--foo a --foo b", print_error=False, exit_on_error=False) def test_allow_repeating_false_consume_multiple(app, assert_parse_args): @app.default def default(foo: Annotated[list[str], Parameter(allow_repeating=False, consume_multiple=True)]): pass assert_parse_args(default, "--foo a b c", foo=["a", "b", "c"]) def test_allow_repeating_false_consume_multiple_repeated(app): @app.default def default(foo: Annotated[list[str], Parameter(allow_repeating=False, consume_multiple=True)]): pass with pytest.raises(RepeatArgumentError): app.parse_args("--foo a --foo b", print_error=False, exit_on_error=False) @pytest.mark.parametrize( "consume_multiple,cmd_str,expected", [ (False, "--foo a --foo b", ["a", "b"]), (True, "--foo a --foo b", ["a", "b"]), (True, "--foo a b --foo c d", ["a", "b", "c", "d"]), ], ) def test_allow_repeating_true_list(app, assert_parse_args, consume_multiple, cmd_str, expected): @app.default def default(foo: Annotated[list[str], Parameter(allow_repeating=True, consume_multiple=consume_multiple)]): pass assert_parse_args(default, cmd_str, foo=expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("--foo a --foo b", "b"), ("--foo a --foo b --foo c", "c"), ], ) def test_allow_repeating_true_scalar(app, assert_parse_args, cmd_str, expected): @app.default def default(foo: Annotated[str, Parameter(allow_repeating=True)]): pass assert_parse_args(default, cmd_str, foo=expected) def test_exception_unused_token(app): @app.default def default(foo: str): pass with pytest.raises(UnusedCliTokensError): app.parse_args("foo bar", print_error=False, exit_on_error=False) @pytest.mark.parametrize( "cmd_str", [ "foo 1", "foo --a=1", "foo --a 1", ], ) @pytest.mark.parametrize("annotated", [False, True]) def test_bind_no_hint_no_default(app, cmd_str, annotated, assert_parse_args): """Parameter with no type hint & no default should be treated as a ``str``.""" if annotated: @app.command def foo(a: Annotated[Any, Parameter(help="help for a")]): # pyright: ignore[reportRedeclaration] pass else: @app.command def foo(a): # pyright: ignore[reportRedeclaration] pass assert_parse_args(foo, cmd_str, "1") @pytest.mark.parametrize( "cmd_str", [ "foo 1", "foo --a=1", "foo --a 1", ], ) @pytest.mark.parametrize("annotated", [False, True]) def test_bind_no_hint_none_default(app, cmd_str, annotated, assert_parse_args): """Parameter with no type hint & ``None`` default should be treated as a ``str``.""" if annotated: @app.command def foo(a: Annotated[Any, Parameter(help="help for a")] = None): # pyright: ignore[reportRedeclaration] pass else: @app.command def foo(a=None): # pyright: ignore[reportRedeclaration] pass assert_parse_args(foo, cmd_str, "1") @pytest.mark.parametrize( "cmd_str", [ "foo 1", "foo --a=1", "foo --a 1", ], ) @pytest.mark.parametrize("annotated", [False, True]) def test_bind_no_hint_typed_default(app, cmd_str, annotated, assert_parse_args): """Parameter with no type hint & typed default should be treated as a ``type(default)``.""" if annotated: @app.command def foo(a: Annotated[Any, Parameter(help="help for a")] = 5): # pyright: ignore[reportRedeclaration] pass else: @app.command def foo(a=5): # pyright: ignore[reportRedeclaration] pass assert_parse_args(foo, cmd_str, 1) @pytest.mark.parametrize( "cmd_str", [ "foo 1", "foo --a=1", "foo --a 1", ], ) @pytest.mark.parametrize("annotated", [False, True]) def test_bind_any_hint(app, cmd_str, annotated, assert_parse_args): """The ``Any`` type hint should be treated as a ``str``.""" if annotated: @app.command def foo(a: Annotated[Any, Parameter(help="help for a")] = None): pass else: @app.command def foo(a: Any = None): pass assert_parse_args(foo, cmd_str, "1") @pytest.mark.parametrize( "cmd_str", [ "1", "0b1", "0x01", "1.0", "0.9", ], ) def test_bind_int_advanced(app, cmd_str, assert_parse_args): @app.default def foo(a: int): pass assert_parse_args(foo, cmd_str, 1) def test_bind_int_advanced_coercion_error(app): @app.default def foo(a: int): pass with pytest.raises(CoercionError): app.parse_args("foo", exit_on_error=False) @pytest.mark.parametrize( "value", [ "10000000000000001", "10000000000000011", "100000000000000111234", ], ) def test_bind_large_int(app, assert_parse_args, value): """Test that large integers are parsed correctly without precision loss. Reproduces bug: https://github.com/BrianPugh/cyclopts/issues/581 """ @app.default def foo(a: int): pass assert_parse_args(foo, value, int(value)) def test_bind_override_app_groups(app): g_commands = Group("Custom Commands") g_arguments = Group("Custom Arguments") g_parameters = Group("Custom Parameters") @app.command(group_commands=g_commands, group_arguments=g_arguments, group_parameters=g_parameters) def foo(): pass assert app["foo"].group_commands == g_commands assert app["foo"].group_arguments == g_arguments assert app["foo"].group_parameters == g_parameters def test_bind_version(app, capsys): app.version = "1.2.3" actual_command, actual_bind, ignored = app.parse_args("--version") assert ignored == {} assert actual_command == app.version_print actual_command(*actual_bind.args, **actual_bind.kwargs) captured = capsys.readouterr() assert captured.out == "1.2.3\n" def test_bind_version_factory(app, capsys): app.version = lambda: "1.2.3" actual_command, actual_bind, ignored = app.parse_args("--version") assert ignored == {} assert actual_command == app.version_print actual_command(*actual_bind.args, **actual_bind.kwargs) captured = capsys.readouterr() assert captured.out == "1.2.3\n" @pytest.mark.parametrize( "cmd_str_e", [ ("foo 1 2 3", MissingArgumentError), ("foo 1 2", MissingArgumentError), ], ) def test_missing_keyword_argument(app, cmd_str_e): cmd_str, e = cmd_str_e @app.command def foo(a: int, b: int, c: int, *, d: int): pass with pytest.raises(e): app.parse_args(cmd_str, print_error=False, exit_on_error=False) @pytest.mark.parametrize( "cmd_str", [ "1 -- --2 3 4", "-- 1 --2 3 4", "--c=3 4 -- 1 --2", "--c 3 4 -- 1 --2", ], ) def test_default_double_hyphen_end_of_options_delimiter(app, cmd_str, assert_parse_args): @app.default def foo(a: int, b: str, c: tuple[int, int]): pass assert_parse_args(foo, cmd_str, 1, "--2", (3, 4)) def test_disabled_double_hyphen_end_of_options_delimiter_from_app(app, assert_parse_args): app.end_of_options_delimiter = "" @app.default def foo(a: int, b: Annotated[str, Parameter(allow_leading_hyphen=True)], c: tuple[int, int]): pass assert_parse_args(foo, "1 -- 3 4", 1, "--", (3, 4)) def test_disabled_double_hyphen_end_of_options_delimiter_from_parse_args(app, assert_parse_args_config): @app.default def foo(a: int, b: Annotated[str, Parameter(allow_leading_hyphen=True)], c: tuple[int, int]): pass assert_parse_args_config({"end_of_options_delimiter": ""}, foo, "1 -- 3 4", 1, "--", (3, 4)) def test_end_of_options_delimiter_from_parse_args(app, assert_parse_args): app.end_of_options_delimiter = "AND" @app.default def foo(a: int, b: str, c: tuple[int, int]): pass assert_parse_args(foo, "1 AND --2 3 4", 1, "--2", (3, 4)) def test_end_of_options_delimiter_override(app, assert_parse_args_config): app.end_of_options_delimiter = "AND" # This gets overridden @app.default def foo(a: int, b: str, c: tuple[int, int]): pass assert_parse_args_config({"end_of_options_delimiter": "DELIMIT"}, foo, "1 DELIMIT --2 3 4", 1, "--2", (3, 4)) # ============================================================================ # GNU-style combined short options tests # Tests for the feature where short options can be combined with their values # in a single token, similar to GNU getopt behavior. # # Examples: # -o9 → -o with value "9" # -iuroot → -i (flag), -u with value "root" # -fvbfile → -f (flag), -v (flag), -b with value "file" # ============================================================================ def test_single_short_option_with_attached_value(app, assert_parse_args): """Test that -o9 is equivalent to -o 9.""" @app.default def main(output: Annotated[str, Parameter(name=("-o", "--output"))]): pass # Traditional syntax should still work assert_parse_args(main, "-o 9", output="9") assert_parse_args(main, "-o=9", output="9") # NEW: Attached value should work assert_parse_args(main, "-o9", output="9") def test_single_short_option_with_attached_string_value(app, assert_parse_args): """Test that -ofile.txt works for string values.""" @app.default def main(output: Annotated[str, Parameter(name=("-o", "--output"))]): pass # Traditional syntax assert_parse_args(main, "-o file.txt", output="file.txt") # NEW: Attached string value assert_parse_args(main, "-ofile.txt", output="file.txt") assert_parse_args(main, "-oroot", output="root") def test_combined_flags_before_option_with_value(app, assert_parse_args): """Test sudo -iuroot style: flags followed by option with attached value.""" @app.default def main( user: Annotated[str, Parameter(name=("-u", "--user"))], interactive: Annotated[bool, Parameter(name=("-i", "--interactive"))] = False, ): pass # Traditional syntax assert_parse_args(main, "-i -u root", interactive=True, user="root") # NEW: GNU-style combined assert_parse_args(main, "-iuroot", interactive=True, user="root") def test_multiple_flags_before_option_with_value(app, assert_parse_args): """Test multiple flags combined with an option that takes a value.""" @app.default def main( output: Annotated[str, Parameter(name=("-o", "--output"))], force: Annotated[bool, Parameter(name=("-f", "--force"))] = False, verbose: Annotated[bool, Parameter(name=("-v", "--verbose"))] = False, ): pass # Traditional syntax assert_parse_args(main, "-f -v -o file.txt", force=True, verbose=True, output="file.txt") # NEW: GNU-style combined assert_parse_args(main, "-fvofile.txt", force=True, verbose=True, output="file.txt") assert_parse_args(main, "-vfofile.txt", verbose=True, force=True, output="file.txt") def test_option_with_value_at_end_consumes_next_token(app, assert_parse_args): """Test that -fu (without attached value) consumes next token.""" @app.default def main( user: Annotated[str, Parameter(name=("-u", "--user"))], force: Annotated[bool, Parameter(name=("-f", "--force"))] = False, ): pass # When -u is at the end without attached value, next token is the value assert_parse_args(main, "-fu root", force=True, user="root") def test_option_with_value_in_middle_stops_processing(app, assert_parse_args): """Test that once we hit an option with a value, rest is consumed as the value.""" @app.default def main( user: Annotated[str, Parameter(name=("-u", "--user"))], force: Annotated[bool, Parameter(name=("-f", "--force"))] = False, verbose: Annotated[bool, Parameter(name=("-v", "--verbose"))] = False, ): pass # -fuvroot should be: -f (flag), -u with value "vroot" # The 'v' is NOT treated as a flag, it's part of the value assert_parse_args(main, "-fuvroot", force=True, user="vroot") def test_combined_short_options_numeric_value(app, assert_parse_args): """Test numeric values attached to short options.""" @app.default def main( port: Annotated[int, Parameter(name=("-p", "--port"))], verbose: Annotated[bool, Parameter(name=("-v", "--verbose"))] = False, ): pass # Traditional syntax assert_parse_args(main, "-v -p 8080", verbose=True, port=8080) # NEW: Attached numeric value assert_parse_args(main, "-vp8080", verbose=True, port=8080) assert_parse_args(main, "-p8080", port=8080) def test_gnu_backward_compatibility_all_flags(app, assert_parse_args): """Test that combining only flags still works (no regression).""" @app.default def main( force: Annotated[bool, Parameter(name=("-f", "--force"))] = False, verbose: Annotated[bool, Parameter(name=("-v", "--verbose"))] = False, quiet: Annotated[bool, Parameter(name=("-q", "--quiet"))] = False, ): pass # This should continue to work as before assert_parse_args(main, "-fvq", force=True, verbose=True, quiet=True) assert_parse_args(main, "-qvf", quiet=True, verbose=True, force=True) def test_gnu_backward_compatibility_space_separated(app, assert_parse_args): """Ensure traditional space-separated syntax still works.""" @app.default def main( output: Annotated[str, Parameter(name=("-o", "--output"))], user: Annotated[str, Parameter(name=("-u", "--user"))], ): pass # Traditional syntax must still work assert_parse_args(main, "-o file.txt -u root", output="file.txt", user="root") def test_gnu_backward_compatibility_equals_syntax(app, assert_parse_args): """Ensure equals syntax still works.""" @app.default def main( output: Annotated[str, Parameter(name=("-o", "--output"))], ): pass # Equals syntax must still work assert_parse_args(main, "-o=file.txt", output="file.txt") assert_parse_args(main, "--output=file.txt", output="file.txt") def test_empty_string_value_attached(app, assert_parse_args): """Test edge case: can we have an empty attached value?""" @app.default def main( user: Annotated[str, Parameter(name=("-u", "--user"))], force: Annotated[bool, Parameter(name=("-f", "--force"))] = False, ): pass # -fu with no characters after should consume next token # This is actually the same as test_option_with_value_at_end_consumes_next_token assert_parse_args(main, "-fu root", force=True, user="root") def test_counting_parameter_combined(app, assert_parse_args): """Test that counting parameters work in combinations.""" @app.default def main( output: Annotated[str, Parameter(name=("-o", "--output"))], verbose: Annotated[int, Parameter(name=("-v", "--verbose"), count=True)] = 0, ): pass # Multiple v's for verbosity levels, then option with value assert_parse_args(main, "-vvvofile.txt", verbose=3, output="file.txt") assert_parse_args(main, "-vvofile.txt", verbose=2, output="file.txt") def test_only_option_no_flags(app, assert_parse_args): """Test single option with attached value (simplest case).""" @app.default def main( user: Annotated[str, Parameter(name=("-u", "--user"))], ): pass assert_parse_args(main, "-uroot", user="root") assert_parse_args(main, "-u root", user="root") def test_long_option_should_not_be_split(app, assert_parse_args): """Test that long options (--) are NOT affected by this feature.""" @app.default def main( output: Annotated[str, Parameter(name=("--output",))], ): pass # Long options should still require space or equals assert_parse_args(main, "--output file.txt", output="file.txt") assert_parse_args(main, "--output=file.txt", output="file.txt") # --outputfile.txt should NOT be split into --output with value file.txt # It should be treated as an unknown option "--outputfile.txt" # (This maintains current behavior) def test_hyphenated_value_attached(app, assert_parse_args): """Test that hyphenated values work when attached.""" @app.default def main( output: Annotated[str, Parameter(name=("-o", "--output"))], ): pass # Value containing hyphens assert_parse_args(main, "-omy-file.txt", output="my-file.txt") assert_parse_args(main, "-o--weird-value", output="--weird-value") def test_special_characters_in_attached_value(app, assert_parse_args): """Test special characters in attached values.""" @app.default def main( pattern: Annotated[str, Parameter(name=("-p", "--pattern"))], ): pass # Special characters should be preserved assert_parse_args(main, "-p*.txt", pattern="*.txt") assert_parse_args(main, "-p[a-z]+", pattern="[a-z]+") # Note: For short options with GNU-style attachment, = is part of the value # Use -p "file=value" or --pattern=file for splitting on = assert_parse_args(main, "-pfile=value", pattern="file=value") def test_multiple_options_only_first_gets_attached_value(app, assert_parse_args): """Test behavior when multiple value-taking options are combined. Only the first option can have an attached value. Once we hit a value-taking option, everything after is the value. """ @app.default def main( user: Annotated[str, Parameter(name=("-u", "--user"))], password: Annotated[str | None, Parameter(name=("-p", "--password"))] = None, ): pass # -uprootpass should be: -u with value "prootpass" # The 'p' is NOT treated as another option, it's part of the value assert_parse_args(main, "-uprootpass", user="prootpass") # To set both, use separate options or space assert_parse_args(main, "-uroot -ppass", user="root", password="pass") @pytest.mark.parametrize( "cmd,expected_output", [ ("-o9", "9"), ("-o99", "99"), ("-o123abc", "123abc"), ("-oabc123", "abc123"), ], ) def test_various_attached_values(app, assert_parse_args, cmd, expected_output): """Parametrized test for various value formats.""" @app.default def main(output: Annotated[str, Parameter(name=("-o", "--output"))]): pass assert_parse_args(main, cmd, output=expected_output) def test_single_char_option_with_negative_value(app, assert_parse_args): """Test that -o-5 treats -5 as the value for -o.""" @app.default def main( offset: Annotated[int, Parameter(name=("-o", "--offset"))], ): pass # -o-5 should be: -o with value "-5" # This tests that the "-5" part is treated as a value, not as a separate option assert_parse_args(main, "-o-5", offset=-5) def test_positional_only_list_interleaved_with_keywords_error(app): """Positional tokens after keyword args should not be consumed by a positional-only list. Regression test for issue #763. """ @app.default def main(foo: list[str] | None = None, /, *, bar: int = 0, baz: int = 0): pass with pytest.raises(UnusedCliTokensError): app.parse_args("a b c --bar 8 --baz 10 d", print_error=False, exit_on_error=False) def test_positional_only_list_then_keywords(app): """Positional tokens before keywords should work fine. Regression test for issue #763. """ @app.default def main(foo: list[str] | None = None, /, *, bar: int = 0, baz: int = 0): pass _, actual_bind, _ = app.parse_args("a b c d --bar 8 --baz 10", print_error=False, exit_on_error=False) assert actual_bind.args == (["a", "b", "c", "d"],) assert actual_bind.kwargs == {"bar": 8, "baz": 10} def test_positional_only_list_keywords_first(app): """Keywords before positional tokens should work fine. Regression test for issue #763. """ @app.default def main(foo: list[str] | None = None, /, *, bar: int = 0, baz: int = 0): pass _, actual_bind, _ = app.parse_args("--bar 8 --baz 10 a b c d", print_error=False, exit_on_error=False) assert actual_bind.args == (["a", "b", "c", "d"],) assert actual_bind.kwargs == {"bar": 8, "baz": 10} def test_positional_only_list_interleaved_with_delimiter(app): """Tokens after -- should still be consumed even with interleaving before the delimiter. Regression test for issue #763. """ @app.default def main(foo: list[str] | None = None, /, *, bar: int = 0): pass _, actual_bind, _ = app.parse_args("a b --bar 8 -- d e", print_error=False, exit_on_error=False) assert actual_bind.args == (["a", "b", "d", "e"],) assert actual_bind.kwargs == {"bar": 8} def test_positional_only_list_and_scalar_interleaved_error(app): """Multiple positional-only params (list + scalar) should also reject interleaving. Regression test for issue #763. """ from pathlib import Path @app.default def main(inputs: list[str], output: Path, /, *, verbose: bool = False): pass # Non-interleaved: should work _, actual_bind, _ = app.parse_args("a b c out.csv --verbose", print_error=False, exit_on_error=False) assert actual_bind.args == (["a", "b", "c"], Path("out.csv")) assert actual_bind.kwargs == {"verbose": True} # Interleaved: should error with pytest.raises(UnusedCliTokensError): app.parse_args("a b --verbose c out.csv", print_error=False, exit_on_error=False) BrianPugh-cyclopts-921b1fa/tests/test_bind_boolean_flag.py000066400000000000000000000166061517576204000240350ustar00rootroot00000000000000from typing import Annotated, Any import pytest from cyclopts import ( Group, Parameter, UnknownOptionError, ) from cyclopts.exceptions import CoercionError @pytest.mark.parametrize( "cmd_str,expected", [ ("--my-flag", True), ("--my-flag=true", True), ("--my-flag=false", False), ("--no-my-flag", False), ], ) def test_boolean_flag_default(app, cmd_str, expected, assert_parse_args): @app.default def foo(my_flag: bool = True): pass assert_parse_args(foo, cmd_str, expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("--my-flag", True), ("--my-flag=true", True), ("--my-flag=false", False), ("--no-my-flag", False), ], ) def test_boolean_flag_unannotated_default(app, cmd_str, expected, assert_parse_args): """https://github.com/BrianPugh/cyclopts/issues/797 A parameter whose type is inferred from a boolean default (no annotation) should still get a generated negative ``--no-`` flag. """ @app.default def foo(my_flag=False): pass assert_parse_args(foo, cmd_str, expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("--my-flag", True), ("--my-flag=true", True), ("--my-flag=false", False), ("--no-my-flag", False), ], ) def test_boolean_flag_any_annotation_default(app, cmd_str, expected, assert_parse_args): """``Any`` annotation with a bool default should behave like an unannotated bool default. ``FieldInfo.hint`` already infers ``type(default)`` when the annotation resolves to ``Any``, so the parsing path treats this as ``bool``; the negative-flag path should match. """ @app.default def foo(my_flag: Any = False): pass assert_parse_args(foo, cmd_str, expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("--my-flag", True), ("--my-flag=true", True), ("--my-flag=false", False), ("--no-my-flag", False), ("--your-flag", True), ("--no-your-flag", False), ("-m", True), ], ) def test_boolean_flag_alias(app, cmd_str, expected, assert_parse_args): @app.default def foo(my_flag: Annotated[bool, Parameter(alias=["--your-flag", "-m"])] = True): pass assert_parse_args(foo, cmd_str, expected) def test_boolean_flag_app_parameter_default(app, assert_parse_args): app.default_parameter = Parameter(negative="") @app.default def foo(my_flag: bool = True): pass # Normal positive flag should still work. assert_parse_args(foo, "--my-flag", True) with pytest.raises(UnknownOptionError) as e: app.parse_args("--no-my-flag", exit_on_error=False) assert str(e.value) == 'Unknown option: "--no-my-flag". Did you mean "--my-flag"?' def test_boolean_flag_app_parameter_negative(app, assert_parse_args): @app.default def foo(my_flag: Annotated[bool, Parameter("", negative="--no-my-flag")] = True): pass assert_parse_args(foo, "--no-my-flag", False) with pytest.raises(UnknownOptionError): app.parse_args("--my-flag", exit_on_error=False, print_error=True) assert_parse_args(foo, "--no-my-flag=True", False) assert_parse_args(foo, "--no-my-flag=False", True) with pytest.raises(CoercionError): app("--no-my-flag=", exit_on_error=False) def test_boolean_flag_app_parameter_default_annotated_override(app, assert_parse_args): app.default_parameter = Parameter(negative="") @app.default def foo(my_flag: Annotated[bool, Parameter(negative="--NO-flag")] = True): pass assert_parse_args(foo, "--my-flag", True) assert_parse_args(foo, "--NO-flag", False) def test_boolean_flag_app_parameter_default_nested_annotated_override(app, assert_parse_args): app.default_parameter = Parameter(negative="") def my_converter(type_, tokens): return 5 my_int = Annotated[int, Parameter(converter=my_converter)] @app.default def foo(*, foo: Annotated[my_int, Parameter(name="--bar")] = True): # pyright: ignore[reportInvalidTypeForm] pass assert_parse_args(foo, "--bar=10", foo=5) def test_boolean_flag_group_default_parameter_resolution_1(app, assert_parse_args): food_group = Group("Food", default_parameter=Parameter(negative_bool="group-")) @app.default def foo(flag: Annotated[bool, Parameter(group=food_group)]): pass assert_parse_args(foo, "--group-flag", False) @pytest.mark.parametrize( "cmd_str,expected", [ ("--bar", True), ("--no-bar", False), ], ) def test_boolean_flag_custom_positive(app, cmd_str, expected, assert_parse_args): @app.default def foo(my_flag: Annotated[bool, Parameter(name="--bar")] = True): pass assert_parse_args(foo, cmd_str, expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("--bar", True), ("--no-bar", False), ], ) def test_boolean_flag_custom_short_positive(app, cmd_str, expected, assert_parse_args): @app.default def foo(my_flag: Annotated[bool, Parameter(name=["--bar", "-b"])] = True): pass assert_parse_args(foo, cmd_str, expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("--my-flag", True), ("--yesnt-my-flag", False), ], ) def test_boolean_flag_custom_negative(app, cmd_str, expected, assert_parse_args): @app.default def foo(my_flag: Annotated[bool, Parameter(negative="--yesnt-my-flag")] = True): pass assert_parse_args(foo, cmd_str, expected) @pytest.mark.parametrize( "negative", ["", (), []], ) def test_boolean_flag_disable_negative(app, negative, assert_parse_args): @app.default def foo(my_flag: Annotated[bool, Parameter(negative=negative)] = True): pass assert_parse_args(foo, "--my-flag", True) with pytest.raises(UnknownOptionError): assert_parse_args(foo, "--no-my-flag", True) @pytest.mark.parametrize( "cmd_str,expected", [ # Positive flags with kebab-case and snake_case ("--my-flag", True), ("--my_flag", True), # Negative flags with kebab-case and snake_case (issue #692) ("--no-my-flag", False), ("--no_my_flag", False), ("--no-my_flag", False), # Mixed case ], ) def test_boolean_flag_snake_case_negative(app, cmd_str, expected, assert_parse_args): """Test that negative boolean flags accept both kebab-case and snake_case. Issue #692: Cyclopts accepts both `--some-flag` and `--some_flag` for positive flags, but only accepts `--no-some-flag` for negative flags. This test ensures that `--no_some_flag` and `--no-some_flag` also work. """ @app.default def foo(my_flag: bool = True): pass assert_parse_args(foo, cmd_str, expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("-n", True), ("--dry-run", True), ("-N", False), ("--no-dry-run", False), ], ) def test_boolean_flag_uppercase_short_negative(app, cmd_str, expected, assert_parse_args): """Uppercase short negative flags like -N should parse correctly. https://github.com/BrianPugh/cyclopts/issues/747 """ @app.default def foo( dry_run: Annotated[ bool, Parameter( name=["-n", "--dry-run"], negative=["-N", "--no-dry-run"], ), ] = True, ): pass assert_parse_args(foo, cmd_str, expected) BrianPugh-cyclopts-921b1fa/tests/test_bind_converter_validator.py000066400000000000000000000135021517576204000254710ustar00rootroot00000000000000from dataclasses import dataclass from typing import Annotated from unittest.mock import Mock import pytest from cyclopts import Parameter, ValidationError from cyclopts.exceptions import CoercionError from cyclopts.token import Token @pytest.fixture def validator(): return Mock() def test_custom_converter(app, assert_parse_args): def custom_converter(type_, tokens): return 2 * int(tokens[0].value) @app.default def foo(age: Annotated[int, Parameter(converter=custom_converter)]): pass assert_parse_args(foo, "5", age=10) def test_custom_converter_dict(app, assert_parse_args): def custom_converter(type_, tokens): return {k: 2 * int(v[0].value) for k, v in tokens.items()} @app.default def foo(*, color: Annotated[dict[str, int], Parameter(converter=custom_converter)]): pass assert_parse_args(foo, "--color.red 5 --color.green 10", color={"red": 10, "green": 20}) def test_custom_converter_user_value_error_single_token(app): def custom_converter(type_, tokens): raise ValueError @app.default def foo(age: Annotated[int, Parameter(converter=custom_converter)]): pass with pytest.raises(CoercionError) as e: app("5", exit_on_error=False) assert str(e.value) == 'Invalid value for "AGE": unable to convert "5" into int.' def test_custom_converter_user_value_error_multi_token(app): def custom_converter(type_, tokens): raise ValueError @app.default def foo(age: Annotated[tuple[int, int], Parameter(converter=custom_converter)]): pass with pytest.raises(CoercionError) as e: app("5 6", exit_on_error=False) assert str(e.value) == 'Invalid value for "--age": unable to convert value to tuple[int, int].' def test_custom_converter_user_value_error_with_message(app): def custom_converter(type_, tokens): raise ValueError("Some user-provided message.") @app.default def foo(age: Annotated[int, Parameter(converter=custom_converter)]): pass with pytest.raises(CoercionError) as e: app("5", exit_on_error=False) assert str(e.value) == "Some user-provided message." def test_custom_converter_user_kwargs_error(app): def custom_converter(type_, tokens): raise ValueError @app.default def foo(**kwargs: Annotated[int, Parameter(converter=custom_converter)]): pass with pytest.raises(CoercionError) as e: app("--foo 5", exit_on_error=False) assert str(e.value) == 'Invalid value for "--foo": unable to convert "5" into int.' def test_custom_converter_user_kwargs_error_with_message(app): def custom_converter(type_, tokens): raise ValueError("Some user-provided message.") @app.default def foo(**kwargs: Annotated[int, Parameter(converter=custom_converter)]): pass with pytest.raises(CoercionError) as e: app("--foo 5", exit_on_error=False) assert str(e.value) == "Invalid value for --foo: Some user-provided message." def test_custom_validator_positional_or_keyword(app, assert_parse_args, validator): @app.default def foo(age: Annotated[int, Parameter(validator=validator)]): pass assert_parse_args(foo, "10", age=10) validator.assert_called_once_with(int, 10) def test_custom_validator_var_keyword(app, assert_parse_args, validator): @app.default def foo(**age: Annotated[int, Parameter(validator=validator)]): pass assert_parse_args(foo, "--age=10", age=10) validator.assert_called_once_with(int, 10) def test_custom_validator_var_positional(app, assert_parse_args, validator): @app.default def foo(*age: Annotated[int, Parameter(validator=validator)]): pass assert_parse_args(foo, "10", 10) validator.assert_called_once_with(int, 10) def test_custom_validators(app, assert_parse_args): def lower_bound(type_, value): if value <= 0: raise ValueError("An unreasonable age was entered.") def upper_bound(type_, value): if value > 150: raise ValueError("An unreasonable age was entered.") @app.default def foo(age: Annotated[int, Parameter(validator=[lower_bound, upper_bound])]): pass assert_parse_args(foo, "10", 10) with pytest.raises(ValidationError): app.parse_args("0", print_error=False, exit_on_error=False) with pytest.raises(ValidationError): app.parse_args("200", print_error=False, exit_on_error=False) def test_custom_converter_and_validator(app, assert_parse_args, validator): def custom_validator(type_, value): if not (0 < value < 150): raise ValueError("An unreasonable age was entered.") def custom_converter(type_, tokens): return 2 * int(tokens[0].value) @app.default def foo(age: Annotated[int, Parameter(converter=custom_converter, validator=validator)]): pass assert_parse_args(foo, "5", 10) validator.assert_called_once_with(int, 10) def test_custom_validator_on_default_signature_value(app, validator): @app.default def foo(age: Annotated[int, Parameter(validator=validator)] = -1): pass app.parse_args("", print_error=False, exit_on_error=False) validator.assert_called_once_with(int, -1) def test_custom_command_validator(app, assert_parse_args): validator = Mock() @app.default(validator=validator) def foo(a: int, b: int, c: int): pass assert_parse_args(foo, "1 2 3", 1, 2, 3) validator.assert_called_once_with(a=1, b=2, c=3) def test_custom_converter_inside_class(app, mocker): converter = mocker.Mock(return_value=5) @Parameter(name="*") @dataclass class Config: foo: Annotated[int, Parameter(converter=converter)] @app.default def default(config: Config): pass app("bar") converter.assert_called_once_with(int, (Token(value="bar", source="cli"),)) BrianPugh-cyclopts-921b1fa/tests/test_bind_custom_type.py000066400000000000000000000013301517576204000237640ustar00rootroot00000000000000from typing import Annotated from cyclopts import Parameter class OneToken: def __init__(self, value: int): self.value = value def __eq__(self, other): return self.value == other.value def test_custom_type_one_token_implicit_convert(app): @app.default def default(value: OneToken): return value res = app("5") assert res == OneToken(5) def test_custom_type_one_token_explicit_convert(app): def converter(type_, tokens): assert len(tokens) == 1 return type_(int(tokens[0].value) + 10) @app.default def default(value: Annotated[OneToken, Parameter(converter=converter)]): return value res = app("5") assert res == OneToken(15) BrianPugh-cyclopts-921b1fa/tests/test_bind_dataclasses.py000066400000000000000000000660031517576204000237100ustar00rootroot00000000000000import sys from dataclasses import dataclass, field from textwrap import dedent from typing import Annotated import pytest from cyclopts import Parameter from cyclopts.exceptions import ( ArgumentOrderError, MissingArgumentError, UnusedCliTokensError, ) @dataclass class User: id: int name: str = "John Doe" tastes: dict[str, int] = field(default_factory=dict) def test_bind_dataclass(app, assert_parse_args, console): @app.command def foo(some_number: int, user: User): pass external_data = { "id": 123, # "name" is purposely missing. "tastes": { "wine": 9, "cheese": 7, "cabbage": 1, }, } assert_parse_args( foo, "foo 100 --user.id=123 --user.tastes.wine=9 --user.tastes.cheese=7 --user.tastes.cabbage=1", 100, User(**external_data), ) def test_bind_dataclass_missing_all_arguments(app, assert_parse_args, console): """We expect to see the first subargument (--user.id) in the error message, not the root "--user". """ @app.default def default(some_number: int, user: User): pass with console.capture() as capture, pytest.raises(MissingArgumentError): app("123", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Parameter "--user.id" requires an argument. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_dataclass_recursive(app, assert_parse_args, console, normalize_trailing_whitespace): @dataclass class Wheel: diameter: int "Diameter of wheel in inches." @dataclass class Engine: cylinders: int "Number of cylinders the engine has." hp: Annotated[float, Parameter(name=("horsepower", "p"))] "Amount of horsepower the engine can generate." diesel: bool = False "If this engine consumes diesel, instead of gasoline." @dataclass class Car: name: str "The name/model of the car." mileage: float "How many miles the car has driven." engine: Annotated[Engine, Parameter(name="*", group="Engine")] = field(kw_only=True) # pyright: ignore "The kind of engine the car is using." wheel: Wheel "The kind of wheels the car is using." n_axles: int = 2 "Number of axles the car has." @app.command def build(*, license_plate: str, car: Car): """Build a car. Parameters ---------- license_plate: str License plate identifier to give to car. car: Car Car specifications. """ assert_parse_args( build, "build --car.name=ford --car.mileage=500 --car.cylinders=4 --car.p=200 --car.wheel.diameter=18 --license-plate=ABCDEFG", car=Car( name="ford", mileage=500, engine=Engine(cylinders=4, hp=200), wheel=Wheel(diameter=18), ), license_plate="ABCDEFG", ) with console.capture() as capture: app("build --help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_dataclasses build --license-plate STR --car.name STR --car.mileage FLOAT --car.cylinders INT --car.horsepower FLOAT --car.wheel.diameter INT [OPTIONS] Build a car. ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * --license-plate License plate identifier to give to car. │ │ [required] │ │ * --car.name The name/model of the car. [required] │ │ * --car.mileage How many miles the car has driven. │ │ [required] │ │ * --car.wheel.diameter Diameter of wheel in inches. [required] │ │ --car.n-axles Number of axles the car has. [default: 2] │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Engine ───────────────────────────────────────────────────────────╮ │ * --car.cylinders Number of cylinders the engine has. │ │ [required] │ │ * --car.horsepower --car.p Amount of horsepower the engine can │ │ generate. [required] │ │ --car.diesel If this engine consumes diesel, │ │ --car.no-diesel instead of gasoline. [default: False] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert normalize_trailing_whitespace(actual) == expected def test_bind_dataclass_recursive_missing_arg(app, assert_parse_args, console): """The ``engine`` parameter itself is optional, but if specified it has 2 required fields.""" @dataclass class Engine: cylinders: int hp: float = 100 @dataclass class Car: name: str mileage: float engine: Annotated[Engine, Parameter(name="*", group="Engine")] = field(default_factory=lambda: Engine(8, 500)) @app.command def build(*, license_plate: str, car: Car): pass # Specifying a complete engine works. assert_parse_args( build, "build --car.name=ford --car.mileage=500 --car.cylinders=4 --car.hp=200 --license-plate=ABCDEFG", car=Car(name="ford", mileage=500, engine=Engine(cylinders=4, hp=200)), license_plate="ABCDEFG", ) # Specifying NO engine works. assert_parse_args( build, "build --car.name=ford --car.mileage=500 --license-plate=ABCDEFG", car=Car(name="ford", mileage=500), license_plate="ABCDEFG", ) # Partially defining an engine does NOT work. with console.capture() as capture, pytest.raises(MissingArgumentError): app.parse_args( "build --car.name=ford --car.mileage=500 --car.hp=200 --license-plate=ABCDEFG", error_console=console, exit_on_error=False, ) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Command "build" parameter "--car.cylinders" requires an argument. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected @pytest.mark.parametrize( "cmd", [ "'Bob Smith' 30", "--nickname='Bob Smith' --player.years-young=30", ], ) def test_bind_dataclass_double_name_override_no_hyphen(app, assert_parse_args, console, cmd): @dataclass class User: # Beginning with "--" will completely override the parenting parameter name. name: Annotated[str, Parameter(name="--nickname")] # Not beginning with "--" will tack it on to the parenting parameter name. age: Annotated[int, Parameter(name="years-young")] @app.default def main(user: Annotated[User, Parameter(name="player")]): # but what about without --? print(user) assert_parse_args(main, cmd, user=User("Bob Smith", 30)) @pytest.mark.parametrize( "cmd_str", [ "100 200", "--a 100 --bar 200", "--bar 200 100", ], ) def test_bind_dataclass_positionally(app, assert_parse_args, cmd_str, console): @dataclass class Config: a: int = field() # intentionally empty field to make sure stuff doesn't assume this field has a default. """Docstring for a.""" b: Annotated[int, Parameter(name="bar")] = 2 """This is the docstring for python parameter "b".""" @app.default def my_default_command(config: Annotated[Config, Parameter(name="*")]): print(f"{config=}") assert_parse_args(my_default_command, cmd_str, Config(a=100, b=200)) with console.capture() as capture: app("build --help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_dataclasses A [ARGS] ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help (-h) Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * A --a Docstring for a. [required] │ │ BAR --bar This is the docstring for python parameter "b". │ │ [default: 2] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_dataclass_default_factory_help(app, console): @dataclass class Config: a: int = field(default_factory=lambda: 5) """Docstring for a.""" @app.default def my_default_command(config: Annotated[Config | None, Parameter(name="*")] = None): print(f"{config=}") with console.capture() as capture: app("--help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_dataclasses [ARGS] ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help (-h) Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────╮ │ A --a Docstring for a. [default: 5] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_dataclass_positionally_with_keyword_only_exception_no_default(app, assert_parse_args): @dataclass class Config: a: int = 1 """Docstring for a.""" b: Annotated[int, Parameter(name="bar")] = 2 """This is the docstring for python parameter "b".""" c: int = field(kw_only=True) # pyright: ignore @app.default def my_default_command(foo, config: Annotated[Config, Parameter(name="*")], bar): print(f"{config=}") expected = ("v1", Config(100, 200, c=300), "v2") assert_parse_args(my_default_command, "v1 100 200 v2 --c=300", *expected) assert_parse_args(my_default_command, "--c=300 v1 100 200 v2", *expected) with pytest.raises(MissingArgumentError): app.parse_args("v1 100 200 300 v2", exit_on_error=False) with pytest.raises(ArgumentOrderError): app.parse_args("v1 --a=100 200 300 v2", exit_on_error=False) with pytest.raises(ArgumentOrderError): app.parse_args("v1 --bar=v2 100 200 --c=300", exit_on_error=False) def test_bind_dataclass_positionally_with_keyword_only_exception_with_default(app, assert_parse_args): @dataclass class Config: a: int = 1 """Docstring for a.""" b: Annotated[int, Parameter(name="bar")] = 2 """This is the docstring for python parameter "b".""" c: int = field(default=5, kw_only=True) # pyright: ignore @app.default def my_default_command(config: Annotated[Config | None, Parameter(name="*")] = None): print(f"{config=}") with pytest.raises(UnusedCliTokensError): app.parse_args("100 200 300", exit_on_error=False) def test_bind_dataclass_tuple_in_var_args(app, assert_parse_args): @dataclass class Square: center: tuple[float, float] side_length: float @app.default def my_default_command(*squares: Square): pass assert_parse_args(my_default_command, "10 20 30", Square(center=(10.0, 20.0), side_length=30.0)) def test_bind_dataclass_with_alias_attribute(app, assert_parse_args): """https://github.com/BrianPugh/cyclopts/issues/505""" @Parameter(name="*", negative=False) @dataclass class DataclassParameters: with_alias: Annotated[ bool, Parameter( alias="-a", help="Parameter that uses alias.", ), ] = False with_iterable: Annotated[ bool, Parameter(["--with-iterable", "-i"], help="Parameter that uses an iterable as name.") ] = False @app.default def main(*, params: DataclassParameters | None = None) -> None: pass assert_parse_args(main, "-a", params=DataclassParameters(with_alias=True, with_iterable=False)) assert_parse_args(main, "--with-alias", params=DataclassParameters(with_alias=True, with_iterable=False)) def test_bind_dataclass_star_parameter_better_error_message(app, console): """Test that Parameter(name="*") raises ValueError at app setup time when parameter has no default.""" @Parameter(name="*") @dataclass class Foo: bar: int = 12 def cmd(foo: Foo): print(foo) expected_message = ( r'Parameter "foo" in function .* has all optional values, uses Parameter\(name="\*"\), but itself has no default value\. Consider either:\n' r' 1\) If immutable, providing a default value "foo: Foo = Foo\(\)"\n' r' 2\) Otherwise, declaring it optional like "foo: Foo \| None = None" and instanting the foo object in the function body:\n' r" if foo is None:\n" r" foo = Foo\(\)" ) with pytest.raises(ValueError, match=expected_message): app.default(cmd) def test_bind_dataclass_with_varargs_consume_all(app, assert_parse_args): """Test dataclass with *args field that consumes all remaining tokens.""" @dataclass class FileProcessor: output: str inputs: tuple[str, ...] @app.default def process(config: Annotated[FileProcessor, Parameter(name="*")]): pass assert_parse_args( process, "out.txt in1.txt in2.txt in3.txt", config=FileProcessor(output="out.txt", inputs=("in1.txt", "in2.txt", "in3.txt")), ) def test_dataclass_field_metadata_help(app, console): """Test that dataclass Field metadata={"help": "..."} is used for help text.""" @dataclass class Config: name: str = field(default="default", metadata={"help": "Help from metadata."}) age: Annotated[int, Parameter(help="Parameter help takes precedence.")] = field( default=25, metadata={"help": "This metadata help is ignored."} ) count: int = field(default=10) """Docstring for count.""" size: int = field(default=5, metadata={"help": "Metadata help overrides docstring."}) """This docstring is ignored.""" @app.default def main(config: Config): pass with console.capture() as capture: app("--help", console=console) actual = capture.get() assert "Help from metadata." in actual assert "Parameter help takes precedence." in actual assert "This metadata help is ignored." not in actual assert "Docstring for count." in actual assert "Metadata help overrides docstring." in actual assert "This docstring is ignored." not in actual def test_bind_dataclass_direct_parent_match_issue_647(app, assert_parse_args): """Test that --foo bar baz correctly converts to Foo(name="bar", age=25). Previously, parent arguments with children would match directly (e.g., --foo), consume tokens, but then fail to properly construct the object because children had no tokens. The fix allows parent arguments to convert their tokens as positional arguments to the structured type. See: https://github.com/BrianPugh/cyclopts/issues/647 """ @dataclass class Foo: name: str age: int @app.default def cmd(*, foo: Annotated[Foo, Parameter()] = Foo(name="default", age=0)): # noqa: B008 return foo # This should work: directly provide values after parent name (positional-style) assert_parse_args(cmd, "--foo bar 25", foo=Foo(name="bar", age=25)) # This should also still work: explicit nested keys assert_parse_args(cmd, "--foo.name baz --foo.age 30", foo=Foo(name="baz", age=30)) # No arguments should use the function default (tested separately) _, bound, _ = app.parse_args("", print_error=False, exit_on_error=False) assert "foo" not in bound.arguments result = cmd(**bound.arguments) assert result == Foo(name="default", age=0) def test_bind_dataclass_kw_only_with_accepts_keys_false_issue_648(app, assert_parse_args): """Test that kw_only dataclass with accepts_keys=False works. When a dataclass is kw_only=True, it cannot accept positional arguments. With accepts_keys=False, Cyclopts should still pass values as keyword arguments. See: https://github.com/BrianPugh/cyclopts/issues/648 """ @dataclass(kw_only=True) class Foo: name: str @app.default def cmd(*, foo: Annotated[Foo, Parameter(accepts_keys=False)]) -> Foo: return foo assert_parse_args(cmd, "--foo Alice", foo=Foo(name="Alice")) @pytest.mark.skipif(sys.version_info < (3, 14), reason="Requires Python 3.14+ for field(doc=...)") def test_dataclass_field_doc_parameter_help(app, console): """Test that Python 3.14's dataclass field(doc=...) parameter is used for help text.""" @dataclass class Config: name: str = field(default="default", doc="Help from doc parameter.") # type: ignore[call-arg] age: Annotated[int, Parameter(help="Parameter help takes precedence.")] = field( default=25, doc="This doc is ignored.", # type: ignore[call-arg] ) count: int = field(default=10, doc="Doc parameter help.") # type: ignore[call-arg] size: int = field( default=5, metadata={"help": "Metadata help takes precedence over doc."}, doc="This doc is ignored.", # type: ignore[call-arg] ) height: int = field(default=8) """Docstring for height.""" width: int = field(default=12, doc="Doc parameter overrides docstring.") # type: ignore[call-arg] """This docstring is ignored.""" @app.default def main(config: Config): pass with console.capture() as capture: app("--help", console=console) actual = capture.get() assert "Help from doc parameter." in actual assert "Parameter help takes precedence." in actual assert "This doc is ignored." not in actual assert "Doc parameter help." in actual assert "Metadata help takes precedence" in actual assert "Docstring for height." in actual assert "Doc parameter overrides docstring." in actual assert "This docstring is ignored." not in actual def test_dataclass_inheritance_simple(app, console): """Test that docstrings from base dataclass are inherited by derived class. Regression test for: https://github.com/BrianPugh/cyclopts/issues/691 """ @dataclass class BaseClass: """Base class.""" some_arg: int = 42 """BaseClass.some_arg docstring.""" @dataclass class DerivedClass(BaseClass): """Derived class.""" some_other_arg: str = "some_other_arg default value" """DerivedClass.some_other_arg docstring.""" @app.default def main(params: DerivedClass): pass with console.capture() as capture: app("--help", console=console) actual = capture.get() # Check that both base and derived docstrings are present assert "BaseClass.some_arg docstring." in actual assert "DerivedClass.some_other_arg docstring." in actual def test_dataclass_inheritance_multi_level(app, console): """Test that docstrings propagate through multiple inheritance levels.""" @dataclass class GrandparentClass: """Grandparent class.""" grandparent_field: int = 1 """Grandparent field doc.""" @dataclass class ParentClass(GrandparentClass): """Parent class.""" parent_field: int = 2 """Parent field doc.""" @dataclass class ChildClass(ParentClass): """Child class.""" child_field: int = 3 """Child field doc.""" @app.default def main(params: ChildClass): pass with console.capture() as capture: app("--help", console=console) actual = capture.get() # Check that all three levels of docstrings are present assert "Grandparent field doc." in actual assert "Parent field doc." in actual assert "Child field doc." in actual def test_dataclass_inheritance_override_docstring(app, console): """Test that derived class can override base class docstrings.""" @dataclass class BaseClass: """Base class.""" shared_field: int = 1 """Base docstring.""" @dataclass class DerivedClass(BaseClass): """Derived class.""" shared_field: int = 2 """Derived docstring overrides base.""" new_field: str = "new" """New field in derived.""" @app.default def main(params: DerivedClass): pass with console.capture() as capture: app("--help", console=console) actual = capture.get() # Derived class docstring should take precedence assert "Derived docstring overrides base." in actual assert "Base docstring." not in actual assert "New field in derived." in actual def test_dataclass_inheritance_with_parameter_name_star(app, console, normalize_trailing_whitespace): """Test inheritance with Parameter(name='*') works correctly.""" @dataclass class BaseConfig: """Base configuration.""" verbose: bool = False """Enable verbose output.""" @dataclass class ExtendedConfig(BaseConfig): """Extended configuration.""" debug: bool = False """Enable debug mode.""" @app.default def main(config: Annotated[ExtendedConfig | None, Parameter(name="*")] = None): pass with console.capture() as capture: app("--help", console=console) actual = capture.get() # Both base and derived docstrings should be present assert "Enable verbose output." in actual assert "Enable debug mode." in actual def test_dataclass_inheritance_no_docstrings_in_derived(app, console): """Test that base docstrings work even if derived class has no docstrings.""" @dataclass class BaseClass: """Base class.""" base_field: int = 1 """Base field documentation.""" @dataclass class DerivedClass(BaseClass): """Derived class.""" # No docstring for this field derived_field: int = 2 @app.default def main(params: DerivedClass): pass with console.capture() as capture: app("--help", console=console) actual = capture.get() # Base docstring should be present assert "Base field documentation." in actual def test_bind_dataclass_with_parameter_alias_issue_696(app, assert_parse_args, console): """Test that user class with parameter alias generates correct arguments. https://github.com/BrianPugh/cyclopts/issues/696 When using a user class as a parameter with an alias, the generated arguments should use the class attribute names, not the parameter alias. For example, parameter `source` with alias `from`, and class attribute `server`: - Should generate: --source.server, --from.server - Should NOT generate: --source.from, --from.from """ @dataclass class Source: server: str port: int = 8080 @app.default def main(source: Annotated[Source, Parameter(name="source", alias="from")]): return source # Check the help output to see what arguments are generated with console.capture() as capture: app("--help", console=console) actual = capture.get() # Should have the correct arguments assert "--source.server" in actual, "Missing --source.server" assert "--from.server" in actual, "Missing --from.server" # Should NOT have the incorrect arguments (using parameter alias as attribute name) assert "--source.from" not in actual, "Incorrectly generated --source.from" assert "--from.from" not in actual, "Incorrectly generated --from.from" # Test with --source.server (should work) assert_parse_args( main, "--source.server=localhost --source.port=9000", source=Source(server="localhost", port=9000), ) # Test with --from.server (should work) assert_parse_args( main, "--from.server=localhost --from.port=9000", source=Source(server="localhost", port=9000), ) # Test mixed usage (should work) assert_parse_args( main, "--source.server=localhost --from.port=9000", source=Source(server="localhost", port=9000), ) BrianPugh-cyclopts-921b1fa/tests/test_bind_dict.py000066400000000000000000000023161517576204000223410ustar00rootroot00000000000000import pytest @pytest.mark.parametrize( "type_", [ dict[str, str], dict, dict, ], ) def test_bind_dict_str_to_str(app, assert_parse_args, type_): @app.command def foo(d: type_): # pyright: ignore pass assert_parse_args(foo, "foo --d.key_1='val1' --d.key-2='val2'", d={"key_1": "val1", "key-2": "val2"}) def test_bind_dict_str_to_int_typing(app, assert_parse_args): @app.command def foo(d: dict[str, int]): pass assert_parse_args(foo, "foo --d.key1=7 --d.key2=42", d={"key1": 7, "key2": 42}) def test_bind_dict_str_to_int_builtin(app, assert_parse_args): @app.command def foo(d: dict[str, int]): pass assert_parse_args(foo, "foo --d.key1=7 --d.key2=42", d={"key1": 7, "key2": 42}) def test_bind_dict_with_mixed_keys_and_regular_params(app, assert_parse_args): """Test that dict parameters with keys work alongside regular parameters.""" @app.command def foo(name: str, config: dict[str, int], count: int): pass assert_parse_args( foo, "foo --name=test --config.key1=7 --config.key2=42 --count=3", name="test", config={"key1": 7, "key2": 42}, count=3, ) BrianPugh-cyclopts-921b1fa/tests/test_bind_empty_iterable.py000066400000000000000000000230441517576204000244240ustar00rootroot00000000000000import textwrap from io import StringIO from typing import Annotated import pytest from rich.console import Console from cyclopts import CycloptsPanel, Parameter from cyclopts.exceptions import ConsumeMultipleError, MissingArgumentError @pytest.mark.parametrize( "cmd_str,expected", [ ("", None), ("--empty-my-list", []), ("--empty-my-list=True", []), ("--empty-my-list=False", None), ], ) def test_optional_list_empty_flag_default(app, cmd_str, expected, assert_parse_args): @app.default def foo(my_list: list[int] | None = None): pass if expected is None: assert_parse_args(foo, cmd_str) else: assert_parse_args(foo, cmd_str, expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("", None), ("--empty-my-set", set()), ("--empty-my-set=True", set()), ("--empty-my-set=False", None), ], ) def test_optional_set_empty_flag_default(app, cmd_str, expected, assert_parse_args): @app.default def foo(my_set: set[int] | None = None): pass if expected is None: assert_parse_args(foo, cmd_str) else: assert_parse_args(foo, cmd_str, expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("", None), ("--empty-my-list", []), ("--my-list", []), ("--my-list http://example.com", ["http://example.com"]), ("--my-list http://example.com http://example2.com", ["http://example.com", "http://example2.com"]), ], ) def test_optional_list_consume_multiple(app, cmd_str, expected, assert_parse_args): """Test that --my-list with no values behaves like --empty-my-list when consume_multiple=True.""" @app.default def foo(my_list: Annotated[list[str] | None, Parameter(consume_multiple=True)] = None): pass if expected is None: assert_parse_args(foo, cmd_str) else: assert_parse_args(foo, cmd_str, expected) # --- consume_multiple=int (minimum count) --- def test_consume_multiple_int_rejects_empty(app): """consume_multiple=1 should reject --option with no values.""" @app.default def foo(*, my_list: Annotated[list[str] | None, Parameter(consume_multiple=1)] = None): pass with pytest.raises(ConsumeMultipleError, match="requires at least 1 elements. Got 0"): app.parse_args("--my-list", print_error=False, exit_on_error=False) def test_consume_multiple_int_accepts_enough(app, assert_parse_args): """consume_multiple=1 should accept --option with 1+ values.""" @app.default def foo(*, my_list: Annotated[list[str] | None, Parameter(consume_multiple=1)] = None): pass assert_parse_args(foo, "--my-list a", my_list=["a"]) assert_parse_args(foo, "--my-list a b c", my_list=["a", "b", "c"]) def test_consume_multiple_int_min_2(app): """consume_multiple=2 should reject --option with fewer than 2 values.""" @app.default def foo(*, my_list: Annotated[list[str] | None, Parameter(consume_multiple=2)] = None): pass with pytest.raises(ConsumeMultipleError, match="requires at least 2 elements. Got 1"): app.parse_args("--my-list a", print_error=False, exit_on_error=False) def test_consume_multiple_int_min_2_accepts(app, assert_parse_args): """consume_multiple=2 should accept --option with 2+ values.""" @app.default def foo(*, my_list: Annotated[list[str] | None, Parameter(consume_multiple=2)] = None): pass assert_parse_args(foo, "--my-list a b", my_list=["a", "b"]) assert_parse_args(foo, "--my-list a b c", my_list=["a", "b", "c"]) def test_consume_multiple_0_allows_empty(app, assert_parse_args): """consume_multiple=0 should behave like True (allow empty).""" @app.default def foo(*, my_list: Annotated[list[str] | None, Parameter(consume_multiple=0)] = None): pass assert_parse_args(foo, "--my-list", my_list=[]) assert_parse_args(foo, "--my-list a b", my_list=["a", "b"]) # --- consume_multiple=tuple (min, max) --- @pytest.mark.parametrize( "cmd_str,expected", [ ("--my-list", []), ("--my-list a", ["a"]), ("--my-list a b c", ["a", "b", "c"]), ], ) def test_consume_multiple_tuple_0_3(app, cmd_str, expected, assert_parse_args): """consume_multiple=(0, 3) allows empty but stops after 3.""" @app.default def foo(*, my_list: Annotated[list[str] | None, Parameter(consume_multiple=(0, 3))] = None): pass assert_parse_args(foo, cmd_str, my_list=expected) def test_consume_multiple_tuple_0_3_max_enforced(app): """consume_multiple=(0, 3) should reject more than 3 values.""" @app.default def foo(other: str, *, my_list: Annotated[list[str] | None, Parameter(consume_multiple=(0, 3))] = None): pass # With 4 values after --my-list, should raise an error since max is 3. with pytest.raises(ConsumeMultipleError, match="accepts at most 3 elements. Got 4"): app.parse_args("--my-list a b c d", print_error=False, exit_on_error=False) def test_consume_multiple_tuple_min_max(app, assert_parse_args): """consume_multiple=(2, 5) requires 2-5 values.""" @app.default def foo(*, my_list: Annotated[list[str] | None, Parameter(consume_multiple=(2, 5))] = None): pass assert_parse_args(foo, "--my-list a b", my_list=["a", "b"]) assert_parse_args(foo, "--my-list a b c d e", my_list=["a", "b", "c", "d", "e"]) def test_consume_multiple_tuple_min_not_met(app): """consume_multiple=(2, 5) should reject fewer than 2 values.""" @app.default def foo(*, my_list: Annotated[list[str] | None, Parameter(consume_multiple=(2, 5))] = None): pass with pytest.raises(ConsumeMultipleError, match="requires at least 2 elements. Got 1"): app.parse_args("--my-list a", print_error=False, exit_on_error=False) with pytest.raises(ConsumeMultipleError, match="requires at least 2 elements. Got 0"): app.parse_args("--my-list", print_error=False, exit_on_error=False) def test_consume_multiple_error_is_missing_argument_error(): """ConsumeMultipleError should be a subclass of MissingArgumentError for backward compat.""" assert issubclass(ConsumeMultipleError, MissingArgumentError) def test_consume_multiple_error_message_max(app): """Exceeding max should produce an 'accepts at most' message.""" @app.default def foo(*, urls: Annotated[list[str] | None, Parameter(consume_multiple=(2, 5))] = None): pass with pytest.raises(ConsumeMultipleError, match="accepts at most 5 elements. Got 6"): app.parse_args("--urls a b c d e f", print_error=False, exit_on_error=False) def test_consume_multiple_error_panel_min(app): """Full rich panel output for min constraint violation.""" @app.default def foo(*, urls: Annotated[list[str] | None, Parameter(consume_multiple=(2, 5))] = None): pass with pytest.raises(ConsumeMultipleError) as exc_info: app.parse_args("--urls a", print_error=False, exit_on_error=False) buf = StringIO() console = Console(file=buf, width=70, force_terminal=True, highlight=False, color_system=None, legacy_windows=False) console.print(CycloptsPanel(exc_info.value)) actual = buf.getvalue() expected = textwrap.dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Parameter "--urls" requires at least 2 elements. Got 1. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_consume_multiple_error_panel_empty(app): """Full rich panel output when zero values provided.""" @app.default def foo(*, urls: Annotated[list[str] | None, Parameter(consume_multiple=3)] = None): pass with pytest.raises(ConsumeMultipleError) as exc_info: app.parse_args("--urls", print_error=False, exit_on_error=False) buf = StringIO() console = Console(file=buf, width=70, force_terminal=True, highlight=False, color_system=None, legacy_windows=False) console.print(CycloptsPanel(exc_info.value)) actual = buf.getvalue() expected = textwrap.dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Parameter "--urls" requires at least 3 elements. Got 0. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected # --- Validation at Parameter creation --- def test_consume_multiple_negative_int_raises(): """Negative int should raise ValueError.""" with pytest.raises(ValueError, match="non-negative"): Parameter(consume_multiple=-1) def test_consume_multiple_tuple_min_gt_max_raises(): """Tuple with min > max should raise ValueError.""" with pytest.raises(ValueError, match="min must be <= max"): Parameter(consume_multiple=(5, 3)) def test_consume_multiple_tuple_negative_raises(): """Tuple with negative values should raise ValueError.""" with pytest.raises(ValueError, match="non-negative"): Parameter(consume_multiple=(-1, 3)) BrianPugh-cyclopts-921b1fa/tests/test_bind_env_var.py000066400000000000000000000030141517576204000230520ustar00rootroot00000000000000from typing import Annotated import pytest from cyclopts import MissingArgumentError, Parameter def test_env_var_unset_use_signature_default(app, assert_parse_args, monkeypatch): @app.default def foo(bar: Annotated[int, Parameter(env_var="BAR")] = 123): pass monkeypatch.delenv("BAR", raising=False) assert_parse_args(foo, "") def test_env_var_set_use_env_var(app, assert_parse_args, monkeypatch): @app.default def foo(bar: Annotated[int, Parameter(env_var="BAR")] = 123): pass monkeypatch.setenv("BAR", "456") assert_parse_args(foo, "", 456) def test_env_var_set_use_env_var_no_default(app, assert_parse_args, monkeypatch): @app.default def foo(bar: Annotated[int, Parameter(env_var="BAR")]): pass monkeypatch.setenv("BAR", "456") assert_parse_args(foo, "", 456) monkeypatch.delenv("BAR") with pytest.raises(MissingArgumentError): app.parse_args([], exit_on_error=False) def test_env_var_list_set_use_env_var(app, assert_parse_args, monkeypatch): @app.default def foo(bar: Annotated[int, Parameter(env_var=["BAR", "BAZ"])] = 123): pass monkeypatch.setenv("BAR", "456") assert_parse_args(foo, [], 456) def test_env_var_unset_list_use_signature_default(app, assert_parse_args, monkeypatch): @app.default def foo(bar: Annotated[int, Parameter(env_var=["BAR", "BAZ"])] = 123): pass monkeypatch.delenv("BAR", raising=False) monkeypatch.delenv("BAZ", raising=False) assert_parse_args(foo, []) BrianPugh-cyclopts-921b1fa/tests/test_bind_generic_class.py000066400000000000000000000200711517576204000242150ustar00rootroot00000000000000from textwrap import dedent from typing import Annotated, Literal import pytest from cyclopts import Parameter from cyclopts.exceptions import MissingArgumentError class Outfit: def __init__(self, body: str, head: str): self.body = body self.head = head def __eq__(self, other): if not isinstance(other, type(self)): return False return self.body == other.body and self.head == other.head class User: def __init__( self, id: int, name: str = "John Doe", tastes: dict[str, int] | None = None, outfit: Annotated[Outfit, Parameter(accepts_keys=True)] | None = None, ): self.id = id self.name = name self.tastes = tastes if tastes is not None else {} self.outfit = outfit def __eq__(self, other): if not isinstance(other, type(self)): return False return ( self.id == other.id and self.name == other.name and self.tastes == other.tastes and self.outfit == other.outfit ) def test_bind_generic_class_accepts_keys_true(app, assert_parse_args): @app.command def foo(user: Annotated[User, Parameter(accepts_keys=True)]): pass assert_parse_args( foo, "foo --user.id=123 --user.tastes.wine=9 --user.tastes.cheese=7 --user.tastes.cabbage=1 --user.outfit.body=t-shirt --user.outfit.head=baseball-cap", User(id=123, tastes={"wine": 9, "cheese": 7, "cabbage": 1}, outfit=Outfit(body="t-shirt", head="baseball-cap")), ) def test_bind_generic_class_accepts_keys_none_1_args(app, assert_parse_args, console): class User: def __init__(self, age: int): self.age = age def __eq__(self, other): if not isinstance(other, type(self)): return False return self.age == other.age @app.command def foo(user: User): pass assert_parse_args(foo, "foo 100", User(100)) with console.capture() as capture: app("foo --help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_generic_class foo USER.AGE ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * USER.AGE --user.age [required] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_generic_class_accepts_keys_false_1_args(app, assert_parse_args, console): class User: def __init__(self, age: int): self.age = age def __eq__(self, other): if not isinstance(other, type(self)): return False return self.age == other.age @app.command def foo(user: Annotated[User, Parameter(accepts_keys=False)]): pass assert_parse_args(foo, "foo 100", User(100)) with console.capture() as capture: app("foo --help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_generic_class foo USER ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * USER --user [required] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected class Coordinates: def __init__( self, x: float, y: float, *, color: Literal["red", "green", "blue"] = "red", ): self.x = x self.y = y self.color = color def __eq__(self, other): if not isinstance(other, type(self)): return False return self.x == other.x and self.y == other.y and self.color == other.color def test_bind_generic_class_accepts_default_multiple_args( app, assert_parse_args, console, normalize_trailing_whitespace ): @app.command def foo(coords: Coordinates, priority: int): pass assert_parse_args(foo, "foo 100 200 7", Coordinates(100, 200), 7) with console.capture() as capture: app("foo --help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_generic_class foo [OPTIONS] COORDS.X COORDS.Y PRIORITY ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * COORDS.X --coords.x [required] │ │ * COORDS.Y --coords.y [required] │ │ * PRIORITY --priority [required] │ │ --coords.color [choices: red, green, blue] [default: red] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert normalize_trailing_whitespace(actual) == expected def test_bind_generic_class_accepts_false_multiple_args(app, assert_parse_args, console): @app.command def foo(coords: Annotated[Coordinates, Parameter(accepts_keys=False)]): pass assert_parse_args(foo, "foo 100 200", Coordinates(100, 200)) with console.capture() as capture: app("foo --help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_generic_class foo COORDS ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * COORDS --coords [required] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_generic_class_keyword_with_positional_only_subkeys(app, console, assert_parse_args): """This test has a keyword-only parameter that has position-only subkeys, which are skipped.""" class User: def __init__(self, name: str, age: int, /): self.name = name self.age = age def __eq__(self, other): if not isinstance(other, type(self)): return False return self.name == other.name and self.age == other.age @app.command def foo(*, user: User): pass assert_parse_args(foo, "foo --user Bob 30", user=User("Bob", 30)) with console.capture() as capture: app("foo --help", console=console) actual = capture.get() expected = dedent( """\ Usage: test_bind_generic_class foo --user USER ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * --user [required] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected with pytest.raises(MissingArgumentError): app("foo --user.name=Bob --user.age=100", exit_on_error=False) BrianPugh-cyclopts-921b1fa/tests/test_bind_kwargs.py000066400000000000000000000012641517576204000227150ustar00rootroot00000000000000def test_kwargs_list_int(app, assert_parse_args): @app.command def foo(a: int, **kwargs: list[int]): pass assert_parse_args(foo, "foo 1 --bar=2 --baz=4 --bar 3", 1, bar=[2, 3], baz=[4]) def test_kwargs_int(app, assert_parse_args): @app.command def foo(a: int, **kwargs: int): pass assert_parse_args(foo, "foo 1 --bar=2 --baz 3", 1, bar=2, baz=3) assert_parse_args(foo, "foo 1", 1) def test_args_and_kwargs_int(app, assert_parse_args): @app.command def foo(a: int, *args: int, **kwargs: int): pass assert_parse_args(foo, "foo 1 2 3 4 5 --bar=2 --baz 3", 1, 2, 3, 4, 5, bar=2, baz=3) assert_parse_args(foo, "foo 1", 1) BrianPugh-cyclopts-921b1fa/tests/test_bind_list.py000066400000000000000000000127021517576204000223710ustar00rootroot00000000000000import collections.abc from collections.abc import MutableSequence as AbcMutableSequence from collections.abc import MutableSet as AbcMutableSet from collections.abc import Sequence from collections.abc import Set as AbcSet from pathlib import Path import pytest from cyclopts.exceptions import MissingArgumentError def test_pos_list(app, assert_parse_args): @app.command def foo(a: list[int]): pass assert_parse_args(foo, "foo 1 2 3", [1, 2, 3]) def test_keyword_list(app, assert_parse_args): @app.command def foo(a: list[int]): pass assert_parse_args(foo, "foo --a=1 --a=2 --a 3", [1, 2, 3]) def test_keyword_list_mutable_default(app, assert_parse_args): @app.command def foo(a: list[int] = []): # noqa: B006 pass assert_parse_args(foo, "foo --a=1 --a=2 --a 3", [1, 2, 3]) assert_parse_args(foo, "foo") def test_keyword_list_pos(app, assert_parse_args): @app.command def foo(a: list[int]): pass assert_parse_args(foo, "foo 1 2 3", [1, 2, 3]) def test_keyword_optional_list_none_default(app, assert_parse_args): @app.command def foo(a: list[int] | None = None): pass assert_parse_args(foo, "foo") @pytest.mark.parametrize( "cmd_expected", [ ("", None), ("--verbose", [True]), ("--verbose --verbose", [True, True]), ("--verbose --verbose --no-verbose", [True, True, False]), ("--verbose --verbose=False", [True, False]), ("--verbose --no-verbose=False", [True, True]), ("--verbose --verbose=True", [True, True]), ], ) def test_keyword_list_of_bool(app, assert_parse_args, cmd_expected): cmd, expected = cmd_expected @app.default def foo(*, verbose: list[bool] | None = None): pass if expected is None: assert_parse_args(foo, cmd) else: assert_parse_args(foo, cmd, verbose=expected) @pytest.mark.parametrize( "cmd_expected", [ ("", None), ("--verbose", (True,)), ("--verbose --verbose", (True, True)), ("--verbose --verbose --no-verbose", (True, True, False)), ("--verbose --verbose=False", (True, False)), ("--verbose --no-verbose=False", (True, True)), ("--verbose --verbose=True", (True, True)), ], ) def test_keyword_tuple_of_bool(app, assert_parse_args, cmd_expected): cmd, expected = cmd_expected @app.default def foo(*, verbose: tuple[bool, ...] | None = None): pass if expected is None: assert_parse_args(foo, cmd) else: assert_parse_args(foo, cmd, verbose=expected) @pytest.mark.parametrize( "cmd_expected", [ ("", None), ("--verbose", [True]), ("--verbose --verbose", [True, True]), ("--verbose --verbose --no-verbose", [True, True, False]), ("--verbose --verbose=False", [True, False]), ("--verbose --no-verbose=False", [True, True]), ("--verbose --verbose=True", [True, True]), ], ) @pytest.mark.parametrize("hint", [Sequence[bool], collections.abc.Sequence[bool]]) def test_keyword_sequence_of_bool(app, assert_parse_args, cmd_expected, hint): cmd, expected = cmd_expected @app.default def foo(*, verbose: hint | None = None): # pyright: ignore[reportInvalidTypeForm] pass if expected is None: assert_parse_args(foo, cmd) else: assert_parse_args(foo, cmd, verbose=expected) @pytest.mark.parametrize( "cmd", [ "foo --item", ], ) def test_list_tuple_missing_arguments_no_arguments(app, cmd): """Missing values.""" @app.command def foo(item: list[tuple[int, str]]): pass with pytest.raises(MissingArgumentError): app(cmd, exit_on_error=False) @pytest.mark.parametrize( "cmd", [ "foo --item 1", "foo --item a --stuff g", ], ) def test_list_tuple_missing_arguments_non_divisible(app, cmd): """Missing values.""" @app.command def foo(item: list[tuple[int, str]], stuff: str = ""): pass with pytest.raises(MissingArgumentError): app(cmd, exit_on_error=False) def test_pos_sequence(app, assert_parse_args): @app.command def foo(a: Sequence[int]): pass assert_parse_args(foo, "foo 1 2 3", [1, 2, 3]) @pytest.mark.parametrize( "cmd_str", [ "fizz buzz bar", "-- fizz buzz bar", "fizz -- buzz bar", "fizz buzz -- bar", "fizz buzz bar --", ], ) def test_list_positional_all_but_last(app, cmd_str, assert_parse_args): @app.default def foo(inputs: list[Path], output: Path, /): pass assert_parse_args(foo, cmd_str, [Path("fizz"), Path("buzz")], Path("bar")) @pytest.mark.parametrize( "hint,expected", [ (AbcSet[str], {"1", "2", "3"}), (AbcMutableSet[str], {"1", "2", "3"}), (AbcMutableSequence[str], ["1", "2", "3"]), (collections.abc.Set, {"1", "2", "3"}), (collections.abc.MutableSet, {"1", "2", "3"}), (collections.abc.MutableSequence, ["1", "2", "3"]), ], ) def test_abstract_collection_types(app, assert_parse_args, hint, expected): """Test that collections.abc abstract types work (issue #702). Tests both parameterized types (e.g., Set[str]) and bare types (e.g., Set). Bare abstract types should default to [str] like bare concrete types. """ @app.default def main(test: hint): # pyright: ignore[reportInvalidTypeForm] return test assert_parse_args(main, "1 2 3", expected) BrianPugh-cyclopts-921b1fa/tests/test_bind_namedtuple.py000066400000000000000000000024211517576204000235510ustar00rootroot00000000000000from collections import namedtuple from typing import NamedTuple def test_bind_typing_named_tuple(app, assert_parse_args): class Employee(NamedTuple): name: str id: int = 3 @app.command def foo(user: Employee): pass assert_parse_args( foo, 'foo --user.name="John Smith" --user.id=100', Employee(name="John Smith", id=100), ) assert_parse_args( foo, 'foo "John Smith" 100', Employee(name="John Smith", id=100), ) def test_bind_typing_named_tuple_var_positional(app, assert_parse_args): class Employee(NamedTuple): name: str id: int @app.command def foo(*users: Employee): pass assert_parse_args( foo, 'foo "John Smith" 100 "Mary Jones" 200', Employee(name="John Smith", id=100), Employee(name="Mary Jones", id=200), ) def test_bind_collections_named_tuple(app, assert_parse_args): # All fields will be strings since cyclopts doesn't know the types. Employee = namedtuple("Employee", ["name", "id"]) @app.command def foo(user: Employee): pass assert_parse_args( foo, 'foo --user.name="John Smith" --user.id=100', Employee(name="John Smith", id="100"), ) BrianPugh-cyclopts-921b1fa/tests/test_bind_no_parse.py000066400000000000000000000132511517576204000232240ustar00rootroot00000000000000from typing import Annotated import pytest from cyclopts import App, Parameter def test_no_parse_pos(app, assert_parse_args_partial): @app.default def foo(buzz: str, *, fizz: Annotated[str, Parameter(parse=False)]): pass assert_parse_args_partial(foo, "buzz_value", "buzz_value") _, _, ignored = app.parse_args("buzz_value") assert ignored == {"fizz": str} def test_no_parse_invalid_kind(app): """Parameter.parse=False must be used with KEYWORD_ONLY or have a default value.""" # Invalid: non-KEYWORD_ONLY without default with pytest.raises(ValueError): @app.default def foo(buzz: str, fizz: Annotated[str, Parameter(parse=False)]): pass app([]) def test_no_parse_with_default_allowed(app): """Parameter.parse=False is allowed for non-KEYWORD_ONLY with default.""" @app.default def foo(buzz: str, fizz: Annotated[str, Parameter(parse=False)] = "default_value"): return (buzz, fizz) result = app("buzz_value", exit_on_error=False) assert result == ("buzz_value", "default_value") def test_no_parse_keyword_only_with_default(app): """Parameter.parse=False is allowed for KEYWORD_ONLY with default.""" @app.default def foo(buzz: str, *, fizz: Annotated[str, Parameter(parse=False)] = "default_value"): return (buzz, fizz) result = app("buzz_value", exit_on_error=False) assert result == ("buzz_value", "default_value") # Tests for regex-based parse behavior via App.default_parameter def test_parse_regex_via_default_parameter(): """App.default_parameter with regex skips underscore-prefixed params.""" app = App(default_parameter=Parameter(parse="^(?!_)")) @app.default def foo(buzz: str, *, visible: str = "visible_default", _hidden: str = "hidden_default"): return (buzz, visible, _hidden) # "visible" matches ^(?!_), so it's parsed # "_hidden" doesn't match ^(?!_), so it's NOT parsed (not in bound.kwargs) _, bound, ignored = app.parse_args(["buzz_value", "--visible", "cli_visible"]) assert bound.args == ("buzz_value",) assert bound.kwargs == {"visible": "cli_visible"} assert ignored == {"_hidden": str} def test_parse_regex_not_in_help(capsys): """Params not matching regex should not appear in help.""" app = App(default_parameter=Parameter(parse="^(?!_)"), result_action="return_value") @app.default def foo(visible: str, *, _private: str = "default"): pass app(["--help"], exit_on_error=False) output = capsys.readouterr().out assert "visible" in output.lower() assert "_private" not in output.lower() assert "private" not in output.lower() def test_parse_regex_explicit_parse_true_override(): """Explicit Parameter(parse=True) overrides app-level regex.""" app = App(default_parameter=Parameter(parse="^(?!_)")) @app.default def foo(buzz: str, *, _private: Annotated[str, Parameter(parse=True)] = "default"): return (buzz, _private) # _private would normally be skipped by regex, but parse=True overrides _, bound, ignored = app.parse_args(["buzz_value", "--private", "cli_value"]) assert bound.args == ("buzz_value",) assert bound.kwargs == {"_private": "cli_value"} assert ignored == {} def test_parse_regex_explicit_show_true_override(capsys): """Explicit Parameter(show=True) overrides regex-based auto-hide.""" app = App(default_parameter=Parameter(parse="^(?!_)"), result_action="return_value") @app.default def foo(visible: str, *, _private: Annotated[str, Parameter(show=True)] = "default"): pass app(["--help"], exit_on_error=False) output = capsys.readouterr().out assert "private" in output.lower() def test_parse_compiled_regex_via_default_parameter(): """App.default_parameter with pre-compiled regex skips underscore-prefixed params.""" import re app = App(default_parameter=Parameter(parse=re.compile("^(?!_)"))) @app.default def foo(buzz: str, *, visible: str = "visible_default", _hidden: str = "hidden_default"): return (buzz, visible, _hidden) _, bound, ignored = app.parse_args(["buzz_value", "--visible", "cli_visible"]) assert bound.args == ("buzz_value",) assert bound.kwargs == {"visible": "cli_visible"} assert ignored == {"_hidden": str} def test_parse_regex_invalid_positional_no_default(): """App.default_parameter regex that skips a required positional param should raise.""" app = App(default_parameter=Parameter(parse="^(?!_)")) @app.default def foo(_value: str): # Positional, no default, would be skipped by regex pass # The error should be raised when trying to parse (Argument creation), # not at registration time (since validate_command only sees direct annotations) with pytest.raises(ValueError, match="KEYWORD_ONLY"): app.parse_args([]) def test_no_parse_did_you_mean_excludes_non_parsed(app): """Issue #730: UnknownOptionError should not suggest parse=False parameters. When a parameter has parse=False, it should not be included in the "Did you mean" suggestions for unknown options, since it's not a valid CLI option. """ from cyclopts.exceptions import UnknownOptionError @app.default def action(*, verbose: Annotated[bool, Parameter(parse=False)] = False): pass with pytest.raises(UnknownOptionError) as e: app.parse_args(["--verbose"], exit_on_error=False) # The error message should NOT suggest "--verbose" since it has parse=False error_message = str(e.value) assert 'Unknown option: "--verbose"' in error_message # Should NOT have "Did you mean" since there's no valid similar option assert "Did you mean" not in error_message BrianPugh-cyclopts-921b1fa/tests/test_bind_none.py000066400000000000000000000004671517576204000223620ustar00rootroot00000000000000from pathlib import Path from typing import Annotated from cyclopts import Parameter def test_bind_negative_none(app, assert_parse_args): @app.default def default(path: Annotated[Path | None, Parameter(negative_none="default-")]): pass assert_parse_args(default, "--default-path", None) BrianPugh-cyclopts-921b1fa/tests/test_bind_pos_only.py000066400000000000000000000037561517576204000232710ustar00rootroot00000000000000import pytest from cyclopts import MissingArgumentError, UnknownOptionError @pytest.mark.parametrize( "cmd_str", [ "foo 1 2 3 4 5", ], ) def test_star_args(app, cmd_str, assert_parse_args): @app.command def foo(a: int, b: int, *args: int): pass assert_parse_args(foo, cmd_str, 1, 2, 3, 4, 5) @pytest.mark.parametrize( "cmd_str", [ "foo 1 2 3", ], ) def test_pos_only(app, cmd_str, assert_parse_args): @app.command def foo(a: int, b: int, c: int, /): pass assert_parse_args(foo, cmd_str, 1, 2, 3) @pytest.mark.parametrize( "cmd_str_e", [ ("foo 1 2 --c=3", UnknownOptionError), # Unknown option "--c" ], ) def test_pos_only_exceptions(app, cmd_str_e): cmd_str, e = cmd_str_e @app.command def foo(a: int, b: int, c: int, /): pass with pytest.raises(e): app.parse_args(cmd_str, print_error=False, exit_on_error=False) @pytest.mark.parametrize( "cmd_str", [ "foo 1 2 3 4", "foo 1 2 3 --d 4", "foo 1 2 --d=4 3", ], ) def test_pos_only_extended(app, cmd_str, assert_parse_args): @app.command def foo(a: int, b: int, c: int, /, d: int): pass assert_parse_args(foo, cmd_str, 1, 2, 3, 4) @pytest.mark.parametrize( "cmd_str_e", [ ("foo 1 2 3", MissingArgumentError), ("foo 1 2", MissingArgumentError), ], ) def test_pos_only_extended_exceptions(app, cmd_str_e): cmd_str, e = cmd_str_e @app.command def foo(a: int, b: int, c: int, /, d: int): pass with pytest.raises(e): app.parse_args(cmd_str, print_error=False, exit_on_error=False) @pytest.mark.parametrize( "cmd_str", [ "foo a 2 3 4", "foo a 2 3 --d 4", "foo a 2 --d=4 3", ], ) def test_pos_only_extended_str_type(app, cmd_str, assert_parse_args): @app.command def foo(a: "str", b: "int", c: int, /, d: "int"): pass assert_parse_args(foo, cmd_str, "a", 2, 3, 4) BrianPugh-cyclopts-921b1fa/tests/test_bind_tuple.py000066400000000000000000000053351517576204000225530ustar00rootroot00000000000000import pytest from cyclopts.exceptions import MissingArgumentError @pytest.mark.parametrize( "cmd_str", [ "1 2 80 160 255", "--coordinates 1 2 --color 80 160 255", "--color 80 160 255 --coordinates 1 2", "--color 80 160 255 --coordinates=1 2", ], ) def test_bind_tuple_basic(app, cmd_str, assert_parse_args): @app.default def foo(coordinates: tuple[int, int], color: tuple[int, int, int]): pass assert_parse_args(foo, cmd_str, (1, 2), (80, 160, 255)) @pytest.mark.parametrize( "cmd_str", [ "1 2 alice 100 200", "--coordinates 1 2 --data alice 100 200", "--data alice 100 200 --coordinates 1 2", ], ) def test_bind_tuple_nested(app, cmd_str, assert_parse_args): @app.default def foo(coordinates: tuple[int, int], data: tuple[tuple[str, int], int]): pass assert_parse_args(foo, cmd_str, (1, 2), (("alice", 100), 200)) @pytest.mark.parametrize( "cmd_str", [ "1 2 alice 100 bob 200", "--coordinates 1 2 --data alice 100 --data bob 200", "--data alice 100 --coordinates 1 2 --data bob 200", ], ) def test_bind_tuple_ellipsis(app, cmd_str, assert_parse_args): @app.default def foo(coordinates: tuple[int, int], data: tuple[tuple[str, int], ...]): pass assert_parse_args(foo, cmd_str, (1, 2), (("alice", 100), ("bob", 200))) @pytest.mark.parametrize( "cmd_str", [ "1 2 3", "--values 1 --values 2 --values 3", ], ) def test_bind_tuple_no_inner_types(app, cmd_str, assert_parse_args): @app.default def foo(values: tuple): pass # Interpreted as a string because: # 1. Tuple -> Tuple[Any, ...] # 2. Any is treated the same as no annotation. # 3. Even if a default value was supplied, we couldn't unambiguously infer a type. # 4. This falls back to string. assert_parse_args(foo, cmd_str, ("1", "2", "3")) @pytest.mark.parametrize( "cmd_str", [ "1", "--coordinates 1", ], ) def test_bind_tuple_insufficient_tokens(app, cmd_str): @app.default def foo(coordinates: tuple[int, int]): pass with pytest.raises(MissingArgumentError): app.parse_args(cmd_str, print_error=False, exit_on_error=False) @pytest.mark.parametrize( "cmd_str", [ "--coordinates 1 2 --color 80 160 255 --coordinates 3 4", "--coordinates 1 2 --coordinates 3 4 --color 80 160 255", "1 2 3 4 --color 80 160 255", ], ) def test_bind_list_of_tuple(app, cmd_str, assert_parse_args): @app.default def foo(coordinates: list[tuple[int, int]], color: tuple[int, int, int]): pass assert_parse_args(foo, cmd_str, [(1, 2), (3, 4)], (80, 160, 255)) BrianPugh-cyclopts-921b1fa/tests/test_bind_typed_dict.py000066400000000000000000000175131517576204000235530ustar00rootroot00000000000000import sys from textwrap import dedent from typing import Annotated, TypedDict import pytest from cyclopts import MissingArgumentError, Parameter from cyclopts.exceptions import UnknownOptionError if sys.version_info < (3, 11): # pragma: no cover from typing_extensions import NotRequired, Required else: # pragma: no cover from typing import NotRequired, Required class MyDict(TypedDict): my_int: int my_str: str my_list: list my_list_int: list[int] def test_bind_typed_dict(app, assert_parse_args): @app.command def foo(d: MyDict): pass assert_parse_args( foo, "foo --d.my-int=5 --d.my-str=bar --d.my-list=a --d.my-list=b --d.my-list-int=1 --d.my-list-int=2", d={ "my_int": 5, "my_str": "bar", "my_list": ["a", "b"], "my_list_int": [1, 2], }, ) def test_bind_typed_dict_missing_arg_basic(app, console): @app.command def foo(d: MyDict): pass with console.capture() as capture, pytest.raises(MissingArgumentError): app( "foo --d.my-int=5 --d.my-str=bar", error_console=console, exit_on_error=False, ) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Command "foo" parameter "--d.my-list" requires an argument. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_typed_dict_missing_arg_flatten(app, console): @app.command def foo(d: Annotated[MyDict, Parameter(name="*")]): pass with console.capture() as capture, pytest.raises(MissingArgumentError): app( "foo", error_console=console, exit_on_error=False, ) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Command "foo" parameter "--my-int" requires an argument. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_typed_dict_missing_arg_renamed_no_hyphen(app, console): class MyDict(TypedDict): my_int: int my_str: str my_list: Annotated[list, Parameter(name="your-list")] my_list_int: list[int] @app.command def foo(d: MyDict): pass with console.capture() as capture, pytest.raises(MissingArgumentError): app( "foo --d.my-int=5 --d.my-str=bar", error_console=console, exit_on_error=False, ) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Command "foo" parameter "--d.your-list" requires an argument. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_typed_dict_missing_arg_renamed_hyphen(app, console): class MyDict(TypedDict): my_int: int my_str: str my_list: Annotated[list, Parameter(name="--your-list")] my_list_int: list[int] @app.command def foo(d: MyDict): pass with console.capture() as capture, pytest.raises(MissingArgumentError): app( "foo --d.my-int=5 --d.my-str=bar", error_console=console, exit_on_error=False, ) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Command "foo" parameter "--your-list" requires an argument. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_typed_dict_missing_arg_nested(app, console): class User(TypedDict): name: str age: int class MyDict(TypedDict): my_int: int my_str: str my_user: User @app.command def foo(d: MyDict): pass with console.capture() as capture, pytest.raises(MissingArgumentError): app( "foo --d.my-int=5 --d.my-str=bar --d.my-user.age=30", error_console=console, exit_on_error=False, ) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Command "foo" parameter "--d.my-user.name" requires an argument. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_bind_typed_dict_total_false(app, assert_parse_args): class MyDict(TypedDict, total=False): my_int: int my_str: str @app.command def foo(d: MyDict): pass assert_parse_args(foo, "foo --d.my-str=bar", d={"my_str": "bar"}) def test_bind_typed_dict_not_required(app, assert_parse_args): class MyDict(TypedDict): my_int: int my_str: NotRequired[str] @app.command def foo(d: MyDict): pass assert_parse_args(foo, "foo --d.my-int=5", d={"my_int": 5}) def test_bind_typed_dict_required(app, assert_parse_args): class MyDict(TypedDict, total=False): my_int: Required[int] my_str: str @app.command def foo(d: MyDict): pass assert_parse_args(foo, "foo --d.my-int=5", d={"my_int": 5}) def test_bind_typed_dict_extra_field(app, console): @app.command def foo(d: MyDict): pass with console.capture() as capture, pytest.raises(UnknownOptionError): app.parse_args( "foo --d.my-int=5 --d.my-str=bar --d.my-list=a --d.my-list=b --d.my-list-int=1 --d.my-list-int=2 --d.extra-key=10", error_console=console, exit_on_error=False, ) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Unknown option: "--d.extra-key". │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected BrianPugh-cyclopts-921b1fa/tests/test_bind_union.py000066400000000000000000000052211517576204000225440ustar00rootroot00000000000000from pathlib import Path from textwrap import dedent from typing import Annotated import pytest from cyclopts import Parameter from cyclopts.exceptions import CoercionError @pytest.mark.parametrize( "cmd_str,expected", [ ("foo 1", 1), ("foo --a=1", 1), ("foo --a 1", 1), ("foo bar", "bar"), ("foo --a=bar", "bar"), ("foo --a bar", "bar"), ], ) @pytest.mark.parametrize("annotated", [False, True]) def test_union_required_implicit_coercion(app, cmd_str, expected, annotated, assert_parse_args): """ For a union without an explicit coercion, the first non-None type annotation should be used. In this case, it's ``int``. """ if annotated: @app.command def foo(a: Annotated[None | int | str, Parameter(help="help for a")]): pass else: @app.command def foo(a: None | int | str): pass assert_parse_args(foo, cmd_str, expected) def test_union_coercion_cannot_coerce_error(app, console): @app.default def default(a: None | int | float): pass with console.capture() as capture, pytest.raises(CoercionError): app.parse_args("foo", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Invalid value for "A": unable to convert "foo" into int|float. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected @pytest.mark.parametrize( "cmd_str,expected", [ ("bar", ["bar"]), ("bar baz", ["bar", "baz"]), ], ) def test_union_of_list_types(app, cmd_str, expected, assert_parse_args): """list[str] | list[Path] should work as a union of list types (issue #780).""" @app.default def foo(paths: list[str] | list[Path]): pass assert_parse_args(foo, cmd_str, expected) @pytest.mark.parametrize( "cmd_str,expected", [ ("bar", ["bar"]), ("bar baz", ["bar", "baz"]), ], ) def test_union_of_list_types_optional(app, cmd_str, expected, assert_parse_args): """list[str] | list[Path] | None should work as a union of list types.""" @app.default def foo(paths: list[str] | list[Path] | None = None): pass assert_parse_args(foo, cmd_str, expected) BrianPugh-cyclopts-921b1fa/tests/test_bind_var_pos.py000066400000000000000000000012621517576204000230660ustar00rootroot00000000000000def test_bind_var_pos(app): """Checks if "Alice" gets erroneously unpacked into ``("A", "l", "i", "c", "e")``.""" @app.default def default(*tokens: str): assert tokens == ("Alice",) app(["Alice"]) def test_bind_custom_class_only_var_positional(app, assert_parse_args): """It's quite common for classes with *args to really only intend to consume 1 element.""" class MyCustomClass: def __init__(self, *args): self.args = args def __eq__(self, other): return self.args == other.args @app.default def default(value: MyCustomClass): pass assert_parse_args(default, "100", MyCustomClass("100")) BrianPugh-cyclopts-921b1fa/tests/test_coercion.py000066400000000000000000000371611517576204000222310ustar00rootroot00000000000000import inspect import sys from collections.abc import Iterable, Sequence from collections.abc import MutableSequence as AbcMutableSequence from collections.abc import MutableSet as AbcMutableSet from collections.abc import Set as AbcSet from datetime import date, datetime, timedelta from enum import Enum, auto from pathlib import Path from typing import Annotated, Any, Literal, Optional, Union from unittest.mock import Mock import pytest from cyclopts import CoercionError, Token from cyclopts._convert import convert, token_count from cyclopts.utils import default_name_transform def _assert_tuple(expected, actual): assert type(actual) is tuple assert len(expected) == len(actual) for e, a in zip(expected, actual, strict=False): assert type(e) is type(a) assert e == a def test_token_count_tuple_basic(): assert (3, False) == token_count(tuple[int, int, int]) def test_token_count_tuple_no_inner_type(): assert (1, True) == token_count(tuple) assert (1, True) == token_count(tuple) def test_token_count_tuple_nested(): assert (4, False) == token_count(tuple[tuple[int, int], int, int]) def test_token_count_tuple_ellipsis(): assert (1, True) == token_count(tuple[int, ...]) def test_token_count_tuple_ellipsis_nested(): assert (2, True) == token_count(tuple[tuple[int, int], ...]) def test_token_union(): assert (1, False) == token_count(Union[None, int]) def test_token_count_standard(): assert (1, False) == token_count(int) def test_token_count_bool(): assert (0, False) == token_count(bool) def test_token_count_list(): assert (1, True) == token_count(list[int]) def test_token_count_sequence(): assert (1, True) == token_count(Sequence[int]) assert (2, True) == token_count(Sequence[tuple[int, int]]) def test_token_count_list_generic(): assert (1, True) == token_count(list) def test_token_count_list_direct(): assert (1, True) == token_count(list[int]) # pyright: ignore def test_token_count_list_of_tuple(): assert (3, True) == token_count(list[tuple[int, int, int]]) def test_token_count_list_of_tuple_nested(): assert (4, True) == token_count(list[tuple[tuple[int, int], int, int]]) def test_token_count_iterable(): assert (1, True) == token_count(Iterable[int]) assert (2, True) == token_count(Iterable[tuple[int, int]]) def test_token_count_union(): assert (1, False) == token_count(Union[int, str, float]) def test_token_count_union_error(): with pytest.raises(ValueError): assert (1, False) == token_count(Union[int, tuple[int, int]]) def test_coerce_no_tokens(): with pytest.raises(ValueError): convert(int, []) def test_coerce_bool(): assert True is convert(bool, ["true"]) assert False is convert(bool, ["false"]) def test_coerce_error(): with pytest.raises(CoercionError): convert(bool, ["foo"]) def test_coerce_int(): assert 123 == convert(int, ["123"]) def test_coerce_annotated_int(): assert [123, 456] == convert(Annotated[int, "foo"], ["123", "456"]) assert [123, 456] == convert(Annotated[list[int], "foo"], ["123", "456"]) def test_coerce_optional_annotated_int(): assert [123, 456] == convert(Optional[Annotated[int, "foo"]], ["123", "456"]) assert [123, 456] == convert(Optional[Annotated[list[int], "foo"]], ["123", "456"]) def test_coerce_annotated_union_str_secondary_choice(): assert 123 == convert(Union[None, int, str], ["123"]) assert "foo" == convert(Union[None, int, str], ["foo"]) with pytest.raises(CoercionError): convert(Union[None, int, float], ["invalid-choice"]) def test_coerce_annotated_nested_union_str_secondary_choice(): assert 123 == convert(Union[None, int | str], ["123"]) assert "foo" == convert(Union[None, int | str], ["foo"]) def test_coerce_annotated_union_int(): assert 123 == convert(Annotated[None | int | float, "foo"], ["123"]) assert [123, 456] == convert(Annotated[int, "foo"], ["123", "456"]) assert [123, 456] == convert(Annotated[None | int | float, "foo"], ["123", "456"]) def test_coerce_enum(): class SoftwareEnvironment(Enum): DEV = auto() STAGING = auto() PROD = auto() _PROD_OLD = ( auto() ) # test that leading underscores are stripped and then hyphens can be used instead of underscores. DEVELOPMENT = DEV # Tests that aliases resolve # tests case-insensitivity assert SoftwareEnvironment.STAGING == convert(SoftwareEnvironment, ["staging"]) # tests underscore/hyphen support assert SoftwareEnvironment._PROD_OLD == convert(SoftwareEnvironment, ["prod_old"]) assert SoftwareEnvironment._PROD_OLD == convert(SoftwareEnvironment, ["prod-old"]) # Test aliases assert SoftwareEnvironment.DEV == convert(SoftwareEnvironment, ["development"]) with pytest.raises(CoercionError): convert(SoftwareEnvironment, ["invalid-choice"]) def test_coerce_enum_invalid_choice(): class GroupedConstants(Enum): FOO = auto() BAR = auto() assert_convert_coercion_error( GroupedConstants, ["invalid-choice"], msg="""Invalid value for "MOCKED_ARGUMENT_NAME": unable to convert "invalid-choice" into one of {'foo', 'bar'}.""", ) def test_coerce_enum_invalid_choice_name_transform(): class SoftwareEnvironment(Enum): DEV_LOCAL = 1 STAGING_US = 2 PROD_WEST = 3 assert_convert_coercion_error( SoftwareEnvironment, ["invalid"], msg="""Invalid value for "MOCKED_ARGUMENT_NAME": unable to convert "invalid" into one of {'dev-local', 'staging-us', 'prod-west'}.""", ) def test_coerce_enum_invalid_choice_custom_name_transform(): class SoftwareEnvironment(Enum): dev_local = 1 staging_us = 2 assert_convert_coercion_error( SoftwareEnvironment, ["invalid"], name_transform=str.upper, msg="""Invalid value for "MOCKED_ARGUMENT_NAME": unable to convert "invalid" into one of {'DEV_LOCAL', 'STAGING_US'}.""", ) def test_coerce_tuple_basic_single(): _assert_tuple((1,), convert(tuple[int], ["1"])) def test_coerce_tuple_str_single(): _assert_tuple(("foo",), convert(tuple[str], ["foo"])) def test_coerce_tuple_basic_double(): _assert_tuple((1, 2.0), convert(tuple[int, None | float | int], ["1", "2"])) def test_coerce_tuple_typing_no_inner_types(): _assert_tuple(("1", "2"), convert(tuple, ["1", "2"])) def test_coerce_tuple_builtin_no_inner_types(): _assert_tuple(("1", "2"), convert(tuple, ["1", "2"])) def test_coerce_tuple_nested(): _assert_tuple( (1, (2.0, "foo")), convert(tuple[int, tuple[float, None | str | int]], ["1", "2", "foo"]), ) def test_coerce_tuple_len_mismatch_underflow(): with pytest.raises(CoercionError): convert(tuple[int, int], ["1"]) def test_coerce_tuple_len_mismatch_overflow(): with pytest.raises(CoercionError): convert(tuple[int, int], ["1", "2", "3"]) @pytest.mark.skipif(sys.version_info < (3, 11), reason="Typing") def test_coerce_tuple_ellipsis_too_many_inner_types(): with pytest.raises(ValueError): # This is a ValueError because it happens prior to runtime. # Only 1 inner type annotation allowed convert(tuple[int, int, ...], ["1", "2"]) # pyright: ignore def test_coerce_tuple_ellipsis_non_divisible(): with pytest.raises(CoercionError): convert(tuple[tuple[int, int], ...], ["1", "2", "3"]) def test_coerce_list(): assert [123, 456] == convert(int, ["123", "456"]) assert [123, 456] == convert(list[int], ["123", "456"]) assert [123] == convert(list[int], ["123"]) def test_coerce_list_of_tuple_str_single_1(): res = convert(list[tuple[str]], ["foo"]) assert isinstance(res, list) assert len(res) == 1 _assert_tuple(("foo",), res[0]) def test_coerce_list_of_tuple_str_single_2(): res = convert(list[tuple[str]], ["foo", "bar"]) assert isinstance(res, list) assert len(res) == 2 _assert_tuple(("foo",), res[0]) _assert_tuple(("bar",), res[1]) def test_coerce_bare_list(): # Implicit element type: str assert ["123", "456"] == convert(list, ["123", "456"]) def test_coerce_iterable(): assert [123, 456] == convert(Iterable[int], ["123", "456"]) assert [123] == convert(Iterable[int], ["123"]) def test_coerce_set(): assert {"123", "456"} == convert(set[str], ["123", "456"]) assert {123, 456} == convert(set[int | str], ["123", "456"]) def test_coerce_frozenset(): assert frozenset({"123", "456"}) == convert(frozenset[str], ["123", "456"]) assert frozenset({123, 456}) == convert(frozenset[int | str], ["123", "456"]) @pytest.mark.parametrize( "hint,expected", [ (AbcSet[str], {"123", "456"}), (AbcMutableSet[str], {"123", "456"}), (AbcMutableSequence[str], ["123", "456"]), (AbcSet, {"123", "456"}), (AbcMutableSet, {"123", "456"}), (AbcMutableSequence, ["123", "456"]), ], ) def test_coerce_abstract_collection_types(hint, expected): """Test that collections.abc abstract types are supported (issue #702). Tests both parameterized types (e.g., Set[str]) and bare types (e.g., Set). Bare abstract types should default to [str] like bare concrete types do. """ result = convert(hint, ["123", "456"]) assert expected == result def test_coerce_literal(): assert "foo" == convert(Literal["foo", "bar", 3], ["foo"]) assert "bar" == convert(Literal["foo", "bar", 3], ["bar"]) assert 3 == convert(Literal["foo", "bar", 3], ["3"]) def assert_convert_coercion_error(*args, msg, name_transform=None, **kwargs): if name_transform is None: name_transform = default_name_transform mock_argument = Mock() mock_argument.name = "mocked_argument_name" mock_argument.parameter.name_transform = name_transform kwargs.setdefault("name_transform", name_transform) with pytest.raises(CoercionError) as e: try: convert(*args, **kwargs) except CoercionError as coercion_error: coercion_error.argument = mock_argument raise exception_message = str(e.value).split("\n", 1)[1] assert exception_message == msg def test_coerce_literal_invalid_choice(): assert_convert_coercion_error( Literal["foo", "bar", 3], ["invalid-choice"], msg="""Invalid value for "MOCKED_ARGUMENT_NAME": unable to convert "invalid-choice" into one of {'foo', 'bar', 3}.""", ) def test_coerce_literal_invalid_choice_keyword(): assert_convert_coercion_error( Literal["foo", "bar", 3], [Token(keyword="--MY_KEYWORD", value="invalid-choice")], msg="""Invalid value for "--MY_KEYWORD": unable to convert "invalid-choice" into one of {'foo', 'bar', 3}.""", ) def test_coerce_literal_invalid_choice_non_cli_token(): assert_convert_coercion_error( Literal["foo", "bar", 3], [Token(value="invalid-choice", source="TEST")], msg="""Invalid value for "MOCKED_ARGUMENT_NAME" from TEST: unable to convert "invalid-choice" into one of {'foo', 'bar', 3}.""", ) def test_coerce_literal_invalid_choice_keyword_non_cli_token(): assert_convert_coercion_error( Literal["foo", "bar", 3], [Token(keyword="--MY-KEYWORD", value="invalid-choice", source="TEST")], msg="""Invalid value for "--MY-KEYWORD" from TEST: unable to convert "invalid-choice" into one of {'foo', 'bar', 3}.""", ) def test_coerce_path(): assert Path("foo") == convert(Path, ["foo"]) def test_coerce_any(): assert "foo" == convert(Any, ["foo"]) def test_coerce_bytes(): assert b"foo" == convert(bytes, ["foo"]) assert [b"foo", b"bar"] == convert(bytes, ["foo", "bar"]) def test_coerce_bytearray(): res = convert(bytearray, ["foo"]) assert isinstance(res, bytearray) assert bytearray(b"foo") == res assert [bytearray(b"foo"), bytearray(b"bar")] == convert(bytearray, ["foo", "bar"]) def test_coerce_parameter_kind_empty(): assert "foo" == convert(inspect.Parameter.empty, ["foo"]) def test_coerce_date(): expected = date(year=1956, month=1, day=31) assert expected == convert(date, ["1956-01-31"]) @pytest.mark.skipif(sys.version_info < (3, 11), reason="Not implemented in stdlib") def test_coerce_date_other_iso_formats(): expected = date(year=2021, month=1, day=4) assert expected == convert(date, ["2021-W01-1"]) @pytest.mark.parametrize( "input_string, format_str", [ ("1956-01-31", "%Y-%m-%d"), # ISO 8601 date only ("1956-01-31T10:00:00", "%Y-%m-%dT%H:%M:%S"), # ISO 8601 with time ("1956-01-31 10:00:00", "%Y-%m-%d %H:%M:%S"), # Space separator ("1956-01-31T10:00:00.123456", "%Y-%m-%dT%H:%M:%S.%f"), # With microseconds ], ) def test_coerce_datetime(input_string, format_str): """Test that various datetime formats are supported.""" expected = datetime.strptime(input_string, format_str) assert expected == convert(datetime, [input_string]) @pytest.mark.parametrize( "input_string, expected_output", [ # Basic single unit tests ("30s", timedelta(seconds=30)), ("5m", timedelta(minutes=5)), ("2h", timedelta(hours=2)), ("1d", timedelta(days=1)), ("3w", timedelta(weeks=3)), ("6M", timedelta(days=30 * 6)), # Approximation: 1 month = 30 days ("1y", timedelta(days=365)), # Approximation: 1 year = 365 days # Combined duration tests ("1h30m", timedelta(hours=1, minutes=30)), ("1d12h", timedelta(days=1, hours=12)), ("2d5h30m", timedelta(days=2, hours=5, minutes=30)), ("1w2d", timedelta(weeks=1, days=2)), ("3h45m20s", timedelta(hours=3, minutes=45, seconds=20)), # Zero and small values ("0s", timedelta(seconds=0)), ("1s", timedelta(seconds=1)), # Large values ("100d", timedelta(days=100)), ("10000s", timedelta(seconds=10000)), # Mixed order (should still work) ("30m1h", timedelta(hours=1, minutes=30)), ("45s2h", timedelta(hours=2, seconds=45)), # Repeated units (should add them) ("1h1h", timedelta(hours=2)), ("1d1d1d", timedelta(days=3)), # Negative duration ("-1h", timedelta(hours=-1)), # Decimal duration ("1.5h", timedelta(hours=1.5)), ], ) def test_parse_timedelta_valid(input_string, expected_output): """Test that valid duration strings are parsed correctly.""" assert convert(timedelta, [input_string]) == expected_output @pytest.mark.parametrize( "invalid_input", [ "", # Empty string "abc", # No numbers or units "1", # Number without unit "1x", # Invalid unit "h1", # Unit before number "3 days", # Full unit names with spaces not supported ], ) def test_parse_timedelta_invalid(invalid_input): with pytest.raises(CoercionError): convert(timedelta, [invalid_input]) def test_coerce_date_invalid_format(): """Test that invalid date format raises CoercionError.""" with pytest.raises(CoercionError): convert(date, ["not-a-date"]) def test_coerce_datetime_invalid_format(): """Test that invalid datetime format raises CoercionError.""" with pytest.raises(CoercionError): convert(datetime, ["not-a-date"]) def test_parse_timedelta_equivalence(): """Test that equivalent timedelta formats produce the same result.""" assert convert(timedelta, ["1h"]) == convert(timedelta, ["60m"]) assert convert(timedelta, ["1d"]) == convert(timedelta, ["24h"]) assert convert(timedelta, ["1w"]) == convert(timedelta, ["7d"]) assert convert(timedelta, ["1h30m"]) == convert(timedelta, ["90m"]) assert convert(timedelta, ["1d12h"]) == convert(timedelta, ["36h"]) BrianPugh-cyclopts-921b1fa/tests/test_command_collision.py000066400000000000000000000017011517576204000241100ustar00rootroot00000000000000import pytest from cyclopts import CommandCollisionError def test_command_collision(app): @app.command def foo(): pass with pytest.raises(CommandCollisionError): @app.command def foo(): # noqa: F811 pass with pytest.raises(CommandCollisionError): @app.command(name="foo") def bar(): pass def test_command_collision_meta(app): @app.command def foo(): pass with pytest.raises(CommandCollisionError): @app.meta.command def foo(): # noqa: F811 pass with pytest.raises(CommandCollisionError): @app.meta.command(name="foo") def bar(): pass def test_command_collision_default(app): """Cannot register multiple functions to default.""" @app.default def foo(): pass with pytest.raises(CommandCollisionError): @app.default def bar(): pass BrianPugh-cyclopts-921b1fa/tests/test_console.py000066400000000000000000000112201517576204000220560ustar00rootroot00000000000000from contextlib import suppress import pytest from cyclopts import App, CycloptsError from cyclopts.exceptions import UnusedCliTokensError def _create_mock_console(mocker): from rich.console import ConsoleDimensions, ConsoleOptions console = mocker.MagicMock() console.width = 80 console.height = 24 console.legacy_windows = False console.is_terminal = True console.encoding = "utf-8" # Add a proper options attribute that matches what Rich provides console.options = ConsoleOptions( size=ConsoleDimensions(console.width, console.height), legacy_windows=console.legacy_windows, min_width=1, max_width=console.width, is_terminal=console.is_terminal, encoding=console.encoding, max_height=console.height, ) return console @pytest.fixture def mock_console(mocker): """Create a mock console with required attributes for Rich compatibility.""" return _create_mock_console(mocker) @pytest.fixture def subapp(app): app.command(subapp := App(name="foo")) return subapp @pytest.mark.parametrize("cmd", ["foo --help"]) def test_root_console(app, mock_console, cmd): app.console = mock_console with suppress(CycloptsError): app(cmd, exit_on_error=False) app.console.print.assert_called() @pytest.mark.parametrize("cmd", ["foo --help"]) def test_root_console_subapp(app, subapp, mock_console, cmd): """Check if root console is properly resolved (subapp.console not specified).""" app.console = mock_console with suppress(CycloptsError): app(cmd, exit_on_error=False) app.console.print.assert_called() @pytest.mark.parametrize("cmd", ["foo --help"]) def test_root_subapp_console(app, subapp, mock_console, mocker, cmd): """Check if subapp console is properly resolved (NOT app.console).""" app.console = mock_console subapp.console = _create_mock_console(mocker) with suppress(CycloptsError): app(cmd, exit_on_error=False) app.console.print.assert_not_called() subapp.console.print.assert_called() @pytest.mark.parametrize("cmd", ["foo --help"]) def test_root_subapp_arg_console(app, subapp, mock_console, mocker, cmd): """Explicitly provided console should be used.""" console = mock_console app.console = mocker.MagicMock() subapp.console = mocker.MagicMock() with suppress(CycloptsError): app(cmd, console=console, exit_on_error=False) console.print.assert_called() app.console.print.assert_not_called() subapp.console.print.assert_not_called() def test_console_populated_issue_103(app): """Ensures console is populated for an UnusedCliTokensError. https://github.com/BrianPugh/cyclopts/issues/103 """ @app.command def foo(): pass with pytest.raises(UnusedCliTokensError): app("foo bar", exit_on_error=False) @pytest.mark.parametrize("cmd", ["foo invalid-command"]) def test_root_error_console(app, mock_console, mocker, cmd): """Test that root error_console is used for errors.""" error_console = _create_mock_console(mocker) app.error_console = error_console with suppress(CycloptsError): app(cmd, exit_on_error=False) error_console.print.assert_called() @pytest.mark.parametrize("cmd", ["foo invalid-command"]) def test_root_error_console_subapp(app, subapp, mocker, cmd): """Check if root error_console is properly resolved (subapp.error_console not specified).""" error_console = _create_mock_console(mocker) app.error_console = error_console with suppress(CycloptsError): app(cmd, exit_on_error=False) error_console.print.assert_called() @pytest.mark.parametrize("cmd", ["foo invalid-command"]) def test_root_subapp_error_console(app, subapp, mocker, cmd): """Check if subapp error_console is properly resolved (NOT app.error_console).""" app.error_console = _create_mock_console(mocker) subapp.error_console = _create_mock_console(mocker) with suppress(CycloptsError): app(cmd, exit_on_error=False) app.error_console.print.assert_not_called() subapp.error_console.print.assert_called() @pytest.mark.parametrize("cmd", ["foo invalid-command"]) def test_root_subapp_arg_error_console(app, subapp, mocker, cmd): """Explicitly provided error_console should be used.""" error_console = _create_mock_console(mocker) app.error_console = mocker.MagicMock() subapp.error_console = mocker.MagicMock() with suppress(CycloptsError): app(cmd, error_console=error_console, exit_on_error=False) error_console.print.assert_called() app.error_console.print.assert_not_called() subapp.error_console.print.assert_not_called() BrianPugh-cyclopts-921b1fa/tests/test_docs_base.py000066400000000000000000000016141517576204000223440ustar00rootroot00000000000000"""Tests for cyclopts.docs.base helpers.""" from cyclopts.docs.base import apply_usage_name def test_apply_usage_name_none_returns_chain_unchanged(): chain = ["cli", "files", "cp"] assert apply_usage_name(chain, None) is chain def test_apply_usage_name_empty_chain_returns_single_element_list(): assert apply_usage_name([], "uv run cli") == ["uv run cli"] def test_apply_usage_name_replaces_root_only(): chain = ["cli", "files", "cp"] assert apply_usage_name(chain, "uv run cli") == ["uv run cli", "files", "cp"] def test_apply_usage_name_does_not_mutate_input(): chain = ["cli", "files"] apply_usage_name(chain, "uv run cli") assert chain == ["cli", "files"] def test_apply_usage_name_empty_string_drops_root(): assert apply_usage_name(["cli", "files"], "") == ["files"] assert apply_usage_name(["cli"], "") == [] assert apply_usage_name([], "") == [] BrianPugh-cyclopts-921b1fa/tests/test_docs_e2e.py000066400000000000000000000642301517576204000221100ustar00rootroot00000000000000"""End-to-end tests for documentation generation. These tests actually build documentation using mkdocs and sphinx, and verify that the generated output contains expected content. """ import os import shutil import subprocess import sys from pathlib import Path import pytest # Path to the complex-demo application COMPLEX_DEMO_DIR = Path(__file__).parent / "apps" / "complex-demo" def _run_command(cmd: list[str], cwd: Path, env: dict | None = None) -> subprocess.CompletedProcess: """Run a command and return the result.""" full_env = os.environ.copy() if env: full_env.update(env) return subprocess.run( cmd, cwd=cwd, capture_output=True, text=True, env=full_env, ) @pytest.mark.slow class TestMkDocsBuild: """Test MkDocs documentation builds.""" @pytest.fixture def mkdocs_build_dir(self, tmp_path): """Create a temporary build directory for MkDocs.""" build_dir = tmp_path / "site" return build_dir @pytest.fixture def ensure_complex_demo_importable(self): """Ensure the complex_app module can be imported.""" sys.path.insert(0, str(COMPLEX_DEMO_DIR)) yield sys.path.remove(str(COMPLEX_DEMO_DIR)) @pytest.mark.skipif( shutil.which("mkdocs") is None, reason="mkdocs not installed or not in PATH", ) def test_mkdocs_build_succeeds(self, mkdocs_build_dir, ensure_complex_demo_importable): """Test that mkdocs build completes successfully.""" pytest.importorskip("mkdocs") result = _run_command( ["mkdocs", "build", "--site-dir", str(mkdocs_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) assert result.returncode == 0, f"mkdocs build failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" assert mkdocs_build_dir.exists(), "Build directory was not created" @pytest.mark.skipif( shutil.which("mkdocs") is None, reason="mkdocs not installed or not in PATH", ) def test_mkdocs_output_contains_commands(self, mkdocs_build_dir, ensure_complex_demo_importable): """Test that built documentation contains expected command content.""" pytest.importorskip("mkdocs") result = _run_command( ["mkdocs", "build", "--site-dir", str(mkdocs_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) if result.returncode != 0: pytest.skip(f"mkdocs build failed: {result.stderr}") # Check CLI index page cli_index = mkdocs_build_dir / "cli" / "index" / "index.html" if not cli_index.exists(): # Try alternate path structure cli_index = mkdocs_build_dir / "cli" / "index.html" assert cli_index.exists(), f"CLI index page not found. Contents: {list(mkdocs_build_dir.rglob('*.html'))}" content = cli_index.read_text() # Verify key commands are documented assert "admin" in content.lower(), "admin command not found in CLI index" assert "data" in content.lower(), "data command not found in CLI index" assert "server" in content.lower(), "server command not found in CLI index" @pytest.mark.skipif( shutil.which("mkdocs") is None, reason="mkdocs not installed or not in PATH", ) def test_mkdocs_output_contains_nested_commands(self, mkdocs_build_dir, ensure_complex_demo_importable): """Test that nested commands (4 levels deep) are documented.""" pytest.importorskip("mkdocs") result = _run_command( ["mkdocs", "build", "--site-dir", str(mkdocs_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) if result.returncode != 0: pytest.skip(f"mkdocs build failed: {result.stderr}") # Check admin page for deeply nested commands admin_page = mkdocs_build_dir / "cli" / "admin" / "index.html" if not admin_page.exists(): admin_page = mkdocs_build_dir / "cli" / "admin.html" if not admin_page.exists(): pytest.skip(f"Admin page not found. Contents: {list(mkdocs_build_dir.rglob('*.html'))}") content = admin_page.read_text() # Verify nested command hierarchy is present assert "users" in content.lower(), "users subcommand not found" assert "permissions" in content.lower(), "permissions subcommand not found" # The deepest level assert "roles" in content.lower() or "role" in content.lower(), "roles subcommand not found" @pytest.mark.skipif( shutil.which("mkdocs") is None, reason="mkdocs not installed or not in PATH", ) def test_mkdocs_output_contains_dataclass_params(self, mkdocs_build_dir, ensure_complex_demo_importable): """Test that dataclass-flattened parameters appear correctly.""" pytest.importorskip("mkdocs") result = _run_command( ["mkdocs", "build", "--site-dir", str(mkdocs_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) if result.returncode != 0: pytest.skip(f"mkdocs build failed: {result.stderr}") # Check data page for flattened dataclass parameters data_page = mkdocs_build_dir / "cli" / "data" / "index.html" if not data_page.exists(): data_page = mkdocs_build_dir / "cli" / "data.html" if not data_page.exists(): pytest.skip(f"Data page not found. Contents: {list(mkdocs_build_dir.rglob('*.html'))}") content = data_page.read_text() # Verify flattened dataclass parameters appear # These come from ProcessingConfig and PathConfig assert "batch" in content.lower(), "batch-size parameter not found" assert "worker" in content.lower(), "workers parameter not found" assert "output" in content.lower(), "output-dir parameter not found" @pytest.mark.skipif( shutil.which("mkdocs") is None, reason="mkdocs not installed or not in PATH", ) def test_mkdocs_output_contains_complex_types(self, mkdocs_build_dir, ensure_complex_demo_importable): """Test that complex union types are documented correctly.""" pytest.importorskip("mkdocs") result = _run_command( ["mkdocs", "build", "--site-dir", str(mkdocs_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) if result.returncode != 0: pytest.skip(f"mkdocs build failed: {result.stderr}") # Check utilities page for complex types utils_page = mkdocs_build_dir / "cli" / "utilities" / "index.html" if not utils_page.exists(): utils_page = mkdocs_build_dir / "cli" / "utilities.html" if not utils_page.exists(): pytest.skip(f"Utilities page not found. Contents: {list(mkdocs_build_dir.rglob('*.html'))}") content = utils_page.read_text() # Verify complex type parameters appear assert "auto" in content.lower(), "'auto' literal option not found" assert "worker" in content.lower(), "worker-count parameter not found" @pytest.mark.skipif( shutil.which("mkdocs") is None, reason="mkdocs not installed or not in PATH", ) def test_mkdocs_hidden_commands_excluded_by_default(self, mkdocs_build_dir, ensure_complex_demo_importable): """Test that hidden commands are not included by default.""" pytest.importorskip("mkdocs") result = _run_command( ["mkdocs", "build", "--site-dir", str(mkdocs_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) if result.returncode != 0: pytest.skip(f"mkdocs build failed: {result.stderr}") # Check CLI index (which doesn't use include_hidden) cli_index = mkdocs_build_dir / "cli" / "index" / "index.html" if not cli_index.exists(): cli_index = mkdocs_build_dir / "cli" / "index.html" if not cli_index.exists(): pytest.skip(f"CLI index not found. Contents: {list(mkdocs_build_dir.rglob('*.html'))}") content = cli_index.read_text() # internal_maintenance is marked show=False, should not appear assert "internal_maintenance" not in content and "internal-maintenance" not in content, ( "Hidden command internal_maintenance should not appear" ) @pytest.mark.skipif( shutil.which("mkdocs") is None, reason="mkdocs not installed or not in PATH", ) def test_mkdocs_hidden_commands_included_when_requested(self, mkdocs_build_dir, ensure_complex_demo_importable): """Test that hidden commands appear when include_hidden is True.""" pytest.importorskip("mkdocs") result = _run_command( ["mkdocs", "build", "--site-dir", str(mkdocs_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) if result.returncode != 0: pytest.skip(f"mkdocs build failed: {result.stderr}") # Check full reference page (which uses include_hidden: true) full_page = mkdocs_build_dir / "cli" / "full" / "index.html" if not full_page.exists(): full_page = mkdocs_build_dir / "cli" / "full.html" if not full_page.exists(): pytest.skip(f"Full page not found. Contents: {list(mkdocs_build_dir.rglob('*.html'))}") content = full_page.read_text() # internal_maintenance should appear on the full page assert "internal" in content.lower(), "Hidden command should appear with include_hidden=true" @pytest.mark.slow class TestSphinxBuild: """Test Sphinx documentation builds.""" @pytest.fixture def sphinx_build_dir(self, tmp_path): """Create a temporary build directory for Sphinx.""" build_dir = tmp_path / "_build" / "html" return build_dir @pytest.fixture def ensure_complex_demo_importable(self): """Ensure the complex_app module can be imported.""" sys.path.insert(0, str(COMPLEX_DEMO_DIR)) yield sys.path.remove(str(COMPLEX_DEMO_DIR)) @pytest.mark.skipif( shutil.which("sphinx-build") is None, reason="sphinx-build not installed or not in PATH", ) def test_sphinx_build_succeeds(self, sphinx_build_dir, ensure_complex_demo_importable): """Test that sphinx-build completes successfully.""" pytest.importorskip("sphinx") source_dir = COMPLEX_DEMO_DIR / "docs" / "source" result = _run_command( ["sphinx-build", "-b", "html", str(source_dir), str(sphinx_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) assert result.returncode == 0, f"sphinx-build failed:\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}" assert sphinx_build_dir.exists(), "Build directory was not created" @pytest.mark.skipif( shutil.which("sphinx-build") is None, reason="sphinx-build not installed or not in PATH", ) def test_sphinx_output_contains_commands(self, sphinx_build_dir, ensure_complex_demo_importable): """Test that built documentation contains expected command content.""" pytest.importorskip("sphinx") source_dir = COMPLEX_DEMO_DIR / "docs" / "source" result = _run_command( ["sphinx-build", "-b", "html", str(source_dir), str(sphinx_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) if result.returncode != 0: pytest.skip(f"sphinx-build failed: {result.stderr}") # Check CLI index page cli_index = sphinx_build_dir / "cli" / "index.html" if not cli_index.exists(): pytest.skip(f"CLI index page not found. Contents: {list(sphinx_build_dir.rglob('*.html'))}") content = cli_index.read_text() # Verify key commands are documented assert "admin" in content.lower(), "admin command not found in CLI index" assert "data" in content.lower(), "data command not found in CLI index" assert "server" in content.lower(), "server command not found in CLI index" @pytest.mark.skipif( shutil.which("sphinx-build") is None, reason="sphinx-build not installed or not in PATH", ) def test_sphinx_output_contains_nested_commands(self, sphinx_build_dir, ensure_complex_demo_importable): """Test that nested commands (4 levels deep) are documented.""" pytest.importorskip("sphinx") source_dir = COMPLEX_DEMO_DIR / "docs" / "source" result = _run_command( ["sphinx-build", "-b", "html", str(source_dir), str(sphinx_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) if result.returncode != 0: pytest.skip(f"sphinx-build failed: {result.stderr}") # Check admin page for deeply nested commands admin_page = sphinx_build_dir / "cli" / "admin.html" if not admin_page.exists(): pytest.skip(f"Admin page not found. Contents: {list(sphinx_build_dir.rglob('*.html'))}") content = admin_page.read_text() # Verify nested command hierarchy is present assert "users" in content.lower(), "users subcommand not found" assert "permissions" in content.lower(), "permissions subcommand not found" @pytest.mark.skipif( shutil.which("sphinx-build") is None, reason="sphinx-build not installed or not in PATH", ) def test_sphinx_output_contains_dataclass_params(self, sphinx_build_dir, ensure_complex_demo_importable): """Test that dataclass-flattened parameters appear correctly.""" pytest.importorskip("sphinx") source_dir = COMPLEX_DEMO_DIR / "docs" / "source" result = _run_command( ["sphinx-build", "-b", "html", str(source_dir), str(sphinx_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) if result.returncode != 0: pytest.skip(f"sphinx-build failed: {result.stderr}") # Check data page for flattened dataclass parameters data_page = sphinx_build_dir / "cli" / "data.html" if not data_page.exists(): pytest.skip(f"Data page not found. Contents: {list(sphinx_build_dir.rglob('*.html'))}") content = data_page.read_text() # Verify flattened dataclass parameters appear assert "batch" in content.lower(), "batch-size parameter not found" assert "worker" in content.lower(), "workers parameter not found" @pytest.mark.skipif( shutil.which("sphinx-build") is None, reason="sphinx-build not installed or not in PATH", ) def test_sphinx_rst_anchors_generated(self, sphinx_build_dir, ensure_complex_demo_importable): """Test that RST reference anchors are generated.""" pytest.importorskip("sphinx") source_dir = COMPLEX_DEMO_DIR / "docs" / "source" result = _run_command( ["sphinx-build", "-b", "html", str(source_dir), str(sphinx_build_dir)], cwd=COMPLEX_DEMO_DIR, env={"PYTHONPATH": str(COMPLEX_DEMO_DIR)}, ) if result.returncode != 0: pytest.skip(f"sphinx-build failed: {result.stderr}") # Check that anchor IDs are present in the output cli_index = sphinx_build_dir / "cli" / "index.html" if not cli_index.exists(): pytest.skip(f"CLI index not found. Contents: {list(sphinx_build_dir.rglob('*.html'))}") content = cli_index.read_text() # Check for anchor elements (id attributes in headers) assert 'id="' in content, "No anchor IDs found in output" class TestDocstringFormats: """Test that different docstring formats are handled correctly.""" @pytest.fixture def ensure_complex_demo_importable(self): """Ensure the complex_app module can be imported.""" sys.path.insert(0, str(COMPLEX_DEMO_DIR)) yield sys.path.remove(str(COMPLEX_DEMO_DIR)) def test_numpy_docstring_parsing(self, ensure_complex_demo_importable): """Test that NumPy-style docstrings are parsed correctly.""" from complex_app import numpy_style # The docstring should be parseable assert numpy_style.__doc__ is not None assert "name" in numpy_style.__doc__ assert "count" in numpy_style.__doc__ def test_google_docstring_parsing(self, ensure_complex_demo_importable): """Test that Google-style docstrings are parsed correctly.""" from complex_app import google_style # The docstring should be parseable assert google_style.__doc__ is not None assert "name" in google_style.__doc__ assert "count" in google_style.__doc__ def test_sphinx_docstring_parsing(self, ensure_complex_demo_importable): """Test that Sphinx-style docstrings are parsed correctly.""" from complex_app import sphinx_style # The docstring should be parseable assert sphinx_style.__doc__ is not None assert "name" in sphinx_style.__doc__ assert "count" in sphinx_style.__doc__ class TestDataclassFlattening: """Test that dataclass parameter flattening works correctly.""" @pytest.fixture def ensure_complex_demo_importable(self): """Ensure the complex_app module can be imported.""" sys.path.insert(0, str(COMPLEX_DEMO_DIR)) yield sys.path.remove(str(COMPLEX_DEMO_DIR)) def test_dataclass_config_imported(self, ensure_complex_demo_importable): """Test that dataclass configs can be imported.""" from complex_app import DatabaseConfig, PathConfig, PipelineConfig, ProcessingConfig # All should be dataclasses assert DatabaseConfig is not None assert ProcessingConfig is not None assert PathConfig is not None assert PipelineConfig is not None def test_dataclass_defaults_accessible(self, ensure_complex_demo_importable): """Test that dataclass defaults are accessible.""" from complex_app import DatabaseConfig config = DatabaseConfig() assert config.host == "localhost" assert config.port == 5432 assert config.ssl_mode == "prefer" def test_nested_dataclass_works(self, ensure_complex_demo_importable): """Test that nested dataclasses work.""" from complex_app import PathConfig, PipelineConfig, ProcessingConfig config = PipelineConfig() assert isinstance(config.paths, PathConfig) assert isinstance(config.processing, ProcessingConfig) class TestEnumsAndFlags: """Test enum and flag handling.""" @pytest.fixture def ensure_complex_demo_importable(self): """Ensure the complex_app module can be imported.""" sys.path.insert(0, str(COMPLEX_DEMO_DIR)) yield sys.path.remove(str(COMPLEX_DEMO_DIR)) def test_enum_members(self, ensure_complex_demo_importable): """Test that enums have expected members.""" from complex_app import LogLevel, OutputFormat assert LogLevel.DEBUG.value == "debug" assert LogLevel.INFO.value == "info" assert OutputFormat.JSON.value == "json" assert OutputFormat.YAML.value == "yaml" def test_flag_combinations(self, ensure_complex_demo_importable): """Test that flags can be combined.""" from complex_app import Permission combined = Permission.READ | Permission.WRITE assert Permission.READ in combined assert Permission.WRITE in combined assert Permission.EXECUTE not in combined class TestAppStructure: """Test the application structure.""" @pytest.fixture def ensure_complex_demo_importable(self): """Ensure the complex_app module can be imported.""" sys.path.insert(0, str(COMPLEX_DEMO_DIR)) yield sys.path.remove(str(COMPLEX_DEMO_DIR)) def test_app_has_commands(self, ensure_complex_demo_importable): """Test that the app has the expected commands registered.""" from complex_app import app # Get command names command_names = set(app._commands.keys()) # Check for expected top-level commands/apps assert "admin" in command_names, "admin app not registered" assert "data" in command_names, "data app not registered" assert "server" in command_names, "server app not registered" assert "cache" in command_names, "cache app not registered" def test_nested_app_structure(self, ensure_complex_demo_importable): """Test that nested apps have the expected structure.""" from complex_app import admin_app, permissions_app, roles_app, users_app # Check nesting: admin -> users -> permissions -> roles assert "users" in admin_app._commands assert "permissions" in users_app._commands assert "roles" in permissions_app._commands # Check roles commands role_commands = set(roles_app._commands.keys()) assert "list-roles" in role_commands or "list_roles" in role_commands assert "create-role" in role_commands or "create_role" in role_commands def test_groups_configured(self, ensure_complex_demo_importable): """Test that groups are properly configured.""" from complex_app import global_group, subcommands_group, utilities_group assert global_group is not None assert subcommands_group is not None assert utilities_group is not None # Check sort keys - create_ordered() creates tuple sort keys like (user_sort_key, count) # We just verify they're in the right order (smaller tuples sort first) assert global_group.sort_key is not None assert subcommands_group.sort_key is not None assert utilities_group.sort_key is not None assert global_group.sort_key < subcommands_group.sort_key assert subcommands_group.sort_key < utilities_group.sort_key class TestMarkdownGeneration: """Test direct markdown generation without building.""" @pytest.fixture def ensure_complex_demo_importable(self): """Ensure the complex_app module can be imported.""" sys.path.insert(0, str(COMPLEX_DEMO_DIR)) yield sys.path.remove(str(COMPLEX_DEMO_DIR)) def test_generate_markdown_basic(self, ensure_complex_demo_importable): """Test basic markdown generation.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs(app, recursive=True) assert "complex-cli" in markdown assert "admin" in markdown.lower() assert "data" in markdown.lower() assert "server" in markdown.lower() def test_generate_markdown_with_filter(self, ensure_complex_demo_importable): """Test markdown generation with command filtering.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, commands_filter=["admin"], ) assert "admin" in markdown.lower() # Other top-level commands should not be present assert "data process" not in markdown.lower() assert "server start" not in markdown.lower() def test_generate_markdown_nested_filter(self, ensure_complex_demo_importable): """Test markdown generation with nested command filtering.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, commands_filter=["admin.users.permissions"], ) assert "permissions" in markdown.lower() assert "grant" in markdown.lower() or "revoke" in markdown.lower() def test_generate_markdown_flattened(self, ensure_complex_demo_importable): """Test markdown generation with flattened commands.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, flatten_commands=True, ) # All commands should be at the same heading level # Count heading levels (looking for consistent depth) lines = markdown.split("\n") heading_levels = [] for line in lines: if line.startswith("#") and not line.startswith("```"): level = len(line) - len(line.lstrip("#")) heading_levels.append(level) # With flatten_commands, most headings should be at the same level # (excluding the root title) if len(heading_levels) > 2: # Most command headings should be at the same level from collections import Counter counts = Counter(heading_levels) most_common_level, _ = counts.most_common(1)[0] same_level_count = sum(1 for h in heading_levels if h == most_common_level) # At least half should be at the same level when flattened assert same_level_count >= len(heading_levels) // 2, "Commands not properly flattened" def test_generate_markdown_include_hidden(self, ensure_complex_demo_importable): """Test markdown generation with hidden commands included.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs # Without include_hidden markdown_normal = generate_markdown_docs(app, recursive=True, include_hidden=False) # With include_hidden markdown_hidden = generate_markdown_docs(app, recursive=True, include_hidden=True) # Hidden command should only appear when include_hidden=True has_internal_normal = "internal-maintenance" in markdown_normal or "internal_maintenance" in markdown_normal has_internal_hidden = "internal-maintenance" in markdown_hidden or "internal_maintenance" in markdown_hidden assert not has_internal_normal, "Hidden command should not appear without include_hidden" assert has_internal_hidden, "Hidden command should appear with include_hidden=True" BrianPugh-cyclopts-921b1fa/tests/test_docs_snapshots.py000066400000000000000000000403151517576204000234550ustar00rootroot00000000000000"""Snapshot tests for documentation generation. These tests capture the generated markdown and RST output to detect unintended changes to the documentation plugins. """ import sys from pathlib import Path import pytest # Skip all snapshot tests on Windows due to platform-specific output differences pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="Snapshot tests not supported on Windows") # Path to the complex-demo application COMPLEX_DEMO_DIR = Path(__file__).parent / "apps" / "complex-demo" @pytest.fixture def ensure_complex_demo_importable(): """Ensure the complex_app module can be imported.""" sys.path.insert(0, str(COMPLEX_DEMO_DIR)) yield sys.path.remove(str(COMPLEX_DEMO_DIR)) # Clean up any cached imports modules_to_remove = [k for k in sys.modules.keys() if k.startswith("complex_app")] for mod in modules_to_remove: del sys.modules[mod] class TestMarkdownSnapshots: """Snapshot tests for markdown generation.""" def test_full_app_markdown(self, ensure_complex_demo_importable, md_snapshot): """Snapshot the full app markdown output.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, include_hidden=False, heading_level=1, generate_toc=True, ) assert markdown == md_snapshot def test_admin_commands_markdown(self, ensure_complex_demo_importable, md_snapshot): """Snapshot markdown for admin commands (tests deep nesting).""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, include_hidden=False, heading_level=2, generate_toc=False, commands_filter=["admin"], ) assert markdown == md_snapshot def test_nested_permissions_markdown(self, ensure_complex_demo_importable, md_snapshot): """Snapshot markdown for deeply nested permissions commands.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, include_hidden=False, heading_level=2, generate_toc=False, commands_filter=["admin.users.permissions"], ) assert markdown == md_snapshot def test_data_commands_markdown(self, ensure_complex_demo_importable, md_snapshot): """Snapshot markdown for data commands (tests dataclass flattening).""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, include_hidden=False, heading_level=2, generate_toc=False, commands_filter=["data"], ) assert markdown == md_snapshot def test_flattened_commands_markdown(self, ensure_complex_demo_importable, md_snapshot): """Snapshot markdown with flatten_commands=True.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, include_hidden=False, heading_level=2, generate_toc=False, flatten_commands=True, commands_filter=["admin.users"], # Limit scope for readable snapshot ) assert markdown == md_snapshot def test_hidden_commands_markdown(self, ensure_complex_demo_importable, md_snapshot): """Snapshot markdown with include_hidden=True.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=False, # Just top-level to keep snapshot small include_hidden=True, heading_level=2, generate_toc=False, ) assert markdown == md_snapshot def test_exclude_commands_markdown(self, ensure_complex_demo_importable, md_snapshot): """Snapshot markdown with exclude_commands.""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, include_hidden=False, heading_level=2, generate_toc=False, commands_filter=["admin"], exclude_commands=["admin.users.permissions"], ) assert markdown == md_snapshot def test_server_commands_markdown(self, ensure_complex_demo_importable, md_snapshot): """Snapshot markdown for server commands (tests Pydantic if available).""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, include_hidden=False, heading_level=2, generate_toc=False, commands_filter=["server"], ) assert markdown == md_snapshot def test_utilities_markdown(self, ensure_complex_demo_importable, md_snapshot): """Snapshot markdown for utility commands (tests enums, complex types).""" from complex_app import app from cyclopts.docs.markdown import generate_markdown_docs markdown = generate_markdown_docs( app, recursive=True, include_hidden=False, heading_level=2, generate_toc=False, commands_filter=["cache", "complex-types", "version", "info"], ) assert markdown == md_snapshot class TestRstSnapshots: """Snapshot tests for RST generation.""" def test_full_app_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot the full app RST output.""" from complex_app import app from cyclopts.docs.rst import generate_rst_docs rst = generate_rst_docs( app, recursive=True, include_hidden=False, heading_level=1, ) assert rst == rst_snapshot def test_admin_commands_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot RST for admin commands (tests deep nesting).""" from complex_app import app from cyclopts.docs.rst import generate_rst_docs rst = generate_rst_docs( app, recursive=True, include_hidden=False, heading_level=2, commands_filter=["admin"], ) assert rst == rst_snapshot def test_nested_permissions_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot RST for deeply nested permissions commands.""" from complex_app import app from cyclopts.docs.rst import generate_rst_docs rst = generate_rst_docs( app, recursive=True, include_hidden=False, heading_level=2, commands_filter=["admin.users.permissions"], ) assert rst == rst_snapshot def test_data_commands_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot RST for data commands (tests dataclass flattening).""" from complex_app import app from cyclopts.docs.rst import generate_rst_docs rst = generate_rst_docs( app, recursive=True, include_hidden=False, heading_level=2, commands_filter=["data"], ) assert rst == rst_snapshot def test_flattened_commands_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot RST with flatten_commands=True.""" from complex_app import app from cyclopts.docs.rst import generate_rst_docs rst = generate_rst_docs( app, recursive=True, include_hidden=False, heading_level=2, flatten_commands=True, commands_filter=["admin.users"], # Limit scope for readable snapshot ) assert rst == rst_snapshot def test_hidden_commands_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot RST with include_hidden=True.""" from complex_app import app from cyclopts.docs.rst import generate_rst_docs rst = generate_rst_docs( app, recursive=False, # Just top-level to keep snapshot small include_hidden=True, heading_level=2, ) assert rst == rst_snapshot def test_server_commands_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot RST for server commands (tests Pydantic if available).""" from complex_app import app from cyclopts.docs.rst import generate_rst_docs rst = generate_rst_docs( app, recursive=True, include_hidden=False, heading_level=2, commands_filter=["server"], ) assert rst == rst_snapshot class TestMkDocsDirectiveSnapshots: """Snapshot tests for MkDocs directive processing.""" def test_simple_directive_output(self, ensure_complex_demo_importable, md_snapshot): """Snapshot the output of a simple directive.""" import textwrap from cyclopts.ext.mkdocs import process_cyclopts_directives markdown = textwrap.dedent( """\ # CLI Reference ::: cyclopts module: complex_app:app heading_level: 2 recursive: false generate_toc: false """ ) result = process_cyclopts_directives(markdown, None) assert result == md_snapshot def test_filtered_directive_output(self, ensure_complex_demo_importable, md_snapshot): """Snapshot the output of a filtered directive.""" import textwrap from cyclopts.ext.mkdocs import process_cyclopts_directives markdown = textwrap.dedent( """\ # Admin Commands ::: cyclopts module: complex_app:app heading_level: 2 recursive: true commands: [admin.users] generate_toc: false """ ) result = process_cyclopts_directives(markdown, None) assert result == md_snapshot def test_nested_directive_output(self, ensure_complex_demo_importable, md_snapshot): """Snapshot the output for deeply nested commands.""" import textwrap from cyclopts.ext.mkdocs import process_cyclopts_directives markdown = textwrap.dedent( """\ # Permissions ::: cyclopts module: complex_app:app heading_level: 3 recursive: true commands: [admin.users.permissions.roles] generate_toc: false """ ) result = process_cyclopts_directives(markdown, None) assert result == md_snapshot def test_multiple_directives_output(self, ensure_complex_demo_importable, md_snapshot): """Snapshot the output of multiple directives on one page.""" import textwrap from cyclopts.ext.mkdocs import process_cyclopts_directives markdown = textwrap.dedent( """\ # CLI Reference ## Data Commands ::: cyclopts module: complex_app:app heading_level: 3 commands: [data] generate_toc: false ## Server Commands ::: cyclopts module: complex_app:app heading_level: 3 commands: [server] generate_toc: false """ ) result = process_cyclopts_directives(markdown, None) assert result == md_snapshot class TestSphinxDirectiveSnapshots: """Snapshot tests for Sphinx directive output.""" def test_simple_directive_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot the RST output for a simple directive.""" from unittest.mock import MagicMock from docutils.statemachine import StringList from cyclopts.ext.sphinx import CycloptsDirective mock_state = MagicMock() captured_content = [] def capture_nested_parse(string_list, offset, parent): captured_content.extend(string_list) mock_state.nested_parse = capture_nested_parse directive = CycloptsDirective( name="cyclopts", arguments=["complex_app:app"], options={"heading-level": 2, "recursive": False}, content=StringList(), lineno=1, content_offset=0, block_text="", state=mock_state, state_machine=MagicMock(), ) directive.run() rst_output = "\n".join(captured_content) assert rst_output == rst_snapshot def test_filtered_directive_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot the RST output for a filtered directive.""" from unittest.mock import MagicMock from docutils.statemachine import StringList from cyclopts.ext.sphinx import CycloptsDirective mock_state = MagicMock() captured_content = [] def capture_nested_parse(string_list, offset, parent): captured_content.extend(string_list) mock_state.nested_parse = capture_nested_parse directive = CycloptsDirective( name="cyclopts", arguments=["complex_app:app"], options={"heading-level": 2, "recursive": True, "commands": "admin.users"}, content=StringList(), lineno=1, content_offset=0, block_text="", state=mock_state, state_machine=MagicMock(), ) directive.run() rst_output = "\n".join(captured_content) assert rst_output == rst_snapshot def test_nested_commands_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot the RST output for deeply nested commands.""" from unittest.mock import MagicMock from docutils.statemachine import StringList from cyclopts.ext.sphinx import CycloptsDirective mock_state = MagicMock() captured_content = [] def capture_nested_parse(string_list, offset, parent): captured_content.extend(string_list) mock_state.nested_parse = capture_nested_parse directive = CycloptsDirective( name="cyclopts", arguments=["complex_app:app"], options={"heading-level": 3, "recursive": True, "commands": "admin.users.permissions"}, content=StringList(), lineno=1, content_offset=0, block_text="", state=mock_state, state_machine=MagicMock(), ) directive.run() rst_output = "\n".join(captured_content) assert rst_output == rst_snapshot def test_flattened_commands_rst(self, ensure_complex_demo_importable, rst_snapshot): """Snapshot the RST output with flatten-commands.""" from unittest.mock import MagicMock from docutils.statemachine import StringList from cyclopts.ext.sphinx import CycloptsDirective mock_state = MagicMock() captured_content = [] def capture_nested_parse(string_list, offset, parent): captured_content.extend(string_list) mock_state.nested_parse = capture_nested_parse directive = CycloptsDirective( name="cyclopts", arguments=["complex_app:app"], options={"heading-level": 2, "recursive": True, "flatten-commands": True, "commands": "admin.users"}, content=StringList(), lineno=1, content_offset=0, block_text="", state=mock_state, state_machine=MagicMock(), ) directive.run() rst_output = "\n".join(captured_content) assert rst_output == rst_snapshot BrianPugh-cyclopts-921b1fa/tests/test_docs_types.py000066400000000000000000000111141517576204000225720ustar00rootroot00000000000000"""Tests for cyclopts.docs.types module.""" from typing import get_args import pytest from cyclopts.docs.types import ( FORMAT_ALIASES, CanonicalDocFormat, DocFormat, normalize_format, ) def test_format_aliases_sync_with_docformat(): """Test that all FORMAT_ALIASES keys are in DocFormat type.""" # Get all literal values from DocFormat type doc_format_values = set(get_args(DocFormat)) # Get all keys from FORMAT_ALIASES format_alias_keys = set(FORMAT_ALIASES.keys()) # Ensure all FORMAT_ALIASES keys are in DocFormat assert format_alias_keys == doc_format_values, ( f"FORMAT_ALIASES keys {format_alias_keys} must match DocFormat values {doc_format_values}" ) def test_format_aliases_values_are_canonical(): """Test that all FORMAT_ALIASES values are valid canonical formats.""" canonical_values = set(get_args(CanonicalDocFormat)) for alias, canonical in FORMAT_ALIASES.items(): assert canonical in canonical_values, ( f"FORMAT_ALIASES['{alias}'] = '{canonical}' is not a valid CanonicalDocFormat" ) def test_common_suffixes_have_format_aliases(): """Test that common file suffixes have corresponding format aliases.""" # Common suffixes that should work common_suffixes = ["md", "markdown", "html", "htm", "rst", "rest"] for suffix in common_suffixes: assert suffix in FORMAT_ALIASES, f"Common suffix '{suffix}' should have a format alias" def test_canonical_formats_have_identity_mapping(): """Test that canonical formats map to themselves in FORMAT_ALIASES.""" canonical_values = get_args(CanonicalDocFormat) for canonical in canonical_values: assert canonical in FORMAT_ALIASES, f"Canonical format '{canonical}' should be in FORMAT_ALIASES" assert FORMAT_ALIASES[canonical] == canonical, f"Canonical format '{canonical}' should map to itself" def test_file_suffix_handling(): """Test that file suffixes work correctly with period stripping and format inference.""" # Test cases for common file extensions test_cases = [ (".md", "md", "markdown"), (".markdown", "markdown", "markdown"), (".html", "html", "html"), (".htm", "htm", "html"), (".rst", "rst", "rst"), (".rest", "rest", "rst"), ] for suffix_with_period, suffix_key, expected_canonical in test_cases: # Test period stripping mechanism key = suffix_with_period.lstrip(".") assert key == suffix_key, f"Period stripping failed for '{suffix_with_period}'" # Test that the key exists in FORMAT_ALIASES assert key in FORMAT_ALIASES, f"Key '{key}' should be in FORMAT_ALIASES" # Test that it maps to the correct canonical format assert FORMAT_ALIASES[key] == expected_canonical, ( f"FORMAT_ALIASES['{key}'] should be '{expected_canonical}', got '{FORMAT_ALIASES[key]}'" ) # Test that this is consistent with file suffix inference # (simulating how the system would infer format from a file extension) inferred_format = FORMAT_ALIASES.get(key) assert inferred_format == expected_canonical, ( f"Suffix '{suffix_with_period}' should infer format '{expected_canonical}', got '{inferred_format}'" ) def test_normalize_format_with_all_aliases(): """Test normalize_format works with all defined aliases.""" for alias, expected_canonical in FORMAT_ALIASES.items(): # Test lowercase assert normalize_format(alias) == expected_canonical # Test uppercase assert normalize_format(alias.upper()) == expected_canonical # Test mixed case if len(alias) > 1: mixed_case = alias[0].upper() + alias[1:].lower() assert normalize_format(mixed_case) == expected_canonical def test_normalize_format_invalid(): """Test normalize_format raises ValueError for invalid formats.""" with pytest.raises(ValueError, match='Unsupported format "invalid"'): normalize_format("invalid") with pytest.raises(ValueError, match='Unsupported format "pdf"'): normalize_format("pdf") def test_all_doc_format_values_have_normalization(): """Test that all DocFormat literal values can be normalized.""" doc_format_values = get_args(DocFormat) for format_value in doc_format_values: # Should not raise an exception result = normalize_format(format_value) # Result should be a canonical format assert result in get_args(CanonicalDocFormat), ( f"normalize_format('{format_value}') = '{result}' is not a CanonicalDocFormat" ) BrianPugh-cyclopts-921b1fa/tests/test_edit.py000066400000000000000000000124341517576204000213510ustar00rootroot00000000000000import subprocess from pathlib import Path import pytest from cyclopts._edit import ( EditorDidNotChangeError, EditorDidNotSaveError, EditorError, EditorNotFoundError, edit, ) @pytest.fixture def mock_editor(): """Mock editor that simulates saving file with edited content.""" def fake_editor(args): Path(args[1]).write_text("edited content") return 0 return fake_editor def test_basic_edit(mocker, mock_editor, monkeypatch): """Test basic editing functionality.""" monkeypatch.setenv("EDITOR", "test_editor") mocker.patch("shutil.which", return_value=True) mocker.patch("subprocess.check_call", side_effect=mock_editor) result = edit("initial text") assert result == "edited content" def test_custom_path(mocker, mock_editor, tmp_path, monkeypatch): """Test editing with custom path.""" custom_path = tmp_path / "custom.txt" monkeypatch.setenv("EDITOR", "test_editor") mocker.patch("shutil.which", return_value=True) mocker.patch("subprocess.check_call", side_effect=mock_editor) result = edit("initial text", path=custom_path) assert result == "edited content" def test_editor_not_found(mocker, monkeypatch): """Test behavior when no editor is found.""" monkeypatch.delenv("EDITOR", raising=False) mocker.patch("shutil.which", return_value=False) with pytest.raises(EditorNotFoundError): edit("test") def test_did_not_save(mocker, monkeypatch): """Test behavior when user doesn't save.""" monkeypatch.setenv("EDITOR", "test_editor") def fake_editor(_): # Doesn't "save" return 0 mocker.patch("shutil.which", return_value=True) mocker.patch("subprocess.check_call", side_effect=fake_editor) with pytest.raises(EditorDidNotSaveError): edit("initial text", save=True) def test_did_not_change(mocker, monkeypatch): """Test behavior when content isn't changed.""" monkeypatch.setenv("EDITOR", "test_editor") initial_text = "unchanged text" def fake_editor(args): assert args[0] == "test_editor" Path(args[1]).write_text(initial_text) return 0 mocker.patch("shutil.which", return_value=True) mocker.patch("subprocess.check_call", side_effect=fake_editor) with pytest.raises(EditorDidNotChangeError): edit(initial_text, required=True) def test_editor_error(mocker, monkeypatch): """Test handling of editor errors.""" monkeypatch.setenv("EDITOR", "test_editor") mocker.patch("shutil.which", return_value=True) mocker.patch("subprocess.check_call", side_effect=subprocess.CalledProcessError(1, "test_editor")) with pytest.raises(EditorError) as exc_info: edit("test") assert "status 1" in str(exc_info.value) def test_custom_encoding(mocker, tmp_path, monkeypatch): """Test custom encoding support.""" test_path = tmp_path / "test.txt" monkeypatch.setenv("EDITOR", "test_editor") def fake_editor_with_encoding(args): assert args[0] == "test_editor" Path(args[1]).write_text("текст", encoding="utf-16") return 0 mocker.patch("shutil.which", return_value=True) mocker.patch("subprocess.check_call", side_effect=fake_editor_with_encoding) result = edit("initial", path=test_path, encoding="utf-16") assert result == "текст" def test_fallback_editors(mocker, mock_editor, monkeypatch): """Test fallback editor selection.""" monkeypatch.delenv("EDITOR", raising=False) def mock_which(editor_name): return editor_name == "vim" mocker.patch("shutil.which", side_effect=mock_which) mock_check_call = mocker.patch("subprocess.check_call", side_effect=mock_editor) result = edit("test", fallback_editors=["emacs", "vim", "nano"]) assert result == "edited content" assert mock_check_call.call_args_list[0].args[0][0] == "vim" def test_editor_args(mocker, monkeypatch): """Test passing additional arguments to editor.""" monkeypatch.setenv("EDITOR", "test_editor") def check_editor_args(args): assert args[0] == "test_editor" assert "--no-splash" in args Path(args[1]).write_text("edited with args") return 0 mocker.patch("shutil.which", return_value=True) mocker.patch("subprocess.check_call", side_effect=check_editor_args) result = edit("test", editor_args=["--no-splash"]) assert result == "edited with args" def test_optional_change(mocker, monkeypatch): """Test when content changes are optional.""" monkeypatch.setenv("EDITOR", "test_editor") initial_text = "unchanged" def fake_editor(args): assert args[0] == "test_editor" Path(args[1]).write_text(initial_text) return 0 mocker.patch("shutil.which", return_value=True) mocker.patch("subprocess.check_call", side_effect=fake_editor) # Should not raise DidNotChangeError result = edit(initial_text, required=False) assert result == initial_text def test_file_cleanup(mocker, tmp_path, monkeypatch, mock_editor): """Test temporary file cleanup.""" test_path = tmp_path / "cleanup_test.txt" monkeypatch.setenv("EDITOR", "test_editor") mocker.patch("shutil.which", return_value=True) mocker.patch("subprocess.check_call", side_effect=mock_editor) edit("test", path=test_path) assert not test_path.exists() BrianPugh-cyclopts-921b1fa/tests/test_enum_flag.py000066400000000000000000000414221517576204000223600ustar00rootroot00000000000000"""Tests for enum.Flag and enum.IntFlag support in cyclopts.""" from dataclasses import dataclass from enum import Flag, IntFlag, auto from textwrap import dedent from typing import Annotated import pytest from cyclopts import MissingArgumentError, Parameter from cyclopts.exceptions import CoercionError, UnknownOptionError class Permission(Flag): """File permissions as bit flags.""" READ = auto() """Enable read permissions.""" WRITE = auto() """Enable write permissions.""" EXECUTE = auto() """Enable execute permissions.""" class HttpStatus(IntFlag): INFORMATIONAL = 100 SUCCESS = 200 REDIRECTION = 300 CLIENT_ERROR = 400 SERVER_ERROR = 500 @dataclass class User: name: str perms: Permission = Permission.READ def test_flag_as_boolean_flags(app, assert_parse_args): """Test that Flag enum members are exposed as boolean CLI flags.""" @app.default def main(perms: Permission = Permission.READ): pass assert_parse_args(main, "--perms.read", perms=Permission.READ) assert_parse_args(main, "--perms.write", perms=Permission.WRITE) assert_parse_args(main, "--perms.read --perms.write", perms=Permission.READ | Permission.WRITE) def test_int_flag_as_boolean_flags(app, assert_parse_args): """Test that IntFlag enum members are exposed as boolean CLI flags.""" @app.default def main(error_on: HttpStatus | None = None): pass assert_parse_args(main, "") assert_parse_args(main, "--error-on.success", error_on=HttpStatus.SUCCESS) @pytest.mark.parametrize( "command", ["--perms read --perms write", "read write"], ) def test_flag_with_list_str_input(app, assert_parse_args, command): """Test that Flag enum members are exposed as boolean CLI flags.""" @app.default def main(perms: Permission = Permission.READ): pass assert_parse_args(main, command, Permission.READ | Permission.WRITE) def test_flag_unknown_member_str(app): @app.default def main(perms: Permission = Permission.READ): pass with pytest.raises(CoercionError): app("foo", exit_on_error=False) def test_flag_unknown_member_option(app): @app.default def main(perms: Permission = Permission.READ): pass with pytest.raises(UnknownOptionError): app("--perms.foo", exit_on_error=False) def test_flag_as_boolean_flags_star_name(app, assert_parse_args, console): """Test that Flag enum members are exposed as boolean CLI flags.""" @app.default def main(perms: Annotated[Permission, Parameter(name="*", negative_bool="")] = Permission.READ): """Manage file permissions.""" pass # Test individual flags assert_parse_args(main, "--read", Permission.READ) assert_parse_args(main, "--write", Permission.WRITE) assert_parse_args(main, "--execute", Permission.EXECUTE) # Test multiple flags combined assert_parse_args(main, "--read --write", Permission.READ | Permission.WRITE) assert_parse_args(main, "--read --write --execute", Permission.READ | Permission.WRITE | Permission.EXECUTE) # Test help output with console.capture() as capture: app.help_print([], console=console) actual = capture.get() expected = dedent( """\ Usage: test_enum_flag [OPTIONS] Manage file permissions. ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help (-h) Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────╮ │ --read Enable read permissions. [default: False] │ │ --write Enable write permissions. [default: False] │ │ --execute Enable execute permissions. [default: False] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert expected == actual def test_flag_with_default_value(app, assert_parse_args): """Test Flag enum with non-zero default value.""" @app.default def main(perms: Permission = Permission.READ | Permission.WRITE): pass assert_parse_args(main, "") def test_flag_with_other_parameters(app, assert_parse_args): """Test Flag enum alongside other parameters.""" @app.default def main(name: str, perms: Permission = Permission.READ, verbose: bool = False): pass # Test with positional and flag parameters assert_parse_args(main, "test.txt --perms.read --perms.write", "test.txt", Permission.READ | Permission.WRITE) # Test with all parameter types assert_parse_args(main, "test.txt --perms.execute --verbose", "test.txt", Permission.EXECUTE, True) def test_flag_star_name_with_other_parameters(app, assert_parse_args): """Test Flag enum with star name alongside other parameters.""" @app.default def main( name: str, perms: Annotated[Permission, Parameter(name="*")] = Permission.READ, verbose: bool = False, ): pass # Test star name expansion with other parameters assert_parse_args(main, "test.txt --read --write --verbose", "test.txt", Permission.READ | Permission.WRITE, True) def test_flag_no_flags_provided(app): """Test behavior when no flags are provided but parameter is required.""" @app.default def main(perms: Permission): # No default value pass with pytest.raises(MissingArgumentError): app([], exit_on_error=False) def test_flag_help_shows_member_docstrings(app, console): """Test that Flag enum member docstrings appear in help output.""" @app.default def main(perms: Permission = Permission.READ): """Manage file permissions.""" pass with console.capture() as capture: app.help_print([], console=console) actual = capture.get() expected = dedent( """\ Usage: test_enum_flag [OPTIONS] Manage file permissions. ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help (-h) Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────╮ │ --perms.read Enable read permissions. [default: False] │ │ --perms.no-read │ │ --perms.write Enable write permissions. [default: False] │ │ --perms.no-write │ │ --perms.execute Enable execute permissions. [default: False] │ │ --perms.no-execute │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert expected == actual def test_flag_help_star_name_shows_member_docstrings(app, console): """Test that Flag enum member docstrings appear with star name expansion.""" @app.default def main(perms: Annotated[Permission, Parameter(name="*")] = Permission.READ): """Manage file permissions.""" pass with console.capture() as capture: app.help_print([], console=console) actual = capture.get() expected = dedent( """\ Usage: test_enum_flag [OPTIONS] Manage file permissions. ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help (-h) Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────╮ │ --read --no-read Enable read permissions. [default: False] │ │ --write --no-write Enable write permissions. [default: False] │ │ --execute --no-execute Enable execute permissions. [default: │ │ False] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert expected == actual def test_flag_in_dataclass(app, assert_parse_args): """Test Flag enum field in a dataclass.""" @app.default def main(user: User): pass # Test with name only (should use default perms) assert_parse_args(main, "Alice", User("Alice", Permission.READ)) # Test with name and flag permissions assert_parse_args( main, "Bob --user.perms.write --user.perms.execute", User("Bob", Permission.WRITE | Permission.EXECUTE) ) def test_flag_in_dataclass_positionally(app, assert_parse_args): """Test Flag enum field positionally in a dataclass.""" @app.default def main(user: User): pass assert_parse_args(main, "Bob write execute", User("Bob", Permission.WRITE | Permission.EXECUTE)) def test_flag_in_dataclass_help(app, console): """Test help output for dataclass with Flag enum field.""" @app.default def main(user: User): """Create a user with permissions.""" pass with console.capture() as capture: app.help_print([], console=console) actual = capture.get() expected = dedent( """\ Usage: test_enum_flag [OPTIONS] USER.NAME Create a user with permissions. ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help (-h) Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * USER.NAME --user.name [required] │ │ --user.perms.read Enable read permissions. [default: │ │ --user.perms.no-read False] │ │ --user.perms.write Enable write permissions. [default: │ │ --user.perms.no-write False] │ │ --user.perms.execute Enable execute permissions. │ │ --user.perms.no-execute [default: False] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert expected == actual def test_flag_in_dataclass_help_no_negative(app, console): """Test help output for dataclass with Flag enum field.""" @app.default def main(user: Annotated[User, Parameter(negative="")]): """Create a user with permissions.""" pass with console.capture() as capture: app.help_print([], console=console) actual = capture.get() expected = dedent( """\ Usage: test_enum_flag [OPTIONS] USER.NAME Create a user with permissions. ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help (-h) Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * USER.NAME --user.name [required] │ │ --user.perms.read Enable read permissions. [default: │ │ False] │ │ --user.perms.write Enable write permissions. [default: │ │ False] │ │ --user.perms.execute Enable execute permissions. [default: │ │ False] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert expected == actual def test_flag_in_dataclass_help_no_keywords(app, assert_parse_args, console): """Test help output for dataclass with Flag enum field.""" @dataclass class User: name: str perms: Annotated[Permission, Parameter(accepts_keys=False)] = Permission.READ @app.default def main(user: Annotated[User, Parameter(negative="")]): """Create a user with permissions.""" pass with console.capture() as capture: app.help_print([], console=console) actual = capture.get() expected = dedent( """\ Usage: test_enum_flag USER.NAME [ARGS] Create a user with permissions. ╭─ Commands ─────────────────────────────────────────────────────────╮ │ --help (-h) Display this message and exit. │ │ --version Display application version. │ ╰────────────────────────────────────────────────────────────────────╯ ╭─ Parameters ───────────────────────────────────────────────────────╮ │ * USER.NAME --user.name [required] │ │ USER.PERMS --user.perms [choices: read, write, execute] │ │ [default: read] │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert expected == actual assert_parse_args(main, "Bob read write", User("Bob", perms=Permission.READ | Permission.WRITE)) BrianPugh-cyclopts-921b1fa/tests/test_env_var.py000066400000000000000000000027221517576204000220630ustar00rootroot00000000000000from collections.abc import Iterable from pathlib import Path from typing import Annotated, Optional import pytest from cyclopts._env_var import env_var_split def test_env_var_split_path_windows(mocker): mocker.patch("cyclopts._env_var.os.pathsep", ";") assert env_var_split(list[Path], r"C:\foo\bar;D:\fizz\buzz") == [ r"C:\foo\bar", r"D:\fizz\buzz", ] @pytest.mark.parametrize( "type_", [ list[Path], list[Path | None], tuple[Path, ...], tuple[Path | None, ...], Annotated[list[Path], "test annotation"], ], ) def test_env_var_split_path_posix_multiple(mocker, type_): mocker.patch("cyclopts._env_var.os.pathsep", ":") assert env_var_split(type_, "/foo/bar;:/fizz/buzz") == [ "/foo/bar;", "/fizz/buzz", ] def test_env_var_split_path_posix_single(mocker): """Dont split when a single Path is desired.""" mocker.patch("cyclopts._env_var.os.pathsep", ":") assert ["foo:bar"] == env_var_split(Path, "foo:bar") def test_env_var_split_path_general(): assert ["foo"] == env_var_split(str, "foo") assert ["foo"] == env_var_split(Optional[str], "foo") assert ["foo bar"] == env_var_split(str, "foo bar") assert ["foo", "bar"] == env_var_split(list[str], "foo bar") assert ["foo", "bar"] == env_var_split(tuple[str, ...], "foo bar") assert ["foo", "bar"] == env_var_split(Iterable[str], "foo bar") assert ["1"] == env_var_split(int, "1") BrianPugh-cyclopts-921b1fa/tests/test_error_console.py000066400000000000000000000140111517576204000232700ustar00rootroot00000000000000"""Tests for error_console functionality.""" import sys from io import StringIO import pytest from cyclopts import App from cyclopts.exceptions import UnknownOptionError def test_error_goes_to_stderr(capfd): """Test that error messages go to stderr.""" app = App() @app.default def main(name: str = "World"): print(f"Hello, {name}!") with pytest.raises(SystemExit): app("--invalid-option", exit_on_error=True, print_error=True) captured = capfd.readouterr() assert captured.err != "" # Error message should be on stderr assert "Unknown option" in captured.err or "invalid-option" in captured.err assert captured.out == "" # Nothing on stdout def test_help_goes_to_stdout(capfd): """Test that help messages go to stdout.""" app = App() @app.default def main(name: str = "World"): print(f"Hello, {name}!") with pytest.raises(SystemExit): app("--help") captured = capfd.readouterr() assert captured.out != "" # Help should be on stdout assert captured.err == "" # Nothing on stderr def test_version_goes_to_stdout(capfd): """Test that version messages go to stdout.""" app = App(version="1.2.3") @app.default def main(name: str = "World"): print(f"Hello, {name}!") with pytest.raises(SystemExit): app("--version") captured = capfd.readouterr() assert "1.2.3" in captured.out # Version should be on stdout assert captured.err == "" # Nothing on stderr def test_custom_error_console(): """Test that custom error_console can be provided.""" from rich.console import Console error_output = StringIO() error_console = Console(file=error_output, force_terminal=False) app = App(error_console=error_console) @app.default def main(name: str): pass with pytest.raises(UnknownOptionError): app("--invalid", exit_on_error=False, print_error=True) error_text = error_output.getvalue() assert "Unknown option" in error_text or "invalid" in error_text def test_error_console_resolution_through_app_stack(mocker): """Test that error_console is properly resolved through app_stack.""" from rich.console import Console # Create separate consoles for testing normal_console = mocker.MagicMock(spec=Console) error_console_mock = mocker.MagicMock(spec=Console) app = App(console=normal_console, error_console=error_console_mock) @app.default def main(name: str): pass try: app("--invalid", exit_on_error=False, print_error=True) except UnknownOptionError: pass # Verify error_console was used for error printing error_console_mock.print.assert_called() # Normal console should not be called for errors normal_console.print.assert_not_called() def test_subapp_error_console_inheritance(mocker): """Test that subapp inherits error_console from parent.""" from rich.console import Console error_console_mock = mocker.MagicMock(spec=Console) app = App(error_console=error_console_mock) subapp = App(name="sub") app.command(subapp) @subapp.default def sub_cmd(value: int): pass try: app("sub --invalid", exit_on_error=False, print_error=True) except UnknownOptionError: pass # Error console should be used error_console_mock.print.assert_called() def test_explicit_error_console_parameter_overrides(mocker): """Test that explicit error_console parameter overrides default.""" from rich.console import Console # Default error console (should not be used) default_error_console = mocker.MagicMock(spec=Console) # Override error console (should be used) override_error_console = mocker.MagicMock(spec=Console) app = App(error_console=default_error_console) @app.default def main(name: str): pass try: app("--invalid", error_console=override_error_console, exit_on_error=False, print_error=True) except UnknownOptionError: pass # Override console should be used override_error_console.print.assert_called() # Default should not be used default_error_console.print.assert_not_called() def test_error_console_with_parse_args(mocker): """Test error_console works with parse_args method.""" from rich.console import Console error_console_mock = mocker.MagicMock(spec=Console) app = App(error_console=error_console_mock) @app.default def main(name: str): pass with pytest.raises(UnknownOptionError): app.parse_args("--invalid", exit_on_error=False, print_error=True) # Error should use error_console error_console_mock.print.assert_called() def test_unused_tokens_error_to_stderr(capfd): """Test that UnusedCliTokensError goes to stderr.""" app = App() @app.command def foo(): pass with pytest.raises(SystemExit): app("foo bar", exit_on_error=True, print_error=True) captured = capfd.readouterr() assert captured.err != "" # Error should be on stderr assert "Unused" in captured.err or "bar" in captured.err assert captured.out == "" # Nothing on stdout def test_normal_output_to_stdout(capfd): """Test that normal command output goes to stdout.""" app = App() @app.default def main(name: str = "World"): return f"Hello, {name}!" app([], result_action="return_none") captured = capfd.readouterr() # The command runs successfully, no errors assert captured.err == "" def test_error_console_default_is_stderr(): """Test that default error_console writes to stderr.""" app = App() # Access error_console to trigger its creation error_console = app.error_console # Verify it's configured for stderr assert error_console.file == sys.stderr def test_console_default_is_stdout(): """Test that default console writes to stdout.""" app = App() # Access console to trigger its creation console = app.console # Verify it's configured for stdout (default) assert console.file == sys.stdout BrianPugh-cyclopts-921b1fa/tests/test_error_formatter.py000066400000000000000000000100461517576204000236350ustar00rootroot00000000000000import textwrap from io import StringIO import pytest from rich.console import Console import cyclopts from cyclopts import CoercionError def test_error_formatter_default_none(): app = cyclopts.App() assert app.error_formatter is None def test_error_formatter_custom(): """A custom error_formatter replaces CycloptsPanel output.""" buf = StringIO() error_console = Console( file=buf, width=70, force_terminal=True, highlight=False, color_system=None, legacy_windows=False ) def my_formatter(e): return f"error: {e}" app = cyclopts.App(error_formatter=my_formatter, result_action="return_value") @app.default def main(value: int): pass with pytest.raises(CoercionError): app.parse_args("abc", exit_on_error=False, error_console=error_console) assert buf.getvalue() == 'error: Invalid value for "VALUE": unable to convert "abc" into int.\n' def test_error_formatter_none_uses_cyclopts_panel(): """When error_formatter is None, the default CycloptsPanel is used.""" buf = StringIO() error_console = Console( file=buf, width=70, force_terminal=True, highlight=False, color_system=None, legacy_windows=False ) app = cyclopts.App(result_action="return_value") @app.default def main(value: int): pass with pytest.raises(CoercionError): app.parse_args("abc", exit_on_error=False, error_console=error_console) expected = textwrap.dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Invalid value for "VALUE": unable to convert "abc" into int. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert buf.getvalue() == expected def test_error_formatter_runtime_override(): """error_formatter can be passed as a runtime argument to parse_args.""" buf = StringIO() error_console = Console( file=buf, width=70, force_terminal=True, highlight=False, color_system=None, legacy_windows=False ) def my_formatter(e): return f"custom: {e}" app = cyclopts.App(result_action="return_value") @app.default def main(value: int): pass with pytest.raises(CoercionError): app.parse_args("abc", exit_on_error=False, error_console=error_console, error_formatter=my_formatter) assert buf.getvalue() == 'custom: Invalid value for "VALUE": unable to convert "abc" into int.\n' def test_error_formatter_inherited_by_subcommand(): """Subcommands inherit error_formatter from the parent app.""" buf = StringIO() error_console = Console( file=buf, width=120, force_terminal=True, highlight=False, color_system=None, legacy_windows=False ) def my_formatter(e): return f"inherited: {e}" app = cyclopts.App(error_formatter=my_formatter, result_action="return_value") @app.command def sub(value: int): pass with pytest.raises(CoercionError): app.parse_args("sub abc", exit_on_error=False, error_console=error_console) assert buf.getvalue() == 'inherited: Invalid value for "VALUE": unable to convert "abc" into int.\n' def test_error_formatter_call(): """error_formatter works when using __call__ instead of parse_args.""" buf = StringIO() error_console = Console( file=buf, width=70, force_terminal=True, highlight=False, color_system=None, legacy_windows=False ) def my_formatter(e): return f"call: {e}" app = cyclopts.App(error_formatter=my_formatter, result_action="return_value") @app.default def main(value: int): pass with pytest.raises(SystemExit): app("abc", error_console=error_console) assert buf.getvalue() == 'call: Invalid value for "VALUE": unable to convert "abc" into int.\n' BrianPugh-cyclopts-921b1fa/tests/test_exceptions.py000066400000000000000000000366051517576204000226130ustar00rootroot00000000000000from dataclasses import dataclass from textwrap import dedent from typing import Annotated import pytest from cyclopts import ( App, Argument, ArgumentOrderError, CoercionError, MissingArgumentError, MixedArgumentError, Parameter, Token, UnknownCommandError, ValidationError, ) def positive_validator(type_, value): if value <= 0: # Seeing if we can translate a ValueError into a ValidationError as helpfully as possible. raise ValueError("Value must be positive.") def multi_positive_validator(type_, values): for value in values: if value <= 0: raise ValueError("Value must be positive.") def test_exceptions_missing_argument_single(app, console): @app.command def foo(bar: int): pass with console.capture() as capture, pytest.raises(MissingArgumentError): app("foo", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Command "foo" parameter "--bar" requires an argument. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_exceptions_missing_argument_flag(app, console): @app.command def foo(bar: bool): pass with console.capture() as capture, pytest.raises(MissingArgumentError): app("foo", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Command "foo" parameter "--bar" flag required. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_exceptions_missing_argument_with_short_flag(app, console): """Error message should reference the flag actually used, not the canonical name.""" @app.command def foo(option: Annotated[int, Parameter(alias="-o")]): pass with console.capture() as capture, pytest.raises(MissingArgumentError): # Use just -o without a value (GNU-style -o1 now works) app("foo -o", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Command "foo" parameter "-o" requires an argument. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_exceptions_validation_error_cli_single_positional(app, console): argument = Argument( hint=int, parameter=Parameter(name=("--bar",), validator=positive_validator), tokens=[ Token(keyword=None, value="-2", source="cli"), ], ) with pytest.raises(ValidationError) as e: argument.convert_and_validate() expected = dedent( """ ValidationError Invalid value "-2" for "BAR". Value must be positive. """ ).strip() assert str(e.value) == expected def test_exceptions_validation_error_cli_single_keyword(app, console): argument = Argument( hint=int, parameter=Parameter(name=("--bar",), validator=positive_validator), tokens=[ Token(keyword="--bar", value="-2", source="cli"), ], ) with pytest.raises(ValidationError) as e: argument.convert_and_validate() expected = dedent( """ ValidationError Invalid value "-2" for "--bar". Value must be positive. """ ).strip() assert str(e.value) == expected def test_exceptions_validation_error_class(app, console): """Double checks that error message is appropriately handled for class validators. https://github.com/BrianPugh/cyclopts/issues/432 """ def v(type_, value): raise ValueError("My custom message.") @Parameter(validator=v) @dataclass class Movie: title: str year: int @app.command def add(movie: Movie): pass with pytest.raises(ValidationError) as e: app("add foo 2020", exit_on_error=False) expected = """My custom message.""" assert str(e.value) == expected def test_exceptions_validation_error_non_cli_single_keyword(app, console): argument = Argument( hint=int, parameter=Parameter(name=("--bar",), validator=positive_validator), tokens=[ Token(value="-2", source="test"), ], ) with pytest.raises(ValidationError) as e: argument.convert_and_validate() expected = dedent( """ ValidationError Invalid value "-2" for "BAR" provided by "test". Value must be positive. """ ).strip() assert str(e.value) == expected def test_exceptions_validation_error_cli_multi_positional(app, console): argument = Argument( hint=tuple[int, int], parameter=Parameter(name=("--bar",), validator=multi_positive_validator), tokens=[ Token(keyword=None, value="100", source="cli"), Token(keyword=None, value="-2", source="cli"), ], ) with pytest.raises(ValidationError) as e: argument.convert_and_validate() expected = dedent( """ ValidationError Invalid value "(100, -2)" for "BAR". Value must be positive. """ ).strip() assert str(e.value) == expected def test_exceptions_validation_error_cli_multi_keyword(app, console): argument = Argument( hint=tuple[int, int], parameter=Parameter(name=("--bar",), validator=multi_positive_validator), tokens=[ Token(keyword="--bar", value="100", source="cli"), Token(keyword="--bar", value="-2", source="cli"), ], ) with pytest.raises(ValidationError) as e: argument.convert_and_validate() expected = dedent( """ ValidationError Invalid value "(100, -2)" for "--bar". Value must be positive. """ ).strip() assert str(e.value) == expected def test_exceptions_coercion_error_from_positional_cli(app, console): @app.command def foo(bar: int): pass with console.capture() as capture, pytest.raises(CoercionError): app("foo fizz", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Invalid value for "BAR": unable to convert "fizz" into int. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_exceptions_coercion_error_from_keyword_cli(app, console): @app.command def foo(bar: Annotated[int, Parameter(name=("--bar", "-b"))]): pass with console.capture() as capture, pytest.raises(CoercionError): app("foo -b fizz", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Invalid value for "-b": unable to convert "fizz" into int. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_exceptions_coercion_error_verbose(app, console): @app.command def foo(bar: int): pass with console.capture() as capture, pytest.raises(CoercionError): app("foo fizz", error_console=console, exit_on_error=False, verbose=True) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ CoercionError │ """ ) assert actual.startswith(expected) expected = dedent( """\ │ foo(bar: int) │ │ Root Input Tokens: ['foo', 'fizz'] │ │ Invalid value for "BAR": unable to convert "fizz" into int. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual.endswith(expected) def test_exceptions_mixed_argument_error(app, console): @app.default def foo(bar: int | dict): pass with console.capture() as capture, pytest.raises(MixedArgumentError): app("--bar 5 --bar.baz fizz", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Cannot supply keyword & non-keyword arguments to "--bar". │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_exceptions_unknown_command(app, console): @app.command def foo(bar: int): pass with console.capture() as capture, pytest.raises(UnknownCommandError): app("bar fizz", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Unknown command "bar". Available commands: foo. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_exceptions_argument_order_error_singular(app, console): @app.command def foo(a, b, c): pass with console.capture() as capture, pytest.raises(ArgumentOrderError): app("foo --b=5 1 2", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Cannot specify token '2' positionally for parameter 'c' due to │ │ previously specified keyword '--b'. '--b' must either be passed │ │ positionally, or '2' must be passed as a keyword to '--c'. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_exceptions_argument_order_error_plural(app, console): @app.command def foo(a, b, c): pass with console.capture() as capture, pytest.raises(ArgumentOrderError): app("foo --a=1 --b=5 3", error_console=console, exit_on_error=False) actual = capture.get() expected = dedent( """\ ╭─ Error ────────────────────────────────────────────────────────────╮ │ Cannot specify token '3' positionally for parameter 'c' due to │ │ previously specified keywords ['--a', '--b']. ['--a', '--b'] must │ │ either be passed positionally, or '3' must be passed as a keyword │ │ to '--c'. │ ╰────────────────────────────────────────────────────────────────────╯ """ ) assert actual == expected def test_exceptions_combined_short_option_error(app, console): """With GNU-style support, -bo is now valid: -b with value 'o'. The CombinedShortOptionError for mixing value-taking options is no longer possible because the first value-taking option consumes the rest of the string as its value. This test now verifies that GNU-style combinations work correctly. """ @app.command def foo( *, bar: Annotated[str, Parameter(name=("--bar", "-b"))], ): pass # -bo is now valid GNU-style: -b with value "o" _, bound, _ = app.parse_args("foo -bo", exit_on_error=False) assert bound.arguments["bar"] == "o" def test_unknown_option_starting_with_h(): """Test that --h (not --help) is treated as an unknown option, not a NameError. Previously, passing --h would trigger a NameError about Console not being defined, because it would try to inspect the help_print method's signature which had a forward reference to Console that wasn't available at runtime. From issue #697. """ app = App(exit_on_error=False) @app.default def foo(loops: int): for i in range(loops): print(f"Looping! {i}") # Should raise an error about unknown option, not NameError about Console with pytest.raises(Exception) as exc_info: app.parse_args(["--h"]) # Make sure it's not a NameError about Console assert not (isinstance(exc_info.value, NameError) and "Console" in str(exc_info.value)) # It should be an unknown option error assert "Unknown option" in str(exc_info.value) or "unknown" in str(exc_info.value).lower() BrianPugh-cyclopts-921b1fa/tests/test_future_annotations.py000066400000000000000000000056751517576204000243640ustar00rootroot00000000000000from __future__ import annotations from dataclasses import dataclass from typing import Annotated, NamedTuple, TypedDict import attrs from pydantic import BaseModel from cyclopts import Parameter def test_future_annotations_basic(app, assert_parse_args): @app.default def default(value: str): pass assert_parse_args(default, "foo", "foo") # TODO: Resolving stringified type-hinted class with closure/local scope # is really hard. @dataclass class DataclassMovie: title: str year: int def test_future_annotations_dataclass(app, assert_parse_args): """ https://github.com/BrianPugh/cyclopts/issues/352 """ @app.command def add(movie: DataclassMovie): print(f"Adding movie: {movie}") assert_parse_args(add, "add BladeRunner 1982", DataclassMovie("BladeRunner", 1982)) # This is unrelated to "from __future__ import annotations", but it's a very similar problem class GenericMovie: def __init__(self, title: str, year: int): self.title = title self.year = year def __eq__(self, other): if not isinstance(other, type(self)): return False return self.title == other.title and self.year == other.year def test_future_annotations_generic_class(app, assert_parse_args): @app.command def add(movie: Annotated[GenericMovie, Parameter(accepts_keys=True)]): print(f"Adding movie: {movie}") assert_parse_args(add, "add BladeRunner 1982", GenericMovie("BladeRunner", 1982)) @attrs.define class AttrsMovie: title: str year: int def test_future_annotations_attrs_movie(app, assert_parse_args): @app.command def add(movie: Annotated[AttrsMovie, Parameter(accepts_keys=True)]): print(f"Adding movie: {movie}") assert_parse_args(add, "add BladeRunner 1982", AttrsMovie("BladeRunner", 1982)) class TypedDictMovie(TypedDict): title: str year: int def test_future_annotations_typed_dict_movie(app, assert_parse_args): @app.command def add(movie: Annotated[TypedDictMovie, Parameter(accepts_keys=True)]): print(f"Adding movie: {movie}") assert_parse_args( add, "add --movie.title=BladeRunner --movie.year=1982", TypedDictMovie(title="BladeRunner", year=1982) ) class NamedTupleMovie(NamedTuple): title: str year: int def test_future_annotations_named_tuple_movie(app, assert_parse_args): @app.command def add(movie: Annotated[NamedTupleMovie, Parameter(accepts_keys=True)]): print(f"Adding movie: {movie}") assert_parse_args(add, "add BladeRunner 1982", NamedTupleMovie(title="BladeRunner", year=1982)) class PydanticMovie(BaseModel): title: str year: int def test_future_annotations_pydantic_movie(app, assert_parse_args): @app.command def add(movie: Annotated[PydanticMovie, Parameter(accepts_keys=True)]): print(f"Adding movie: {movie}") assert_parse_args(add, "add BladeRunner 1982", PydanticMovie(title="BladeRunner", year=1982)) BrianPugh-cyclopts-921b1fa/tests/test_fuzzy_command_matching.py000066400000000000000000000150751517576204000251670ustar00rootroot00000000000000"""Tests for fuzzy command matching (backward compatibility feature). Should probably be removed in v5. """ import pytest from cyclopts import App from cyclopts.exceptions import UnknownCommandError @pytest.mark.parametrize( "func_name,expected_result,test_inputs", [ ( "myCommand", "myCommand executed", ["my-command", "mycommand", "my_command", "MyCommand", "MYCOMMAND"], ), ( "my_command", "my_command executed", ["my-command", "mycommand", "my_command", "MY-COMMAND"], ), ( "HTTPServer", "HTTPServer executed", ["http-server", "httpserver", "http_server", "HTTPSERVER"], ), ], ) def test_fuzzy_command_matching_basic(app, func_name, expected_result, test_inputs): """Fuzzy matching allows commands to be invoked with various styles.""" def command_func(): return expected_result command_func.__name__ = func_name app.command(command_func) for input_cmd in test_inputs: assert app([input_cmd]) == expected_result def test_fuzzy_command_matching_no_false_positives(app): """Fuzzy matching doesn't match unrelated commands.""" @app.command def my_command(): return "my_command" @app.command def other_command(): return "other_command" # These should NOT match with pytest.raises(UnknownCommandError, match="myother"): app.parse_args(["myother"], exit_on_error=False) with pytest.raises(UnknownCommandError, match="commandmy"): app.parse_args(["commandmy"], exit_on_error=False) def test_fuzzy_command_matching_exact_takes_precedence(app): """Exact matches are preferred over fuzzy matches.""" @app.command(name="my-command") def cmd1(): return "cmd1" @app.command(name="mycommand") def cmd2(): return "cmd2" # Exact match wins assert app(["my-command"]) == "cmd1" assert app(["mycommand"]) == "cmd2" def test_fuzzy_command_matching_ambiguous_error(app): """Ambiguous fuzzy matches raise clear errors.""" @app.command(name="my-command") def cmd1(): return "cmd1" @app.command(name="mycommand") def cmd2(): return "cmd2" # Both normalize to 'mycommand', so this is ambiguous with pytest.raises(ValueError, match="Ambiguous command 'my_command'.*my-command.*mycommand"): app.parse_args(["my_command"], exit_on_error=False) @pytest.mark.parametrize( "input_cmd,user_id,expected", [ ("get-user-data", "123", "User 123"), ("getuserdata", "456", "User 456"), ("get_user_data", "789", "User 789"), ("GetUserData", "999", "User 999"), ], ) def test_fuzzy_command_matching_with_arguments(app, input_cmd, user_id, expected): """Fuzzy matching works with commands that have arguments.""" @app.command def getUserData(user_id: int): # noqa: N802 return f"User {user_id}" assert app([input_cmd, user_id]) == expected @pytest.mark.parametrize( "command_name,input_variants,expected", [ ( "buildProject", ["buildproject", "build_project", "build-project"], "building", ), ( "testProject", ["testproject", "testProject", "test-project"], "testing", ), ( "deployProduction", ["deployproduction", "deploy_production", "deploy-production"], "deploying", ), ], ) def test_fuzzy_command_matching_multiple_commands(app, command_name, input_variants, expected): """Fuzzy matching works with multiple commands.""" def command_func(): return expected command_func.__name__ = command_name app.command(command_func) for input_cmd in input_variants: assert app([input_cmd]) == expected @pytest.mark.parametrize( "command_name,input_variants", [ ("cmd1", ["cmd1", "CMD1", "Cmd1"]), ("cmd2", ["cmd2", "CMD2", "Cmd2"]), ], ) def test_fuzzy_command_matching_preserves_cmd_digit_behavior(app, command_name, input_variants): """Fuzzy matching doesn't break cmd1, cmd2, etc (no lowercase-digit split).""" def command_func(): return command_name command_func.__name__ = command_name app.command(command_func) for input_cmd in input_variants: assert app([input_cmd]) == command_name @pytest.mark.parametrize( "subcommand,input_variants,expected", [ ("createTable", ["createtable", "create_table", "create-table"], "table created"), ("dropTable", ["droptable", "drop_table", "drop-table"], "table dropped"), ], ) def test_fuzzy_command_matching_nested_commands(app, subcommand, input_variants, expected): """Fuzzy matching works with nested commands.""" sub_app = App() def command_func(): return expected command_func.__name__ = subcommand sub_app.command(command_func) app.command(sub_app, name="database") for input_cmd in input_variants: assert app(["database", input_cmd]) == expected def test_fuzzy_matching_excludes_flags_issue_718(app): """Fuzzy matching should NOT match bare words to flags like --version or --help. Regression test for issue #718: Running 'app version' (without --) should NOT match the '--version' flag via fuzzy matching. """ @app.command def hello(): return "hello" # "version" should NOT match "--version" via fuzzy matching with pytest.raises(UnknownCommandError, match="version"): app.parse_args(["version"], exit_on_error=False) # "help" should NOT match "--help" via fuzzy matching with pytest.raises(UnknownCommandError, match="help"): app.parse_args(["help"], exit_on_error=False) # But actual flags should still work # Note: --help and --version cause the app to exit, so we check parse_commands instead command_chain, apps, unused = app.parse_commands(["--version"]) assert "--version" in command_chain command_chain, apps, unused = app.parse_commands(["--help"]) assert "--help" in command_chain def test_fuzzy_matching_excludes_short_flags(app): """Fuzzy matching should also exclude short flags like -v and -h.""" @app.command def hello(): return "hello" # "v" should NOT match "-v" via fuzzy matching with pytest.raises(UnknownCommandError, match="v"): app.parse_args(["v"], exit_on_error=False) # "h" should NOT match "-h" via fuzzy matching with pytest.raises(UnknownCommandError, match="h"): app.parse_args(["h"], exit_on_error=False) BrianPugh-cyclopts-921b1fa/tests/test_generate_docs.py000066400000000000000000000646331517576204000232360ustar00rootroot00000000000000"""Tests for App.generate_docs() method.""" import tempfile from pathlib import Path from textwrap import dedent from typing import Annotated import pytest from cyclopts import App, Parameter def test_generate_docs_simple_app(): """Test basic documentation generation for a simple app.""" app = App(name="myapp", help="A simple CLI application") @app.default def main(name: str, verbose: bool = False): """Main command. Parameters ---------- name : str Your name. verbose : bool Enable verbose output. """ pass actual = app.generate_docs() expected = dedent( """\ # myapp ```console myapp NAME [ARGS] ``` A simple CLI application **Parameters**: * `NAME, --name`: Your name. **[required]** * `VERBOSE, --verbose, --no-verbose`: Enable verbose output. *[default: False]* """ ) assert actual == expected def test_generate_docs_with_commands(): """Test documentation generation with subcommands.""" app = App(name="myapp", help="CLI with commands") @app.command def serve(port: int = 8000): """Start the server. Parameters ---------- port : int Port number. """ pass @app.command def build(output: str = "./dist"): """Build the project. Parameters ---------- output : str Output directory. """ pass actual = app.generate_docs() # Check structure without being too specific about Usage lines that vary by context assert "# myapp" in actual assert "CLI with commands" in actual # Main usage section assert "myapp COMMAND" in actual # Commands list with hyperlinks assert "**Commands**:" in actual assert "* [`build`](#myapp-build): Build the project." in actual assert "* [`serve`](#myapp-serve): Start the server." in actual # Serve command details (now shows full path) assert "## myapp serve" in actual assert "Start the server." in actual assert "**Parameters**:" in actual assert "* `PORT, --port`: Port number. *[default: 8000]*" in actual # Build command details (now shows full path) assert "## myapp build" in actual assert "Build the project." in actual assert "* `OUTPUT, --output`: Output directory. *[default: ./dist]*" in actual def test_generate_docs_recursive(): """Test recursive documentation generation.""" app = App(name="myapp", help="Main app") subapp = App(name="db", help="Database commands") @subapp.command def migrate(): """Run database migrations.""" pass @subapp.command def backup(output: str): """Backup the database. Parameters ---------- output : str Backup file path. """ pass app.command(subapp) actual = app.generate_docs(recursive=True) # Verify structure of recursive documentation assert "# myapp" in actual assert "Main app" in actual assert "## myapp db" in actual assert "Database commands" in actual assert "migrate" in actual assert "backup" in actual assert "Run database migrations" in actual assert "Backup the database" in actual def test_generate_docs_non_recursive(): """Test non-recursive documentation generation.""" app = App(name="myapp", help="Main app") subapp = App(name="db", help="Database commands") @subapp.command def migrate(): """Run database migrations.""" pass app.command(subapp) actual = app.generate_docs(recursive=False) # Note: In non-recursive mode, subcommands like 'migrate' don't get headings, # so they appear in the ToC but the link is broken. This is a known limitation. # The current implementation always collects ToC entries recursively. # Note: The 'db' subcommand doesn't show usage because it has no default_command. expected = dedent( """\ # myapp ```console myapp COMMAND ``` Main app ## Table of Contents - [`db`](#myapp-db) - [`migrate`](#myapp-db-migrate) **Commands**: * [`db`](#myapp-db): Database commands ## myapp db Database commands """ ) assert actual == expected def test_generate_docs_with_hidden_commands(mocker): """Test documentation with hidden commands.""" # Mock sys.argv[0] for consistent output mocker.patch("sys.argv", ["myapp"]) app = App(name="myapp", help="App with hidden commands") @app.command def visible(): """Visible command.""" pass @app.command(show=False) def hidden(): """Hidden command.""" pass # Test WITHOUT include_hidden actual_without_hidden = app.generate_docs(include_hidden=False) expected_without_hidden = dedent( """\ # myapp ```console myapp COMMAND ``` App with hidden commands ## Table of Contents - [`visible`](#myapp-visible) **Commands**: * [`visible`](#myapp-visible): Visible command. ## myapp visible ```console myapp visible ``` Visible command. """ ) assert actual_without_hidden == expected_without_hidden # Test WITH include_hidden actual_with_hidden = app.generate_docs(include_hidden=True) # Verify the hidden command is present when include_hidden=True assert "## myapp hidden" in actual_with_hidden assert "Hidden command." in actual_with_hidden # Note: --help and --version are builtin flags, not commands, so they # should not appear in the Commands section even with include_hidden=True def test_generate_docs_with_required_parameters(): """Test documentation with required parameters.""" app = App(name="myapp") @app.default def main( required: Annotated[str, Parameter(help="Required parameter")], optional: str = "default", ): """Main command.""" pass actual = app.generate_docs() expected = dedent( """\ # myapp ```console myapp REQUIRED [ARGS] ``` Main command. **Parameters**: * `REQUIRED, --required`: Required parameter **[required]** * `OPTIONAL, --optional`: *[default: default]* """ ) assert actual == expected def test_generate_docs_with_choices(): """Test documentation with parameter choices.""" from enum import Enum app = App(name="myapp") class Color(Enum): RED = "red" GREEN = "green" BLUE = "blue" @app.default def main(color: Color = Color.RED): """Choose a color.""" pass actual = app.generate_docs() # Should show available choices in the documentation assert "red" in actual assert "green" in actual assert "blue" in actual assert "choices:" in actual def test_generate_docs_with_custom_usage(): """Test documentation with custom usage string.""" app = App(name="myapp", usage="myapp [OPTIONS] ") @app.default def main(): """Main command.""" pass actual = app.generate_docs() expected = dedent( """\ # myapp ```console myapp [OPTIONS] ``` Main command. """ ) assert actual == expected def test_generate_docs_no_usage(): """Test documentation with suppressed usage.""" app = App(name="myapp", usage="") # Empty string suppresses usage @app.default def main(): """Main command.""" pass actual = app.generate_docs() expected = dedent( """\ # myapp Main command. """ ) assert actual == expected def test_generate_docs_write_to_file(): """Test writing documentation to a file.""" app = App(name="myapp", help="Test app") @app.default def main(): """Main command.""" pass with tempfile.TemporaryDirectory() as tmpdir: output_path = Path(tmpdir) / "docs" / "cli.md" # Generate docs actual = app.generate_docs() expected = dedent( """\ # myapp ```console myapp ``` Test app """ ) # Write to file manually output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(actual) # Check file was created and contains expected content assert output_path.exists() content = output_path.read_text() assert content == expected # Should return the expected content assert actual == expected def test_generate_docs_output_format_explicit(): """Test explicitly specifying output format.""" app = App(name="myapp", help="Test app") @app.default def main(): """Main command.""" pass # Explicitly specify markdown format actual = app.generate_docs(output_format="markdown") expected = dedent( """\ # myapp ```console myapp ``` Test app """ ) assert actual == expected def test_generate_docs_output_format_markdown(): """Test generating markdown format documentation.""" app = App(name="myapp", help="Test app") @app.default def main(): """Main command.""" pass # Test markdown format (default) docs_md = app.generate_docs() assert "# myapp" in docs_md # Test explicitly specifying markdown docs_explicit = app.generate_docs(output_format="markdown") assert "# myapp" in docs_explicit assert docs_md == docs_explicit def test_generate_docs_invalid_format(): """Test that invalid output format raises ValueError.""" app = App(name="myapp", help="Test app") with pytest.raises(ValueError, match='Unsupported format "pdf"'): app.generate_docs(output_format="pdf") # type: ignore[arg-type] with pytest.raises(ValueError, match='Unsupported format "invalid"'): app.generate_docs(output_format="invalid") # type: ignore[arg-type] def test_generate_docs_with_heading_levels(): """Test custom heading levels.""" app = App(name="myapp", help="Main app") @app.command def cmd(): """A command.""" pass actual = app.generate_docs(heading_level=2) # Check the heading levels assert "## myapp" in actual assert "Main app" in actual assert "**Commands**:" in actual assert "### myapp cmd" in actual def test_generate_docs_complex_nested_app(): """Test complex nested application documentation.""" app = App(name="cli", help="Complex CLI tool") # Add top-level command @app.command def version(): """Show version information.""" pass # Create nested subapp git = App(name="git", help="Git operations") @git.command def clone( url: Annotated[str, Parameter(help="Repository URL")], depth: int = 1, ): """Clone a repository. Parameters ---------- url : str The repository URL to clone. depth : int Clone depth. """ pass @git.command def push(force: bool = False): """Push changes. Parameters ---------- force : bool Force push. """ pass app.command(git) docs = app.generate_docs(recursive=True) # Check structure assert "# cli" in docs assert "Complex CLI tool" in docs assert "version" in docs assert "Show version information" in docs assert "## cli git" in docs assert "Git operations" in docs assert "clone" in docs assert "push" in docs assert "Clone a repository" in docs assert "Push changes" in docs assert "Repository URL" in docs assert "Force push" in docs def test_generate_docs_with_aliases(): """Test documentation with command aliases.""" app = App(name="myapp") @app.command(alias=["s", "srv"]) def serve(): """Start the server.""" pass docs = app.generate_docs() assert "serve" in docs assert "Start the server" in docs # Note: Aliases might not be shown in current implementation # This is acceptable as the main command name is shown def test_generate_docs_with_meta_app(): """Test documentation generation with meta app.""" from typing import Annotated app = App(name="myapp", help="Main application") @app.default def main(input_file: str): """Process a file. Parameters ---------- input_file : str Input file path. """ pass @app.meta.default def meta( verbose: bool = False, config: Annotated[str | None, Parameter(help="Config file")] = None, ): """Meta app for global options. Parameters ---------- verbose : bool Enable verbose output. config : str Configuration file path. """ pass actual = app.generate_docs() expected = dedent( """\ # myapp ```console myapp INPUT-FILE [ARGS] ``` Main application **Parameters**: * `INPUT-FILE, --input-file`: Input file path. **[required]** * `VERBOSE, --verbose, --no-verbose`: Enable verbose output. *[default: False]* * `CONFIG, --config`: Config file """ ) assert actual == expected def test_generate_docs_meta_app_with_commands(): """Test documentation with meta app and subcommands.""" app = App(name="myapp", help="Main app with meta") @app.meta.default def meta( debug: bool = False, ): """Global meta options. Parameters ---------- debug : bool Enable debug mode. """ pass @app.command def serve(port: int = 8000): """Start the server. Parameters ---------- port : int Port number. """ pass @app.command def build(): """Build the project.""" pass actual = app.generate_docs() # Meta parameters should appear with the main app assert "debug" in actual.lower() or "DEBUG" in actual assert "Enable debug mode" in actual # Commands should still appear assert "serve" in actual assert "build" in actual assert "Start the server" in actual assert "Build the project" in actual def test_generate_docs_nested_meta_apps(): """Test documentation with nested apps that have their own meta apps.""" # Create a subapp with its own meta db_app = App(name="db", help="Database commands") @db_app.meta.default def db_meta( connection: str = "sqlite:///:memory:", ): """Database meta options. Parameters ---------- connection : str Database connection string. """ pass @db_app.command def migrate(): """Run database migrations.""" pass # Main app with its own meta app = App(name="myapp", help="Main application") @app.meta.default def main_meta( verbose: bool = False, ): """Global options. Parameters ---------- verbose : bool Verbose output. """ pass # Register the db app with its meta app.command(db_app.meta, name="db") docs = app.generate_docs(recursive=True) # Check main app meta parameters appear assert "verbose" in docs.lower() or "VERBOSE" in docs assert "Verbose output" in docs # Check db subcommand appears assert "## myapp db" in docs # When registering db_app.meta, it uses the meta's docstring assert "Database meta options" in docs # Check nested db commands appear when recursive assert "migrate" in docs assert "Run database migrations" in docs # Check db meta parameters appear with db command assert "connection" in docs.lower() or "CONNECTION" in docs assert "Database connection string" in docs def test_generate_docs_flatten_commands(): """Test flatten_commands option for markdown documentation.""" app = App(name="myapp", help="Main app") sub1 = App(name="sub1", help="First subcommand") @sub1.command def nested1(): """Nested command 1.""" pass @sub1.command def nested2(): """Nested command 2.""" pass sub2 = App(name="sub2", help="Second subcommand") @sub2.command def nested3(): """Nested command 3.""" pass app.command(sub1) app.command(sub2) # Without flatten_commands - hierarchical headings (but full paths to avoid collisions) docs_hierarchical = app.generate_docs(flatten_commands=False) # Main app should be h1 assert "# myapp" in docs_hierarchical # Subcommands should be h2 with full paths assert "## myapp sub1" in docs_hierarchical assert "## myapp sub2" in docs_hierarchical # Nested commands should be h3 (properly nested under their parent) with full paths assert "### myapp sub1 nested1" in docs_hierarchical assert "### myapp sub1 nested2" in docs_hierarchical assert "### myapp sub2 nested3" in docs_hierarchical # With flatten_commands - all at same level docs_flat = app.generate_docs(flatten_commands=True) # Main app should be h1 assert "# myapp" in docs_flat # All subcommands should also be h1 (flattened) with full paths assert "# myapp sub1" in docs_flat assert "# myapp sub2" in docs_flat # All nested commands should also be h1 (flattened) with full paths assert "# myapp sub1 nested1" in docs_flat assert "# myapp sub1 nested2" in docs_flat assert "# myapp sub2 nested3" in docs_flat # Should NOT have h2 command headings when flattened assert "## myapp sub" not in docs_flat def test_generate_docs_toc_anchors_flattened(): """Test that TOC anchors match actual heading anchors in flattened mode.""" app = App(name="myapp", help="My app") sub1 = App(name="sub1", help="First subcommand") @sub1.command def nested1(): """Nested command 1.""" pass @sub1.command def nested2(): """Nested command 2.""" pass sub2 = App(name="sub2", help="Second subcommand") @sub2.command def nested3(): """Nested command 3.""" pass app.command(sub1) app.command(sub2) docs = app.generate_docs(flatten_commands=True) # In flattened mode, TOC anchors should include full path with app name assert "- [`sub1`](#myapp-sub1)" in docs assert "- [`nested1`](#myapp-sub1-nested1)" in docs assert "- [`nested2`](#myapp-sub1-nested2)" in docs assert "- [`sub2`](#myapp-sub2)" in docs assert "- [`nested3`](#myapp-sub2-nested3)" in docs # Verify headings exist that will generate these anchors (all show full paths) assert "# myapp sub1" in docs assert "# myapp sub1 nested1" in docs assert "# myapp sub1 nested2" in docs assert "# myapp sub2" in docs assert "# myapp sub2 nested3" in docs def test_generate_docs_toc_anchors_hierarchical(): """Test that TOC anchors match actual heading anchors in hierarchical mode.""" app = App(name="myapp", help="My app") sub1 = App(name="sub1", help="First subcommand") @sub1.command def nested1(): """Nested command 1.""" pass @sub1.command def nested2(): """Nested command 2.""" pass sub2 = App(name="sub2", help="Second subcommand") @sub2.command def nested3(): """Nested command 3.""" pass app.command(sub1) app.command(sub2) docs = app.generate_docs(flatten_commands=False) # TOC anchors use full paths with app name to avoid collisions (e.g., files.cp vs other.cp) assert "- [`sub1`](#myapp-sub1)" in docs assert "- [`nested1`](#myapp-sub1-nested1)" in docs assert "- [`nested2`](#myapp-sub1-nested2)" in docs assert "- [`sub2`](#myapp-sub2)" in docs assert "- [`nested3`](#myapp-sub2-nested3)" in docs # Verify headings exist that will generate these anchors (now showing full paths) assert "## myapp sub1" in docs assert "### myapp sub1 nested1" in docs assert "### myapp sub1 nested2" in docs assert "## myapp sub2" in docs assert "### myapp sub2 nested3" in docs def test_generate_docs_toc_anchor_collisions(): """Test that headings with same command name don't collide (full paths prevent this).""" app = App(name="myapp", help="My app") sub1 = App(name="sub1", help="First subcommand") @sub1.command(name="create") def create1(): """Create via sub1.""" pass @sub1.command(name="delete") def delete1(): """Delete via sub1.""" pass sub2 = App(name="sub2", help="Second subcommand") @sub2.command(name="create") def create2(): """Create via sub2.""" pass @sub2.command(name="delete") def delete2(): """Delete via sub2.""" pass app.command(sub1) app.command(sub2) docs = app.generate_docs(flatten_commands=False) # Headings now use full paths with app name, so no collisions occur assert "- [`sub1`](#myapp-sub1)" in docs assert " - [`create`](#myapp-sub1-create)" in docs assert " - [`delete`](#myapp-sub1-delete)" in docs assert "- [`sub2`](#myapp-sub2)" in docs assert " - [`create`](#myapp-sub2-create)" in docs assert " - [`delete`](#myapp-sub2-delete)" in docs # Verify the actual headings use full paths assert "### myapp sub1 create" in docs assert "### myapp sub1 delete" in docs assert "### myapp sub2 create" in docs assert "### myapp sub2 delete" in docs def test_generate_docs_nested_command_list_hyperlinks(): """Test that command list hyperlinks in nested apps use full command path. This tests that when a deeply nested command (e.g., app -> parent -> child -> grandchild) lists its subcommands in the **Commands**: section, the hyperlinks use the full path (e.g., #app-parent-child-grandchild) not just the app name and command (e.g., #child-grandchild). """ app = App(name="darts", help="Main app") training = App(name="training", help="Training commands") create_dataset = App(name="create-dataset", help="Dataset creation") @create_dataset.command def planet(): """Preprocess Planet data for training.""" pass @create_dataset.command def sentinel2(): """Preprocess Sentinel-2 data for training.""" pass training.command(create_dataset) app.command(training) docs = app.generate_docs() # The command list in create-dataset should have hyperlinks with the FULL path # NOT just #create-dataset-planet (wrong) but #darts-training-create-dataset-planet (correct) assert "* [`planet`](#darts-training-create-dataset-planet):" in docs assert "* [`sentinel2`](#darts-training-create-dataset-sentinel2):" in docs # Verify the headings exist with matching anchors assert "### darts training create-dataset" in docs assert "#### darts training create-dataset planet" in docs assert "#### darts training create-dataset sentinel2" in docs # Also verify the root-level command list (darts listing training) assert "* [`training`](#darts-training):" in docs def test_generate_docs_usage_name_overrides_root_usage(): """usage_name replaces the app name in the root Usage: line.""" app = App(name="cli", help="A CLI") @app.default def main(name: str = "world"): """Greet. Parameters ---------- name : str Name. """ pass actual = app.generate_docs(usage_name="uv run cli") assert "# cli" in actual # heading unchanged usage_block = actual.split("```console")[1].split("```")[0].strip() first_line = next(line for line in usage_block.splitlines() if line.strip()) assert first_line.startswith("uv run cli"), f"usage line: {first_line!r}" def test_generate_docs_usage_name_overrides_subcommand_usage(): """usage_name prefixes every subcommand's Usage: line too.""" app = App(name="cli", help="A CLI") @app.command def serve(port: int = 8000): """Start the server. Parameters ---------- port : int Port. """ pass actual = app.generate_docs(usage_name="uv run cli") # Subcommand heading still uses plain app name assert "## cli serve" in actual # TOC anchor unchanged assert "](#cli-serve)" in actual # Every console block begins with "uv run cli" for block in actual.split("```console")[1:]: body = block.split("```")[0].strip() first_line = next(line for line in body.splitlines() if line.strip()) assert first_line.startswith("uv run cli"), f"usage line should start with 'uv run cli': {first_line!r}" def test_generate_docs_usage_name_none_is_default_behavior(): """Passing usage_name=None (default) produces identical output to omitting it.""" app = App(name="cli", help="A CLI") @app.default def main(): """Entry.""" pass assert app.generate_docs() == app.generate_docs(usage_name=None) @pytest.mark.parametrize("fmt", ["markdown", "rst", "html"]) def test_generate_docs_usage_name_applies_across_formats(fmt): """usage_name is accepted by every output format and affects its Usage: output.""" app = App(name="cli", help="A CLI") @app.default def main(): """Entry.""" pass output = app.generate_docs(output_format=fmt, usage_name="uv run cli") assert "uv run cli" in output def test_generate_docs_usage_name_empty_string_is_inserted_verbatim(): """Empty usage_name is inserted verbatim; the bare "cli" prefix is dropped.""" app = App(name="cli", help="A CLI") @app.command def serve(port: int = 8000): """Start the server. Parameters ---------- port : int Port. """ pass actual = app.generate_docs(usage_name="") # Heading untouched assert "## cli serve" in actual # Subcommand usage line no longer starts with "cli " — the root token has been replaced by "". subcommand_usage = actual.split("## cli serve")[1].split("```console")[1].split("```")[0].strip() first_line = next(line for line in subcommand_usage.splitlines() if line.strip()) assert not first_line.startswith("cli ") assert first_line.startswith("serve") BrianPugh-cyclopts-921b1fa/tests/test_generate_html_docs.py000066400000000000000000000275001517576204000242520ustar00rootroot00000000000000"""Tests for HTML documentation generation.""" import tempfile from pathlib import Path from typing import Annotated from cyclopts import App, Parameter def test_generate_html_docs_simple_app(): """Test basic HTML documentation generation for a simple app.""" app = App(name="myapp", help="A simple CLI application") @app.default def main(name: str, verbose: bool = False): """Main command. Parameters ---------- name : str Your name. verbose : bool Enable verbose output. """ pass docs = app.generate_docs(output_format="html") # Check HTML structure assert "" in docs assert "" in docs assert "myapp - CLI Documentation" in docs assert "