pax_global_header00006660000000000000000000000064152510413030014504gustar00rootroot0000000000000052 comment=20307928dd3895964fa28284cd7723f657e31ff4 biopragmatics-curies-aa8bd37/000077500000000000000000000000001525104130300163135ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/.cruft.json000066400000000000000000000016331525104130300204120ustar00rootroot00000000000000{ "template": "https://github.com/cthoyt/cookiecutter-snekpack", "commit": "e3a8928186124d29d96565e16894fae924b067f9", "checkout": null, "context": { "cookiecutter": { "package_name": "curies", "package_name_stylized": "CURIEs", "short_description": "Idiomatic conversion between URIs and compact URIs (CURIEs)", "author_name": "Charles Tapley Hoyt", "author_github": "cthoyt", "author_email": "cthoyt@gmail.com", "github_organization_name": "biopragmatics", "github_repository_name": "curies", "command_line_interface": true, "gitlab": false, "runner": "tox", "__runner": "tox -e", "__runner_uv": "--with tox-uv tox -e", "__runner_pip": "tox tox-uv", "__runner_tests": "py", "__gh_slug": "biopragmatics/curies", "_template": "https://github.com/cthoyt/cookiecutter-snekpack" } }, "directory": null } biopragmatics-curies-aa8bd37/.github/000077500000000000000000000000001525104130300176535ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/.github/CODE_OF_CONDUCT.md000066400000000000000000000125511525104130300224560ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at cthoyt@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations biopragmatics-curies-aa8bd37/.github/CONTRIBUTING.md000066400000000000000000000120671525104130300221120ustar00rootroot00000000000000# Contributing Contributions to this repository are welcomed and encouraged. ## Code Contribution This project uses the [GitHub Flow](https://guides.github.com/introduction/flow) model for code contributions. Follow these steps: 1. [Create a fork](https://help.github.com/articles/fork-a-repo) of the upstream repository at [`biopragmatics/curies`](https://github.com/biopragmatics/curies) on your GitHub account (or in one of your organizations) 2. [Clone your fork](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) with `git clone https://github.com//curies.git` 3. Make and commit changes to your fork with `git commit` 4. Push changes to your fork with `git push` 5. Repeat steps 3 and 4 as needed 6. Submit a pull request back to the upstream repository ### Merge Model This repository uses [squash merges](https://docs.github.com/en/github/collaborating-with-pull-requests/incorporating-changes-from-a-pull-request/about-pull-request-merges#squash-and-merge-your-pull-request-commits) to group all related commits in a given pull request into a single commit upon acceptance and merge into the main branch. This has several benefits: 1. Keeps the commit history on the main branch focused on high-level narrative 2. Enables people to make lots of small commits without worrying about muddying up the commit history 3. Commits correspond 1-to-1 with pull requests ### Code Style This project uses `tox` for running code quality checks. Start by installing it with `pip install tox tox-uv`. This project encourages the use of optional static typing. It uses [`mypy`](http://mypy-lang.org/) as a type checker. You can check if your code passes `mypy` with `tox -e mypy`. This project uses [`ruff`](https://docs.astral.sh/ruff/) to automatically enforce a consistent code style. You can apply `ruff format` and other pre-configured formatters with `tox -e format`. This project uses [`ruff`](https://docs.astral.sh/ruff/) and several plugins for additional checks of documentation style, security issues, good variable nomenclature, and more (see `pyproject.toml` for a list of Ruff plugins). You can check if your code passes `ruff check` with `tox -e lint`. Each of these checks are run on each commit using GitHub Actions as a continuous integration service. Passing all of them is required for accepting a contribution. If you're unsure how to address the feedback from one of these tools, please say so either in the description of your pull request or in a comment, and we will help you. ### Logging Python's builtin `print()` should not be used (except when writing to files), it's checked by the [`flake8-print` (T20)](https://docs.astral.sh/ruff/rules/#flake8-print-t20) plugin to `ruff`. If you're in a command line setting or `main()` function for a module, you can use `click.echo()`. Otherwise, you can use the builtin `logging` module by adding `logger = logging.getLogger(__name__)` below the imports at the top of your file. ### Documentation All public functions (i.e., not starting with an underscore `_`) must be documented using the [sphinx documentation format](https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html#the-sphinx-docstring-format). The [`darglint2`](https://github.com/akaihola/darglint2) tool reports on functions that are not fully documented. This project uses [`sphinx`](https://www.sphinx-doc.org) to automatically build documentation into a narrative structure. You can check that the documentation builds properly in an isolated environment with `tox -e docs-test` and actually build it locally with `tox -e docs`. ### Testing Functions in this repository should be unit tested. These can either be written using the `unittest` framework in the `tests/` directory or as embedded doctests. You can check that the unit tests pass with `tox -e py` and that the doctests pass with `tox -e doctests`. These tests are required to pass for accepting a contribution. ### Syncing your fork If other code is updated before your contribution gets merged, you might need to resolve conflicts against the main branch. After cloning, you should add the upstream repository with ```shell $ git remote add biopragmatics https://github.com/biopragmatics/curies.git ``` Then, you can merge upstream code into your branch. You can also use the GitHub UI to do this by following [this tutorial](https://docs.github.com/en/github/collaborating-with-pull-requests/working-with-forks/syncing-a-fork). ### Python Version Compatibility This project aims to support all versions of Python that have not passed their end-of-life dates. After end-of-life, the version will be removed from the Trove qualifiers in the `pyproject.toml` and from the GitHub Actions testing configuration. See https://endoflife.date/python for a timeline of Python release and end-of-life dates. ## Acknowledgements These code contribution guidelines are derived from the [cthoyt/cookiecutter-snekpack](https://github.com/cthoyt/cookiecutter-snekpack) Python package template. They're free to reuse and modify as long as they're properly acknowledged. biopragmatics-curies-aa8bd37/.github/FUNDING.yml000066400000000000000000000002701525104130300214670ustar00rootroot00000000000000# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository github: - cthoyt biopragmatics-curies-aa8bd37/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000011241525104130300234520ustar00rootroot00000000000000 ## Summary biopragmatics-curies-aa8bd37/.github/codecov.yml000066400000000000000000000001631525104130300220200ustar00rootroot00000000000000# see https://docs.codecov.com/v4.6/docs/codecov-yaml ignore: - "src/curies/__main__.py" - "src/curies/cli.py" biopragmatics-curies-aa8bd37/.github/workflows/000077500000000000000000000000001525104130300217105ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/.github/workflows/cruft.yml000066400000000000000000000045151525104130300235630ustar00rootroot00000000000000# from https://cruft.github.io/cruft/#automating-updates-with-github-actions name: Update repository with Cruft permissions: {} on: workflow_dispatch: schedule: - cron: "0 2 * * 1" # Every Monday at 2am jobs: update: permissions: contents: write pull-requests: write runs-on: ubuntu-latest strategy: fail-fast: true matrix: include: - add-paths: . body: Use this to merge the changes to this repository. branch: cruft/update commit-message: "chore: accept new Cruft update" title: New updates detected with Cruft - add-paths: .cruft.json body: Use this to reject the changes in this repository. branch: cruft/reject commit-message: "chore: reject new Cruft update" title: Reject new updates detected with Cruft steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.11" - name: Install Cruft run: pip3 install cruft - name: Check if update is available continue-on-error: false id: check run: | CHANGES=0 if [ -f .cruft.json ]; then if ! cruft check; then CHANGES=1 fi else echo "No .cruft.json file" fi echo "has_changes=$CHANGES" >> "$GITHUB_OUTPUT" - name: Run update if available if: steps.check.outputs.has_changes == '1' run: | git config --global user.email "you@example.com" git config --global user.name "GitHub" cruft update --skip-apply-ask --refresh-private-variables git restore --staged . - name: Create pull request if: steps.check.outputs.has_changes == '1' uses: peter-evans/create-pull-request@v4 with: token: ${{ secrets.GITHUB_TOKEN }} add-paths: ${{ matrix.add-paths }} commit-message: ${{ matrix.commit-message }} branch: ${{ matrix.branch }} delete-branch: true branch-suffix: timestamp title: ${{ matrix.title }} body: | This is an autogenerated PR. ${{ matrix.body }} [Cruft](https://cruft.github.io/cruft/) has detected updates from the Cookiecutter repository. biopragmatics-curies-aa8bd37/.github/workflows/tests.yml000066400000000000000000000067561525104130300236130ustar00rootroot00000000000000# This file configures the continuous integration (CI) system on GitHub. # Introductory materials can be found here: https://docs.github.com/en/actions/learn-github-actions/understanding-github-actions. # Documentation for editing this file can be found here: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions name: Tests # by default, give the GITHUB_TOKEN no permissions # See https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/controlling-permissions-for-github_token permissions: {} on: push: branches: [main] pull_request: branches: [main] jobs: lint: name: Code Quality permissions: # give only read-only access to the contents of the repository # this is the only permission this job requires, so keep it to the least privilege # i.e., not to issues, discussions, actions, etc. contents: read runs-on: ubuntu-latest strategy: matrix: python-version: ["3.14", "3.11"] tox-command: ["lint", "mypy"] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Install uv" uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 with: enable-cache: true cache-dependency-glob: "pyproject.toml" - name: "Run command" run: | uvx -p ${{ matrix.python-version }} --with tox-uv tox -e ${{ matrix.tox-command }} docs: name: Documentation permissions: contents: read runs-on: ubuntu-latest strategy: matrix: # We only test documentation on the latest version # sphinx 8.0 / sphinx-rtd-theme 3.0 discontinued Python 3.9 support # a year early, which prompted re-thinking about this. python-version: ["3.14"] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Install uv" uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 with: enable-cache: true cache-dependency-glob: "pyproject.toml" - name: Install dependencies run: | sudo apt-get install graphviz - name: Lint documentation run: uvx -p ${{ matrix.python-version }} --with tox-uv tox -e docs-lint - name: Check docstring coverage run: uvx -p ${{ matrix.python-version }} --with tox-uv tox -e docstr-coverage - name: Check documentation build with Sphinx run: uvx -p ${{ matrix.python-version }} --with tox-uv tox -e docs-test tests: name: Tests permissions: contents: read runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] python-version: ["3.14", "3.11"] pandas: ["pandas2", "pandas3"] exclude: - python-version: "3.11" pandas: "pandas3" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: "Install uv" uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1 with: enable-cache: true cache-dependency-glob: "pyproject.toml" - name: Test with pytest and generate coverage file run: uvx -p ${{ matrix.python-version }} --with tox-uv tox -e py-${{ matrix.pandas }} - name: Upload coverage report to codecov uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 if: success() with: file: coverage.xml biopragmatics-curies-aa8bd37/.gitignore000066400000000000000000000400301525104130300203000ustar00rootroot00000000000000# Created by https://www.toptal.com/developers/gitignore/api/macos,linux,windows,python,jupyternotebooks,jetbrains,pycharm,vim,emacs,visualstudiocode,visualstudio # Edit at https://www.toptal.com/developers/gitignore?templates=macos,linux,windows,python,jupyternotebooks,jetbrains,pycharm,vim,emacs,visualstudiocode,visualstudio ### Emacs ### # -*- mode: gitignore; -*- *~ \#*\# /.emacs.desktop /.emacs.desktop.lock *.elc auto-save-list tramp .\#* # Org-mode .org-id-locations *_archive # flymake-mode *_flymake.* # eshell files /eshell/history /eshell/lastdir # elpa packages /elpa/ # reftex files *.rel # AUCTeX auto folder /auto/ # cask packages .cask/ dist/ # Flycheck flycheck_*.el # server auth directory /server/ # projectiles files .projectile # directory configuration .dir-locals.el # network security /network-security.data ### JetBrains ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml .idea/**/usage.statistics.xml .idea/**/dictionaries .idea/**/shelf # AWS User-specific .idea/**/aws.xml # Generated files .idea/**/contentModel.xml # Sensitive or high-churn files .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml .idea/**/dbnavigator.xml # Gradle .idea/**/gradle.xml .idea/**/libraries # Gradle and Maven with auto-import # When using Gradle or Maven with auto-import, you should exclude module files, # since they will be recreated, and may cause churn. Uncomment if using # auto-import. # .idea/artifacts # .idea/compiler.xml # .idea/jarRepositories.xml # .idea/modules.xml # .idea/*.iml # .idea/modules # *.iml # *.ipr # CMake cmake-build-*/ # Mongo Explorer plugin .idea/**/mongoSettings.xml # File-based project format *.iws # IntelliJ out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Cursive Clojure plugin .idea/replstate.xml # SonarLint plugin .idea/sonarlint/ # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties # Editor-based Rest Client .idea/httpRequests # Android studio 3.1+ serialized cache file .idea/caches/build_file_checksums.ser ### JetBrains Patch ### # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 # *.iml # modules.xml # .idea/misc.xml # *.ipr # Sonarlint plugin # https://plugins.jetbrains.com/plugin/7973-sonarlint .idea/**/sonarlint/ # SonarQube Plugin # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin .idea/**/sonarIssues.xml # Markdown Navigator plugin # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced .idea/**/markdown-navigator.xml .idea/**/markdown-navigator-enh.xml .idea/**/markdown-navigator/ # Cache file creation bug # See https://youtrack.jetbrains.com/issue/JBR-2257 .idea/$CACHE_FILE$ # CodeStream plugin # https://plugins.jetbrains.com/plugin/12206-codestream .idea/codestream.xml ### JupyterNotebooks ### # gitignore template for Jupyter Notebooks # website: http://jupyter.org/ .ipynb_checkpoints */.ipynb_checkpoints/* # IPython profile_default/ ipython_config.py # Remove previous ipynb_checkpoints # git rm -r .ipynb_checkpoints/ ### Linux ### # 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* ### macOS ### # 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 ### PyCharm ### # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff # AWS User-specific # Generated files # Sensitive or high-churn files # Gradle # Gradle and Maven with auto-import # When using Gradle or Maven with auto-import, you should exclude module files, # since they will be recreated, and may cause churn. Uncomment if using # auto-import. # .idea/artifacts # .idea/compiler.xml # .idea/jarRepositories.xml # .idea/modules.xml # .idea/*.iml # .idea/modules # *.iml # *.ipr # CMake # Mongo Explorer plugin # File-based project format # IntelliJ # mpeltonen/sbt-idea plugin # JIRA plugin # Cursive Clojure plugin # SonarLint plugin # Crashlytics plugin (for Android Studio and IntelliJ) # Editor-based Rest Client # Android studio 3.1+ serialized cache file ### PyCharm Patch ### # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 # *.iml # modules.xml # .idea/misc.xml # *.ipr # Sonarlint plugin # https://plugins.jetbrains.com/plugin/7973-sonarlint # SonarQube Plugin # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin # Markdown Navigator plugin # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced # Cache file creation bug # See https://youtrack.jetbrains.com/issue/JBR-2257 # CodeStream plugin # https://plugins.jetbrains.com/plugin/12206-codestream ### Python ### # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ 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 *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ docs/build docs/source/api # PyBuilder .pybuilder/ target/ # Jupyter Notebook # IPython # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .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 # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # 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/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ ### Vim ### # Swap [._]*.s[a-v][a-z] !*.svg # comment out if you don't need vector files [._]*.sw[a-p] [._]s[a-rt-v][a-z] [._]ss[a-gi-z] [._]sw[a-p] # Session Session.vim Sessionx.vim # Temporary .netrwhist # Auto-generated tag files tags # Persistent undo [._]*.un~ ### VisualStudioCode ### .vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json !.vscode/*.code-snippets # Local History for Visual Studio Code .history/ # Built Visual Studio Code Extensions *.vsix ### VisualStudioCode Patch ### # Ignore all local history of files .history .ionide # Support for Project snippet scope ### Windows ### # Windows thumbnail cache files Thumbs.db Thumbs.db:encryptable 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 ### VisualStudio ### ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore # User-specific files *.rsuser *.suo *.user *.userosscache *.sln.docstates # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs # Mono auto generated files mono_crash.* # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Ww][Ii][Nn]32/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ # Visual Studio 2017 auto generated files Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* # NUnit *.VisualState.xml TestResult.xml nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ # .NET Core project.lock.json project.fragment.lock.json artifacts/ # ASP.NET Scaffolding ScaffoldingReadMe.txt # StyleCop StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c *_h.h *.ilk *.meta *.obj *.iobj *.pch *.pdb *.ipdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *_wpftmp.csproj *.tlog *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opendb *.opensdf *.sdf *.cachefile *.VC.db *.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx *.sap # Visual Studio Trace Files *.e2e # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # AxoCover is a Code Coverage Tool .axoCover/* !.axoCover/settings.json # Coverlet is a free, cross platform Code Coverage Tool coverage*.json coverage*.xml coverage*.info # Visual Studio code coverage results *.coverage *.coveragexml # NCrunch _NCrunch_* .*crunch*.local.xml nCrunchTemp_* # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to # checkin your Azure Web App publish settings, but sensitive information contained # in these scripts will be unencrypted PublishScripts/ # NuGet Packages *.nupkg # NuGet Symbol Packages *.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets # Microsoft Azure Build Output csx/ *.build.csdef # Microsoft Azure Emulator ecf/ rcf/ # Windows Store app package directories and files AppPackages/ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx *.appxbundle *.appxupload # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !?*.[Cc]ache/ # Others ClientBin/ ~$* *.dbmdl *.dbproj.schemaview *.jfm *.pfx *.publishsettings orleans.codegen.cs # Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk # Since there are multiple workflows, uncomment next line to ignore bower_components # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) #bower_components/ # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak # SQL Server files *.mdf *.ldf *.ndf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings *.rptproj.rsuser *- [Bb]ackup.rdl *- [Bb]ackup ([0-9]).rdl *- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ # GhostDoc plugin setting file *.GhostDoc.xml # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ # Visual Studio 6 build log *.plg # Visual Studio 6 workspace options file *.opt # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw # Visual Studio 6 auto-generated project file (contains which files were open etc.) *.vbp # Visual Studio 6 workspace and project file (working project files containing files to include in project) *.dsw *.dsp # Visual Studio 6 technical files # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/ModelManifest.xml **/*.Server/GeneratedArtifacts **/*.Server/ModelManifest.xml _Pvt_Extensions # Paket dependency manager .paket/paket.exe paket-files/ # FAKE - F# Make .fake/ # CodeRush personal settings .cr/personal # Python Tools for Visual Studio (PTVS) *.pyc # Cake - Uncomment if you are using it # tools/** # !tools/packages.config # Tabs Studio *.tss # Telerik's JustMock configuration file *.jmconfig # BizTalk build output *.btp.cs *.btm.cs *.odx.cs *.xsd.cs # OpenCover UI analysis results OpenCover/ # Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log *.binlog # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder .mfractor/ # Local History for Visual Studio .localhistory/ # Visual Studio History (VSHistory) files .vshistory/ # BeatPulse healthcheck temp database healthchecksdb # Backup folder for Package Reference Convert tool in Visual Studio 2017 MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ # Fody - auto-generated XML schema FodyWeavers.xsd # VS Code files for those working on multiple tools *.code-workspace # Local History for Visual Studio Code # Windows Installer files from build outputs # JetBrains Rider *.sln.iml ### VisualStudio Patch ### # Additional files built by Visual Studio # End of https://www.toptal.com/developers/gitignore/api/macos,linux,windows,python,jupyternotebooks,jetbrains,pycharm,vim,emacs,visualstudiocode,visualstudio scratch/ biopragmatics-curies-aa8bd37/.readthedocs.yml000066400000000000000000000015701525104130300214040ustar00rootroot00000000000000# .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 # Set the version of Python and other tools you might need build: os: ubuntu-22.04 apt_packages: - graphviz tools: python: "3.12" # adapted from uv recipe at https://docs.readthedocs.io/en/stable/build-customization.html#install-dependencies-with-uv # and comment at https://github.com/readthedocs/readthedocs.org/issues/11289#issuecomment-2103832834 commands: - asdf plugin add uv - asdf install uv latest - asdf global uv latest - uv venv $READTHEDOCS_VIRTUALENV_PATH - VIRTUAL_ENV=$READTHEDOCS_VIRTUALENV_PATH uv pip install --group docs .[pandas,flask,fastapi,rdflib,sqlmodels,sqlalchemy] - python -m sphinx -T -b html -d docs/_build/doctrees -D language=en docs/source $READTHEDOCS_OUTPUT/html biopragmatics-curies-aa8bd37/LICENSE000066400000000000000000000020641525104130300173220ustar00rootroot00000000000000MIT License Copyright (c) 2026 Charles Tapley Hoyt Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. biopragmatics-curies-aa8bd37/README.md000066400000000000000000000271111525104130300175740ustar00rootroot00000000000000

CURIEs

Tests PyPI PyPI - Python Version PyPI - License Documentation Status Codecov status Cookiecutter template from @cthoyt Ruff Contributor Covenant DOI

Idiomatic conversion between URIs and compact URIs (CURIEs). ```python import curies converter = curies.load_prefix_map({ "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", # ... and so on }) >>> converter.compress("http://purl.obolibrary.org/obo/CHEBI_1") 'CHEBI:1' >>> converter.expand("CHEBI:1") 'http://purl.obolibrary.org/obo/CHEBI_1' ``` Full documentation is available at [curies.readthedocs.io](https://curies.readthedocs.io). ### CLI Usage This package comes with a built-in CLI for running a resolver web application or a IRI mapper web application: ```shell # Run a resolver python -m curies resolver --host 0.0.0.0 --port 8764 bioregistry # Run a mapper python -m curies mapper --host 0.0.0.0 --port 8764 bioregistry ``` The positional argument can be one of the following: 1. A pre-defined prefix map to get from the web (bioregistry, go, obo, monarch, prefixcommons) 2. A local file path or URL to a prefix map, extended prefix map, or one of several formats. Requires specifying a `--format`. The framework can be swapped to use Flask (default) or FastAPI with `--framework`. The server can be swapped to use Werkzeug (default) or Uvicorn with `--server`. These functionalities are also available programmatically, see the docs for more information. ## 🧑���🧑 Related Other packages that convert between CURIEs and URIs: - https://github.com/prefixcommons/prefixcommons-py (Python) - https://github.com/prefixcommons/curie-util (Java) - https://github.com/geneontology/curie-util-py (Python) - https://github.com/geneontology/curie-util-es5 (Node.js) - https://github.com/endoli/curie.rs (Rust) - https://github.com/cthoyt/curies4j (Java) - https://github.com/biopragmatics/curies.rs (Rust, Node.js, Python) ## 🚀 Installation The most recent release can be installed from [PyPI](https://pypi.org/project/curies/) with: ```console python3 -m pip install curies ``` As of v0.8, this package only supports Pydantic v2. v0.6.x and v0.7.x had cross-version support for Pydantic v1 and v2. v0.5.x and before only supported Pydantic v1. See the [Pydantic migration guide](https://docs.pydantic.dev/2.0/migration/) for updating your code. ## � Contributing Contributions, whether filing an issue, making a pull request, or forking, are appreciated. See [CONTRIBUTING.md](https://github.com/biopragmatics/curies/blob/master/.github/CONTRIBUTING.md) for more information on getting involved. ## Users See [who's using `curies`](https://github.com/search?q=%22import%20curies%22%20OR%20%22from%20curies%20import%22%20-user%3Acthoyt%20-user%3Abiopragmatics%20-is%3Afork%20language%3APython&type=code&l=Python). ## 👋 Attribution ### � Acknowledgements This package heavily builds on the [trie](https://en.wikipedia.org/wiki/Trie) data structure implemented in [`pytrie`](https://github.com/gsakkis/pytrie). ### ⚖� License The code in this package is licensed under the MIT License. ### � Cookiecutter This package was created with [@audreyfeldroy](https://github.com/audreyfeldroy)'s [cookiecutter](https://github.com/cookiecutter/cookiecutter) package using [@cthoyt](https://github.com/cthoyt)'s [cookiecutter-snekpack](https://github.com/cthoyt/cookiecutter-snekpack) template. ## 🛠� For Developers
See developer instructions The final section of the README is for if you want to get involved by making a code contribution. ### Development Installation To install in development mode, use the following: ```console $ git clone git+https://github.com/biopragmatics/curies.git $ cd curies $ uv --preview pip install -e . ``` Alternatively, install using legacy pip with `UV_PREVIEW` mode enabled until the uv build backend becomes a stable feature: ```console $ UV_PREVIEW=1 python3 -m pip install -e . ``` ### Updating Package Boilerplate This project uses `cruft` to keep boilerplate (i.e., configuration, contribution guidelines, documentation configuration) up-to-date with the upstream cookiecutter package. Update with the following: ```console python3 -m pip install cruft cruft update ``` More info on Cruft's update command is available [here](https://github.com/cruft/cruft?tab=readme-ov-file#updating-a-project). ### 🥼 Testing After cloning the repository and installing `tox` with `python3 -m pip install tox tox-uv`, the unit tests in the `tests/` folder can be run reproducibly with: ```console tox -e py ``` Additionally, these tests are automatically re-run with each commit in a [GitHub Action](https://github.com/biopragmatics/curies/actions?query=workflow%3ATests). ### 📖 Building the Documentation The documentation can be built locally using the following: ```console git clone git+https://github.com/biopragmatics/curies.git cd curies tox -e docs open docs/build/html/index.html ``` The documentation automatically installs the package as well as the `docs` extra specified in the [`pyproject.toml`](pyproject.toml). `sphinx` plugins like `texext` can be added there. Additionally, they need to be added to the `extensions` list in [`docs/source/conf.py`](docs/source/conf.py). The documentation can be deployed to [ReadTheDocs](https://readthedocs.io) using [this guide](https://docs.readthedocs.io/en/stable/intro/import-guide.html). The [`.readthedocs.yml`](.readthedocs.yml) YAML file contains all the configuration you'll need. You can also set up continuous integration on GitHub to check not only that Sphinx can build the documentation in an isolated environment (i.e., with `tox -e docs-test`) but also that [ReadTheDocs can build it too](https://docs.readthedocs.io/en/stable/pull-requests.html). #### Configuring ReadTheDocs 1. Log in to ReadTheDocs with your GitHub account to install the integration at https://readthedocs.org/accounts/login/?next=/dashboard/ 2. Import your project by navigating to https://readthedocs.org/dashboard/import then clicking the plus icon next to your repository 3. You can rename the repository on the next screen using a more stylized name (i.e., with spaces and capital letters) 4. Click next, and you're good to go! ### 📦 Making a Release #### Configuring Zenodo [Zenodo](https://zenodo.org) is a long-term archival system that assigns a DOI to each release of your package. 1. Log in to Zenodo via GitHub with this link: https://zenodo.org/oauth/login/github/?next=%2F. This brings you to a page that lists all of your organizations and asks you to approve installing the Zenodo app on GitHub. Click "grant" next to any organizations you want to enable the integration for, then click the big green "approve" button. This step only needs to be done once. 2. Navigate to https://zenodo.org/account/settings/github/, which lists all of your GitHub repositories (both in your username and any organizations you enabled). Click the on/off toggle for any relevant repositories. When you make a new repository, you'll have to come back to this After these steps, you're ready to go! After you make "release" on GitHub (steps for this are below), you can navigate to https://zenodo.org/account/settings/github/repository/biopragmatics/curies to see the DOI for the release and link to the Zenodo record for it. #### Registering with the Python Package Index (PyPI) You only have to do the following steps once. 1. Register for an account on the [Python Package Index (PyPI)](https://pypi.org/account/register) 2. Navigate to https://pypi.org/manage/account and make sure you have verified your email address. A verification email might not have been sent by default, so you might have to click the "options" dropdown next to your address to get to the "re-send verification email" button 3. 2-Factor authentication is required for PyPI since the end of 2023 (see this [blog post from PyPI](https://blog.pypi.org/posts/2023-05-25-securing-pypi-with-2fa/)). This means you have to first issue account recovery codes, then set up 2-factor authentication 4. Issue an API token from https://pypi.org/manage/account/token #### Configuring your machine's connection to PyPI You have to do the following steps once per machine. ```console $ uv tool install keyring $ keyring set https://upload.pypi.org/legacy/ __token__ $ keyring set https://test.pypi.org/legacy/ __token__ ``` Note that this deprecates previous workflows using `.pypirc`. #### Uploading to PyPI After installing the package in development mode and installing `tox` with `python3 -m pip install tox tox-uv`, run the following from the console: ```console tox -e finish ``` This script does the following: 1. Uses [bump-my-version](https://github.com/callowayproject/bump-my-version) to switch the version number in the `pyproject.toml`, `CITATION.cff`, `src/curies/version.py`, and [`docs/source/conf.py`](docs/source/conf.py) to not have the `-dev` suffix 2. Packages the code in both a tar archive and a wheel using [`uv build`](https://docs.astral.sh/uv/guides/publish/#building-your-package) 3. Uploads to PyPI using [`uv publish`](https://docs.astral.sh/uv/guides/publish/#publishing-your-package). 4. Push to GitHub. You'll need to make a release going with the commit where the version was bumped. 5. Bump the version to the next patch. If you made big changes and want to bump the version by minor, you can use `tox -e bumpversion -- minor` after. #### Releasing on GitHub 1. Navigate to https://github.com/biopragmatics/curies/releases/new to draft a new release 2. Click the "Choose a Tag" dropdown and select the tag corresponding to the release you just made 3. Click the "Generate Release Notes" button to get a quick outline of recent changes. Modify the title and description as you see fit 4. Click the big green "Publish Release" button This will trigger Zenodo to assign a DOI to your release as well.
biopragmatics-curies-aa8bd37/docs/000077500000000000000000000000001525104130300172435ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/docs/make_schema.py000066400000000000000000000017231525104130300220550ustar00rootroot00000000000000"""Generate a JSON schema for extended prefix maps.""" import json from pathlib import Path from pydantic.json_schema import models_json_schema from curies import Records HERE = Path(__file__).parent.resolve() PATH = HERE.joinpath("schema.json") TITLE = "Extended Prefix Map" DESCRIPTION = ( """\ An extended prefix map is a generalization of a prefix map that includes synonyms for URI prefixes and CURIE prefixes. """.strip() .replace("\n", " ") .replace(" ", " ") ) URL = "https://w3id.org/biopragmatics/schema/epm.json" def main() -> None: """Generate a JSON schema for extended prefix maps.""" rv = { "$schema": "http://json-schema.org/draft-07/schema#", "$id": URL, } _, schema_dict = models_json_schema( [(Records, "validation")], title=TITLE, description=DESCRIPTION, ) rv.update(schema_dict) PATH.write_text(json.dumps(rv, indent=2) + "\n") if __name__ == "__main__": main() biopragmatics-curies-aa8bd37/docs/schema.json000066400000000000000000000035271525104130300214050ustar00rootroot00000000000000{ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://w3id.org/biopragmatics/schema/epm.json", "$defs": { "Record": { "description": "A record of some prefixes and their associated URI prefixes.\n\n.. seealso:: https://github.com/cthoyt/curies/issues/70", "properties": { "prefix": { "description": "The canonical CURIE prefix, used in the reverse prefix map", "title": "CURIE prefix", "type": "string" }, "uri_prefix": { "description": "The canonical URI prefix, used in the forward prefix map", "title": "URI prefix", "type": "string" }, "prefix_synonyms": { "items": { "type": "string" }, "title": "CURIE prefix synonyms", "type": "array" }, "uri_prefix_synonyms": { "items": { "type": "string" }, "title": "URI prefix synonyms", "type": "array" }, "pattern": { "anyOf": [ { "type": "string" }, { "type": "null" } ], "default": null, "description": "The regular expression pattern for entries in this semantic space. Warning: this is an experimental feature.", "title": "Pattern" } }, "required": [ "prefix", "uri_prefix" ], "title": "Record", "type": "object" }, "Records": { "description": "A list of records.", "items": { "$ref": "#/$defs/Record" }, "title": "Records", "type": "array" } }, "title": "Extended Prefix Map", "description": "An extended prefix map is a generalization of a prefix map that includes synonyms for URI prefixes and CURIE prefixes." } biopragmatics-curies-aa8bd37/docs/source/000077500000000000000000000000001525104130300205435ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/docs/source/api.rst000066400000000000000000000002131525104130300220420ustar00rootroot00000000000000############### API Reference ############### .. automodapi:: curies :no-heading: .. automodapi:: curies.dataframe :no-heading: biopragmatics-curies-aa8bd37/docs/source/conf.py000066400000000000000000000203121525104130300220400ustar00rootroot00000000000000"""Configuration file for the Sphinx documentation builder. This file does only contain a selection of the most common options. For a full list see the documentation: http://www.sphinx-doc.org/en/master/config -- Path setup -------------------------------------------------------------- If extensions (or modules to document with autodoc) are in another directory, add these directories to ``sys.path`` here. If the directory is relative to the documentation root, use ``os.path.abspath`` to make it absolute, like shown here. """ import os import re import sys from datetime import date sys.path.insert(0, os.path.abspath("../../src")) # -- Project information ----------------------------------------------------- project = "curies" copyright = f"{date.today().year}, Charles Tapley Hoyt" author = "Charles Tapley Hoyt" # The full version, including alpha/beta/rc tags. release = "0.15.1" # The short X.Y version. parsed_version = re.match( r"(?P\d+)\.(?P\d+)\.(?P\d+)(?:-(?P[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+(?P[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?", release, ) version = parsed_version.expand(r"\g.\g.\g") if parsed_version.group("release"): tags.add("prerelease") # noqa:F821 # See https://about.readthedocs.com/blog/2024/07/addons-by-default/ # Define the canonical URL if you are using a custom domain on Read the Docs html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") # See https://about.readthedocs.com/blog/2024/07/addons-by-default/ # Tell Jinja2 templates the build is running on Read the Docs if os.environ.get("READTHEDOCS", "") == "True": if "html_context" not in globals(): html_context = {} html_context["READTHEDOCS"] = True # -- General configuration --------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # If true, the current module name will be prepended to all description # unit titles (such as .. function::). add_module_names = False # A list of prefixes that are ignored when creating the module index. (new in Sphinx 0.6) modindex_common_prefix = ["curies."] # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ "sphinx.ext.autosummary", "sphinx.ext.autodoc", "sphinx.ext.coverage", "sphinx.ext.intersphinx", "sphinx.ext.todo", "sphinx.ext.mathjax", "sphinx.ext.viewcode", "sphinx_automodapi.automodapi", "sphinx_automodapi.smart_resolver", # 'texext', ] # generate autosummary pages autosummary_generate = True # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = { ".rst": "restructuredtext", } # The master toctree document. master_doc = "index" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = "en" # 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 = [] # The name of the Pygments (syntax highlighting) style to use. pygments_style = "sphinx" # -- 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" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # 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'] # Custom sidebar templates, must be a dictionary that maps document names # to template names. # # The default sidebars (for documents that don't match any pattern) are # defined by theme itself. Builtin themes are using these templates by # default: ``['localtoc.html', 'relations.html', 'sourcelink.html', # 'searchbox.html']``. # # html_sidebars = {} # The name of an image file (relative to this directory) to place at the top # of the sidebar. # if os.path.exists("logo.png"): html_logo = "logo.png" # -- Options for HTMLHelp output --------------------------------------------- # Output file base name for HTML help builder. htmlhelp_basename = "curies_doc" # -- Options for LaTeX output ------------------------------------------------ # latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # # Additional stuff for the LaTeX preamble. # # 'preamble': '', # # Latex figure (float) alignment # # 'figure_align': 'htbp', # } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). # latex_documents = [ # ( # master_doc, # 'curies.tex', # 'CURIEs Documentation', # author, # 'manual', # ), # ] # -- Options for manual page output ------------------------------------------ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ( master_doc, "curies", "CURIEs Documentation", [author], 1, ), ] # -- Options for Texinfo output ---------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ( master_doc, "curies", "CURIEs Documentation", author, "Charles Tapley Hoyt", "Idiomatic conversion between URIs and compact URIs (CURIEs)", "Miscellaneous", ), ] # -- Options for Epub output ------------------------------------------------- # Bibliographic Dublin Core info. # epub_title = project # The unique identifier of the text. This can be a ISBN number # or the project homepage. # # epub_identifier = '' # A unique identification for the text. # # epub_uid = '' # A list of files that should not be packed into the epub file. # epub_exclude_files = ['search.html'] # -- Extension configuration ------------------------------------------------- # -- Options for intersphinx extension --------------------------------------- # Example configuration for intersphinx: refer to the Python standard library. # Note: don't add trailing slashes, since sphinx adds "/objects.inv" to the end intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "bioregistry": ("https://bioregistry.readthedocs.io/en/stable", None), "pandas": ("https://pandas.pydata.org/docs", None), "flask": ("https://flask.palletsprojects.com/en/stable", None), "pydantic": ("https://docs.pydantic.dev/latest", None), "httpx2": ("https://httpx2.pydantic.dev", None), # "fastapi": ("https://fastapi.tiangolo.com/", None), # "gunicorn": ("https://docs.gunicorn.org/", None), # "uvicorn": ("https://www.uvicorn.org/", None), "prefixmaps": ("https://linkml.io/prefixmaps", None), "rdflib": ("https://rdflib.readthedocs.io/en/stable", None), "sssom": ("https://mapping-commons.github.io/sssom-py", None), # "sqlalchemy": ("https://docs.sqlalchemy.org/en/20", None), # "sqlmodel": ("https://sqlmodel.tiangolo.com", None), } autoclass_content = "both" # Don't sort alphabetically, explained at: # https://stackoverflow.com/questions/37209921/python-how-not-to-sort-sphinx-output-in-alphabetical-order autodoc_member_order = "bysource" todo_include_todos = True todo_emit_warnings = True biopragmatics-curies-aa8bd37/docs/source/database.rst000066400000000000000000000001571525104130300230440ustar00rootroot00000000000000########## Database ########## .. automodapi:: curies.database :no-inheritance-diagram: :no-heading: biopragmatics-curies-aa8bd37/docs/source/dataframe.rst000066400000000000000000000047111525104130300232240ustar00rootroot00000000000000######################### Working with Dataframes ######################### *********** Filtering *********** In the following examples, we'll use a dataframe representing semantic mappings between disease ontologies in the SSSOM format: ============ =============== =============== ============================ subject_id predicate_id object_id mapping_justification ============ =============== =============== ============================ DOID:0080795 skos:exactMatch EFO:0003029 semapv:ManualMappingCuration DOID:0080795 skos:exactMatch mesh:D015471 semapv:ManualMappingCuration DOID:0080799 skos:exactMatch EFO:1000527 semapv:ManualMappingCuration DOID:0080808 skos:exactMatch mesh:D000069295 semapv:ManualMappingCuration ============ =============== =============== ============================ To get the set of unique prefixes appearing in a column, use :func:`curies.dataframe.get_df_unique_prefixes`: .. code-block:: python from curies.dataframe import get_df_unique_prefixes df = ... prefixes = get_df_unique_prefixes(df, column="object_id") assert prefixes == {"EFO", "mesh"} To filter to objects that use EFO, use :func:`curies.dataframe.filter_df_by_prefixes`: .. code-block:: python from curies.dataframe import filter_df_by_prefixes df = ... df = filter_df_by_prefixes(df, column="object_id", prefixes=["efo"]) ============ =============== =========== ============================ subject_id predicate_id object_id mapping_justification ============ =============== =========== ============================ DOID:0080795 skos:exactMatch EFO:0003029 semapv:ManualMappingCuration DOID:0080799 skos:exactMatch EFO:1000527 semapv:ManualMappingCuration ============ =============== =========== ============================ To filter to rows that have the subject ``DOID:0080795``, use :func:`curies.dataframe.filter_df_by_curies`: .. code-block:: python from curies.dataframe import filter_df_by_curies df = ... df = filter_df_by_curies(df, column="subjects_id", curies=["DOID:0080795"]) ============ =============== ============ ============================ subject_id predicate_id object_id mapping_justification ============ =============== ============ ============================ DOID:0080795 skos:exactMatch EFO:0003029 semapv:ManualMappingCuration DOID:0080795 skos:exactMatch mesh:D015471 semapv:ManualMappingCuration ============ =============== ============ ============================ biopragmatics-curies-aa8bd37/docs/source/discovery.rst000066400000000000000000000331541525104130300233120ustar00rootroot00000000000000###################### URI Prefix Discovery ###################### .. automodule:: curies.discovery ******************************************* Discovering URI Prefixes from an Ontology ******************************************* A common place where discovering URI prefixes is important is when working with new ontologies. In the following example, we look at the `Academic Event Ontology (AEON) `_. This is an ontology developed under OBO Foundry principles describing academic events. Accordingly, it includes many URI references to terms in OBO Foundry ontologies. In this tutorial, we use :func:`curies.discover` (and then :func:`curies.discover_from_rdf` as a nice convenience function) to load the ontology in the RDF/XML format and discover putative URI prefixes. .. code-block:: python import curies from curies.discovery import get_uris_from_rdf ONTOLOGY_URL = "https://raw.githubusercontent.com/tibonto/aeon/main/aeon.owl" uris = get_uris_from_rdf(ONTOLOGY_URL, format="xml") discovered_converter = curies.discover(uris) # note, these two steps can be combine with curies.discover_from_rdf, # and we'll do that in the following examples We discovered the fifty URI prefixes in the following table. Many of them appear to be OBO Foundry URI prefixes or semantic web prefixes, so in the next step, we'll use prior knowledge to reduce the false discovery rate. ============ ============================================================================== curie_prefix uri_prefix ============ ============================================================================== ns1 ``http://ontologydesignpatterns.org/wiki/Community:Parts_and_`` ns2 ``http://protege.stanford.edu/plugins/owl/protege#`` ns3 ``http://purl.obolibrary.org/obo/AEON_`` ns4 ``http://purl.obolibrary.org/obo/APOLLO_SV_`` ns5 ``http://purl.obolibrary.org/obo/BFO_`` ns6 ``http://purl.obolibrary.org/obo/CRO_`` ns7 ``http://purl.obolibrary.org/obo/ENVO_`` ns8 ``http://purl.obolibrary.org/obo/IAO_`` ns9 ``http://purl.obolibrary.org/obo/ICO_`` ns10 ``http://purl.obolibrary.org/obo/NCBITaxon_`` ns11 ``http://purl.obolibrary.org/obo/OBIB_`` ns12 ``http://purl.obolibrary.org/obo/OBI_`` ns13 ``http://purl.obolibrary.org/obo/OMO_`` ns14 ``http://purl.obolibrary.org/obo/OOSTT_`` ns15 ``http://purl.obolibrary.org/obo/RO_`` ns16 ``http://purl.obolibrary.org/obo/TXPO_`` ns17 ``http://purl.obolibrary.org/obo/bfo/axiom/`` ns18 ``http://purl.obolibrary.org/obo/valid_for_`` ns19 ``http://purl.obolibrary.org/obo/valid_for_go_`` ns20 ``http://purl.obolibrary.org/obo/valid_for_go_annotation_`` ns21 ``http://purl.obolibrary.org/obo/wikiCFP_`` ns22 ``http://purl.org/dc/elements/1.1/`` ns23 ``http://purl.org/dc/terms/`` ns24 ``http://usefulinc.com/ns/doap#`` ns25 ``http://wiki.geneontology.org/index.php/Involved_`` ns26 ``http://www.geneontology.org/formats/oboInOwl#`` ns27 ``http://www.geneontology.org/formats/oboInOwl#created_`` ns28 ``http://www.w3.org/1999/02/22-rdf-syntax-ns#`` ns29 ``http://www.w3.org/2000/01/rdf-schema#`` ns30 ``http://www.w3.org/2001/XMLSchema#`` ns31 ``http://www.w3.org/2002/07/owl#`` ns32 ``http://www.w3.org/2003/11/swrl#`` ns33 ``http://www.w3.org/2004/02/skos/core#`` ns34 ``http://www.w3.org/ns/prov#`` ns35 ``http://xmlns.com/foaf/0.1/`` ns36 ``https://en.wikipedia.org/wiki/Allen%27s_interval_`` ns37 ``https://groups.google.com/d/msg/bfo-owl-devel/s9Uug5QmAws/ZDRnpiIi_`` ns38 ``https://ror.org/`` ns39 ``https://w3id.org/scholarlydata/ontology/conference-ontology.owl#`` ns40 ``https://w3id.org/seo#`` ns41 ``https://www.confident-conference.org/index.php/Academic_Field:Information_`` ns42 ``https://www.confident-conference.org/index.php/Event:VIVO_`` ns43 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_`` ns44 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_orga_`` ns45 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_talk1_`` ns46 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_talk2_`` ns47 ``https://www.confident-conference.org/index.php/Event_Series:VIVO_`` ns48 ``https://www.wikidata.org/wiki/`` ns49 ``https://www.wikidata.org/wiki/Wikidata:Property_proposal/colocated_`` ns50 ``urn:swrl#`` ============ ============================================================================== In the following block, we chain together (extended) prefix maps from the OBO Foundry as well as a "semantic web" prefix map to try and reduce the number of false positives by passing them through the ``converter`` keyword argument. .. code-block:: python import curies ONTOLOGY_URL = "https://raw.githubusercontent.com/tibonto/aeon/main/aeon.owl" SEMWEB_URL = "https://raw.githubusercontent.com/biopragmatics/bioregistry/main/exports/contexts/semweb.context.jsonld" base_converter = curies.chain( [ curies.load_jsonld_context(SEMWEB_URL), curies.get_obo_converter(), ] ) discovered_converter = curies.discover_from_rdf( ONTOLOGY_URL, format="xml", converter=base_converter ) We reduced the number of putative URI prefixes in half in the following table. However, we can still identify some putative URI prefixes that likely would have appeared in a more comprehensive (extended) prefix map such as the Bioregistry such as: - ``https://ror.org/`` for the `Research Organization Registry (ROR) `_ - ``https://w3id.org/seo#`` for the `Scientific Event Ontology (SEO) `_ - ``http://usefulinc.com/ns/doap#`` for the `Description of a Project (DOAP) vocabulary `_ Despite this, we're on our way! It's also obvious that several of the remaining putative URI prefixes come from non-standard usage of the OBO PURL system (e.g., ``http://purl.obolibrary.org/obo/valid_for_go_annotation_``) and some are proper false positives due to using ``_`` as a delimiter (e.g., ``https://www.confident-conference.org/index.php/Event:VIVO_2021_talk2_``). ============ ============================================================================== curie_prefix uri_prefix ============ ============================================================================== ns1 ``http://ontologydesignpatterns.org/wiki/Community:Parts_and_`` ns2 ``http://protege.stanford.edu/plugins/owl/protege#`` ns3 ``http://purl.obolibrary.org/obo/AEON_`` ns4 ``http://purl.obolibrary.org/obo/bfo/axiom/`` ns5 ``http://purl.obolibrary.org/obo/valid_for_`` ns6 ``http://purl.obolibrary.org/obo/valid_for_go_`` ns7 ``http://purl.obolibrary.org/obo/valid_for_go_annotation_`` ns8 ``http://purl.obolibrary.org/obo/wikiCFP_`` ns9 ``http://usefulinc.com/ns/doap#`` ns10 ``http://wiki.geneontology.org/index.php/Involved_`` ns11 ``https://en.wikipedia.org/wiki/Allen%27s_interval_`` ns12 ``https://groups.google.com/d/msg/bfo-owl-devel/s9Uug5QmAws/ZDRnpiIi_`` ns13 ``https://ror.org/`` ns14 ``https://w3id.org/scholarlydata/ontology/conference-ontology.owl#`` ns15 ``https://w3id.org/seo#`` ns16 ``https://www.confident-conference.org/index.php/Academic_Field:Information_`` ns17 ``https://www.confident-conference.org/index.php/Event:VIVO_`` ns18 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_`` ns19 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_orga_`` ns20 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_talk1_`` ns21 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_talk2_`` ns22 ``https://www.confident-conference.org/index.php/Event_Series:VIVO_`` ns23 ``https://www.wikidata.org/wiki/`` ns24 ``https://www.wikidata.org/wiki/Wikidata:Property_proposal/colocated_`` ns25 ``urn:swrl#`` ============ ============================================================================== As a final step in our iterative journey of URI prefix discovery, we're going to use a cutoff for a minimum of two appearances of a URI prefix to reduce the most spurious false positives. .. code-block:: python import curies ONTOLOGY_URL = "https://raw.githubusercontent.com/tibonto/aeon/main/aeon.owl" SEMWEB_URL = "https://raw.githubusercontent.com/biopragmatics/bioregistry/main/exports/contexts/semweb.context.jsonld" base_converter = curies.chain( [ curies.load_jsonld_context(SEMWEB_URL), curies.get_obo_converter(), ] ) discovered_converter = curies.discover_from_rdf( ONTOLOGY_URL, format="xml", converter=base_converter, cutoff=2 ) We have reduced the list to a manageable set of 9 putative URI prefixes in the following table. ============ ========================================================================= curie_prefix uri_prefix ============ ========================================================================= ns1 ``http://purl.obolibrary.org/obo/AEON_`` ns2 ``http://purl.obolibrary.org/obo/bfo/axiom/`` ns3 ``http://purl.obolibrary.org/obo/valid_for_go_`` ns4 ``https://w3id.org/scholarlydata/ontology/conference-ontology.owl#`` ns5 ``https://w3id.org/seo#`` ns6 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_`` ns7 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_talk1_`` ns8 ``https://www.confident-conference.org/index.php/Event:VIVO_2021_talk2_`` ns9 ``urn:swrl#`` ============ ========================================================================= Here are the calls to be made: - ``ns1`` represents the AEON vocabulary itself and should be given the ``aeon`` prefix. - ``ns2`` and ``ns3``` are all false positives - ``ns6``, ``ns7``, and ``ns8`` are a tricky case - they have a meaningful overlap that can't be easily automatically detected (yet). In this case, it makes the most sense to add the shortest one manually to the base converter with some unique name (don't use ``ns6`` as it will cause conflicts later), like in: .. code-block:: python base_converter = curies.chain( [ curies.load_jsonld_context(SEMWEB_URL), curies.get_obo_converter(), curies.load_prefix_map( { "confident_event_vivo_2021": "https://www.confident-conference.org/index.php/Event:VIVO_2021_" } ), ] ) In reality, these are all part of the `ConfIDent Event `_ vocabulary, which has the URI prefix ``https://www.confident-conference.org/index.php/Event:``. - ``ns4`` represents the `Conference Ontology `_ and should be given the ``conference`` prefix. - ``ns5`` represents the `Scientific Event Ontology (SEO) `_ and should be given the ``seo`` prefix. - ``ns9`` represents the `Semantic Web Rule Language `_, though using URNs is an interesting choice in serialization. After we've made these calls, it's a good idea to write an (extended) prefix map. In this case, since we aren't working with CURIE prefix synonyms nor URI prefix synonyms, it's okay to write a simple prefix map or a JSON-LD context without losing information. .. note:: Postscript: throughout this guide, we used the following Python code to create the RST tables: .. code-block:: python def print_converter(converter) -> None: from tabulate import tabulate rows = sorted( [ (record.prefix, f"``{record.uri_prefix}``") for record in discovered_converter.records ], key=lambda t: int(t[0].removeprefix("ns")), ) print(tabulate(rows, headers=["curie_prefix", "uri_prefix"], tablefmt="rst")) ************************************************************************* Just Make It Work, or, A Guide to Being a Questionable Semantic Citizen ************************************************************************* The goal of the :mod:`curies` package is to provide the tools towards making semantically well-defined data, which has a meaningful (extended) prefix map associated with it. Maybe you're in an organization that doesn't really care about the utility of nice prefix maps, and just wants to get the job done where you need to turn URIs into _some_ CURIEs. Here's a recipe for doing this, based on the last example with AEON: .. code-block:: python import curies ONTOLOGY_URL = "https://raw.githubusercontent.com/tibonto/aeon/main/aeon.owl" # Use the Bioregistry as a base prefix since it's the most comprehensive one base_converter = curies.get_bioregistry_converter() # Only discover what the Bioregistry doesn't already have discovered_converter = curies.discover_from_rdf( ONTOLOGY_URL, format="xml", converter=base_converter ) # Chain together the base converter with the discoveries augmented_converter = curies.chain([base_converter, discovered_converter]) With the augmented converter, you can now convert all URIs in the ontology into CURIEs. They will have a smattering of unintelligible prefixes with no meaning, but at least the job is done! biopragmatics-curies-aa8bd37/docs/source/img/000077500000000000000000000000001525104130300213175ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/docs/source/img/trie.png000066400000000000000000004665621525104130300230130ustar00rootroot00000000000000‰PNG  IHDR€ÝAÜs€IDATx^ìÝ}œ\e}÷ñß&!j0 …Á¢T/ˆ¥PA¸‰Ejè ZhUªhË�"UÀŠ�¢‚-PÑR¤(‚¦¬ˆP„V¢„¢ÂC$DžLˆ’�@B²ç¾ÎÎF2ç:»Ù$»›™Ù÷ûõúüåžìî̹üc¿ÌLЎƤ&¦&¥öO‘zoêc©³S¤.îíÊÔŒJW¬ó¿ÿSï5§¥þ&uxj¿Ô¢ñ=ÊïÀ&xE4†Ø¿Jýcê²ÔÍ©‡RϧŠaªü^óRßOý{êÑø™ÊŸ­üèU¾Âv·Ô_D㕸ק�|ˆmÕ‰ÆÏ\þìåïPþ.^5 ŒãSoN�šº6µ8òQµÝ[žº%£ðÛS¯ €°e4FÐ/¤~‘Zù`Úé•¿ó]Ñx þ,5.ÚÄkRï‹Æ+|Ÿ�|镟+\¾B¸|ô@‹)‡ÌÏ¥æE>xªÿŒÆÛEO€ÍdRjZê¾ÈGMm\óSDãs’†Ô6©SRs"/5¸Ý�:9õÊ$]©?IMO=ùP9ì�=º˜4iRñÖ·¾µ8î¸ãŠøÃÅYg�Uüë¿þkñÕ¯~µ˜1cFO×]]qã�76uýý×oÿ÷¯}ík=×”×–ÿFùotÐA=ÿvù=ªßw3U>æßH½%ÏÀÛ*õÑÔ/#%‡¼vØ¡8æ˜cŠiÓ¦—]vYñßÿó?Ž÷Þ[,]º´.K–,éùžå÷.†òg™:uj±ýöÛg?ï057õ‘Ô„€SÿœZù9$í¿ÿþÅi§�V\yå•ÅO~ò“ay7V9ßqÇÅW\Qœzê©Å~ûí—ý^CXùÜüSêÕPcrêòÔ󑎃VùÖʇrHqî¹ç3gÎ,–.ký±w V¬XQÜyç�Å%—\ÒóvÒãÆ�Ë~ÿAnMêÚÔ@4ÆÃ#­rðýâ¿XÌ™3§X³fMu7íXåïzÏ=÷]tQÏg W—Aî†Ô^ŒH˜únäCâ&·å–[§œrJqÃ÷¾×oç<\Ê·�¾á†ŠøÃCùêàïDãÕÜÀ°[ê[©îÈÇÃ�nìØ±Åßÿýß³fÍ*žþùêöIEùýèG?êƒËÇ®úxnbås{uj×:Ò«Rÿ�Ï�­†Õ¨Q£Š“N:©øÁ~P¬Zµªºq2@+W®ìù<äO<±ç1­>ΛÐêÔÅ©mè[¤>˜z:ò�p£Ú}÷Ý‹K.¹¤xúé§«[&›h鲥Ō3Š7½éMÙã¾ -KMK½(€¶õŽÔ‘‚\ùÊÔò-žþóŸW7K†ÈìÙ³{>Ky_|êÏh+¿�Ï€­€Ü„ ŠsÏ=·X´hQuŸd˜,^¼¸¸ð ‹ñãÇgÏÏFöÝÔïÐÒºRïK-‰|ôÛ vÙe—âòË//ž}öÙêÉfR~Vð5×\Sì±ÇÙóµ•÷HùÖà£h9¯OÝùзAí¹çžÅM7ÝT¬Y³¦º?Ò"Êçæ†ï}o°†à¦þ €–0:õ±Ôs‘�{n×]w-®»þ:Ão)Ÿ«k¯½¶˜4iRö|n`å½sjx50lV¯Ný òAoÀ½æ5¯)¾ýío«W¯®î‹´‰ò¹ûÖ·¾Uì´ÓNÙó»�Ý>6‹#SOF>â ¨Q£F^xaÏçÊÒV­ZÕó¹Ícƌɞï èéÔ_0,^–º8òánÀ�p ł ªû!â7¿ùMqúé§gÏû6#µUCf·ÔÜÈǺuÐAsæÌ©î…t¨»ï¾»8ðÀ³û`º/õ†ÝÔÔ3‘�të­|»çK.¹¤X³fMu#¤Ãuww3fÌ(F��ÝlYêèŘÔÙ‘sê˜cŽ)~ýë_WwAF˜E‹ïyÏ{²ûc*ßv|‹6ÚÄÔ"ãÖÛ„ Šë®¿®º2Â]{íµÅøñã³ûe€ÝœzU¬üìÕ‡"áÖÛQGÕóŠO¨óÔSOÇ{lvß °y©×0`oI-Ž||ë7ŸõË@­ýlàòž©ÞGè©Ô¬×_§VF>ºõÛž{îY<ðÀÕ�ú5oÞ¼bŸ}öÉî§TÞ£ÇP«+5-ÕùØÖoûØÇŠ•+WV·=�òÞ9õÔS³ûj•÷ê4)Çßó#Øú­«««¸êª«ª{l”k®¹fcßúK©QÄèÔ¥‘�jý¶ýöÛwß}wuÃMrï½÷¯yÍk²ûm}-5&`Û"õ‘�iývä‘GO?ýtu»ƒA±xñââðÃÏî»4=÷4Œ8/Jýgä#Z¿}ä#)žþùêfƒjõêÕÅ?üÃ?d÷ßúnê%#H9þÞùxÖoÿöoÿVÝé`H}éK_ÊîÃô_©±#@ù™¿3"Íú¬«««¸îúëªÛ ‹›nº©5jTv_®§òÕí>€Ž6*ueäcYŸ�;¶˜={vu“ƒaõÓŸþ´ç^¬ÞŸëéŠhÜóÐqºRÿùHÖg[mµUqÿý÷W·8Ø,î»ï¾b„ Ù}ºžþ=÷>t”ó#ÇúlÛm·-zè¡ê›Õƒ>XLœ81»_×Óyä”ÈG±>Ûi§�ŠGy¤º½AKxì±ÇŠ×¾öµÙ}»ž>Ðþ<µ&òA¬¶]vÙ¥X¸pausƒ–²`Á‚ �W§ hc{¦ž‰| «mûí·ïyu%´ƒr._­^½�ûiEê�ÚÐN©‘�`µmµÕV>ó—¶óðÃoèg?‘Ú9 �¼,uoäãWmãÇ�/~ùË_V·5h sçÎ-Æ�—Ý×ýtOj|@èJ}+òÑ«¶1cÆsæÌ©njÐVî¹çžbôèÑÙýÝOߌÆY€–ö±ÈÇ®>»õÖ[«[´¥Y³fe÷÷zúH@ û“Ôêȇ®Ú¦OŸ^ÝР­]uÕUÙ}ÞOåY98 í�ZùÈUÛ?ýÓ?U·3èçœsNv¿÷Ó“©�ZÈ©ÿ�|ܪíoÿöo‹îîîênaÍš5Å 'œ�Ý÷ýôãÔ˜€ñ™ÈG­Ú8à€båÊ•ÕÍ :ÊsÏ=Wì»ï¾ÙýßOŸ hoŽ~îï¸qãŠÇ{¬º•AGúõ¯]Œ?>;}´&õ6£ ©ù‘�YµÍš5«º‘AG»ýöÛ³sÐO§^°™|=ò«¶/ùËÕm F„/}éKÙy觯lÇD>^ÕvüñÇÝÝÝÕ] F„òÞ×»Þ•�‹~:*`½2µ0òá*k»í¶+–,YRÝÄ`DYºti±Ã;dç£�¤^0L.�|´ª­ü T (~üãg磟. oIuG>Xe�sÎ9Õ F´Ï|æ3Ù9é£òŒ½5`�K=ùX•µï¾û«V­ªî_0¢­\¹²xÓ›Þ”�—>z(µeÀù|ä#UVWWW1oÞ¼êö$sçÎí9#ÕsÓGg �?H­Š| Êºè¢‹ª›°Ž .¸ ;7}´2õº€Av}äãTÖî»ïÞó6·@ßÊ·GŸ<5ÕÕÕU,\¸°ºcáñÇï9SÕsVÓÓ©— Ðg#�²¦M›Vݰ€Mðñ�<;g}T¾BÖk|4>g´:855jÔ¨â‰'ž¨îWÀ&øÍo~Ós¶ªç­¦òó¹_°'G>6e�}öÙÕí gžyfvÞú褀~ŒN=ùÐÔTù ŧžzªº[ƒ`ñâÅÅèÑ£³sWÓC©1}xgä#SÖ¹çž[ݬ€AôÙÏ~6;w}tt@n‹|`jª«««çsJ�¡S~¾vy֪篦;jìù¸”uÊ)§T·*`|èCÊÎ_íPñ•ȇ¥¬x ºSC`îܹÙùë£XÇ„Ôòȇ¥¦¦N�Zݨ€!tÔQGeç°¦©­zý]ä£RÖ�üãê> ¡Y³feç°�>Ðëg‘JMí²Ë.EwwwuŸ†Pyæ^ÿú×g籦ÙÉ^‘�IY_þò—«Û0 .¾øâì<öÑ€ï¢È‡¤¬'Ÿ|²ºKà<{ÕóØG#ÚèԂȇ¤¦Þ÷¾÷U7)`½ç=ïÉÎeM¿ŽÆ™`„ú“ÈG¤¬[o½µºGÃèG?úQv.ûèÿ#ÖÅ‘HMm¿ýöÅš5kª{0ŒÊ38qâÄì|ÖôÅ`D“z"ò©©Ï|æ3Õ- Ø ¦M›–�ÏšÊ3]žmF˜ƒ#�²îºë®êl³gÏÎÎg•oíÀó¥È‡£¦vÜqÇ¢»»»ºC›AywØa‡ìœÖtQ0âÌ�|8jêœsΩnPÀftÖYgeç´¦yÀˆò†ÈG£¬»ï¾»º?›Ñ/~ñ‹ìœöÑë€ã”È£¦¶Ùfoÿ -¦<“'NÌÎkM'#ÆM‘FM�rÊ)Õí h'Ÿ|rv^kº>¶L=ù`ÔÔ ßû^uwZÀu×_—�ךV¤^t¼C#‹²–.]ZÝ�€°dÉ’ì¼öÑ�@ÇûLäCQS‡rHusZÈ�˜�Ûš¦ïæÈ‡¢¦¾ð…/T÷& …\pÁÙ¹­©ü¬o:Ø©å‘EMÝqÇÕ½ h!·Ýv[vnkz&5&èX{E>e-_¾¼º7-¤<£ÕsÛGo :ÖÉ‘DMM™2¥º5-hß}÷ÍÎoM'ë?"ˆš:óÌ3«;Ђ¦M›–�ßš¾t¬"ˆšºé¦›ª;Ђn¸á†ìüÖtoБƥVG>5µ`Á‚êδ Ç<;¿5•gþÅ@ÇÙ+òq¨©qãÆU7& EuwwcÇŽÍÎqMo :ÎßD> 5uØa‡U7& …zè¡Ù9®é]@ǹ òa¨©O~ò“Õ} hagœqFvŽkú|Ðq¾ù0ÔÔ7¿ùÍê¾´°éÓ§g縦€ŽóXäÃPSwß}wu_ZØìÙ³³s\Óü £¼(µ&òa¨©åË—W÷% …-]º4;Ç5­NmtŒ×E> 55~üøê¶´�±cÇf繦×ã È¡¦¦L™RÝ•€6°Ï>ûd繦·ã}‘BM�p Õ] hÇ|vžk:>ègE>5uÎ9çTw%  œuÖYÙy®éÌ c\ù ÔÔ7¾ñ�ê®´�+®¸";Ï5]tŒ"„šš9sfuWÚÀÍ7ßœ�çš®:ÆO"„šºë®»ª»ÐfÏž��çšþ7èó#„šzä‘Gª»ÐæÏŸŸ�çš :Æ3‘BM-]º´º+màé§ŸÎÎsMK€ŽðâÈÇ ¬îîîê®´�5kÖdç¹�Æmo»È‡ ¦&L˜PÝ”€6²å–[f约m€¶÷ºÈ‡ ¦vÝu×êž´‘׿þõÙ¹®éµ@ÛÛ-ò!¨©½÷Þ»º'mdÏ=÷ÌÎuMoÚÞF>5uÀT÷$ �ì·ß~Ù¹®irÐöö‰|jêàƒ®îI@yë[ßš�ëšþ8h{D>5uøá‡W÷$ �¼ãïÈÎuMûmï È‡ ¦ÞùÎwV÷$ �uÔQÙ¹®é­@Û{[äCPSÇ{luOÚHy†«çº¦òÿ hs‡D>55uêÔêž´‘£�>:;×5•ï@›{KäCPS>ÚÛa‡–�ëšÚÞ›#‚š:ä�Cª{ÐF>øàì\×ô¦ íýQäCPSS¦L©îI@Ùÿý³s]Ó€¶·{äCPS{ï½wuOÚÈ^{í•�ëšv ÚÞD>5µÛn»U÷$ �ìºë®Ù¹®éu@ÛÛ!ò!¨©m¶Ù¦º'mdë­·ÎÎuM¿´½-#‚²º»»«›Ðʳ[=Ï}ôâ #¬ˆ| jjÙ²eÕ] hK—.ÍÎsMË€ŽñHäƒPS�>úhuWÚÀÃ?œ�çšæcväƒPS÷ÜsOuWÚÀ]wÝ•�çš~tŒÿŽ|jê–[n©îJ@øá˜�çšn :ÆôÈ¡¦®ºêªê®´�3fd繦¯ã¼È¡¦Î?ÿüê®´�óÎ;/;Ï5�tŒÿù ÔÔI'�TÝ•€6ð�| ;Ï5} èù ÔÔ!‡RÝ•€6pà�f繦C€Ž±käƒPSÛm·]uWÚÀV[m•�çšþ è/�|ÊZ¹reu[ZØŠ+²s\Swê%@GYù0ÔÔܹs«ûÐÂæÌ™“�ãš:έ‘CM]{íµÕ} haßþö·³s\Ó�€ŽsqäÃPSŸûÜçªûÐÂÎ:ë¬ì×ô… ãœù0ÔÔÔ©S«ûÐÂŽ<òÈì×tBÐqö�|jjÛm·­îK@ Ûzë­³s\Ó>@ÇÙ*òa(kÉ’%Õ� hA‹/ÎÎoMÝ©—éW‘DMÝrË-Õ� hA3gÎÌÎoMó€ŽumäQSÿüÏÿ\Ý™€tÞyçeç·¦k€Žõ‘DMvØaÕ� hA‡zhv~k:-èXD>55jÔ¨bÕªUÕ­ h!+W®ì9«Õó[Ó›€Žõ’ÔÊÈG¢¦î¾ûîêÞ´�Ù³gg綦ò¬—g€ö¿‘EM]vÙeÕ½ h!—^zivnkúqÐñþ9ò¡¨©©S§V÷& …yä‘Ù¹­éÜ ãùPÔTùÙ¢Ï=÷\usZÀŠ+Š®®®ìÜÖtDÐñ¶N­‰|,jjÖ¬YÕÝ h3gÎÌÎkM«S¯F„Û#Œšúä'?YÝ�€ð‰O|";¯5ÝŒÓ"Œšš4iRuwZÀ.»ì’�ך>Œ{G>eýêW¿ªnOÀfôÐCeç´�ö FŒQ©'"�šúâ¿XÝŸ€Íè_þå_²sZSy¶Ë3Àòõȇ£¦öÚk¯êþlF“'OÎÎiM—#ÎÑ‘GYå[Λ߃>˜�Ï>:2qÆ¥ž‰|&5uà�Vw)`L™2%;�5Í‹ÆY€øDäƒRÖý÷ß_ݦ€!tß}÷eç°�>ÐëwS«#•š:ùä“«û0„N:é¤ìÖô|jû€u|7òa)káÂ…Õ� åY«ž¿>úN@Åa‘KYŸûÜçª;0>ûÙÏfç¯�ÞP1:õËÈÇ¥¦ÆŒS,[¶¬ºUƒè™gž)¶Øb‹ìüÕ4/g2'E>0e]vÙeÕ½ D—^zivîúèÄ€>ŒKý&ò‘©©m¶Ù¦xöÙg«›0V¬XQl½õÖÙ¹«éÉÔ–ý8+ò¡)Ë«€ah\rÉ%Ùyë£O¬ÇÄÔ³‘�MM�?¾X¾|yu»6Ay¦&L˜��·šžKm0ÿùà”uñÅW÷+`\tÑEÙ9ë£  ßO­Š|tjjܸqŲe˪°–.]ZŒ;6;g5­Lí°¾ùð”uî¹çVw,`#œuÖYÙùê£/l ¢ñ9£Õñ©©®®®âᇮnYÀxä‘GzÎRõ|ÕTžÉß Ø_Œ|€Ê:þøã«{°Ž=öØì\õÑéwR+"¡²n½õÖê¦ Àm·Ý–�§>z6µ}À&ø\äCTÖÞ{ï]¬Y³¦ºmýX½zu1yòäì<õÑg6ÑøÔ¯#£²¾ò•¯T÷- —\rIvŽúhAêeƒà½‘RY£F�*}ôÑêÆÔxì±ÇzÎLõõÑ»ɨÔ‘�RYGqDÑÝÝ]ݺ€u”gäÈ#�ÌÎOý4g;©îÈÇ©¬k®¹¦ºwë¸úꫳsÓGå™{sÀ˜ù@•µå–[O=õTuó’'Ÿ|²;vlvnú芀!²mê©ÈGª¬cŽ9Æ[ACEy&Ž;î¸ì¼ôÑ“©‰Cè}‘UµMŸ>½ºÁˆvå•Wf礟Þ0ĺRÿùX•5jԨ◿üeuƒiÞ¼y=g¢zNúèæhœ5r¯M­ˆ|´ÊÚwß}‹U«VU·0QV®\Yì³Ï>Ùùè£òlí0ŒN‹|¸ªíì³Ï®îa0¢|úÓŸÎÎE?}$`˜�IÍŠ|¼ªíÆo¬nb0"|ÿûßÏÎC?Ý’°ì”Zùˆ•5zôèâÁ¬ncÐÑzè¡b̘1Ùy裧S;lFù�UÛ®»îZ,[¶¬º‘AGZ¾|y±Ç{dç Ÿþ" |=ò1«¶w¿ûÝEwwwu+ƒŽRÞãï}ï{³û¿Ÿ.h[¥~ù¨UÛ¾ð…ê^åüóÏÏîû~š—zY@ Ù;õ\äãVm×\sMu3ƒŽðŸÿùŸÙýÞOϦþ( �ùÀÕg·ß~{u;ƒ¶vÇw]]]Ù½ÞOÇ´°/G>rÕ6f̘bîÜ¹Õ ÚÒ¼yóбcÇf÷y?]Ðâ^œúIäcWm;ï¼s±hÑ¢ê–meáÂ…ÅŽ;î˜Ýßýô¿©´�W§E>zÕ6yòäbñâÅÕM ÚÂSO=Uì¾ûîÙ}ÝOO¤vh#oN=ùøUÛÞ{ï],Y²¤º­AK[¶lY±ß~ûe÷s?•gb߀6tTjMä#Xmx`±|ùòêÆ-éÙgŸ-=ôÐì>î§îÔ_´±Ó#ÂúìðÃïÖ ••÷hy¯VïßõôÑ€aðŠÔ?¦®M]ž:¸ùÞd_Œ| ë³òU•Ï<óLusƒ–P¾Jýˆ#ŽÈîÛõtI ž‰©3Sÿ•úJjJÓÿ Àˆ¶Kê‘ȫϮûE›h‹Ôõ‘�>›2eŠÏ¦å”÷ä�ݯë©ü+ÆÄàøƒÔ‚hþ÷Ë·–ž¶Î×0B½.õXäƒÕÚQiϾt“½$usäß§Ï&Ož\,Z´¨ºÁÁfñä“O{ï½wvŸ®§[R[Æà™ù÷XÛ'_ø2Fš�S�F>"­Û§~ûÕƒc|êöÈ¿OŸí¶ÛnÅ‚ ª[ «Ç¼˜4iRv®§§^ƒgB4þÃŒê÷Y·Oüö«1^“z8òñ¨Ú9k/D[¥~ù÷곉'sæÌ©nr0,xà�b»í¶ËîËõôóh|¶ö`zeäß§®�­½€Î·cj~ä£Q]‡ô\1ø¶I͉üûõÙ¨Q£Š™3gV·9R³fÍ*F��Ý�ëé�Ô¶14~ù÷«ë£k/ sí�šùXT×5½× •r »+òïÛoW^yeu£ƒ!1cÆŒìþ@åذ} �)©U‘ߺNé½€ô»©_F>ÕuSê%�ˆTùJà z;è²OúÓÅêÕ««{ ŠçŸ¾øÇüÇì¾@?ImCïÏSÏGþýëòJ`€41þ–Ë?J½´qÙ°˜�šùÏÑoo{ÛÛŠ'Ÿ|²ºÝÁ&Y¼xqqÄGd÷Û*ÏMy/—#c`#pwêÄÞkèåø{OäÃP]·ÄðŽ¿km™º1òŸ§ß¶Ûn»âç?ÿyuÃ�2{öìbâĉÙ}6€nŽÍsnŽŽ��À×{ mìU©»#„ê*_…;¾qÙfñâÔÕ‘ÿ\ýÖÕÕULŸ>½ºåÁ)?[ºzo °©ÅæóÎÔêÈ®jåü�ÞkhCåçëþ"ò!¨®[cóŽ¿ku¥¦Eþó­·÷¾÷½Å’%Kª»ôkÙ²eʼn'ž˜ÝOì‚Ô¨ØüÞ•ZùÏW­�Oh\@;Ù:uWäP]?M½¼qYËø›Ø[Û6µÃ;wÜqGuãƒZå½²ýöÛg÷Ñ*_qÛjo©üžøü¾Þkh[¥~ùðS×ÏR¯h\ÖrJ-‰üg^oŸüä'‹U«VU÷>è±zõêâ /Ìî›¶,õgÑšŽ���Àå××{ -¬ïˆ|ð©ëÎhÝñw­É©ù‘ÿìëm¿ýö+xà�êöÇwÿý÷ûî»ov¿ °‡R{Dk{o4^å[ýÙ«•¯bþËÞkhAR·G>ôÔ5;õÊÆe-¯ü9¿ùï0 ÊW¯X±¢º2¬\¹²çU¿]]]Ù=2À¾ŸzU´‡“"ÿùë*Gà¿è½€ò²Ôm‘ìÔuojÛÆeméˆØÈÏ.;í´ÓЧŸ~ººÒ¡žzê©â#ùHvl@O§ÞíëäȧºV¥ï½€Íh\jfäƒN]÷¥~§çªööºÔO"ÿýÔØ±c‹¯|å+ÅóÏ?_Ý é«V­*.»ì²b‹-¶Èžÿ ¨|;õ�£ý�ùïV×Êhï± í•ãoù¹¤Õ!§®ûSÛ5.ëc¢ñ–ÐåÛ×V×5iÒ¤âÆo¬n‡´¹Ûn»­ØsÏ=³ç{*ßòù‚ÔØè‰ü÷¬«�ßÞ{ èÿ'ò§®¢³ÆßuMI=ùï<à¦N�ZÜ{ï½Õ‘63gΜâè£�Îžß ìW©ý£3M‹ü÷­«�ßÖ¸€áð’ÔG>ÜÔ5?õêž«:×ËSÿùï¾AüñÅܹs«»"-®|ÎÞýîwgÏçFô�ÔVÑÙÎŒü÷®ë¹ÔŸö^À*ß–ö»‘6u•¯fܱ窑¡|ëÚG"6¨ãŽ;ÎÜzè¡âCúPöümD¿N#Çg" êZ‘zKï5 �rü½6ò¡¦®rüÝ©qÙˆ2!Ÿßº&òÇdƒ*‡àÙ³gWwG6³ò­žiø-?ë÷òÔ+bä9+òÇ£®åÑx›uY9þ^ù@SWù™¸¯i\6b½95'òÇfƒÛÿý‹k®¹¦X³fMu‹d˜”�ýÌ™3‹#�<2{~6²¹áÕ­gGþ¸ÔUŽÀ4.�A³kä÷Z]O¯½:É©oGþ‡ñºÊ·@þýÆe#Þ‹SO=ùã´Áíºë®Å•W^Y,]¶´ºO2D–.]Z\qÅŤI“²çc#[–:=õ¢ +uaä�Q]åÚ¿q 0#ÖèÔôÈÿ(^ׂÔë—±ŽíRÇ ¼-tYWWWq '·Ýv[ÑÝÝ]Ý,åÛ<Ÿ~úéŨQ£²Ç#+ßîyFj‡`]åü…ȯº–¤ön\›Ì ÀˆTŽ¿_�üâu-L½¡q}(Ç«Û"ì6ºÝvÛ­¸ôÒK‹… V7L6Ђ ŠK.¹¤ç•ÖÕÇy»5õGA_Êø¢È·ºÊ1n¯Æe°I ÀŒ8åø{Eä ¯«'5.c=ʱë˜Ôý‘?Ž›ÔÁ\|ík_+-ZTÝ6éÃOžu-NíÙ¸ 6Š€cTê«‘ÿ¼®§Rol\ÆFÚ"õWÑ «�ï ´óÎ;÷|ží�7ÞX<óÌ3Õ=´ã=ûì³=Ÿ—|î¹ç{ì±Göø b§>˜zQ°±Êÿÿ¹<òǶ.ÿÿæ00"”¯Àû×Èÿ^Wù ¼?l\Æ (GÿI͉ü±´ºººŠC=´g �9sf±dÉ’ê^ÚöÊß©üÝÊß±ü]Ëß¹ú8 r÷¤ŽO� ƒw `8€€¶T¾ÅìŠÈÿ˜Y6ÿ…/ƒåøûÅÈ|çÐ)Ÿ‡?KÝùã>$Mž<¹8õÔS‹éÓ§wÞyg±lÙ²ê¦Ú²–.[Züìg?+¾ñ�oýèG‡ú¾Õ¾Ÿz[øŒß¡PŽÀWFþ˜×å3ÈÙ` -•o�YýCæÚæ¿ðeÐ3`]ù}RWùÇð½—1ÄÊÏ8ýFjeäÏÃ�¶ÓN;Çw\ñùϾ¸úê«‹Ûo¿½xôÑG‹çŸ¾ºÁn�… ×^{mÏ`ûƒü xî¹çª_’)¿gù½ËŸáª«®*Î;ï¼âØc�-vÜqÇìç†ÊçâëáÕïá�Ëû¿úÔUŽÀoh\ò»©éèÒµ´‚÷GþGòµÍáËáÊñ÷_"¿GêZ’Ú»qÃèU©¿OÝùs2ì•ãðÛßþöâýïqÆgçŸ~qùå—÷ »³fÍ*î¸ãŽbΜ9==ôÐC=ãmYù Ýê¿5vìØâ¢‹.ê¹¶ü7Ê«ü7Ëûmo{Ûæyëº7uJj›`8•Ÿ‘ýŸ‘?u-H½¾qt¦�üäk›ÿ—1Â�ùýQ×3©ý{¯aó)_yzq4ž�ês¤Áí¹ÔŒÔ�ámž7§r¾&òç§®GR;7.€ÎsOä_Ûü¾ŒììÈï�º–§h\B‹Ø*õ®Ôõ©U‘?gڸʷxþnê¯R‚V16õ�ÈŸ¯ºÊø÷—@çŸZùÆ×6ÿ·_ÉHuVä÷E]åø;¥÷ZÓË£1X^Æà�iuê–Ô£ñvÛ´¦r.ïñêóW×é�—@g(»êÄ×mþo¿’‘èÓ‘ßu­H½¥÷ÚÃÖ©¿H}-µ0òçT�ÊÏ‹ýjjjê•A»xQêºÈŸÏº~•Ú±ç*è§EþÇðu›ÿÛ¯d¤ùTä÷C]åçŸþiï5´§Q©=S�Æ+\Ë·7®>Ï#¥ò~þaêc©7†Ïômg/IýwäÏq]óà @‡øVä¯þQœ‘gZä÷B]åPø¶Æ%t�-R�·:ž‘Zùsß)-MÝ�{þÀhŒ†tŽq©ïGþ¼×57µ}ã2h_�FþGðu›ÿÛ¯d¤øûÈÊñ÷í½×ÐÙÊWÁ¾!u\ês©ï¥�üžhõÊŸ¹üÙËß¡ü,äòwò ßÎWŽÀ7G~?Ôõ@j»ÆeÐ~Ê?rWÿø]mþÚ/fD8%ò{ ®rü}Gï5Œ\ågâ–Ÿý|bêóÑxG�Ÿ¥G~Ï WOEãg(–ògú»hüŒ>¿wdÛ253òû¥®ûR¿Ós´™#"ÿÃwµùk¿˜ŽwräÏ]«R‡õ^}Ù*µGê�Ô±Ñx;éòs¥/JM�ÆÛ.ÿOê'½Ý“z0µ"ò{nÝnˆÆ¿Qþ['¥þ2ߣü^å÷„¾”#pùùÎÕ{ª®rÞ¶q´�ÏFþGïjó×~1­çªÏ}]«SS{¯�¡ðƒÈï»u›ô—›�º=òûª®»R[7.€öP¾ú®úïjó×~1ë}©îÈŸûjåø{Lï50TfE~ï­Û./|)l”rþßÈï­ºf‡· …ù³‡º�ƒvò71ðñ·|«]j·E~ÿ­Ûï¿ð¥°ÑÊ· ¿#òû«®;Ã<’íù=Q×Ók/J`úóžÔšÈŸÃjåø[~†+ ‡òó€«÷ຽú…/…MRŽÀë»ßÖö³Ô+—1€€–b¦/ïŽ��¿å×üUï50Ê·Ü­Þ‡ë¶ý _ ›l›h|Öoõ>«ë§©—7.c1-ÅL�¿Ž��¿å[CŸÐ{ —»#¿×mâ _ ƒ¢�ù½V×­©—5.c„0-ÅLÕÑÑxKçêóV­?Ð{ §û"¿×më¾Í«R÷D~¿Õ5+5¾q#€h)`ÖuTêùÈŸ³jåøû·½×Àp›ù=¹nåç¶ÂP(_]>'ò{®®[R/m\F‡3-ÅÌZGÆÀÇß{¯�Íá¡ÈïËu3º1”¶MÝù}W×�Âý8€€–Rþ!ûØ>úT伬kQ͵ýå�á­çÏS«"nëúhï5°¹<ù}¹n/~áKaHünê—‘ß{uý0µeã2:”hoŽü�—uÍïýzÚÓŸ¦ž‹üy­ë´Þk`súuä÷æº�yáKaÈü^êÁÈn ÿaB'3mÃÜù���¿ë½6·ò]ª÷çºu½ð¥0¤vHÍ‹ü¬ë{aîT` m€;ÛÁ©g#>ëúxï5Ð G~�®mõ:_ÃáÕ±þÏ¥^Û¥^Ô¸ŒbÚ†¸sÏè½ZÅÒÈïÓµ•¯h‡á¶sêÑÈïǺ®#p§1mÃÜ™L­ˆüy¬kZãh)ýݿϬóu0œ^›z,ò{²®o¥¶h\F0mÃÜyö‹Æ@V}ë:·÷h5«"¿_×f`asz]êñÈï˺®J�i\F›3ÿŸ½ûŽ·¥*ïÇÿÜ{ñ"E   (E4 1*vÁò3Æ & kbC¾X‚Á *Á†•b׈ØE$Š% ±""HW‘&J�vïþ­¹³{î¬ÙçìÓgö~¿_¯Ï?°ž³Û̺³æÙ{ 34€ÇKñyþ1òϯ)ïè×@­�|›�Ê*ã`9ì˜rqäÛfS>šÀã@è àññ�˜þ¾©Õ¼«_m´*òm¶šKCaÙÜ3å’ȷϦ|:4�»Nè àñðà½ùûî~ ´ÕF‘o·Õ÷`…6¸oÊe‘o£M9*Ê/7ÐMÀ@ghw߃R®Šü3kÊá)+Ê2h­Õ‘o»Õüz0–ÝýR~ùvÚ”§¬,Ëè ` 34€»mç”Ë#ÿ¼šrDhþÒ ›E¾ýVsî`(´Âý£¼7u}[mÊC¸‹4€€ÎÐî. ÆÕí"߆«9s0Zã1úrŽ sr×h�¡ÜM³¹äè‡B£�nÙ*òí¸šÓC¡U\’|i�¡Ü=÷M¹,òϨ) Í_ºçŽ‘oËÕœ: ­óà”«#ßn›rH¿†öÓ:C¸[î™rqäŸOS>�²AY�²]äÛs5? …VÚ5å�‘o»MyW¿†vÓ:C¸;vL¹(òϦ)G‡æ/ݵCäÛt5? …Ö*þ}µ üŽ~ í¥ t†p7Ü#Foþ~64é¶»G¾]Wsò`(´ÚÃR®‰|nÊÛû5´“0ÐÀíW4Ã.Œü3iÊçR6,Ë ³vŠ|ۮ滃¡Ðz»§ü)òí¸)ÿÙ¯¡}4€€ÎÐn·?OùmäŸGSŽM¹UY�vŸÈ·ïjN …NxlŒÞ~c¿†vÑ:C¸½îœr~äŸES¾š¿Œ�ûG¾�WsÂ`(tÆãbô&ð¿õkh ` 34€Ûiû”ó"ÿšr\ÊÆeŒ…]"ßΫ)¾ð]ô„”ë#ߦ›òú~ í  t†pûl—rnäŸAS¾š¿ŒŸE¾­WsÌ`(tÎSSnŒ|»nÊëú5,? ` 34€ÛåN)çDþþ7¥¸ î­Ë2+3ÍK_ …NzZŒÞÞ·_ÃòÒ:c¦FËT~ÕÏâÙ:åŒÈßû¦|'e“² ÆÎ##ßæ«ùì-#¡»žžrSäÛw=kSþµ_ÃòÑ:C¸Šæï/"ß›ò¿)·)Ë`,=&òí¾š£C¡Óž£7�_Ö¯ayh�¡¼ü¶J9-ò÷¼)'¥lZ–ÁØz\äÛ~5Ÿ …Î{VÊÍ‘oçõMà—ökXzÀ@gh/¯-S~ùûÝ”“CówR½#òíaº\Ÿrûu•ÝôÄÈ_S5¹e$Œ‡ç¦¬‰|[¯§h¿¸_ÃÒÒ:CxùüYÊ©‘¿×MùqÊmË2&ÌÆ)ˆ|›˜)¯.Š;êÉ‘¿žjŽ …±ñO1zø…ý–Ž0ÐÀËc‹”Eþ>7å')·+˘@Ï�|›%gÅõw‘¿žj>0 cåù1Z¸óœ~ KCè à¥W4ù{Ü”Ÿ†æï¤û~äÛŨyTtSqOÔúk©æÐÁP;/ˆòW¾õí¾žâ¾Á{ökX|À@gh/­ÍS~ùûÛ”S¢Û÷qeþvŽ|»˜M>Ýô÷‘¿–j …±ôò½ \ì/,> ` 34€—NÑüõל?‹òÁL¶Ã#ß6f“S¶ŠîùÇÈ_K5ï …±õÊÈ·ý¦Mà=ú5, ` 34€—Æ&)߉ü}mÊ)w(˘`›¥\ùö1Û¼6ºg¯È_G5 †ÂX{UäÛSŠ/{<¥_ÃâÐ:CxñÍßoEþž6å—)w\WŤû—È·�¹ä¼”Ñ-/ŒüuTsà`(Œ½½#ßšR4�ÿ¶_ÃÂÓ:Cxq­N91ò÷³)g¦lS–Aœù6RÏ ÿ­)�‹nyi䯡šÿ …‰°OäûASnHyR¿†…¥ t†ðâ)š¿ßŒü½lÊY¡ùËÀ#"ßFšrï”Ëþ{=Ÿ�nyy䯡š …‰±äûBSŠ&ðß”%, ` 34€Ç­Sþ'ò÷±)¿J¹óº*(ùvRÏéý±G6ü¿znŠn}Á`¦ûž¾a0&Jñë÷úþДëSþ¿~ Cè à…·QÊW"›òë”»¬«‚ÒVQþ‚¯¾­Ôó–þøÇ7ü¿¦ì×ß3]îvßÁP˜8oŽ|ŸhÊu)�î×0À@gŒÚ¾pª€iÍßc"ÿšR4w(Ëà¯�|[iÊ.ýñ¦\ÑðÿëùMʪ~MÛ½.òç_ÍÞƒ¡0‘Š/€Ô÷‹¦\›ò¨~ ó£ tƨ `'4gV4¿ù{×”¢wײ n±2Ê_Û×·—zÎë�ŸòÑÊÿ›.]¹/hñkåús¯æƒ¡0±Þù¾Ñ”¢ üȲ„yÐ:ã�‘Ÿ¼lÊÚ”Õýrů0¿ùûÖ” RîV–ÁzŠm}{iJÑø©úÛÊÿ›.ťɻ`ÿÈŸ{5/»e$L¶ÿŒ|ÿhÊ5)ï×07À@gÜ3ò“—Ãò€~ ë+.«{TäïWS.IÙ©,ƒÌ±‘o3M¹ÿTAß­¢l:ÔÇÕ³&eû~M›Ít�Ó†ÂD[‘òÞÈ÷‘¦\•ò ²Œ9Ð:㎑Ÿ¼–⾜¬¯hþ~*ò÷ª)Eó÷^edîeƒ¶¾ÝÔó‹þøºG>¶)L´Ø[#ÞÕ¼`0&^Ñ>4òý¤)Es²¸ò³§ tFñËÁ›#?�ٔ⾣—eDÙüýdäïSS.M¹wY�F½Ÿçë§ jv�|lS.JÙ _ÓVGþ¼«yÞ-#�BÑ~äûJSŠå_•eÌ‚0Ð)?�üæ°|"Úßÿv/4 ` ³ŠûÖ—‚¬ŸÐœKÆá$rqŸÔúëjJqÖGôk`T§D¾-5åµS#ú«ÈÿÆ°ìØ¯i“£#žÕ ``,�àç¥|<åg)FÙ¾9ÊË¿.šLǧ–òŠ”Ç¤lÝ÷æÈOê6¥hþ>º_³QìWõí©)_š*˜¥b¬ÿ­¦ü!eã~M[¯¹þ<«)šçÀìMà¯D¾O5å×);”eÐmÿù‰ð¦\eÃfëö1ú/ìÿ¶_3[·‹Ñã9ýš¶86òçXÍC�YºuÊÿD¾_5åW)w)Š «ö�üxSnHybY³¶OäÛTS.NÙ _3GEþ7›ò¿S-Q\U þ«yÀ`(0«S¾ù¾Õ”³£û·t`B�Ú”+š¿Ó¯�ÙZ‘rNäÛUSÞÖ¯™«Ý#ÿ›ÃRÜß²-fjLÝw0˜£¢ |bäûWSÎJÙ¦,€nØ;òÞM)š¿s½$/ùv5,÷è×ÌUÑl>?ò¿Û”ÿê×´Á·#~ÕÜk0˜‡MR¾ù>Ö”3S ZîU‘ŸènÊ�)Oî×À\}1òm«)ß™*˜§7Fþ·›re”¿lƒ“"~Õì8 ÌSÑžéKSùeÊÊ2h§WF~‚»)7§<«_su§(·¥úöÕ”çökæk»”5‘ÿý¦ìÕ¯Ynß�ü¹Us·ÁP`l3ïwS9#eë² Úåå)k#?¹]OѰ{v¿æã?"ß¾šru,ì¯q�‹ü1šòƒ©‚eö£ÈŸ[5w ÈæQÎõý­)?K¹}YíðÏ1zó÷ú50¦\ù6Ö”Ãú5 å‘?ưìܯYN§Dþ¼ªÙv0X@[¤ü0ò}®)? M`Zâù1Ú%q‹æïžý˜¯Ù4aدY(¥\ùã4e¡›ÏsqZäÏ«—Ÿ…ÅS4�gúþTŠ&ðíÊ2Xÿ£5‹1ÿد�…ðÍÈ·³¦ü|ª`�½3òÇjÊS6í×,—_Fþ¼ªù³ÁP`ûØ©‘ï{MùqÊmË2XZϋњ¿Å¥¡_T–À‚Ø1òílX^Ù¯Yh÷Žü±†åÅýšårNäÏ©šâŠÀâÚ2Ê/¤Ô÷¿¦|/e³² –Ƴ¢¼¤sý¤u=Eó÷%ýX(ïŽ|[kÊ ±¸¿ný~ä�ٔⲮËéüÈŸS5· ÑV1ó%Ù§rr,ÿÕ˜ŽWoŠüdu=Eó÷¥ýX(·N¹"òí­)G÷kË "Ìaùë~Ír¸ òçSÍÆƒ¡À"+î¹ý‹È÷æüoø‚‹ìé1zó÷_ú5°�öŠ|{–ÇõkKñë¼k"ܦ|¨_³.ŽüùT³Á`(°Š&ð‘ï‹Mùnh°Hž–rcä'§›òÿú5°Ð~ùö֔ߤ¬ì×,¦¢±[ì¦\Ëw¯ÝË">Õ¬ –È�bæûsOå;)›”e°0ž£7÷í×ÀBÛ%òímXÞÔ¯Yl»FþØÃò¯ýš¥6Ý%³‹{yËc»”s#ß/›òõ(/�óö„”ë#?Ý”×õk`1ŒúkÛâäw)K–Ĩ—r=mª`‰]ùs™J±oËgû”ó"ß7›r|¸g7óôø”?E~º)oè×Àb(.Ÿ|]äÛ]SNè×,•×Dþ†å¡ýš¥4ÝûVÜÃX^wN9?òý³)_ M`æè±1zów¿~ ,–WF¾Ý Ëýš¥²UŒ~‰ôOôk–ÒtÏíÊÊ8`ùüyÊo#ßG›òÕ”[•e0šÝcôæïþe ,ª_F¾í5å±<�‘ÏGþ\šR\rùöýš¥R\»þ<ªïÐwO¹0òý´)Åœ³aYÓ{X”—…­ŸlnÊÁýXL�Ž|ÛëröŽ¥³*òǯæÒÁP î‘rQäûjS>šÀÌ ¸?é#?ÉÜ”·÷k`±}&òí¯Ë93–ÎF‘?~5ů �vÙ1Foóãe¬o×½ùûÎ~ ,¶;¦Üù6Øõ¿j^ «#ìj~= ´È=S.Ž|ŸmÊÑ¡ @̓S®Žü¤rSÞݯ�¥°_äÛà8äÓ±46‹ü±«9w0h™û¦\ù~Û”£¢¼ä;ăR®ŠüdrSKYQ–Á¢+š¿‰|;‡Ü˜²u,¾ÛEþØÕ,åå¨�Ù»_Êï#ßw›òᔕe“ê)—G~¹)G„æ/Kë)‘o‡ã”ׯâÛ*òÇ­æôÁP ¥îŸò‡È÷ߦ|(4�&ÖlN(0œPféù¶8N9/ÿKÅ=”ë�[Í©ƒ¡@‹í£aëÈðo6ÀÄ)š¿£^RÒ¯‰XwKYùö8ny\,®í"Ìj~2 ´ÜlnÙpx,þLh‰ûÆèÍß�„æ/ËãàÈ·Ça94eÏåÕ‘?ÇaùB,®"Ìj~0 tÀƒS®Ž|_nÊ!¡ 0öî™rIä'‰›òé”Ue,©[Åè_R¸6e‹²¬U~ùsmÊM)ÛôkÃÝ#ÌjN :â!1zø]ýÆÐŽ)G~r¸)G§lP–Á’{NäÛä°—(o£Gþ\‡å�ýšÅ°Sä�WÍwC�yhÊ#ß§›òÎ~ cä)E~R¸)Ÿ Í_–Wñ«Ôúv9,ݯi›McôæÌobñ~mŸÈ¯šC�ŽyXÊ5‘ï×My{¿€1P\öÂÈO7ås)–e°,îùv9,Åe–ÛìðÈŸó°<©_³ÐîùcUsÂ`(ÐA»¥\ù¾Ý”ÿì×ÐažòÛÈO7åØ(ï½ Ëé°È·Ía).³Üf»Dþœ‡å+ýš…6ÓsøÚ`(ÐQ�MùSäûwSþ½_@Ý9åüÈOþ6å«¡ùËòÛ,F¿lr1®¸ÌrÛý8òçÞ”5)Û÷kÒƒ"¬jŽ :ìq1zx¿~ R4’΋ü¤oSŽKÙ¸,ƒeõ²È·Ïa)~)Ü/Œü¹Ëýš…ôÐȧš/ †÷ø”ë#ßÏ›ò†~ °]ʹ‘ŸìmÊñ¡ùK{ü<òmtXЯi»Û¤\ùóoÊE)”e æ‘‘?N5Ÿ½e$0þ¿½ üº~ -v§”s"?ÉÛ”¯§Üº,ƒe÷°È·ÑaùA¿¦+>ùk–§õkÊn‘?F5G †c¢˜GnŒ|oʾýZè)gD~r·)ßIÙ¤,ƒVøïÈ·Óay~¿¦+vŽü5 KñÅŒ…TÜ´þÕ|b0#—rSäû|=kSþµ_@‹l�rzä'v›òÝ(/K m±eÊ ‘o«M¹*ºùå…FþZšR4cîÖ¯YOŒü1ªùÈ-#�qóŒ½ \܃€–Ø*呟ÐmÊI)›–eЯ�|[–÷ökºæ‘¿–a9¨_³žù߯æÈÁP` =3åæÈ÷ýzŠ&ðKû5,£â—“§E~"·)'‡æ/í³2åüÈ·×a¹OYÖ9ů–‹_/×_OS~—²QY6oÅe`뿚âþÄÀx{nÊšÈ÷ÿzŠ&ð‹û5,ƒ¢ù{jä'p›ò½”ÍÊ2h•™.Q\Mñ%†.{_ä¯iXöè×Ì׳"ÿÛÕ: Œ±ŠÑ›À/ì×°„¶HùQä'n›ò“”Û–eÐ:_‰|›–ì×tÕý"MÃrb¿f¾þ>ò¿]Í!ƒ¡À˜Û+FkcžÓ¯` ÍßF~¶)?M¹]Y­sç­Qäò”[—e�öýÈ_Û°ìØ¯™�¢i^ÿ»Õ¼c0˜ÅýÈ‹_ùÖç‚zŠûïÙ¯`mžòƒÈOÔ6å””Û—eÐJo‰|»–w÷kº®ø^ýµ Ë;û5ó1Óã4 LˆŽÑ›ÀÅUX$Eó÷ÿ"?AÛ”Ÿ¥üYY­´QÊ¥‘o»Ã²SYÖy«S®Œüõ5å)—esVÜ˳þw«9p0˜ ¯ˆ|>hJÑ^¨{’P±YÊ÷"?1Û”3RîP–Ak …ú¶;,ßî׌‹÷Fþ‡e¾÷á|i䳚7 †æU‘Ï M)šÀÏê×°6‰²V?!Û”_†æ/Ý0ê6]dÜ.AzŸÈ_㰜ԯ™«—Gþ7«yã`(0�^ù¼Ð”SžÜ¯`ŠËÅžù‰Ø¦œ™rDz Zí^‘o¿ÃrYʭʲ±rrä¯uXþ¢_33ýÂï ƒ¡À„zMäsCSnHyR¿€9(š¿ßŒülSÎJÙ¦,ƒÖû¯È·áa9¸_3nžùk–â’ÑsµO䯚}C� ¶äóCSŠ&ðß”%ÌÆ­S¾ù‰×¦œ�²mY°ž×E>gT³÷`(0áþ#ò9¢)Eø‰ýFP\îöØÈO¸6åW)w)Šìù¼QÍ+CâÍ‘ÏM¹.å1ý¦±QÊ1‘ŸhmʯSv(ËíùÜQÍËn PzKäsES®MyT¿€Eó÷Ë‘Ÿ`mÊoRîZ– 5Ó¯ù^< p‹·F>_4¥h?², jÔ/E~bµ)¤Ü­,˜ÖLMœ †¬ç È猦\“òˆ~ Q6¿ù Õ¦\’rϲ `FG>�Tó¼[F¬oEÊE>o4¥h?¬,˜l«R>ù‰Ô¦Íß{•e#yWäsI5{†dŠ&ð¡‘ÏM¹2å¯Ë2€ÉT4?ù Ô¦\š¿ÀìÍôë½=CMà÷E>4¥hÿUY0YŠæïÇ#?qڔߥüEY0+ï�|N©æéƒ¡CMàD>‡4劔]Ê2€É°2åc‘Ÿ0mÊe)÷)ËfíˆÈç•jž: 0­âøå£‘Ï#M)šÀY–Œ·â4‡E~¢´)Nžóõ¡Èç–jž4 0£Ù\ÁÄ—Ø€±W4gºëT\>X3]mà ƒ¡#)šÀŸˆ|>iŠÛXc«hþù‰Ñ¦\™òWeÀ¼üwäsL5»†Œ¬hÏ4¿LåÒ”{•eã¡hþ¾7ò¢M)š¿]–ÌÛÑ‘Ï3Õòy§š †ÌKÑþJäóLS~�²CYÐ~D~¢³)×¥<¦_°¾ùÜSÍ}CæíÖ)ÿù\Ó”_¥Ü¥(h³7E~‚³)7¤<±_°X¾ùüSͽCÄê˜ùË'S9;eÛ²  }ö‰üÄfSŠæïßôkÓI‘ÏAÕì8 °`Š&ð7#ŸsšrVÊ6e@{ìù ͦÍß'õkÛ÷#Ÿ‡ª¹Û`(À‚*šÀߊ|ÞiÊ™)w\WЯŽüDfSnLyr¿`)ü(ò¹¨š;†,¸MbæKÑOå—)w(Ë–Ï+#?�Ù”›SžÕ¯X*§D>UãÞ›ÀbÛ,f¾ÁTÎM``½<ò—M)š¿{ôk–Òi‘ÏIÕl= °h6Où¿Èç ¦ü,åÏÊ2€¥óÏ)k#?iYOÑüýû~ ÀR+.©ZŸ—ª¹ý`(À¢Ú"凑ÏCM)®^`~–ÌóSÖD~²²ž¢ù»g¿`9œùÜTMÑ�X*Åœ3ӽɧòÓ”Û•e‹g¯­ù[ŒyN¿`¹œùüTÍmC–DqyçS#Ÿ�šò“”Û–e ïy1Zó·¸4ô Ë€euAäsT5†,™-S~ùœÔ”ï¥lV–,œgEyIçúIÉzŠæï‹û5ËíâÈç©j6 XR[¥œù¼Ô”“S6-Ëæï™)7E~2²ž¢ùûÒ~ @\ù\UÍŠÁP€%·uÊ/"Ÿ›šrRh àé1zó÷eý€¶¸"òùj*ÅU –[Ñ>=ò9ª)ß ÷.æáïbôæï¿ökÚäêÈ笩\_°œî”rNäóTS¾“²IY0º§¦ÜùIǦìÛ¯h›ë"Ÿ³¦rMeÀrÛ.åÜÈ窦œ�rë² `fOˆò—qõ“�My]¿ �¦û"Ë••qm°}Êy‘ÏWM9>eã² `¸ÇÇèÍß7ôkÚª¸D}}îšÊï+ãÚ¢hŸùœÕ”ãB˜ÆcSþùÉŦìׯh«U‘Ï]Õ\: Ð*wNùUäóVS¾šr«uU»ÇèÍßï×´ÙF‘Ï_Õ\8 Ð:w�ržªÏ]M964�€Š‡¥\ùÉĦüg¿ íVG>‡UóëÁP€VºGŒÞþ\ʆe0ÉšòÇÈO"6åíý€.Ø,òy¬šsCZkÇ”‹"ŸÃšò™” Ê2`í£7ßÙ¯èŠÛE>—Usæ`(@«Ý3åâÈ籦šÀ0‘’ruä' ›ò®~ @—lù|VÍ郡­wß”Ë"ŸËšòé”Ue0 œrUä' ›rHÊŠ²  S¶‰|N«æÔÁP€N¸_Êï#ŸÏšò‘”•몀±ö€”Ë#?IØ”ÃCóè®í"Ÿ×ªùÉ`(@gÜ?å‘ÏiMùPhÀXÛ9F?axd8atÛ‘ÏmÕü`0 SfsL÷ÁpLc©øµÈ¨— ôk`Ü=òù­š“C:g6Wu9"\ÕÆÊlî÷áÐüÆÃN‘ÏqÕ|g0 “œruäó[S M` ÷I¹,ò“€M9*eUYÐyÅüWŸçª9q0 ³£7�ßݯ橸eÑ\='娔¿\ÿ/šS.Žüä_SŽNÙ , Å¥ïës]5' †tÚ®)Œ|žkÊ;û5‹íV)¦œå=×ÿiýÿ ݵyÊocýoצ<º:hÍß‹"?éהτæ/0~v‰|¾«ækƒ¡�÷°½ üö~Íb¹uÊ×#ÜU@W'ºê'¿ŠMàÇTÆ-¤»§\ùc6ås)–ecåA‘ÏyÕ3 0vK¹.òù®)÷kZÑü-®°P¼"gWÆ@g}:ò“_SYŒ&ðŸGþ‹ãaù|hþãë¡‘Ï{Õ|a0`lìžò§È缦ì_–,˜éš¿SÙþ–ÑÐA+R.‰üÄW5ů4Š_k,„;§œùc4å«QÞ› `\=2ò¹¯šâò÷ãèq1zx¿~Í|�Òü-òœ©è¢�"?éÕ”…ø%pñkŠó#ÿÛM)î{¹qY0¶Š/×Ôç¿jŽ ;�O¹>ò¹¯)oè×ÌÕ¨Íß"ì×@'½4ò“^Ã2Ÿ_o—r^ä³)LJæ/0Š_ÀÕçÀj>1 0–ž£7�_ׯ™­Ù4‹œS–@7Mwÿߦ̥ |§(O¤ÕÿVS¾åI:€IðÄÈçÁj>rËH€ñõ´”#Ÿ›²o¿fT³mþNÅ}€è¤QîÿÛ”Ù4�‹æïÙ‘ÿ�¦|'e“² `"<9ò¹°š#CÆÚߥÜù<Ø”ÿׯ™É\›¿EÜ€Nõþ¿MåžÀ[§œymS¾›r›² `b �ú|XÍCÆÞÓc´&ðÚ”é× 3Ÿæo_ÀZkƒ(›p÷NyxÊSSþ9åõ)oK9$å°~>™rt-ާþÿ;û5¯MyAÊSR–r¯(£x,ºå%‘ŸìšM¦û%ðV)¿ˆ¼¦)ÿš¿ÀdzVäsb5‡†L„gÆèMà—ökêæÛü-R\Á†î±þ`lÜ.Ê…è?¦ü{ʇSNL9?F;y²P)ë¼”o¦|(å�Q>§â¹Ï‘ö9*òÏq¶ijÍßÓ"Û”“S6-Ë&ÎßG>/VSœ¨˜4ÏMYùœXOÑ.¾ÐXµÍß©¸p;Yÿ0VŠoß'Ê“ÅÅ7‘¿šòÛÈ¢mÍQ>ç⹯¡x-¾5½¼.ŒüsšKªMà-S~Þ0¦)ßKÙ¬,˜HʼnâúÜXÍ;C&Êóbô&ð‹Ê’mþÙ3XNÖ¿Œ¥âW‘MÙ7嘔+"_Tv=Å}d‹Ëÿ‹â'¥Ü>X*ó¹ÿoSŠ&pqY´7ü¿¦ãn“m¯ÈçÇj ˜8Å9J¸S\¢w!›¿EŽ–’õ/ci“(�ï�ò”£œì·¯ùÔ(߃¿IY,–GþþÏ7£n³? —E(¼0ò9²šC&RÑØ-~å[ŸëeÌlã>À‹Ëú×ú`lÝ5Ê“¿Å7œÿù‚pÒSÜW©ø†tñ-ð¿ Ò§"¿—"Eó×7ÝJ/�|ž¬æMƒ¡ëŸcq¼£d»`!YÿNë_€+rÿ™r^ä >™>çFy¹¬�ƒùZ¨ûÿÎ&? Í_€ª—G>WVóÆÁP€‰öŠÈçÈ¥È?óeý;÷Xÿ´Ü½SöOùeä‹:™[~•rH”÷‰bvîùû¹Ø9#eë êU‘Ï—Õ¼a0`â½2òyr±sx0Ö¿ Ÿ_…õ/@+l™²wÊé‘/ÞdasZ”'Ñýºt4‹qÿßéRœø¹CP·Oäsf5Å% xuäsåbæ¬`TÖ¿Kë_€%¶"å1)G¥\ùBmɳjÕªÞ½ï}ïÞî»ïÞ{ÎsžÓ{õ«_Ý{Ë[ÞÒûÀ>ÐûèG?Ú;úè£×娝Ûûú׿¾^¾úµ¯Ýòÿ?þñ�¯«)j‹¿Qü­Ç>ö±ëþvñõÇ]¦ïyqoÛGGùYÐl)ïÿ[ܯí™@“×E>oVSœH`àÖQ6eëóåbfÛ`ëßå�õ/À"Û"åÿ¥œù¢lѳýöÛ÷žýìg÷ößÿÞ‡?üáÞÿ|ã½3Î8£wõÕW÷–ÊUW]µî1‹Ç.žCñ\öØc�Þ¶Ûn›=ß%ÊÙQþ²jó n©ïÿ{]Ên@Ý~‘Ï™Õ÷¼ T4Oˆ|®\ìü}Pgýký 0Öî’ò®”«#_€-Jþð‡÷^ûÚ×ö>ùÉOö~ô£-é"w®ŠÅñøÃÞ'>ñ‰Þ¾ûîÛ{ØÃ–½®ELñÙ¼3åÎAaÇÈߣ¥ˆ&0@nÿÈçËj^vËH€É¶\Íß"‡SîÖ¿3²þè®�S>–rSä ®Kqi©Ç?þñ½ƒ>¸÷­o}«wõÛ¿ØÕu×]×ûéOÚ;âˆ#Ö]NkõêÕÙë_à¬I9&åA1Ù^ù{³TÑXß›#Ÿ+«)îÙ0é–³ù[äÌÀúwž¬Ú­X<}=òÅÕ‚¥Xð¾ï}ïë�~úé½5kÖÔ×�c«x­¿øÅ/z‡zèº{+Õß—Îq)ŒÉôß‘¿KM`€�·E>OVó‚ÁP€‰´ÜÍß©Lê}€­‰õ/@;üeÊW"_HÍ;›l²Ioï½÷îwüñ�¸œÕR).›uÜqÇõ^ýêW/æ·£¿å·Ù'ÉRßÿ·)šÀ¥ƒ#Ÿ#«yÞ-#&O[š¿E&í>ÀÖ¿KÌú`iÝ'åó)k#_<Í9m´Qï5¯yM邏NêÝtÓMõµ5Å{ôÝï~wÝb¸xïêïçº÷à?8{ßç‘?¦ìŸr«?ŸŒüõ.w4��IöþÈçÅjž> 0ÚØü�Ê61~¬;Äú`nþ6å¬ÈD³NñÍÜâW?ûÙÏêk6É)§œ²î^R ø­è3Sþ&ÆËo#�mˆ&00©Žˆ|N¬æ©ƒ¡c¯ÍÍß"ÏŽñbýÛaÖ¿3»c”÷À©/€f�Í7ß¼wðÁ÷.»ì²úúŒ%rÅWôÞóž÷ô6ÝtÓìó™c¾’²]t_›îÿÛM``}(òù°š' †Œµ¶7‹| ƃõï±þÈ­ˆòž¨WE¾è™UvÜqÇÞÇ>ö±ÞŸþô§úzŒeRÜ+éK_úRïþ÷¿öyÍ!Å6R\met×?GþºÚM``Ò|,ò¹°š' †Œ­.4‹tý>ÀÖ¿cÌú ´SÊÿF¾Ð™UvÙe—Þ 'œÐ[³fM}ýEKŸÍqÇ¿P áï¤Ü3ºé‘¿ž6F˜$ÿùÀÖ¿Âú˜T«R^Ÿr}ä‹›‘óñ½c¿z¬…o‡ŸÕ1ÇÓ»÷½ï�}ž³L±íìÝû6t[ïÿÛ”¢ ü˜GG>Vó¨ÁP€±Óµæo‘=¢[¬'”õ/0IîœòíÈ4#ç®w½kï‹_übïæ›o®¯¯èˆâ³ûüç?ßÛa‡²Ïw–91ºso¤?�üù·=L¹{Œ·ÏG>ÿUó°ÁP€±ó©Èç½¶§K÷¾sXÿN¼ ]ÿäé)ˆ|3RV®\Ù{Ï{Þ³î¾:Œ‡o¼qÝ}«6Ø`ƒìóžE®Lù‡h¿DþÜ»�wÀxûräs_5 +Å¥”×F>ïµ=gD7Xÿ²ž [ÿ`³”Ã"_¸Œœ½èE½K.¹¤¾~bLüþ÷¿ï½îu¯Ë>÷Y¦¸„çÑ^�ü9w! €ñvläs_5 +÷Š|ÎëJîíeýË´&dý Œ¹û¤œùbe¤<ö±�í�~úéõõcê´ÓNëí¶ÛnÙv0‹ü2ÊYmtAäÏ· éÚ=ÖfëøÈç¾j0 0V6H9?òy¯ ië1ªõ/#óõ/0ÆŠEù5‘/RfLq¹«#Ž8¢·fÍšú‰1·víÚÞÑGÝ[µjU¶]Œ˜â¾µÏŒv¹[äϳí¹)å])+`¼}#ò9°šû†Œ�¿Jùmäs_Ûóþhë_fmL׿À˜*¾Iþ¶È&#åÙÏ~vïâ‹/®¯‹˜0—]vYo¯½öʶ�Y¤¸ìÚ†ÑÏ�üùµ1¿IùXÊ^)w €ÉðíÈçÃjü²w·JylÊ�)'¥Üù\ض´é>ÀÖ¿ÌÛ˜­€1´uÌ|"µ1›o¾yïØ¯[_1áŽ9æ˜Þ¦›nšm/#æÄ”­bùµõþ¿Ey都Ü;&SÑì¨Ï�Õì8 0V§<4eß”R®�|n\î¬M¹C,?ë_Ô˜¬€1SüBæüÈ!3æÏxƺo¼B“Ë/¿¼·çž{fÛ͈9/e§X^¿Žüy-G4|rß�|¾¬¦¸Œ?À$kkCøY±¼¬Yc°þÆÈ£S®ˆ|ñ1mÜëˆQMÝ©ØfêÛѹ<呱<–óþ¿¿JùhÊóRvšü(òù³š;†eCx·”R¾›rCäsçRä}±|¬YT^ÿcä¹1‡Eÿ.»ìÒ;묳êë˜Öyç�×{ÈC’mO#¤ØFŸK¯¸Ÿný¹,VÎOùH” ß»£8%òù´šmChP4„“ò±´ áÓcy<7æð­™‹®€1°"eÿ(ï¿T_lL›×¿þõ½n¸¡¾¶�‘Ûξûî›mW#¤ØV÷‹¥õ±ÈŸÇBÅ%�æï´Èç×jŠû;0º[Çú—ŒþSäsëB¤8¶_Ê9Úú—eѱõ/ÐqÅâ÷Ý‘/0¦ÍŠ+zŸýìgë똓/}éKs½$ÖûSVÆÒXÈûÿV¾w Â/#Ÿo«¹ý`(sP4„‹K&¿)å;±°÷~f, ë_–]GÖ¿@‡­Jù`䋊i³í¶ÛöN;í´úæåŒ3ÎèÝõ®wͶ·òñ” bqmùãÎ&ç¦|(åS¶Ã9‘Ï¿Õl1 À(Â�в!üí˜_Cø­±ø¬i�–¯€Û0åÓ‘/&¦ÍÓŸþôÞ•W^Y_»À‚¸âŠ+zOyÊS²ín„å6½Xn“rcä�;,E÷8¹¤á °tÎ�|>®¦˜ËX<§<2ÊË++fwÉè×Çâ²þ¥uZ¼þ:êV)_ˆ|1möÙgŸÞM7ÝT_³À‚ºùæ›{oxòío„|%Ê_!,–Ã#Ì©¿:+¾ÏIÙnª€%uAäós5Ec€¥SmŸÃÂWÆâC[ÿÒZ-^ÿS,~�‹|ñ0m?üðú:Õûßÿþl;!_KÙ(GñwßœrzÊi)G¦ì™r§ê –ÍÅ‘ÿ»P�Ë%,¯¢!üˆ”OùFÊyQ®Mï_´À¬é„®€)îytt䋆¡Y±bEïØ¯[_›À’8á„z+W®Ì¶ËR|»ßI~€ÉsYäÿ&T³b0€ `ýK§Xÿs±2å“‘/†f£�6ê�rÊ)õ5 ,©ÿøÇë¶Åúö9C>å6Àä¸"ò¦rseãÏú—N²þf£øÅË"_$ Í[lÑ;óÌ3ëkX¿üå/{›o¾y¶�Î�…_{L’?FþoÁT®¯Œ`¼YÿÒiÖ¿À¨Þùâ`hîp‡;ôÎ?ÿüú–Õ¹çžÛÛzë­³íu†¼=˜×EþïÀT®©Œ`¼YÿÒyÖ¿ÀLöŽ|Q04;ì°Cï‚ .¨¯= .¼ðÂÞÝï~÷l»�!¯&Á�‘ÿ0•++ã_Ö¿Œ ë_`˜§¥¬‰|AИwܱw饗Ö×Ð*—\rÉlÁÅ}ŸŒ»µ‘ÿ0•ßWÆ0ž¬;Ö¿@Ý.Q^î°¾h̶Ûn»îÛ¥ÐÅ"¸ø¶~};ž&ÅeAŒ«U‘ÏýÕ\: À²þelYÿSvH¹$òE@c¶Øb ÷<¢s~ó›ßÌöžH¿Kùó`mù¼_Í…ƒ¡Œë_Æžõ/°YÊ‘ü7fÓM7í�sÎ9õµtÂÙgŸÝ[½zu¶]O“_¤lŒ›Õ‘ÏùÕüz0€1býËİþ€Éµ"åó‘ô7fƒ 6è�~úéõ5tÊ/~ñ‹ÞªU«²í{š|&Ê}€ñQ4êó}5ç†0&¬™8Ö¿0™^ùÁþМ|òÉõµtÒI'�”mß3dŸ`œÜ.ò¹¾š3CÖ¿L$ë_˜,�I¹9òýÆuÔQõ5tÚg?ûÙl;Ÿ&žò¸`\lù\_Mq DƇõ/Íú&Ãö)—E~�ߘw¾ó�õµŒ…ƒ:(ÛÞ§ÉRvÆÁ6‘ÏóÕœ: @ÇYÿBÏúÆÝ†)?ˆüà¾1/}éK{k×®­¯`,¬Y³¦÷¢½(Ûî§É÷R6ºn»Èçøj~2 @‡YÿBŸõ/Œ·#?¨oÌ#ùÈÞ 7ÜP_3ÀX¹þúë{»îºk¶ýO“ÿº®øEK}~¯¦hÐ}Ö¿Paý ãé¡1â}�V¯^Ý»ð ëkK_|qoÓM7Íöƒ!Y“òˆ Ëîùü^ÍɃ¡t”õ/4°þ€ñ²yʯ"?˜oÌI'�T_#ÀXû¿ÿû¿l?˜&¿I¹mÐU;E>·Wó�ÁP:Èú¦aý ãã¿#?ˆoÌ‘GY_ÀDxÿûߟíÓäã@WÝ'òy½šCè ë_˜�õ/tß³#?xoÌóŸÿüÞÚµkëë˜Ŷÿ¼ç=/Û/¦É3€.Ú9ò9½šCèë_�õ/tÛíS.�üÀ=Ë6Ûlӻꪫêk˜(W_}uoûí·Ïö�!¹$åv@×ìùœ^Í×Cèë_˜ë_è®�E~ÐÞ˜â0@¯÷½ï}/Û?¦É€®yPäóy5Ç †Ð!Ö¿0KÖ¿Ð=�NYù{–ƒ:¨¾€‰và�fûÉ�ûØî@—<4òù¼š/ †ÐÖ¿0GֿЫSÎ�ü`=Ë®»îÚ»ñÆëÇÿ0Ñn¸á†Þƒüàl’óS6 ºâ‘‘ÏåÕ|æ–‘t�õ/̃õ/tÇ;"?HϲbÅŠÞyç�W?ö’³Ï>{Ý>Rßo†äm@Wìù<^ÍQƒ¡t€õ/Ì“õ/´ß=SnŒü=Ë¡‡Z?æ*9ä�l¿’RîtÁã"ŸÇ«ùÄ`(-gý ÄúÚí«‘œg¹ßýî·î2?ÀpÅåávÞyçlÿ’/]ðÄÈçðj>rËHÚÎúˆõ/´×î‘”7æ”SN©ë N=õÔlÿ™&ůÊh·'G>Wsä`(-fý ÌúÚgƒ”Ó"? ÏrÀÔ�ñ�iì·ß~Ù~4$§§l´ÙßE>Wó�ÁPZÊú‰õ/´Ë+"?Ïr‡;Ü¡wíµ×Ö�ï�i\sÍ5½-·Ü2ÛŸ†äe@›=+ò¹»šCCh)ë_X$ֿзIù]äâY¾üå/×�í�|îsŸËö§!¹,eÓ ­þ!ò¹»šCCh!ë_XdÖ¿ÐûE~žåáxoÍš5õãz`žó¨G=*Û¯†äµ@[ýcäóv5ï  …¬a‘YÿÀòÛ<åòÈÀ³üô§?­Ó³pÊ)§dûÕ�\‘²EÐF{E>oWsÐ`(-cý KÄú–ב|gyÅ+^Q?–æàÅ/~q¶ É€6zaäsv5†Ð2Ö¿°„¬`yÜ>åªÈ¼×ËŠ+z—^ziý8˜ƒ‹.ºhÝ>UßÏreÊm€¶yiäsv5o  E¬a‰YÿÀòxkäÝYößÿú1<0ÿöoÿ–ígCRüB€vyyäóu5~ÁÐNÖ¿° ¬`imå}VêÜëeåÊ•½ßýîwõãw`~ÿû߯۷êû[CŠû“Ý&h“WE>_Wó†ÁPZÂú–‰õ/,­™N^®ËÛÞö¶ú±;°8à€l’â—f´Ç>‘ÏÕÕì; @KXÿÂ2²þ€¥±*åÜÈ´×Kñ ÍË/¿¼~Ü,€+®¸¢·jÕªl¿kÈù)mñºÈçêjö  ¬a™YÿÀÒxVäÙY>øàú1;°€ÞúÖ·fûÝ�<3h‹ý"Ÿ§«yÅ`(-`ý -`ý ‹ïû‘`¯—+V¬»O °xŠû‹ûZ}ÿkÈ€¶Ø?òyºš—Ý2€6°þ…°þ€Åµkä×YöÞ{ïú±:°^ùÊWfûß�<$hƒ7G>GWó¢ÁP–™õ/´ˆõ/,ž�D~`�嬳Ϊ§‹àì³ÏÎö¿!ùP0Š-Sþ.å…Q^öóµ·ÓN;eûcCN †)~{aäsç8g“`ë_h!ë_XXŒü`:Ë‘GY?6–Àa‡–í�Cò€`˜Ç¥ÜùÜ9ŽùV0Œõ/´˜õ/,œC#?�Îò‡?ü¡~\,�bß«ï�Còž`:ûD>wŽ[ÎKÙ:ÆúZÌúFqIÄK"?�^//|á ëÇäÀÚk¯½²ý²!G¹O0܇"Ÿ?Ç%H¹g0Œõ/t€õ/Ìßc"?ˆÎròÉ'×�Ç�%ôÝï~7Û/‡äÀt6NùAäóg×sCÊ£€éXÿBXÿÀüùôzÙvÛm{kÖ¬©�K¨Ø·ÞzëlÿlÈû€™l—riäshW³6候XÿŽ™sÎ9'û ›²á†ÖKi1ë_˜Ÿ R~ùôz9ðÀëÇâÀ2Øÿý³ý³!Å>]ìÛLï!Qþj¶>�v1{3±þCÀãËúæîq‘‡v-Faý;†4€Ç—õ/ÌÝû#?p^/w¹Ë]zk×®­‡Ór7ß|soåÊ•ÙçYd‡v¨§#Š}qûí·Ï>Ó†ŒjÆË�¶8Ǥ¬ Faý;†4€Ç—õ/Ìݯ"?p^/tPýœ8ãŒ3²Ïr*ÀÝö–·¼%ûLr^0ª S¾ù\Úöü e“`T¿Š|.]/Ö¿Ý£<Þ¬`öîùAs–ÓN;­~üMuÔQÙg9 ànûùÏž}¦Cr�`T[§\ù\ÚÖœås`4Ö¿cJx¼YÿÀìíùózÙrË-]þª£^óš×dŸçT4€»­Ø'·ÞzëìsmÈË€ÙØ9åÚÈçÓ¶å÷á$'ÀlYÿŽ) àñfý ³wBäÌëeï½÷®{Ó;ï¼söyNE¸û^õªWeŸkC¾ÌÖž‘ϧmÊu) fËúwLi�?ë_]q¿¸ë#?`^/Ç|ý¸›¸öÚk³Ï² àî;ö«ÇfŸkCŠ&Á­€ÙzWäsj²&åiÀlYÿŽ1 àñgý £{BäËY®¾úêúq7ðÃþ0û,«Ñ®º*û\‡d·`¶V¥|-ò9u¹óŠ`.¬ÇØ¥—^Ú{ÉK^2cöÙgŸz)aý £;0òåõòøÇ?¾~ÌMG~øáÙçY�ðxØm·Ý²Ï¶!ûsq»”s"ŸW—+ïæÊú:ÎúFsbäÊëå½ï}oýx›ŽxþóŸŸ}žÕh�‡C9$ûlRÜë €¹Ù)åªÈçÖ¥ÎgRVsubäsëz±þ…v³þ€™m˜rmäÊ른Œ0Ý´Í6ÛdŸg5Àãáûßÿ~öÙ6äš” €¹zjÊÚÈç×¥Êÿ¥¬æÊúÆ€õ/Ìì�‘$g¹öÚkëÇÛtÀe—]–}–õh�‡b­¶Cò€`>Þùܺ).A½e0Ö¿0¬`f¯Šüy½<êQ�ªkÓßüæ7³Ï³ àñ±ë®»fŸoC^ÌGqùå/G>¿.f.K¹{0_Ö¿0&¬`zŸŽüy½pÀõãl:âío{öyÖ£<>ößÿìómȧ€ùÚ4å´ÈçØÅÈu)‚õ/Œ ë_˜ÞY‘ ¯—N8¡~œMG<õ©OÍ>Ïz4€ÇÇqÇ—}¾ 9#X÷H¹"òyv!ssÊS€…bý cÂú†[å‰Åúòz¹ä’KêÇÙ´Ä÷¿ÿýìóZì\pÁõ§AK\tÑEÙçÕ�bŸß8X��Ž¥æ‘ Šõ/Œë_î�‘¯—Õ«W×�±i `ªÖ®]ÛÛh£�²Ï¬!Êk#Ÿg"o ’õ/Œë_—'?ùÉõclZD˜º'<á ÙgÖ�ç eE”÷—«ÏµóIq�Ê•ÀB²þ…1cý ͉üÀx½¼éMoª_Ó"ÀÔí·ß~ÙgÖ�wüÿìÝ œUe½ÿñß o‘T1�QfÀN�bâAÑQ áü½TjÇ0;jbÞ:¦™—–wDo �ä©$ƒ”KH^•D®ÃsaÖÿyf±×ó[kfï™}[{}Þ¯×÷U/YkÏÞëòìç·Ÿµž “3Y*º½mJþdrˆ2�ú(2Ô¿@°?ŠîûòÌ3ϸýk€áš>}ºÚgyY™ö%“�E·¹éd…Ég� Ô¿@‘¡þ‚ý]tÇØ—·ß~Ûí_£€0 ×òåËÕ> Èzdà “jÑín*ÙhÒYÙBýkÖ¬Qû5(­ZµrWEQÿš�Zp¿èޱ/nÿ„`¸víÚ¥öY@jLZ ~(ºÝm,&ýìÊ€¬ þ� €ã…úо*ºSìK›6mܾ5 ÌÖ­[½Ù³gf„ jŸÅîgw݆Â�"…¯uëÖj?ädËDÑínXì�’버ú7&Žê_Àï4Ñb_ äö«!o¼ñ†Ú§AéÒ¥‹»*"nÀ€j?äd‹½Óì5ÑmoP¾W¿ {¨c‚àø¡þü¾/ºCìË%—\âö«! Ç×Å_¬ös@.@6u�ÆŸ7ùó. È&êߘ`8~¨¿ÛDwˆ}ùå/éö«! Ç×m·Ý¦ös@n@¶}ÓdŸè6ØfŠIÉÁEYDý Çõ/à÷¤è±/Ó¦MsûÕˆ€ãëÉ'ŸTû9 và�}çšTˆ¿ ~Ö¤uòB€¬¢þ� €ã‡úð›'ºCìËüùóÝ~5"„àøzå•WÔ~È\äÊÑ&ç™\dò çßÙGý Çõ/à·Tt‡Ø—7ß|ÓíW#BޝåË—«ý�ÅÄõoL0?Ô¿€ßzÑb_>úè#·_�a8¾Ö¯_¯ös@Ö ëE÷‡}¡þ- Çõ/à·[t‡Ø—]»v¹ýjDÀñµcǵŸ²S€x þ� €ã‡ú8èPÑ�a•ÚÚZ·_�a8¾öï߯ösHZ Pܨc„àø¡þê º#ìË‘Géö©1 ÇÛG¡öu@Ú Pܨc„àx¢þ¾*º#ìK÷îÝÝþ4"†àx;î¸ãÔ¾ÈW(nÔ¿1ÂpÓhI|Fˆ3êßa8~¨¿=¢;þ”——»ýjDÀñµk×.µŸR./öÎXûÈšL3ùPtÛX¬±ŸuªÉå&ÿ"?Ô¿1ÁpüPÿ~‰îû²a÷_�a8¾>üðCµŸ²^ øµ5i2Éd£è¶0®ù»É#&#$±� ØQÿÆÀñCý ø-Ý!öå�wÞqûÕˆ€ãëÍ7ßTû9 KŠSW“ñ&6©Ýþì6zÕäZ“/ 'êߘ`8~¨¿ß‹îû²`Á·_�a8¾^}õUµŸò²@ñ(3¹Âd�I­è6�¤žwLn–Ä@: êߘ`8~¨¿é¢;ľ<ûì³n¿Âp|͘1Cíç€ØgA@”eò#“E o6b·ék’Xÿœ@´QÿÆÀñCý øÝ%ºCìË„ Ü~5"„àøºë®»Ô~È/¢§ÄäßL¦™ìݶå=-Z´¨ûníÝ»·wÒI'ÕeøðáÞˆ#¼‘#GÖÅþÿ³Î:ëŸÿÞ§Oï˜cŽ©[×}½‰ÝÖö‡ÓA’Ø5Ô¿1ÁpüPÿ~?Ý!öeܸqn¿Âp|]vÙej?ä2€èh'‰gԮݞå$¥¥¥^Ïž=½Ñ£G{?ýéO½‰'zóæÍó–,Yâ­^½ÚÛ²e‹WYYé6Éi³¯a_˾¦}í¹/½T÷·ìß<÷Üs½=zÔ½÷ýå0«M~"‰}QAý Çõ/à÷Ñb_N?ýt·_�a8¾¬ös@N(|ÝL&IŽïöíÛ·¯÷Ãþ°nàÕ>róæÍnS›WµµµÞ¦M›¼?ÿùÏuïñòË/¯»‹ØýYŽÝ'�˜|] ðQÿÆÀñCý øuÝ!ö¥C‡n¿Âp|µmÛVíç€|M pÙižçJží[VVæýà?ðž|òIoåÊ•^UU•Û¬F†}ï+V¬ðžx≺»a:vì¨>ob÷Ño%1=4*êߘ`8~¨¿O‰î«db*CäÀñ´gϵ�b¬?L °ØgËŽ0Y&ºÝÊX:wîì�?¾njåB»³7ì�ÂsæÎñ®½öÚºÁnw{d8KMΞ  ðPÿÆÀñBý Û"ºcì‹}þ¢‰àx²w~¹û8  Ë`“¿Šn¯2;­§�*Ù¶‘vúä8Û°aƒ7cÆ oìØ±^II‰ÚVÊ[&#…�`……ú7Y¨¡€IDATŽê_ Ø_DwŒ}™={¶Û¿FD0O3gÎTû8 ( g˜,ÝN5+¥¥¥ÞÅ_ìÍ›7Ï«¨¨p›JÔ³ÛÆÞ }ÑEÕm3w;f ‹L†êß`8^¨€`‰îûrÇw¸ýkDÀñtÛm·©}�{ò«¯ÉÑíS“cïf½à‚ ¼9sæ0èÛ»wïö^|ñÅlÝlxí#�_Ô¿1Àp¼PÿÁ.Ý1öeôèÑnÿ‘êpûöíÝUa#FŒPû8 —äGIü¿_tÛÔ¤ôìÙ³nzç;v¸M"šhWù®ºi¢û÷ﯶw3bŸ¿7äL ?¨c€àx¡þ‚�$ºcì ƒƒÑ•ê0…oq9ꨣÔ>È€Üjmr…ÉNÑmRÚ±Ó_rÉ%Þ믿ûgúf›}¶Þøñã39Eôn“›MÈ-êß`8^¨€`mEwŒUvîÜéö±o¿ý¶Ú—aÙ»w¯»:"hûöíjßÄÞ�õi€Üf²Nt{”vÊÊʼɓ'{ååånˆ,³w?úè£uûÀÝ/MÌZ“¡¹Cý Çõ/аDw�}Y°`�ÛÏF¬[·Ní˰¬\¹Ò]4þ|µoò¾@n´3™"ºJ; ðfÍšåUWW»Mrlÿþýuß7ƒVû©‰±ÓB^ 7¨‹ÀñAý 4l¶è²/¿úÕ¯Ü~6"à“O>Qû2,=ô�»:"è®»îRû6 ³²«Ää“­¢Û ´2jÔ(oÉ’%ns‡±hÑ"ïœsÎQû­ ùÄä?²�ú·È1Ô¿@Ã~*ºƒìËðáÃÝ~6" ²²Rí˰tíÚµnyDÛgœ¡öm@®Èž.&ÿ'ºíI+gžy¦·lÙ2·™C�Zºt©7tèPµ›�ß™tÈêß"Çp|Pÿ ;YtÙ—ÒÒR¯ªªÊík#úôé£ögXÆ�çÕÔÔ¸/�ˆ°øö\u÷k@NÈŽ‘&ÛE·;)çÔSO­›ÎÑôÆoÔ Þ»û5Íì4ù¾@vœ,ºÝñ…ú7ÚŽê_ q‡™TŠî$ûòöÛo»ýmDÀ�ücµ/J÷îݽ'žxÂ{÷Ýw½íÛ·{{öìñ¶nÝZ÷Œàyóæy÷Üs�7vìXoãÆ�îŸBž-_¾\íÏ€ØsÝžó�IŸ1yZt›“rì÷¿Åã•W^ñºuë¦ösš™jr¤@fQÿ9€ã�úHÍbÑe_&Ožìö·sæÌQû2Y·n�û§�g“&MRû) ¯ dV“µ¢Û›”Ò¢E ï׿þ5�!(BÕÕÕÞ”)Sê~`w÷{ùÀä$€Ì¢þ-b Çõ/�š_‰î(û2zôh·¿�°?¨·nÝZíÏæ†àÂ3bĵŸr§@f´0¹Åd¿è¶&¥\wÝuu³M ¸mÛ¶Í»öÚkÕþO#5&7™” dõoc8¨€ÔØgö¹e_ì³UöíÛçö¹¿üå/Õþln.,vªî’’µŸr¶@ó}Îdžè6&¥œp u�@¼¬X±Âëß¿¿:ÒÈIL7ÍEý[Ä.~Ô¿@ꎒîàY¸p¡ÛïFØâèØc�Uû³9a¸°Øçfºû( öªÏ 4O/“÷E·1�Æþ˜n§{®ªªr›1ÄDMMMÝ´ÐöXp��c§„î#Ð<Ô¿EŒàâGý ¤g‘è³/?ûÙÏÜ~7"âÍ7ßlÎ�­* –o¼Qí£€,hž1&¢Û—F3tèP¾;ðO6lðÎ9çuœ¤˜½& 4õo‘b¸øQÿé¹Yt‡Ù—nݺ¹ýnDˆ�z±mÛ¶j¿6%üˆ_XR¼Ãûz€¦±Ïû� º]i4öâ£éÓ§{µµµnÓ…˜³ÇÄÔ©S›s�Úÿ ÏÐt7‹nW|¡þ�&€‹õ/�ž~¢;Ì*|ð�Û÷F„lݺµîJöŸ—ûü¾�?þØ}i䉌w÷QHŽHß&3E·)�¦OŸ>ÞêÕ«Ýf ð±ßcTÇOŠyÁäp€ôQÿ)€‹õ/�>{ÅÇ¢;;Üÿýnÿd‚gΜé]qÅ^¿~ý¼öíÛÿsPØÞ‰cïîÛ·¯7jÔ(oüøñuÏë[´h‘·{÷n÷¥�g¿ùÍoÔy{ns—€tµ7Y,ºMi4öb#žõ‹TUWW×=Ú=ŽRŒ�Ƶ�@z¨‹ÀÅ�úhš©¢;ξØAA…£wïÞê< È€ô|Ýd�èö¤ÁØ Š–,Yâ6U@JìÅfG}´:®RÈû&_Hõob¸¸QÿM3JtÇY…ç¿…aíÚµêü É€Ô�`²]t[Ò`†Z7ÃÐ[¶lñ† ¢Ž¯²Í¤¿@ꨋÀÅ‹úh:û µÝ¢;ϾØ)úäß„ Ôù{NÛgx@*¾e²Kt[Ò`ìã˜ò™RSSãÝyç�ê8K!ö;ïT€ÔPÿ!€‹õ/Љ"Œú(`Ô¿@fÝ(ºC­òî»ïº}sY´jÕ*u†äz€`ýMÊE·¡ùÍo~ã6G@^Mœ8Q§�¤Âd @0ê_ Qÿ™÷EI<7ÍíTûrå•WºýsY4nÜ8uÄ>ϳ£€öu“í¢Û�Ð<ñÄnS„Ç{L¯�d›É×4ê_ QÿÙñ[Ñk•Í›7»}tY`Ï5÷ü É‹Z{“u¢ÛŒÐ<ûì³nS”3f¨ã¶‘¬5ù¼€Fý ê_ {†‹îX«ÜqÇn?@üâ¿Pç_HÎð;Ìä5ÑíEhž~úi· Ò /¼ ŽßF²Ää?ê_ €PÿÙÓÂd�èε/-[¶ôÊËËݾ:€ Ú½{·×ªU+uþä}Iœ»p@©Éó¢Û‹Ð<úè£n34;U¹{7’ÙÂ÷%?ê_ @PÿÙ7Nt[eòäÉn@Mš4I�w!¹\Ào‚è¶"4>ø Û‘pß}÷©ã¹‘Ü-àGý ê_ û7ùDt'Û—£�>ÚÛ»w¯Ûg�{öìñŽ:ê(uÞd«0¥%¿1¢ÛŠÐüêW¿r› Rîºë.u\7’ ¢þòŒúÈ�ÛDw´U¸ ÈŽ‰'ªó-$ÿ#pпšìÝVfüøñnóDÒÕW_­ŽïRaÒSà ê_ �¨€Üig²WtgÛ—6mÚxnß@3ØsêÈ#�Tç[@ö™´Hø¬ÉZÑmE`Î=÷\¯¦¦Æm‚€Hª®®öFŽ©Žó²Þäs Ô¿@žPÿ¹÷¨è·ÊC=äöß4CÏ3|X ¡Ôd®èv"0ǼW^^î6?@¤íÞ½ÛëׯŸ:ÞÈË&-¨�< þrïË&U¢;ݾ~øáüˆ dÈ®]»¼Ö­[«ó, •&�nÝN¦C‡ÞæÍ›Ýæ( ›6mòÚµk§Žûr“@õ/�cÔ¿@þ<"ºã­rç�wºýxMpÛm·©ó+$$|Ëd¿èvB¥´´Ô[µj•ÛôEeÅŠuǺ{ü‡¤ÆäD€ê_ ‡¨€ü)“ÄsVÜη/%%%Þ‡~èöå¤á£�>ª;—Üó+ öœì$ ÒVÏ2uÛ‰ÀÌœ9Ómz€¢4gÎuü7�M>#@ý ä õ/�÷‹î€«\|ñÅn@ÆŒ£Î«�Ü#�0Mt˜[n¹Åmv€¢vã�7ªó �ÌH þr€úÈ¿/˜ìÝ WùË_þâöé¤àõ×_WçSHöštù¢ÛˆÀ 2Ä«ªªr› ¨UWW{C‡UçC9_€úÈ:ê_ pÜ!º#®Ò¯_?oÿþýnß@jjj¼Þ½{«ó)$¿éb²Kt¡Ò¾}{oëÖ­nÓÄ–-[¼£�>Z�!ÙaÒY€úÈê_ °´1ù‡èθÊc�=æöï4`âĉê< É&“O ˆ¼,º�ÌâÅ‹Ýfˆ•¥K—ªó¢�üÁ¤DÄõ/�%Ô¿@áùžè¹Jii©·a÷� Àßÿþ÷ºsÆ=�Br�€È…¢Û‡ÀÜu×]n³ÄÒ/~ñ u~4�±Ô¿@ÆQÿ…©Ôd‰èN¹ÊÙgŸíÕÖÖº}}Iì92bÄuþ„䯒8ÄÛQ&[D·*乿@½ÊÊJoÀ€ê< É'&ŸqGý dõ/PØN0©Ý9W™5k–Ûß�ä¹çžSçMHì9w¢€ÈÓ¢Û{gźuëÜfˆ5{N¤q×ÑÔ¿@ÆPÿ…o†èºÊGámÛ¶Ííó0¶nÝêµnÝZ�7!yR@d˜èö!0Ó§Ow›ÆÔ©SÕùÒ@Πþš�úˆ†ö&ÛDwÒUÎ;ï<¦Âöœ;v¬:_B²Õ¤�ˆ»CMÖ‰n#TÎ<óL¾{�öܰSµºçMHÖš"âŽúhê_ Z¾/º£îBüžzê)už4� D®Ý>¨Øém?úè#·Ù�äƒ>Hg*èk¨�&£þ¢¥Ää÷¢;ë*ö¶5kÖ¸5Kï¿ÿ~:?:¿"‰s @¼u4)ÝF¨<òÈ#n³ À< ÎŸ�ì2ù‚ˆ;ê_  ¨€húŠÉÑ�v•N8Á«ªªrk V*++½¨ó#$öÜê* 2Et¡Ò¿¯ººÚmz¨©©©ëŸºçQH& Pÿi¡þ¢íZÑ÷ÀÜ~ûín=ÄÊÏþsu^4�«DúšÔŠn#TV®\é6;ðöÛo«ó($ûM¾!@ý ¤Œúˆ¶–& EwÞóòË/»5 üãÕùÐ@˜´ù³è6Båºë®s›)¸úê«Õù’ùÔ¿@J¨€âÐÅd§èN¼J‹-¼µk׺µPÔÖ­[çµlÙR�!ÙaÒY@ätÑm„Šm_¶nÝê6=R°mÛ6¯U«Vê¼ É`ê_ AÔ¿@qùOÑùÀtïÞÝ+//wk (UTTx½zõRçAù€„×E·*�=ö˜ÛôHÃĉÕy’Å&%Ô¿@ ê_ 8Mݙ̅^èÕÖÖºµPTì1þ½ï}Oÿ dŠ@ÂpÑm„ÊqÇçUVVºÍ€4TUUyݺuSçWH¾-�@ý $¡þŠW[“Dwêsï½÷ºõPT&L˜ Žûò¾É§w¾!º�P™?¾Ûôh‚4žU¸T¸ @õ/�„ú(nýLö‰îÜfÖ¬YnÍ…^xAï d¯I€„¢Û •!C†¸M€&²w- 4H�g!9K �ú𨀸¸Dt?4‹-rk Ò–,Yâ•””¨c½�\,pÐ2Ñí„ÊâÅ‹Ýæ@3,\¸P�g!Y"põ/b�úˆ—GDwòÓ²eKoõêÕn DÒûï¿ïµnÝZç ä!€ƒþMt;¡rÆg¸Í€ 6l˜:ßBò-€ƒ¨KÔ¿@ü*‰g¤¹�ýÀtíÚÕÛ²e‹[K‘²yóf¯sçÎêøn ‹M8h®è¶BeéÒ¥n –-[¦Î·�Ì8ˆú±Cý Ä×—L¶ˆîô¦wïÞÞöíÛÝšˆ„mÛ¶y={öTÇuùؤLà n&µ¢Û _FŽé6A2謳ÎRç]@ì¹zœÀA_ê_Äõ/€MöŠîü¦_¿~ÞÎ�;ÝÚ(håååÞÀ�ÕñÜ@ì9q‚€ß$Ñí…Š}΀ìyíµ×Ôy’‰~Ô¿(zÔ¿i²_t˜Áƒ{n�¤½{÷Ö=‹Ó=Žˆ½cè|¿v&ûD·¾ØÛd_ÿþýÕùûƒ¶=w õ/Šõ/×xÑ…@hìÔ{¶° ™=FSœ&29×h׊n/Tæ¾ô’ÛÈ‚_|Q�!á{@ê_ê_aî] „Æ^Uº{÷n·æ ‚½Jÿì³ÏVÇm#aªHAJLÞÝføÒ±cG¯ººÚmŽd�=×:wî¬ÎÀ¬‘Ä9 .ê_ ê_ ie2WtQšAƒñL${Lž|òÉêxm$³MZ hÿ&ºÍP™[o)äÏ’8– ËD·#¾\xá…n ÆŒ£ÎÏ€Ø)Ý Ô¿(Ô¿²í“—E ¦C‡Þßþö7·†šdùòå^»víÔq–B^1ù”@j¾,ºQ™3wŽÛLȃY³f©ó3$]RCý‹¼£þ�+‡š<'º¸h0vZ¾éÓ§»µ �ûl-÷ØJ13LHÝxÑm‰/¥¥¥^EE…ÛT¡€ÝvÛmj?6¦øŽŽòòòT§�¶S»@ª¨‘7Ô¿r­ÄäfÑEF£ùÞ÷¾Ç©H›ýQ÷òË/WÇSŠ¹Ç¤T = D·'¾|÷»ßu›+°ÊÊJïðÃWû±±Lž<Ù})(;%»»ÿ2_ =Ô¿È)ê_ùö]“jÑGƒ)++ó–,YâÖ8@ {¬tìØQG)¤Æä¿Ò÷IáûmÞ¼yn“…6sæLµSI÷îÝÝ—B�šûÒKjÿÄžÛ<@SPÿ"먊ÓLvŠ.>ÍÏ~ö3¯ªªÊ­w€:555Þ¯ýkuܤ˜r“ï4ÍHÑíŠ/vjG¦Ž–Áƒ«ý˜jøá>ì9™â4Ðç4 õ/²‚ú@!êm²^tÒhè½÷Þ{n탘{÷Ýw½N8A/)f�I/€¦{TtÛâË9çœã6](`«V­Rû0�\zé¥îK¢@�uÖYjÿä!€¦£þEFQÿ(dŸ3ù�èb$¥Ø«¡÷ìÙãÖAˆû|F{ÕsŠwïå�&Ÿh:ûœ¿¿‹n_|á¹°Ñríµ×ª}˜n¶mÛæ¾, Ð#�<¢ö]@>hê_4õ/€¨har³É~Ñ…I£9öØc½E‹¹5bbÙ²e^Ïž=Õq‘bjMn—Ä1ÍÑSt£²víZ·C�Ú½{·WZZªöaºyøá‡Ý—FZ½zµÚw!ùº@óPÿ¢É¨DÑÙÒÄç"ÙØ»tvìØáÖG(RöŽª«¯¾Zid‡É™™1Nt;ãKÇŽݦ ì©§žRû°)éÚµ«W[[ë¾< PYY™Ú¹L 3¨‘2ê_Q÷U“¥¢‹•”Òºukï±Ç󪫫Ýz E¢ªªªn ÕV­Z©ýŸF™tÈœé¢Û_Æ�ï6i(`ǼڇnFŒ¡þ[P.\è¾< Ð5×\£ö]@žÈê_4ˆú@1i)‰)±jD.)¥[·nÞË/¿ìÖNˆ¸×_=¥äˆ�òê“Ö™eŸ ê¶9¾Ì›7ÏmÖP –.]ªö_Pì”ÞmÚ´QÿÝÍÅ_ìþ  Ù³g«}�u™Eý‹@Ô¿ŠÕ “E1)gôèÑÞÊ•+Ý: ³bÅ oÔ¨Qjÿ¦™LNȼ/‰nsT6oÞì6o(P—]v™ÚnzõêU·lŠw�z[¶lqþ ÍÆ�Õ~ É2�úu¨ÄÁgLž]̤{çÍêիݺ Îî³ /¼PíÏ&dšI[€ì8Ot»ãK‡Ü&Ê>c¯¤¤DíC7wß}wÝòvzg÷ß‚rß}÷9 …È>«ÛÝw%�Ô¿1Fý ކI Ók6–±cÇRGÀºuë¼ýèGjÿ5!ÿ0!�]¿ÝþøòƒüÀmêP |ðAµÿ‚bïαìsí3ÝwSVVæíß¿ßùk(4—\r‰Úw±Ói@6QÿÆõ/€¸;R?¸í]è¤[/_¾Ü­»�göÇô ¾öYGSL>+�}¯‹n‡|yê©§Ü&ÈÐvéÒEí?7]»võ­wå•Wªe‚ò§?ýÉ· Ï”)SÔ~ È€ì£þ-rÔ¿àw¢É ÑEOÚ9餓¼Y³fqGNÙm?þ|oĈjÿ41«MNÈ�R“rÑm‘/<�/ì­»ï‚rç�wúÖ{eþ+j™ Œ3Æ· �ý1ÞÝoÙiR"�Ô¿E„úv¨Éõ&»E@i§{÷îuwgí*ßåÖgÈ’]»vyO>ù¤×­[7µ?š;3Þä€Üé*º=R©ªªr›A óÏ?_í» ¼ûõ*++½V­Z©å‚²iÓ&ߺ(,v_ºû,$�r‡ú7⨠=L’ L‹eSRRR÷ì·×_Ý«­­uk6d€½³füøñ^ii©ÚþMŒ�îj†I™@î�#º]ò¥_¿~nSˆ´qãFµï‚Ò»wowÕ:ÿýßÿ­– Ê„ ÜUQ`ì>v÷[@ÎÈ=ê߈¡þ€æé')<ƒ1�ôèÑÛ4i’·yóf·†CšìÝN'N¬»ÒÜÝÎÍÌ_LúäÏÍ¢Û&_®¸â ·YD²Ó:»û.(>ø »j�×^{M-”víÚy555îê( —]v™Úoù©@þPÿ0ê_È,û,¶óLÞ](5+C† ñžxâ oË–-nm‡ü±7eÊï´ÓNSÛ3Yer®ðü=ù÷œè6Ê—GyÄm"Q`ìÝGy¤ÚwA±w ±Ïôkß¾½Z>(¿ÿÃÜÕQ@xàµÏ2] ¿¨ õ/d_K“ LÞ]85;Æ «+ì>øà·æ‹=»Mì¶±ÛÈÝnÊZ“ÿgÒB 0¬ÝVù²`Á·¹D�™3wŽÚoA9óÌ3ÝU}î¸ãµNPF�宊2þ|µÏò–@a þÍê_HM7“ÿ¬ÿßLh%‰×³E“[He$]»v­{žÏË/¿ìíÞ½Û­‹ÞÞ½{ëže§ÍìÕ«—Ú>̇&W˜"Í÷Y“ÿ09M(¤4�½ c�è6˦R,|gœq†ÚoAyá…ÜU}Ö¯_¯Ö ˆ ÜÕQ R|ôn€ÂBý›eª{KâXøªû�k¶¸Ù/‰b§ÖdœÿŸ›ÅMß5Y!º°ÊXJJJê~@¶Å ½sdçÎ�n½yö3ÙÏf?£ý¬ö3»Û!ÃyÇäb“Ö’öâ‚-rðõ/™{mñóÑí–/¥¥¥^mm­Ûœ¢€¬Y³Fí· Ø}YQQá®® :T­û]ŠÂd§óN±�s´@á¡þÍ�ˆÖ¿×ËÁ׫6¹ÐÿÏ�;ß�Da’\øØ;ªO^(ì�Zß1yEt¡••ôîÝÛûÉO~âMŸ>Ý{ã�7¼òòr·¦,X»ÊwyË–-ó¦M›æ]sÍ5Ù¾ÂÙÍM†JæŸqôŒè¿u‹o H]ÑmŠ/={öt›W˜n¸Aí· \yå•îª�fΜ©Ö JÛ¶mëž=ŒÂtì±Çª}�>…‹ú7 ERÿ%Á¿­›¼䂽:Ù>CÍ-€lÚ'-—iÇ›L3©ýw³š.]ºxcÇŽõî¾ûnï¹çžó-ZT7 duuµ[ƒf�ý›öoÛ÷ðì³ÏzwÝu—7f̯sçÎê}ç v_L•ÄÙògÑ×Èöx€tÙéäÝ6Å—Ñ£G»M/ ˆ½£·eË–j¿å¯ý«»z ;=dª¯9oÞ J·nÝÜUtÝuש×ÊðáÃÝUQ ìlîþ ÈO¢…ú7ÿÉfýkyíë»Óæš¤å «þÕ¤JtabïÈÌäU°©²ó!“Ý¢ßÉlö™Ì0,ÍŸæ*-M–Š~?6¿HZRa¿3ܶė‰'ºãJ( ýû÷Wû,(?ü°»jƒÞyçõaY¿~½»: Àƒ>¨öU@ˆ.êßÜ%—õ¯}DI�¿‡îIË@VØ»-í]—nQbsCÒrùÐÖ䓹<@Mš;ÅÕoMþÓäHÉŸ‘à©Ïö›œ˜´4f¦è¶Ä¦ø-\Ë—/Wû+,Ÿ|ò‰»z£úöí«^'(·Þz«»* Àœ¹sÔ¾ ÈóÑGý›�ä³þ½[ôû±YfÒ*i9ȸ;E#…X�|FÛl¡nJì•Ç L®�Ätc…Â^dà¾W;ÚaIË@CŠnG|±Ó¢0]qÅjå‚ .pWM‰�~Ò}­ ~øá^ee¥»:òÌ>ÒÝWyU ¸Pÿ6/…RÿÚ îßýþl~š´dTCSõHZ®Ðeò&O˜lýþI"›L7mò9)Lv*èŢ߻�½8RÑèóóÖ¬YãŽ+¡ìܹÓ+))Qû+(¯ÌÅ]=%éü�_|Ñ]yöÞ{ï©ýûã:+êßÔR¨õï7%øw{g²� 2êp“wE!6ã“–+t¥&Ç›\/‰+|ƒ¦ŽKìÀ½½æ:I<×9ÛÏ4Ê”ãLöŠþ›µ&ŸJZ\ö‡Ð »*|©ªªrÇ•�gµµµÞ±Ç«öUPî¼óNwõ´¼öÚkê5ÃÂÝâ…ÅNËíTKaÿðÙBý v*è·D&››’–€f Á?–Û+h»%-W ì��_7kr‡ÉïL6Šþì…ûží{·ŸÁ> Ê~¦bú¡ÓàØ+ØÝÏmc/V€0vŠ?·Ýð¥eË–î˜ ÀÂ… Õ¾ Ë|ஞ;Ø|Ì1Ç¨× ÊM7Ý䮎<+--Uû) m@ý[¸ì]ÊAÏs¶1ÙA|h;õó{¢‹›«“–+vvÀà“ËMî6yÞd™ÉvÑÛ%WÙ&‰÷`ß‹}Oÿ%‰÷XHÏ/ʦ¯™ì½]ìTÐßJZ’}Yt»áK×®]Ýñ$€ /¼Pí«  :Ô]µIî½÷^õÚAiÕª•·wï^wuäQ—.]Ô~ H„¡þ- ·ŠÞ63i�´¤í7¢‹ ›×LZ$-gö’^&§›Œ‘ÄtZÿcrŸÉtIL;õ“¥õyGS'Çþ·ÿn—µëØuíkØ×gr¾$þ†ý[ܵ’`/Bp�M›÷…© ³3W¸m†/}úôqÇ“�g)>×µ.3gÎtWo’tþæóÏ?ﮎ<êÕ«—ÚG±’ÒGý›;v�÷MÑßa6v;@“ 2©]hì•Ä”J@¾Ù© _}ŒÚØ‹ÀÕ[t{áËÀ�Ýñ$äÙ„ Ô~ J‹-2z7î¹çž«þFP 䮊<êß¿¿ÚGù ðÙð°© û$-)9Âd�è"ÃæÊ¤å€|;Ƥ\ôqj/^8-i9°ú‰n/|9õÔSÝñ$äQuuµ×®];µŸ‚2~üxwõfyùå—Õß˪U«ÜÕ‘'§œrŠÚ?áGs@TÜ,ú{Ìf…É¡€Æ= º¸°Y(LýŒÂó#ÑǪÍ:“6IËÀI¢Û _† æŽ'!�Ò„}ûí·ÝÕ›¥ªªÊkÓ¦�ú;AÉôà3šîŒ3ÎPû' ' ÑÐRÓe»ße6·%- :E‚§~®0ùJÒr@¡°SAÏ}ÌÚØ‹à€Á¢Û _FŒáŽ'!�Î:ë,µ�‚’­g7ßzë­êoÅN?]QQᮎ<8çœsÔþ ˆíïöÑ•¢¿Ïö›œ�´ú´É¢‹ ›Ë“– M Ÿ úô¤åÄÛ·E·¾œþùîxòdýúõjÿ„åñÇwWψµkת¿–éÓ§»«#Î;ï<µor†-7ˆþ>³YirXÒr L]LØüѤ$i9 Ù‹Üc×fƒIÛ¤åÄÀrË-·¨ý”’’oÇŽîê3xð`õ7ƒ2`ÀwUäÀ€"e§‚^,ú;ÍæŽ¤åÀÇN‹4õón“/'-*{‘ÂïDÃ6�$- ¾˜:"öíÛçµnÝZퟠ\rÉ%îêõì³Ïª¿–·ÞzË]9ÆЀ"vœÉ^Ñßkv*è�IË@�#M>]DØ\š´Pè:›ì}ÛØ;ÿÄÛI¢Û_† æŽ'!žþyµo²páBwõŒ²Ïö---U7(W]u•»:rìŒ3ÎPû% ' Ñ4^ô÷šÍZ“O%-2Ytñ`óaêgD�½hÁ=–mþnò™¤åÄÏ7E· ¾Øé~‘'Ÿ|²Ú7A)++óöïßﮞqv`×ýÛA±Å»Êw¹«#‡N9åµ_ÒGˆ¦&¯‹þn³ùUÒrbî;¢‹›�&eIËQa/ZxIô1móØÁÅÄпŠn|9á„Üñ$äØŠ+Ô~ Ë=÷Ü㮞Ë—/W;,O>ù¤»:r¨ÿþjŸä_€èúšÉÑßov*èo%- ¦Úš|$ºh°ùnÒr@Ôt4Ù.ú¸¶9;i9ñÒMt›àËñÇïŽ'!ÇR½ÛÖfãÆ�îêYÓ³gOõ÷ƒÂ1”_½zõRû$ ö‡s¢ìjÑßo6ï SA±÷¤èbÁæÿ„©Ÿ}ö"÷ضÙhòÙ¤åÄGWÑm‚/ÇsŒ;ž„²Ó'§ú¼Ý#F¸«gÕ£�>ªÞCXÞxã wuäHçÎ�ÕþH ÚJM^ýgó›¤åÄÌ™¢‹›&�’–¢l®ècÜÆ^ü ~>'º=ð¥E‹îxrèñÇWû$,óæÍsWϪmÛ¶©÷–qãÆ¹«#GR¼€àH úŽ1Ù-ú{®Öä´¤åÄ„ýü¢‹› .D^“m¢�s›OZ@<Ø;%jD·¾TVVºcJÈ‘=z¨ý”Ã?Öm>6ùüÁÅÄÄÑí�/[¶lqÇ•�‹/Vû"Ê™=i9Eê(IÜõà6c’–ŠÍL¶Š>îmF&- ø-Ýø²dÉw\ YöÉ'Ÿ¨ýP ±w5#w^ýuµò' øüPôwžÍ“¶IË(Bψ.l^L^(Rö"÷Ø·±ÓÁ¶KZ@q›)ºðeÞ¼yì¾ûîSû¡r饗ºY4gîµbg ØØçÛ¿,ú{ÏfbÒrŠÌy¢‹›OLÚ'-³gEŸ6v@@<<$º ðeâĉhÿþý^YY™ÚÅ’­[·ºYòàƒªíž‡(V�Mv‰þî³9ãàbŠÅÑ&›E6£“–Š]C炽H@ñ»NôùïËOúSw\ YôÊ+¯¨}PLyøá‡Ý�Œ,ùÉO~¢¶@®Š×e¢¿ûlþnò™¤å3Ÿ29Y?Œ¶|éÑ£‡;®„,Ù°aƒÚþŘ… ºYpì±Çªm�>@që(‰‹lÝï@›³“–Có ½}ƒb/fN…¼ý�ɇ¢_#�¬3ù¹pƒ±vºÉïEw2�M&7JbêUäÖL¶ŠÞ'6Üý€8;Jm“{^ØØ‹&Ð<Ÿ—Ĭa?:d"öªç§$qW1�ûÝèO¾”––rÇfŽÜ~ûíjû‡å†nðfÏž]0™ ¶@±û®èï@{ÃÆg“–CÓ}Sôö Š(nˆø}F2wSÎ�ض”D}bâx“Å¢;ÙÎnI(vP~Wù.÷%ònÀ€꽆eË–-îêÈ �7ªm�r >æŠþ.´y"y!4Y7ÑÛ6(çXÁaë;Ís£µI3c§Ž¶7€"f§bþµdþвt³Úä‚l»Pô¶·ùX¸ú8`ªèsÄÆ>CÇcHÝ“÷DoË\e›ÉR³Bô1äË‚ Üñ%dØ‹/¾¨¶{X®ºê*wõ‚0}útõ^Ãrï½÷º«#ƒæÏŸ¯¶y@Þ⣃$ê$÷ûÐÆ>ªÍÓEôv нÛe£}Vô²ÙŠ�Aë"EÉ>ÿãuÑ€|ÅÞf(‘tò�Ô|Îä¢ÏÚ¨Ô]*‰çóºÛ0¹Ë¤T€†Ù)ÖÜcÇ—‰'ºãKȰÓN;Mm÷°¼õÖ[îê¡¢¢ÂkÑ¢…z¿A)++«›¦Ùqÿý÷«m{áqr�èïCnh¾F-SŸX¡Þ¡~wv6cgϲ3¡€"rŒÉG¢¿ø !W ²!¬#É4?€6Lô¹bc§‚.KZÁî½íò;¸×B€p7Š>n|7nœ;¾„ zï½÷Ô6‹�f¹�]{íµê=‡ÅÞ¥Šì¸ôÒKÕöÈx ~žý�hÃãÁšç(ÑÛ4(×XA3�Í X&WÙ%üÎ@Ñø’É¢¿ðS�}…½sø)“;%ñlÇŸšÜfrŸ$׬—N~ Ȥï‰ÞÆ6M>›´€ƒ¦ˆ>glþO˜ º!¿½ÍRÍ>I|¿Øm»É ’”³ÿÿq“W%ñ¬Fw½T3YØww–ècÆ—¾}ûºãKÈ ñãÇ«m;Ír![±b…zÏa3fŒ»:2¤W¯^j{ä;@üØ;U·Šþ^´™´ÒÓVôö Šý õ€›þ=,vöÄu&o›¬”Äï»v×].ݼ ò3Y.ú‹¾±Ø©ƒï7(‰gR¤¢“$¦mÊ4ÓÕ&'2ÁNõ½]ô6¶±?vv¤„Ï”`/ª€öCÑÛª±ØöÞN¿y†$¦½jŒ�Êù›’¸)싆b/X‚ØÙQÜãE¥ªªÊcBìÞ½;åi“írvšåB7pà@õÞòiÓ&wu4See¥ÚÎ!áŽñÐñ¹/–tšúri§©^*iÑiê|÷%"�mà#ú{Ñf‹I»¤å�º6¢·gPþ·~ùÓ%1 ³ûïb/€¶C�–DŸ%ì‚æ£MN6¹Åd­è×I%ßi“DÁ7{7–í<|Ú®Ü §™¼%úõŠxáîÔæ±Ãy¢·­Í£IË6Tô¹c³ÛäËIËAd€¤ÿÌ_;-s»r.‰Ý ѯ;àÜß® 8ìwf£Wϯ\¹ÒgBL�:Umë°Ø;…£`ÆŒ꽇e„ îêh¦ï¶�vû!ŠF‹²icK;MÛáp6”büd„zVô÷£ÍÌä…�2{ã�»-ƒbgP<ÂäÀ³±}”ëL>%é³MÛÁ}{3�ûº ÅÎô"Ê^Uæ~¹7”wMzÖ­™öή‰¢ÿNC±"4Ýe¢·©ÍßM>“´€pöb ÷²ùƒð£ñ ®A±¶ö æLùšÉ;¢ÿNXÖ˜R·&à÷ÑÇ‹/O>ù¤;΄ èÓ§�ÚÖa‰Ê ¼½K9Õ»šÛµkçÕÔÔ¸/�fxüñÇÕvˆ}´¯/?ÿù’NÓ_p6SIÑ ~² cïÝ,ú;Òæ¼¤å�ûÛ§»ƒòˆÉÝÿÝÆ>vÊÎæ×\öbë°YÍ‚bët[Û€ˆ±Ó6Û]÷Ë=,oš|¾nÍÌ»Kôß K�I÷ÄjHSg ¿“ÉNµ 5v*è°ç¦ó¼ò;³»mÂb¯dî›X-£ìE-‹Eÿ½°\“X ðùµècÅ—Ë.»ÌgB3-[¶Lmç° 4È]½ ]wÝuê3„å÷øƒ»:šá’K.QÛ8 ŠT‹/M?§´ÓÔ�ÝAÍTS ƒŸlƒ”Ù‹sÝïH›OLÚ'-‡ÆÙß_Ýí{³��Êýïš´�ÌéaR)úï„…ç?AWˆþR‹½ò/Wš…±w̽(úï†ÅNGƒôØé^^½-mNZ@jKðsyìTÐ]“–‹#{÷mPá»Ü‰‰Õ²Â^½6Xïf§Éç«ÿt®ècÅ—:¸ãLh¦Ë/¿\mç°<÷ÜsîêmÕªUê3„eäÈ‘îêh{Wµ»�2B Øt~¡miÙ´'ÜÁÌtéÁO¶ASÌý=ic¿CêZ‰Þ†©ÆÎ>–�YÆný·ÂÂLŒDLK“õ¢¿ÔÃb§ŠÎ6û£ûVÑ;(ö.àÎuk!U?½m6˜´MZ@êìÅî9e³@]ÄÕ㢷IXn¨_'›N�àÁú ØçÉìpîq¢²iÓ&w¬ M´}ûv¯¤¤Dmã ´lÙÒÛ³g�ûïä“OVŸ%,6lpWGØíènÛ�dó¢Wȹ–�ž9­´Ó´ î@fSÕÁO¶A“e²Iôw¥Íؤåа¦¿^¿n6Ø÷‰þ›AY^¿ˆˆ°©\‚ò\ý:¹0NôßËõë qö墷¡’´€ôØgá¬}nÙØö,޾$©ßý»P2;•UCR}Þ¼}z¶ŠlDW£Ï³žûÒKîxšèá‡VÛ7,7Þx£»z$<ûì³ê³„åŽ;îpWGÌš5KmÛ€Øït(í~wDi§i¸˜ÍIä?Ù™0\ô÷¥�}ŒÏ“–C8{Ž»ýË^“/Û•³h¦è¿;]t®êv�/‹þBK.Ÿ·{˜$ž'⾇ Øc³1 J±±w!þIôö³¹?i9MsŠß]Zaò•¤åâ"�©¤zÖ¯“ e’úsŽÎ©_8`šèãÄ—k¯½ÖoBìß¿ß;æ˜cÔö ËêÕ«Ý—ˆ{ײ½{ÙýN|騱£;Þ„&xõÕWÕ¶ Ëi§�æ®)×_½úLa™7ož»:ÒP[[ëµoß^m×€ØB ººÎ=¤´lê¥�¦îw.3‘H ~² ²Á>²í¢¿7m.LZÁÒÞ-‰mžm½Eÿí°œZ¿(pßýE–ÿ¨_'—Žý>Âò‹úuìX“=¢·›½[‘Î�9v*èÕ¢Ï5{F\ØgíºŸ?,ß®_'—‰~AÙerHý:€õ/¢�•5kÖ¸ãNHÓ˜1cÔv ‹�Ò7ÊÞ{ï=õ™Â2|øpwu¤!�mýu€¨úÒô-í4õmwÀ2“)øÁO¶A6 ý½ic§‚¶³-!\ºÀöñE¹p¨¤~ƒÐùõë€÷Œè/ò ØçMØA�|hôY{õYp`(vêç?‹Þf6¿NZ@fØÁÏý¢Ï·}Ÿ”íE9îçÊG’Ÿ)üíÝØ©>fà´úu˯D'¾<úè£î¸Òð�üCmÓ°´nÝÚÛ·oŸû‘3xð`õÙ²~ýzwu¤(ÅçJÛï&ˆž“_iYúÅé7–všZíVf:;øÉ6Èû¨÷ûÓæÿ$?õ]T¤;l/\ΕTýÑ�@aÛ$ú‹<(<°B<)úýŬp—V°kDo/›÷M>•´€Ì±W¸çœÍk�© ‰þìA™r`…N|)))ñ***ܱ'¤ ªªÊkÛ¶­Ú¦aY·n�û‘´wï^¯U«VêóåðÃ÷*++Ý—@#vïÞ­¶eHΈ ¯´´Óô—vš¶× L1µüÒÓƒ¤óŒöÿšÂüdäÉPÑß¡6ö¹µÇ$-‡ƒÒ~©~�\™+ú=ågV…k”è/ñ°œQ¿N>ØA]÷ý„å¢úu�`;–awáýoÒr²c€„Ú º'-WlŠþÌaéR¿N>¤ô,W“ra3øiR%úXñeîK/¹ãOHÁ¼yóÔ¶ ˰aÃÜÕ#íÆoTŸ1,/¾ø¢»:1{ölµbÏm{Ž@á+{ö˜eÓ_u&SÏ´RöÛÏÔ½VT?Ùù6Yôw©�½ˆ–JKgØ>V)—žý‚’ë÷šà&Ñ_âaùBý:ù`ï$«ýž‚rGý:H°Ó²¸ÛÈf­äï™Î@ÜØ‹-ÜsÐæ¯&­’–+&—‰þ¼A±Ï—ϧÖ&Õ¢ßWP:Õ¯ðgÑlj/]t‘;þ„ØA]w[†eÎÜ9îê‘¶zõjõÃ2dÈwu4bìØ±j;ä€hñÅ©ß-í4µÜ”L)eSwµè4íß Fpð“mPìESaÏŽµu!üÒ[¿N®Lý‚rû�@áš.úK<(;¬�G©>«xÖ� ÇIbpÅÝFvêg{w€Ü8ÔäÑç¢M±>;ç>ÑŸ5(öb”|[)ú}eð�€z?}œøRZZZ7å,R÷þûï«í–6mÚå4ȧ�všú¬aY³f�»:Bì*ßU75»» rµ@”všºÉ�L%-ʦþÅÞ5ë¾^?KÙ…ÂÖJA7nØ© »&-‡ô€Õ¯“+�‰~Aa€›ØÍ›VÈ£%¢ßWP–X!æl‡r±èícÃ]Ò@î٩샦‚®4é‘´\±°Ï*r?kPì”ù–ê{ý¯+õì�†A?tùÂ4½é¹é¦›Ô6 Ëí·ßî®^fΜ©>kXì”ÑHMÛõËPšöàç´šÒ²§n–‘^ ÷µêDpð³”mPH&ŠþNµY`Rš´\Ü¥3üõúurå1Ñï!( ëE‰å·õËçÓ ¢ßWP6X!æn½mlì�n‡%- wìÅî9ió†ßTÐËEΠ<}`…èêêòkRjd‡v0Çe‘è;  12%ŠŸM¹Ö¿ÊÈi-þ2%%°ø™aÄÑz.sÅ^g5? µkT€@ÕéÝíþ<*§ô,SO§Š]¯¨4ê�û;Hô“…»„Ú¨/-ò>&öXÕèMI7Lì÷JCô]Í@1ljÝ_LfÏžíפÔÀßÿþwsI`ñ“mkƒ]ž{ÍÕèC�Š0¨º-Å^À£‡ŽÙ‰b×+*[ô,Ó(>"ÑOîj ^tà÷ˆØcWó«P»¤Ñ'¨ü¼(@´�‹ÝgL.»ì2¿> Š&OžlŽÃˆ|L aº‹Ÿ-ùù͹¶ÏúÿÖ/ ,~² bo—b¯»ï¹|8Ô®‘PU—{�о·ÞN»^QiéY¦¬ãòb·�æ'¡vâi{—±Ç¯’w µK’õÄ~Ÿ¨<ïrWBr�Ñš$ú ‡ÿd³Í6 :;;ý€*X¾|y�ËåÌqX$OIá€DÉd[/•1­#üÿÞo ,~² áwb¯½šG]Ö µk€@Õm"ö•8¼CV×Á_¯¨¼¿g™Fp¶Øï¯ù‡4fGH"½YÃ?†5Oº l ³úÐ÷9ùß%*¿èYHƒ“Äîã&3fÌðëTª`úôéæø‹Hf;€ú£øÉ6¨}½Ñb¯¿š8$öXÖœj—$KÄ~—b™´j ôæºwÅîçkdÏ=÷ôëT*¬««+Øm·ÝÌñW$zÌê±  øÉ6¨þ~µ`P‹Å^Ä‹åªU Ô‘®ƒ¿^ŢߩèÔÏÜA ¤ÇÖR¼p´ÒeïP»¤xFìw)–?®ZH‰Éb÷s“|ЯW¨ Y³f™ã."  €â'Û ºôæfÿ:¬i´ì(€š˜+ö"^,·­Z Žþ,v½ŠE¿S#ø­Øï®iÔw¨i 7oøÇ´FßM˜´™ f‰ýÅÂ{u‘6Û¸t‰Ý××ÈÁì׫TÐ�hŽ»"Ñ›¬>$€ŠŸlƒêÒ{=Öèk‘`P{/->Ô[ÔÓ®~î]µ@Ší.ŧÎyÏåáv’E‚Š=¶5¿ µK-ìúß¡Xô:¤ÍMb÷u“‡~دY¨}ÂÞ?Þ"2]«QüdTßG\:Å^“—»ìj—f€@M\*ö"^,:-i¦g™z)÷}’W®Z ¥»<-ö{kN µ�Lú$Ò;b�o}Jéc¡vq÷ ±ß¡X^^µ�"ûŠÝ×MÆ�ï×­TÀg>ós¼E$‰¯X€ê¡øÉ6¨�_‰½&kf»¬j—V€@ML{�ÊV=ËÔC‹Øõ‰ÊÉ=ˤÕyb¿³æ~—æP;É¥7søÇ¸æ9—õCíâì+b×?*Cz–Ò$êiþ5òÀøµ+pï½÷šã," `M?Ùµ¡SA?&öÚ¬ùY¨]ZQ5q€Ø‹xT¾Ø³L=|ZìúDås=ˤўRx ÐÿÎ:õ³¾s@:èŒ ÷ˆ=Ö5zHl/vÝ£ò±že€4Ñ~“¿¯›ì·ß~~ý @?uuuûì³�9Î"r ÖDñ“mP;;JôTл„Ú¥`PÃ]ºÄ^È‹å7=ËÔÃ�Å®OTr=ˤÍz.sÅ~_Íñ¡vÒa¬Ÿ ZÏÙû…ÚÅ•jË�ºÿ”že€4irù»ØýÝäλîôëXúáöÛo7ÇWDôé_=Fa?Ùµõs±×hÍ—÷…Ú¥ `P3Oˆ½�Ë£«¨ƒ;Ä®O±Ì[µ@ ýQì÷ÕÜ'Lý ¤•ÞÜáó«ÎuI˜6ù±ë^,wõ´ÒFg%ñ÷w“±cÇ~- @tvv[o½µ9¾"2^ÅO¶Ami!ô±×iÍ/CíÒ†0¨™¨Â¢�z¸Ö5XŠ?W,—÷,“6—âOj/“ú¾›@uéTÐw‹=ö5zîŽ;}'»¿ÞŲÂåý=Ëió€Ø}Þä²Ë.óëYúà /4ÇUDf  8ŠŸlƒÚÓWuˆ½^ëTл†Ú¥ `P3Ÿ{!�ʱ=ËÔÒ—Å®GT¾Ú³LšèÔÏÏŠý®šï‡ÚH§1R|*e½)äS¡vq´­ØõŽÊ{–ÒF�S7innÞ|óM¿¦  zì 4ÈWÑ+ÅPüdÔÇOÅ^¯5Oº¬j—€@ͬå²PìżXô]vµv“Øõ(}J8 S¢öÕÅb¿«æ^)< ýôfÿ yÉeX¨]=.v½‹Eßq^�éìõ�ú„æ4—oºŒ\óŸ�Šø›Ø}Þää“OöëZÊ0qâDs,öº­9;Ô.-(€šºTìÅ<*ëY¦>(ŧ>.–öžeÒä“Rüû/uÙ2Ô@ºéÍwŠ=hô&‘8;Mì:Gå=ËÔÒb×c¶Ë¯]ö–Âà¨�¥ð* _3™3gŽ_ÛPÂc�=fŽ£ˆè1¸£¢QüdÔÏ6.ïJñë÷^¡vi@ÔÔNb/æQ¹§g™Z¸ZìÏ�JܧBí«¡.ÿû=5ß µÐF»,{>Ðh3®FHñ�|±èÍzî«•µ]»á,’ Fõ,ô×åb÷/“ÝvÛ-X¾|¹_ãPÄŠ+‚=öØÃG‰û SP?ÙõuªØë·æ_’®© )€š»Kì=*G©*½ÃÏÿ¹QѧµÒæ2±ßSs‡¦,Ðx¾#öœ yÙeÃP»¸¹Hì:G媞ejáWb~±<ºj`ôfˆ·Åî_&_|±_çPÄþðsüDDo ªõP y(~² êK £нŽk~j—t€@Íí'ö‚ýæ…Ūâýýôk±ZX,5ôw5õs5·;€xÓ›?n{nÐèM#q¥ç­±ë•ã ‹U•¾ï×ÿ¹QùRÏ2À@�"vÿ2ijj æÍ›ç׺„<ÿüóA&“1ÇOD&  w?Ùõ÷!—wÄ^Ëu*èq¡vIFÔÅ ±õ¨<íò�Âb5Ìå±?/*³$]OÄêô§/Šýžšo‡ÚhL#¥0-±~Ðj7úN]}£¢ƒûjNuŒ”ù>V—g¤ðf tÚqí?ùû™É�tuuù5/ÎÊ•+ƒ8À7Ñóø:èÅO¶A<œ(öz®yÎeýP»¤¢ êbK—÷Ä^Ø£2×e»î%+c¬Ë?Åþœ¨,wÙ¾{Éô¸Rì÷ÔüEÒUèÐÿ-ö¡yEâû®ÚÁÒ·™4ç¹¼O®ýcÁåbN©ÄùýÊH¦ÏˆÝÏŠ&ŸÏûu/ÎÕW_mŽ—Ù_塸É6ˆ½÷±×t͹¡vIEÔ�>uå_ØKe™¦4È�õ빜&…)ŽýÏ/•ÓuáùœØï¨Ñ)·s¡vp³Øs…¦–ïÐí«=\:Å®s©üËå‹2°`֒µm¾ØÏ/• ua  ZÅîo&:½íܹsýÚÐО{î¹¾Lýü'”�â'Û >ô‘¨© ÷YÝ,‘(€ºj{qï-¯ºüÜe[)�þA~O)ܽ÷†ØÏë-Óe`�¸ÙX ÛÐÿžš£Bí@mæ²PìùB£Ó¸úb×·œè´¹§ºì åMÉ<ÜåÓ.—º¼)öóz‹Nª7'Õð~—×Åîw&»ì²KÐÑÑá×À€†¤ÇÂî»ïnŽ“ˆ,�µP.ŠŸlƒx9^ìõ]3ÏeH¨]ÒPu¥SuþMì¾Üh!ó6—?ºüR ´?Ãe’Ë)¼ã·Ø�|åæ~— $]¢žÒ혦B7€Êù†Øs†F K›„ÚÅÍ9b×¹/ÑYq¹Æå2— ¤ð”×õ.wJôÍ4åæe)¼¨¦¯‹Ý÷Šæ¬³Îòë`@CúÅ/~aŽ�ùŠú†â'Û ^ôÆß»Å^ã5ú÷Ƥ¢ êN ¬‰½È×;³$}ÅßÏ‹ýžš·\²¡vàÓ¢§îÐäÃ�bè7b×9Ñ÷(P€Ú¸Uì>X4>ø _ Ê}÷ÝgŽ‹Ñ×$úŠâ'Û ~Ƹ,{­ïrùT¨]’P± Ó_^+öB_¯è»-õéä4Ñi _û]5G„Ú@1�è)Ž'„ÚÅÑw\:Ä®w½òBñµ5Z O´ûû¢Éðáà ø51 !ÌŸ??6l˜9."²È%'€¾£øÉ6ˆ§ï‹½Þk^rj—€@lèôÃ'»¼'ö‚_«èÝ~ÇH:E½oùÆp#(AoñÏ!}ÿáˆP»8ú¨ËSb×½–Ñ»Çﲎµuüšì·ß~Agg§_RmùòåÁøñãÍñP"Lý ýEñ“mO:ô�b¯ùš‹Cí’‚0ˆ}*ê/b/úÕŽ¾w3I§/‹ý¾}rad¨ôfšØs‰fz¸QL­írºË2±ë_íè;é?.@}M»oÍOúS¿>¤Ú©§žjŽƒÑ÷Áú‹â'Û ¾F»,{í×°ºY"P±5N ïÕò/þ•Œ>m|¹Ëv’^Ã]æ‹ýîšÃBí ¥Î)‡†ÚÅ™N‰ÿS—×Å~‡Jç—OC]æ‰ÝO‹fúôé~� H¥3f˜ý¿DžsÙ@ýGñ“moß{ý×¼ì²a¨]ÜQ±7Úå$—G\VŠí ô5ïºÌtù¦ËÆ’~QïVNÂÓzâIoñÏ)š7¤öÇ�Ð'‚?#…Á§NcíŸþDß5|›Ëÿ“Âõ ˆ›½]VˆÝwM2™Lðä“Oúµ2 UæÌ™Ó½¯ûûD–»ì!€�¡øÉ6ˆ7}EÝ-bûšËBíâŽ0H}re)Láy…Ë].ÏJá)®¥R(ëßß’Â�y�¹Ìp9ßå{.;»¬%�ã«b;Oš$¼¯@¼]#öÜ¢Iò{ÅõúžÔIRø~¹¼àò¦nÒkŒ^k^u™ëò)ÜPô)ÜT´»ËúÄßb�Ý¢ÙtÓMƒùóçû53 ^}õÕ`øðáf¿/‘Ó0p?Ùñ7Êåm±}ÍA¡v@Í}@ E ¿£ª™jý¡Ó(¿&öü¢9<Ô@üd\n{ìÍŽ;î,^²Ø¯�‰¶téÒ`×]w5û{‰èM¥zìŠâ'Û ¾-¶? yÅe£P; ¦®ÛIÕ´…À|^ì9F³È¥%Ô@üèûËž{üÍç?ÿù ³³Ó¯¡‰´|ùòà�C1ûy‰è¬ÃP?ÙÉ SAß,¶_ ¹2Ô¨™#ÅvN5:Uö&«›À€éM%þ¹F£Oˆ·í]–‰=~‹æÄOôëh@âtuuǼٿKD§þßN•Cñ“m�#]Šíhµªn3‰îœ~1Ô*ac)¼×?ßhŽ\Ý @L}Eì±™_üâ~= H”sÎ9Çì×½äk¨,ŠŸlƒd9Jlÿ@ÃC¨©¨éi®7€ :Pì9Gó–K6Ô@<ýVìñ™óÏ?߯©‰ð¿ÿû¿fî%ç ò(~² ’çz±ýMk¸P-ßÛÕ¼â²Q¨TšÞdâŸ{4·KáÝIâ+ã2Mìñ™K/½Ô¯­±vÕUW™ý¸—\#…cPi?ÙÉ£³í½)¶¿ 9$Ô¨8}/É"±QÍBí †¹¼(öü£Ñ›SÄÛº.÷‰=~#ÓÖֿר€XºþúëÍþÛKr,€ê øÉ6H¦#Äö4 \F„Ú£O×ýYl'TsÅêfPUŸ{Ò¼í’ µOú³çÄÑioo÷km@¬L�:Õì·½ä—ᨊŸlƒäŠš5gz¸P)Ljí|j^vÙ0Ôªír±ç"ÍÂTÐ@|Èe¡Øc82W\q…_sbáòË/7ûk/yÃ僨.ŠŸlƒäÒ›Äæ‹íCh¾j Øh—Åb;žšO¯n51Ôå±ç#�Þ¬ þv“è¾EÑLš4ɯ½u5yòd³Ÿö’¥.{  ú(~² ’í0±ý�ÞH¶i¨Ðoú4Ý­b;�šKBí –>éÒ%ö¼¤\ß"Ô@|}Üå]±ÇqdÎ9眠««Ë¯Ã5¥ûàYg�eöÏ^òŽË>¨ ŠŸlƒä»FlBsC¸Ð_ÇŠílj^¦~P_“Åž›4÷¸dBíÄ—¾×»Cìq™N8!X¾|¹_“j¢³³38þøãÍ~ÙKt/€Ú¡øÉ6H¾÷»¼&¶_¡ùj¨Ðgc\–ˆíhêSwü @½màòo±ç(�Þ¼ ¾à²\ìq™ƒ>8X²d‰_›ªjÙ²eÁ¡‡jöÇ^²ÂåK¨-ŠŸlƒtø¼Ø¾…f‘KK¨P6}zî.±�LÍ…«›@]é²Å¦‚^æ²U¨€x;B …2ÿXŽÌ®»î¼öÚk~�¨ŠW_}5ØqÇÍ~ØKôƞЀz øÉ6H�6±} ÍMáF@¹~ ¶s©yÞeÈêfPwˆ=Wif¹4‡Úˆ· ÒÇé 7ÝtÓàÉ'Ÿôku@E=ñÄÁðáÃÍþ×Kt_þ¢êƒâ'Û =6vyUl_Csäêf@ï6—詟÷µ€8XÏå±ç,ÍCíÄß.ïˆ=–#“Éd‚뮻ίÙ1cÆŒî}ÌßïzÉ{.  ~(~² ÒEû~Có–K6Ôˆ¤S?ÿMl§Ró‡P;ˆ“}¥øTÐïºlj þƹ,{<—Ì)§œtvvúõ; _:::‚Ÿýìgf?+#K]>!€ú¢øÉ6HŸ«Åö;4·»4…ÚEýHlgRóœ0õ3€xû_±ç.ÍýÂTÐ@Òìî²Pìñ\2ãÇ�,Xà×ò€>™?~°ß~û™ý«Œ¼á²«ê�â'Û }†¹¼(¶ÿ¡ùV¨`Œ•âÓ.®tùX¨ÄÑ`—§ÅžÃ4'„ÚH†¹<+öx.}Wë<à×ô€²Ìš5+6l˜Ù¯Êˆ¾ŠàƒˆŠŸlƒtú¬Ø>ˆæm—\¨ð:õó=b;‘šß‡Ú@œíá²BìyLßǸm¨€dØX¢û'%£Ó÷ê4¾@9t_™4i’Ù�ÊÌ.› >(~² Òë ±}ÍÂTÐ(âd±�G�>y³~¨ÄÝÿˆ=—iuY+Ô@2¬ã’{L÷š�wÞ9˜;w®_ëÖ0oÞ¼`�=ö0ûO™™æ²®â…â'Û ½†º¼ ¶O¢9&Ô�­]ÞÛqÔ©Ÿ÷µ€$xŸËbÏišSBí$‡ÎTò±Çt¯Éd2Á”)S‚®®.¿î‡·råÊફ®êÞGüý¦ÌL’¾ ˆŠŸlƒtÛÏ¥Klßd©Ë¡vh`ƒ\Ûi\õG-H¢Ý%z*èíBí$ËaRøÃ–l÷šñãÇw?é ¨_|1øÂ¾`ö“2£7N)€ø¢øÉ6H¿KÄöQ4¦‚€sºØÎ¢æ_Âtv’MobñÏmš¿ SAIö_.ψ=¶ËŠ¾çµ³³Ó¯¢A,_¾<˜e~�ýÈÍ.9T]†â'Û•4Øåi±}Í�Bí�B< ‘é»§{ÔèÍ1Òå—b�÷>å ƒ î¿ÿ~¿ÞˆЩžgÍšxà�æ÷Ö�¼îòUÔL†â'Û•¶‡Ë ±ýœ÷\¶ µ@Šè;0ÿOl'PsÆêf�jÛ»tˆ=ê�0»†ÚH‡ ].{Ì÷9:5t{{{ÐÑÑá×!QcË—/»S=¯J»ËpÔT†â'ÛÕð?bû:šG]Ö µ@Jœ%¶ó§™-…÷@£ø©Øs¡æIa*h ­Æ»Ì{Ü÷9#GŽ .»ì²`É’%~]U¶xÉâàÒK/ 6Ûl3ó{égtšÄýPŠŸlTƒNý”Ø~�æäP;¤ÀnR| �ú#¡vÐt*è‡Åž5z³ €tÒ'ŽsyKì±ßçd2™àè£�xà�Q=sæÌ N9å”îmîÿú™¥R˜GgÈÔI†â'ÛÕ²»ÿ; N½]¨Lÿ°õ¸ØNŸæôP;h$Û¸¼+ö¼¸Òe¯P;鳱˹Rü�býÊvÛmLž<9X¸p¡_»D?-^¼¸{Êí]wÝÕlïDÏñWºŒ@Ýe(~² PM“Äö…4¦‚H…߈íìÑá‘ÓÄž5ÿrY7Ô@:é,(w‰=ô;MMMÁá‡Üpà LÝ:ÅóôéÓƒÃ;ÌlÛ ä¯.; 62?Ù¨¦R„ü8Ô Ä”/M§‚~Pì9RóÛP;éöI—ûÄž-ãßfÞ<3X¶l™_ëD�¥K—3gÎ Ž<òÈîmæoÇ d–Ë';ŠŸlT[Ô+á–»ìj€ìò”ØNžæäP;hd’è© Ç…ÚH?-?$ö|P‘Œ7.8ï¼óºßgÛèï ~饗‚+¯¼28ä�CªUôÕüÓe‚4¶_‹í'if»¬j€„ø½ØÎ�æ—æP;ht'‰=WjžsY?Ô@ú5¹äò°ØsBŲ馛'œpB0cÆŒàå—_ö룩£ßo¼±û;ëw÷·G…£3;|^ ¿K€F§SA?&¶Ï¤9cu3$ÁžRxzÍïØéÔÏÛ†ÚD2.÷ˆ=gjÎ µÐX>æ2Ã¥K칡¢Ñ¢èÑGÝýTìO<tvvú5ÔÄèèèüñîïòío;1b„ù¾Uˆö{ofn(fG—N±}(� zçP;ĘNý«›h@[»L–âÓÅW-;î¸cð�ï|'¸è¢‹‚»îº+xå•W‚•+WúõÖºÑuѧ—uÝ.¼ðÂà˜cŽ vØaó=ªý�\,…éüíL±})Í.ï µ@LýAlgNsŸ0õ3”2Qì¹S3ÏeH¨€Æ´‰Ë‰.O‹=OÔ$ú¾Ü­·Þ:˜0aBpòÉ'w^gΜ<ôÐCÁÓO?¼þúëÝOà”~Æüùó»?S?[†þ¬“N:©û½½cÇŽ­æ»{ËÉSRø]èï½[ËåQ±ý*Í/BíCûJñi —¹|0Ô`éTÐw‹=‡jÎµÐØôݲû¸L‘?\n2™L0zôè`»í¶ Æ�×�Ï}îsÝÅ[-kôë[õïÚV—ÑeýÏ‹It[_-…©¹y¿/@ßmïÒ!¶Ÿ¥SAïj€YÏå±�8ÍBíÑ6wY"ö<ª7×|*ÔÔF.ß—ÂL+ÅnÂ#‹nÓ{]ŽuÙP0Pgˆísiž¦‚ˆ¥ ÅvÞ4úG3¦~€ò'ö\ªyÞeƒÕÍ` Y)œ?´ïE1x`ÑwÑ�á²… ’¹<,¶ÿ¥9+Ô1ð )þ‡F�úyËP;@ït*è»ÄžS5­n‘t6�“¤0­¼N©çŸKÈšét¹Óå—Ñ€jÚFŠ¿Êd¥Ë^¡v¨#}íßb;mšï…ÚÊ7Æe±ØóªÞls@¨ôf¨ËÁ.»¼ ö¼Ò¨Ñþ«ÞTó—!ÒFNkiʶޖɶå¤9Ûz—ÿqãÖó)½“�ü[þw ¥NÛ?ÓüËeÝP;ÔÉ%b;kš¿º4…ÚúFo¢ñÏ­š—„wQè¿m]¾ërµË<±ç˜´æ9—«\Ž‘ÂS' ¥9—?B‹‹¶à�D€[òóýõNv(†Ný�Ø>›æ7¡v¨ƒý¤øÔÏK…w¦À@éM4·Š=Çj. µ€�éò%—s¥ðþà·Åžs’ýú]ô;MpÙLÕ×mÒ”m»Þ{O" ÀÙÖ÷üõNv(ÊÖ=ô¸P;Ô�N'5�àÑ¡v€þ%ŧ‚Öj•4Úås.§ºLuyL 7øùç¡zG×I×M×Q×U×y´ [󨶃3ÙÖ×m‘±¼Ä¾Ürߺþ:'?€ çd±}<ͳ.ë‡Ú F®Û9ÓüE˜ú*éÛbϵšW\6 µ€jž>Éå—ë]þæò„Ëk.ËÅž¯úý ý,ýLýlýt9Q ?[×A×ÅŒ¾~X&—¿Êû–Ø€GÝò�“ À€†“q¹GlPóûP;ÔÀgÅvÊ4:Ý^.Ô0pzSÍÍbϹš?…Ú@\ sÙÜe+—�z²·Ë'¥ð �þoýo«þ]Ûê2º,úiPöšý3ÙüK¶°Ø÷Ŀܶµ¿ÎÉ`@CëòŽØñ®Ný±P;T‘þQî%±�2Í7Cí•£ïè\(ö¼«98ÔЈFܺ^&›¿ÀûŸø€Ûw÷×9ù¡ hXÅŽu5Ϲ µ@•LÛÓÜ&Lý Õt”Øs¯æua*Th\¹ö½2Ùü3¶˜8°Ä½ÜÜÒv€¿ÎÉ`@ÃÒ© ï;ÞÕü!ÔUp ØN˜FÿP‘ µTÇL±ç`�Þœh$[Þ¼N&×zN&ۺҞ؀³Só×9ù¡ hhú:�%bÇ»].û‡Ú ‚ÞïòšØN˜æë¡v€êÙÌåM±çaÍ!¡v€4Õö‘L¶õq[@¬\â^ÎdÛ¾ã¯sòCÐðŽ;ÖÕ”P5;‹í€øYõ4ðàžeÂ&Šm¯™'å== H¶?ˆ½hîsiµ[å3.¯ˆmïçìU b*EàL6?×_ßÈ´äÏò—©4L¢ ¹G…Ú­²®Ë¹RüFi?[ö,PŸÛ‰Ê)ŒWëòN‘v+]öYÝ �bzsÐ\±×Í�Bíz›þÊOW@Ü¥«¼À_ßÈäZOñ—/j§GÖ’ì´]š[Z¿•i™rnS¶íF÷sqŸñ²û¿o¹ÿûžËJ—¥.¯gZZŸoεާSLgrmg»å—‘mt—ÅRO€êú¬Øñªæm—\¨Ý^.Ïi•ðßX*î`±�RYî¢w¼¿Ïåž"ÿ®Ñ;Ý�cO)Üüã_ÞsÙÖe—‹ü{©äoé*wúë�¶ïøËÿÇ–íÃÝg}¯)×:Óµ]b—íW¹Ïlï.çnÒª@mýIì˜Uó)<õ«+]QäßKåãPEßÛ)'¯ùo}gðúh4¿{MXu½(gú+?3oi)osçúþº–JsvêaþG Õúñ¾­Ëýö•�ªóSdô´�úëªf¨Ë bÇ­š¾Þì¼*_€*:Nl¤¿Ñ§¿Æ  éTÐO‰½6ô7÷ ÞÒR9­Å_×Rini;`Õ¢ƒrS?Ù3­³iWí4çÚþ&-×îþ* jö“þÝÜ•¯ @ýDl¤¿ù­ÙîÒ÷i¯¢òˆ·´€³×}Ø_×R‘Qí»ËÓ³=Oüš¯}òm²Åu›ø_ TÜ¥bÇ®ýÍ÷ Š´hëw@ú½ît—ŒÕ¹¼.öÑŸèôZ€8KK8×¾—¿®¥“?3Óý^^ÿ¿×1-ùùÍ£Ú?ã5P1MR(ÚVê)à @]"¶2�Üæ’@#är’K‡ØëBó¶â-%àæ\ÛgýuMnògúß Øh—;ÅŽ[’ß@]#¶2мåòm4‚m\{-hô®jf•€8KK¸¥õp]ž?ÉN�¬åOÐ/_sY,vÌ:ÐL€*Ò'výH¥2ÓåH#�þJoöY&öü_© @|¥¤œÕv¬¿®ÉO¾Ý]J¹‘ €þÛÔåF±ãÔJÅ]«ªç±�JFßy°Òd´T~ú«bÉ ¾ÒRÎæO÷×µÂyÏýŒ¹M-ù;š²m×gr­W»ÿÖÚ”ÍO×mâþí9—Î"Ë 4úß”e‚ËbǨ•Ì-PEOŠí€T#zWÛFHºjMU,@|¥¦Üú]˜·3Ù|[sË”oJö:w-+ãIÜmÚ×–l~çL.ÿý¦–)©XA8×úÿG€Hº\-vlZ�Ü/4°A.#\¶uçò—ÿv9Õå,—s].êÉ)ÃÑ öªÿ]Ï2'»|Ëå —½¥ðîBýú³Ñ+b; ÕÊ«.Ÿ@U{ú«bÙSýÉ�špþ]ûý~Í£¦~±»˜;PcZGdZÚ~âÖm�ÿsú–|§d§íâ<0pyY츴Z™#PŒ Åô©P=ëE?u¹\ ÓJÎsY.öâX­èÏzÎå¯.—¹üD ë¤ë–æ'W—ŠÝÕL—Ëù.ï@R|Åe¡ØszµóI/ú?’/%à¦\Ûµþºö%ÍÙü½2ª}wÿs+bË›7ÈäÚÎv?g¹ÿsËOþ÷9ëø ºm …ñ˜?Vªv´ØÜ(ÿ@Êé6ÛIá�Èz'ÎÍ./‰=Ç5/Ja�uÝõ;èwIú]Cºþþ÷¬UZ�‡ˆ=‡×*z½M:ú?Ò+-àî)—íúöžü›Í£Ú¾ê^UŒžöQ÷óæÙu(7ùÓý�Ýô]¼þ8¨чrÒ†ñ/4ˆ!.{¹œä2Ãe‘Ø“jÒ³Ìå^)\>ç²±$‹®¯ÿ�j•%H½+Ö?‡×*ß‘ä¡ÿ q¤¤œÉNyÔ_×ÞÒ”m»E¿¿ÿYUµÅu›4góùëR^òËdä´ÿ#hpZÜÓÙ ý1O­’ôâ"ã_hëIá$ø—ÿsY)ö„™öèw~L Û@ßu;Xâms±ß¡VÑ»ëñw”Øsx­¢ïí‰;ú?Éëÿ¨”Ô€óÏúëZ:ù_¹Ó_Æÿœš(L ýw»Neå·þǹ_ì§VIZ1‘ñ/ã_ D ˆß–Â>ïŠ=!6zô½z‡�Þµ“ÄÏb×¹ÚyG ïæ½ �G»é¤wú>(ö_­èµqAÿ§ÿ‰[ÿ@¥¤¥œDÙüÞ™l~…¿�{Oþ{þG€nZ>Ãe¡Ø1Mµ²½Äãßþ‡ñ/€ÄÙV À‰=©‘þåy)Dõ= õr Øõªtæ¹'…)Bé ×.½û·ÚïJºJê‹þOåó¼Ô¿ÿ R(וۦçûÛ¸·¸ßÁÝþç€5¬/…¿e¾ váÒæòžØUÍÓÜÜl»í¶Á~ûíqÄÁñÇüêW¿ .¼ðÂàOúSÐÞÞÞ�™7Ï n»í¶5róŸÿüŸ¿êª«º—Ñeõ3ô³ößÿîÏÖŸáÿÜ:E·yÞåãRø]TÛ±b×a Ñwhç¥ëˆ‡ÑR˜âH§û÷¯ I­žR¢ÿSßÔºÿ R(×�Û®¿ñ·soiÎN=ÌÿPÒªY°ü±Ì@s¸Ôãßú†ñ/€šær¢Ë3bOJUO.— ;ì°àŒ3Î.¿üòà/wÜ<ùä“ÁâÅ‹ƒZyûí·»¦þl]]—C=49r¤Yße®Ë .C¥zNûsû“•Rèí"€F¶�¦ÊzYìµ¢?ÑÙ$ª‰þOcöT àú˶máoç^“k;ÏÿP}¯k%gÁúžÔã_Æ¿Ìh—ÿqY,öT•Œ7.8ù䓃)S¦�<òHMOòý¥‡‡~8¸ú꫃“N:)Ø{ï½Í÷ªbôwó;—QRyçˆýy}É)¼Ç ëH®u¤0UÖ“b¯}É<©ŽÑBÿ§W)îÿ¨ À±�ɵþÃßÖ¥ÒœÍ?äè“1Rø›è2±c™¾äT©¾ÑÂø·WŒ¤I¥ïV*�ZaüøñÁ¤I“‚»îº+X¼$þ'ûr½óÎ;Á?þñ�`òäÉÝÓI <Ø|ÿ gÕS¶•ʹPìÏ)'¯Iáéá�€h—C\{-)' ¥²èÿ PJú?*…p,dr­çøÛºtòúÊ0p›¸üÜå ±c™rr¶Tãßbü iôäq›Ø“KÅ¢'ü?þñ�Áœ9s‚•+WúçÍÔÒïúÄOçŸ~÷»üíRáÜⲫ œ¾oÀÿìRyV S{®« ЫޙÔ%öú¨Vâ�8ôª$¡ý•B8šGå¿àoëÞ"¹›6ô?ôÛªY°ž;Ž)• tá cü[%ŒÄÕN.7‰=‘ 8ë­·^0qâÄà–[oMÄtµ¢ÓFÜrË-Ý/œ¯âÝA7Ján®þš)ö3‹å~—ƒ¥ð$±­Ë.b¯7Ų~÷RýCÿ§ÆÒÿP)€ãaLûX[÷•ç< @å r9ÌåïbÇ1ÅÒZX¬"ÿÖã_õ¶�ËuÒ·§mzÍÚk¯üèG? fÍš,_¾Ü?÷Á£Ûèž{îé¾è¶ó·ç£¿Ûi.–¾›%öóŸ{»ËçþÓ€Êár†¦yö¯AálÖÓ¾/èÿÄ@Œû?*…pœ3PŒc€ñ/€ZÒ÷\,…yãýF¿’Éd‚ïÿûÁÝwßtvvúç8”©£££û}Ç{l÷6õ·ó²Âå"—áR¾»Ä~Ž>‘u™Ë6«›P5C]NtyYì5I³éꦽ¢ÿS1ëÿ¨ À±á¶ïR{—J󨩟ö?TÅ.S¤ø»xõéÎþbüSŒTËZRxGë[bOýÊöÛoßý¢ó·ÞzË?—a€/Y´··»ï¾»ÙîÈ)€³­ùŸjbC—Ï»Œñÿ¡ ŒŒñ/€þø€æ€÷O}ÎСCƒI“& ,ðÏO¨‘E‹ç�w^0dÈóûégnrÉ éBÿ'Eèÿ E862¹ÖÅþö.•AÙkö÷?Äãßaü  úTÌ·]Þ{Ð÷)cÇŽ ®¼òÊàÝwßõÏG¨}WÀ 7Üì°Ãæ÷Õ�è>¢Sƒd€d£ÿ“bô€„¡A&“ͯð·w©H®}/ÿS@ì0þM1Æ¿¢lír¯Ø½OÙyç�ƒÛo¿=X¹r¥þALèïæ–[o­Ô…ào.’‰þOƒ ÿ$àxuËümÝ[${݇ý�±Âø·A0þ°J³Ë©.ï‰=¸Ë·?üá`æÍ39ñ'ˆþ®f̘l»í¶æ÷ÙÇè¾s’p7 9èÿ4(ú?@ÌQއ–ö]ýmÝ[dË›7ð?ÄãßÅøhl£\î{@—�Í7ß<˜>}z°bÅ ÿü‚„ÐßÝu×]Œ3Æü~û˜;…wâo”Ðÿixô€˜¢ Í-S¾éoëÒÉ/ô?ÄÂ(aüÛðÿ�ç—7ÅÄe%“Ét¿X\ç•G:tvvv¿·aРAæ÷݇¼åòU žèÿ` ô€˜¡ nÛžïoëÒ™ò¨ÿ îÿb Œ�ôÓi™.{à–�£�>:xíµ×üóRâ�7ÞN9åó{ïcÚ]† ñ@ÿ%Ñÿb¢ À[¶—\ûNÒ2c¤ÿOq‘ii}ÒßÖ%“k½Øÿ P7ŒQã_ �¶s™+ö`-+ûï¿0gÎÿ|�”züñǃO~ò“f?èCþå²�P_ôP6ú?@�%½¼Í�ë˨¶­e§~ª¹¥õ[™lþç.—7µäïÈäÚžvÿûÝÕEÓ¶Éþâ±0ª}Œ¿�{‹Ní ¨ Æ¿(ã_ =uY*ö í5:ÝÃäÉ“yÁ{êêê ÚÛÛƒææf³_”™%._êƒþúŒþPG .grù“üõ+�üBÙ¦}mÿsê-3*ÿc»®¥#¹k·õ?Ôã_ôã_ Ù¹œ%öÀ,+‡vXðꫯúç4˜ Gu”Ù?ú�vd- 6èÿ`Àèÿu�àð QS÷õׯ·4gÛ>ïN]Mš3Ùü¿ýõ,™–Öçý�5ÅøÆøHž.w‹={ÍСCƒ™7ÏôÏhp3f̆ bö—2s§Ë&@uÑÿAEÑÿj(Á`Ùì‘Á™l¾Ã_ÇRiÎæïõ?¦žt*g{Ï”ßùŸj†ñ/*Šñ/� :÷ú<±a¯™0aB÷@1 . ?üp³ß”™ç\¶ªƒþª‚þP#I.;M-­7ùëØ[bóðˆ[×Ëdó/øë×[dÔ5{øj‚ñ/ª‚ñ/owY$öà+æúG¹V½@÷?½T±Š€IDAT*# ]ö*‹þªŠþP /7góGúëXFžˆÃ»€Ýzœ_dÝzIþŸþ瀚`ü‹ªbü ÄÓ×]:Ät%³óÎ;O?ý´œ%=÷ÜsÁ{ìaö§2¢ûè@e|]èÿ FèÿU”ð°lyó™\ëb={Oþÿ£j©yTÛÁnºìz•޼ýÏU÷uaü‹aü ÄC“Ë.]b¶’9õÔSƒŽŽÿØÊ¢ûÎI'�dö«2¢ûê�€þ£ÿƒº ÿTIÒ ÀN&×vž¿žå¤yÔÔ£üϪ‰–kwËdóËüõ)#/ÆáÉeã_Ôã_ ¾ôäÿ{±XÉ455×^{­<ýrà 7ôwJ½Û=#ô ýÔý ÂRP–ܵ›g²ùN]{O¾³9×úuÿãª*;u\ÿžXî~wñ¡þÇ€ªaü‹ºcü Ô^³Ë¥bª’9rdðøã�ûÇ00 O>ùd°ùæ››ý­Œ\å2H(ýÄý ‚ÒP–þ?\HþLwŠÐ?òVU&Ûö]÷óÞ³?¿÷¸í~�ÿy jÿ"6ÿµ³–ËT±SÉrÈ!Á[o½å»@E,Z´(8è ƒÌ~WFÚ¤°OP ýÄý BRR–‘×mœÉæøë[nš³ùû¥¥}Wÿc+¢åÚ­š²m7û?³Y*£¯ý�ÿ± *ÿ"vÿÕ·ŽËõb¢’9á„‚åË—ûÇ,PQ+V¬N;í4³ÿ•‘›\ÖŠ£ÿƒØ¢ÿƒÆddŸ;U%-3F)8F¦9Ûz·ùŒJe€šGMý¢¿¾}K¾+“k½Z²ù½¥Og¯û°ûÜKÝçvØŸU~šsù#ü�UÁø±Åø¨=ùß"öà)™‹/¾Ø?N�ªºà‚ Ì~XFþì²¶°&ú?Hú?H»L¶õB¿(˜ÆÈèöüïÞW™lþÿsû—ü¼LKþ¬ælþ@ÙbzÖÿ9EmsçúƒFç÷É´´ýÄ-?Û~f’×÷·€êcü‹D`ü T–Îùß.ö ‰Œ¾ì}æÍ3ýc¨‰Ûo¿½?/‡×»Û|×= 5èÿ Qèÿ Í2€Ë·MûÚ:MµÿÙÈ™\Ûß›²ù;]¦grù«ÜËëÿnÎæïÍdó/¹ÿe‘å�¼»ÿ+€Šcü‹Daü T†¶¦ˆ=X"³öÚk³gÏö�I ¦}ôÑî}Ñß?{ÉÕRØç��þ‰þÒ*C¸or7m˜Éæñ??IijÉÏÐb¶ÿÕ@Å1þE"1þFßùs¡Øƒ$2Æ žzê)ÿXêâ_ÿúW0tèP³Ÿö’ˤ°ïý$ý¤Q†pßm~ûГ¹öçÄ?ù dB O"€êbü‹Dcü ôßïÅ‘ÙtÓMƒyóæùÇ PWÏ>ûl0bij¿ö’ß QÑÿAâÑÿAÚd(÷Ï–7¯S¹w×"ùN—ú_T ã_$ã_ ï&Š=("3f̘àÅ_ô�= ^~ùå`«­¶2ûm/9N�†þRƒþÒ$Cx@šsm_Ëdó ýŸ¯äÿ)cÚ¶÷×T ã_¤ã_ |»¬{@ÍØ±cƒùóçûÇ+¯½öZ_/+\>/€FAÿ©Cÿi‘¡úh³ß—Èý.ƒ�4ô€ô€Tcü ô`ü‹FóK±;uÑì³Ï>AGG‡Ì©òÞ{ï{î¹§ÙÿKäçHú?@ý µÿ!ŒÑ(ö’2çý¢ÿ$ã_ �ÿ" >îÒ%v‡59ûì³ýchh¿üå/Íq=Æö@\Ðÿú‰þ�(Œ�~bü‹$ìò¬Ø�ÕdÏ=÷ :;;ýýhhÁî»ïnŽ—ˆÌsYOõFÿú?@b0þ€ñ/’ì·bwR“¦¦¦à¹çžó÷}Îܹs»�ÿ¸‰ÈY¨7ú?ÀÑÿ�ñ/0@Œ‘Dr黃šœþùþ> äÜsÏ5ÇMD:\>(€z¡ÿTý ÖÿÂøIs³Ø�Ódûí·ï~Ì@4�eÇw4ÇODn@½Ðÿ*„þkŒ� aü‹$Ñ—Qû;eÑÌž=Ûß×ñØc�™ã§D>%€Z£ÿTý –ÿÆøI0Èåq±;¤É™gžéïãJøñ�lŽ£ˆÌqYKµBÿ¨ú?@¬0þª„ñ/âî8±;£É¦›n,[¶Ìß¿”°téÒ`øðáæxŠÈ÷P+ô€*¡ÿÄ ã_ Jÿ"ÎÖwy]ìŽhrã�7úû6€2L›6ÍOYà2DÕFÿ¨2ú?@,0þªŒñ/âêÇbwB“qãÆ+W®ô÷keÐcgß}÷5ÇUDN@µÑÿªŒþ Œ�*cü‹8ê²Pìhò�üÃß§ôÁìÙ³Íq‘E.ÃP-ô€¡ÿÔã_ Fÿ"nλó™wÜqþ¾  Ž9æs|Eä'¨ú?@ Ñÿê†ñ/PCŒ»¼-vÇ[#MMMÁüùóýý@?¼òÊ+ÝÇ”œÉ[.  Òèÿ5Fÿ¨ Æ¿@�1þE\üZìNgrÆgøû0€8ýôÓÍq½CPYô€: ÿÔã_ ÿ¢Þ†Hažq‡[#™L&xýõ×ýýÀ¼ñÆÝÇ–¼‰¾Ÿc}T ý Nèÿ5Åø¨Æ¿¨·ŠÝÙLÎ:ë,ßPgžy¦9Þ"ò}T ý Žèÿ5Ãø¨#Æ¿¨—f—gÅîhkDïPX¸p¡¿ß¨€E‹ÍÍÍæ¸+’y.ƒ0Pô€:£ÿÔã_ Îÿ¢^¾,v'3™4i’¿Ï¨ _ÿú׿¸‹È—0Pô€ ÿTã_ ÿ¢»ƒ­‘¦¦¦îyÊT�¾_C�5ÿø+’‡0Pô€ ÿTã_ ÿ¢Öö»s™Lœ8ÑßWTÁ~ðsüEdôý FèÿUÃøˆÆ¿¨¥+ÄîX&O?ý´¿Ÿ¨‚¹sçšã/"—  ¿®{^5¡ÿÔý j®Û¯6aü Ôã_ÔÊP—ebw¬5r衇úû(€*š0a‚9‹ä—aè+ú?@ Ñÿ*Žñ/CŒQ ß»S™Üÿýþþ  ŠfÍšeŽÃˆ|G}EÿÿŸ½;�—¢<ó=þpÅI® ¢‘0¼JFeÐ舃^F ¹¢8‘ÄݘA”(nш゠*.¨¸D£"H$F�^£3ÊUpø Á*(ˆ,²sØÏ©û¾}R§»ß§ª·ÓU½ý¾ŸÏÿ3ó‰oUõ)Þê~žªî*”!ê èè�2Dÿ‹8¼#zB¥¥wïÞ^SS“;?DÈs}úôQÇc@Þ@¾¨€2Dýý/P†èµƒDO&•ûï¿ß�›b0yòdu<†äÈõPƨ€¢¡ÿÊý/¢4IôDRYµj•;/ÄÀ{îñ’Û�+ê ŒQÿECÿ ”1ú_D¥Þd™è‰”–³Î:Ë�“btÚi§©ã2 _Ió1 ÈŒú¨Ô?@«Ñÿ€þQø_¢'‘Êk¯½æÎG1zõÕWÕq’@6Ô?@ þZ�þ¨ô¿ˆÂdÑ(-ݺuóÝù Föìܹ³:>r—²¡þ*õÐjô¿EöÉ'Ÿ¨}”víÚ¹‹¡èQlmMVˆž@i¹öÚkݹ Æ�§ŽÏ€ØcÚÛ€`Ô?@¡þ Fÿ.#*ô¿(¦!¢'�ÊܹsÝy Þ{ï=u|†ÄÞÚŒú¨ Ô?@Áè#À`D…þÅt·è‰“–ž={zMMMîøà5—Bò=¸¨b@ýƒb£þòFÿ.#Jô¿(† DO˜´têÔ‰Û?´Ò…^¨ö«N€¢ö˜ìܹ³šO-õO ¨PlÔ?@Þè#Â`D‰þÅð¢è “– .¸À�{ÈSÿþýÕ~õà Pj̘1j>ä9¸¨b@ýƒ(Pÿy¡ÿ�€5ú_´Æ.&[DO˜´<ÿç?»óyhhhPû45œE¡ž}îY5Ÿ²Édgø¨b@ýƒ¨Pÿ9£ÿ�€5ú_´ÆÑ¢'‹ÊúõëÝy‡<Ì™3GíÓÔp…Z·n�šO!,õO ¨ê gô¿Z¾|¹wÎ9çdÍØ±cÝE�œÐÿ¢5®=QÒrÔQG¹syº÷Þ{Õ~M '@уVs* ãà£þ‰õ¢Dýä„þ¨pô¿(ÔË¢'JZî¼óNw¾!O§Ÿ~ºÚ¯©á(Zã¶ÛnSs* öY€f/‹~ŸL õOëQÿ JÔ?@N^]'§…þ(oô¿(D;“Ñ%-öö}h�®]»ªýšN€¢5Þxã 5§²Ñ¤­¨bBýƒ(QÿYÑÿU€þ…8Hô$Qihhpçò°råJµOÝp­a�QwN…ä þ‰õ¢FýdEÿ Tú_bŒè ’–Aƒ¹s yz饗Ô~uà P´Ö!‡¢æU@F €ú'Ô?ˆõ�ý/P%葯ÇEO�´\}õÕîê ý/PEè‘�3DOŒ´Ø_. »J>:oÞ}¡QW\q…ÚǹY vQÿI%Ö?Ö{ï½çõíÛW­;®Øoë>óÌ3îËB+Pÿ�è�*Cÿ‹\½$zb¤å÷¿ÿ½;¿ ÒN€®X±ÂÜûÍo~ã͘1Ã[´h‘»*DŒþ¹úRôÄHˇ~èÎ/¨¤ K–,ñºuë¦ÖWy衇ܗ‹<Ù_5¹û5 ‹jõO‘TRýcÝpà j}¥ÎYg�ÅEà" þÑÿÆà“O>Qû5(íÚµsÍË+¯¼¢Ö”wß}×]4�½x{å•Wz=zôPëÈ'{ï½·wë­·z_}õ•» D€þ¹ø†I£è‰‘–††w~!@¥œµoÂ={öTëÊ5ö›>ö—Ãçž{®wýõ×'ž�w÷Ýw{7ß|sâÖ'�tRâ ß].ŸL�:Õ}ÙÈÃúõëÕ> È“vµ‡ú§ˆ*¥þ±n¼ñFµ®\Ó¦M›Dýsþùç{7Ýt“7iÒ$ïÎ;ïLüÿcÆŒñ†êÕ×׫årÍ\à555¹/y þúߘÄuxîܹj�A±Š3±~í—‘Ý劑Ë.»Ì[½zµ»Iý/rñ=Ñ“"-»îº«;·bÕªU‰g¹eâĉjßÅîowÙLÉ÷ÃyË–-ÞÀ�Õv³Å>§ÎÞúá�wÞI<78Ë—/OÜŠ ÐÛLÛo± pößÌݧÙ[ öPÿQ%Ô?Ö£�>ª¶›KF�åÍš5ËÛºu«»JÅþŠ×6ãö r:tPëÊû…:´õ�†þ7&q]þôÓOÕ:ƒòüóÏ»‹&Ø/ÛÛ<û·uŽ*öÖѳgÏv7�"¢ÿE6?=!Ò2hÐ w^¡ö– î¾ Ê^{íå.ZTcÇŽUÛÌûk{òvãÆ�îªòòÚk¯yx Z¦Ø[T¯[·Î]rtðÁ«}�#jõOLÊ¥þÉñÖHi±ß„þòË/ÝUålóæÍ‰ ºù6Õï¿ÿ¾»*ä�úHCÿ“¸.Û>Õ]gPžxâ wÑÄ»Î<óL56Êüáp_Š„þÙœ%zB¤åì³Ïvç P'@í7nÜíeJ¿~ý¼ùó绫)˜ýåÌÅ_¬¶“)ö–Ò(Ìé§Ÿ®ög@N¨=Ô?1)‡úgÓ¦My=ËÈ^° û¦t!.\èõïß_m',½{÷ÎéׯFý¤¡ÿ�I\€¿þúkµÎ üîw¿K[Îö™öÑ�î¸8òòË/§½ý/²¹Nô„HË 7ÜàÎ+ Ô'@í·{ì]w{a9è ƒ"»OÿøñãÕö2Å~x"×]w�Ú—¹Z öPÿĤÔõ�eoÇìn/,¶ÿðÃÝU´š}6�ýV½»½°Üÿýî*�#ê ýoL⺼víZµÎ Üu×]-ËØÛ>ŸrÊ)jL\©¯¯÷–-[–òW è‘Í£¢'DZ¦N�êÎ+ Ô'@~øaµ­°Øûó¯X±Â]EÑØœŸÿüçj»a±·¥@þr|Öáõ‡ú'&¥®ì¯oÝmeŠ}½QY³f�׳gOµÍ ´mÛ–Ç`ˆúHCÿ“¸.¯ß°^­3(7ß|sË2wÜq‡úïa±wÅÚ{ï½½8ÀÛo¿ý}¬½€ëŽË7§�vZÊ_�b ÿE6Ï‹ži™9s¦;¯P€RžÝ±cGb½î¶ÂÇÃÙíIÍ:¨m‡eéÒ¥î*�…½µ†»òœ@í¡þ‰I)ëk̘1j[a™4i’»xÑåó,bûü`ä�úHCÿ“¸.744¨uåÚk¯MŒÏöXH{q×öÎöQHöWºöÇ[Aì—šçÌ™ãMœ8ÑëÕ«—ZO.™7ož»Z´ý/²y[ô„HËܹsÝy…”ò¨}óv·–3Î8Ã]<2�<òˆÚ~X¸Iþr<Éü–@í¡þ‰I)럯¾úJm',C† ñÝUDââ‹/VÛJ—.]¼íÛ·»‹# ê ýoL⺼e˵Π\qÅÞ¦M›¼=z¨ÿ濎{î¹'qA9_¶~æ™g¼öíÛ«õfʹçžë® ­@ÿ‹l‰žiY¼x±;¯P€Rž>|¸ÚNXâ|Þ®ý°Úe—]ÔkŠý  ûö‚-Z´HíÇ€|*P{‰~?L õOq”²þ±·»r·–ùó绋GÆ~£ºM›6ê5å¿þû¿ÝÅ‘õ�f‘èz8-ô¿Å×à­[·ªu墋. }FìñÇ_”G@~ùå—^·nÝÔúÃbo/m/J£8è‘ÍFÑ"-ëׯwç Pª öÖ î6Ârâ‰'º‹GÎþ²×}aùàƒÜÅ‘ÁÚµkÕ> È:€ÚCý“RÕ?öKcö™Eîv‚ò“ŸüÄ]]m#,Ï>÷¬»xäì}ÿÝ×– &¸‹#ûAîîÃ�´¨Ô?1*Uý“ãm�yõÕWÝÅ#gŸ›ä¾Ž Øg1Ùow#wÔ?@ úßÅuØ>*È]g®¹ð #ù÷Îç\öÖÔ(ú_dÒUôDHËn»íæÎ)¨T'@Ï:ë,µ� ”òö aÏ!psÔQG¹‹"‹o±ÝE vPÿĨTõ�ýÒ˜»� Ø[UEÑüfc›´?£½×^{Í]Yä¸o©PíècTX6 «W¯ÎùWÀtG+Ðÿ"Ì÷DO„´ôë×Ï�O(P©N€î¾ûîjAùñ�ì.›Ñ£G«×û!¯`òÓ§OµÒK vPÿĨTõÏG¡¶”óÏ?ß]46¹Ö?'NtEÔ?@ýoŒâº¼cǵÎl±?þZ²d‰»ª¢:õÔSÕvƒbÏñóÔµŽþaö=Ò2`Àw>¡@¥8úù矫õ‡eÒ¤Iîâ±yüñÇÕë ËܹsÝÅ‘Á öa@¾/P;¨bTŠú§¡¡A­?,O>ù¤»xlr­† æ.Š,¨€úß•óà[n¹Å]MÑåÚãÚ,^¼Ø]¢ÿE˜=Òrøá‡»ó *Å Ðçÿügµþ°Ìš5Ë]<6ö¢®ûzÂò‡?üÁ]zè¡j¤¿@í þ‰Q)êŸwÞyG­?,_~ù¥»xl,X ^OPìs€Kq›êJFý$Ðÿƨ\/Û_ÿ®[·Î]MÑýíoSÛË믿î.ŽÑÿ"ÌÁ¢'BZ† âÎ'¨'@ï¸ãµþ°|ýõ×îâ±Éç—:7Üpƒ»828òÈ#Õ> È¿Ôꟕ¢þ™6mšZPl\JÛ¶mS¯),Ë—/wGÔ?@ýoŒÊõðÅ_ì®"öÑ�î¶Ãòì³Ïº‹£@ô¿s¸è‰�–R>¶Ú”âè9眣Ö”¶mÛº‹Æ.×gŸrÊ)î¢ÈàØc�Uû0 ‡ ÔŽÃE¿¦…ú§xJQÿ\qÅjýAéÕ«—»hìöÛo?õº‚òÆo¸‹"ê ápÑupZè‹§\/Ï™3Ç]Edzôè¡¶”ßýîwî¢(ý/ÂüHôDHˉ'žèÎ'¨'@�8âµþ tÐA³·q_WP9äwQd0|øpµr¤@í þ‰Q)êŸ#F¨õåè£�v�]®¯õ±ÇsEÔ?@ýoŒÊñ°}¤�—\ÏñßsÏ=î¢(ý/ =ÒròÉ'»ó *Å P».wýA)‡çÓN;M½® tíÚÕ]Ø[wľ@­ þ‰Q)êŸ�ªõå¿ø…»hì.¹äõº‚2aÂwQd@ý$Ðÿƨ/Û/Ç餓NR¯!(·Ýv›»( Dÿ‹0G‰ži‰û ¢š•â¨}¶�»þ üêW¿r�Ý…^¨^WPÚ´iã.Š N8áµb¿ µ‚ú'F¥¨:vì¨Ö_é;v¬ûg"ê �þ7Fåx8î/ŸqÆê5%î×UÍèæÑ!-< xâ>ÚÐРÖ–k®¹Æ]·Ôî¾ûnõºÂbÿ6dg�]wß…d'€ÚAý£¸ëŸM›6©u‡ÅnóØc�­ˆœuÖYÔ?@ úßq˜ Àq£ÿE6›DO†´lذÁ�W(@Ü'@W¯^­Ö–‰'º‹Çξ÷u…eíÚµîâ°~ýzµï²A öPÿÄ$îú§±±Q­;,·Þz«»8ªõ�†þ7&\æpÜè‘ÍbÑ"-Ün·8â>ºe˵7Î]÷¬ZXÜÅQᨀ4ô¿1á0€ãFÿ‹lþMô„H ·Ü-ŽRœµÏop×–_|Ñ]<6¯¾úªz=a™9s¦»8B û¬»8Büñ�Tû/ ¯ Ô.꟔ªþùôÓOÕ6ÂòÐC¹‹£BQÿ�ècÀ`.ljþ¹š,zb¤åÆotçòTª Û·o÷Ú·o¯¶”C9Ä] H“É·jõO J]ÿ\zé¥j;a9ï¼óÜÅ#sóÍ7«íåÐCuEê ýo ¸Ìà¸Ðÿ"_Ÿ‹ž i±·©Cá.\¨öiX>úè#wñV{ýõ×ÕvÂÒ¶m[oÉ’%î*ŠfíÚµ^Ïž=ÕvÃòüóÏ»«@3gÎTû0 Ÿ €ú'b¥®l=Ó¦Mµ­°<øàƒî*ŠÎ>ï×ÝnXžÿóŸÝÅ‚úȈþ7b\æp\葯gDO�´Üzë­î‰ñŶxñâ¼~Ó§OoÁ‚îj ¶hÑ"oÀ€j;™2þ|w5ÈÂ>ÇÙÝ�¹DÔ?+‡úÇÞZ:Ÿ;�ØŒ7®¨¯¥¡¡Á»à‚ Ôv2…oßç‡úȈþ7b\æp\葯ÃEO�´ÔÕÕyÛ¶msçòpà�ªý–Ñ£G'ÞÌ‹ÍþªÅÝV¦Øwû‹œÖœÝ´i“w÷Ýw'Öå®?Sîºë.wUÈÂþ;帟(€ÃE¿?¦…ú§õÊ¡þyï½÷Ô¶²eß}÷õ^|ñÅV݉dûöí‰Ú«cÇŽjý™rÙe—¹«BÔ?@V‡‹®�ÓBÿÛ:\æpèQˆ�M¶Šž$iùðÃÝù†<äúè§_¿~Þ#�<â}üñlj{Û ©öMÛ>#Ï>÷¶ÛnKÜÖyéÒ¥î¦2:ûì³Õ¶²¥S§NÞĉ½O?ýÔ]] {ÂÓžl½êª«¼]vÙE­/[N=õÔV�p­U9žà¶Çº=æ ÖQÿÄ \êŸG}Tm+—ØÇbÜsÏ=‰×“Ë-™ík~õÕW½±cÇz:tPëË{ëgû7#wÔ?@Vô¿ãpîý?€ Gÿ‹B½%z¢¤åÁtçòðì³Ïª}ZŒ,\¸ÐÝTFöVˆC‡UëÉ5öbððáýË/¿Ü»å–['Eo¿ývïúë¯÷Î=÷ÜÄ3~süJ`Ž<òHoãÆ�îËFxàµ?òº|Ô?+—úǺá†ÔzòIÛ¶m½Ã;Ì;묳¼ /¼Ðûõ¯í�3Æ;í´Ó·K³5’»L>éÒ¥Kâ‘Èõ�úßq˜ Àq ÿE¡n=QÒ2bÄw¾!öçùíÛ·Wûµµ)䨽À:hÐ µ®RgÈ�!\üm…ã�?^íÓ€Ü$õOÄÊ©þ±Æ�¯ÖUéܹ³÷ù矻/9 þrBÿ!.s8ô¿(ÔpÑ%-öW�[¶lqçòÐÚ_ž¥Ð öö‚gžy¦Z_©rÞyç%~�ŒÂØÏ6mÚ¨ý�aðQÿÄ œêkÚ´i¹~fÆûœd.þ†úÈýo„¸Ìà¨Ñÿ¢5v7i=YÒ2{ölwÞ!ö µÏvs÷kkÒš ö9»÷Þ{o®o‘¤¾¾>q"­3sæLµo²Ãä;ðQÿÄ Üêkîܹ‰ç »ë�;W_}uâWÒ( õ�3úßq˜ ÀQ£ÿEk½)z¤媫®rçòdO8¶æ¹nZ{Ô²¿:ùÉO~¢ÖuF�å­\¹Ò}9(Ào~óµ2K.꟔cý³mÛ6ï®»î*êëÊ5ƒöÞzë-÷%!OÔ?@^è#Â`.G�þ­5Nô„IKß¾}Ýy‡Ì›7ÏëØ±£Ú¿…¤'@}ùË_¼“N:Im£˜±¿6¾à‚ ¼ ¸›G+äøËªËà'úý2-Ô?ÅQ®õÏÚµk½;î¸ÃÛm·ÝÔvŠ�aÆyo¾ù¦ûP ê /ãD×Ëi¡ÿ- €¹5ú_´ÖÑF…g”‡}¶ß¨jÍí—è­X±Â]u«-]ºÔ»ï¾û¼Ã;Lm³�Ø_Öüô§?õf̘á­[·ÎÝZÉžw÷yH€‹ú'Få\ÿØ_¿òÊ+‰¦u×]wUÛ-$öï>|¸÷è£�&ê+õ�7ú߈p˜ ÀQ¢ÿE1Ô™¬=iÒbo•‡â±oÄüã½óÎ;Ï0`€×¥K—–“¢ö©ý¥ÌAä�p ޥ—^ê=üðÉ_ŽlܸÑ]U$6lØà½öÚk‰wû&~ì±Çz½zõJüJÆ¿e¢}½öÃ˾vûZGŽé]qÅÞc�=–øµÏöíÛÝÕ¢ˆì¯–Üã4 öضÇ8 õO ”{ýcÙ“O?ý´wýõ×{g�u–7hÐ ¯G�^‡Zj û;uêäõéÓÇ;øàƒ_x?~|âKoï¿ÿ¾×ÐÐà®EBýä�þ7"\æp”èQ,�‰ž8i±'ã”�þýû«ã4  õPa¨€‚ÐÿF€ À\Žý/ŠåÑG¥˜Ï]P¸O?ýTŸ!9^a¨€ BýŒþ7\æpTèQLL6Šž=¹ÒÒ¶m[oÆ î\PD7nôÚµk§Ž¿€Ø‡¿ÛcPê LPÿ‘¢ÿÊý/Ja´è ¦òàƒºó@=ðÀê¸ É(´õP¨€ÈÑÿe€þ¥ÐÁäkÑ“,-�:uò6oÞìÎYE°iÓ&o÷ÝwWÇ]@V™ì"€Ö¢þJŒúˆý/Pbô¿(¥ëDO4¾Dã¾ûîSÇ[HþSÅBý”õú_ „èQJ�M6‹žliÙu×]½††wîh{Lí¶Ûnêx È“.(ê D¨€XÑÿ%Bÿ‹rð[ÑNeòäÉîüÐ “&MRÇYHî@±Qÿ%@ýÄŽþ(ú_”ƒïšl=éÒÒ¡CoÆ îP€õë×{íÛ·WÇY@¶šô@±Qÿ1£þJ‚þˆý/ÊÉý¢'žÊM7ÝäÎc¸îºëÔñ’»ê FÔ?@ÉÐÿ1¢ÿE9é!Í÷w'_ZÚ´iã}ñÅî\�‡Å‹'Ž%÷ø ˆ=&» *Ô?@L¨€’¢ÿbBÿ‹rt—è ¨rúé§»ó@N>ùdu\…ä6D�úˆõPrô¿@ èQŽþÑd“èI¨òÚk¯¹s@Þxã u<…d³I7D�úˆõPè�ˆÑÿ¢œÝ(z"ª 0Àklltç6€ vìØáõïß_O!/€¸Pÿ¡þÊ ý/ú_”»]M¾=Uzè!w~Èà¾ûîSÇQH–™|Kq¡þ"Bý”ú_ "ô¿¨gŠž�*uuuÞ’%KÜ9 À—_~™8fÜã($§ nÔ?@‘Qÿe‰þ(2ú_TŠ:“9¢'¥Ê°aü¦¦&w®Ha�‘ã�?^?!ù‹4ƒ€xQÿEDý”-ú_ ˆèQi1i=9Užzê)w¾H1cÆ uÜ„Äs?@©PÿEBý”5ú_ HèQ‰¦‹ž *»ì²‹·zõjwÎ0V­Zåµoß^7!yT¥Fý´õPè�V¢ÿE¥êb²Zô$U9餓¸à°ÇÄÈ‘#Õñ’U&�PjÔ?@+Pÿƒþhú_Tº³DOÔÀL›6Í�ÿ@M›2eŠ:N2ä” ê @Ô?@E¡ÿ Dÿ‹Jׯä¿DOV•ºº:ï“O>q� &}öÙg‰cÂ=NBò²4k€ò@ý€ú¨8ô¿@èQ-z™l=iU9äoÛ¶mî±Ô”­[·z|°:>Bb�­}Pn¨€(B3hÐ ž €²cçäᇮæk–˜2fèСުU«Üch•5kÖxÆ Só-‡¼*ÍsPyìû÷"Ñïí~l�Rï.ê”�2¨Ž2i½~?c’CTú_”�2è�šcoýf¿ÝïTcÌýþûï»Ç0P�÷Þ{Ïëܹ³šg9äe“o  RMýÞîgƒI¯äТ¢þAÉ•Qýs»èmøÙb²_r(€ Cÿ‹’+£þ¨9;™Ì}peL›6m¼iÓ¦¹Ç2�ûl wnå˜é&ß@¥.ú½=5?K�õJ¦Ì껾÷EoËÏ_Mvn  ÒÐÿ¢dʬÿj’}¾×8ÑYÖœyæ™ÞºuëÜãÈhÆ Þ¨Q£Ô|Ê1·™Ô  Ru7Y-úýÝÏÉ¡‘¢þA¬Ê¸þék²Yô6S·  rÑÿ"VeÜÿ5ë “í¢¸ŒéÑ£‡7gÎ÷Ù¹Ò­[75�rÈ“ÿ@%³üË¢ßãý,6ùNËèxPÿ rPÿüJô¶ýØçMP¡è¹ è�šõ#“u¢¾¬¹êª«¼mÛ¶¹Ç;�°cÇïöÛoWó&ÇØgAþ›*Ýå¢ßãý4š‘+êD¢‚êû‹€çD¿?ËM:·ŒP©è‰ ê�šÖßd‘èƒ0k=ôPoþüùî±�÷ñÇ{‡rˆš/9f¡Éþ¨tÿl²Môû¼ŸñÉ¡%Aýƒ¢ªÀú§«ÉJѯÅ�½@l/¨lô¿(ª ì�šö?Lþ,ú`Ì)öÛ@›6mrßPc¶nÝšøÖO›6mÔÉ1/™üƒ*Ý.&ý>ïç/&í[F—õZ­Â럋~=©ùEr(€ Fÿ‹V«ðþ¨iõÒüpx{KF÷ÀÌšÞ½{{o¾ù¦ûž€ñÎ;ïxûí·Ÿš9Æ>kîzižƒ€Êw¿è÷z? &ÿ”ZrÔ?(X•Ô?÷‰~m~ÊíxP8ú_¬Jú_ æ “Ÿ `sÉ%—xk×®ußP¥V¯^í�;V̓<²ÖäXT‹l¿(<;9´¬Pÿ gUVÿTÊ/öý/rVeý/ã{&o‹>XsJûöí½‡zÈÛ¾}»û~�*±mÛ6ïÁôÚµk§þýóÈ›&û ZØgŠ~-úýÞO¹?S”úUqýcŸÙ½Uôkõc¿©  zÐÿ"£*îm¥ù–;D¸9¥oß¾Þ /¼à¾w Â½ñÆÞ þ½óˆ½åÃm¯I šØ »ö¯ûžïg¹Iç–Ñå‹ú�j þùµè×ìÇÞ"îˆäPU€þ�j ÿðwƒL¾}çœ#Fx}ô‘û>‚ 3oÞ<ï„NPÿ¾yæs“ÃPm~%ú=ß�-ü‡&‡Vê$ÔPýSgò²è×îg‰ÉwZF¨ô¿H¨¡þ@Šo›<.ú`Î+§Ÿ~º·`Á÷}eÎþ›�zê©êß³€L5é(€jÓ×d³è÷}?ö[Ÿ•ˆú§†Õhý³‡ÉjуŸ'’CTúßV£ý/Ç1&‹EØyeäÈ‘|T€… z矾ú÷+ _™/€jô “÷E¿÷ûù«ÉÎ-£+õO ¡þ‘á¢ÿ–Ôü,9@•¡ÿ­!ô¿\»Ió/yì³ÀÜ=¯Ø‚÷Þ{Ï}ßA‰Ù[=é�ßÞòóaáv�PÍnýþïg‹É~É¡�ú§ÊQÿ¤™"úïò³Á¤Wr(€*Cÿ[åèdóC“y¢ú¼sØa‡yO=õ”רØè¾!&vßÏœ9Ó;þøãÕ¿O�Y`r„ªÙi.öÝÏ?c’C«õO¡þ eOø,ý÷ùyˤ]ËhÕˆþ·ŠÐÿÈ×N&—›lý�wúõëçM™2Å[¿a½ûþ„ˆ¬_¿Þ{ôÑG½¾}ûª�cr©4ßP½:Ió-~ÜÏ?/˜´i]]¨*õON1Ù!úoõser(€*Eÿ[áè´VW“ÉR„ÛBØ´iÓÆ;ûì³½7ÞxÃkjjrß³Pö6—^z©WWW§ö�±¿›nÒCµà)ÑŸ~V›tO­ZÔ?†ú'o׈þ›ýl7˜  ŠÑÿVú_Å6Àä Ñogß}÷õxàoùòåî{ò´lÙ2ï¾ûîK|ÓÊÝÏ­Ìk&  Vœ#ú³ 5ÓCkõO£þi•¶&¯‹þÛý|fò­–Ѫýo£ÿ5{»Ç“L>ýFѪ 2Ä{ä‘G¼•+Wºïm±bÅ ïá‡ö~ô£©ýY„üÍäD©Þ[|´>& ¢?üÜ›ZS¨ÊõOQ}×d½èýàçÁäP5€þ·ŒÐÿ(û‹�S¤ù—îG«sÌ1Ç$ÞØ>ÿüs÷=¯æÙ}b÷�ÝGî~+R>5ù¹I½jI;“·D.øùÄdזѵ‰ú§D¨"u†èý‘šÉ¡jýo‰Ðÿ(ödñϤùMÃ}#)JöÙgŸÄýì_xáoãÆ�îûaÕÛ¼ysây 7Ýt“·ÿþû«ýSÄ|aržð€w¨U7ˆþlðcŸjo…fÔ?£þ‰Ýã¢÷�Ÿ5Âs €ZEÿ1ú_åξiØ_ÌýÆR´ØÈ}ôщ7Ù3gzëÖ­sß/+žý›ìßfÿFû·Ú¿ÙÝEÎ_MN7i/€Zu¨ÉÑŸ~.KE êŸ"¡þ)¹oKóÉw?ùyEøv8PËè‹„þ@¥²÷‹ÿ7“—E¿ÑD’þýû{_|±7mÚ4ïÝwßõ6lØà¾§–­õÖ{ï¼óŽ7uêT.Šú>n^2*Üãj]G“ÏENøyU¸ð“ õO¨ÊÖ`“FÑûÌÏEÉ¡jýoèT«L¦šlýæiöÚk/oäÈ‘ÞÍ7ßì͘1Ã{óÍ7½%K–xÛ·ow߃#g·i·m_ÃO<áM˜0Á;ù䓽ž={ª×Cì¿Åc&ÿ,4³ŸÕîç…Ÿµ&=[F"Ô?õO»Yôþó³ÍäÀäP5Žþ×£ÿPÛþÁäB“¿‰~CŠ=öÃÁ>@ý¿ø…wÅWx'NLÔ?ù³·u{Oô¾ôco÷Ö¡e4Ðÿ–C蔜ýæÉd“�¢ß¤Hq³Ådº4ßÎ�Û<\{™¬ýùáç‘äP´õO|¡þi½ï›l½oýÜ• iüþw»è^‚7ô¿Ê’}Þà)&ÏIóíäÜ7/RXì-þdò3“Ý€`mM^ý9âç “o·ŒF±PÿDêŸâ;Wô~Nͱɡ�æç¢{RœÐÿ¨(ö³}ÃzF8ZHv˜Ì29Ošo·@6ãDžøi49܈ÈPÿ´.Ô?Ѳß·sÓÝï~V˜ücËhh–íncöœƒû¿‘Ì¡ÿPv—æç ÚÛN.ýfGš³Ìäw&#Lþ‡�»%óÇÿLEL¨r õO¼ì‰…¯Dÿ;øù³p›1I¹Üml¡ÿÍ%ô¿ªZ�É&—Kó7\ìí Ü7ÂZ‰½Ÿÿ+&—™ü@8Ù(Ì7Mæ‹þœñó¶I»–Ñ(êŸd¨Jï(“&Ñÿ6~ì­¢À'ºgðt·1úßdèÔ4{BÚ>DÞÞêÀ>Ü|¥è7ÊjÉz“¥ùCÓ>Ä}g õì·GÝÏ?M¾—Š2AýƒR»Kô¿•{’â&‡¨QÙî6vurh(ú_@‚ýÌ÷MFšÜ(Í·¡[*úÍ´Üc_³}íöo°Ï´ßðÛÿý”šÓ’CQƨ·�L>ýoèçÿ�P›¢ºÛý/ �½'þ&£Ln6ùƒÉ;&kD¿ùÆ•ÕÒüìk±¯é?¤ù5rÿ~@º™¬ýùäÇ~>¡²Qÿ Jý%óíØì¿/€Úô�èÁ�½ÛXï–‘ÅAÿ P:šì/ÍÏ3;Yšo'ñŸ&“L¦Iómþ[š¿•dóW“O�ØÿÍÿïv¬]Æ.k×a×5Úä§Ò¼ »-»MJÅ>SÇ~^¹Í‰Ÿ/…†¤Úå[ÿ|"Í·£²·÷m0Y!Ô?¹Hôû‡ûœà£“CÔˆŸˆîRszrh,òí9ÿ "],ºóÓ(ÍÏš|û˜l=W®L„šÄ—I¤Êv·±'“CÅòÉ|ÛÖ É¡@Â9¢ç‰Í»©ƒP³8ÁÀâ ¢PL>Ý„ùùÀd§–Ñ@³1¢çŠ €á+·[¼ˆ_¦»�ñˆˆÈÝ¢›0?›MöMZ\(z¾ØÌI„š÷�è9âg£Iï–‘ªMánc;ûM[û�[· ó3*9Hs‰èùbózê Ô¼ošÌ=Oü¼mÒ®e4€jaï$fï(æö~>üû@ýƒÉ2ÑM˜ŸçMÚ´ŒÒ].zÎØ¼š:04Ù&z®ø¹:9@•Èv·±ÿ™ ({a÷O¢›0?+Lº´Œ´q¢ç�ÍË)cß8ÑsÅO£Éáþ@�»�@ ŒÝ€ù±MÚ1É¡@ û«MwîØ¼˜:ø»¶&³EÏ?‹M¾Ý2@¥âncPß7Ù$º ó3)95^ôÜñ›y È^&ëDÏ?Ó“CT {a÷ѵ¾î6ø†É{¢›0?óL:´ŒÂÝ$zþØØ[‹a~.zΤæß“CT˜sE×ø©969P,·ˆnÀül3909È(l.ý1u`ªèyãg­IÏ–‘*E¶»�Ý• (–#¥ùù¾næglr(�Õí¢ç�Í©ƒ€M‰ž;~f™Ôûƒ”=î6%ðm“/D7a~þŸpÁù±ßÞvç‘Í´ÔA@ˆCMvˆž?~~�  ÌÝ,º¦÷ÃÝÆ "ÓE7a~Ö˜ôHrr¯è¹dóhê ƒëEÏ?ÛM$‡(SƒME×ô~.JË™¢°Ôœ˜ äì·¢ç’ÍïR´3ySôòó‰É®-£”î6%ð]“õ¢›0?ö"Pˆ‡EÏ'›RYìc²Aô<òs_r(€2ó¸èÞw€´5yCtæçS“oµŒò3Eôœ²™œ:ÈÁ/DÏ£Ôœ�  Lœ!ºvO͈äP@±\+ºócŸ¯909È[Ø7½'¥rô”è¹ägµI÷äP%–ínc&‡Šå‡&;D7a~~“ d†èyes[ê G»›,=Ÿü¼`R×2@©p·1(�ÝLŠnÂüÌ6©o &ì›·¤òð#“&ÑsÊϯ’C”È5¢ku?Üm "ölV› &½’C�‚=+z~Ùܘ:È“ý¹;§ül1Ù/9@Ì‘Ìw»29P,'‹nÀR329h•çEÏ/›ëRyú†Éû¢ç•Ÿ¿šìÜ2@\²Ýmì-“v-£EÑÝdµè&ÌÏÉ¡@«ý—è9f󟩃€ô5Ù$znù¹=9@L¸ÛĬÎäeÑM˜ŸÅ&ßi ´ÞLÑóÌæÊ”1@¡Æˆž[~ìs‚ÿ-9@Ć‹®ËSó³äP@±\.ºóÓhrDr(P³DÏ5›_§ ÔFŸ3m³Ü¤sËhQÙC¸ÛÄîŸM¶‰nÂüŒOŠæ ÑsÍæâÔA@+Ø ¼öB¯;Çü<'ÍŠD#ÛÝÆ–w€¢ÛÅäcÑM˜Ÿ¿˜´o ÏÛ¢ç›Í…©ƒ€Vú±è9–š_$‡(2{‡'·÷ÃÝÆ "÷‹nÂü4˜üSr(PTs6ç§Šà>ÑóÌÏf“~É¡ŠÄÞml«èÜÏõɡġçô.uÝój-ÒsÆ¿¸»PÕ²ý2îìäP è>=çlF¥ŠÀÞéào¢çšŸw„;Å”­�õncuÝûØ=^Ù™ºÖý¹à0 úu5ùZtæ‡gc"j‰žw6Ü’Qà×@|Êêncu{L]îž ¯ìp Ã`@u³ví^· ó³Ü¤sËh  DÏ=›3REÄóÇ€è•ÝÝÆêº?¶Å=^Ùá0† À€êv�èÌO“ÉÐäP 2 EÏ?›SRÆÅTgò’è9çg‰ÉwZFÈWùÝml�×vvσW~¸ …á0 zõ3Ù,º ó319ˆÔbÑóÏfdê  Èö0Y-zÞù™‘ åy·±=ŸÿG÷Ïqb ¨nß49Ôd´4ßšç“9Ò|b{�4ÿÏÞÓþÚiƒÉ “�L^6yØäb“ÿmòmA%écÒ º ó3995¨Ô€EoËæˆÔA@ ¾+á·$·y¨e$*Åþ&£Lî2ùÒü~·\š?m½cÿï×Òü<:{bòN“ÓMzÛ…�“§D×Î~V›tO-�ºÓnÐçÃCó¡»<åãûÓÛ›«¹`�©ßsúPwªÂ÷¥ùù…oHø…–|c×ó“ߘì-(gíLÞýoèÇž ßµe4jQ)/׉ގŸÃRÆq±ÿܹ˜šÉ¡(Ûƒ¸û9(?ôÈÂ^¼½Ãä ÑëÈ' M®1ÙSæÑuTjÊæncöΗîùð°ÔwŸ:Ë]€²Q·ÇÔëݯÐô˜úˆ»<€ŠfoeùS“·EßQäÿJî'f¯Eÿ{ù±¿ô�ŠUÊ ÀíEoÇÏÁ)ã€8=.z>ú±wË葊"øÑû9(Ù¾¬j/üþ^Š÷e7?ö³ò“¤ÚGšïçÖO~ÊêQƒuݧNWçÄCÒf�Çþä.@yØsú@ó¡¶Ãýð ÌS—ËÓ¿ã®@Åú7“¢ ï82ä« \Ø_PÚ[\ºÿN~.ME +åࢷãç ”q@œìc2ýzôžµU<}Eïã œè/à°Ï“³·y¶�±p—)fì­£�Xw·±6Ý{A�ÍÔ)îò”^×·;Ôõ˜6_p§¾Çã'¸«P‘laý¨è¢;îØç» ”ZG“ÏEÿûøyU¸€�f¥¼lß·Üíøùç”q@ÜþU2ÿ’ôâäP´Ò^¢÷oPÎðHaï"ð„è±QÅ~©ê4À ¢k%?ey·±úîSßrÏ‹gÈ$wyJ®n�)·|h¦ÍSŸq—P‘º™|(ºè.UìIó3¥4Mô¿‹ŸµÂ3 ‘TÊ Àö—–îvüì—2(… ¢ç¥Ÿm¯ԋåEïß œï/ðw;™<÷÷ÿgšLŽ€Úu¨d¾ÛØeÉ¡å#ŸLÕuŸz­»<¥µçï6R�úC+ =[/Ýfìá®@ű'NŠ.¸K{‚t¤ Ný“’C�’^î$z;~ìma�Rú†É»¢ç¦û¸…o¶ŒF¡v½oƒr¹¿€4ßöù©€1qe½ð,hP›*öncuÝ[¡Î�‡e�icÝå(�}žûFÝ�}¤>°B3õ?ÜU¨8ö×/]pçû+¦�¥ùÙ-/™üÉä¿¥y½ŸIó­{ÜerÍá6®qÛÛd�è ?'‡ ¥¼œé—½SÆ¥ò}ÉülÙ»“CQ {ÑݯA¹Î_À¸2࿇e³4QÎÞ)å#i>ai/àºãòÍ“P{¦Š®‹üØ»�õlYfêºOݪÏ�§¾ÇãÜÕP>ì­)Ü«°Ôï1u¶ù\¶ßœPÙ&Š.¸³eƒÉt“Ÿšô2i+™í"Í~~cò…èõe‹ý…”ý¢gÿ-_ýoàÇþûÙ[î©Jy¸»èíøùnÊ8 ”F‰žŸ©969Èô,ðÔÜò÷ñGIó]FÜÿîÇÖ9¿3!Í¿Ò ëyì7¹ÚäSÑëÉ%|É Ô’SD×C©ù÷–‘å¦ëÛÜóã™RßãñÜUP=§ïo>œ¶»VÁ™ºMz<Ám�Ê×_2Ÿu³ÕäfiÝ@{�Ñ^8^.zý™òk»0"w•è}ïÇ>—ù_“C�¥¼¼—èíøÙ3ePJöâ3¢ç¨Ÿ•ÒükvfgÑû4(“¤ùKia_F³¿8±Ï›+ä¶Üu&'›¬½ÞL™b¨¶wËt·±G’CËP÷§ºêsäáiÛý÷?rW@ü¹m]÷)q?¨Â35õöi*×ÿ]p‡åÿ³w`’TåþÇßéYâ’”ÌNÏÀ*¸*A½ƒŠxå"ù "Â.HFQ‘¿Š ’䪈 (A$Lϰ"9ˆ»( $H�$9Ã`Yv·ÿç�šš*ÎÛq¦ºº«ûûyžßs½ì©ªžî>ÕUuªÞ£¥û‡—J†Îþw±Û)½(;– ²¨Ý'¤r¹îFM�wiæð:b·F÷3@«Ð§EŸû= s­”Ò•étþûY*gIp#›ÿß5×»L’ñÓ ›O‹]¹Ì•`P �e¿ÚXïÐúöyùHÏЦþ*H]®gàÛþ�Tù“ž[ô.{Ù¦scúÜå¢O *X,QZ²ñŸb·W.‡‹¡tpýa±ïy˜Û]m ¼[3€? v;aV‹µZAµÒÇDMQ‡Åž—¥r¿”¾Ñé —nIÎTMñ·S.»‹´­cÄ…ÑjcýaÖÕwÑ'ìuòò‘IƒïóW@ºÖº¤/—/Ìõ¤Ê…ò@Û8IìAw¹ürd™FÐ2Ô Än³Tô‰a4†Îuè¿ßaæ¸pâ‚Jš9QÓÖ—Ë|×\//ŸçýåH�û!úi‰§2)Ü#ýÓõ €ö ƒ"ëº|R‚ò¿¸ãrºË%.7¹<â2Ëeó`‘†«õ)™kÂ0nßûþ†Yèòé¨)2Dí¿.AYÿs­5:ø¤¥¸t€ª–2ÊÍþ±Û “‰RbèXÕnÂyV˜ÇºVõëÓ&iÜà¤OzûÛ.—­G–h:}W¥jczr¦äò…“í5ó2éÐi×h‚ü´)îÇèóãT2…EÒ;U/®@#ýHì A©<.€qùˆT.?zrÔ1Q‚9æŠý<Ç“Ùa–D´t¸ï!±íJ¥¤}’¶» û dÉïÄ~‡Ãh¹â÷GMQB=À:½EšSÛÜ.ö5”Êÿ ȰjÕÆþ/jš-]ù�ëK\;/“ ¿<�µæô%Ýпí�R™ôôŽ-Hƒ>Á蟔‹–ÂØè@»Þqë¿§aŠš"¾/ö3L#?‘w«u¾áþ‘öIÚVìv4:hd…–p¯t#…Îɽøhkøê¾zd™´\%ö5”ʱáÕÖÕÆºó·›kçå£7Þ�÷ãóÝ?H¥Ó;ø°¬?Ä…&iÙDìÉA¹¤ùäN;YÅå±ïg-QÄœ©Ùq°ØÏ0Í"‘»Kü{©ô�´OÒöb·£y#ÞÈ€�]æ‹ý.‡ùqÔžz€OY&-—ˆ} ¥’öëHÚéb�qÂd¾ÚX._xÄ\?/—žÂ‰þò4NÏå“ÜÐóƒT&ÝùA½  iÑ ßþ B¹0\?ؽBì{æ%—ÕF[£Õm.•Š*EËEÿÃåB—“$xŠø<Õ{¾Ë-.o–XÎÏ;.[H Ö§ý#í“TnþÔWã�€Œø�Øïr˜…Ò˜>ÔêÞsd™´ ˆ} ¥ÂEB�eÕª�é Ì™–Ë^ö¯Ÿ—MïÀQþò%}ôŽÅ$É&Ý=ûæz.üEW~ð2·�;Ü:žuÿ÷u÷ç¹,Ì×ô_Êõ <ÑÝ;p‹–˜Îõžä–û²L|Ÿ{‹¹™:™ûaø�ù1*“®|aº¿<4À�u˜Ø÷1Œž¤m5E‹ÓR±OŠý«EKÈîåò©N+€léò[ æËô׿ —¥$4öÿ­Tú%yÿ-v;šã�€ŒÐ)fˆý>‡yZjëÃ�¦žà­F–I˹b_C©0 ²ª#ª�åò…ùþ5ôò<À_~Ô:C+»uÔÕ;p¥k;Û.;¦¼æÖ94< Ü{ç ÐQz†6Èw ù?%RX$}…û«€c¸qÖ—ÊOtþ*jŠ 8YìgX)ú$ìxžx[Éå—<}è¯[óC—Kü÷Ré—ä}Iìv4ÏÆ²–%Ìýït˜‹¢¦Qϰþ&¦é\±¯¡TYÔÕÆÖŸ¾Œ½†^>Ýù©»û«˜Ð7ð©à ß�wüöÉFª Êš—|Ì €6ä~\®²?åR8×_bôà~M—m\ö• |ìi.—Ë]¦»üÝåN—{\îwyÈå1—§\ž“à`¦OêÀ¤–²­T*ÈÀµ[B*ÏÏú€ËÒ£­Ñêú¤¾ÒÏ»¬=¼äø}Úåe±Ûйœþ]â¿—J¿$ï+b·£Ñ§¤�¬*÷½³GÔRßðšÁ"©9Wìk(€@*ö¸&žö¨66é’{ ½|º{µ$ö° ½S?3RÖÙ´ktº{ÿ"=oÿSmdBßÔ­ü�…Ì“ÉC½þ:t´å]vvù™»sÅЧ€k÷s±ï_HÜ$jŠ Ð›-üϱ\ô ؤÓ§HPZÙßV­é—ä}Uìv4:( dY¥¹c_—ô2[Y=À«�,“–sž†RadMµjc¿Žšf\~Ú”×ÑËFú†>.k_šyâ×ü{ú) ÊÚÓ´T7 }»ºó·Û�~ÙüÔ_€Ž¤szþ�ËuRßÓ†i…àÚl-•Ÿ¬þfÔ ¥˜ç‰ýKE?÷O‹%ns—wÄn³–ôKòþWìv4úô3�e+H0϶ÿÝs“KwظÃÕ3œv Âsž†RadIgUëúd‰ëèRøQnx^^ÿ¿71=…»û†¶õÿ4@Fu÷îjvöåó†Lš¶¢¿EŸö=^‚òÌþÁ{+…àêt°PËmûï]�³•�ƒl9DìçX.¿Y¦QŽ»ÍZÒ/É;@ìv4zÁÈ:½áb�Øïw˜ïFM;ÀéÒ*qþñL˜¶«6ÖÝ;ø_%®¥g4…ù sŠ9·S¿Ïîäˤwêü5è(úÝ«bÜ[1 W7$ö} óš$_�w‡ØÏ²Tôéܾ‘eeI©üdb¹ôKòÊ Œßod˜ úßï0Úß?5íX ¤G«�-{<æ[QÓöÐÝ3ðes-=Ûù½|ôŽÅü¿�ÝùÁÝJìÜËå5™|½>ù ó,çòG±ì­€+ÛOì{Ï£¦È°÷?ÇrùÃÈ2�VnàµRúuÁ„}]ìv4ÿˆ72L/Ìü]ìw<Ì£.ËŽ¶îL ¤ã=.O‰=– Ó–ÕÆr}ƒ—¸žžñ†ÜG–óÿV@ËÓ§ Ø{™ðô/ЩVu¹Kì{«‡àòÖv™%ö= svÔRnžÛRÙ~d™FÓùœ*}×J¥_L˜Þ]îoGs[¼�që¸Ìû=sVÔ´#1 �ŽŽ¬6–˾g®§'›yn�tõnèÊþ!×;p�ûo]ùÂ¥Ýù�îßþí2¿ÄrãÍþß hqÝù©»—Ø¡—NïÀ,Yó+øëÐö–‘dõÎÏk$Xü±Oîã²»Ë\>çòi—-\>ˆ.뻬벦Ë$ Jùë.€KÓ÷åV±ïW˜Ç„§Ä²ê"±Ÿg©¼%ÁÀlZÎû*¥x©d%v;š¿Åm`±ßóxþ;jÚqh¼j7&·mµ±\~àÿÌ5õñå�\¾0ØÝsá>’Ÿ6Ejywý¡Å%_Ø8×[8´«çÂ?%6 Ü;p¸¿)@Ë*ær=š�yÙ\ø3 :‚–‰õÖkÉs.¿tùO—$KÇëÀ°¿­ra¸´ãžWatžH€G6i¿ó?ÓR¹6\ %ú´±ÿ*¥x©d}Oìv4�7ÚÄ¥b¿ëafºä£¦…`€ÆªVm율iûÉå gÙkêõGŸæíóð`îx­5°j®gðh÷Ú^ö·S_ ó%É&þê-HD쎼lÞ‘ÉCmYš@E_{°^-O¸ì)Á\„�Àðø|Òe�Ø÷*Ì÷£¦È¬ð?Ïr9&X$5zH¥ï�ŸþᥒuŒØíh¦ÇÚíb%©|CÈõ.ÕŸh? 4N-ÕÆ–m݆ºz/.q]½ætç 7IßPcnÊ_çªår½ƒ'éu~»µ§ð¨[Ïþª-¦;?p«Ý‰—Í€¿<€¶§åa_{À^)§»LÔ…ˆà±ÓA¸'ľOanré#s>/ö3-—mG–IÓ�b_G¹ô‹$êGb·£Ñ�0 }Öe‘Øï|˜oDM;À�sœØc—0Qm,(¹l®«×�«Ý}ƒ{øëkˆ5/ù˜ÛÞãö5Ôš‚V×´¬üÔ-ìλ|¤oð#þ*´=½0ì°WÊ7ƒÅŽà±»Pì{f¶Ë:QSd�öAÿs-—µF–I“–úò_G¹ô‹$ê±ÛÑ\o´™_ˆý·™ç²QÔ´#0 ÐÕª�5m_¹ü…wú×Õ«¥+?x�¬9”î±çÚÓVéÎnó_Km)Ì•I—ôø«´ˆ®ž�+ìλtºzþì/ #üKì{¹œ2²Lô¤Âß~¹0ù²Ø÷'-Û�l;MìçZ*oIsJ¿.öµ”K°H¢N»ÍåñF@›Ñòl÷ˆýÞ‡¹ße©ÑÖí�`€äiµ±ÇÅ·„¹Y:äúL._xÌ¿¶^9…ãÝ[ÔŒóó°$ô?ìkª)?õWh½Cë»—E%vÜ%ÓÝ;u;ÚÞGŰ—Ë£\`NËľ†r鈌ä]fŠ}Â\5E†é“¬þg[*… ¤¬ž¾Û,’¨Ÿ‹ÝŽæÒx#  }ÐåM±ßý0§FMÛÀ´­fr°7ÃÞ(Áõ‰]æJð4ªþßW$¸éí:—_¹ìãò~]‰©Vmlݨi›ëÚTz‡>YSz†6ðOÝÚÓVÉå ÏøãUÓ;0K&_¯ÿ€V’ë)üÆì´ËçiÙµÈ|�@ç9RìA{¹¤3GId±¯¡\žôœ!ö½ ó´Ë{ÃÆÈ4½¨á¾¥2c¤}Ú6ûZÊ¥?X$Q¿» 7@ |]ìw?ŒÎü_QÓ¶Æ0éÛVìïY©hµ¯Zèà­Û?%võäq—»ô ÆC¯ ùïm<_‰š¢%å ›çò…%ƪ¤p�¿*@3ii‡üÀl»Ã.“Þ�cýUè{Ð^*³$ýÒ‘¿û:Ê…`‘ï‹}_Â,tÙ*jŠŒÓ;ÝýϸT á)[Yìk)—þ`‘D•+‘=o´©.—+Å~ÿÃèþ#íÏf`€ô}LìïY©è@q%:ð{‘ç±þ²ãÉ;.g¸¬"¨ÕÆÚD.?ðk3.P%Ýù}êÐ*r}ƒû;ë Y(k]Òç¯@Gбþ�{©è@qÚîû:Ê¥Ó€µ”÷|±ïK˜ã£¦h•>ëxNHY=/ýÁ"‰*wóÈñF@[Õå±} Œ–‘×�âvVÏ~ˆ`’¡ÓQø¿g¥òÅp�Ÿh™çJSZ$½!îs‚Ziµ±ébßÇ0TË’ž¡÷æòo”¨”…²æPÚÇÌ€rÜŽù¾;ë’éÊèE �GO®Þ{ð^*Ç‹¤Fç�ñ_C¥tòðD©<�§Ë⣭‘uˉýŒË夑ešA«ø¯§TúGÚ'é±ÛÑœk´»íÅö�xˆš¶%€HßZbÏJeßp�=gÕ§Hý¶�ŠÎüUA-¾'öý £Oi*jŠ,ÈåþϨ–îüÔÝýõš!?u '])Ý}ƒ;ù«Ð–{ð^.{�,“–ˆ} •ÒÉÀg‹}?ÂÌuù@Ôm` ±Ÿs¹hYðf©µLuÿHû$�'v;š³â�€PîixÍ[.DMÛÀ¤ou±¿g¥rx¸Àˆ%]®ù·4³H‚›æP^µjc'DM‘ùÁµýñ�ªé<Õ_   ÜNù÷f']>/IÿôN8:™–ˆôÞËeÇ‘eÒ s ?/ö5TJ§îÇôsñß‹xö‹š¢Mô‰ýœËå;#Ë4ƒ–ó_O©ô�´OÒ€Øíh~ot­ñ/±}!Ì?¤}+D0 @úVû{V*úDiH+“é”S~›´¢•‹z¥T;–¤ÚX†åzî*1NP6ÝùÂmþ:i[õÚ‰n§<ÛßIWÈþ*t ½àéÀ—ËF–IÃ×Ån¿Z:qx’Ë+bß‹0—FMÑFê)�~ÄÈ2Íðo±¯§TúGÚ'iHìv4¿Ž7:ÄG¤òtÍ,ßH �¾Äþž•ÊñáR_õ/­`ò¸Ë}.º<)µO=S)”¢”ü÷* ÕÆ2.×;ð“ãRxÝ_ eݽ…=íº|&ôMÝÊ_€Ž±¼ØƒørÙ;X¤áV‘ʃšåÒiÀ9—?‰}Âhù]}Âíg}±Ÿw¹ø¥ÕÒô„Ø×S*ýAóD]"v;š_Ääÿ‰íaÚuÞ6€Hß²bÏJåg#í?'AfÿßÃÌvù½Ën<¥«O —²²ç?ryLìzj‰–:Fd±ïQ<ûGM‘EÝ}…ýq‚j‘Þ+Þã¯�¢®üÀõþιB^p¿Ù:ˆ 3iÿ¯4—K±º@ìvkMŸtý îû÷‡¹_‚»®Ñ¾òb?÷rùþÈ2Í0Oìë)•þ‘öI*·_‹Ï5t¢]Äö‹xöm™} �¾ÅÅþž•Šž·¾S⿟áÒ-ÉÙÀåm±Û)—]ƒÅ:Úbß—0ZmlÛ¨)².—˜ã�TJwßTªÍ@³tå ÓýsÙô^Ê?9NìA}¹0²LÒv–ÒO´N“ÚçïÙ\:ïÄþíatÀmè)ڔΓíöåròÈ2i«µô›¦?X$Q7ˆÝŽæ‡ñF@‡:_lß3Çeݨi¦1 @úû{VkΑƔÖ›@ým•K§WJü ”¾6攨)Ú�Ž˜1ƒ éîÒJ©{ßå+åò…þ޹lz çù«Ð‘¶{P_.Z>i¥`±DèM(ßvY(v[³%(ûô§ÿV*�œn%ÛHpÇ­ÿ·‡9,jŠ6Vë]õšßŒ,“6�3Ê-åÒ,’¨b·£ùA¬ ЩtÊ„ÇÅö�0·Ipñ6ë }c¾udÙFÐhk­Nt÷È2�H«�Ý#ö= Cµ±6T÷p~`€t÷ ìëï”+¥»op:’^ }EìÁ}¹è“uK /9>ë¸üEìúÃ2Ò®Ò“JñFÚ·«•]žûw‡¹Ns·4Z“>¥çJe(\ e›Š}-åÒ,’¨›ÄnGóÝx# ƒý‡Tžvá˜Ñ–ÙÅ0é«ç÷7Œ>qº¶.Ü@—ŠÝn©h¹è$KPgÉ/ža¨6Ö¦r½³ü1ƒJ™�¿è³þ:)èê¸Âß)—Oa‘¬=Mï€õS±ø•òw—žá%ë7Ùå·.óÅ®7ÌŸ%Ì<¶Ä¿—Ê\—F–i7ú^\&öoó²Ëê£­Ñ žû=(½É¢ªÍ3O°H¢ô ;š#ã�€÷#±}$ŒVæØ2jšIõ\€f€dÔóûF§¥j´ýÅn·\ôfõNS­ÚØáQS´�b®®j¢.Ò;ôI-€F[çª%Ü{®¿S.Ÿ ïôW £õIí%‘¼érªË©üä©þ›Î'x�Ët©üÄ‘æEy÷`æçK´)—³G–i7Šý[ãÙ5jŠ¡%ZýïA©<.�²o‰}-åÒ,’¨;ÄnGsD¼ÐáôíßÄö“0ÿvYn´uöÔsš`’QÏï¯F+­8¼dc}Xì¶Ëeë‘e:ÕÆ:Uß5«Û1ƒÊ‘ü4½HÓ„Þ§ýråŽ÷× ã�,ö@¿Ö¼$ÁI�–a>Ãå÷”XÒ Ë:—¯ß¾\tZËRƽW*߉êGKÞ®?¼ä»éœ¤Y´žO7ûg˜fÍñŠæÒï¹ÿ](•f•0;Oìk)—þ`‘DéÜ]þv4Ì“ ¼›Vå˜%¶¯„9w´eöÔsš`’QÏï¯æÌ`±†Óùm«ÝŒ¦Ó¦Ìû£Ø÷ ŒVËêµTÓ3´©3¨Yçª,ß Ù”ë<Éß!WÊ„¾�OùëÐñ–ryHìZyÇe')íF±í«å—{%ø›^ùoY³˜T~Òó—eG[£“œ$öûP.ú~ÚÊ À–J°H¢î»ÍÁñF†í#¶¯Ä³{Ô4Sê¹Í0ɨç÷W³U°X*j�F§“Ê öï�‡jcm¬»çÂ}ü1ƒÊ)Ìô×H�ÛßmwÊe³PÞJÑ'gÃÁÒ4£Oþî,åí)v™±$k*=•­æ›EMÑaöû�(—íG–IËòRûÝõšþᥒõ Øíh¾o`Ô Øþæ5 ¦ŠÈšz.@3 @2êùýÕ*$Ú>-·‹} ¥òÿÂÚ\µjc¿�š¢åò¿.1nP!L) é›4mÅ\¾°Èî”ËæŸþ* æ“’î ðsbË>û–pyXì²õ&K¶�ʃh߉š¢m*ö;Q.iOûð_b_C¥ô/•,}:Þߎfßx#£VpyRlŸ óiN9ùñ¨ç4À$£žßß«G–IËUb_C©.ÐÆªU{T¨66>ë ­,½C•žË'ùÿÔ*r=–7(ŸÞn €´u÷NÝÎì�+¦p–¿ð|Èå_bO’ŽÎcº²ÔfK©o.àRÉŠv¼�déVú¸ÿÝ(•Á"©Ñ“Bÿ5TJÿðRÉz\ìv4{ÇÚx·-]Ší7aŽŒšfB=  õüþž0²LZ.ûJ%í×Õ ?ûw‡¡ÚX5ëO_Fú×›�ŸºMwÏÀ¾¹|á‡.¿ëê)Ü�ë|Øýï·¢AÓÁ´æ¹®OßÐZvÌ r´d´¿@ƒåz 'ú;äJéî�ú¿þ: „¥%8ñyKì Áxs¥ËæR¿/Kíƒ^¥’•JqêÓÙ}QSt°»Ä~?Je¾e™Ó |^û*¥_LØÓb·£Ñ}€òþOl¿ £û­>�õ\€f€dÔóû«S=¥i@ìk(•vÿý­Vm쨨)|¹Þ‘þµöÊ)Ì”õ‡÷×Ól¹¾Â÷ík­é½øƒþz ÖÝ;ø‡\)²Öà†þ: ‚5\Ž‘òOÔÕš‡$¸°¼�Œ�ÿ]ìúkIü�Ø×ÏîQSt¸ŸŠý~”Ë—F–i´/ŠÝvµôë‚ {^ìv4»Å0tÊ…J7—hyõeF[·¶z.@3 @2êùýÝjd™´œ+ö5”J;ÿþV«6öW¡ÚXEú¦nå_k¯–îüàöþzšj×bw._xÒ�Ó3ð„¿@£}ôŽÅÜNøM³S.›Â[º“÷W5šâr�‹–’׃»¼!ÁSAz©>�ªOÞÝ/ÁS¾'KPrõý’<=YÔ³›]žp™-ÁÓÁ¯Jðºî–à5œ$ÁSY¸ùe²ï§漨) Ÿû)—F–i´[Ån»ZúuÁ„•{ y×x#%­çò¦Øþæô¨)À»Ô3¼þÈ2i9Wìk(•v.ˆý{ÃPm¬kܱt._xÛ^s/Ÿî|á&5ͤ¥œý×X=þÌ_ ÑÖÚÈî�+æŸþ*-AO”o{Fµ—m ˆ,%Á�þw¥TtþìFß¡ƒ«þvkI¿$o¦ØíhvŒ7PÖÁbûO<_ˆšŒªgxÍ`‘Ôœ+ö5”J»ï-öo�'­ªQ™×Õ3pE‰kîÓ2O¯zíÄ\¾ð”ÿúªEú.ú„¿*@ƒu÷~Åß!WJW¾p‰¿@K8Vì X˜….[FM�Q¿û})—kF–i„÷¸<%v›µ¤_’7Kìv4ÛÅ(«Ëår±}(Œ>e¿úhk€@=ÀiKœ+ö5”J;W«6v~ÔÕtç {û×ÜkÈý­0°{¿.ñÚª¤p�¿@ Üød»S®”Âñþ:M§wRj mÿ$,Œ¥|Zì÷¥R¾,–(�Zâ2±Ûª5ý’¼råkÿ3Þ@E+Kùù´5×J0P ªgxµ‘eÒr®Ø×P*í6\­Ú˜ÞÈ«7ô¢Vë\µ\®w`–½î^-…¦N¥ÒÝ7¸“{ ‹ìëªðö×HAWþÂký�r¥°Ã€–³ŒËÃbOÂÂÜî²ØhkàÝtðE§wð¿7墣›/™ üÕ»ÅýíÔ“~IžÎKîoG³u¼€ªt®q-!ï÷¥0‡FMnALjýÃPmlŒr½ƒ§ú×ÝkIwßÔFÜ”]]ÏÅ›åò…¹þë©!O·Â“ËБÜNøÙ;沑¾¡�ûë4ÕybOÂÂÌqy_Ô(i±ß�JÑyƒw^r|´dÛ ±ë×è]ä—–øï¥ò)I^¹«­â�Ôä×bûR˜y.Šš€ÇpkÙDÊß«ùaÔué½xr._˜ï_{¯žÂüîÞ�½üÕ5T~êc{byxîâÝüÕÒÐsËRþN¹Zd�!-åh ;‹=‹ç¢¦@Yú®ÎÉã*EH粆Ôoy—£¤ü<»š/»œ]â¿—Êg$Y9±Û³E¬€Ú,ér¯Øþæ>—¥F[€NÆpë¨Vmì¡ÚظŒõ)à …¹�¡áÓ©äòƒºíͳۯžîüÀ_ýõÒÒ;´¾¿c®’wÒøaÔ¤ÇåU±'aa¦EM�ª>&åŸz­½|H‚Û^)=Ÿ§^ÄYÇå+.¸Ì»žxn“`=g”ø·RÙF’¥å©üm„Ñù¶ÔoŠË[bûT˜ŸEM@c¸uü^ì߆jcI˜4mÅ\¾ðr‰kð5¥;_ø›ô mê¯6=¯Û•¼Êßf™#k^üµ€”t÷ m[bç\)Ïúë4…>¡øg±'aažqYq´5P›“Å~—ê�î>.Á¼Âº<-•K†ùÑòÒï—ÀOKü{©l;Ò>)K‹ÝF˜Æœ\�á[bûT½åóQSСn Õª�5gÚ6ÔÝ7uç×àëHaQ®wàÉ6—$ÜÊO›âÖ{¶[ïÛv[µ§»·°§¿j@Šr}ƒû;çŠé¸Ë_ )´|®f¡Ë§£¦@Í´ô b¿Si&>?б%þ½T’˜�8n9±Ûó‘X;õÑ›—þ$¶_…yÁeÕÑÖ 1Ü|“„jc©Êå g™ëðcJáñ\OáÄî|á ²ö¥y;%­?}™ kús=ƒG»åï¶ëK §û›¤,×3p‚ÝA—OW~ð€ÔéÔÛbOÂÂü$j Ôm—[Å~¯ÒÈÿ“wûfìß*å‹á y¯Øm„ùP¬€úéÅWÄö­0—FM@b¸¹ô†½J7Sm¬ÖZ¼;?0ÿŸ@^Éõþ£+_˜îri®·p¾ûoýßÝùÂM¹|á÷ÿ/,±Ü8Rr_ýši wéÜ€æÑ àÒÆ€IDATÒ´ZV×? £ew—m ŒÍ².׋ý~5*ZúõP±öÛ¶Tö HÈÊb·惱vÆfG±}+žÿ�š€Ãps}Gì߆jc�Ô{Å{rùÂ%®Çg&]=…Ëu0ÛÿÓMЕ/üÑßQWNAç4ÏoÄž„…yËeƒ¨)0.zÇî÷\Þû]K2O¹|VJÛIlûR9 \ !«‹ÝF˜p~bãsŽØþfŽÐ×èT 7Oµjc\n´É×/<™ë_“ÏB §Ë®E�V Ð º{ 7Û�u…ô^xŒ¿@jþS‚'%ý“°0FM�Ĭ'AIVÿû6ÞèÏÏ%˜o·œ-Ä.W*G† $DçJò·fíX;c7Ñå!±},Ì.<=@ça¸9¨6Ö*Ö¹j‰1Tílb ó]¾îÿ€&Ëõ æ1—eG[€vÂpã¼Çå)±¯7Ì�Ât�¸u\f‰= svÔ@m#¶ÿiæÅh(ýMœ-¶†9+j ÚÀ�Cµ1HO;­c[±}P3'Þ@Ãí/¶ÆóßQSÐ&nŒÿû:ã¡Ú4ÀñbOÀÂ0ß!�®íÅöCÍëñFRq‘ؾf¦ð¤ í†àä­-•«��5$å“Ì-Ꞔ…ù~Ô@ vÛ5¯ÄHÅJ.ωí�af¸äÂÆ óNV-ÕÆ–m HÄò.Oˆ= s“KwØ@*´ü™ß5/ÆHÍÖ.‹ÄöÉ0GDM@Æ1œ¬ãľ¾0ZmìãQS@RÄž„…™í²NÔ@J¾$¶?jž�7�ªSÄöÉ0ó\6Šš€ c89Õª�5$eO±'`ñ|9j E_Û5OÆHÕ.÷ˆí—aîwYj´5È*€“¡ÕÆûÚÂÜ,T€Ä­åò†Ø“°0EM¤ì«bû¤FçÇÐ<tySlß ó˨)È(€“q¡Ø×F«�­5$!ç2CìIX˜§]Þ6�ºýÄöKÍCñFšâp±}3ŒÎü_QS�A �ŸVó_Sj  EüXlÕ\o eèÍVÛgÃ<ã²âhk€ö°†T®6v•K×hk@"¦¸¼%ö$,ÌÏ£¦ZÈ bû«æêx#-¥ÇåU±ý6Ì%QS€ÌÓ�]àõ�yÂPm `I—{Åž„…¹Ïe©ÑÖZÉÉbû¬æòx#-g±ý6ž½G[dÛb�uÂhµ±m£¦€¤üJìIX˜y.Ššh1út¾ßo5ˆ7Ð’ÎÛwÃÌqy_Ô “ªU;%j HÊç$¸ãÖ? sXÔ@ ú¥Ø~«¹(Þ@KZÆå±ý7Ìí.‹�¶È–jÕÆîª�@âVvy^ìIX˜k%˜«@ë:]lßÕ ÆhYÿá²@lslÔ SÊݬªÑjcFMIÐ�ÝËÄž„…yÙeõÑÖZÕ™bû¯æ‚x#-í‡bûp˜….[FM2a©\mìð¨) )‰=‹gû¨)€vŽØþ«97Ö@k›àr‹Ø~æß.Ë�¶hmÕª�]'T€Ä­ç2WìIX˜3¢¦ZÜybû°æ¬x#-o²Ë,±}9Ì-­Zµ±5¢¦€$,ár—Ø“°0�¸,3Ú@«Û�5ÜÈdÏWÅöåxv�š´¤ÅÃijkÔ�”“Åž€…yÇeÓ¨)€ Û—5¿Š7�ƒbûs˜×\ú¢¦-¥Zµ±ßDMIÙÂe¡Ø“°0߉šȈibû²æñF2c—'Åöé0qém Ðs¹Mì±K˜G]–m H”�öTn~­ŸÆÈnØYó±Ç,a´ÚØfQS@R() ´§+ÅöiÍIñF2‡)@VèÍk ij„9*j �gI—ºüÍå\—÷½ë_Çî«bOÀâÙ=j c®Û§5ÇÅÈœ%\îÛ·Ã<â²Ìhë±Ë¹ìï2Ãår—ÿx׿TV­ÚØ_…jc:Üiòî¥7]Žt™oT§É.³Äž„…ù}Ô@Ý ¶_kŽ�7�Ië¹ÌۿÜ5“õ]n‘w¯S�=ÖŠ7¨ Rµ±×…jc ω=aÒÜéòáX»ZéÀ±a7ž»,7Ú@Ý(¶okŽŽ7�Y‰íßñì5­Ùbì#æ‰]Ÿæ€¨)@Yÿ#ö8"ª�€3_ì S�ïï.G[W§å¤ýõ„Yè²eÔ@FÝ,¶k¾o ³º\.ÛÇüì²úhëê6’àÆ2=ñ|´5@iZmì ±Ça΋š@çÒ�]ÿ„©Tþå²ùÈ2•è~ Ä.æ˜Ñ–²ìV±ý[£åã´‡•]žÛÏÃ\'Á@q%K»üT*„ù¿‘eJ©Vmì)—÷Œ¶€6IìIS¹,rù�”/ß¼ŒË#b— s»ådßbû¸æˆx#™·�¿ÿ~_sXÔÔÐÇ»L¹œ,PÒ±b�ÂPm b>(öÄ©ZtÎàuaÏùbÛ†™ãò¾¨)€Œ»[l?×T �M¿Û×Ãè|¾Šš[^‚Æ* —ÊEº0@ Ÿ�`Š*ÿø!ŒNEñq±'Nµæ—•$°K‰�gï‘vÚÃ}bû¹æ x#maI—{Åö÷0º?Xj¤­Þ ¦7ŠùmjÉõ`iµ±‡Å;„¡Úx>/ö䩞̔`ÎÏWKü[˜K@»ÑyÁý¾®Ù?Þ@Û˜âò–Ø>FŸø=¯Ä¯'· €õ{±Ç a¨6%|Iì T’yÆeEÐnÛß5ûÆh+ßÛ瓌>Ù·³Øc†x¾5„{•Tº|Z´£ÇÅöyÍÞ±6ÚKÎåObû}RyI"=R¹ÚØ´¨) î(±'QIå' ]=-¶Ïk¾o íLryElßO"o @@o<»AìñBª�@'Š=‘J"úôï�\íèy±ý^³[¼€¶³®Ë}bû~RYZD¾#ö8! ÕÆ Š3ÄžL%™{\6íæe±ý]³K¼€¶±˜Ë‘.óÄöû$³º€N÷ *ƒøÇ aNŽšJ)ˆ=™J:ï¸üÂe¢h¯‰íëšâ�´…�\îÛß‘õt2­ò Øc„0ÿtYr´5 ¤«ÅžP5*�º|J´ƒÙbû¸f»x#™¶”SE,Ûו� èd•ª”½å²AÔPÎßÄžT52‹\~㲜Ȳ7ÅöoÍÆȬÍ]ÛÇ�Ï èTz.¡× üãƒ0EM•< ö¤*�<粣Ȫùbûµfëx#™³¼7jUºøÚÈì& ­âò‚Øcƒ0׸t�¶T¤±þ‰UšrYYdM¹Á¡­â�dʹ<-¶_§™¯ è4:°{…Øã‚0/¹¬6ÚPU¹2®if¦Ë~ +ºÅöã0Z6@¶¬êržØþÜŒ) Ó&ö˜ ŒÞxº]ÔPÍbbO®š½ã·G´º%Äöß0�µÐúö–àF,¿/7+'è$ëKåÓ5ÔBK/û'WÍ΋.“@+[Zlß ³i¬€ÖöC±}¸Ù9M@§ÐKï{<æ Î=uXWì V+„ò�@k[Nl¿ ó‘X;­KçÚ›#¶7;:ÅÏÅ „™ï²IÔP«�ÅždµB­ì½bûm˜ÅÚh]:ü‚Ø>Üì\) l-Áü¾þ±@˜oFMõøŒØ“¬fçV—e@+[Elß £sxȆÝ]æ‰íÇÍÌÍÚÝJ.ω=s£K÷hk@]v{¢Õ¬èßÿNò€,XCló¾X;­oM—Ÿº¼.¶?7#÷ hwCb�¼æÒ5Ôk_±'[iFçôÑ¿O€,É‹íÏaÖŽµ�Z}c? `ý~�fžÐÎôxÃÿý�ç‹QS(o1—÷”‚9uü“­4ò’ˉ "ÈžµÄöë0}±v²GçÖ)".—Êóò5*s¤³,'Áq©Î­®Ñÿ½ì»ZÐ>ôfÑYbÿÜ5ÐIVwù¸sÖéàåIœ \êòW—$\Ô’Aþ‰D¹èS¨3]žu¹×eºËE.§»üÈåp—]>,í7p¬Ÿÿ~42ÿr9Ìei�eëŠíßa&ÅÚÈ6íë¿�`PÖïë�ŒÞ¼—5KHP—ý]Ž–à½»Àå—;\ž�à˜³žy—ßYæq—Û\®–`�ºn݆>EõY ¶­¯€V5ÁåV±¿uan‚Úš– ü˜´~ér•˃.o‰=AhFt޼»%tþ‰Ë—]6tY\²çT±_ÒYèr½ËvA|¡Ë·\>鲤´¶óÄþIEË:ýÆå Ýl ¶Ï‡Y1Ö@{ÉIpC—ÞØå÷ý$³Ž´}ªvo—_¹Ü(õU˜iµèk׿Aÿ–½%øÛ(Eov÷G†d|7zêõ‘b׿ûQSY¤óFê³§I0XšÕÁÞZ£%þnv9Ùe{—U¤µ\&ö5�7Z¶éHi¿rÙ"ZßïûaôÆíï£ÜèÕˆ -Kú´|ò'$˜bD/|¿(öuµ[ôoüƒ³þíY¬fHÞ+b34¯ºìkW+=?xBìúÂhåŠî°1€l˜(Á“":àûo±ú�-Í÷ Jm.Á8Íô±¯q¬Ñ“¶]…7 èàŒ¿£eüt-yŒËËb÷cͧ%“]r¹\ҟ縣ï�Þx€7m:�NÛTéI]Í —uGÚ×B«¤ùë3[Z§ò€*ôàý”Ô'`ý|EËñ]ä²�ËÊ’>�ËØMõDŸú9ËåC “è<íþþ L«—¾ÐK¹ì+A…¿Pov–ÆÐï>ërŠËCb·KÞ�¹üÜekiþM‹€t,'ö÷ TtàöP ¦‡¨D+ÀùËÆ³gÔ@+êu9L‚§@õ)Wÿ žT�Þe«ïŸ¾�i•Š~Rìë¨%/Hð´O3­4ŸÎáåïÂ0H@÷:W`µ'ˆÊEoŒKŠ^˜Ö×ó Ž_üm‘Ú¢e?Ï“ ²ûyh_zmÇÿ ¨”[\Ö^Òʻ̻L˜‹£¦ZÉ<é{«0è›tæ»\)Áü:�,§ªO ûÛ®”Û\¾$Ìtº~±û‡0Z6”V…Ñ�×Yb÷•¢Ç—ã¡û¡-$˜‚¤æòM;:�þ+ Öíe±ûýjÑÊoG»,&½kF¬�Ÿ§]Þ6Ð|:¿ë¶.—º¼#ö ¾%³øâ‹›ømZ8zÑô·.›H²ô„l¡ØíùÑ6:7Þg‚Å`xàï+4ú»¾e]ösyPì~£TŽ «›VPù–d¨¼sww÷ðqéÒK/=ýß&L0íZ8ú™!T…€v¡7PùûúZóO—Í$ðýÿF¯1l5Ò@“i c$¸KÓ?xO-¹\®¸ÁwÛm·âw¿ûÝâ¯ýëb¡P(^síµÅÛo¿½øÈ#�_zé¥â¬Y³Š‹-*ÖbáÂ…Ãí_xá…âƒ>X¼ù曋W^ueñ‚ .(žzê©Å#Ž8¢¸ýöÛ'Ožl^O¢sö䲂ŒŸÎÓé¯?-õw¢e› îsb÷½ûÊÑ›Ïvpù³ØýG<'… Ô@שsÔjÉé·Å®+µ,¿üòÅ­¶Úª¸ÿþûO<ñÄâï~÷»â¥—^Zœ1cFñî»ï.>ñÄÅ×_½8oÞ<ÿp´¬ùóçßxã�â“O>9¼]—®S×­ÛÐmé6uÛþëI9úÞO•à¡jóAZ—–ú÷÷ñõDw/‘ ª™ÿoaŽM÷.Ó¤¶'E˲Ë.[Üi§�Š?þñ�‹Ó¦M¾àõÊ+¯ø×ÄR·`Á‚âsÏ=7<Ø<00P<ꨣŠ[o½õðÓþßÐàÌ• ¬áº2>:ç°¿îû]öwY:Öâ´„¿ïÐ̉7€ 6t9Ûå-±û’mbíÊÑã”]»|C³á†¼ž~úéÅéÓ§{ì±â›o¾é6¦nîܹÅG}tø5�vÚiïQoœô_ yØåáX²hO±ûõ$s§0¥Ð4Zæy—¿‰=XOmüÌ3Ïÿtà œN:©ø¹Ï}®ØÕÕeþÖDæu€^êÇb’OÌ<æò—Ï ów¨n{±û#ÍëñFP-ü=—{F²ï»ÿÙXÕåG.¯ˆÝ%ž)S¦¿ñ�o/ºè¢â¹x×]wµí€o5Z>Zÿ~}ôýðߣóO—]…�`�óE±ûÍ ñF�€^—߸¼#vŸ3îLœ8q¸T²>ûÒK/ù‡oãµ×^+^wÝuÃÓ›¬¼òÊæ}J(Z¹F+Ï0 ­ëd±ûï¤r¹UÈ4Ø—}\{`>®är¹â>ûìS¼êê«‹³fÍò¯1Áyá…†çÞn»íÌû—Pþ.µÍ¡õÚCì>Gól¼ŒCÞå —ùb÷5ãÊZk­U<ñÄ‹÷Þ{oÇÞ˜X‰¾'ZÅFŸ„^sÍ5Íû—@Þv9Í¥G­F«møûí$óš pÃ:Ðz ½›Ë#bÆÇ�óVK_~ùåÅ9sæø×’PÁË/¿\<ÿüó‡çöß×r“Œ}Ž`(e/±ûÍ“ñF0+ºüB.õè_BBô"Û­·Þ:]ícÌ›.'º,#0vZ¶Íß¿hŠ7€}ÆåŸb÷)cÊFmT<óÌ3‹¯¿þºx…„Ìž={xÞäO|âæýGþå²­šé6±ûçFço.uѧ)¾ã2KìAvÝÙxã�‹—\rIñÍ7ßô¯¡�^}õÕâgœQ\yå•Íg2Æ<åòßcs ØýŠæþx#¨b—+ÅîKÆ”ƒ>x¸Ä3Ò¥%¢<ð@óyŒ#—¹L@3<,v¿œFæKp³ú ª�\î{`]wtnÚ3f0gZ“ÍŸ?¿øÇ?þ±¸É&›˜ÏhŒÑ‹®½õ9TìþDsO¼”¡¥~µÜó±û‘ºÒÝÝ]<öØc‹Ï?ÿ¼Ø„”½òÊ+ÅSO=µ¸øâ‹›Ïi Ѫ5”€ô½(vŸœf´"Èf ¤‰.?“Ê=zè¡Å‡zÈ¿¾ƒ&Ó�ø›nº©¸Ã;˜Ïl ѧÃuÞµœ@m¾!v_¢ùG¼”ða—;Åî?êÊk¬Q<çœs†K£µègrÖYgW]uUó¹�!z3«ÞÔ HÇ<±ûâ´³P‚)̘º ˆÙR(Ù£óÎ>ñÄþõ´ »ï¾»¸ãŽ;šÏp ¹E˜w@m¾%v¢Ñ9à”¥\Žqy[ì¾£æ,»ì²ÃóûΛ7Ï?$B‹ÑÊ5:OpÁï¸üB‚›\�³¤Ø}p3ó˜Ët8½¨¦F‰=h®9{î¹'OüfÔ]wÝUü¾`>Ó:£wûj¹=žPÉQb÷½‘|S\î»Ï¨9'N./ïò/›»|ԋν8Ù‹Vœð÷šé}Šèj±û‰š£S’<÷Üsþa2^*~øáæó®37¸L@’6»¿m…|I€¢OgþYì�qMYl±ÅŠgŸ}öðÜ\h_o¼ñFñè£�6Ÿ�9]‚¹€d—îÎÛ¿ÓÈ›.3½¼(Áœ^ñ<ârG,7»üÔå= kvtyUìþ ¦lµÕVÅ{î¹Ç?¬A›¹ûýýýæó¯#¯¸|AIÙFì¾¶™Yèò—n:Ä']ž{p\S;ì°âÌ™3ýk0hc�>úhq›m¶1ß…:r»KŸÈªß‹í×YÉ% +ôí‰.‹ÄöåªYz饋ӦM+.Z´È?”A›Z¸pañâ‹/..¾øâæûPcô»öcap’°›Øýl32[‚�ßõè ûÉK>¯¶ÚjÅéÓ§û×]Ð!ôbª–…ž0a‚ùnÔ}Êâ³ ‹>î2_l¿ÎB.Y eæ¯Û‡kÊž{î9\�éÕW_oYèé.«`<ö»M3ÿv9Òå½t-Á{ŽØäšrÔQGçÎ�ë_kAzå•WŠûì³�ùŽÔ}ÊBŸìÉ €¬ùšØ>ÝêÑÒЫ €V÷a .Úú}¸jV]uÕâõ×_ï® CÝxã�ÅÞÞ^ó=©1O¹l*PŸ.—•\&»LqùèH¶’`•­G¢ÿ{ËØ¿¯7²ŒÞÕ.tðÕß·¦‘›\vu™ @‡éu¹[ìArÕL™2¥xï½÷ú×V€âUW_=žr{Z’ui�5gˆíÏ­­:ð~Ðê¾âò–Ø>\5GydqΜ9þ! :ÜìÙ³‹ßúÖ·Ì÷¥ÆèwñË�Õ%¨„³»ËQ”þ£Žº¼$cœ¶À‹ÎU«ëÒuêº/•`[ºMݶ¾}-­îx±[£¢•í†\6 C}Èåi±ËU£eÔxꕼøâ‹Åí·ßÞ|wjŒÞ”0IdÉb.3ÄöçV‹^À×ùî´.}bêÃ…s½íšk®ñK€wùÓ 7'Nœh¾?5D¿“ÇHðÐþ´¯ë“¸;º-Áô!:;¦›“}MHðõµêkÖ×Þ*û«Óľæ¤ó‚UŸ–€Ž¶�Ëb˜+†‹j¨‡Î |Þy绺ºÌw©†<㲡È-©¬e2ýþÜ*Ñ ÷{€V¶¸ËybûoÕì´ÓNÌõ‹šéÜÀ»ï¾»ùÕ˜s%ø®h/y žª=Õå—YbûÖ¢Ãß$ø›v“àol† ž¶¤ò—ý$˜Þ èh¹,{Ð\1»îºkñå—_ö¯�U=øàƒÅ 6ØÀ|§jÈkÌ� ;t¾Î¹bûs+äë •½ÇåÏbûnÅè�fçž{îð�g@=.\X<çœsÆz³âŸ\VY¥OÆê LJ¸¤µobL:ú·¸,Ae¸4\)öuŒ'Zûr æO:žžàœ,öÀ¹jN9å”á $ÀXiÉð}÷Ý×|·jˆÎßÃ{@¶è‰~_nvN­LŸHÒÒ•~ß­˜I“&ï½÷^ÿ°¨ËÝwß]\mµÕÌ÷«†Ü'”²DoÚØÕål—çÄöéNͳ.g¹ì"�»±åf±ÛK´’Ý/\úÀ0üÕ’?þÁsÅtwwÏ‘$!, íÏjˆ–mÕ;³dÇÏÄöåfEŸè­jM—GÅöÝŠÙvÛm‡ËøIxíµ×Š;ùžÕ�'\Ö­JûçQ.uyGl&GqùŽËÚ’œŠÝV=ѹ—p™(FéEï߉=€®˜ 7ܰøøã�û×F€q»ãŽ;Š+­´’ùÎU‰!²B{®Û—ÓÎmÂ…" •}Àå±}·bŽ:ê¨â;ï¼ãbã²`Á‚âÉ'Ÿl¾o5äy—) Uôºær“ç‘~Ÿ%µç~—cdü7ºÔý[?ý ·“à¡1‹»\,ö ºböÙgŸá’½@£<ÿüóÅÍ6ÛÌ|÷jÈÑ +ÞëòˆØ~œVsYE´ª�\^ÛwË&—˯ºújÿ°HÔå—_>–y�_”à;  9Vr9Üåï o#¢ïéß$X_Qê7[ì:ËeŽËiÜ$ „%]®{0]1Çwóý"sæÌ)~ñ‹_4ßÁò�ëI0W—ß��—]Þ'ZÕf.3Åöݲ™8qbñ®»îò'€†¸óÎ;‹K/½´ùV‰~§õ» úTè§] .óÄöɦG§ÕZk­µŠþð‡‹[l±Åp¶ß~ûâ.»ìRÜu×]‡£ÿ{‡vý÷M6Ù¤8yòäáeýõµHô½pÙJj{2w‚Ô6(¯ó2#c`:ÆbÌyèPWÌ\à_ûJo6øÞ÷¾g¾‹5ä8�;Hm}’Ê›.Ÿ­jC—WÅöݲYwÝu‹O<ñ„4Ô3ÏVÉë.�´ªsÔÖ=|RÑŠ:mÖn»íVüîw¿[<óÌ3‹×\sMñöÛo/>òÈ#Å—_~¹øöÛoû»•ºé:t]ºN]·VÁÐmé6õfê 6Ø`øµø¯/ÅhµŸ#%øLÊÑÊt Ä.FçgÞE‚�b輋z¬P]6zÂ0cÆ ÿ\HÍyç�g¾—5ä(�?Û‡‘….; €Võ~—ÄöݲÙzë­‹3gÎô€T¼ñÆÅm·ÝÖ|/«D«P¬/’öA—³%å§}7ÝtÓâ!‡2<ðzÓM7_|ñEWÑT‹-*¾ð ſþõ¯Ã¯ñàƒ~ŠØÿ;ýLÎ’òû>}b8Þþm—ó„f€šå\~/ö`¼l´´Ùý÷ßïŸC©»ì²ËÌ÷³†"²@ËÃ]$¶'�C@«ZG‚òŽ~¿-›½öÚ+‘'¨€ñ˜7o^q�=ö0ßÏ*yÖem�-ó|•¤PQ¦···xà�WG{ðÁ‹óçÏ÷w ™¡¯ý�(žþùÅ8 8iÒ$ó÷6 ú]!Ayè¸%\¾í2Õå{.«½ûŸT¢×O{^6+¬°Bñá‡öÏ€¦Ñ'Ñë,a¥'˜û €,XÖå>±ý8©œ,ZU�ËãbûmÙ|ík_+¾óÎÿgïNÀ¤(Ò¼�¿ÐÀÌÂòÑ3#» °ð9,°ÀŽÀ ‹ƒ,‚ ¨ ,:\ # ¢\ â‚ Š ¢Â€¨Hs)Ç * ÷¥‡È!÷}4444�_¾ÕƒKÆY�Õ]••‘õÿ=Ïÿ™y¤++£"¢+:#3"G*$O¤tïÞ]´Ó<²Ïί òƒ¯oð²ÀIö­¸¥bÅŠÖàÁƒ#K+íÉÞDà'…°Ø4hPd²[ý<âœõ”»2�—}‚ ÞUp»†ïþÄ^jD¼tUŒ“À¼ŸÐÝ&¨jç4É~\Ð̦ÜU0 xn²³ƒd¿uMÿþý­«W¯ªC€¤â6Ù·o_Ñ^óÈ6;?'ˆES;Hö§¸äÎ;ïŒ,•ÌOÇòòÉ©Œ÷:Ÿ={¶õÀX… ŸUœò��v„‰`€|éFr�íšJ•*EúAµaÃ+--M´Ý(¹`§€ î Ü7Ô~œß|aç§ATÌΧ$û­kžxâ LþB`ñdÑÓO?-Úmáï)^þ¢kag-É>T ðÍÅݺu³–,Ybeee©ÝþŽ?~ºk×®±Þ�í5kì4'ð¬™�Ë$×ÚT®\9%–6ómÚ´)Ö?<�Ú©L`‚�$ûp~²ÓNi€ â'}2Hö[×<õÔS)ÿ4·Ñ¡C‡Šö›Gfž~pSÏÎ ’ý&ßá§Y;wîl-^¼“¾ùpþüykáÂ…‰z2øK;u ¢ªN1,¥Y®\9<ù FY³fM¬“ÀXfÀ |ü]’}8–³S… ¨F“ì·®yôÑGñä/ƒ'�(ÚqAp£rv^·s•dÉWjÕªYÞùÌ™3j·…|Ê<—Y&ºAƒâó.@®Qî.þciÉ�´6éééÖž={Ô±<@à}öÙg¢=ç‘ÏËì˜àì¬#Ù‡½$ËÎï‚ê!’ýÖ5={öÄä/‡Ûl�=D{Î#] x{€>vÎ’ì#1‡o澸zõj¬"‘`¼oòàÁƒc½I;ZÎÛNØÎàGE)†%’Š-jmÛ¶M»ããO>í:�ð�ä||×?/ß®öáháýƒÛð‹ �عD²ïjÃKtæää¨_ýFà¶ËK¤ªí:JxëžF�ºZÙÙC²oÄœ *XÓ¦M³Î�;§vMH0~*xêÔ©‘:Pë%ŸÙe§%@drK0kÃwf~ýõ×êxÀ8sæÌí;�à 34¦ö²·Ó#ò*"^¡æ0É~«M›6m¬K—.©_ùFá6ܲeKѾ£ä �²�ZÊØy‹dˆ9·Þz«µ`ÁÜ<¼¯ØÕ´iSQOù / ýKHQ�$»†Ÿœ‹±cÇŠ6%íÔ%0A/’}X—g®¿‡W¨ù‚d¿Õ†÷iÌÌÌT¿êŒtöìY«fÍš¢�GÉJÊ] ì Ùélç$É~SÚ·oo­[·Ní~kÖ¬±î¹çQoùÈ ;$€SÛÎ’dmx9$€0á=�úôé#Úz”ð>Ù¥ LðÉ>|cfRîED¦—Iö[mJ–,iíß¿_ýš0ÚÁƒ­ôôtÑÞ£äE·Jv–‘lû1¥uëÖÖÆ�Õ.µ~ýúXWEpËGv*@ ¸ÉÎ^’ƒbm†ªŽÃB�—ÙkÑ¢…hóQÂÒ‚î'v¾"Ù‡9Ë OJÙý$û­6¼=É7ß|£~½„–-["m\m÷QÒ�©��Ó$Û¼çÜqÇ‘å…ÁL›6mŠLÞ«õcÎÚéN!7Ÿä`X›Ž;Föb+^f¯Zµj¢íGÉüÊÎrößoí¤ßøC(Uíœ#ùÝ« ¶'�°[ºt©h÷Q’i§2„ÇÏìÌ"ÙÖ=§F�˜ø ‘åË—[Õ«WõcÞµSŠBèO$ÀÚðÀ:óöSƒðÛ³g�U¤HÑ\’c§>€ *Øogåîùû ç?@€±³Šä÷®6/½ô’úuJãÆ�í?JÖRîÚ¦k`gÉ6î)iiiÖ„ "+>A¸äääXo½õ–U´hQQï1ä;·@ˆT!�OUðrcßÿ½:Ö­O?ýTôƒ(ùÞNI€xIòûV›víÚa…HÜÖ;tè úA” 'sñv;#ì\%Ù¶=eÈ�!ÖéÓ§Õ®!sêÔ)kРA¢þcÈ;OÙ)L†ã;Á×�ôj³dÉu| z£F�}!J&ÄÃPî…Xõ»V¤R¥JÖ™3gÔ¯p€Pã-KªV­*úƒKxâì÷`^©e É6í) 6´¶mÛ¦v¹­[·Z 4í!†,¦ÜåÆŒ5†ä@W›gžyFS¤„+W®X­[·}"Jî!(Þ‡o/ÉïX^¡fÇŽê×7@Jà‰-îj¿pÉn;ÿ‡ÌQ›rÛ­Ú–ó ÷ ^îùòåËj·�ÁÇó²Ð1üŽTÃKB×%ñ~¥žžªhÞ¼ydð �ªNž7ü{‡¿¿¦)å^„äcý@¢ñŠê÷«6ï½÷žúµ �RfÍš%úE”¼Ff¸ßNÉ6œgZ¶liíÙ³Gí*�¢8`ÝsÏ=¢�xÌE;] À Eìl"9¸IKK³öíÛ§Ž¡RÎÊ•+Eÿˆ’7 �'wÿÕNK;ÿcçy;s쬳³ßÎ’}3^á ’û쬵3ÛÎsvzÚùåž&‰òï÷v®‘ìw"ݺuS¿®RR�=Dÿp ÷­&\¼ßïx’m7Ïð“žÖµk×Ô.)ŽÛÄ»ï¾[�§�_ ì †Jr@«Í‚ Ô±3@Ê:t¨è#QÂO@|”³ÓÊÎpÊ�pýåîg¨ö» å��E”»ÝÂíT§Ü‹šàî'v¶‘ìO"åÊ•‹ì� Vdì2eʈ~â’v~JÁSÂÎ|’m6ÏÔ­[×Ú¹s§Ú5øÉðF�‰öã1ïSî ¨�u3å.c£fEzè!u¼ �Ò.]ºdÕ«WOô—ìµó�±ú¥�6”ûTíJJ쓼~‡Ÿ^A¹ekm§4À�ø† µßh³fÍõk ¥­ZµJô“(AÁR–rWVQÛjžyúé§±×/x–““ÙZmG³ÆN ^²æK’ƒX‘ *X™™™êX åmß¾Ý*T¨�è3.G�~º·‹�i”ûT’Ú�žív¦ÚéD¹?RïÕ�C²�ˆŒ=ZýzÛÈ‘#EqÉe;5 xûŒ=$ÛiÔ”-[ÖZ·n�Ú <áÉJ—.-Ú•‡ì¶ó˜n$¯Úð~§ ÷òË/‹>ã’+vþ�àF¼ òo)w9çõäq¯Ï /oÍOA6µSŒRC!;«Iö‘š5kFVä);;Ûª^½ºè7.á)¸ï$SC;§I¶Ï¨iÙ²¥uòäIµ ÄäøñãVóæÍEûò�Sv@@”¤ÜýÕ�«Hß¾}Õq1Ü€—»å–[Dßqɧ¼_V{;sìœ!ÙO}ø‚è,;míü„W'’í_›�7ª_Ëpƒõë׋~% y~o'“d»ŒšÁƒcÉgˆ›+W®XÏ?ÿ¼hgrÞμߠ:`)Q¢„uêÔ)uL Šo¾ùFôŸ(¹‡RÏOí´²óåãâ"Â{!/²óG;% <þÑÎA’m^äÉ'ŸT¿Ž@càÀ�¢ÿ¸d?á;’ã”;¶QÛ¤k .lÍŸ?_mîq±øƒÅ‘6¦¶»<’mç.H¢Ê”;0U«"<èo†.ú�KxŸ ž ;^Jò÷„IßDç¬�7í4"ó�"ÙÆEÊ”)cežËT¿Š@ãܹsV¹råD?rÉpðW;Ê݇Zm‹®áö¼}ûvµ©ÄÕÖ­[#ã µýå‘K”Û¦’â}’ƒT‘Ž;ªã_ˆ"++˪X±¢èK.DáõK;�ÙÙN²í'5¥K—Ž,ÙÞªU+«[·nVïÞ½#KrFŒYòíÆŒ9òÇïÓ§�õàƒF^ËÇàc©Ç@¶Ùéo§4˜§"y|lùòåê×0D±lÙ2Ñ�\’eçŸ ÀعB²º¦AƒÖÑ£GÕ&�GޱêÕ«'Úaá6ÍmÀW·“œŠ*TÈÚ¿¿:ö€<,]ºTô'—ðÓ�e \šØ™M¹w¾«m>á)Uª”Õ¬Y3«W¯^Ö /¼`Í™3ÇZµj•µwï^ëüùójw�>6¿ÇW_}eÍž=Û7n\äø\J–,)ÎÓ§ðJ”û½` n³j[ÁMŠùÓ®];ÑŸ\2ƒ�·ÅÉ!Ùþ\Ãm˜ŸhðßèÍcµ=æž¾�|ôÉ�©ÈsÏ=§ŽyÀ£Ö­[‹>å’ À|Eít´³‘dOHø&¥F�Y�>ú¨5mÚ4kõêÕÖÉ“'Õ®|n<=uêT«_¿~VÆ #ePË•Àl°óßvŠ@pÕ´s•dûá. v{öìýÉ%Ük@âüŸì3¯ÆråʵYø"''Ç0`€h—y„oŠmI>øÉ©Hzz:îª(€;vˆ~å’‹vþ‰ÌTÒN;?�lÛqM‘"E¬¶mÛZ&LˆLö^ºtIívÆá ˜¼¯Ø[o½eõèÑ#òô²Zîäåîíø3ž$۬ȨQ£Ôî16l˜èW.™G‰Á+ÆðßAj›s oË/½ô’hŸy„'�›@‚­!9™7ož:Æ€ 8Pô-—¼Bfù¹�gìœ%Ùžã’´´4«k×®Ö¬Y³RæI¿k×®EžÌÊÈȰ:wîù ÔÏ%Ž9cga"‚ã·v®‘l«Ž/^Ü:{ö¬Ú} ™™™V‰%DÿÒ„ûd]ˆ¯vΑlo®™8q¢ÚŒ’jòäÉ¢�æÞ[½$È]$¡"¼,åÕ«WÕñ-Ĉ—|å§Õ>¦ ßük¾R”ûôhB&~ëÖ­k=ûì³ÖÚµk­Ë—/«]*åðSÎkÖ¬±ÆŒcÕ©SG|^qÊi;ÃìüH®I¶O¾A îí·ßýË%‹ ~þ•rÇj;s ·U€ zóÍ7E{Í#§ìü†â¬��Í$ "+W®Tǵ�O¼\­ÚÇ\2™‚«„��”{ÑBm» Olr?á'_!ºƒF–‹¾í¶ÛÄ燜¤ÜÉ}LC2ÜJ²MŠT«V-²ÿ÷¥ªU«Š~æ’ß@Á•µ³‡dûrÍܹsÕ¦ (³gÏí6�ì²óKˆ#OOÿÞyç�êx €÷Òæå*Õ¾¦Ée; XÒìô°sŒd›ÍwjÖ¬‰IßÚµkWdÿ±êÕ«‹Ï·€9bçA;… À?žžþ]¸p¡Ú Þÿ}ÑÏ\‚§€¡ þÁÎ*’mË5¼ € bø]z=ë(÷[€¸XArÐ)²nÝ:u, ôú믋¾æ’q�ÉãÊ^R¨P!«G�ÖêÕ«#{ÝBülݺÕ¼Dr€)òù矫cVˆ³ñãÇ‹¾ç’?€?JÚ™`ç*Évè9<¹øüóÏ['OžT›=×ͳÏ>�‰`¾©Œ—r,AÃmè$É6æH¥J•°÷/@‚åääX*TýOî³øý^üÜÎ.’mH›{ソë!4øwj»víD;�’½v~A1à?ÎÏ’\:R³fM,«àƒS§Ny�|YM‰×ÜÎ$ÛŸçp{5jT¤mƒx"xĈ^EË;M ÿ$Ù®D¦OŸ®6cH€©S§Šþç’®]a;�l;ÚÜrË-Ö¹sçÔ& `´óçÏ[õë×í=J–ÚI#�ø�suP)2wî\u¬ ÂK›©}Ð%µ 1~jg ð©_nËÇŽS›8‚'íù©íN_£Ü•F~B±[C²M9R¤H‘ÈTH¼¬¬,«hÑ¢¢j²Š¢A²ÝhS®\9ëèÑ£js…#GŽXeÊ”í>Jž"�ø�su@éHzzºuéÒ%uœ òÃ?ˆ~èžTˆ·µ³‰d{óœxÀ:pà€Ú´ÁP|aŠ÷mVë9ÆðþÑÿFÞq{QÛ‘ߤþ3fŒè‡.Á~•àæ÷äñ&C¾ mûöíj3•­[·ÆrÃ%oµò�‡š$“"&LPǧ�`;v}Q“Svþ�â£��îv²H¶5O©]»¶µråJµ9CH¬[·.ÖeêÔ\´Ó‡rÛ@^&’lC"x* À_|S�Ú]‚A'�r÷2UÛ‹6óçÏW› @(-^¼X´ÿ(ÙgçgÅ’I‘Ç«cSH°åË—‹¾è’ŽPp|á¯$Û—§”*UÊš={6öŠO\ÇVÉ’%E;ˆ!óí”"w|sß䤶G:uê¤6Qð�ÇOnTi&ɶ¢Íˆ#Ô¦jÆ ý Jf€ Þß1Ï küÇ=øïÊ•+^÷úŒ †—YýždÛò”=zXgΜQ›0„\fff,û•벓rW"Ðù#É6#²ü³åjÓàFEȧN$Ûˆ6Í›7·._¾¬6=€PËÉɱZ¶l)úC”àw,hÝCrð(ÂÜ@rŒ7NôIM®Ù©@ùÓÁÎy’í*ÏT©RÅúꫯÔf )†—ü®\¹²hs�r/¨>"Ù^ᛤøf)ð÷½²eËŠ~©ÉbÈUÉN&É6"ÂmëäÉ“j³H Ç�·J—.-ú…KÎØ©HŠ ’ƒGGxÐÉw @r±Sˆþ®¸�s$ŽŽðõ�\mÛ¶}S“Õà]:å^(PÛQžá§~·lÙ¢6S€ˆM›6äi`¾0Œ}��=D²}ˆ:tHm‚ࣈ~é’®©® Év¡ÍرcÕ¦�’F�-úG”<@מä€Qd÷îÝê|6þ|Ñ75áe +@ÞÊÛÙL² åÞë7ó\¦ÚDΟ?_�½�¿µók‚T÷1ɶáH«V­Ô¦IÀû´ªýS^ÒR¯êpœd»iÔ¨öýø»K—.Y·Þz«è'.9aç—`›KrÀèH½zõÔñ'$O¸*THôQM'€èjÙ9@²íDMÑ¢E­Å‹«M ª¥K—ZÅŠíÉCÙù-Aª*M–ž1c†Úä Þzë-Ñ?5á>�‰‰Ô5‹d›)\¸°µgϵ‰¤4îÜ7Ôþâ’· R^ ;Y$‹Ž¼òÊ+êØ’¤S§N¢�j²ŽÜµ±sžd»‰šfÍšYG�U›$€'‡¶š4i"Ú•‡ð6ÿE�Š&ÙDNœ8¡67H‚ãÇ�‹þé’ɶ MFF†Ú¼Àöî»ïŠþ%-RZk’ƒDï¾½råŠëÝ‘•*URÀ‹-mZ^ºHÝì\!Ùf¢fèСX† Œ—¯Ëç’ÐÜf;¤šÅ$Û‚#wÝu—ÚÌŒƒ1+„ /É®¶cM¤šŸÚÙC²-ˆ´nÝÚºvíšÚ´ÀÆ}ãî»ïýÆ%»ìü„ eM"9Ht„÷1ѶmÛDY®ÓÀdYYY®Š•t&'~šî*ɶânkóæÍS›!@�,\¸Ðëï±Ã7¶ô%HªŽ1�À{¤ªe¹1¸˜aP»vmѶ5ù3A*ãúWÛDÔ<ýôÓÖÕ«WÕæ�P|³Ù„ D{ô�áaT‰d]‹lÞ¼YmJÆÁ˜ÂhÓ¦M¢-»äŸ ÂîK’õ.2dȵ€�?þ¸èO.ùŒ eµ“ErPèÈ’%KÔñ¥Ö­['Êrcp1 Â`Ô¨Q¢mkòAªâ}RÕö5³fÍR›€¯f̘!Ú¥‡ô'›®$ëÙ‘bÅŠ…âfŒY!Œ¸o/^\´gM ³;IÖ¹H‘"EB±Ÿ;@2ð–=E‹ýÊ%M R/ŤExo>½ñÆ¢,7Ó ¾úê+Ѷ5ɶó‚Tó?$ÛBÔ,Z´HmbIÁ7Ÿ©í3�ðÒ’„ ïU§Ö³#={öT›Ž‘0f…°êÞ½»hÏš¼Ff«Iֹțo¾©6ˆÁäÉ“E¿rÉZ;…B¯/ÉÁ #�7VÇ•ÆÈk/A\Lƒ0à=Z *$Ú·&Ø8µt!�{­q .l}öÙgjóHªåË—{ýýv=¼·ž$ �m$ëØ‘¹sçªÍÆH³BXeddˆö¬ ö¯6$ë[¤ZµjÖ¥K—Ôæ1¸|ù²U½zuÑ¿\ò€Ð›Cr èÈÈ‘#Õq¥1Ê•+'Êscp1 ¢Y³f¢}kò8Aªà¥örH¶mxòwÕªUj³^7--M´Û(¹l§�én"7±ìÙ³Gm2F˜Âêûï¿íYîë?'~ÂpÉúÁMˆññé§ŸŠþå’õ„§€BïÉ� #K—.UÇ”F8~ü¸(‹\Lƒ°3fŒhßšÌ'Hµíd’¬mx¿µM›6©M P6lØë$ðY;5 LÖšd½:R¢D ëÚµkjs1ƬfÜG=ѧ-ÉziÞ¼¹Úl Ÿøwîí·ß.ú™Kî"­J$€"G�UÇ”Fðrç#.¦AXð2©jûÖä8á.ß°+ggɺ׆Ÿüå§+L°yóæH›UÛq”ðMnÿD`ª1$ëÔ‘N�:©ÍÄH³BØuèÐA´iMF„ÍF’õ,²víZµÉ@¬\¹Rô3—¬#­{I©\¹²:–4ÆØ±cEyÔàb„Å©S§DûvIe‚°*ig3É:׆÷UýòË/Õ¦h¼TyŒ{óÅç$0Ñ'$ëÓ‘7ÞxCm"F˜ÂnÒ¤I¢Mk²Œ Lþ“d‹´hÑBm.­ZµýÍ%¿'¥‘$ŽôîÝ[Gãî»ïåQÄ‹iW¯^µvîÜiÍž=Ûzúé§­Î�;[õë×�ì W¬X±/|ó²­¥K—¶jÖ¬iµoßÞÜzà�¬Æ�ªT‰”ýÆ'¿x)ÐR¥JYµjÕ²Ú´icõíÛך YÇ"ëׯW› Ä_WQû›K„ôÔÁŸ#Ó§OWÇ‘�±zõjq¾‰ÎþýûÕÓˆ‹ììlkÙ²eÖÃ?¹H¦¾o~“¢3gδNž<©¾] ìÞ½Ûš0a‚Õ´iSQ†‚¤Q£FÖË/¿l8p@}ËÐêÕ«—ø4JF£IÖµk–,Y¢6£,Z´H´ë<òg“”'Y‡"|㘠0f�žd�Y¿øâ q.ºðDµ|X} cq_UËé’2aPÝÎ5’õëßü‰s×]w‰~§ ÷Õj¡óÉÁŸ#kÖ¬QÇ��†‹i¼lï /¼yJB}¯x†Ÿäæ'4‚âÊ•+ÖâÅ‹­Ûn»Mœk"rß}÷y¾xg²×_]”]“ÙaÃOuçy‘ízø {€0à'Õö%ÜGî!0E ’uèHµjÕÔ&X³z�ŸcÖ¯¿þZ¼¿.GÚ·o¯ŽÅÄ‹iâ˜ÉÊèÑ£}› :vì˜uË-·ˆsHfæÌ™£ž¦ñøI�K3þ;Ad�¬[m7nl]¸pAm2¡À7ñ“Dj»�ž\„`ÛD²Þùø“OÔ¦X³,‰³ž8qB¼—.Ó§Ow¼ŽŸŠîСƒø9?²|ùrǹ˜dÉG‰òh²ŽÀd…ì|G²^)_¾¼•““£6Hîk+VýP“ï)·@»rÝW­ZUÇKL½�ÑãX½�‰øæ�l’õéÈc�=¦6 ð?©ýQîÃ7ï8ÉÁž#&-­§â'ÞÔòèR©R%õ¥qµyófñžy…—¸;xð z(Ï.^¼¹8æñÏÃONÅÓöíÛÅ{D ?5òÌ3ÏX™™™ê¡<ãÉbž8NOOÇ�–x^LL6¾è«–O“Ŧú3ÉúÔ¦OŸ>jóH ü” Ú¢dAÐü?’õ䯳&oÌš��-Ž­ËСC#7ºM^-ZÔzíµ×ò5éÎâñná'&øIˆx9zô¨Õ¤Iñ>ná wù¹hD/àn%0Q;WHÖ§/‘ζRO*Õ¯__ô —䨩G$ýIÖ“#¼ÝAX`Ìš¼1+C=¶. ˆÜ¤¨þwNÛ¶m­cÇŽ©‡ŽO¤—/_^ß-\\o¦ñøZ_­%Y—ŽÔ®][mà#�ßµ¼Ú¬&ÉAž#åÊ•SÇŠF ÂÅ4^ÚN}?·ðä·ß~«¢ÀøiZÞ;W}?·L™2E=D¾ìÝ»W;ZÖ®]«¢ÀxB·nݺâ½Üˆ†_„T˦É;…LRÌη$ëR„/ sHe<¡ÃS…_Snƒ`˜H²ŽáíÂcÖ\ɳzÝ“Öí‚ù�!C"OðÆËÎ�;cÙÇÜZºt©zˆÀ›0a‚(‡&ã LSÉÎ5’uéÈ[o½¥6 ðÑ´iÓD¿Ô„ûrEc5#9ÈsäÎ;ïTÇŠFIöÅ4¾ð¬¾W´ðù& ï3V±bEñžº)RÄ:{ö¬zˆ˜=÷ÜsâØnyê©§Ô—ÇM,ËPóÃaÀ"=NzüŒÀ$ÃIÖ¡6sçÎU›@Jš9s¦èQò$APÌ%Y?Ž,\¸P­ncaÌú¿ü³æääˆãz ïcê¶ÇoAŒ7N¼—[xijÓÌŸ?_”C“9¦@²á› Úg `Μ9ãõF£ÇŒõß$xŽôèÑC+%ÙÓ<.o‰O±x\8’xì‡[«V-q\·ÄcÙ¼hzöì)ÞÓ-‰>¿p»V˦ISüÆN6É:éÚµ«ÚROÎtêÔIô—p«FŸ“¬GV¯^­V·±0fuòsÌšß à¦M›F^›§N�òzqÞjР�úòÀ[¹r¥(‡&Ÿ˜f#Ézt¤K—.js´ï¿ÿ^”A^À$÷ß¿hÇšð’î`¨GHðyâ‰'Ôq¢Q’y1íðáÃâ}ÜÒ¼yó¸.ÍÀ�ÅûëR¶lÙ]Ô:~ü¸8¦[ø��D[¿~½x_·Äs?»dâý_Õ²iò;¶³‚dý‰ÜtÓM‘ Çð¿ø‰ÂÒ¥K‹þâžxÄòøÉ—çr÷;vìP«ÚX³J~�Y¯\¹"Ž™Wx••¨‡Š+ž(SßWž(ö«NâeëÖ­¢šl!0É¿�¬C‘Å,V›C aÂjÁ‚¢»7Œêi’ƒ;GÆ�¯Ž�’Ì‹i±,ÝöÝwß©/O˜#GŽx~¢àãO>Q_îÙòåËÅñÜÂ{Ð$?V²dIñÞºŒ9R}¹‘Ú·o/ʦIKô&YwÚðS5 }ñÅ¢¿DI‚d;B²^9zô¨ZÍÆÂ˜UòkÌšŸ à^xA=LÜÍš5K¼¯[öï߯¾<Ð:$Ê ÉA“ &Y‡Žð�YYYjs4LCX�;wÎëw,/íz™äàΑéÓ§«ãD£$ëbO6V®\Y¼�.÷Üs�úò„ëÕ«—8]xÙäüºté’µoß¾H,[¶ÌÊÈȰ&L˜yªüÁŒì/]­Z5+--ÍÚ¸q£úò„ðú$Ž÷Þ«¾ÔH½{÷eÓ¤AЕ¶sšd݉ð^„àî‘GýÆ%§ìü‚ Yø ìË$ëÅ‘ììlµŠ�…1«žcÖX'€yË�=L·oß.ÞÛ-«V­R_h< ¨–A^’Ì‘çJ5ü7 i0 aæñúÈgFÊ 9¸sdÑ¢EêÑ(ɺ˜˾e_~ù¥úò„[·n�8]xr–'rŸhW˨KíÚµÕ—iøðá¢lšR›hðÍmjÿq ÷½šÉP‰d}8R¡Bµj�†1«žcÖX'€yRÚ/ÜÎÕ÷×ÅÄU›Ê•+'ʡɯ LÐŽdÝ9ÂKÍš¶ü3Ã0„÷I�Ë@ßC`œ/Hìá‹Q&KÖÅ´&Mšˆ÷Ð¥oß¾êK}ãuLÓ÷�¾ÑŠ+Dùtá?‚€÷VV˦ÉX‚ [J²ÎD:vì¨V?DÑ©S'Ñ�\ò1A2T#YŽÔ©SG­V£aÌê.ÑcÖX&€ùIcþy¿4nÜXœƒ.¯½öšúÒÀ«Y³¦(‡&7˜`*ɺs$KÈÇ&€!ìîºë.Ñž5y�À8«IìùöÛoÕñ¡Q’q1ÍãžV‘¼ÿþûêË}3kÖ,q>ºÜ}÷ÝêK�åµ=pø‰Ó͘1C”K“‰Au7Éúá‹ÑG�U«¢8tè�×åG9ÿEà·Ú$ëÁ‘† ªÕj4¯cŒYÝ“ß1k,À÷ÝwŸúò„êСƒ8]^zé%õ¥�W¿~}QM° CðñžíIÖ�#|cª‰0 a7eÊÑž5ÙO`œ�$vŽìرC%Ó6nÜ(Žï–ƒª/÷ÍÎ�;Åùè“KÉXò/þö·¿‰ò¹Åϧ;eΜ9¢\šànÞ`*bç;’õ%2iÒ$µêÀž0Qû“K¶ÚI#ðS=’õàHÓ¦MÕ*5Ƭî=f�e˜—ÌöÓƒ>(ÎA¿Ï+<>Ý| AÐÕ"Yo"»víR›€0 açõ;Öοåo$uŽìÞ½[%Ó222Äñuá'�’éòåËâœÜ–§ ·nÝ*Êæ–0L{ÜóøM‚ êJ²®DxoÀ‹/ªUdgg[+VýÊ%÷øé?HÖ�#-[¶T«Ôh³ºKô˜5– à… ª/O¨^½z‰sÐåùçŸW_xwÞy§(‡&·Ý#$ëÍÞCÜTü;åá‡Î3�?þ¸úRcxÜoþa£ì$9¨sdÿþýêØÐ(ɸ˜6tèPq|]ªV­ª¾ÔwµjÕç¥ËêÕ«Õ—)Õ&€—,Y"Ê¥ÉL‚ )jgɺY¶l™ZíƒÅ‹‹~å3ñ“ùà�ÿ$YŽ˜ºŸ¤ŒY£Kä˜5– àuëÖ©/O¨G}Tœƒ.&N{Üwòv‚ Ë YoŽ }º(—&/Ac’u#Ò¯_?µš Žø {µß¹Ë’&VM’Ÿ¹#uëÖU«Ïh³Æ'ù³b89êÔ©#Ê¡I5‚ ªB²¾D._¾¬V=P +ƒT$0§$sެ^½ZÅï‹i'NœÇCªV­ªÕ3¾Pæñÿ@% Ào¼ñ†(—&ÏÁ_IÖ�?¡‰Ã^À éÿ’ü̹ùæ›Õê3ƬñI~Ƭ˜N®+µšü APÝC²¾©_¿¾Zí`oÌiM`„÷IæYòÑGê˜Ð(~_L;{ö¬8vR¾|yµ¨ždeeÅeò·B… Ö½÷Þk=þøãÖ‹/¾h½óÎ;‘ým?øðCkùgË­•+WZkÖ¬±6lØ`mÙ²%²|Ñwß}gíÞ½ÛÚ·oŸuèÐ!ëØ±cÖªU«Ä±Ý† à1cƈri2˜ Ùn¶s�dÝ8Ò¡CµŠ Ú¶m+úŸ&WíT%H”2$?sGŠ+¦V�Ñ0f�Oò3fÅpr)RD”C“_Õp’õåHŸ>}Ôj€{øá‡E?Öä #L!9˜sdæÌ™ê˜Ð(~_L»pá‚8¶[ø=[·nmDºwï®Õ“®]»Šr{I™2e¬§žzÊZ±b…uîÜ9õ°ùæq_›HÂ0Ü¿Q.Mº$ÛX’õ"²víZµŠ ø¦"µÿ¹+($NòpcLNNŽZ}ÆÂ˜5>ÉϘÀþ㾫–A¾Ñ†÷™…`šG²Î™2eŠZõ`“&MýX“ #Œ!9˜sä•W^QÇ„FñûbÚÕ«WűÝÂO²†Ù‚ D™ó ×ÃÂ… vA7Õ&€;uê$Ê¥ /ßÉSÌÎQ’õâ/¡wíÚ5µŠ A<®^qÄNQ‚D9Mò3wäÔ©SjÕ cÖäÁ°ÿ<.A~œ ȶ‘¬3Gøf^H¾;vX3f̰ž|òI«U«V‘-ÒÓÓ­Â… Gê‰ÿ·D‰‘åÛµkg 6,²ÚÖÞ½{ÕC)ÕË‹Ï>ûLôcM¾!0Âã$sŽŒ1BÅï‹i,--M_^ž7¬.^¼hÝtÓM¢ÌÑòÄODžFI¤T›nÖ¬™(—&¿'H¦{IÖ‰ÈâÅ‹Õꀚ?¾è‡.ù‰²“äçíoõ³&&€ýçq¿õíAUÈÎ’uæÈÑ£GÕª7Î÷ß/Ê¥KÑ¢EÕ—Æä‹/¾ÇÔ…¿+¼àÉK^Q‹·RR�K*W®¹ èðáÃê[ÄUª—?(xë,õ3Ðä<€º�Ì9òÈ#�¨cB£$ãbßQª_¾5¬¦M›&Ê-S§NU‘©6\½zuQ.Mª$ÓÇ$ëÄ‘’%KZ—.]R«èòåËV©R¥DÔäC‚DYMòóvdãÆ�jÕ cÖäÁ°ÿÖ­['Ê É ‚ úÉúr„Ÿª Ãê5~Mýõ×☺ðDi4<ñÉK᫯‹G† ’°•7R½üAÁ«ƒ*TH”]“Ò�ךä@ΑöíÛ«cB£$ãbZóæÍÅñuÉÏe¦¨Y³¦(¯[ü|ÊÜk{à„a¸X±b¢\šðHŽJ”»¿�Z'Ž<ûì³jÕ€F�%ú£&܇M�%ùy;òñ'Ÿ¨Õf,¯cŒYãÀþ[¶l™(ƒ& ‚ªÉúr¤V­Zjµɯ à]»v‰cê²dÉõ¥<ÙÎË__Ö8QᥓW®\©¾}�¥zùƒÄãÍau ïw$rŽ˜þ‡[2.¦y½ã´eË–êKC!–§lù� ?Ÿl\þÙrqn1}øüùó¢LšðÄö¯Lž$ëD$÷Ÿ^^Xí�.yŠ ¦’ü¬yóÍ7Õj3ƬɃ `ÿy\-h APý7Éúrä¾ûîS«ÝH~MÌwG«eÒd:A²Œ&YŽð…$?—HH�{‰YëÖ­S_št/^Œ\àSÏU^Òâë÷$?gGêׯ¯V™±0fMLû¯víÚ¢ š4$ªù$ëË·½ZMã×pæ¹LqL]Æ�÷ãk&Nœ(þÝ-¼²[åÊ•­[n¹%²ÍWÅŠ½Žo¢¦k×®7”"ÿR½üAâñæ°÷Œ°„ä`Α>üP#Óø"’×?&^ýuõåFã»p *$ʩ˄ Ô—'Ô¾}ûÄ9D‹éÀ¼,¦Z&M†$Ë÷$ëÑþýû«Õ `$“'€Ù#�<"ÎUîÓ_¿&ù9;Âã­°À˜5y0ì/þ{�'cÔ2hRž ¨V’¬/G‚ú�+¿&€y•õ˜ºŒ5*òó+W®ÿvcøw{¿~ý"ñü”ªÛ¶^§OŸŽÔÕøñã­ªU«ŠãxÉÖ­[ÕÃÆ,ÕË$kÖ¬eÔ+‰‚—nQsŽLš4I#ÓØ½÷Þ+ÞC—°íïéõ7/«é§Xîæ˜>Ü©S'Q&Mº$C=’u!²bÅ µZŒdúðòÏ<-©Ï©COiv.“üœá‹·a€1kò`Ø_÷ú̶S˜ ¨ò\EŒ'NÃÀ¯ `ÞI=¦.C‡µ.\¸`U¨PAüÛõóà%|ó³lÿÕ«W­E‹YÅŠÇ�–Þ½{«‡ŠYª—?H<®ò7# $9˜s¤gÏžê˜Ðɺ˜öÆoˆ÷p ïw|QL-Ÿ[>ýôSõå ÃP–.]ZœC´˜>ìñæÆÉ0†d]8Â._¾¬V+€‘LŸæ¥Ø=^ Io»H~ÎF´›XaÌš<˜ö×êÕ«Åùk²ƒ ÈN�¬3Gx¢? ü𿱆zL] `=óÌ3â¿sÚ¶mk;vL=tÌâmذa¢<šŒ#H†y$ëÂ~ LÂ0Ì:wî,ÎY“w â©:ÉÏØk%ã©ÔxØU/‘cÖë0ìî«ÛÚÍA•çïåºuëªUo,¿&€cù=t=Ü—8 *®ºté"ÞWž(-Èwqª—?hj×®-ʨÉoŒð*ÉÁœ#£F�RÇ„FøöÛoEYÜrñâEõå6xð`ñ>néÓ§�úò„ñºœß¡«öíÛ‹ãèÂëù”í—_~)Þ3–>|X=¤1Z´h!Ê£I¿±s†d]82gεJ�ÄrÜ.ò&b S®°LgddˆsÖä¤�4‚x)j'›äçìÈÞ½{Õê2Ƭz‰³^ËÄ&€ f÷îÝâÜ5¹Hø=duHÖ™#é�Aä à^xA=LÜy\Y*’ýû÷«/÷,ÕË4üD³Z>Mþ�À“Ì9ÂKë˜hÏž=¢,nÙ¶m›úòã;R½.Ä™6mšzˆ¸ã½ÓÔ÷uË’�>R_ž'þCL=Ž[øbz",[¶L;ñÔ­[7«jÕªâ¿ëÂK šˆ'Õue×äß üö$ëAäèÑ£jµ‰§ªe»L§–°L:tHœ³KêÄÓ&’Ÿ±#ÉX–8Þ0f•=f½.–‰L ß䦞»&ë ‚Œ¿ãÔ:säŽ;îP«ÞXA�æ¿÷ò»êA,x+0õ½Ý²jÕ*õ垥zùƒ†·×P˧I]#äùG_ÊÎÎVÇ…�wâÄ Q·¼þúëêËã‚÷'Sß+ZxÙâDñøôR$|Á>?Ëñ|õXn)_¾¼uæÌõùÆç;eÊñ>Þ»ñرcÖ=÷Ü#þM—DMN'Ú–-[DY4¹dç§~B². Ó’yÑ~ß`8µ„e˜Õ¬YSœ·&ý âi*ÉÏØ‘¨UeŒY�¢}‡¨Éï˜õºX&0\0ýû÷ç®Éd‚ »�d�9ÒªU+µê�Ô à�ª‡H¾¹X}o·,^¼X}¹g©^þ ñ¸¢ßÜ àåõ²HèÙ°aƒ:. ¼XìUªTIȒļL_ÅŠÅûEËðáÃãz.YYY^/¸ü˜+V¨‡ñ„ÿx+Q¢„8ž[î¾û•—\jÙ²¥8þõ¼óÎ;‘Ÿãe ÕÓ¥gÏžÊ;˜á/ù‹(‹&_$Ã"’uáȘ1cÔ*5Öc�=&Êw=˜N-aš9r¤8oMÞ#ˆ§n$?cGjÕª¥V•q0fÍåç˜õºX&0\0ÕªUç®Ig‚ kJ²ÎiÛ¶­ZõÆ ê°Ÿc¦ *ˆ÷×eúôéêK=Kõò�Ç›æ›ãs’:Gõ´A¢ñujYÜòÈ#�Dþøˆ·Í›7‹÷Ê+ü”/e|íÚ5õpžåääDžÎHOOÇ�–!C†¨‡ŠÉ3Ï<#Ž-¼ÄP~—½å%  $Žycîºë®?Ç—^zIü».¼¬Tæ¹LåÝ‚¯K—.¢,š^–·ãsþòË/­ÇÜ*^¼¸8^^á õ\æ‚8|øpLûÈqx•Ÿ"á?²£]@äÛ·oŸõî»ïZmÚ´ÇQÃyYÅëø³QÆ-üš„?›R¥J‰rhr�ßþ•d=ˆÄsIôdâ'¸Ô²ÝL§–0Mów¬zÞ.ÁFüðdú1’Ÿ±#Ë?[®V—q0f•ÇŒ–xŒYY,˜Î¿�?ùDœ·&Ü×qM°aX?'€ï»ï>õå åq20ò�“_©^þ ñXfLä¿Hè)V¬˜¯wZÆ ïÅ¢–%Ù³g�úVyzöÙgÅqbI‘"E¬Ûn»ÍêÞ½{dyÕ'žxÂêׯŸÕµk×ÈdhéÒ¥ÅkbIÙ²e#K)Ç/e«ßkx³]»v‘e˜ùÉ.#?ÙzÇwDîøUÞ-< ÍO²ÜèìÙ³â碥G�ÖîÝ»Ç`Ç�WÿSÒñyªç¯É5;¿$ðÛC$ë‘ڵk«Uj¬¼öÇpj Ó0ãI.õÜ5éBO I~ÆŽŒ=Z­*ã`Ìê=ñ³Æ2ñ€ àü1b„8oMÞ':,­‰ŸÀ¼_»Ÿ|ðAqºä¼R½üAƒ% Âçgv®’Ô9¢N¦™€÷%ãÉkµ,M~.¦1¾@©+)S¦ŒõÃ?¨§›oÙÙÙ‘'SÔ÷ñ3ü¤�N«V­ÄÏæ•›nºÉªW¯^¤Lü>ÿ· ™6mš8oMv$Ã4’uáÈ€Ô*5Öo¼!Êwc0œZÂ6ìqÒÉñ4ˆägìÈ-·Ü¢V•q0fõ–x�Yc™xÀpþñRáêykÒŸ èn#YoŽðßZaÄ à… ª/O¨^½z‰sÐ¥ ¿‡R½üAÓ¢E Q>MþƒÀ(ß’Ô9òâ‹/ªcC#ô)]ò{1�eddļDr"Ã{ÎÅóBÚuüDêõÉR?ß-ïEç†ÿhT_“Ÿ ?5­ž£&!H†-$ë‘ùóç«Uj¬nݺ‰òÝL§–°MÏ�;Wœ»&â©!ÉÏXäÈ‘#jucÖèIĘ5–‰LçÏÁƒÅ9»¤>AÐýŽd½9Ò´iSµ +ˆÀ~�—üø=”êåš&MšˆòiR—À(/�Ô9ÂK¹™ˆ÷ózÚk r1�}ýõ×IB–Ã˱ñ'‰²iÓ&_'�ù©�¼žTçòz\Â3j‚„Û¸Ç ´í üVÔN6ɺp¤ ¿S‚¤\¹r¢|7À©Åë÷¯ßôòk×®]âÜ5¹h'� ^ŠØ9MòsvdöìÙjucV÷$jÌËÄ&€ógæÌ™âœ59Eø½i‚'YwŽ4lØPmÆ â°n{¢Dòã÷Pª—?h4h ʧɿå’ƒ:‘'N¨ãC#ðū… ‹òä7½˜Æ._¾l½úê«q=/¯á;³×®]«žRBìܹÓë²o ï×{úôiõíµÖ¯_/^k‚äóÏ?ç§É;?'ð[M’uáÿ¸zõªZ­Fâý±Õò©Ápjñ:™eÊ0÷U�ßÛ¿!ˆ§¹$?cGxõ�0À˜Õ™D�Yc™xÀpþtêÔIœ³&3 LP�dÝ9†%ù¯ âð¡C‡Ô—'”¿‡R½üAS»vmQ>M0Î0ÌOìœ#9°sdÁ‚êøÐ[·nµÒÓÓE™ò“x\L»îÌ™3Öĉ­R¥J‰÷‰wî¾ûnkÍš5ê)$ÜÅ‹­±cÇ&äÂaÇŽ­�7ªo™§E‹‰cÅ’ 6l˜8?MV$CG’uáH˜–ÊûôÓOEùÔ`8µ„m˜5jÔHœ¿&Xq!¾º’üŒIKK‹LT†Ƭþ�Yc™xÀpì¸OrßTÏY“Î&¨B²î©\¹²Ú ŒÄ `¿oÈ÷ã÷Pª—?h*V¬(ʧI%ã,"9°sÄô§+øÂÍÓO?íu¹\mxIœcÇŽ©‡.0¾@òÅ_DþÈ(Y²¤xßü„ËÉ{þóÎ;¾ß-«ÃON˜0!ra@=×XÂKŽ=:òtqAðıÇ=nD‚âÚµk^ÿHJ� cHÖ…#ƒ R«ÕX|£‡Z>5˜N-aœîß¿¿8MFÄÓ¯ì\#ù9;²råJµºŒ…1«?b™xÀpì¸�¨ç« ÷mîã|¿ YŽð„Xq8Œ¿‡R½üAãñ¦ýRÆù’;Gx0xîÜ9uŒhþÃaþüùVŸ>}¬úõë[eË–ýñ—‘Ÿº¨W¯žÕ¾}{kðàÁÖ[o½y áüùóê¡æ‡~°.\h�3ÆêÞ½»uûí·[*T°Š/þã œÿ·téÒVµjÕ¬[o½5ò4,OŒÎ›7ÏÚ²e‹•••¥60øêwß}×0`€Õ¢E «J•*V‘"E~lkü‡tùòå­:uêDÊÅŸ×ÙÞ½{ÕCO<ðmÍ›7�LN]R�?k>/¾€ÊçðÜsÏEžþî»ïÔC$ ïy¬öS—ÜB� �¬ GøbwXð[jùÔq˜—õåJxQžpéܹsä»�÷3.V¬Ø�ßü;Šçò²ö׿¦N�Ùï<{S&ÒÑ£G#¿Syžtá‰ZžÈ¹þýÂåæßÁuëÖµºví™Ü_¼xqÌÞÂ8}Z­ÚÀ Ãð©S§¬^x!2Q¤¾W<Ó»wï/S/.\ˆ,¿ïqI7ÏáÕFŽue�0Nóþsêùk’M¸ñ&Þx% õsùüóÏÕ*€$X¾|¹èŸ.©M`’m$ëБ Œ ÀþL€¦zùƒdûöí¢lš|K`¬$xŽð²sü$O˜¨ýÓ%� ’¡"ɺpÄ´}ÒLžæ'‘1 šWzöìéûŪñòÔ¼”³z^ñ ÅŠê[G„q˜÷^÷ØŽÊÄÛN’Ÿ³#}ûöU« ’ W¯^¢jò=�iV�¬GGLúN�ÀþL€¦zùƒÄãßzŸ‹—�¾Fr�çÈû￯ŽÀgO>ù¤è›š¦Ü'QÁ�IÖ‡#¼Ÿ£I<^ˆkâ1Ì{eW¯^]Û¯ðÓÆÉØ>aúôéâ\™7ÞxC=«F�âçt1íbqíÚµE4iHoÏ�üœá%Ýy‰wHž‹/FnrSû§&# LÃ{Ü«õèÈ’%KÔ&a$Lû3šêå’Å,eÓä=£­!9Ès¤E‹êX|Ä·=.a;� Y:“¬Gºté¢Vm ™8œ‘‘!Ž™¬Œ=:²t»^yåñþ~äÙgŸuœ‡×ý†M›îСƒ(ƒ& â�—ŠU?g‘dÜpÿkþüù¢_º¤�i^'Y�ŽLžºóÎ;E¿Ô„÷’ó !Y—Ž<ñÄj“0&€ý™MõòÉÀ�EÙ4ámãÀ`ÿlç*É�ž#Ï<óŒ:^Ÿ´lÙRôIMöÙ)L�,SIÖ‰#¼D¯ILš~î¹çı¼†÷ºoÚ´id/Q¾ ÃOÓ¾üòË‘ÿ߯_¿Hÿó¸´¥6ýû÷�ì#›7nïK6lhõîÝ;òùq¹'Nœ™HïÓ§�Õ¬Y3¯{ßZ6lˆœOãÆ�Å¿ébÚðÔ©SE4™B�O“ü¬EöìÙ£Vø€ûžÚ]2ŒÀDHÖ¥#÷ÝwŸÚ,Œ„ `&@S½üAÒ¶m[Q6MÚï#’=GJ”(a]ºtI3@‚íÝ»WôG—ü™ ™þJ²Nùø“OÔê 4¾èÂK«ê2~üxQ>]J–,)^-YYYêi䉟@Uß×Kzõêe­X±ÂÓw?Åûõ×_[cÆŒ±Š/.Ž•W&Mš¤²À.\¸`U¬XQ¼W^¹í¶Û"Ëufffª‡._¾l­_¿Þ4hPÔIðJ•*E–ªçIcõßt1mxéÒ¥¢ š, H„JäáFEî›à¿Q£F‰þ¨ ÷á_˜¨Éút¤fÍšj³0&€ý™MõòÉÍ7ß,ʦI]ãµ%9ÐY²d‰:f€ã}DÕ¾¨É5;ÿB�Lyî§¾iÓ&µz�ÅeQ˧ O &ÒæÍ›Å{æ^–ùàÁƒê¡<»xñbdB×ëӱ׳eËõPÂ^ê{D O\/\¸P=ŒggΜ±žzê)qÜëá›x)^õ¿ëbÚ0Ÿ¯ZMV$Ê2’Ÿ·#|³‰—›9 ~ø&¡R¥J‰þ¨É‡¦úÉút„ÇC‰ZéÄO˜ög4ÕË|s+¯‚¤–M“›ŒWÔÎa’ƒ=GZ´h¡Ž �x¢‰Ÿ¾Wû¢&ü?$×÷$ëÅ‘0-Q„ `~¶B… â=ÝÂ(ãy#×D´'m IDATg�:uÄû¸…ï²�×ÕáÇÅñ£¥zõêÖ�ÔÃäËš5k"“mê{ðç[¥Jñßu1mØãEáí‰Òžäç-R�àÿ³w'pRwÿÇ;&Á1‘À» 1jÔDñA#Q£ã��g”x úODŒ|¼ðŠH¢QñegYA‚!Å äðGăx€rÉ}-Gÿ«f4që×=Çî\=óy¿^ß—¾€®éž®š©éê®Êž�MÂm‡ù_AXU™l}NeÉ’%nõ� ¿ë–æ €Vúñ—ŠÅ‹«ãòÉZ@Ù¸Mt‡OÅN�  0²˜ÚÖ>Å�âZ%ú¼4ÊÊ•+ÝSZ¥0œÍ°öbÝœ9sÜ"šÍN£|Øa‡©× ʃ>èÑ$×\s�*;(íÚµó>ûì3·ˆf±I[·n­^+Ó„mxùòåê|²\�/;˜,ýž7J·nÝÊâ)4 ,ºwï®Ú¡O>“äÍÆ¯y¢Ïk£Ø%5ÂŽà €Vúñ—ŠçŸ^—OÞ” ;}¬�FÖíô5JŸ>}ܾ#€<°Ó2e¸.Ï2“¯ ŠÉ^ØLûùiÏi¹(ö°}úÖ}­TÉçôÛv`?Óµx[´há­^½Ú-"+v*æ §lKdÖ¬Yn9ñú믫×Ê4aÞ²e‹:ŸØ5.£‚|¹]ô{®òÊ+¯¸§@L›6Mµ¿€Ü*»'DŸ×F>|¸[EB‡à €Vúñ—Š{ï½W—OjeåYÑ�>•\M# ØäÉ“UÛ ˆ}zÅÕVôyi»þj9)öð¥—^ª^+(÷Üs�»yÎe³±]?¸9²˜À»üòËÝÍsêî»ïV¯™IÂ6lÙÁ{÷8|òA¾|_’ƒìî{Þ(§žzª{êäÁI'�¤ÚŸOl›Ý]v׉>·�rÉ%—¸U$t.Ìh¥©èÛ·¯:.Ÿ ”•_Šîô© <Øí?ȱ#�€2Re2_tǯQ"‘HY­e ”;8â¶»€<&({‰>7�Rƾª˜ÀwÞy§z� r È®³›éÔÌöYwóŒØ×pË Ê¹çžënžÙ<‘üeÂ8œá…²òég¢ßs•~ýú¹§@ýæ7¿Qí. ‡Ê�½¹É=·* nU € 3ZéÇ_ ì œî1„ÍÊТ;~*C† qû‘rä¸ãŽSm. û JÁ�EŸ›F±Om–“b oß¾Ýëܹ³z¿œxâ‰îæy—éEñ‹/¾ØÝ4#O<ñ„*+(S§Nu7Ï‹�7zÑhT½~ª„qø ƒRÇá“}ù6Côû®b× �{ï¿ÿ¾jo™%(ö&ñ5¢Ïq£ÌŸ?ß­.¡Âpa@+ýøKÁ¼yóÔ1ùd•$Û>ÊÌ×L>ÝlûðÒ¥Kݾ$€fš>}ºjo™,(ÝEŸŸFùÅ/~ážêP+Öp6kí¾ôÒKîæy—éÓûvÀÔÞ}Ÿ­ /¼P•åûmf ¥ÿþjR%ŒÀ‡~¸:Ÿ È·ÓD¿ï*M½É@j\p�jo9IPN¦‹>Ç�bg 3€ 3ZéÇ_ FŒ¡ŽÉ'/ ÊÖu¢;€*×_½Û—ÐL®ýkó A©ø™èóÓ(ö©îrR¬à;î¸C½†_:tè�xZ¸ÐìzÃ;Ú¿Ø›=²Õ¶m[UŽ_N9åwÓ¼²O»û�*a>úè£Õqøä§‚|‹š¼/ú½W)äð@%Èbíß$ÙVQ>†‰>Ï�rÑE¹U&T.Ìh¥)èÓ§�:&Ÿ ”­ïHÓ<Ù'Œ–,Yâö'4Ñ /¼ ÚY@^¦d*%GŠ>G�ròÉ'»§;ÔŠ5œáS˜‰'R‹å’K.Qûã—¡C‡º›¦d/‚¹eeذaîæyµvíZµ©Æ`;¥¸{>ù¹ ~#ú½W9ÿüóÝÓ Î:ë,ÕÎÒWPnN}ž¥}ûön• € 3ZéÇ_ 2¼¡”YÊÜ ¢;�*ƒ rû“šÀ>9xðÁ«6�_ JÉ1¢ÏQ£œyæ™î)µb ¯_¿^•”§žzÊݼ`F�­öÇ/½zõr7MÉNií–”_|ÑÝ<ï9äµA ãðé§Ÿ®ŽÃ'G Á.Wò‘è÷_Å®s ùÞ~ûmÕ¾²ÐdA¹é ú\«|öÙgnÕ € 3ZéÇ_lŸ|ò‰:ž€Ø6€2ÖÚäsÑA;€æ;v¬j[™-<ý[jì �{žÅ•“b ¿þúëªü ,Z´Èݼ`2�"Ó®œÍ4Õ?ü°*#(Å8þ+®¸BíGPÂ8|ê©§ªãðÉQ‚Bé#úýW9ꨣ²jg4Û†zöì©ÚW@Δ«´7Þ<ó׿ºÕ'4.Ìh¥±�?^�OìR¨­| '¸ýJY°O6f8“ kÿ–;í«{žÅN[NŠ1\WW§Ê÷‹]ž ˜Ô>%›e®¹æµ½_ìñÛ mĈj_‚Æàã�?^‡OJKÉp-àI“&¹§@&L˜ ÚU@Þ5i!(WqÑç¼Q®ºê*·ú„À…­ôã/¶ oØ|LP¾i²Tt‡PeêÔ©nß@†n»í6Õ¦ò² "ú\5ÊÑGížöP+Æðµ×^«Ê÷Ëî»ïînZpûì³�Ú/¿Ì˜1ÃÝ4Ði§�¦¶÷Ë^{íånZSŸŸªö%(a¶O’ºÇá“î‚B:Gô9PéСƒ·qãF÷”È€m;�:uRí* g ÊYÚõ×íçmX1\˜ÐJ?þb²³9´k×N�Oì,+¨EwUºvíêmÞ¼ÙícHîÃSUU¥ÚT@”¢EŸ«F9üðÃÝSjÅÎt´Û3Ý×ÚÚZwÓ@ûí·ŸÚÞ/Çwœ»iA,X°@íKPÂ8Ü£Gu>ù‰ �쓆ïˆ>*÷Üs�{Jd`èСª=džITPÎ~$ú¼«Ø�Ô0b¸0 •~üÅ”E_ý‡€Šñ “‰îª<ðÀn@çœsŽjKyZPªö}¾å ƒrO}¨c¸{÷îª|¿ôíÛ×Ý´àìˆî~ùåŽ;îp7 Ôºukµ½_.¾øbwÓ‚X¹r¥Ú— „q¸[·nê8|²— ÐN}TìÔèÙL¹Àó/^œh;n{ È/å®ÊäÑç¾Q~øa·*…À…­ôã/&{½Î=Ÿ|,¨8§ˆîªØ ü±ÛÏà…^Pí( ›M¾/(Uö.i÷œ5ʾûîëžþP+Æp¦ aÊ€Üà än”ßýîwͽ0Û©µÝãðÉî‚bø›ès¡rÆg¸§@ ½{÷Ví( S•â!Ñç¿QzõêåV¥P`¸0 •~üÅdg r�Å'÷ *Ò ¢;‡*Ç|bm©­[·Îkß¾½jC¹MPÊv}Î%Ìk¢ù)ô°ý^qË.‡Ø‹ë™°Ÿî¶A±kŠK4Uûã—0g¸fZLP ö&œ-¢Ï‡Ê¤I“ÜS ÀÇÓO?­ÚO@lÛcöƒÊq’è:Ð(vi›õë×»Uªä1\˜ÐJ?þbÉâ·D/@EÚÏd«è¢ÊرcÝþ&Ç AƒTÛ È“�¥Ìž÷¼5Š�!¡œzØ®1ï–]9òÈ#ÝCõµlÙ2µmPйÖi¦Oi‡q8õÚ[ Šå^ÑçCe§�vJLW ØêÕ«3þ<7*¨$¶ÏÛ º4Ê3ý«[­JÀ…­ôã/– &¨ãð‰mÛ\w¨`Šî$ªØEK—.uûœ¾ðÚk¯©v“"ç ÂÀNÓíž»FÙ°aƒ[B«ÐÀkÖ®Qe—C2]úÓO?UÛÅ®ïU,öIwwü¶à ŸšØ (¦o›,}^Tì^Á.¹äÕn²Âä;‚Jó’èºÐ(çž{®[­JÀ…­ôã/– §ôŸ*¨hm%ùCßí(ªœwÞynŸ€±qãÆL×’´™i„Á§¢Ï_£|öÙgnu­BÛ @nÙå�L׆þè£�Ô¶Ayä‘GÜÍ ¦K—.jü¶àO>ùDƒO>Ûÿ}^|3mÚ4÷40^|ñEÕ^RäBA%(º.4Š�ùÆÞ<& f´Ò�¿ì�´Îd3@PñÎÝQô SAÚÕW_­ÚJ@ìšjvêu„Ã?DŸÃF™?¾[B«ÐÀË—/We—Cvß}w÷P}}ðÁjÛ Œ1Âݼ`ìùv÷Ç/až;w®:Ÿ¼!(6{ÃTÚ'Ólìô¶…¾° ”:;=z›6mT{ È &U‚JÔÙd»è:Ñ(vé0a¸0 •~üÅ0nÜ8u ùž âÙú“EwUì�¿~ø¡Ûÿ*ÖË/¿¬ÚIŠÜ(“çEŸÃFyå•WÜ*Z…¶ëºe—Cì”É™X¸p¡Ú6(?ü°»yÁ´k×Ní�_Â6œág÷ߥ «ÉFÑçGåôÓO÷¶oßîžn "Ù¶pÖYg©v�M&{*ÙlÑõ¢Q씳aÂpa@+ýø‹á´ÓNSÇà“|¡“É:Ñ�F•=zx[¶lqû @ÅùüóϽ]wÝUµ‘€,0ùº Lž}eüøñnµ­BÛõ“ݲƒb_ó¸ãŽ E.¼ðB÷P}e8q"Å\xç�wVûã—° ?õÔSê|2ZP*‹>?¾=z´{º�Š4jÔ(Õ>Räw‚Jg§ŠuëE£Ø)gW­ZåVµ’Åpa@+ýø ÍÎìàî@.à+Òþèû2C‡uû¡@E±OUœqƪmÄN©ösAØüQô¹l”?ýéOnÕ­BoÛ¶M•”»îºËÝ<ô–,Y¢Ž3(÷Üs�»yÁd¸¾Xè€m?Æ=ŸÜ%(;˜Ì}ŽTìl5ö { ’Ùel[pÛG@Þ6i)¨t5’Á4Ð�>ú¨[Ý��Èm—>±m9&ÀW´0™%ºóè›™3gº}Q b >\µ‰¹OF—‹>—�2`À·j„V¡€­h4ªÊ÷Ë�!CÜMCÏÎ àgPŠuü›6mRû”° ÷ïß_ƒOú JÉ›l}žTºwïîmܸÑ=í@E°3ltëÖMµ‹€Ø6ÕM€$;e¬[GeŸ}öaª} ˆöÝw_Õ.}ò²>¾g²FtR¥U«VÞ¢E‹Üþ(PöfÍš¥ÚCмk²“ ŒþWôùl”N8Á­¡UŒà®]»ªòýrÍ5׸›†^CCƒ:Π 4Èݼ –-[¦ö%(a>úè£Õ1øä8A©,ú<ùæ·¿ý­{Ú�Šp饗ªö�"× ðöÆ'·Ž¨¼ùæ›nµP¯¿þºj�ù�.Ý�ôÍÁœxB¨vÚÖL×Ä4i09PV?}N¥K—.n ­b uÔQª|¿dº®nØd:=gŸ>}ÜM bΜ9j_‚¶àöíÛ«cðÉÞ‚Rcg«™.ú\ù¦¶¶Ö=õ@Y1b„j)bŸ‹ ðß1Ù(º®4Êå—_îV=�á 6¶ [€F‹îHúÆvB�J°yófï°ÃSm E ÂÌþpvÏ©ÊÖ­[ݪJŶ»nù~9æ˜cÜMËB,SÇê—bÿ”)SÔ¾%LÀY<}Íì ¥i7“բϗoìÓ2@%°Oefºn»É*“Nhµ¢ëK£ØØÖ®]ëVAy´fíšLo}L€4ìÝ¿ŸˆîLúæÉ'Ÿtû§@Ù8p ªû)òœIDvö©{nå£�>r«J(cø®»îRåû¥]»vî¦e!ÓJìTÙÅðÐC©} J˜€?øàµÿ>Y.(e¿}Î|c??–/_îV ¬,]ºÔkÓ¦�ªÿ)ò+ü.º¾¨<üðÃn5�GÇWí0 ? ?7Ù*ºCé›™3gº}T lØ‹n�O;pP-(¯‹>¿�bŸ’,ÅžøÌDU~PÖ¯_ïnzvjg÷8ýbŸèÚ¶m›»yÞe8Í\"až4i’ÚŸÌ”º´O©}™C=ÔÛ°aƒ[€²`¿í²ûlUßSämI¶ »Œ�[wT&L˜àVIy0nÜ8Õþ2@€,Ø»Ÿݱô�½ØNA”‹¹sçfºÖΗ±7M |ØÑî9n”óÎ;Ï­6¡TŒ`»~r4U¯á—ûï¿ßÝ<ôêëëÕqeêÔ©îæye×ös÷!UÂ4Ü»woµÿ>é'ƒÝMVŠ>¾¹úê«Ýê„Úu×]§êyŠØ¶ÒE€ô¾k²Qtjûä9€üÚ¾}»wà�ªöçÛfmÛ²²“É\ÑLßôèуiöP-ZäµnÝZÕñ/L¹TnŽ}že�=öp«N(cØ:õÔSÕkøå¸ãŽs7 ½9sæ¨ã Ê�wÞénžW/¼ð‚Ú‡T Ó°­ÃîþûÄ®�ˆp8V’³o¸çÐ7#FŒp«JÙ¬Ón²Ý¤—™.º©° �_Ó¦MSí. ÷ ÐD?4Y+º“é›ÓN;�5�jË—/O ì¹u;Eìz;; Ê͉>×*å°>m±€xàõAYµj•»y¨mÞ¼YcP =~ÕUW©}H•° gñdsA˜Ü(úÆ®?„ÙÓO?­êuš\/@vìï{ã€[—åÄOt«'€²¿Üvç{#Üh†ÿ•,ž°°Ó¢Úé=�°±ƒLûï¿¿ªÓ)²ÚdOA¹Z*úœ7Êk¯½æV£Ð)Öðû￯^#(�>ú¨»yètÐAê8ƒb/ Á~wï´ÓNêõS%,Àv?Ý}÷ɧ‚°‰˜L}.SèiÕ�\ùû³Ïªúœ&$ÙF€lýEt}R Kû„½ÛÞ2N€(º³˜ /¼ÐÛ¶m›Û�JÖºuë¼C=TÕåi0ù… œý]ôyo”rXŸ¶XÀV¦7\اò‹ñ�b×Þ:âˆ#¼óÏ?ß3fLN׺¿ùæ›Õq¥PO-Nš4I½vº„åâï½÷Þ«öÝ'„‘]²ä ÑçÓ7UUUÞK/½äV ¤½òÊ+‰ºëÖçyÍä›4Ía¢ë”JÏž=ݪ Ž9æÕÞrˆ9ò'ÑÎÀ\qʼn‹ç@©Û¸q£wì±Çª:œ&}åîfÑç½QÎ<óL·:…N¦ÀíÚµs7m¶?ÿùÏêu‚òÔSO¹›çÝË/¿¬ö£{÷îÞwÜá½þúëÍší"‹u½¼^½z¹›ç…ìv_;]Â2œášÓÿ'«ö&Š>§¾‰D"¡©»À›o¾™¨³n=N‘OLªhž™¢ë–ÊŒ3Ü*  ü~d–9ÔÂäÑÏÀ\wÝu £¤mذ!1¸âÖÝ4±k¢üýRô¹o”vØ¡(O¦æR¦À-[¶t7m¶+Vd|Q»}ûö› Ù²ëÙwëÖMíÇWcÏŸ>}¼Õ«W»›§µiÓ&/�ª2ƒ²`Á·ˆœšòÈ#n9g×ûu_7(“þö7wóŒÍœ9S•—*cÇŽu‹h6ûtìYg�¥^+Ó„a8�«ýöÉ[‚rñkÉàæ�/c?¼@©™:ujVß�’¬óg�[ÇŠ®k*]»võ6oÞìVcY°³í±Çª}¤§yTcò/ÑÑÀص2í”—@±ÙigÓMíê“é&ßTšá¢ëB£Ø\Âlùòåꘂrÿý÷»›ç„]S×}­T±zùRWW§^/(ö‚gs¦·Ó¼pÀªÜ Ø§s9e­ÝwûTµû:Ù$ Àç�wžÚoŸÜ#('›l}ž3~üx·êEñÌ_ÿªêgšØºÞW€ü˜!ºÎ©<üðÃnU�…ûî»Oµ«€L ºš|&ºC»Öêšµkܾ.P0ÿú׿¼Ýwß]ÕÍ4™mò-A%:[t}h»l˜§¹·Ol¸Ç”.]ºäå ;µt§N�Ôë¥ÊàÁƒsº/ëׯ÷.¿ürõ:©òòË/»ÅdmâĉªÜT±Ó�Ú'ÚËüwÜqª|;ô9眣þÜ/³fÍr‹.)¶mf¸þï¯åf€èóœ2ù¼¹ÈÄc�=¦êe¹\€ü±Së»uNÅöOìM¶²gÛN‹-T» ÈáÈÞ&ËEwJ³ß~ûy‹/vû¼@Þ½õÖ[ÞÎ;ï¬êdšüÃä;‚JÕIt�PyóÍ7Ýê*Ù<…š¯uÝí{è¾Vºì½÷Þ‰µ²ì“´Me—'°ƒ>­[·Vå§ÊÕW_íÕ$ö)Ül§É·n¾ùfõ÷~yñÅ�#)-t�ÚgŸÜ*(wEŸ÷”9üðÙÎy·jÕ*¯gÏžªþe�!Ö#¢ë¡Š�ÕÄή ={£f†}U›(¢˜|"º£š2·ß~{FOlÙ²ëƒf1�ÒWóªÉ®$ýBtQ ó@�m+v-c÷˜š›¦ [·Ür‹*«Ò¶m[ïÃ?tw7'ì÷`¯^½Ôk2öéí/Ù§¸Ý¿÷K)ßÈe§Èv÷7 ¬¥V®}îSÆ®‡=gηj9agͰ7í¸õ.Mì”õv}k ÐÚš¬]'Uxà·ºðñ§?ýIµŸ€¬f&@ ØÍä}ÑÖ”±S…®Y»ÆíMfŸÐkʺš’\ãê[üÇ×LÖŠ®+�R[[ëVÃPiî“·~iê°UWW×”iÛóûTm¾¿d¿�8âõÚ…ˆ�Bû«~øaõoüòÕAãR“áìë$ÙÆQúJ†Ó˜~û9ôä“OºÕ h;-º)ù}b/ x‰®—*ös³9}@ ØYw²ø¸\€3yGt§5eìš’ï¿ÿ¾Û/²ö /4õiÆ¿š´@û‹èúÒ(Ç{¬[CÅ®ik×¶u�«9iîÅ?»&–ýnpË-tn¸á†ÄSÒ…°~ýz邏NRû�ÏØõœ]vÀËýw~7nœ»iÉ8ꨣÔþúdœ Òœc²Ut]H;�|¡>P¾l8p ª_d‹IoŠk“¢ë§ÊqÇçmß¾Ým¼äÌ?G}´j7yO¸Y%¦�ÉtÑ�×”±ë>ó׿ºýc #ö‡ÔСCU½Ê0�˜´À_ÑuFÅN9fvÀ5‹;ÑÓ¦¹ÀVCCƒwï½÷æt¿2�}wÖ¬Yî.å�ý,³ÇìîO®c§·�>}ºûò öYõïýbŸÔ.EK–,Qû�ó•èXI>ýíÖ‡”9òÈ#½O?ýÔ­n@F-ZävØaª^e; ÉÿPŽ]G}�ÇÝfÀxüñÇU{I»Prì]е¢;°iÓ§O¦„FVìÙcŽ9FÕ¥ b§ÓlR%@0»&´}úÆ­?�â÷$eØÌ›7Ïkݺµ:¶¦$À_Zµj•÷Ç?þÑÛyç�Õëä:v-Þ™3gº»Ppöý;çœsÔþ57v0ýÆoôÖ­[ç¾ä¿½öÚkj;¿ >ÜÝ´$<òÈ#j_}Ò`òA¥ú‘ÉÇ¢ëEÊ´hÑ›0a‚[倔&OžÜÔÙi›üD€Ò’Ño|Ûßx÷ÝwÝæT´>ø ›PÂì Ú`ÑÙ´Ùm·Ý¼×_Ýí/J3.ªm¦ÓCæ&‹®C�Ò³gO·z†ÒŠ+¼ë¯¿¾YkðvïÞÝ[ºt©[t³Ù'‚_|ñEï²Ë.óvÚi'õºM‰=ΓO>9q7þâŋݗ,:{ñÔNCÝÜÁï=zx>ø`F7XÙuÔÝíý2dÈwÓ’pøá‡«}õÉ3‚J×Áä Ñu#m7+®Iß–PÙì�6ýúõSõ'ÃüC’Kë¥ÆÞ¹Tt�U9à€˜>ø‚m ö7’ÛN²L’3ë%¯¯dðôœ_† æmݺÕí;‰AŒ‹/¾XÕ™ ó¹É¡dÎNëÖ#•O>ùÄ­ª¡e‚í¯öâõ�èµk×î߃ÂöÎuû¤p·nݼSN9Å4h�7räÈÄ“³©ž,͵?üÐ{úé§‘^xabzM;µq«V­þ}w½ýo›6m¼=öØÃ;è ƒ¼3Î8ûå–[¼1cÆxo½õVbÝÝ0°ké-X° ñ>0 ±î´½YÊë—çÅ>�ؾ}{oÿý÷O<=|ûí·{'NLœËJñÑG©v�³ÙÙdŠèú‘6�;wöÞ|óM·  öFVûíÖ› ó7“o PºÎ]o}Sª7‹…fgàqÛGŠüJ€éaò©èŽmÚØ»$çÎ�ëöŸQÁ^xá…Ä �[W2Ì|“=ÈÎ.’|jÜ­O�ò‡?üÁ­® è¶ÛnSíÒ'¶-Û6 X-L†HrY·®¤�½&,7’ ÿ6nܘ¸ùÆ­'Yä~“–”>{£‚[}S KkÅ4}útÕ.R„YjJvª½WDwp3Š�’tÆ n_Ä>ÅÖ¿U7²ˆ]³jGšÆþwëT£Ø'M·lÙâV]`g iÛ¶­j—>/€všÉ:Ñõ%mì“÷S§Nu«$*ÌË/¿Üœ§~ךœ*@xt2Y-º.«Øþñ²eËÜ&T„%K–$fNrÛE@V Óÿ ľfr·èŽnFÙ}÷ݽ3f¸}j”9;õéøñã½wÜQÕ‰ c§ (@óœ)ºn©0Çߟ}VµÇ€Ø�>ÀÏL扮3Å® ¼råJ·j¢Ì­^½:ñ$¸[²È“½ŸÞ¢ë³oŽ<òH¯¡¡Ám>@Y³7÷ìÙSµ‡aêg”…óL6Šîðf”K.¹$q'%Êß¼yó¼£�>ZÕ�,²Øäšï’\?Ú­c�Ò»wo·(€ÓN;MµGŸ,7ùºÁìºÀö)q·îd{³Z<O<‘ŽòfÏñ¨Q£k±»õ ‹ŒÖûE¸�]¯}óûßÿÞmF@Y»úê«U;H‘”‘š¼!ºã›Q"‘Hb�-Ö]+Oöi ;í·{Þ³Ì$“väÎ0ÑõL…T€ÂúôÓOU; È]dæ×ÒÄ)¡möÙg綾^r«*ÊĬY³¼nݺ©óžEì�°ýLª7{ÓÌBÑuÜ7ãÆ�s›P–&L˜ êŠ| Ü €2d§„b²Mt'8£Øu×ìôÀvš`„Ÿ�&iäÈ‘^Ë–-Õ¹Î"\TC¾ì-º¾©Üzë­nÕ�G7Ýt“j‡ÙS€Ìíaòºèz”qN:é$oáÂ…n•EH}üñlj©¾Ýóœeþ!ÉþP.ìlK[E×u{÷üùóݦ”;‹™­ënýˆ]®ê ÊØ&‹Dw†3Î�èMž<Ùí{#$ìÀ¯ÈïÚµ«:·Y†‹jÈ·¢ë]£´hÑÂ[»v­[ÍäÁºuë2½ihšÙköÍŠ6ýû÷÷-ZäV_„„�ÙÃÎLSUU¥ÎmÙnr·$ëPn‹®ó¾i×®³å lÙYiÚ´i£ê}ŠüN€ ÐÆä Ñâ¬Ò³gÏÄ´l‡mÛ¶åjà×Þ9{‹pQ ùg×0wëŸÊ#�<âVwy0|øpÕþr¶Mw¤É¿D׫ŒcŸ²ƒˆ+V¬p«1JÔòåËë–6sàׯNïùsÊWÄä/¢ë¾oöÛo?oÍÚ5n“BÍÞ”˜åò$Ùv€Šq¼É'¢;ÇYå„NðfÏžíöÉQ"¶nÝêM|fbb�<÷Ü5!¯šì+@aìh²Bt=l”N�:%žl�? ‰¥ Üöç“å&­hžoš • §: Š2dHbp¥iÙ²eÞÍ7ߜ͞A±uå’ì;ån“÷D·ßüñ‰ïq Øß}vÙ·ž§È»&­¨@ß’äiÍšnÏæàƒNÝqÎ:vòºë®óÞyç·ß޳ç_yå¯OŸ>¹X;íËØ©’N ø]?Užùë_ݦ ²xú÷>ò§£Éh“í¢ë^Ö±k>þøãÞš5¬�™o«W¯ö{ì1ï€P硉±u nR#îÝFsÏ=÷¸M…?þñ�ª>§Ém@ù¶$§…Þ"ºݤôèÑ#ñT°½„ܱOû>ÜÛ}÷ÝÕ{ÞŒØi’›|]€ÒðÉ`šz;Ý9Sйeo0Úk¯½T{ó‰�ÙC€ü³Sý¾"º6)öÆ9{ÝŒ3SK"wæÍ›ç 4(WÓ<™×L_Š˜ŒÝVóÐC¹Í(iö&"·§É’lìe2EtgºÉ±€úõëç=ÿüóÞæÍ›Ý~=2°fíoܸq¹œ>ïËØ¶…é;Qšž]gUlÛ�;O>ù¤jg/@áØ‹ºç™|*º.69öF¢¡C‡zï¿ÿ¾Û�¡wß}7ñvíÚU½¿ÍÌb“s„ ú€Ÿo˜LÝnSWWç6_ $=õÔSªþ¦É,“V #v}à©¢;ÖÍŠ�"ºÿþ‰Áà††·Ÿ�¯Ø´iSâ}²ïWŽŸ¢°±OmM0ÙW€ÒÕ]tÝU±k�óyä†mK]ºtQí, ö©L Ðì^»|Ég¢ëd³²ß~ûyÆ ó.\è6 8-Z”˜éç�CQïc²\’3Óì$Rù®É¢ÛP`êëëÝæ ””Ñ£G«z›&ï™´Yû“Ù¢;ÙÍN«V­¼‹.º(ñôÞŠ+Ü~EúðÃÓN;í´| úÚØ�_ûÄÖ>„ÃDÑõX¥¶¶ÖmNš`Ĉª}dœÅe¯5Y)º~6;ûï¿¿wË-·$¦‰f/ñØ÷¾'v Ü}¿r”ÏM®~�lØeSlÛqÛS`}ôQ·‰%á‘GQõ5Mì CßMVer¼É¢;Ü9ËÁìÝyç�ÞìÙ³½�7º¿Ê’]yêóS½k¯½6×kúº±¿v íÂå'’¬¿n�n;»ë�ÍcÛ�½9Ëm_>±m’$P*v1¹Ád•躚“Ø›òÎ:ë,/�{|ðAE¬l�Ñ«½ÁªwïÞùº1ñËØAüëMZ €¦8Ðd�趘Ûo¿Ýmö@Q >\ÕÓ4Ygr°È;5´�:8í€LscŸ.4h�7~üxï³Ï>s„Ò²e˼ɓ'{×_½×£GuÌyÈf“‘&{ ^öIC·n«Øv é®¾újÕ®R/@éù¦$§†þ—è:›Ó´hÑÂ;餓ÓEÛ§bí²a·eËoÞ¼y‰™húôéãí´ÓNê¸ó»Æï`aàÈ…ÃM6Šng�¹í¶Û*↔6[‡ ¢êgšl09Tä…P|T’ŒngÁl/¢�3ƻᆼO<±PÒ¾Œ}’bˆI{Âo/“m¢ë¹Ê{ï½ç6G°ß©n{ ˆm‹ÜT„RÖÒäW&¯‰®¿yIUUUb&›þýû{=ô�7}úôÄM¥jéÒ¥Þ´iÓ¼|ÐëׯŸ×½{÷Ä1¸Ç•ÇØssºI �KÇH–¿Ï �¸(†††ï²Ë.Sõ2Mlï)òÎ0þŸÉÇ¢;æy�½XeŸ>óÌ3½ßÿþ÷‰5cþþì³Þüùóë çk€Ø®ƒf/ž½õÖ[ÞÄg&z÷ÝwŸ7pàÀÄÓ ]»vUûYÀ¼eòaí4”ŸZÑõ]Å®Ÿ {½zõRí) vV ,ìq£%Ë‘\eÇwô?üp.JL·:zôèÄÀëÂ… ½µk׺Í0gÖ¬]“x ûZuuu‰×îÛ·ob_2œæ=ÙdRgr˜È§^&[D·ÁÀØ›•óù™øY¿~}â·›[Ód«É)  ¢&¿4/YþàÌwìÚ {î¹§wä‘G&Ší0;µ´ÍàÁƒÅl†úïÿ¿æškå•W&¦Á;õÔSS6Û§�£Ñ¨z�"g­ÉpI®ý”«Ž’œêË­ÿ*Ï=÷œ{}@ vV ·Ä®µV-@ø´1¹Üd¾èz]Ôìºë®Þþûïï{ì±Þyç�çýö·¿ýw?õ¦›núwßôËØ?ûòïí¿µÛØmí ‘¶,·üÈ<“ËLv…Ò[’en{ L·nÝÊfÙ%”¾O?ý4ñ½åÖÃ4±×™ÎEeŸ þ�”àE¶2Š]ƒùe“ „§}Q9ìln[PiÛ¶­·fÍ÷:«W¯ÎfÐ诇ɓբë8ÉMV™Ý.Tì“QÒ»âŠ+Tû ÈG&­(_39V’Óš¯]çIv±³tL0ùµðY”Š£%Ãt¾L$ñÆŽëv€œ˜0aB¢Ž¹õ.MìÇ €’ö“[…Áàlb§îšar¥I'`§ýrÛ‰ofΜé^sð³gÏVí&Eì“D@¹²–¶Ž×KòéU·þÿ|.Éu}O”äMZJ��õ ë›\ìÍ” n×h’Í›7{×_½ªgÄ.?òs*»™\(\hóË2I¾/öýù/ðUU&¯ˆn7*{íµWâbmãÆ�^×®]U» È Tލ$oZ(Éå6¶‰n•;Í“#LZ €0è.É6Üöœ2={öô–-[æv!€¬,Y²Ä;òÈ#UýÊ ËMº €PÛA’wuÞ$É‹ÌYMSUY!É)óì…F{ÁÑp¶�Iƒè¶¤rÓM7¹× ƒVí% v͵ P¹ÚJrZãMæ™lÝNÊ5öXí179Óä» ¬~`ò¾èvž2v­Ö3f¸Ý #Ó¦MóZ·n­êUyÏäû ìا þÛäR“1&‹Dÿ kì…´&#$ù„¯½¨Î€/�½›E·/ß¼ôÒKîµ ¢Ù‹qn;I‘Áà«v•äÚÁvY“—LÖ‹n7a�={Lö _{ŒßåĶiÛÆÝ¶Ÿ6vú^fÖA¦l]¹ýöÛU=Ê0v ,n8*Hk“ŸJrÐônINÉg׃q,”RìÔÖv?ï7é'ÉýßIäÂ×$ùT’ÛîTvÝuWoÅŠîu  "­\¹2ñ4�ÛNòO“¯ €tÚKrÀÔÎæ2R’Ó$—úÔÑ‹%9�ìµO8ï)É鯔7Û‡Ž‹þLH›ý÷ßß{÷ÝwÝ®ÐÈÂ… ½ƒ:HÕŸ c`My‰'g;Hr`µ·Éµ&›<'ɧl³^ç(‹l5Y"ɨ¿šüÙäJ“SL0i#ò­‡d8ç9çœã^›*ÎöíÛ½³Î:Kµ�€ØÁ+ûý  iìÍvÊÕž&Iò‰áz“Y&I~Ÿ¶eÛטi2Z’¯ÝW’ûb÷‰;€Ê1¹CôgGÚD"oÔ¨Q‰>ðUÛ¶mó{ì±DqëM†¹]’u2bŸd°Óí!É Ù'˜œlr�$Ÿ&þ�$ŸÖ°¹æ+ÿo/’Ù¿?ç‹ÿK“î’\‡æÛ TØ'ìÝ‹¾©¯¯w¯S¥¶¶Vµ‹¹Gä›}Ê©Z’kÛÿ\’}ÎÓ$Ùµé/ÿé›~ûg_þ½ý·v›Ã%Y†-‹'§dêtiâ¬Z={öL<é Xü±×«W/UO2ÌF“³à+v6Y(úB‚н}þüùîõ  "Ì™3Ç«ªªRí" ï KP ~$Éï}·/�Qì:¯ n·bË–-ÞðáÛóÔï»&{ €�ƒ%9-»{AA¥S§NÞªU«Ük@Y[½zµ×¹sgÕ²E’3^€Ê°‹ÉDÑ}‚Œrà�&n4Ceyûí·½8@Õ‡,ò´IkHáÿD_Tð͉'ž˜X£ ¨¶®ŸrÊ)ª¤Èµ*�]{õ÷’áM•~0`€·bÅ ·+‚2³|ùrïòË/Wç?‹Ø› íò[U�F “é¢/0øæ®»îr¯eeéÖ[oUõ?E^6‰ ¨Tÿ-͘ÚNEV™tPév6%º¯�qºvíê=÷Üsn×!´}ûvïïÏ>ëí¾ûîêðäÉ“Ýn BbÆŒÞa‡¦Îk–Ymr¦4Ó]¢/<øf§�vò>úè#÷ZjŸ|ò‰·óÎ;«úž"· €ÖÉd’è¾CVéÙ³§7{öl·Ë‚5sæL﨣ŽRç± yÆ$&9`×~^ôߨ)êV®\é^÷BiõêÕÞ^{í¥êyŠ<'É6¤·É2Ñýˆ¬r 'x¯¼òŠÛ}A °S=O›6Í;î¸ãÔykB–šœ!9ÖÎd‘苾9öØc½Í›7»×A€PÙ´iS¶Ok|lò]Ho“ûE÷'²Ž�º¾¾žþw زeKbšîLõüeêMÚ@žü·ÉfÑ%|sÊ)§p ¡ÕÐÐà�y晪^§HƒÉÁ��ž&ïŠî[d�:x?ü°·víZ·kƒ<[³v�÷ÐCyíÛ·W祉Y`ò (€sE_œLŸ>}¼mÛ¶¹×G€’¶uëVïüóÏWõ9E¶›œ%MÓÒ¤ŸÉ*ÑýŒ¬‰Dýð3f$¦"FþÌ›7Ï4hPâ=wÏC³Îd°É× €n}¡"0—^z)ƒÀ {‘ôÊ+¯Tõ8M @ó}Çän“­¢ûMÊÞ{ïí >ÜûüóÏÝnšhÍš5‰)·»uë¦Þïfd›ÉH“¶PU&£D_´̵×^ËÓ(y¶Ž^sÍ5ªþ¦ÉI¶ €\ù±Éó¢ûMNUUUby‹ñãÇ3EtØ)žÇ�ç�~úéê½ÍAž3ÙOŠl“gE_¼Œ��'�QªìàïàÁƒU½M“„éù@þa2]t¤Y±ƒÁçœsŽ7ñ™‰ÞúõëÝn¾°nÝ:oâĉÞÙgŸ�xÏÜ÷1™fòs(!ß6™/úBF`ú÷ïÏ 0JŽ]ó÷’K.Qõ5M昴€ü³Á³D÷Gr’=zxÆ K¬g[é³ö|òÉ'ÞÈ‘#½“N:)_ƒ¾6o™œ,%ªÚäÑ5sÖYgy›7ov¯µEÑÐÐà]pÁªž¦É{&í pì’'˜ÌÝ7ÉYÚµkç 0À›0a‚·hÑ"·ëTvì€ïÓO?�8f{ìîû‘ãÌ49^X>@¸y‰Õÿ0Z?5Ò±îÆHMm‰§bçÎ�›¸y.¬ìM¨sæÌIË…^èµmÛVo²Íd¼I€ÐÚ­¶m¤¦îb{Á,Rÿ]~8õ›îK(ªï›|&ú‚G`Ž8âoÅŠîu  V­ZåõìÙSÕË4Yj²‡(ëö0ýËßUÕħšÿnÒýÏ&gS¢Ìêºë¤Sý¾îË@ŽØ~Ép“�¢û-yË~ûíç]tÑEÞý÷ßï=ÿüóÞâÅ‹Kj™»/öée»o÷ÝwŸ×·o_oß}÷UÇ‘çØsò€ÉBéd/�ÕŸTUS;ù‹§yÝ `yHâ Œx‹Ž£sw@ÑüÈd…è‹�éÒ¥‹·páB÷š �W}ô‘·Ç{¨ú˜&«L~,@9è0¦:ÙosûWþ±3¸¸E„ZÛ¿íéX÷ÓŸ|Õ=Ö±J$ñÍ‘XÜþÈP\]M>}¡$e ä544¸ãv@³lݺջýöÛU}Ë Jr}k Ô¢ëN4ý¤¥ºß”YB=¼[mÛªšºIî1•Jì¾Iu½¤€|ª1é'ÉKƒ›—¹&ƒM¾'P:=ùƒH¬n�{áªS«{Rª§Ã=ÕÑä]ÑMRæè£�ö–/_îŽáM²lÙ2﨣ŽRõ,ƒ|`ÒY€0ëôTëH,þ˜ÛOÊ6anÑ)~¨ÙÿÏÜã)½ÄJõ“?r÷òÄöoš¼`²Etˆ4NƒÉT“&�ÊJuýÏ"5ñÏõ«ÒMâbe×—wr@Au0™#úBJÊ´k×Î{óÍ7ݱ< +vmé6mÚ¨ú•AÞ2ù/B,¹\Gü·Ô”„q8Z3úôÄÌ0>ÇSš‰¯�ŽñýÜã€<ÛÙäD“L>Ý'ªÔØY`î7éeÂ5å©E,þóHMí&}¡ªô­ŽO“.Ï|Í=&e/,M}a%eìZ­=ô�·mÛ6w\HÉÖ™x@Õ© 3Éä[„•]®£&þg·OÔœ„m8«ëcÞƒíîq”~âŸÛwÜã€ÚÓäb“ÇMŠî'•kìÌ/�™ô5ù¡@Ù«~òÀHMíZ}�ªYYn§’ŽÖÄgÚuϪbµMž3öz¤:¾$÷ìâ£ÜÃPp-Lþ,úbKÚôêÕË[²d‰;Æø²uåøã�Wõ(ç˜Ü-ÉõƒW‹î?…-öì±Øc:Ù¤½@EÙ­¶m¤¦v±º0•}þ‰ÕÞíï%�êÛ¹/£tyæ[R?$R¿&«�îS^ÿîË(Š~&ÛD_ˆI™–-[zŸ™èŽõ�L™2ÅkÕª•ª?d»É`ªË3_³}­HN>ÿ“Ð W×wËÍ´Ï£^‹ÄFÿÞöGÓ/'âEìS»ÑšºsÌk×Gr3 <Ú}(!�LŽ5¹ÚÄ~^½m²NtÿªØ±ûd÷Íî£ÝW»Ï�$ìjÆ·7ß9ÇGªë®3ßÙ›“"ÕµóÍÿ/2YùÅ÷à–äÿ×~lÿ®ªzÔßÍÿ߉Å/±3¼Iõôo¸Å¨^¤ª:þ¬Ï©LcpŒ–šÑ=Ü’³¶[}WSÖ}æ‡LƒÏëdšµ²Û˜ŽnÑŠâW&D_¤I›+®¸Â[³v�;î‡ ·fÍïÒK/Uõ%ÃØºxšaÕ±îǦŸ3ǧ„b8ö—]"Õµÿr÷=‹Ø¾ë©®ßÛ-:+�žjmú¬W˜,óy�Œ­®;Ú-J\“n’|bx $gÿyÊäE“¹&Ÿ™lÝË6¶ [–-Ó–m_ã^“+%ùÚvì¾”‡ö¯¶2ßÃ'˜ï•G"ÉA^õ�‘}â“Çu쀲û’ÊXrí4÷GB†‰Õ¾!»Õíã–Ùl»Õw�ÖÄ_Q¯—aªªkŸp‹P4ûš¼/ú‚NÚ´iÓÆ›ç~'"öuór3?€Ò‰ÕÝêþHŸøÖhÍèÓݲòżÞõ>d”nYŠÊ>Ýt£$×`uèÒf‡vðF�ímÛ¶ÍD™²ç:�{-Z´Põ!ÃØ5¨›ämÀÈ/©©»ÌNÝèÓÇÉ$·è8ú0éTßÎçïSêÀ‰5{}ö;M¶ØÁY·¬|ˆÆFÛ´¥LâoºeÊ”ýnŽÕ>ž¸9I}4öF±?³N0Pn:OÙÙtøWûüH™hõ¨óÜ¢òË‹TÕŒú›»d�´ýÛŽniŠîX“e¢ë2Ê!‡â½þúëîX!ÊÌìÙ³½îÝ»«óŸE–˜ü�a{²s4V÷¢Oß&ÃÄëí:¹‰²Êh¸Eu}Ow3I¡û®‘êÚߺû�Iš½&1 ä™ï÷_šÏü¥îw@‘ó» —»¯B*ÕϧãŸ:±ÚÜr b·Ú¶‘šø*µ?iRÈ'•d¥­ÉDуvÇN ½hÑ"wÜ!g§úîß¿¿:ßYfŠI{B(Z]{¾éìuû4%V»&Z?»Q�e4\UÖÝßô‰ÿÉ-§ªjê&é}I—ø n9€ráU™ïéÛôgÉdm´ãhnžÊA´¦v¶O§?Uó‰ÚHMü Ÿ}J—‡Ür”Œ*“ËL6‰ÀË(‘HĻ뮻¼µk׺㈙5k×xwÞyg✺ç9‹l4é'ɺ„’é»|æÓŸI›h¬vº}rØ-¯l€¿7®&û©2ãïK§©_w‹*ˆNOþ ’˜ZÓݧTõš[   üäÕ–æ;i”þÜ/µÄ7Gkâǹ» Ljê¾§;û©í8ú\·˜‚úáÔofÿpü]·%gO“·Dæeœh4êÝ~ûí ‡Ðúõë½áÇ'ÖxvÏk–™k²¯!Éz8¾55XNöì:ëZ™ Gbñ�K4V’[N!UÕÔ=åîSšl)æÍ–€<0ßÏæûàiŸÏüM¼�A` Ä"5uëŽ~Ê|*?¬ßÁ-§ÐÌ�‘?ûì[ÊØµŽÝr”û„Ö`“Í¢ö2ÎŽ;î˜Lܸq£;Έ³aÃoäȑޮ»îªÎc–Ùb2D’u½HVÀö ×1ÿí–ÑH™ 7aMäwÌGDQg°Ð}ö+eZtŠê–¯H¬n¸ûYߌ¬‹ÄjߨŠÕ=™(7V7ÔüÙ=‘êøHûýɪ‘2ëX—©HM¼Þ§“Ÿ"ñÛÝ2Š¡Eu}O½o©#ë~ì– díc2Kô _ViÓ¦Mb ØN+ŒÒ²zõjï¾ûîËÅÀ¯Í .N¡¬D2¾xTº¾¼“»½RÀÕÓ¿a§¥t÷5e:޾Ü-¦à:MýºÙ— jßR%¿Ð-N‘êÚ+Õç|Ö‰¿›˜é£úÉåЩ-Ü×PbOv¶7üGkâ3uYÙ$þ®tzªµ[<€g:óé~p¤ã¹eE×—wŠd¹žZ4Vw²[ €’1±À׊ðË*vjèAƒy~ø¡;‰[¼xqbšî–-[ªóÔ„¬7hâ?å-b‘´ÀñϳêÛ”Á°}*ÖÝÏt‘Îõ1·œbˆVǧ¹û–:ñ›Ý2!«ÿ‰�NYÎg˜XÝëÍžŠ¹fÌ_<¬ËÏ U5µcÝ"”²ÄZººsœøzùÉ«-ÝbŠ%q¬ÚÇT‰÷uË �Lž=ø×¤\|ñÅÞÛo¿íŽK"ÏÞ|óM¯oß¾ê|4#cL: P¦")€«bµÏI‡1Õî6)•Áp¤cÝoÜýL“wÜ2ŠÅôCïõÙ¿àÄâ�¹eBÆÎ\«ý§úŒÏ(ñ�‰Y,Nörv£c4V÷kSöZýZécú'¸å(U5ñýÝN}ªDcµÓÝ"Š©ª¦vŠ»�©Sw™[€P9Ìä-Ñ�MÊ>û쓘zÕªUîX%rÄN½]__ïuïÞ]½ÿÍÈ<“ž”¹ˆïp|³�FÒ4;CBvÊa8ÛAÔšø#nʼnÅêý NUM�½ñ bæóüwîç{†ù41Õs>ìVßÕ|?.ôyÍ4‰$mÿ¶£[€Re×r©®ï"�Æüw4V÷ËhMülÓ±¿"Rbþû ½ø­©})R];?R=ênwób2û7Jÿ(I•ø5nBÇ®wu±ÉrуMJ$ñ~ûÛßz³gÏö¶mÛæŽa"Kö=œ1cFâI몪*õ~7#KMúÓ=£BDÔp|žtŒïçþ»Œ•Á°�ÎÙN­©;-«íoú§·ØA^Ó_}ÆN‘iö{±É–GuÝ·ˆb‰ÆF_à¾Ç©RUS;Ù-"]êÛ˜ÏóÕîç{Yœ¸F“OÆT›×yÇçµÓ„å € Ö?HR$·ëD(»˜Üi²Aô@a“Ó®];ïúë¯OLW¼}ûvwlì ïo¼á <ØkÛ¶­z_›»Îïí&­¨ ‘FÀñ{í4’î¿ÉJ9 gÄ«²Ý¥fì^‰ÿ–ˆhǺ3Ü÷8UªjâSÝ2áa>Ëït?Û3ȺÄ÷W!ÄžìlúË|ö!8±Ú5û‹ýùc~¬<ª~�¤J,~‰[€Ðkoò'“Í¢›•X,æÝ|ó͉Á`ž Ö¾ô½é¦›¼:¨÷/Ùd2Ì俨@;\_bghqÿ®I*f¸4E«G�ç¾Ç©RUŸà–‰ö¯¶2Ÿå+ÝÏöt‰ÖŒ>Ý-*¯ªëf^w›»)S]w�[ äTU¬îIõc$E¢5ñSÝ2”�;M½4t›�h4êõîÝ;±ží’%KܱЊñùçŸ{“'Oöú÷ïïí°Ãê}ÊQLFšt ‚™¾ËC²[m[÷Ï›ŒࢊÔÔ]æ¾Ç)«}Ü-ÙNûoSUã–Sæµïp÷%M–³0€¼ŠÔÄ_õù1éøÄAnÊŽ4¼Or<5´›ƒ:È»ñƽ)S¦xË—/wÇIËÆ²e˾7Üpƒ×½{wõ>ä8vªç{Mv¹ÇpQ™~ëMî{œ2±º[Ý2á`¾C_RŸë©³I¾7ÎÞÐZx�¦~Ý|G}è³O�‰ÆjÏr‹€ñ"vý÷‡Hª°V PQìº�ƒM–ŠhÌyºvíê]zé¥ÞèÑ£½¹sçz7ntÇRKÞ† ¼9sæxuuu‰'|»t颎3OYbb§’ÛUäÀEUUï¾Ç©­®=Ó-ߟ°k$Ûi•kâö&È¢±ß9zŸ‚SU=êïn�5cp„¤Nü}·á&}MˆxÌköÜsOï‚ .ðþøÇ?&ž^°`�·víZwܵàì>¼óÎ;‰}6l˜wþùç'°Ýý/@þir¡$Ï€|c¸¨²}ºJv«ÛÇ-Pú¢5ñ³ÝÏôt‘êú.n9…åEÌ÷Ô{î~¥È6é0¦Ú-šÍü8¹ÁçGHŠÄtËPQªLŽ0©“<­œiZµjårÈ!‰Áá›nºÉ{衇¼§žzÊ›úüTïÍ7ßô.\è­\¹2ñDn¦ì¿µÛØmmS§NõÆŽ›(Û¾†䵯™Çu{3�}ïã&‡Kòœ(€‹§s}Ì}S'¾Ì|dFÜb¥Ï|†?ª?׃­‰¿ì–Q ‘XüwßR¦ºn€[4ÏO^mi~p|¬~€¤H´c¼—[ €Še§¾Ìd¾èÊ’L4M ÞÚÁcûÿöÏÜW™kÒßä; 8.šHÇÑ—»ïoÊÄâ�¹eÂÁ|ŽÏUŸë©ÕÏ-£(bÙ%RoPû�ªšøT·h–HMÝÅî��Ô‰/“.Ï|Í-ŒƒMþhò©èAKÒ¼,2¹Û¤»(>€‹Æ¼—³Ý÷7U¢±ú“Ü2!Ðöo;F²\ÿW:ÖíáS,U5uϸûœøf{¼nÐ4‰‹—ñeúÇGŠTLJ¸Å€ÃNµùSIX2Üô,7ir¬I P:.ŠkwßÛ”©Ž/‘Öïà–�šøþês=ulß¹dDjâ}}ö10ÑŽõǸe@ö~òj˪šÚ)î�Ž4Y+]êÛ¸E@ vàÒ®ü Ñ4ÑEÌ“;$¹®oT”&€‹Â¼�/¸ïmÊT×Þâ–‡hǺÕçzŠ”Ü4ʱúºû˜2±º¡n��“½h$Vû¸úÁ‘.±º«Ü¢ K�LúšŒ3Y+z´Ò²Úd¬É…&1 \4Vw²û¾¦N|£tSí–‡H¬¶¿þlO•ø½nÅföi…ÞOÿDk⯸Û@溾¼SUMÝÓî��ô‰¿*‡Ne R¹d?Sö”äà§�êø_¢HË-vJì &%9Mv×T÷"Ñêø4ý=‘:|‡ ¬0\X�§ìlÞÇÅîûš:£ìÌ€�ŠÄjoÓŸí)ó;·Œb‹Æê^ôÙÏ€Ä×Û›õÝ2 ½XýOÍ�Š÷õ�t‰/“NOur‹€<øžÉ¯%¹~ðs&+D¢†%v2{ öXì1u–2©®½RO¤À(+ T$Ì}OÓ„eK äì½>Ÿï�‰v}®[F±™cø³»Ÿ©b§�vË€`±'÷4?<êÜf­t|â ·H( &=M®4yÄä“�L¶‰t-t¶š|(É}zD’ûx”I{)G5c÷2ß ›|¾+Ò†`”€ Æ^ÐwßÏ´‰�êç–—HMüõùž"Ñšø©nʼnÕr÷3U¢Õµgºe@cÆ~Ç^0«ª©�âþ¨È<ñÏíSÃnÑP"v0ébr„Éù&טÜ%Éé¤'šÌ4yßäs“5¢oƒbÿ­Ýæ=“’,Ë–i˶¯a_˾¦}Z¹¥TŠŸ¼Ú2«{]Wd€QV.ŒŽñý씘îû™*Ñšø,óQq‹„K¶7±GkêŽwË(6ó�t¶»Ÿ)S]{‹[€Jc/¤·ýÛŽ²[m[©®ß»EÍ¿0?~›œb(þ–Évõc"›Äjÿ)ê¾ï¾,”�Mv1ùö±ÿoÿ )˜ï•ÔwEae…àüë0¦Ú¼w‹Ü÷2M6Iõ“?r‹„�ùL�û|Φ$€;Žþw?S'^ç–  ™ÎÿƒúA¾cŽã÷JûW[¹û¨PÕõÝÌwÃVý�‘yFYa8¿bÙ%yC£~/S§îb·(@8•ÃÐ-:Ž>ÌÝÏTIÎb�ÿßÞ½Ir×÷ïéYñoD¡ëîÙ{ ™³ †Ã°q @l\Æå‡!ØÄN Á(TLª‚±��ð²;0~�Íõ¿w´w'rp%�Ã…D¶uáaÀ<l!—xˆ—!:�6={‡8ý»gvvo³3ŸOÕ·VZÝüú?S§ÿÿßó›é€©·Ù ànþ|ð&<fXvõÝÓ¬üT¼f¬6ÀL à�³ëý÷©_¯kâ×p¥t²ò²¸Û×àƒéñ\?*ƒ[`Å5¶\¯ÿ¸xœ£¾—¦Ðæ4€Ãí�|ÿ•sExr||H‹êM͵cõÑfªhoŒåæoøPüú�‘�'{ŽÜ;.ÀöUïA_Û2ß�ÊŠkl¹Þe�oçÈ$^un\˜2Ú.Ê�¤YõÊdþÐ||\˜›O<ë{ÊŸŽ0SExýí>xÞòþ´åõ[!_Lvõ‹¸Û[½&¼´eΕKã[­›UOkçÈ$ÙâCã:À”Ù€ð;»Ex^²ã@ îä«ÎM³òÚ–µdMÑfªh¯¯�zõëô™øu#7%yxt\€í¯[ôÿi˼?4�,üϸÆVëæ ÏŽÇ¹R’ù��ëSfýÀá–úç';yxwZ,üúàÓ¨ƒKíÅÇ€z½øÃæ:²öh3U4€×O/<²~�¾¿fcäø\±ð”¸S"ë?¦eîž,|9.±Õï»4ƹBº½…ŸŽëSfýÀ­9ÑÍËÖǺdpé½x ÌžnQ=½e½8«h3U4€×Å\Öÿ©´(¿¿^cä¸7ȦÜÞ£÷Zí­H’¬¿'.³•–?|ß2ÎQY¾j0Ý6©|fNtŠj1)úûâ±0#v|@½|±e�8«h3U4€ÏZš•ÿ®ÞëÞ¿Vcäæ¹|á'ãzLŸ´¨>Û²ŒHõoâ[g)­Çô¥æWHQ¾4®L™-hß‘N.Ozý�ñ˜˜nõÚÓ�ׄ֬òþÀÀL àµÛwìœzžykü�™o&ùÂOÄ%˜NõzQµ¬CÓÉË÷Å5¶Ê\oáIñøÆJ/üZ\ ˜2[Ù>•p‹OŸÌŽn¾ðìæZОnoáâß�Š0SExmê×­~-þOüúŒ™’^õ¨¸$Ó+-Â/µ¬#nKzW<$®³Ò¢|{s|ã$ü—¸0mvè%yxtk²þcêŸO˜+“»½…ŸK‹ê—Ó¬zeš…?éåÕõ‰ÃM͉µepßšÁýwâá0EòwŸŸæákñЖåoWì8�Å¿ `¦Šðêe‹Õ¯Åõñk3^Âç&í¾Žl‚Ý—çÍ5a¥„ÿ—ÙtÙáõXŽ7Ç6FŠêµq9€ï»x©›dý|ƒwðF}}"q¢qb±ŠtóòšdOÿAña˜�¼:ÏýCr2É®ÌLÓ^•n^½ ]ëáy8–ì,×`6ÔkÁ_7׆Q 7&ó‡î×ÙLõÞÒ׸©Þ×nðFe^^ŸP|¡y‚1fŠêÃÉž#÷ŽK°½­êòzEùöåi3Ë4€ÇSÿ_ï7~=ÆM' ‡“_yϸ,³#ͪÿ¯+'¼9®³iŠ…\Šº9¦±si\`eóGï–ö^–®ñÑË—ýL–Ò¸,ÛT¯¿³žß¿Ï÷CòÍ;¾‰§Ì,Ó^Ù‡ØÉÃÑøµXE.]¾¢ ³­Þ{¦«¾¢Ùà^À—=>.µá.¼êÜÁm šãYE¾÷aK€5Ùy ×ÍÃU�“�qÒ ¿—`;ZJ»yùÁÆ™¹½q2"ݼú™¸ jÏ‘»Ös÷'ã¹|x¿ŽKÜA˜Y¦|jÈÊOÅÏu¼„¯Ïý§Æ%`E{úª×‘ëškËZnìå{럯ì{»yõÏïqtóðói~iðmÜú÷ý´¨>Û|ìèÔkÿ5ÉüÑ»�ºÚZó¿K7+_?e€³–åGâ�ò[q &Ó`În™Ç[ÓÉ«+âÇ߉0³lÖÀÙâCÓ<|>~žc¥(?�dý=qIÛàŠ6Eù­Æ39ùÒ`¯¼<Ö<<ºå¿M½gxfôlÎ^}õºødT—_Šk0�òð„tùÞg͹¼™ðõ$÷ùq‰;Ñf–ÍrxùŠ1åâç8N:yù¾dþÐ}ã’°jEÿÇëµå¦x­™€|5Éüè÷†Ùͪ§µü™¡9uij€uÖ-Âóâ�Ñ ×Å5˜0{�Þ«ž¯?לÃÛÓÍž—hÐf–ÍjøTó÷úøù�™Kͬ«¬ÿ�êõå-kÎ%üý`­/ÍÃÇâç´rÂ�ƒKsÆå`£ÌõžT¯AŸl®I‘p[�ýÉ®~�ãLƒ«ë4;$Yù©øñÀV4uwè%Yÿ1Ý<<£[,ü«z“ÿŠ:oîdåeÝ¢úßiQ}¶ÞÐóô‰Â‹ã#[¼¨q²B&º¡ 0Ãê9ºŒçì¡)Ê·Ç�I˜Y6+ à½ý» .i?Ÿ“…/×{ʇÇå`ã-¥ƒ«O¤yøhc}ZŸÜ^epk„øÈmêuôš–­éå{ãÇ[¨›‡Å÷‘œ,L¨Á·¿ã÷˜@Ý¢º¸eÎ’ðwÉÞ£÷ŠkŒ¤Ì,›‘pZ„?�ŸË¹>™_ü�¸lº¢¿/Í«7Ö{ÝÏ·¬W«ÉñNw‹òùƒ+ìć¥~ìW[ê Iøïñã€-´†{Í|"®1)ê“£µŒwT¾×`‹í,œŽÿfÓÉ$_ø‰¸ÄŠ4€™e3ÐN‹…_�ŸÇŠÉÊk“¼Ú×€-7h¾[„çÕëÕowòpyýóãu¾�žºRÛ‰4·Ô?¿”å§OÈÿ�Òbÿ¯,ßÎ`µ”üž ?°±VŽJQ¾4.l¡úDá’ÆÆ}… Þ8ŒëL‚zl—Æc�ðѸ[«“Wÿ£9_Kx}üø±h3˦¼Üí…Ÿ�ŸÃ¹~ÜËaÀ,˜+“[ÖË¡™+ž×¶RÑÿñxã¾rªÅe&AZTnŽux:yu(®ÀÖéö~!ž«Gäk¾�»0³lšÀyµ;ÍÃ�ñsX!7$½êaq)˜eioáe-kæÐ$Yÿþq `+í;vN½Y¿)Þ¼�J·(¯ŽËl¹ìðŽ4·Çc™,¼&.ÀÙy —žºŒ]s¾n$ÜšôªGÅ%ƦÌ,›ÖðüÑ»­öÀu¾�ôÂ#ãR0ëÒ¢|W˺9$áïâÇ “WW47ð£“Ìxl\g+­öÓ©ƒtóðŒ¸[a©Ó)ÊÄóôð„WÄVE˜Y6¥ à´¨Þ�}…œìÕÓã:Àòí®kY;‡$ôãÇ -ª_nnàG§“ï¿2®³ežxt®>áø|<ÆÑ ·';> .ÀæK‹ý¿Òœ§ÛÓÍÃ_&/uã«¢Ì,›Æp¯ÿ¸z¬'ã±�LQ¾4.$§o©Ð²vKVþj\˜YÿþƒËi66ñ+¤›W?—Ú «i|ìá¯â:l��ý ëyùæxž’›>.±jÀ̲ikï9r×zû7ñ¸G'Tqà”nV¾°¹vORô÷Å5€ Ñ)ªÅx?FnHzW<$®µ©ŠÅ]iQ~«el£ã[[ïâ¥îà½�9zXŠð’¸Äšh3˦¬\ïé~3óè„Ï%{ŽÜ;®œÒÉÂáæú947 nç×&E¶xQËF~Œ„cÉÞ£÷ŠËmŠ ¯:·ÃÇ›cZ1'¶¼q ÀàÞb¯h™£[ÓÉöÿÙº½¹¤Ì,›¦ð©ÿ—ǽ‚ÀÒò-@zýÇÅe€ÓN½Ïr¼¹†IQ¾+.L˜NŽ66óc¤›‡«’â=÷‹ëm¨úx«úÖØ�Þ—`“Í÷düÛ„“Ý—çq‰5Óf–MQ8-ŸÆã]!o‹kß×íUÏiY?‡¦›•Ï�k“&�®7ð'ã ý˜ùLÒ �ŒKnˆ^õ¨ÁåûZÆ0NN&;ª â’l¢½ý»Ôóñ_·ÌÑ­éåóãgE˜Y6- ࢿoð�Þx¼Ã¾²éX€mfu_ ßM²þýãÀª7ðomnêÇMønš…× .×]{ŽÜ»>Æëëc�h{Ü„7ÇeØ\iQ½¶9?·§“W‡âÇŸ5 `fÙ”4€;EùÞx¬#S„—Ç5€3‹?ØX?G¤“‡q `Rízÿ}Ò<ü¿xc¿º„¯/7‚³Å‡Æåפ®“få«—mk5 ×mXs€ñô.{|:þÕ&nHöô—8kÀL¼¥tùïÝF$;¼#þ;>*ݼü`£Æze­vV�ˆÇ9:ákÉü¡û6Ž¿�,ÍÂï5×Ðáéæáq `’õÂ#Ó<Üoî×–ðÑúçouóò™Ë á‹—ºñáîl©“ôú;'iQ¾®ÎGš5×”s½…'ÅG`�ìõºð·-stk–׎� Ì„«ÿÞ½-þ{8�Ü <~îã¨ç‘*®5íIæ<6~؆v½ÿ>I¶xÑà'ƒº®vSÿüT½¶õã?º©ŠÅ]õn�ן¹!Ùw윸 0áºÅ³Òñ¿¡µš .ß|ý 1ÜÍÇß*©O|®N‹êÃoè®ò„c©^?G6W=_ÚœŸ‡%¼#~üºÑfÂ¥ÀÃíêõüp[\kÚ£ ° e‹¥ExI½n½¥S”¨çó/Æóû÷nÝ�+ߌ)-Êw5Ç4"Yù기MtóêõIÈí��þ¶Køoñs`sÍý§Ž¿¦„ë–¿±Q4€™p©ðpó×™…hl?ËW5k™Ó‡¦·ð²¸Æ¦ÈÃÆß§/çæd÷Áóâ2À6ÒÍÃϧëv9è­HxEüœØdË÷—/ÿ¡9G·%Ü>×+ÿq\b]i3áR àá4€Ø.z—=>žÏG& _N.¼êܸ̆Zå-Z–ST¿—¶£¬ÿ˜tì7î'&7u{Õsâ§ÀæK³ð'-ót{²ý¿?~Ýi3áR àá4€Ø6–ÒS·ºjÎëCÓ«^WÙHiQý~c #nMv_žÇu€íjþÐ}ëÍþ;››ÿILøhÒ«?6_·~¶9OIQ~:É®¾{\cÝi3áR àá4€ØFêýíoÆsú 9žä~4®³Ò<\ÒrüÑÉÂkâ:À˜Ë/û'õ ÌG'‘pcZ„—$/uãq°v<¯žŸohÎ×­91¸âD\bCh3áR àá4€ØNvè¥y¸-ž×G'\—ìé?(.µžºEùüúX'›Ç•z\¾òžq-`j,uºYøçiQ}¸yB°%ùêàSµÉŽƒˆG ÀÖéäåÁ–9{HÂŽ¿a4€™p©ðpÀl3iQ¾+ž×WNøX’¿ûü¸ÖzHóêß×õoostWö‰kÓªXø±úÄáõÉÀ7Ⓝ ÎÉNQ~ ›íÿÅäüc÷ˆ‡ÀÖêÕ¿h™»‡dÿÿMö;'®±a4€™p©ðpÀl7Å⦫þ¶í áºu½ô‡¸ºhÞi,ý¸0 ö;g._øÉ4ßÿ†n^^³úK�•ëëºû»Yù¤wÅCâ!0!–¬áÆ–y¼-ßNvTÄ%6”0.ÕN€m(]ûÚ~²ÞW¿õ¬®x–]}÷Ó÷û÷Ö,q>“\xÕ¹qY`í=z¯ÁUݼzAZ”¯«Oºyù¿Ò¬üTš…/×ÿþÍ:ÇO]v(ÜZÿ™oÕ?¿Rçïë?÷�<\^ÿó[')ƒû'Ùáñ!˜DK�N^¾¯å�£Ö ¾)WØpÀl¦ ?ðÔ{ͽæx ·Ô?ËÁïÇ»ïRzêCSÕëÇ}©YoìÜœdýŽ«0CÒ<¼¸å�£! )4€Ødݼ|f¼Ï\c—“þD½—®ßN³òÕi±ÿ7Wd«ÿGÝ¢¼ºþySËãV™p[··ðsñó`–dý=i¾Ó|ó¨%Eùéñ¾½°4€Øi¶ÿwâ½æ„æd7_xv<~fÉÅKÝÓß6ˆß7~ü¦Ò`«Ì½Ûä6�Ã׿ŠþSã!0ƒÒ¼üBó ¤™Íñøõ€;ì;vN½g|[Ë>rëR”IæÍÇC`F¥yù¥Æ›H³ `VÔíUÏ|ë¶e?¹™¹9ͪW&{ŽÜ53,Õ>3ÀŒgOÿAiþ ÞCžhÙWn`Âí�¼:ä[¿´J5€ÏŒ0«S,îJóðæ:7¶ì/×1á;õÏ·%½êañà©ð™Ñ`m²«ïÞ-ž•å»ê}å -{͵äiúÝ<üˤxÏýâC@Cª|f4€XÙâC—ïÜ«^Uï3ºEyuš‡¿­óõtpÿÞ¼`_ with: .. code-block:: shell $ pip install curies The most recent code and data can be installed directly from GitHub with: .. code-block:: shell $ pip install git+https://github.com/cthoyt/curies.git .. toctree:: :maxdepth: 2 :caption: Contents: tutorial reconciliation discovery struct api services/index typing w3c preprocessing database dataframe triples biopragmatics-curies-aa8bd37/docs/source/preprocessing.rst000066400000000000000000000127621525104130300241700ustar00rootroot00000000000000############################## Converter with Preprocessing ############################## When simple expansion and contraction aren't enough, and you want to inject global or context-specific rewrite rules, you can wrap a :class:`curies.Converter` and preprocessing rules encoded in an instance of :class:`curies.PreprocessingRules` inside a :class:`curies.PreprocessingConverter`. ********** Rewrites ********** For example, you always want to fix legacy references to the ``OBO_REL`` namespace: .. code-block:: python import curies from curies import PreprocessingRules, PreprocessingConverter, PreprocessingRewrites rules = PreprocessingRules( rewrites=PreprocessingRewrites( full={"OBO_REL:is_a": "rdfs:subClassOf"}, ), ) converter = curies.get_obo_converter() converter = PreprocessingConverter.from_converter(converter, rules=rules) parsed = converter.parse_curie("OBO_REL:is_a") assert parsed == ReferenceTuple("rdfs", "subClassOf") Similarly, there may be a whole class of references that need to be fixed based on their prefix, such as the ``APOLLO:SV_`` references that are mangled by the OWLAPI due to the OBO Foundry's PURL rules .. code-block:: python import curies from curies import PreprocessingRules, PreprocessingConverter, PreprocessingRewrites rules = PreprocessingRules( rewrites=PreprocessingRewrites( prefix={"APOLLO:SV_": "APOLLO_SV:"}, ) ) converter = curies.get_obo_converter() converter = PreprocessingConverter.from_converter(converter, rules=rules) parsed = converter.parse_curie("APOLLO:SV_1234567") assert parsed == ReferenceTuple("APOLLO_SV", "1234567") The CURIE and URI rewrites are unified. Therefore, you can also use a URI as a rewrite, such as handling Creative Commons license URLs, which unfortunately aren't themselves part of a semantic space for licenses. Luckily, SPDX is, and we can remap to that. .. code-block:: python import curies from curies import PreprocessingRules, PreprocessingConverter, PreprocessingRewrites rules = PreprocessingRules( rewrites=PreprocessingRewrites( full={ "http://creativecommons.org/licenses/by/3.0/": "spdx:CC-BY-3.0", }, ) ) converter = curies.get_obo_converter() converter.add_prefix("spdx", "https://spdx.org/licenses/") converter = PreprocessingConverter.from_converter(converter, rules=rules) parsed = converter.parse_uri("http://creativecommons.org/licenses/by/3.0/") assert parsed == ReferenceTuple("spdx", "CC-BY-3.0") Some rewrite rules only apply to a specific resource, because of its own quirks in curation or encoding. For example, CHMO encodes OrangeBook entries with ``orange`` as a prefix, which is not typically specific enough to warrant curating ``orange`` as a prefix, e.g., in the Bioregistry .. code-block:: python import curies from curies import PreprocessingRules, PreprocessingConverter, PreprocessingRewrites rules = PreprocessingRules( rewrites=PreprocessingRewrites( resource_prefix={ "CHMO": {"orange:": "orangebook:"}, }, ), ) converter = curies.get_obo_converter() converter.add_prefix("orangebook", "https://bioregistry.io/orangebook:") converter = PreprocessingConverter.from_converter(converter, rules=rules) parsed = converter.parse_curie("orange:10.2.1.1.3") assert parsed == ReferenceTuple("orangebook", "10.2.1.1.3") Similarly, this can be used to inject knowledge about resources that improperly import EDAM sub-trees such as MCRO, which uses ``format`` as a prefix where it means ``edam.format`` ******** Blocks ******** Some references are _never_ informative, and can be configured to be thrown away, such as ``Bgee:curators``, ``BioGRID:curators``, ``GROUP:OBI``, and similar group curation flags. .. code-block:: python import curies from curies import PreprocessingRules, PreprocessingConverter, PreprocessingBlocklists rules = PreprocessingRules( blocklists=PreprocessingBlocklists( full=["Bgee:curators", "BioGRID:curators", "GROUP:OBI"], ), ) converter = curies.get_obo_converter() converter = PreprocessingConverter.from_converter(converter, rules=rules) # raises a BlocklistError converter.parse_curie("GROUP:OBI") Blocklists cause throwing an exception that can be handled by downstream code, such as returning a None. This is done because in some places, it's nice to have the distinction between ``None`` being returned by parsing failing, versus actively being blocked. This can be toggled with the ``block_action`` argument. ************************ Suffix Post-processing ************************ URIs from some prefixes that are known to sometimes have unneeded suffixes can be configured to be stripped in the following way .. code-block:: python import curies from curies import PreprocessingRules, PreprocessingConverter, PostprocessingRewrites converter = Converter.from_prefixes( {"emedicine": "http://emedicine.medscape.com/article/"} ) rules = PreprocessingRules( postprocessing=PostprocessingRewrites( suffix={ "emedicine": "-overview", }, ), ) converter = PreprocessingConverter.from_converter(converter, rules=rules) parsed = converter.parse_curie("http://emedicine.medscape.com/article/276512-overview") assert parsed == ReferenceTuple("emedicine", "276512") biopragmatics-curies-aa8bd37/docs/source/reconciliation.rst000066400000000000000000000252241525104130300243040ustar00rootroot00000000000000################ Reconciliation ################ Reconciliation is the high-level process of modifying an (extended) prefix map with domain-specific rules. This is important as it allows for building on existing (extended) prefix maps without having to start from scratch. Further, storing the rules to transform an existing prefix map allows for high-level discussion about the differences and their reasons. As a specific example, the `Bioregistry `_ uses ``snomedct`` as a preferred prefix for the Systematized Nomenclature of Medicine - Clinical Terms (SNOMED-CT). The OBO Foundry community prefers to use ``SCTID`` as the preferred prefix for this resource. Rather than maintaining a different extended prefix map than the Bioregistry, the OBO Foundry community could enumerate its preferred modifications to the base (extended) prefix map, then create its prefix map by transforming the Bioregistry's. Similarly, a consumer of the OBO Foundry prefix map who's implementing a resolver might want to override the URI prefix associated with the `Ontology of Vaccine Adverse Events (OVAE) `_ to point towards the Ontology Lookup Service instead of the default OntoBee. There are two operations that are useful for transforming an existing (extended) prefix map: 1. **Remapping** is when a given CURIE prefix or URI prefix is replaced with another. See :func:`curies.remap_curie_prefixes` and :func:`curies.remap_uri_prefixes`. 2. **Rewiring** is when the correspondence between a CURIE prefix and URI prefix is updated. See :func:`curies.rewire`. Throughout this document, we're going to use the following extended prefix map as an example to illustrate how these operations work from a high level. .. code-block:: json [ {"prefix": "a", "uri_prefix": "https://example.org/a/", "prefix_synonyms": ["a1"]}, {"prefix": "b", "uri_prefix": "https://example.org/b/"} ] ************************ CURIE Prefix Remapping ************************ CURIE prefix remapping is configured by a dictionary from existing CURIE prefixes to new CURIE prefixes. The following rules are applied for each pair of old/new prefixes: 1. New prefix exists ==================== If the new prefix appears as a prefix synonym in the record corresponding to the old prefix, they are swapped. This means applying the CURIE prefix remapping ``{"a": "a1"}`` results in the following .. code-block:: json [ {"prefix": "a1", "uri_prefix": "https://example.org/a/", "prefix_synonyms": ["a"]}, {"prefix": "b", "uri_prefix": "https://example.org/b/"} ] If the new prefix appears as a preferred prefix or prefix synonym for any other record, one of two things can happen: 1. Do nothing (lenient) 2. Raise an exception (strict) This means applying the CURIE prefix remapping ``{"a": "b"}`` results in either no change or an exception being raised. 2. New prefix doesn't exist, old prefix exists ============================================== If the old prefix appears in a record in the extended prefix map as a preferred prefix: 1. Replace the record's preferred prefix with the new prefix 2. Add the record's old preferred prefix to the record's prefix synonyms This means applying the CURIE prefix remapping ``{"a": "c"}`` results in the following .. code-block:: json [ {"prefix": "c", "uri_prefix": "https://example.org/a/", "prefix_synonyms": ["a", "a1"]}, {"prefix": "b", "uri_prefix": "https://example.org/b/"} ] Similarly, if the old prefix appears in a record in the extended prefix map as a prefix synonym, do the same. This means applying the CURIE prefix remapping ``{"a1": "c"}`` results in the following .. code-block:: json [ {"prefix": "c", "uri_prefix": "https://example.org/a/", "prefix_synonyms": ["a", "a1"]}, {"prefix": "b", "uri_prefix": "https://example.org/b/"} ] 3. New prefix doesn't exist, old prefix doesn't exist ===================================================== If neither the old prefix nor new prefix appear in the extended prefix maps, one of two things can happen: 1. Do nothing (lenient) 2. Raise an exception (strict) *********************************** Transitive CURIE Prefix Remapping *********************************** There's a special case of CURIE prefix remapping where one prefix is supposed to overwrite another. For example, in the Bioregistry, the `Gene Expression Omnibus `_ is given the prefix ``geo`` and the `Geographical Entity Ontology `_ is given the prefix ``geogeo``. OBO Foundry users will want to rename the Gene Expression Omnibus record to something else like ``ncbi.geo`` and rename ``geogeo`` to ``geo``. Taken by themselves, these two operations would not accomplish the desired results: 1. Remapping with ``{"geo": "ncbi.geo"}`` would retain ``geo`` as a CURIE prefix synonym 2. Remapping with ``{"geogeo": "geo"}`` would not change the mapping as ``geo`` is already part of a different record. The :func:`curies.remap_curie_prefixes` implements special logic to identify scenarios where two (or more) remappings are dependent (we're calling these *transitive remappings*) and apply them in the expected way. Therefore, we see the following .. code-block:: python from curies import Converter, Record, remap_curie_prefixes converter = Converter( [ Record( prefix="geo", uri_prefix="https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=", ), Record(prefix="geogeo", uri_prefix="http://purl.obolibrary.org/obo/GEO_"), ] ) remapping = {"geo": "ncbi.geo", "geogeo": "geo"} converter = remap_curie_prefixes(converter, curie_remapping) assert converter.records == [ Record( prefix="geo", prefix_synonyms=["geogeo"], uri_prefix="http://purl.obolibrary.org/obo/GEO_", ), Record( prefix="ncbi.geo", uri_prefix="https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=", ), ] ``geogeo`` is maintained as a CURIE prefix synonym for the Geographical Entity Ontology's record. Synonyms of Gene Expression Omnibus would also be retained. .. note:: This is not the same as an "overwrite" which would delete the original ``geo`` operation. This package expects that you give a new CURIE prefix to all "overwritten" records such that no records are lost. .. warning:: Primary prefixes must be used when doing transitive remappings. Handling synonyms proved to be too complex. Therefore, if you use a CURIE prefix remapping like in the following, you will get an exception. .. code-block:: converter = Converter([ Record( prefix="geo", prefix_synonyms=["ggg"], uri_prefix="https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=", ), Record(prefix="geogeo", uri_prefix="http://purl.obolibrary.org/obo/GEO_"), ]) curie_remapping = {"ggg": "ncbi.geo", "geogeo": "geo"} ********************** URI Prefix Remapping ********************** URI prefix remapping is configured by a mapping from existing URI prefixes to new URI prefixes. The rules work exactly the same as with CURIE prefix remapping, but for the :data:`curies.Record.uri_prefix` and :data:`curies.Record.uri_prefix_synonyms` fields. ********** Rewiring ********** Rewiring is configured by a dictionary from existing CURIE prefixes to new URI prefixes. The following rules are applied for each pair of CURIE prefix/URI prefix: CURIE prefix exists, URI prefix doesn't exist ============================================= If the CURIE prefix appears as either the preferred prefix or a prefix synonym, do the following 1. Replace the record's preferred URI prefix with the new URI prefix 2. Add the record's old preferred URI prefix to the record's URI prefix synonyms This means applying the rewiring ``{"b": "https://example.org/b_new/"}`` results in the following .. code-block:: json [ {"prefix": "a", "uri_prefix": "https://example.org/a/", "prefix_synonyms": ["a1"]}, {"prefix": "b", "uri_prefix": "https://example.org/b_new/", "uri_prefix_synonyms": ["https://example.org/b/"]} ] CURIE prefix exists, URI prefix exists ====================================== If the CURIE prefix and URI prefix both appear in the extended prefix map, there are three possibilities. 1. If they are in the same record and the URI prefix is already the preferred prefix, then nothing needs to be done. This means that the rewiring ``{"a": "https://example.org/a/"}`` results in no change. 2. If they are in the same record and the URI prefix is a URI prefix synonym, then the URI prefix synonym is swapped with the preferred URI prefix. This means if we have the following extended prefix map .. code-block:: json [ {"prefix": "a", "uri_prefix": "https://example.org/a/", "uri_prefix_synonyms": ["https://example.org/a1/"]} ] and apply ``{"a": "https://example.org/a1/"}``, we get the following result .. code-block:: json [ {"prefix": "a", "uri_prefix": "https://example.org/a/", "uri_prefix_synonyms": ["https://example.org/a1/"]} ] 3. If they appear in different records, then either do nothing (lenient) or raise an exception (strict) CURIE prefix doesn't exist, URI prefix doesn't exist ==================================================== If the CURIE prefix doesn't appear in the extended prefix map, then nothing is done. Adding fully novel content to the extended prefix map can be done with other operations such as :meth`:curies.Converter.add_record` or :func:`curies.chain`. .. note:: There is discussion whether this case could be extended with the following: if the CURIE prefix doesn't exist in the extended prefix map, then the pair is simply appended. This means applying the rewiring ``{"c": "https://example.org/c"}`` results in the following .. code-block:: json [ {"prefix": "a", "uri_prefix": "https://example.org/a/", "prefix_synonyms": ["a1"]}, {"prefix": "b", "uri_prefix": "https://example.org/b/"}, {"prefix": "c", "uri_prefix": "https://example.org/c/"} ] This is not included in the base implementation because it conflates the job of "rewiring" with appending to the extended prefix map CURIE prefix doesn't exist, URI prefix exists ============================================= If the URI prefix appears as either a preferred URI prefix or as a URI prefix synonym in any record in the extended prefix map, do one of the following: 1. Do nothing (lenient) 2. Raise an exception (strict) biopragmatics-curies-aa8bd37/docs/source/services/000077500000000000000000000000001525104130300223665ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/docs/source/services/index.rst000066400000000000000000000002351525104130300242270ustar00rootroot00000000000000########## Services ########## .. automodule:: curies.cli .. toctree:: :maxdepth: 1 :caption: Services: resolver_service mapping_service biopragmatics-curies-aa8bd37/docs/source/services/mapping_service.rst000066400000000000000000000002541525104130300262740ustar00rootroot00000000000000############################ Identifier Mapping Service ############################ .. automodapi:: curies.mapping_service :no-inheritance-diagram: :no-heading: biopragmatics-curies-aa8bd37/docs/source/services/resolver_service.rst000066400000000000000000000002171525104130300265010ustar00rootroot00000000000000################## Resolver Service ################## .. automodapi:: curies.resolver_service :no-inheritance-diagram: :no-heading: biopragmatics-curies-aa8bd37/docs/source/struct.rst000066400000000000000000000130161525104130300226220ustar00rootroot00000000000000################# Data Structures ################# A *semantic space* is a collections of identifiers for concepts. For example, the Chemical Entities of Biomedical Interest (ChEBI) has a semantic space including identifiers for chemicals. Within ChEBI's semantic space, `138488` corresponds to the chemical `alsterpaullone `_. .. warning:: `138488` is a *local unique identifier*. Other semantic spaces might use the same local unique identifier to refer to a different concept in their respective domain. Therefore, local unique identifiers should be qualified with some additional information saying what semantic space it comes from. The two common formalisms for doing this are Uniform Resource Identifiers (URIs) and Compact URIs (CURIEs): .. image:: syntax_demo.svg :alt: Demo of URI and CURIE for alsterpaullone. In many applications, it's important to be able to convert between CURIEs and URIs. Therefore, we need a data structure that connects the CURIE prefixes like ``CHEBI`` to the URI prefixes like ``http://purl.obolibrary.org/obo/CHEBI_``. ************* Prefix Maps ************* A prefix map is a dictionary data structure where keys represent CURIE prefixes and their associated values represent URI prefixes. Ideally, these are constrained to be bijective (i.e., no duplicate keys, no duplicate values), but this is not always done in practice. Here's an example prefix map containing information about semantic spaces from a small selection of OBO Foundry ontologies: .. code-block:: json { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", "MONDO": "http://purl.obolibrary.org/obo/MONDO_", "GO": "http://purl.obolibrary.org/obo/GO_" } Prefix maps have the benefit of being simple and straightforward. They appear in many linked data applications, including: - the ``@prefix`` declarations at the top of Turtle (RDF) documents and SPARQL queries - `JSON-LD `_ - XML documents - OWL ontologies .. note:: Prefix maps can be loaded using :meth:`curies.Converter.from_prefix_map`. *However*, prefix maps have the main limitation that they do not have first-class support for synonyms of CURIE prefixes or URI prefixes. In practice, a variety of synonyms are used for both. For example, the NCBI Taxonomy database appears with many different CURIE prefixes: ============== ==================================== CURIE Prefix Resource(s) ============== ==================================== ``taxonomy`` Identifiers.org, Name-to-Thing ``taxon`` Gene Ontology Registry ``NCBITaxon`` OBO Foundry, Prefix Commons, OntoBee ``NCBITAXON`` BioPortal ``NCBI_TaxID`` Cellosaurus ``ncbitaxon`` OLS ``P685`` Wikidata ``fj07xj`` FAIRsharing ============== ==================================== Similarly, many different URIs can be constructed for the same ChEBI local unique identifier. Using alsterpaullone as an example, this includes (many omitted): ==================================================== =================== URI Prefix Provider ==================================================== =================== ``https://www.ebi.ac.uk/chebi/searchId.do?chebiId=`` ChEBI (first-party) ``https://identifiers.org/CHEBI:`` Identifiers.org ``https://identifiers.org/CHEBI/`` Identifiers.org ``http://identifiers.org/CHEBI:`` Identifiers.org ``http://identifiers.org/CHEBI/`` Identifiers.org ``http://purl.obolibrary.org/obo/CHEBI_`` OBO Foundry ``https://n2t.net/chebi:`` Name-to-thing ==================================================== =================== In practice, we need to be able to support the fact that there are many CURIE prefixes and URI prefixes for most semantic spaces as well as specify which CURIE prefix and URI prefix is the "preferred" one in a given context. Prefix maps, unfortunately, have no way to address this. Therefore, we're going to introduce a new data structure. .. _epms: ********************** Extended Prefix Maps ********************** Extended Prefix Maps (EPMs) address the issues with prefix maps by including explicit fields for CURIE prefix synonyms and URI prefix synonyms while maintaining an explicit field for the preferred CURIE prefix and URI prefix. An abbreviated example (just containing an entry for ChEBI) looks like: .. code-block:: json [ { "prefix": "CHEBI", "uri_prefix": "http://purl.obolibrary.org/obo/CHEBI_", "prefix_synonyms": ["chebi"], "uri_prefix_synonyms": [ "https://identifiers.org/chebi:" ] } ] An EPM is simply a list of records (see :class:`curies.Record` and :class:`curies.Records`). EPMs have the benefit that they are still encoded in JSON and can easily be encoded in YAML, TOML, RDF, and other schemata. Further, prefix maps can be automatically upgraded into EPMs (with some caveats) using :func:`curies.upgrade_prefix_map`. .. note:: We are introducing this as a new standard in the :mod:`curies` package. They can be loaded using :meth:`curies.Converter.from_extended_prefix_map`. We provide a Pydantic model representing it. Later, we hope to have an external, stable definition of this data schema. A JSON schema for EPMs is available at https://w3id.org/biopragmatics/schema/epm.json. It can be updated at https://github.com/biopragmatics/curies/tree/main/docs/make_schema.py. biopragmatics-curies-aa8bd37/docs/source/syntax_demo.svg000066400000000000000000002775051525104130300236360ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/docs/source/triples.rst000066400000000000000000000002271525104130300227600ustar00rootroot00000000000000######### Triples ######### .. automodapi:: curies.triples :no-inheritance-diagram: :no-heading: :include-all-objects: :skip: Triple biopragmatics-curies-aa8bd37/docs/source/tutorial.rst000066400000000000000000001007211525104130300231410ustar00rootroot00000000000000################# Getting Started ################# ******************* Loading a Context ******************* There are several ways to load a context with this package, including: 1. pre-defined contexts 2. contexts encoded in the standard prefix map format 3. contexts encoded in the standard JSON-LD context format 4. contexts encoded in the extended prefix map format Loading a pre-defined context ============================= There exist many registries of semantic spaces that include CURIE prefixes, URI prefixes, sometimes synonyms, and other associated metadata. The Bioregistry provides a `detailed overview `_ of the registries available. This package exposes a few high quality registries that are internally consistent (i.e., are bijective). ============== ========================================== Name Function ============== ========================================== Bioregistry :func:`curies.get_bioregistry_converter` OBO Foundry :func:`curies.get_obo_converter` Prefix Commons :func:`curies.get_prefixcommons_converter` Monarch :func:`curies.get_monarch_converter` Gene Ontology :func:`curies.get_go_converter` ============== ========================================== These functions can be called directly to instantiate the :class:`curies.Converter` class, which is used for compression, expansion, standardization, and other operations below. .. code-block:: python import curies # Uses the Bioregistry, an integrative, comprehensive registry bioregistry_converter = curies.get_bioregistry_converter() # Uses the OBO Foundry, a registry of ontologies obo_converter = curies.get_obo_converter() # Uses the Monarch Initiative project-specific context monarch_converter = curies.get_monarch_converter() Loading Prefix Maps =================== A prefix map is a dictionary whose keys are CURIE prefixes and values are URI prefixes. An abridged example using OBO Foundry preferred CURIE prefixes and URI prefixes is .. code-block:: json { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", "MONDO": "http://purl.obolibrary.org/obo/MONDO_", "GO": "http://purl.obolibrary.org/obo/GO_" } Prefix maps can be loaded using the :func:`curies.load_prefix_map`. First, a prefix map can be loaded directly from a Python data structure like in .. code-block:: python import curies prefix_map = {"CHEBI": "http://purl.obolibrary.org/obo/CHEBI_"} converter = curies.load_prefix_map(data) This function also accepts a string with a HTTP, HTTPS, or FTP path to a remote file as well as a local file path. .. warning:: Ideally, prefix maps are *bijective*, meaning that both the keys and values are unique. The Python dictionary data structure ensures that keys are unique, but sometimes values are repeated. For example, the CURIE prefixes ``DC`` and ``DCTERMS`` are often used interchangeably with the URI prefix for the `Dublin Core Metadata Initiative Terms `_. Therefore, many prefix maps are not bijective like .. code-block:: json { "DC": "http://purl.org/dc/terms/", "DCTERMS": "http://purl.org/dc/terms/" } If you load a prefix map that is not bijective, it can have unintended consequences. Therefore, an error is thrown. You can pass ``strict=False`` if you don't mind having unsafe data. A better data structure for situations when there can be CURIE synonyms or even URI prefix synonyms is the *extended prefix map* (see below). If you're not in a position where you can fix data issues upstream, you can try using the :func:`curies.upgrade_prefix_map` to extract a canonical extended prefix map from a non-bijective prefix map. Loading Extended Prefix Maps ============================ Extended prefix maps (EPMs) address the issues with prefix maps by including explicit fields for CURIE prefix synonyms and URI prefix synonyms while maintaining an explicit field for the preferred CURIE prefix and URI prefix. An abbreviated example (just containing an entry for ChEBI) looks like: .. code-block:: json [ { "prefix": "CHEBI", "uri_prefix": "http://purl.obolibrary.org/obo/CHEBI_", "prefix_synonyms": ["chebi"], "uri_prefix_synonyms": [ "https://identifiers.org/chebi:" ] } ] Extended prefix maps can be loaded with :func:`curies.load_extended_prefix_map`. First, a prefix map can be loaded directly from a Python data structure like in .. code-block:: python import curies epm = [ { "prefix": "CHEBI", "uri_prefix": "http://purl.obolibrary.org/obo/CHEBI_", "prefix_synonyms": ["chebi"], "uri_prefix_synonyms": ["https://identifiers.org/chebi:"], } ] converter = curies.load_extended_prefix_map(data) An extended prefix map can be loaded from a remote file via HTTP, HTTPS, or FTP with .. code-block:: python import curies url = "https://raw.githubusercontent.com/mapping-commons/sssom-py/master/src/sssom/obo.epm.json" converter = curies.load_extended_prefix_map(url) Similarly, an extended prefix map stored in a local file can be loaded with the following. This works with both :class:`pathlib.Path` and vanilla strings. .. code-block:: python from pathlib import Path from urllib.request import urlretrieve import curies url = "https://raw.githubusercontent.com/mapping-commons/sssom-py/master/src/sssom/obo.epm.json" path = Path.home().joinpath("Downloads", "obo.epm.json") urlretrieve(url, path) converter = curies.load_extended_prefix_map(path) Loading JSON-LD Contexts ======================== A `JSON-LD context `_ allows for embedding of a simple prefix map within a linked data document. They can be identified hiding in all sorts of JSON (or JSON-like) content with the key ``@context``. JSON-LD contexts can be loaded using :meth:`curies.Converter.from_jsonld`. First, a JSON-LD context can be loaded directly from a Python data structure like in .. code-block:: python import curies data = {"@context": {"CHEBI": "http://purl.obolibrary.org/obo/CHEBI_"}} converter = curies.load_jsonld_context(data) .. note:: This correctly handles the more complex data structures including ``@prefix`` noted in `here `_. A JSON-LD context can be loaded from a remote file via HTTP, HTTPS, or FTP with .. code-block:: python import curies url = "https://raw.githubusercontent.com/biopragmatics/bioregistry/main/exports/contexts/semweb.context.jsonld" converter = curies.load_jsonld_context(url) A JSON-LD context stored in a local file can be loaded with the following. This works with both :class:`pathlib.Path` and vanilla strings. .. code-block:: python from pathlib import Path from urllib.request import urlretrieve import curies url = "https://raw.githubusercontent.com/biopragmatics/bioregistry/main/exports/contexts/semweb.context.jsonld" path = Path.home().joinpath("Downloads", "semweb.context.jsonld") urlretrieve(url, path) converter = curies.load_jsonld_context(path) Loading SHACL ============= The `shapes constraint language (SHACL) `_ can be used to represent prefix maps directly in RDF using the `sh:prefix` and `sh:namespace` predicates. Therefore, the simple ChEBI example from before can be represented using .. code-block:: turtle @prefix sh: . [ sh:declare [ sh:prefix "CHEBI" ; sh:namespace "http://purl.obolibrary.org/obo/CHEBI_" . ] . ] A SHACL context can be loaded from a remote file via HTTP, HTTPS, or FTP with .. code-block:: python import curies url = "https://raw.githubusercontent.com/biopragmatics/bioregistry/main/exports/contexts/semweb.context.ttl" converter = curies.load_shacl(url) A SHACL context stored in a local file can be loaded with the following. This works with both :class:`pathlib.Path` and vanilla strings. .. code-block:: python from pathlib import Path from urllib.request import urlretrieve import curies url = "https://raw.githubusercontent.com/biopragmatics/bioregistry/main/exports/contexts/semweb.context.ttl" path = Path.home().joinpath("Downloads", "semweb.context.ttl") urlretrieve(url, path) converter = curies.load_shacl(path) **************************** Introspecting on a Context **************************** After loading a context, it's possible to get certain information out of the converter. For example, if you want to get all of the CURIE prefixes from the converter, you can use :meth:`Converter.get_prefixes`: .. code-block:: python import curies converter = curies.get_bioregistry_converter() prefixes = converter.get_prefixes() assert "chebi" in prefixes assert "CHEBIID" not in prefixes, "No synonyms are included by default" prefixes = converter.get_prefixes(include_synonyms=True) assert "chebi" in prefixes assert "CHEBIID" in prefixes Similarly, the URI prefixes can be extracted with :meth:`Converter.get_uri_prefixes` like in: .. code-block:: python import curies converter = curies.get_bioregistry_converter() uri_prefixes = converter.get_uri_prefixes() assert "http://purl.obolibrary.org/obo/CHEBI_" in prefixes assert ( "https://bioregistry.io/chebi:" not in prefixes ), "No synonyms are included by default" uri_prefixes = converter.get_uri_prefixes(include_synonyms=True) assert "http://purl.obolibrary.org/obo/CHEBI_" in prefixes assert "https://bioregistry.io/chebi:" in prefixes It's also possible to get a bijective prefix map, i.e., a dictionary from primary CURIE prefixes to primary URI prefixes. This is useful for compatibility with legacy systems which assume simple prefix maps. This can be done with the ``bimap`` property like in the following: .. code-block:: python import curies converter = curies.get_bioregistry_converter() prefix_map = converter.bimap assert prefix_map["chebi"] == "http://purl.obolibrary.org/obo/CHEBI_" ********************* Modifying a Context ********************* Incremental Converters ====================== As suggested in `#13 `_, new data can be added to an existing converter with either :meth:`curies.Converter.add_prefix` or :meth:`curies.Converter.add_record`. For example, a CURIE and URI prefix for HGNC can be added to the OBO Foundry converter with the following: .. code-block:: import curies converter = curies.get_obo_converter() converter.add_prefix("hgnc", "https://bioregistry.io/hgnc:") Similarly, an empty converter can be instantiated using an empty list for the `records` argument and prefixes can be added one at a time (note this currently does not allow for adding synonyms separately): .. code-block:: import curies converter = curies.Converter(records=[]) converter.add_prefix("hgnc", "https://bioregistry.io/hgnc:") A more flexible version of this operation first involves constructing a :class:`curies.Record` object: .. code-block:: import curies converter = curies.get_obo_converter() record = curies.Record(prefix="hgnc", uri_prefix="https://bioregistry.io/hgnc:") converter.add_record(record) By default, both of these operations will fail if the new content conflicts with existing content. If desired, the ``merge`` argument can be set to true to enable merging. Further, checking for conflicts and merging can be made to be case insensitive by setting ``case_sensitive`` to false. Such a merging strategy is the basis for wholesale merging of converters, described below. Chaining and Merging ==================== This package implements a faultless chain operation :func:`curies.chain` that is configurable for case sensitivity and fully considers all synonyms. :func:`curies.chain` prioritizes based on the order given. Therefore, if two prefix maps having the same prefix but different URI prefixes are given, the first is retained. The second is retained as a synonym >>> import curies >>> c1 = curies.load_prefix_map({"GO": "http://purl.obolibrary.org/obo/GO_"}) >>> c2 = curies.load_prefix_map({"GO": "https://identifiers.org/go:"}) >>> converter = curies.chain([c1, c2]) >>> converter.expand("GO:1234567") 'http://purl.obolibrary.org/obo/GO_1234567' >>> converter.compress("http://purl.obolibrary.org/obo/GO_1234567") 'GO:1234567' >>> converter.compress("https://identifiers.org/go:1234567") 'GO:1234567' Chain is the perfect tool if you want to override parts of an existing extended prefix map. For example, if you want to use most of the Bioregistry, but you would like to specify a custom URI prefix (e.g., using Identifiers.org), you can do the following .. code-block:: python import curies overrides = curies.load_prefix_map({"pubmed": "https://identifiers.org/pubmed:"}) bioregistry_converter = curies.get_bioregistry_converter() converter = curies.chain([overrides, bioregistry_converter]) assert converter.expand("pubmed:1234") == "https://identifiers.org/pubmed:1234" Subsetting ========== A subset of a converter can be extracted using :meth:`curies.Converter.get_subconverter`. This functionality is useful for downstream applications like the following: 1. You load a comprehensive extended prefix map, e.g., from the Bioregistry using :func:`curies.get_bioregistry_converter()`. 2. You load some data that conforms to this prefix map by convention. This is often the case for semantic mappings stored in the `SSSOM format `_. 3. You extract the list of prefixes *actually* used within your data 4. You subset the detailed extended prefix map to only include prefixes relevant for your data 5. You make some kind of output of the subsetted extended prefix map to go with your data. Effectively, this is a way of reconciling data. This is especially effective when using the Bioregistry or other comprehensive extended prefix maps. Here's a concrete example of doing this (which also includes a bit of data science) to do this on the SSSOM mappings from the `Disease Ontology `_ project. >>> import curies >>> import pandas as pd >>> import itertools as itt >>> commit = "faca4fc335f9a61902b9c47a1facd52a0d3d2f8b" >>> url = f"https://raw.githubusercontent.com/mapping-commons/disease-mappings/{commit}/mappings/doid.sssom.tsv" >>> df = pd.read_csv(url, sep="\t", comment="#") >>> prefixes = { ... curies.Reference.from_curie(curie).prefix ... for column in ["subject_id", "predicate_id", "object_id"] ... for curie in df[column] ... } >>> converter = curies.get_bioregistry_converter() >>> slim_converter = converter.get_subconverter(prefixes) ******************* Writing a Context ******************* After loading and modifying a context, there are several functions for writing a context to a file: - :func:`curies.write_extended_prefix_map` - :func:`curies.write_jsonld_context` - :func:`curies.write_shacl` - :func:`curies.write_tsv` Here's a self-contained example on how this works: >>> import curies >>> converter = curies.load_prefix_map( ... { ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... } ... ) >>> curies.write_shacl(converter, "example_shacl.ttl") which outputs the following file: .. code-block:: @prefix sh: . @prefix xsd: . [ sh:declare [ sh:prefix "CHEBI" ; sh:namespace "http://purl.obolibrary.org/obo/CHEBI_"^^xsd:anyURI ] ] . ************************************************ Faultless handling of overlapping URI prefixes ************************************************ Most implementations of URI parsing iterate through the CURIE prefix/URI prefix pairs in a prefix map, check if the given URI starts with the URI prefix, then returns the CURIE prefix if does. This becomes an issue when a given URI can match multiple overlapping URI prefixes in the prefix map. For example, the ChEBI URI prefix is ``http://purl.obolibrary.org/obo/CHEBI_`` and the more generic OBO URI prefix is ``http://purl.obolibrary.org/obo/``. Therefore, it is possible that a URI could be compressed two different ways, depending on the order of iteration. :mod:`curies` addresses this by using the `trie `_ data structure, which indexes potentially overlapping strings and allows for efficient lookup of the longest matching string (e.g., the URI prefix) in the tree to a given target string (e.g., the URI). .. image:: img/trie.png :width: 200px :alt: A graphical depiction of a trie. Reused under the CC0 license from Wikipedia. This has two benefits. First, it is correct. Second, searching the trie data structure can be done in sublinear time while iterating over a prefix map can only be done in linear time. When processing a lot of data, this makes a meaningful difference! The following code demonstrates that the scenario above. It will always return the correct CURIE ``CHEBI:1`` instead of the incorrect CURIE ``OBO:CHEBI_1``, regardless of the order of the dictionary, iteration, or any other factors. >>> import curies >>> converter = curies.load_prefix_map( ... { ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... "OBO": "http://purl.obolibrary.org/obo/", ... } ... ) >>> converter.compress("http://purl.obolibrary.org/obo/CHEBI_1") 'CHEBI:1' ***************** Standardization ***************** The :class:`curies.Converter` data structure supports prefix and URI prefix synonyms. The following example demonstrates using these synonyms to support standardizing prefixes, CURIEs, and URIs. Note below, the colloquial prefix `gomf`, sometimes used to represent the subspace in the `Gene Ontology (GO) `_ corresponding to molecular functions, is upgraded to the preferred prefix, ``GO``. >>> from curies import Converter, Record >>> converter = Converter( ... [ ... Record( ... prefix="GO", ... prefix_synonyms=["gomf", "gocc", "gobp", "go", ...], ... uri_prefix="http://purl.obolibrary.org/obo/GO_", ... uri_prefix_synonyms=[ ... "http://amigo.geneontology.org/amigo/term/GO:", ... "https://identifiers.org/GO:", ... # ... ... ], ... ), ... # ... ... ] ... ) >>> converter.standardize_prefix("gomf") 'GO' >>> converter.standardize_curie("gomf:0032571") 'GO:0032571' >>> converter.standardize_uri("http://amigo.geneontology.org/amigo/term/GO:0032571") 'http://purl.obolibrary.org/obo/GO_0032571' Note: non-standard URIs (i.e., ones based on URI prefix synonyms) can still be parsed with :meth:`curies.Converter.parse_uri` and compressed into CURIEs with :meth:`curies.Converter.compress`. ***************** Bulk Operations ***************** Expansion, compression, and standardization operations can be done in bulk to all rows in a :class:`pandas.DataFrame` using the following examples. Bulk Compress URIs ================== In order to demonstrate bulk operations using :meth:`curies.Converter.pd_compress`, we construct a small dataframe: .. code-block:: python import curies import pandas as pd df = pd.DataFrame( { "uri": [ "http://purl.obolibrary.org/obo/GO_0000010", "http://purl.obolibrary.org/obo/GO_0000011", "http://gudt.org/schema/gudt/baseCGSUnitDimensions", "http://qudt.org/schema/qudt/conversionMultiplier", ] } ) converter = curies.get_obo_converter() converter.pd_compress(df, column="uri", target_column="curie") Results will look like: ================================================= ========== uri curie ================================================= ========== http://purl.obolibrary.org/obo/GO_0000010 GO:0000010 http://purl.obolibrary.org/obo/GO_0000011 GO:0000011 http://gudt.org/schema/gudt/baseCGSUnitDimensions http://qudt.org/schema/qudt/conversionMultiplier ================================================= ========== Note that some URIs are not handled by the extended prefix map inside the converter, so if you want to pass those through, use ``passthrough=True`` like in .. code-block:: python converter.pd_compress(df, column="uri", target_column="curie", passthrough=True) ================================================= ================================================= uri curie ================================================= ================================================= http://purl.obolibrary.org/obo/GO_0000010 GO:0000010 http://purl.obolibrary.org/obo/GO_0000011 GO:0000011 http://gudt.org/schema/gudt/baseCGSUnitDimensions http://gudt.org/schema/gudt/baseCGSUnitDimensions http://qudt.org/schema/qudt/conversionMultiplier http://qudt.org/schema/qudt/conversionMultiplier ================================================= ================================================= The keyword ``ambiguous=True`` can be passed if the source column can either be a CURIE or URI. Then, the semantics of compression are used from :meth:`curies.Converter.compress_or_standardize`. Bulk Expand CURIEs ================== In order to demonstrate bulk operations using :meth:`curies.Converter.pd_expand`, we construct a small dataframe used in conjunction with the OBO converter (which only includes OBO Foundry ontology URI prefix expansions): .. code-block:: python import curies import pandas as pd df = pd.DataFrame( { "curie": [ "GO:0000001", "skos:exactMatch", ] } ) converter = curies.get_obo_converter() converter.pd_expand(df, column="curie", target_column="uri") =============== ========================================= curie uri =============== ========================================= GO:0000001 http://purl.obolibrary.org/obo/GO_0000001 skos:exactMatch =============== ========================================= Note that since ``skos`` is not in the OBO Foundry extended prefix map, no results are placed in the ``uri`` column. If you wan to pass through elements that can't be expanded, you can use ``passthrough=True`` like in: .. code-block:: python converter.pd_expand(df, column="curie", target_column="uri", passthrough=True) =============== ========================================= curie uri =============== ========================================= GO:0000001 http://purl.obolibrary.org/obo/GO_0000001 skos:exactMatch skos:exactMatch =============== ========================================= Alternatively, chaining together multiple converters (such as the Bioregistry) will yield better results .. code-block:: python import curies import pandas as pd df = pd.DataFrame( { "curie": [ "GO:0000001", "skos:exactMatch", ] } ) converter = curies.chain( [ curies.get_obo_converter(), curies.get_bioregistry_converter(), ] ) converter.pd_expand(df, column="curie", target_column="uri") =============== ============================================== curie uri =============== ============================================== GO:0000001 http://purl.obolibrary.org/obo/GO_0000001 skos:exactMatch http://www.w3.org/2004/02/skos/core#exactMatch =============== ============================================== The keyword ``ambiguous=True`` can be passed if the source column can either be a CURIE or URI. Then, the semantics of compression are used from :meth:`curies.Converter.compress_or_standardize`. Bulk Standardizing Prefixes =========================== The `Gene Ontology (GO) Annotations Database `_ distributes its file where references to proteins from the `Universal Protein Resource (UniProt) `_ use the prefix ``UniProtKB``. When using the Bioregistry's extended prefix map, these prefixes should be standardized to ``uniprot`` with :meth:`curies.Converter.pd_standardize_prefix`. This can be done in-place with the following: .. code-block:: python import pandas import curies # the first column represents the prefix for the protein, # called "DB" in the schema. This is where we want to upgrade # `UniProtKB` to `uniprot` df = pd.read_csv( "http://geneontology.org/gene-associations/goa_human.gaf.gz", sep="\t", comment="!", header=None, ) converter = curies.get_bioregistry_converter() converter.pd_standardize_prefix(df, column=0) The ``target_column`` keyword can be given if you don't want to overwrite the original. Bulk Standardizing CURIEs ========================= Using the same example data from GO, the sixth column contains CURIE for references such as `GO_REF:0000043 `_. When using the Bioregistry's extended prefix map, these CURIEs' prefixes should be standardized to ``go.ref`` with :meth:`curies.Converter.pd_standardize_curie`. This can be done in-place with the following: .. code-block:: python import pandas import curies df = pd.read_csv( "http://geneontology.org/gene-associations/goa_human.gaf.gz", sep="\t", comment="!", header=None, ) converter = curies.get_bioregistry_converter() converter.pd_standardize_curie(df, column=5) The ``target_column`` keyword can be given if you don't want to overwrite the original. File Operations =============== Apply in bulk to a CSV file with :meth:`curies.Converter.file_expand` and :meth:`curies.Converter.file_compress` (defaults to using tab separator): .. code-block:: python import curies path = ... converter = curies.get_obo_converter() # modifies file in place converter.file_compress(path, column=0) # modifies file in place converter.file_expand(path, column=0) Like with the Pandas operations, the keyword ``ambiguous=True``` can be set when entries can either be CURIEs or URIs. ********************************************* Tools for Developers and Semantic Engineers ********************************************* Working with strings that might be a URI or a CURIE =================================================== Sometimes, it's not clear if a string is a CURIE or a URI. While the `SafeCURIE syntax `_ is intended to address this, it's often overlooked. CURIE and URI Checks -------------------- The first way to handle this ambiguity is to be able to check if the string is a CURIE or a URI. Therefore, each :class:`curies.Converter` comes with functions for checking if a string is a CURIE (:meth:`curies.Converter.is_curie`) or a URI (:meth:`curies.Converter.is_uri`) under its definition. .. code-block:: python import curies converter = curies.get_obo_converter() assert converter.is_curie("GO:1234567") # This is a URL assert not converter.is_curie("http://purl.obolibrary.org/obo/GO_1234567") # This is a valid CURIE, but not under this converter's definition assert not converter.is_curie("pdb:2gc4") assert converter.is_uri("http://purl.obolibrary.org/obo/GO_1234567") # This is a CURIE, but not a URI assert converter.is_uri("GO:1234567") # This is a valid URI, but not under this converter's definition assert not converter.is_uri("http://proteopedia.org/wiki/index.php/2gc4") Extended Expansion and Compression ---------------------------------- The :meth:`curies.Converter.expand_or_standardize` extends the CURIE expansion function to handle the situation where you might get passed a CURIE or a URI. If it's a CURIE, expansions happen with the normal rules. If it's a URI, it tries to standardize it. .. code-block:: python from curies import Converter, Record converter = Converter.from_extended_prefix_map( [ Record( prefix="CHEBI", prefix_synonyms=["chebi"], uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", uri_prefix_synonyms=["https://identifiers.org/chebi:"], ), ] ) # Expand CURIEs assert ( converter.expand_or_standardize("CHEBI:138488") == "http://purl.obolibrary.org/obo/CHEBI_138488" ) assert ( converter.expand_or_standardize("chebi:138488") == "http://purl.obolibrary.org/obo/CHEBI_138488" ) # standardize URIs assert ( converter.expand_or_standardize("http://purl.obolibrary.org/obo/CHEBI_138488") == "http://purl.obolibrary.org/obo/CHEBI_138488" ) assert ( converter.expand_or_standardize("https://identifiers.org/chebi:138488") == "http://purl.obolibrary.org/obo/CHEBI_138488" ) # Handle cases that aren't valid w.r.t. the converter assert converter.expand_or_standardize("missing:0000000") is None assert converter.expand_or_standardize("https://example.com/missing:0000000") is None A similar workflow is implemented in :meth:`curies.Converter.compress_or_standardize` for compressing URIs where a CURIE might get passed. .. code-block:: python from curies import Converter, Record converter = Converter.from_extended_prefix_map( [ Record( prefix="CHEBI", prefix_synonyms=["chebi"], uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", uri_prefix_synonyms=["https://identifiers.org/chebi:"], ), ] ) # Compress URIs assert ( converter.compress_or_standardize("http://purl.obolibrary.org/obo/CHEBI_138488") == "CHEBI:138488" ) assert ( converter.compress_or_standardize("https://identifiers.org/chebi:138488") == "CHEBI:138488" ) # standardize CURIEs assert converter.compress_or_standardize("CHEBI:138488") == "CHEBI:138488" assert converter.compress_or_standardize("chebi:138488") == "CHEBI:138488" # Handle cases that aren't valid w.r.t. the converter assert converter.compress_or_standardize("missing:0000000") is None assert converter.compress_or_standardize("https://example.com/missing:0000000") is None Reusable data structures for references ======================================= While URIs and CURIEs are often represented as strings, for many programmatic applications, it is preferable to pre-parse them into a pair of prefix corresponding to a semantic space and local unique identifier from that semantic space. ``curies`` provides two complementary data structures for representing these pairs: 1. :mod:`curies.ReferenceTuple` - a native Python :class:`typing.NamedTuple` that is storage efficient, can be hashed, can be accessed by slicing, unpacking, or via attributes. 2. :mod:`curies.Reference` - a :class:`pydantic.BaseModel` that can be used directly with other Pydantic models, FastAPI, SQLModel, and other JSON-schemata Internally, :mod:`curies.ReferenceTuple` is used, but there is a big benefit to standardizing this data type and providing utilities to flip-flop back and forth to :mod:`curies.Reference`, which is preferable in data validation (such as when parsing OBO ontologies) Integrating with :mod:`rdflib` ============================== RDFlib is a pure Python package for manipulating RDF data. The following example shows how to bind the extended prefix map from a :class:`curies.Converter` to a graph (:class:`rdflib.Graph`). .. code-block:: python import curies, rdflib converter = curies.get_obo_converter() graph = rdflib.Graph() converter.bind_rdflib(graph) A more flexible approach is to instantiate a namespace manager (:class:`rdflib.namespace.NamespaceManager`) and bind directly to that. .. code-block:: python import curies, rdflib.namespace converter = curies.get_obo_converter() namespace_manager = rdflib.namespace.NamespaceManager(rdflib.Graph()) converter.bind_rdflib(namespace_manager) URI references for use in RDFLib's graph class can be constructed from CURIEs using a combination of :meth:`curies.Converter.expand` and :class:`rdflib.URIRef`. .. code-block:: python import curies, rdflib converter = curies.get_obo_converter() uri_ref = rdflib.URIRef(converter.expand("CHEBI:138488", strict=True)) biopragmatics-curies-aa8bd37/docs/source/typing.rst000066400000000000000000000060501525104130300226100ustar00rootroot00000000000000######## Typing ######## This package comes with utilities for better typing other resources. **************** Prefix Parsing **************** Let's say you have a table like this: ====== ========== ======== ====== prefix identifier name smiles ====== ========== ======== ====== CHEBI 16236 ethanol CCO CHEBI 28831 propanol CCCO CHOBI 44884 pentanol CCCCCO ====== ========== ======== ====== Note that there's a typo in the prefix on the fourth row in the prefix because it uses ``CHOBI`` instead of ``CHEBI``. In the following code, we simulate reading that file and show where the error shows up: .. code-block:: python import csv from pydantic import BaseModel, ValidationError from curies import Converter, Prefix converter = Converter.from_prefix_map( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) class Row(BaseModel): prefix: Prefix identifier: str name: str smiles: str records = [ {"prefix": "CHEBI", "identifier": "16236", "name": "ethanol", "smiles": "CCO"}, {"prefix": "CHEBI", "identifier": "28831", "name": "propanol", "smiles": "CCCO"}, {"prefix": "CHOBI", "identifier": "44884", "name": "pentanol", "smiles": "CCCCCO"}, ] for record in records: try: model = Row.model_validate(record, context=converter) except ValidationError as e: print(f"Issue parsing record {record}: {e}") continue Note that :meth:`pydantic.BaseModel.model_validate` allows for passing a "context". The :class:`curies.Prefix` class implements custom context handling, so if you pass a converter, it knows how to check using prefixes in the converter. *************** CURIE Parsing *************** Let's use a similar table, now with the prefix and identifier combine into CURIEs. =========== ======== ====== curie name smiles =========== ======== ====== CHEBI:16236 ethanol CCO CHEBI:28831 propanol CCCO CHOBI:44884 pentanol CCCCCO =========== ======== ====== Note that there's a typo in the prefix on the fourth row in the prefix because it uses ``CHOBI`` instead of ``CHEBI``. In the following code, we simulate reading that file and show where the error shows up: .. code-block:: python import csv from pydantic import BaseModel, ValidationError from curies import Converter, Reference converter = Converter.from_prefix_map( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) class Row(BaseModel): curie: Reference name: str smiles: str records = [ {"curie": "CHEBI:16236", "name": "ethanol", "smiles": "CCO"}, {"curie": "CHEBI:28831", "name": "propanol", "smiles": "CCCO"}, {"curie": "CHOBI:44884", "name": "pentanol", "smiles": "CCCCCO"}, ] for record in records: try: model = Row.model_validate(record, context=converter) except ValidationError as e: print(f"Issue parsing record {record}: {e}") continue biopragmatics-curies-aa8bd37/docs/source/w3c.rst000066400000000000000000000002261525104130300217710ustar00rootroot00000000000000################ W3C Validation ################ .. automodapi:: curies.w3c :no-inheritance-diagram: :no-heading: :include-all-objects: biopragmatics-curies-aa8bd37/pyproject.toml000066400000000000000000000151611525104130300212330ustar00rootroot00000000000000[build-system] requires = ["uv_build>=0.9.6,<1.0"] build-backend = "uv_build" [project] name = "curies" version = "0.15.1" description = "Idiomatic conversion between URIs and compact URIs (CURIEs)" readme = "README.md" authors = [ { name = "Charles Tapley Hoyt", email = "cthoyt@gmail.com" } ] maintainers = [ { name = "Charles Tapley Hoyt", email = "cthoyt@gmail.com" } ] # See https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#classifiers # Search tags using the controlled vocabulary at https://pypi.org/classifiers classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Operating System :: OS Independent", "Framework :: Pytest", "Framework :: tox", "Framework :: Sphinx", "Natural Language :: English", "Programming Language :: Python", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", "Programming Language :: Python :: 3 :: Only", "Typing :: Typed", ] keywords = [ "snekpack", # please keep this keyword to credit the cookiecutter-snekpack template "cookiecutter", "semantic web", "compact uniform resource identifiers", "uniform resource identifiers", "curies", "IRIs", "URIs", ] # License Information. # See PEP-639 at https://peps.python.org/pep-0639/#add-license-files-key license = "MIT" license-files = ["LICENSE"] requires-python = ">=3.11" dependencies = [ "pystow>=0.9.0", "pydantic>=2.0", "typing-extensions", ] [dependency-groups] tests = [ "pytest", "coverage[toml]", "requests", "httpx", "httpx2", ] docs = [ "sphinx>=8", "sphinx-rtd-theme>=3.0", "sphinx-click", "sphinx-automodapi", "autodoc-pydantic", ] lint = [ "ruff", ] typing = [ { include-group = "tests" }, "mypy", "types-requests", "types-PyYAML", "types-ujson", "pandas-stubs", "pydantic", "bioregistry", "types-Markdown", "types-defusedxml", "types-tqdm", "httpx", "httpx2", ] docs-lint = [ { include-group = "docs" }, "doc8", ] format-docs = [ { include-group = "docs" }, "docstrfmt", ] doctests = [ "xdoctest", "pygments", ] pyroma = [ "uv-build", "pyroma", "pygments", ] [project.optional-dependencies] pandas = [ "pandas", ] sqlalchemy = [ "sqlalchemy", ] sqlmodel = [ "sqlmodel", ] flask = [ "flask", "defusedxml", ] fastapi = [ "fastapi", "python-multipart", "httpx2", "defusedxml", "uvicorn", ] rdflib = [ "rdflib" ] # See https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#urls # and also https://packaging.python.org/en/latest/specifications/well-known-project-urls/ [project.urls] "Bug Tracker" = "https://github.com/biopragmatics/curies/issues" Homepage = "https://github.com/biopragmatics/curies" Repository = "https://github.com/biopragmatics/curies.git" Documentation = "https://curies.readthedocs.io" Funding = "https://github.com/sponsors/cthoyt" [project.scripts] curies = "curies.cli:main" [tool.cruft] skip = [ "**/__init__.py", "tests/*" ] # MyPy, see https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] plugins = [ "pydantic.mypy", ] # Doc8, see https://doc8.readthedocs.io/en/stable/readme.html#ini-file-usage [tool.doc8] max-line-length = 120 # Pytest, see https://docs.pytest.org/en/stable/reference/customize.html#pyproject-toml [tool.pytest.ini_options] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", ] filterwarnings = [ "ignore::pyparsing.PyparsingDeprecationWarning", ] # Coverage, see https://coverage.readthedocs.io/en/latest/config.html [tool.coverage.run] branch = true source = [ "curies", ] omit = [ "tests/*", "docs/*", "src/curies/version.py", "src/curies/__main__.py", "src/curies/cli.py", ] [tool.coverage.paths] source = [ "src/curies", ".tox/*/lib/python*/site-packages/curies", ] [tool.coverage.report] show_missing = true exclude_lines = [ "pragma: no cover", "raise NotImplementedError", "if __name__ == \"__main__\":", "if TYPE_CHECKING:", "def __str__", "def __repr__", ] [tool.ruff] line-length = 100 extend-include = ["*.ipynb"] [tool.ruff.lint] # See https://docs.astral.sh/ruff/rules extend-select = [ "F", # pyflakes "E", # pycodestyle errors "W", # pycodestyle warnings "C90", # mccabe "I", # isort "UP", # pyupgrade "D", # pydocstyle "DOC", # pydoclint "B", # bugbear "S", # bandit "T20", # print "N", # pep8 naming "ERA", # eradicate commented out code "NPY", # numpy checks "RUF", # ruff rules "C4", # comprehensions ] ignore = [ "D105", # Missing docstring in magic method "E203", # Black conflicts with the following "ERA001", # TODO remove "E501", # TODO remove "N818", # TODO remove "C901", # TODO remove? "DTZ011", ] # See https://docs.astral.sh/ruff/settings/#per-file-ignores [tool.ruff.lint.per-file-ignores] # Ignore security issues in the version.py, which are inconsistent "src/curies/version.py" = ["S603", "S607"] # Ignore commented out code in Sphinx configuration file "docs/source/conf.py" = ["ERA001"] # Prints are okay in notebooks "notebooks/**/*.ipynb" = ["T201"] [tool.ruff.lint.pydocstyle] convention = "pep257" [tool.ruff.lint.isort] relative-imports-order = "closest-to-furthest" known-third-party = [ "tqdm", ] known-first-party = [ "curies", "tests", ] [tool.ruff.format] # see https://docs.astral.sh/ruff/settings/#format_docstring-code-format docstring-code-format = true [tool.bumpversion] current_version = "0.15.1" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(?:-(?P[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+(?P[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?" serialize = [ "{major}.{minor}.{patch}-{release}+{build}", "{major}.{minor}.{patch}+{build}", "{major}.{minor}.{patch}-{release}", "{major}.{minor}.{patch}", ] commit = true tag = false [tool.bumpversion.parts.release] optional_value = "production" first_value = "dev" values = [ "dev", "production", ] [[tool.bumpversion.files]] filename = "pyproject.toml" search = "version = \"{current_version}\"" replace = "version = \"{new_version}\"" [[tool.bumpversion.files]] filename = "docs/source/conf.py" search = "release = \"{current_version}\"" replace = "release = \"{new_version}\"" [[tool.bumpversion.files]] filename = "src/curies/version.py" search = "VERSION = \"{current_version}\"" replace = "VERSION = \"{new_version}\"" biopragmatics-curies-aa8bd37/src/000077500000000000000000000000001525104130300171025ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/src/curies/000077500000000000000000000000001525104130300203745ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/src/curies/__init__.py000066400000000000000000000043521525104130300225110ustar00rootroot00000000000000"""Idiomatic conversion between URIs and compact URIs (CURIEs).""" from .api import ( Converter, DuplicatePrefixes, DuplicateURIPrefixes, DuplicateValueError, NamableReference, NamedReference, Prefix, PrefixMap, Record, Records, Reference, ReferenceTuple, chain, load_extended_prefix_map, load_jsonld_context, load_prefix_map, load_shacl, upgrade_prefix_map, write_extended_prefix_map, write_jsonld_context, write_shacl, write_tsv, ) from .dataframe import filter_df_by_curies, filter_df_by_prefixes from .discovery import discover, discover_from_rdf from .mixins import SemanticallyProcessable, SemanticallyStandardizable, process, standardize from .preprocessing import ( PostprocessingRewrites, PreprocessingBlocklists, PreprocessingConverter, PreprocessingRewrites, PreprocessingRules, ) from .reconciliation import remap_curie_prefixes, remap_uri_prefixes, rewire from .sources import ( get_bioregistry_converter, get_go_converter, get_monarch_converter, get_obo_converter, get_prefixcommons_converter, ) from .triples import Triple from .version import get_version __all__ = [ "Converter", "DuplicatePrefixes", "DuplicateURIPrefixes", "DuplicateValueError", "NamableReference", "NamedReference", "PostprocessingRewrites", "Prefix", "PrefixMap", "PreprocessingBlocklists", "PreprocessingConverter", "PreprocessingRewrites", "PreprocessingRules", "Record", "Records", "Reference", "ReferenceTuple", "SemanticallyProcessable", "SemanticallyStandardizable", "Triple", "chain", "discover", "discover_from_rdf", "filter_df_by_curies", "filter_df_by_prefixes", "get_bioregistry_converter", "get_go_converter", "get_monarch_converter", "get_obo_converter", "get_prefixcommons_converter", "get_version", "load_extended_prefix_map", "load_jsonld_context", "load_prefix_map", "load_shacl", "process", "remap_curie_prefixes", "remap_uri_prefixes", "rewire", "standardize", "upgrade_prefix_map", "write_extended_prefix_map", "write_jsonld_context", "write_shacl", "write_tsv", ] biopragmatics-curies-aa8bd37/src/curies/__main__.py000066400000000000000000000001531525104130300224650ustar00rootroot00000000000000"""Command line interface for ``curies``.""" from .cli import main if __name__ == "__main__": main() biopragmatics-curies-aa8bd37/src/curies/api.py000066400000000000000000003701641525104130300215320ustar00rootroot00000000000000"""Data structures and algorithms for :mod:`curies`.""" from __future__ import annotations import csv import itertools as itt import json import logging from collections import UserDict, defaultdict from collections.abc import Callable, Collection, Iterable, Iterator, Mapping, Sequence from functools import partial from pathlib import Path from textwrap import dedent from typing import ( TYPE_CHECKING, Annotated, Any, Literal, NamedTuple, Self, TypeAlias, TypeVar, Union, cast, overload, ) from pydantic import ( AfterValidator, AnyUrl, BaseModel, ConfigDict, Field, GetCoreSchemaHandler, RootModel, field_validator, model_validator, ) from pydantic_core import core_schema from .utils import NoCURIEDelimiterError, _split if TYPE_CHECKING: # pragma: no cover import httpx import httpx2 import pandas import rdflib from .triples import Triple __all__ = [ "Converter", "DuplicatePrefixes", "DuplicateURIPrefixes", "DuplicateValueError", "NamableReference", "NamedReference", "NoCURIEDelimiterError", "Prefix", "PrefixMap", "Record", "Records", "Reference", "ReferenceTuple", "Trie", "TrieNode", "chain", "load_extended_prefix_map", "load_jsonld_context", "load_prefix_map", "load_shacl", "upgrade_prefix_map", "write_extended_prefix_map", "write_jsonld_context", "write_shacl", "write_tsv", ] logger = logging.getLogger(__name__) X = TypeVar("X") LocationOr: TypeAlias = str | Path | X #: A hint for a URI, must include a str() method that does the expected thing URIType: TypeAlias = Union[str, AnyUrl, "rdflib.URIRef", "httpx.URL", "httpx2.URL"] def _get_field_validator_values(values: Any, key: str) -> str: """Get the value for the key from a field validator object.""" return cast(str, values.data[key]) class ReferenceTuple(NamedTuple): """A pair of a prefix (corresponding to a semantic space) and a local unique identifier in that semantic space. This class derives from the "named tuple" which means that it acts like a tuple in most senses - it can be hashed and unpacked like most other tuples. Underneath, it has a C implementation and is very efficient. A reference tuple can be constructed two ways: >>> ReferenceTuple("chebi", "1234") ReferenceTuple(prefix='chebi', identifier='1234') >>> ReferenceTuple.from_curie("chebi:1234") ReferenceTuple(prefix='chebi', identifier='1234') A reference tuple can be formatted as a CURIE string with the ``curie`` attribute >>> ReferenceTuple.from_curie("chebi:1234").curie 'chebi:1234' Reference tuples can be sliced like regular 2-tuples >>> t = ReferenceTuple.from_curie("chebi:1234") >>> t[0] 'chebi' >>> t[1] '1234' Similarly, reference tuples can be unpacked like regular 2-tuples >>> prefix, identifier = ReferenceTuple.from_curie("chebi:1234") >>> prefix 'chebi' >>> identifier '1234' Because they are named tuples, reference tuples can be accessed with attributes >>> t = ReferenceTuple.from_curie("chebi:1234") >>> t.prefix 'chebi' >>> t.identifier '1234' """ prefix: str identifier: str @property def curie(self) -> str: """Get the reference as a CURIE string. :returns: A string representation of a compact URI (CURIE). >>> ReferenceTuple("chebi", "1234").curie 'chebi:1234' """ return f"{self.prefix}:{self.identifier}" @classmethod def from_curie(cls, curie: str, *, sep: str = ":") -> Self: """Parse a CURIE string and populate a reference tuple. :param curie: A string representation of a compact URI (CURIE) :param sep: The separator :returns: A reference tuple >>> ReferenceTuple.from_curie("chebi:1234") ReferenceTuple(prefix='chebi', identifier='1234') """ prefix, identifier = _split(curie, sep=sep) return cls(prefix, identifier) # docstr-coverage:excused `overload` @overload def to_pydantic(self, *, name: str = ...) -> NamedReference: ... # docstr-coverage:excused `overload` @overload def to_pydantic(self, *, name: None = ...) -> Reference: ... def to_pydantic(self, *, name: str | None = None) -> Reference | NamedReference: """Get a Pydantic model.""" if name is None: return Reference(prefix=Prefix(self.prefix), identifier=self.identifier) if not name: raise ValueError( f"tried to construct a pydantic named reference with a missing name from {self.curie}" ) return NamedReference(prefix=Prefix(self.prefix), identifier=self.identifier, name=name) class Prefix(str): """A string that is validated by Pydantic as a CURIE prefix. This class is a subclass of Python's built-in string class, so you can wrap any string with it: .. code-block:: python from curies import Prefix prefix = Prefix("CHEBI") You can implicitly type annotate data with this class: .. code-block:: python from curies import Prefix prefix_map: dict[Prefix, str] = { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } You can more explicitly type annotate data with this class using Pydantic's `root model `_: .. code-block:: python from pydantic import RootModel from curies import Prefix PrefixMap = RootModel[dict[Prefix, str]] prefix_map = PrefixMap.model_validate( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ).root This pattern is common enough that it's included in :class:`curies.PrefixMap`. When used inside a Pydantic model, this class knows how to do validation that the prefix matches the regular expression for an XSD NCName. Here's an example usage with Pydantic: .. code-block:: python from curies import Prefix from pydantic import BaseModel class ResourceInfo(BaseModel): prefix: Prefix name: str model = ResourceInfo.model_validate( { "prefix": "CHEBI", "name": "Chemical Entities of Biological Interest", } ) # raises a pydantic.ValidationError, because the prefix # doesn't match the NCName pattern ResourceInfo.model_validate( { "prefix": "$nope", "name": "An invalid semantic space!", } ) This class implements a hook that uses Pydantic's "context" for validation that lets you pass a :class:`Converter` to check for existence and standardization with respect to the context in the converter: .. code-block:: python from curies import Prefix, get_obo_converter from pydantic import BaseModel class ResourceInfo(BaseModel): prefix: Prefix name: str converter = get_obo_converter() model = ResourceInfo.model_validate( { "prefix": "CHEBI", "name": "Chemical Entities of Biological Interest", }, context=converter, ) # raises a pydantic.ValidationError, because the prefix # is not registered in the OBO Foundry, and is therefore # not part of the OBO converter ResourceInfo.model_validate( { "prefix": "efo", "name": "Experimental Factor Ontology", }, context=converter, ) # In case you need to pass more arbitrary # context, you can also use a dict with the key # "converter" ResourceInfo.model_validate( { "prefix": "CHEBI", "name": "Chemical Entities of Biological Interest", }, context={ "converter": converter, }, ) .. warning:: Serialization with YAML with :mod:`yaml` might not work as expected for classes containing :class:`Prefix` instances, since they are subclasses of strings. Do something like this: .. code-block:: python def _reference_representer( dumper: SafeRepresenter, data: curies.Prefix ) -> yaml.ScalarNode: return dumper.represent_str(str(data)) # if you're using yaml.safe_dump() yaml.add_representer(curies.Prefix, _reference_representer, Dumper=yaml.SafeDumper) # if you're using yaml.dump() yaml.add_representer(curies.Prefix, _reference_representer, Dumper=yaml.Dumper) """ @classmethod def __get_pydantic_core_schema__( cls, source: type[Any], handler: GetCoreSchemaHandler ) -> core_schema.AfterValidatorFunctionSchema: return core_schema.with_info_after_validator_function( cls._validate, # TODO consider if we should use strict NCNAME pattern # here like ^$|^[a-zA-Z_][\w.-]*$. See also # https://cthoyt.com/2023/01/11/bioregistry-w3c-compliance.html core_schema.str_schema(strict=False), ) @classmethod def _validate(cls, /, __input_value: str, info: core_schema.ValidationInfo) -> Self: converter = _converter_from_validation_info(info) if converter is None: return cls(__input_value) return cls(converter.standardize_prefix(__input_value, strict=True)) class PrefixMap(RootModel[dict[Prefix, str]]): """A simple prefix map. This can be used to validate dictionaries: .. code-block:: python from curies import PrefixMap prefix_map_model = PrefixMap.model_validate( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) # note that you have to unpack it prefix_map_dict = prefix_map_model.root Similarly, a prefix map can be used as part of another Pydantic model like in: .. code-block:: python from pydantic import BaseModel from curies import PrefixMap class RDFContent(BaseModel): prefix_map: PrefixMap triples: list[tuple[str, str, str]] rdf_content = RDFContent.model_validate( { "prefix_map": { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", }, "triples": [ ("CHEBI:1234", "RO:0000001", "CHEBI:5678"), ], } ) # note that you have to unpack the resulting prefix map prefix_map = rdf_content.prefix_map.root """ class MalformedIdentifierError(ValueError): """Raise for malformed local unique identifiers.""" def _validate_identifier(s: str) -> str: if " " in s: raise MalformedIdentifierError(f"local identifiers can not contain spaces: {s}") return s class Reference(BaseModel): """A reference to an entity in a given identifier space. This class uses Pydantic to make it easier to build other more complex data types with Pydantic that also uses a first- class notion of parsed reference (instead of merely stringified CURIEs). Instances of this class can also be hashed because of the "frozen" configuration from Pydantic (see https://docs.pydantic.dev/latest/usage/model_config/ for more details). A reference can be constructed several ways: >>> Reference(prefix="chebi", identifier="1234") Reference(prefix='chebi', identifier='1234') >>> Reference.from_curie("chebi:1234") Reference(prefix='chebi', identifier='1234') A reference can also be constructued using Pydantic's parsing utilities, but keep in mind if you're using Pydantic v1 or Pydantic v2. A reference can be formatted as a CURIE string with the ``curie`` attribute >>> Reference.from_curie("chebi:1234").curie 'chebi:1234' References can't be sliced like reference tuples, but they can still be accessed through attributes >>> t = Reference.from_curie("chebi:1234") >>> t.prefix 'chebi' >>> t.identifier '1234' If you need a performance gain, you can get a :class:`ReferenceTuple` using the ``pair`` attribute: >>> reference = Reference.from_curie("chebi:1234") >>> reference.pair ReferenceTuple(prefix='chebi', identifier='1234') """ prefix: Annotated[ Prefix, Field( description="The prefix used in a compact URI (CURIE).", ), ] identifier: Annotated[ str, AfterValidator(_validate_identifier), Field(description="The local unique identifier used in a compact URI (CURIE)."), ] model_config = ConfigDict(frozen=True) @model_validator(mode="before") def _parse_from_string(cls, values: str | dict[str, str]) -> dict[str, str]: # noqa:N805 if isinstance(values, str): prefix, identifier = _split(values) return {"prefix": prefix, "identifier": identifier} return values def __lt__(self, other: Reference) -> bool: """Sort the reference lexically first by prefix, then by identifier.""" return self.pair < other.pair def __hash__(self) -> int: return hash((self.prefix, self.identifier)) def __eq__(self, other: object) -> bool: return ( isinstance(other, Reference) and self.prefix == other.prefix and self.identifier == other.identifier ) def __composite_values__(self) -> tuple[str, str]: """Return values appropriate for :func:`sqlalchemy.orm.composite`.""" return self.prefix, self.identifier @property def curie(self) -> str: """Get the reference as a CURIE string. :returns: A string representation of a compact URI (CURIE). >>> Reference(prefix="chebi", identifier="1234").curie 'chebi:1234' """ return f"{self.prefix}:{self.identifier}" @property def pair(self) -> ReferenceTuple: """Get the reference as a 2-tuple of prefix and identifier.""" return ReferenceTuple(self.prefix, self.identifier) # note that it's important that this is explicitly # Reference and not Self, since all subclasses should # return only a reference. def without_name(self) -> Reference: """Return this reference, since it already has no name.""" return self def with_name(self, name: str) -> NamableReference: """Return this reference, with a name.""" return NamedReference(prefix=self.prefix, identifier=self.identifier, name=name) @classmethod def from_curie(cls, curie: str, *, sep: str = ":", converter: Converter | None = None) -> Self: """Parse a CURIE string and populate a reference. :param curie: A string representation of a compact URI (CURIE) :param sep: The separator :param converter: The converter to use as context when parsing :returns: A reference object >>> Reference.from_curie("chebi:1234") Reference(prefix='chebi', identifier='1234') """ prefix, identifier = _split(curie, sep=sep) return cls.model_validate({"prefix": prefix, "identifier": identifier}, context=converter) @classmethod def from_reference( cls, reference: Reference | ReferenceTuple, *, converter: Converter | None = None ) -> Self: """Parse a CURIE string and populate a reference. :param reference: A pre-parsed reference :param converter: The converter to use as context when parsing :returns: A reference object """ return cls.model_validate( {"prefix": reference.prefix, "identifier": reference.identifier}, context=converter ) class NamableReference(Reference): """A reference, maybe with a name.""" name: Annotated[ str | None, Field( description="The name of the entity referenced by this object's prefix and identifier, if exists.", ), ] = None model_config = ConfigDict(frozen=True) @classmethod def from_curie( cls, curie: str, name: str | None = None, *, sep: str = ":", converter: Converter | None = None, ) -> Self: """Parse a CURIE string and populate a reference. :param curie: A string representation of a compact URI (CURIE) :param name: The optional name of the reference :param sep: The separator :param converter: The converter to use as context when parsing :returns: A reference object >>> NamableReference.from_curie("chebi:1234") NamableReference(prefix='chebi', identifier='1234', name=None) >>> NamableReference.from_curie("chebi:1234", "6-methoxy-2-octaprenyl-1,4-benzoquinone") NamableReference(prefix='chebi', identifier='1234', name='6-methoxy-2-octaprenyl-1,4-benzoquinone') """ prefix, identifier = _split(curie, sep=sep) return cls.model_validate( {"prefix": prefix, "identifier": identifier, "name": name}, context=converter ) @classmethod def from_reference( cls, reference: Reference | ReferenceTuple, *, converter: Converter | None = None ) -> Self: """Parse a CURIE string and populate a reference. :param reference: A pre-parsed reference :param converter: The converter to use as context when parsing :returns: A reference object """ return cls.model_validate( { "prefix": reference.prefix, "identifier": reference.identifier, "name": getattr(reference, "name", None), }, context=converter, ) def without_name(self) -> Reference: """Return this reference without a name.""" return Reference(prefix=self.prefix, identifier=self.identifier) def with_name(self, name: str) -> Self: """Return this reference, with a name.""" return self.model_copy(update={"name": name}) class NamedReference(NamableReference): """A reference with a name.""" name: Annotated[ str, Field( description="The name of the entity referenced by this object's prefix and identifier." ), ] model_config = ConfigDict(frozen=True) @classmethod def from_curie( # type:ignore cls, curie: str, name: str, *, sep: str = ":", converter: Converter | None = None ) -> Self: """Parse a CURIE string and populate a reference. :param curie: A string representation of a compact URI (CURIE) :param name: The name of the reference :param sep: The separator :param converter: The converter to use as context when parsing :returns: A reference object >>> NamedReference.from_curie("chebi:1234", "6-methoxy-2-octaprenyl-1,4-benzoquinone") NamedReference(prefix='chebi', identifier='1234', name='6-methoxy-2-octaprenyl-1,4-benzoquinone') """ prefix, identifier = _split(curie, sep=sep) return cls.model_validate( {"prefix": prefix, "identifier": identifier, "name": name}, context=converter ) @classmethod def from_reference( cls, reference: Reference | ReferenceTuple, *, converter: Converter | None = None ) -> Self: """Parse a CURIE string and populate a reference. :param reference: A pre-parsed reference :param converter: The converter to use as context when parsing :returns: A reference object :raises TypeError: if a reference that has no name field is passed (e.g., a vanilla :class:`curies.Reference`) """ if not isinstance(reference, NamableReference): raise TypeError( f"tried to construct a named reference from a non-named reference: {reference}" ) return cls.model_validate( { "prefix": reference.prefix, "identifier": reference.identifier, "name": reference.name, }, context=converter, ) def with_name(self, name: str) -> Self: """Return this reference, with a name.""" return self.model_copy(update={"name": name}) RecordKey = tuple[str, str, str, str] class Record(BaseModel): """A record of some prefixes and their associated URI prefixes. .. seealso:: https://github.com/cthoyt/curies/issues/70 """ prefix: Annotated[ str, Field( title="CURIE prefix", description="The canonical CURIE prefix, used in the reverse prefix map", ), ] uri_prefix: Annotated[ str, Field( title="URI prefix", description="The canonical URI prefix, used in the forward prefix map", ), ] prefix_synonyms: list[str] = Field(default_factory=list, title="CURIE prefix synonyms") uri_prefix_synonyms: list[str] = Field(default_factory=list, title="URI prefix synonyms") pattern: Annotated[ str | None, Field( description="The regular expression pattern for entries in this semantic space. " "Warning: this is an experimental feature.", ), ] = None @field_validator("prefix_synonyms") # type:ignore @classmethod def prefix_not_in_synonyms(cls, v: str, values: Mapping[str, Any]) -> str: """Check that the canonical prefix does not apper in the prefix synonym list.""" prefix = _get_field_validator_values(values, "prefix") if prefix in v: raise ValueError(f"Duplicate of canonical prefix `{prefix}` in prefix synonyms") return v @field_validator("uri_prefix_synonyms") # type:ignore @classmethod def uri_prefix_not_in_synonyms(cls, v: str, values: Mapping[str, Any]) -> str: """Check that the canonical URI prefix does not apper in the URI prefix synonym list.""" uri_prefix = _get_field_validator_values(values, "uri_prefix") if uri_prefix in v: raise ValueError( f"Duplicate of canonical URI prefix `{uri_prefix}` in URI prefix synonyms" ) return v @property def _all_prefixes(self) -> list[str]: return [self.prefix, *self.prefix_synonyms] @property def _all_uri_prefixes(self) -> list[str]: return [self.uri_prefix, *self.uri_prefix_synonyms] @property def _key(self) -> RecordKey: """Get a hashable key.""" return ( self.prefix, self.uri_prefix, ",".join(sorted(self.prefix_synonyms)), ",".join(sorted(self.uri_prefix_synonyms)), ) # An explanation of RootModels in Pydantic V2 can be found on # https://docs.pydantic.dev/latest/concepts/models/#rootmodel-and-custom-root-types class Records(RootModel[list[Record]]): """A list of records.""" def __iter__(self) -> Iterator[Record]: # type:ignore[override] """Iterate over records.""" return iter(self.root) class DuplicateSummary(NamedTuple): """A triple representing two records that are duplicated, either based on a CURIE or URI prefix.""" record_1: Record record_2: Record prefix: str class DuplicateValueError(ValueError): """An error raised with constructing a converter with data containing duplicate values.""" def __init__(self, duplicates: list[DuplicateSummary]) -> None: """Initialize the error.""" self.duplicates = duplicates def _str(self) -> str: rv = "" for duplicate in self.duplicates: rv += f"\n{duplicate.prefix}:\n\t{duplicate.record_1}\n\t{duplicate.record_2}\n" return rv class DuplicateURIPrefixes(DuplicateValueError): """An error raised with constructing a converter with data containing duplicate URI prefixes.""" def __str__(self) -> str: return f"Duplicate URI prefixes:\n{self._str()}" class DuplicatePrefixes(DuplicateValueError): """An error raised with constructing a converter with data containing duplicate prefixes.""" def __str__(self) -> str: return f"Duplicate prefixes:\n{self._str()}" class ConversionError(ValueError): """An error raised on conversion.""" class ExpansionError(ConversionError): """An error raised on expansion if the prefix can't be looked up.""" class CompressionError(ConversionError): """An error raised on expansion if the URI prefix can't be matched.""" class StandardizationError(ValueError): """An error raised on standardization.""" class PrefixStandardizationError(StandardizationError): """An error raise when a prefix can't be standardized.""" class IdentifierStandardizationError(StandardizationError): """An error raise when an identifier can't be standardized.""" class CURIEStandardizationError(StandardizationError): """An error raise when a CURIE can't be standardized.""" class URIStandardizationError(StandardizationError): """An error raise when a URI can't be standardized.""" def _get_duplicate_uri_prefixes(records: list[Record]) -> list[DuplicateSummary]: return [ DuplicateSummary(record_1, record_2, uri_prefix) for record_1, record_2 in itt.combinations(records, 2) for uri_prefix, up2 in itt.product(record_1._all_uri_prefixes, record_2._all_uri_prefixes) if uri_prefix == up2 ] def _get_duplicate_prefixes(records: list[Record]) -> list[DuplicateSummary]: return [ DuplicateSummary(record_1, record_2, prefix) for record_1, record_2 in itt.combinations(records, 2) for prefix, p2 in itt.product(record_1._all_prefixes, record_2._all_prefixes) if prefix == p2 ] def _prepare(data: LocationOr[X]) -> X: if isinstance(data, Path): with data.open() as file: return cast(X, json.load(file)) elif isinstance(data, str): if any(data.startswith(p) for p in ("https://", "http://", "ftp://")): return cast(X, _get_remote_json(data)) with open(data) as file: return cast(X, json.load(file)) else: return data def _get_remote_json(url: str, timeout: int = 15) -> Any: import urllib.request with urllib.request.urlopen(url, timeout=timeout) as response: # noqa:S310 json_str_data = response.read().decode() return json.loads(json_str_data) class Converter: """A cached prefix map data structure. .. code-block:: # Construct a prefix map: >>> converter = Converter.from_prefix_map( ... { ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... "MONDO": "http://purl.obolibrary.org/obo/MONDO_", ... "GO": "http://purl.obolibrary.org/obo/GO_", ... "OBO": "http://purl.obolibrary.org/obo/", ... } ... ) # Compression and Expansion: >>> converter.compress("http://purl.obolibrary.org/obo/CHEBI_1") 'CHEBI:1' >>> converter.expand("CHEBI:1") 'http://purl.obolibrary.org/obo/CHEBI_1' # Example with unparsable URI: >>> converter.compress("http://example.com/missing:0000000") # Example with missing prefix: >>> converter.expand("missing:0000000") """ #: A prefix trie for efficient parsing of URIs trie: Trie #: A mapping from prefix to regular expression pattern. Not necessarily complete wrt the prefix map. #: #: .. warning:: patterns are an experimental feature pattern_map: dict[str, str] #: The list of records. Don't modify this directly records: list[Record] def __init__( self, records: Iterable[Record] | None = None, *, delimiter: str = ":", strict: bool = True ) -> None: """Instantiate a converter. :param records: A list of records. If you plan to build a converter incrementally, pass an empty list or leave this blank. :param strict: If true, raises issues on duplicate URI prefixes :param delimiter: The delimiter used for CURIEs. Defaults to a colon. :raises DuplicatePrefixes: if any records share any synonyms :raises DuplicateURIPrefixes: if any records share any URI prefixes """ if records is None: records = [] records = sorted(records, key=lambda r: r.prefix) if strict: duplicate_uri_prefixes = _get_duplicate_uri_prefixes(records) if duplicate_uri_prefixes: raise DuplicateURIPrefixes(duplicate_uri_prefixes) duplicate_prefixes = _get_duplicate_prefixes(records) if duplicate_prefixes: raise DuplicatePrefixes(duplicate_prefixes) self.delimiter = delimiter self.records = records self._prefix_to_record: dict[str, Record] = {} self._prefix_ci_to_record: dict[str, Record] = {} self._uri_prefix_to_record: dict[str, Record] = {} self._uri_prefix_ci_to_record: dict[str, Record] = {} self.trie = Trie() self.pattern_map = {} for record in records: self._index(record) @property def prefix_map(self) -> Mapping[str, str]: """Get the non-URI-prefix-unique prefix map..""" return { prefix: record.uri_prefix for record in self.records for prefix in record._all_prefixes } @property def reverse_prefix_map(self) -> Mapping[str, str]: """Get the non-URI-prefix-unique prefix map.""" return { uri_prefix: record.prefix for record in self.records for uri_prefix in record._all_uri_prefixes } def __len__(self) -> int: """Count the number of records.""" return len(self.records) def __iter__(self) -> Iterator[Record]: """Iterate over records.""" return iter(self.records) @property def bimap(self) -> Mapping[str, str]: """Get the bijective mapping between CURIE prefixes and URI prefixes.""" return {r.prefix: r.uri_prefix for r in self.records} @property def reverse_bimap(self) -> Mapping[str, str]: """Get the bijective mapping between URI CURIE prefixes and CURIE prefixes.""" return {r.uri_prefix: r.prefix for r in self.records} def has_prefix(self, prefix: str) -> bool: """Check if the converter has the prefix (either as a primary or secondary).""" return prefix in self._prefix_to_record def _match_record( self, external: Record, case_sensitive: bool = True ) -> Mapping[str, list[str]]: """Match the given record to existing records.""" rv: defaultdict[str, list[str]] = defaultdict(list) if case_sensitive: if record := self._prefix_to_record.get(external.prefix): rv[record.prefix].append( f"primary prefix ({external.prefix}) match to {self._label(external.prefix == record.prefix)} prefix for {record.prefix}" ) for prefix in external.prefix_synonyms: if record := self._prefix_to_record.get(prefix): rv[record.prefix].append( f"secondary prefix ({prefix}) matched {self._label(prefix == record.prefix)} prefix for {record.prefix}" ) if record := self._uri_prefix_to_record.get(external.uri_prefix): rv[record.prefix].append( f"primary URI prefix ({external.uri_prefix}) matched {self._label(record.uri_prefix == external.uri_prefix)} URI prefix for {record.prefix}" ) for uri_prefix in external.uri_prefix_synonyms: if record := self._uri_prefix_to_record.get(uri_prefix): rv[record.prefix].append( f"secondary URI prefix ({uri_prefix}) matched {self._label(record.uri_prefix == uri_prefix)} URI prefix for {record.prefix}" ) else: for prefix in external._all_prefixes: if record := self._prefix_ci_to_record.get(prefix.casefold()): rv[record.prefix].append("prefix case-insenstive match") for uri_prefix in external._all_uri_prefixes: if record := self._uri_prefix_ci_to_record.get(uri_prefix.casefold()): rv[record.prefix].append("URI case-insenstive prefix match") return dict(rv) @staticmethod def _label(x: bool) -> str: return "primary" if x else "secondary" def add_record( self, record: Record, *, case_sensitive: bool = True, merge: bool = False ) -> None: """Append a record to the converter.""" matched = self._match_record(record, case_sensitive=case_sensitive) if len(matched) > 1: msg = "".join(f"\n {m} -> {v}" for m, v in matched.items()) raise ValueError(f"new record has duplicates:{msg}") if len(matched) == 1: prefix, values = next(iter(matched.items())) if not merge: msg = "\n".join(f"- {v}" for v in values) raise ValueError( f"failed to add {record.prefix} because of overlaps. Full record:\n\n{record.model_dump_json(indent=2)}\n\nExplanation:\n{msg}\n" ) existing_record = self._prefix_to_record[prefix] self._merge(record, into=existing_record) self._index(existing_record) else: # Append a new record self.records.append(record) self._index(record) @staticmethod def _merge(record: Record, into: Record) -> None: xx = set(into._all_prefixes) for prefix in record._all_prefixes: if prefix not in xx: into.prefix_synonyms.append(prefix) into.prefix_synonyms.sort() yy = set(into._all_uri_prefixes) for uri_prefix in record._all_uri_prefixes: if uri_prefix not in yy: into.uri_prefix_synonyms.append(uri_prefix) into.uri_prefix_synonyms.sort() def _index(self, record: Record) -> None: for prefix in record._all_prefixes: self._index_prefix(prefix, record) for uri_prefix in record._all_uri_prefixes: self._index_uri_prefix(uri_prefix, record) if record.pattern and record.prefix not in self.pattern_map: self.pattern_map[record.prefix] = record.pattern def _index_prefix(self, prefix: str, record: Record) -> None: self._prefix_to_record[prefix] = record self._prefix_ci_to_record[prefix.casefold()] = record def _index_uri_prefix(self, uri_prefix: str, record: Record) -> None: self._uri_prefix_to_record[uri_prefix] = record self._uri_prefix_ci_to_record[uri_prefix.casefold()] = record self.trie[uri_prefix] = record def add_prefix_synonym( self, prefix: str, prefix_synonym: str, *, case_sensitive: bool = True ) -> None: """Add a prefix synonym to the record with the given prefix.""" if not case_sensitive: raise NotImplementedError try: record = self._prefix_to_record[prefix] except KeyError as e: raise KeyError( f"can not add prefix synoynm {prefix_synonym} to prefix {prefix} since {prefix} is not already indexed" ) from e sr = self._prefix_to_record.get(prefix_synonym) if sr is not None: if sr.prefix != record.prefix: raise ValueError(f"this prefix synonym is already taken by record for {sr.prefix}") return self._index_prefix(prefix_synonym, record) record.prefix_synonyms.append(prefix_synonym) record.prefix_synonyms.sort() def add_uri_prefix_synonym( self, prefix: str, uri_prefix_synonym: str, *, case_sensitive: bool = True ) -> None: """Add a URI synonym to the record with the given prefix.""" if not case_sensitive: raise NotImplementedError try: record = self._prefix_to_record[prefix] except KeyError as e: raise KeyError( f"can't add URI prefix synoynm {uri_prefix_synonym} to prefix {prefix} because " f"{prefix} is not already indexed" ) from e sr = self._uri_prefix_to_record.get(uri_prefix_synonym) if sr is not None: if sr.prefix != record.prefix: raise ValueError( f"can't add URI prefix synonym {uri_prefix_synonym} to {prefix} becauase " f"it is already taken by record for {sr.prefix}" ) return self._index_uri_prefix(uri_prefix_synonym, record) record.uri_prefix_synonyms.append(uri_prefix_synonym) record.uri_prefix_synonyms.sort() def add_prefix( self, prefix: str, uri_prefix: str, prefix_synonyms: Collection[str] | None = None, uri_prefix_synonyms: Collection[str] | None = None, *, pattern: str | None = None, case_sensitive: bool = True, merge: bool = False, ) -> None: """Append a prefix to the converter. :param prefix: The prefix to append, e.g., ``go`` :param uri_prefix: The URI prefix to append, e.g., ``http://purl.obolibrary.org/obo/GO_`` :param prefix_synonyms: An optional collection of synonyms for the prefix such as ``gomf``, ``gocc``, etc. :param uri_prefix_synonyms: An optional collections of synonyms for the URI prefix such as ``https://bioregistry.io/go:``, ``http://www.informatics.jax.org/searches/GO.cgi?id=GO:``, etc. :param pattern: An optional pattern :param case_sensitive: Should prefixes and URI prefixes be compared in a case-sensitive manner when checking for uniqueness? Defaults to True. :param merge: Should this record be merged into an existing record if it uniquely maps to a single existing record? When false, will raise an error if one or more existing records can be mapped. Defaults to false. This can be used to add missing namespaces on-the-fly to an existing converter: >>> import curies >>> converter = curies.get_obo_converter() >>> converter.add_prefix("hgnc", "https://bioregistry.io/hgnc:") >>> converter.expand("hgnc:1234") 'https://bioregistry.io/hgnc:1234' >>> converter.expand("GO:0032571") 'http://purl.obolibrary.org/obo/GO_0032571' This can also be used to incrementally build up a converter from scratch: >>> import curies >>> converter = curies.Converter(records=[]) >>> converter.add_prefix("hgnc", "https://bioregistry.io/hgnc:") >>> converter.expand("hgnc:1234") 'https://bioregistry.io/hgnc:1234' """ record = Record( prefix=prefix, uri_prefix=uri_prefix, prefix_synonyms=sorted(prefix_synonyms or []), uri_prefix_synonyms=sorted(uri_prefix_synonyms or []), pattern=pattern, ) self.add_record(record, case_sensitive=case_sensitive, merge=merge) @classmethod def from_extended_prefix_map( cls, records: LocationOr[Iterable[Record | dict[str, Any]]], **kwargs: Any ) -> Converter: """Get a converter from a list of dictionaries by creating records out of them. :param records: One of the following: - An iterable of :class:`curies.Record` objects or dictionaries that will get converted into record objects that together constitute an extended prefix map - A string containing a remote location of a JSON file containg an extended prefix map - A string or :class:`pathlib.Path` object corresponding to a local file path to a JSON file containing an extended prefix map :param kwargs: Keyword arguments to pass to :meth:`curies.Converter.__init__` :returns: A converter An extended prefix map is a list of dictionaries containing four keys: 1. A ``prefix`` string 2. A ``uri_prefix`` string 3. An optional list of strings ``prefix_synonyms`` 4. An optional list of strings ``uri_prefix_synonyms`` Across the whole list of dictionaries, there should be uniqueness within the union of all ``prefix`` and ``prefix_synonyms`` as well as uniqueness within the union of all ``uri_prefix`` and ``uri_prefix_synonyms``. >>> epm = [ ... { ... "prefix": "CHEBI", ... "prefix_synonyms": ["chebi", "ChEBI"], ... "uri_prefix": "http://purl.obolibrary.org/obo/CHEBI_", ... "uri_prefix_synonyms": [ ... "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:" ... ], ... }, ... { ... "prefix": "GO", ... "uri_prefix": "http://purl.obolibrary.org/obo/GO_", ... }, ... ] >>> converter = Converter.from_extended_prefix_map(epm) Expand using the preferred/canonical prefix: >>> converter.expand("CHEBI:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' Expand using a prefix synonym: >>> converter.expand("chebi:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' Compress using the preferred/canonical URI prefix: >>> converter.compress("http://purl.obolibrary.org/obo/CHEBI_138488") 'CHEBI:138488' Compressing using a URI prefix synonym: >>> converter.compress("https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:138488") 'CHEBI:138488' Example from a remote source: >>> url = "https://github.com/biopragmatics/bioregistry/raw/main/exports/contexts/bioregistry.epm.json" >>> converter = Converter.from_extended_prefix_map(url) """ return cls( records=[ record if isinstance(record, Record) else Record(**record) for record in _prepare(records) ], **kwargs, ) @classmethod def from_priority_prefix_map( cls, data: LocationOr[Mapping[str, list[str]]], **kwargs: Any ) -> Converter: """Get a converter from a priority prefix map. :param data: A prefix map where the keys are prefixes (e.g., `chebi`) and the values are lists of URI prefixes (e.g., ``http://purl.obolibrary.org/obo/CHEBI_``) with the first element of the list being the priority URI prefix for expansions. :param kwargs: Keyword arguments to pass to the parent class's init :returns: A converter >>> priority_prefix_map = { ... "CHEBI": [ ... "http://purl.obolibrary.org/obo/CHEBI_", ... "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:", ... ], ... "GO": ["http://purl.obolibrary.org/obo/GO_"], ... "obo": ["http://purl.obolibrary.org/obo/"], ... } >>> converter = Converter.from_priority_prefix_map(priority_prefix_map) >>> converter.expand("CHEBI:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.compress("http://purl.obolibrary.org/obo/CHEBI_138488") 'CHEBI:138488' >>> converter.compress("https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:138488") 'CHEBI:138488' """ return cls( [ Record( prefix=prefix, uri_prefix=uri_prefixes[0], uri_prefix_synonyms=uri_prefixes[1:] ) for prefix, uri_prefixes in _prepare(data).items() ], **kwargs, ) @classmethod def from_prefix_map(cls, prefix_map: LocationOr[Mapping[str, str]], **kwargs: Any) -> Converter: """Get a converter from a simple prefix map. :param prefix_map: One of the following: - A mapping whose keys represent CURIE prefixes and values represent URI prefixes - A string containing a remote location of a JSON file containg a prefix map - A string or :class:`pathlib.Path` object corresponding to a local file path to a JSON file containing a prefix map :param kwargs: Keyword arguments to pass to :meth:`curies.Converter.__init__` :returns: A converter >>> converter = Converter.from_prefix_map( ... { ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... "MONDO": "http://purl.obolibrary.org/obo/MONDO_", ... "GO": "http://purl.obolibrary.org/obo/GO_", ... "OBO": "http://purl.obolibrary.org/obo/", ... } ... ) >>> converter.expand("CHEBI:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.compress("http://purl.obolibrary.org/obo/CHEBI_138488") 'CHEBI:138488' """ return cls( [ Record(prefix=prefix, uri_prefix=uri_prefix) for prefix, uri_prefix in _prepare(prefix_map).items() ], **kwargs, ) @classmethod def from_reverse_prefix_map( cls, reverse_prefix_map: LocationOr[Mapping[str, str]], **kwargs: Any ) -> Converter: """Get a converter from a reverse prefix map. :param reverse_prefix_map: A mapping whose keys are URI prefixes and whose values are the corresponding prefixes. This data structure allow for multiple different URI formats to point to the same prefix. :param kwargs: Keyword arguments to pass to :meth:`curies.Converter.__init__` :returns: A converter >>> converter = Converter.from_reverse_prefix_map( ... { ... "http://purl.obolibrary.org/obo/CHEBI_": "CHEBI", ... "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=": "CHEBI", ... "http://purl.obolibrary.org/obo/MONDO_": "MONDO", ... } ... ) >>> converter.expand("CHEBI:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.compress("http://purl.obolibrary.org/obo/CHEBI_138488") 'CHEBI:138488' >>> converter.compress("https://www.ebi.ac.uk/chebi/searchId.do?chebiId=138488") 'CHEBI:138488' Altenatively, get content from the internet like >>> url = "https://github.com/biopragmatics/bioregistry/raw/main/exports/contexts/bioregistry.rpm.json" >>> converter = Converter.from_reverse_prefix_map(url) >>> "chebi" in converter.prefix_map """ dd: defaultdict[str, list[str]] = defaultdict(list) for uri_prefix, prefix in _prepare(reverse_prefix_map).items(): dd[prefix].append(uri_prefix) records = [] for prefix, uri_prefixes in dd.items(): uri_prefix, *uri_prefix_synonyms = sorted(uri_prefixes, key=len) records.append( Record( prefix=prefix, uri_prefix=uri_prefix, uri_prefix_synonyms=uri_prefix_synonyms ) ) return cls(records, **kwargs) @classmethod def from_jsonld(cls, data: LocationOr[dict[str, Any]], **kwargs: Any) -> Converter: """Get a converter from a JSON-LD object, which contains a prefix map in its ``@context`` key. :param data: A JSON-LD object :param kwargs: Keyword arguments to pass to :meth:`curies.Converter.__init__` :returns: A converter Example from a remote context file: >>> base = "https://raw.githubusercontent.com" >>> url = f"{base}/biopragmatics/bioregistry/main/exports/contexts/semweb.context.jsonld" >>> converter = Converter.from_jsonld(url) >>> "rdf" in converter.prefix_map .. seealso:: https://www.w3.org/TR/json-ld11/#the-context defines the ``@context`` aspect of JSON-LD """ prefix_map = {} for key, value in _prepare(data)["@context"].items(): # TODO how to handle key == "@base"? if not key: logger.warning( "The JSON-LD specification says in https://www.w3.org/TR/json-ld/#terms that " "keys are not allowed to be empty strings. The given @context object contained " "an empty string as one of its keys" ) continue if key.startswith("@"): continue if isinstance(value, str): prefix_map[key] = value elif isinstance(value, dict) and value.get("@prefix") is True: prefix_map[key] = value["@id"] return cls.from_prefix_map(prefix_map, **kwargs) @classmethod def from_jsonld_github( cls, owner: str, repo: str, *path: str, branch: str = "main", **kwargs: Any ) -> Converter: """Construct a remote JSON-LD URL on GitHub then parse with :meth:`Converter.from_jsonld`. :param owner: A github repository owner or organization (e.g., ``biopragmatics``) :param repo: The name of the repository (e.g., ``bioregistry``) :param path: The file path in the GitHub repository to a JSON-LD context file. :param branch: The branch from which the file should be downloaded. Defaults to ``main``, for old repositories this might need to be changed to ``master``. :param kwargs: Keyword arguments to pass to :meth:`curies.Converter.__init__` :returns: A converter :raises ValueError: If the given path doesn't end in a .jsonld file name >>> converter = Converter.from_jsonld_github( ... "biopragmatics", ... "bioregistry", ... "exports", ... "contexts", ... "semweb.context.jsonld", ... ) >>> "rdf" in converter.prefix_map True """ if not path or not path[-1].endswith(".jsonld"): raise ValueError("final path argument should end with .jsonld") rest = "/".join(path) url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{rest}" return cls.from_jsonld(url, **kwargs) @classmethod def from_rdflib( cls, graph_or_manager: rdflib.Graph | rdflib.namespace.NamespaceManager, **kwargs: Any, ) -> Converter: """Get a converter from an RDFLib graph or namespace manager. :param graph_or_manager: A RDFLib graph or manager object :param kwargs: Keyword arguments to pass to :meth:`from_prefix_map` :returns: A converter In the following example, a :class:`rdflib.Graph` is created, a namespace is bound to it, then a converter is made: >>> import rdflib, curies >>> graph = rdflib.Graph() >>> graph.bind("hgnc", "https://bioregistry.io/hgnc:") >>> converter = curies.Converter.from_rdflib(graph) >>> converter.expand("hgnc:1234") 'https://bioregistry.io/hgnc:1234' This also works if you directly start with a :class:`rdflib.namespace.NamespaceManager`: >>> converter = curies.Converter.from_rdflib(graph.namespace_manager) >>> converter.expand("hgnc:1234") 'https://bioregistry.io/hgnc:1234' """ # it's required to stringify namespace since it's a rdflib.URIRef # object, which acts funny if not coerced into a string prefix_map = {prefix: str(namespace) for prefix, namespace in graph_or_manager.namespaces()} return cls.from_prefix_map(prefix_map, **kwargs) def bind_rdflib( self, graph_or_manager: rdflib.Graph | rdflib.namespace.NamespaceManager, synonyms: bool = False, ) -> None: """Add the prefix map from this converter to a RDFlib graph or manager. :param graph_or_manager: A RDFLib graph or manager object :param synonyms: Should CURIE prefix synonyms be bound? Binding a graph: >>> import curies, rdflib >>> converter = curies.get_obo_converter() >>> graph = rdflib.Graph() >>> converter.bind_rdflib(graph) Binding a manager: >>> import curies, rdflib, rdflib.namespace >>> converter = curies.get_obo_converter() >>> manager = rdflib.namespace.NamespaceManager(graph=rdflib.Graph()) >>> converter.bind_rdflib(manager) """ from rdflib import Namespace for record in self.records: namespace = Namespace(record.uri_prefix) graph_or_manager.bind(record.prefix, namespace) if synonyms: for synonym in record.prefix_synonyms: graph_or_manager.bind(synonym, namespace) @classmethod def from_shacl( cls, graph: str | Path | rdflib.Graph, format: str | None = None, **kwargs: Any, ) -> Converter: """Get a converter from SHACL, either in a turtle f. :param graph: A RDFLib graph, a Path, a string representing a file path, or a string URL :param format: The RDF format, if a file path is given :param kwargs: Keyword arguments to pass to :meth:`Converter.__init__` :returns: A converter """ if isinstance(graph, (str, Path)): import rdflib temporary_graph = rdflib.Graph() temporary_graph.parse(location=Path(graph).resolve().as_posix(), format=format) graph = temporary_graph query = """\ SELECT ?curie_prefix ?uri_prefix ?pattern WHERE { ?bnode1 sh:declare ?bnode2 . ?bnode2 sh:prefix ?curie_prefix . ?bnode2 sh:namespace ?uri_prefix . OPTIONAL { ?bnode2 sh:pattern ?pattern . } } """ results = cast(Iterable[tuple[str, str, str]], graph.query(query)) records = [ Record(prefix=str(prefix), uri_prefix=str(uri_prefix), pattern=pattern and str(pattern)) for prefix, uri_prefix, pattern in results ] return cls(records, **kwargs) def get_prefixes(self, *, include_synonyms: bool = False) -> set[str]: """Get the set of prefixes covered by this converter. :param include_synonyms: If true, include secondary prefixes. :returns: A set of primary prefixes covered by the converter. If ``include_synonyms`` is set to ``True``, secondary prefixes (i.e., ones in :data:`Record.prefix_synonyms` are also included """ rv = {record.prefix for record in self.records} if include_synonyms: rv.update( prefix_synonym for record in self.records for prefix_synonym in record.prefix_synonyms ) return rv def get_uri_prefixes(self, *, include_synonyms: bool = False) -> set[str]: """Get the set of URI prefixes covered by this converter. :param include_synonyms: If true, include secondary prefixes. :returns: A set of primary URI prefixes covered by the converter. If ``include_synonyms`` is set to ``True``, secondary URI prefixes (i.e., ones in :data:`Record.uri_prefix_synonyms` are also included """ rv = {record.uri_prefix for record in self.records} if include_synonyms: rv.update( uri_prefix_synonym for record in self.records for uri_prefix_synonym in record.uri_prefix_synonyms ) return rv def format_curie(self, prefix: str, identifier: str) -> str: """Format a prefix and identifier into a CURIE string.""" return f"{prefix}{self.delimiter}{identifier}" def is_uri(self, s: str) -> bool: """Check if the string can be parsed as a URI by this converter. :param s: A string that might be a URI :returns: If the string can be parsed as a URI by this converter. Note that some valid URIs, when passed to this function, will result in False if their URI prefixes are not registered with this converter. >>> import curies >>> converter = curies.get_obo_converter() >>> converter.is_uri("http://purl.obolibrary.org/obo/GO_1234567") True >>> converter.is_uri("GO:1234567") False The following is a valid URI, but the prefix is not registered with the converter based on the OBO Foundry prefix map, so it returns False. >>> converter.is_uri("http://proteopedia.org/wiki/index.php/2gc4") False """ return self.compress(s) is not None # docstr-coverage:excused `overload` @overload def compress_or_standardize( self, uri_or_curie: str, *, strict: Literal[True] = True, passthrough: bool = ... ) -> str: ... # docstr-coverage:excused `overload` @overload def compress_or_standardize( self, uri_or_curie: str, *, strict: Literal[False] = False, passthrough: Literal[True] = True, ) -> str: ... # docstr-coverage:excused `overload` @overload def compress_or_standardize( self, uri_or_curie: str, *, strict: Literal[False] = False, passthrough: Literal[False] = False, ) -> str | None: ... def compress_or_standardize( self, uri_or_curie: str, *, strict: bool = False, passthrough: bool = False ) -> str | None: """Compress a URI or standardize a CURIE. :param uri_or_curie: A string representing a compact URI (CURIE) or a URI. :param strict: If true and the string is neither a URI that can be compressed nor a CURIE that can be standardized, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the string is neither a URI that can be compressed nor a CURIE that can be standardized, return the input. Defaults to false. :returns: If the string is a URI, and it can be compressed, returns the corresponding CURIE. If the string is a CURIE, and it can be standardized, returns the standard CURIE. :raises CompressionError: If strict is true and the URI can't be compressed >>> from curies import Converter, Record >>> converter = Converter.from_extended_prefix_map( ... [ ... Record( ... prefix="CHEBI", ... prefix_synonyms=["chebi"], ... uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", ... uri_prefix_synonyms=["https://identifiers.org/chebi:"], ... ), ... ] ... ) >>> converter.compress_or_standardize("http://purl.obolibrary.org/obo/CHEBI_138488") 'CHEBI:138488' >>> converter.compress_or_standardize("https://identifiers.org/chebi:138488") 'CHEBI:138488' >>> converter.compress_or_standardize("CHEBI:138488") 'CHEBI:138488' >>> converter.compress_or_standardize("chebi:138488") 'CHEBI:138488' >>> converter.compress_or_standardize("missing:0000000") >>> converter.compress_or_standardize("https://example.com/missing:0000000") """ reference = self.parse(uri_or_curie, strict=False) if reference is not None: return self.format_curie(reference.prefix, reference.identifier) if strict: raise CompressionError(uri_or_curie) if passthrough: return uri_or_curie return None # docstr-coverage:excused `overload` @overload def parse( self, str_or_uri_or_curie: str | URIType, *, strict: Literal[True] = True ) -> ReferenceTuple: ... # docstr-coverage:excused `overload` @overload def parse( self, str_or_uri_or_curie: str | URIType, *, strict: Literal[False] = False ) -> ReferenceTuple | None: ... def parse( self, str_or_uri_or_curie: str | URIType, *, strict: bool = False ) -> ReferenceTuple | None: """Parse a string, URI, or CURIE.""" if str_or_uri_or_curie.__class__ is not str or self.is_uri(str_or_uri_or_curie): return self.parse_uri(str_or_uri_or_curie, strict=strict) # type:ignore[no-any-return,call-overload] if self.is_curie(str_or_uri_or_curie): return self.parse_curie(str_or_uri_or_curie, strict=strict) # type:ignore[no-any-return,call-overload] if strict: raise CompressionError(str_or_uri_or_curie) return None def compress_strict(self, uri: URIType) -> str: """Compress a URI to a CURIE, and raise an error of not possible.""" return self.compress(uri, strict=True) # docstr-coverage:excused `overload` @overload def compress( self, uri: URIType, *, strict: Literal[True] = True, passthrough: bool = ... ) -> str: ... # docstr-coverage:excused `overload` @overload def compress( self, uri: URIType, *, strict: Literal[False] = False, passthrough: Literal[True] = True ) -> str: ... # docstr-coverage:excused `overload` @overload def compress( self, uri: URIType, *, strict: Literal[False] = False, passthrough: Literal[False] = False ) -> str | None: ... def compress( self, uri: URIType, *, strict: bool = False, passthrough: bool = False ) -> str | None: """Compress a URI to a CURIE, if possible. :param uri: A string representing a valid uniform resource identifier (URI) :param strict: If true and the URI can't be compressed, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the URI can't be compressed, return the input. Defaults to false. :returns: A compact URI if this converter could find an appropriate URI prefix, otherwise none. :raises CompressionError: If strict is set to true and the URI can't be compressed >>> from curies import Converter >>> converter = Converter.from_prefix_map( ... { ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... "MONDO": "http://purl.obolibrary.org/obo/MONDO_", ... "GO": "http://purl.obolibrary.org/obo/GO_", ... "OBO": "http://purl.obolibrary.org/obo/", ... } ... ) >>> converter.compress("http://purl.obolibrary.org/obo/GO_0032571") 'GO:0032571' >>> converter.compress("http://purl.obolibrary.org/obo/go.owl") 'OBO:go.owl' >>> converter.compress("http://example.org/missing:0000000") .. note:: If there are partially overlapping *URI prefixes* in this converter (e.g., ``http://purl.obolibrary.org/obo/GO_`` for the prefix ``GO`` and ``http://purl.obolibrary.org/obo/`` for the prefix ``OBO``), the longest URI prefix will always be matched. For example, parsing ``http://purl.obolibrary.org/obo/GO_0032571`` will return ``GO:0032571`` instead of ``OBO:GO_0032571``. """ reference: ReferenceTuple | None = self.parse_uri(uri) if reference: return self.format_curie(reference.prefix, reference.identifier) if strict: raise CompressionError(uri) if passthrough: return str(uri) return None # docstr-coverage:excused `overload` @overload def parse_uri(self, uri: URIType, *, strict: Literal[False] = ...) -> ReferenceTuple | None: ... # docstr-coverage:excused `overload` @overload def parse_uri( self, uri: URIType, *, strict: Literal[True] = True, ) -> ReferenceTuple: ... def parse_uri(self, uri: URIType, *, strict: bool = False) -> ReferenceTuple | None: """Compress a URI to a CURIE pair. :param uri: A string or object representing a valid uniform resource identifier (URI). URIs represented as :class:`rdflib.URIRef`, :class:`pydantic.AnyUrl`, :class:`httpx.URL`, and :class:`httpx2.URL` are accepted in addition to plain strings. :param strict: If true and the URI can't be parsed, returns an error. Defaults to false. :returns: A CURIE pair if the URI could be parsed, otherwise a pair of None's :raises CompressionError: if strict is set to true and the URI can't be parsed >>> from curies import Converter >>> converter = Converter.from_prefix_map( ... { ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... "MONDO": "http://purl.obolibrary.org/obo/MONDO_", ... "GO": "http://purl.obolibrary.org/obo/GO_", ... } ... ) >>> converter.parse_uri("http://purl.obolibrary.org/obo/CHEBI_138488") ReferenceTuple(prefix='CHEBI', identifier='138488') >>> converter.parse_uri("http://example.org/missing:0000000") """ rv = self.trie.parse_uri(str(uri)) if rv is not None: return rv if strict: raise CompressionError(uri) from None return None def is_curie(self, s: str) -> bool: """Check if the string can be parsed as a CURIE by this converter. :param s: A string that might be a CURIE :returns: If the string can be parsed as a CURIE by this converter. Note that some valid CURIEs, when passed to this function, will result in False if their prefixes are not registered with this converter. >>> import curies >>> converter = curies.get_obo_converter() >>> converter.is_curie("GO:1234567") True >>> converter.is_curie("http://purl.obolibrary.org/obo/GO_1234567") False The following is a valid CURIE, but the prefix is not registered with the converter based on the OBO Foundry prefix map, so it returns False. >>> converter.is_curie("pdb:2gc4") False """ try: return self.expand(s) is not None except ValueError: return False # docstr-coverage:excused `overload` @overload def expand_or_standardize( self, curie_or_uri: str, *, strict: Literal[True] = True, passthrough: bool = ... ) -> str: ... # docstr-coverage:excused `overload` @overload def expand_or_standardize( self, curie_or_uri: str, *, strict: Literal[False] = False, passthrough: Literal[True] = True, ) -> str: ... # docstr-coverage:excused `overload` @overload def expand_or_standardize( self, curie_or_uri: str, *, strict: Literal[False] = False, passthrough: Literal[False] = False, ) -> str | None: ... def expand_or_standardize( self, curie_or_uri: str, *, strict: bool = False, passthrough: bool = False ) -> str | None: """Expand a CURIE or standardize a URI. :param curie_or_uri: A string representing a compact URI (CURIE) or a URI. :param strict: If true and the string is neither a CURIE that can be expanded nor a URI that can be standardized, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the string is neither a CURIE that can be expanded nor a URI that can be standardized, return the input. Defaults to false. :returns: If the string is a CURIE, and it can be expanded, returns the corresponding URI. If the string is a URI, and it can be standardized, returns the standard URI. :raises ExpansionError: If strict is true and the CURIE can't be expanded >>> from curies import Converter, Record >>> converter = Converter.from_extended_prefix_map( ... [ ... Record( ... prefix="CHEBI", ... prefix_synonyms=["chebi"], ... uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", ... uri_prefix_synonyms=["https://identifiers.org/chebi:"], ... ), ... ] ... ) >>> converter.expand_or_standardize("CHEBI:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.expand_or_standardize("chebi:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.expand_or_standardize("http://purl.obolibrary.org/obo/CHEBI_138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.expand_or_standardize("https://identifiers.org/chebi:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.expand_or_standardize("missing:0000000") >>> converter.expand_or_standardize("https://example.com/missing:0000000") """ reference = self.parse(curie_or_uri, strict=False) if reference is not None: return self.expand_reference(reference, strict=strict, passthrough=passthrough) # type:ignore[no-any-return,call-overload] if strict: raise ExpansionError(curie_or_uri) if passthrough: return curie_or_uri return None def expand_strict(self, curie: str) -> str: """Expand a CURIE to a URI, and raise an error of not possible.""" return self.expand(curie, strict=True) # docstr-coverage:excused `overload` @overload def expand( self, curie: str, *, strict: Literal[True] = True, passthrough: bool = ... ) -> str: ... # docstr-coverage:excused `overload` @overload def expand( self, curie: str, *, strict: Literal[False] = False, passthrough: Literal[True] = True ) -> str: ... # docstr-coverage:excused `overload` @overload def expand( self, curie: str, *, strict: Literal[False] = False, passthrough: Literal[False] = False ) -> str | None: ... def expand(self, curie: str, *, strict: bool = False, passthrough: bool = False) -> str | None: """Expand a CURIE to a URI, if possible. :param curie: A string representing a compact URI (CURIE) :param strict: If true and the CURIE can't be expanded, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the CURIE can't be expanded, return the input. Defaults to false. If your strings can either be a CURIE _or_ a URI, consider using :meth:`Converter.expand_or_standardize` instead. :returns: A URI if this converter contains a URI prefix for the prefix in this CURIE :raises ExpansionError: If strict is true and the CURIE can't be expanded >>> from curies import Converter >>> converter = Converter.from_prefix_map( ... { ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... "MONDO": "http://purl.obolibrary.org/obo/MONDO_", ... "GO": "http://purl.obolibrary.org/obo/GO_", ... } ... ) >>> converter.expand("CHEBI:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.expand("missing:0000000") """ reference = self.parse_curie(curie, strict=False) if reference is not None: return self.expand_reference(reference, strict=strict, passthrough=passthrough) # type:ignore[no-any-return,call-overload] if strict: raise ExpansionError(curie) if passthrough: return curie return None # docstr-coverage:excused `overload` @overload def expand_all( self, curie: str, *, strict: Literal[False] = False ) -> Collection[str] | None: ... # docstr-coverage:excused `overload` @overload def expand_all(self, curie: str, *, strict: Literal[True] = True) -> Collection[str]: ... def expand_all(self, curie: str, *, strict: bool = False) -> Collection[str] | None: """Expand a CURIE pair to all possible URIs. :param curie: A string representing a compact URI :param strict: If true and the prefix can't be expanded, returns an error. Defaults to false. :returns: A list of URIs that this converter can create for the given CURIE. The first entry is the "standard" URI then others are based on URI prefix synonyms. If the prefix is not registered to this converter, none is returned. :raises PrefixStandardizationError: if the prefix in the CURIE can not be looked up >>> priority_prefix_map = { ... "CHEBI": [ ... "http://purl.obolibrary.org/obo/CHEBI_", ... "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:", ... ], ... } >>> converter = Converter.from_priority_prefix_map(priority_prefix_map) >>> converter.expand_all("CHEBI:138488") ['http://purl.obolibrary.org/obo/CHEBI_138488', 'https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:138488'] >>> converter.expand_all("NOPE:NOPE") is None True """ reference = self.parse_curie(curie, strict=False) if reference is not None: return self.expand_pair_all(reference.prefix, reference.identifier) if strict: raise PrefixStandardizationError(curie) return None # docstr-coverage:excused `overload` @overload def parse_curie( self, curie: str, *, strict: Literal[False] = False ) -> ReferenceTuple | None: ... # docstr-coverage:excused `overload` @overload def parse_curie(self, curie: str, *, strict: Literal[True] = True) -> ReferenceTuple: ... def parse_curie(self, curie: str, *, strict: bool = False) -> ReferenceTuple | None: """Parse and standardize a CURIE.""" prefix, identifier = _split(curie, sep=self.delimiter) norm_prefix = self.standardize_prefix(prefix, strict=False) if norm_prefix is None: if strict: raise PrefixStandardizationError(prefix) return None norm_identifier = self.standardize_identifier(norm_prefix, identifier) if norm_identifier is None: if strict: raise IdentifierStandardizationError(curie) return None return ReferenceTuple(norm_prefix, norm_identifier) # docstr-coverage:excused `overload` @overload def standardize_identifier( self, standard_prefix: str, identifier: str, *, strict: Literal[True] = ... ) -> str: ... # docstr-coverage:excused `overload` @overload def standardize_identifier( self, standard_prefix: str, identifier: str, *, strict: Literal[False] = ... ) -> str | None: ... def standardize_identifier( self, standard_prefix: str, identifier: str, *, strict: bool = False ) -> str | None: """Standardize an identifier. :param standard_prefix: This is a prefix that has already been standardized using :meth:`standardize_prefix` in this converter :param identifier: An unstandardized identifier :param strict: If true, requires standardization to succeed or throws an error :returns: A standardized identifier. By default, this function is a no-op, meaning that it just returns the identifier as is. You can override the :class:`Converter` class to implement this method to do standardization (e.g., removing redundant prefixes) and to do validation (e.g., checking against a regular expression). """ return identifier # docstr-coverage:excused `overload` @overload def expand_reference( self, reference: ReferenceTuple | Reference, *, strict: Literal[True] = ..., passthrough: bool = ..., ) -> str: ... # docstr-coverage:excused `overload` @overload def expand_reference( self, reference: ReferenceTuple | Reference, *, strict: Literal[False] = ..., passthrough: bool = ..., ) -> str | None: ... def expand_reference( self, reference: ReferenceTuple | Reference, *, strict: bool = False, passthrough: bool = False, ) -> str | None: """Expand a reference.""" uri_prefix = self.prefix_map.get(reference.prefix) if uri_prefix is not None: return uri_prefix + reference.identifier if strict: raise ExpansionError(reference.prefix) if passthrough: return self.format_curie(reference.prefix, reference.identifier) return None # docstr-coverage:excused `overload` @overload def expand_pair( self, prefix: str, identifier: str, *, strict: Literal[True] = True, passthrough: bool = ... ) -> str: ... # docstr-coverage:excused `overload` @overload def expand_pair( self, prefix: str, identifier: str, *, strict: Literal[False] = False, passthrough: bool = ..., ) -> str | None: ... def expand_pair( self, prefix: str, identifier: str, *, strict: bool = False, passthrough: bool = False ) -> str | None: """Expand a CURIE pair to the standard URI. :param prefix: The prefix of the CURIE :param identifier: The local unique identifier of the CURIE :param strict: If true and the prefix can't be expanded, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the prefix can't be expanded, return the input. Defaults to false. :returns: A URI if this converter contains a URI prefix for the prefix in this CURIE >>> from curies import Converter >>> converter = Converter.from_prefix_map( ... { ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... "MONDO": "http://purl.obolibrary.org/obo/MONDO_", ... "GO": "http://purl.obolibrary.org/obo/GO_", ... } ... ) >>> converter.expand_pair("CHEBI", "138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.expand_pair("missing", "0000000") """ return self.expand_reference( # type:ignore[no-any-return,call-overload] ReferenceTuple(prefix, identifier), strict=strict, passthrough=passthrough ) # docstr-coverage:excused `overload` @overload def expand_pair_all( self, prefix: str, identifier: str, *, strict: Literal[True] = True ) -> Collection[str]: ... # docstr-coverage:excused `overload` @overload def expand_pair_all( self, prefix: str, identifier: str, *, strict: Literal[False] = False ) -> Collection[str] | None: ... def expand_pair_all( self, prefix: str, identifier: str, *, strict: bool = False ) -> Collection[str] | None: """Expand a CURIE pair to all possible URIs. :param prefix: The prefix of the CURIE :param identifier: The local unique identifier of the CURIE :param strict: If true and the prefix can't be expanded, returns an error. Defaults to false. :returns: A list of URIs that this converter can create for the given CURIE. The first entry is the "standard" URI then others are based on URI prefix synonyms. If the prefix is not registered to this converter, none is returned. :raises ExpansionError: if the prefix in the CURIE can not be looked up >>> priority_prefix_map = { ... "CHEBI": [ ... "http://purl.obolibrary.org/obo/CHEBI_", ... "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:", ... ], ... } >>> converter = Converter.from_priority_prefix_map(priority_prefix_map) >>> converter.expand_pair_all("CHEBI", "138488") ['http://purl.obolibrary.org/obo/CHEBI_138488', 'https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:138488'] >>> converter.expand_pair_all("NOPE", "NOPE") is None True """ record = self._prefix_to_record.get(prefix) if record is not None: rv = [record.uri_prefix + identifier] for uri_prefix_synonyms in record.uri_prefix_synonyms: rv.append(uri_prefix_synonyms + identifier) return rv if strict: raise ExpansionError(prefix) return None # docstr-coverage:excused `overload` @overload def standardize_prefix( self, prefix: str, *, strict: Literal[True] = True, passthrough: bool = False ) -> str: ... # docstr-coverage:excused `overload` @overload def standardize_prefix( self, prefix: str, *, strict: Literal[False] = False, passthrough: Literal[True] = True ) -> str: ... # docstr-coverage:excused `overload` @overload def standardize_prefix( self, prefix: str, *, strict: Literal[False] = False, passthrough: Literal[False] = False ) -> str | None: ... def standardize_prefix( self, prefix: str, *, strict: bool = False, passthrough: bool = False ) -> str | None: """Standardize a prefix. :param prefix: The prefix of the CURIE :param strict: If true and the prefix can't be standardized, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the prefix can't be standardized, return the input. Defaults to false. :returns: The standardized version of this prefix wrt this converter. If the prefix is not registered in this converter, returns none. :raises PrefixStandardizationError: If strict is true and the prefix can't be standardied >>> from curies import Converter, Record >>> converter = Converter.from_extended_prefix_map( ... [ ... Record(prefix="CHEBI", prefix_synonyms=["chebi"], uri_prefix="..."), ... ] ... ) >>> converter.standardize_prefix("chebi") 'CHEBI' >>> converter.standardize_prefix("CHEBI") 'CHEBI' >>> converter.standardize_prefix("NOPE") is None True >>> converter.standardize_prefix("NOPE", passthrough=True) 'NOPE' """ record = self._prefix_to_record.get(prefix) if record: return record.prefix if strict: raise PrefixStandardizationError(prefix) if passthrough: return prefix return None # docstr-coverage:excused `overload` @overload def standardize_curie( self, curie: str, *, strict: Literal[True] = True, passthrough: bool = False ) -> str: ... # docstr-coverage:excused `overload` @overload def standardize_curie( self, curie: str, *, strict: Literal[False] = False, passthrough: Literal[True] = True ) -> str: ... # docstr-coverage:excused `overload` @overload def standardize_curie( self, curie: str, *, strict: Literal[False] = False, passthrough: Literal[False] = False ) -> str | None: ... def standardize_curie( self, curie: str, *, strict: bool = False, passthrough: bool = False ) -> str | None: """Standardize a CURIE. :param curie: A string representing a compact URI (CURIE) :param strict: If true and the CURIE can't be standardized, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the CURIE can't be standardized, return the input. Defaults to false. :returns: A standardized version of the CURIE in case a prefix synonym was used. Note that this function is idempotent, i.e., if you give an already standard CURIE, it will just return it as is. If the CURIE can't be parsed with respect to the records in the converter, None is returned. :raises CURIEStandardizationError: If strict is true and the CURIE can't be standardized >>> from curies import Converter, Record >>> converter = Converter.from_extended_prefix_map( ... [ ... Record( ... prefix="CHEBI", ... prefix_synonyms=["chebi"], ... uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", ... ), ... ] ... ) >>> converter.standardize_curie("chebi:138488") 'CHEBI:138488' >>> converter.standardize_curie("CHEBI:138488") 'CHEBI:138488' >>> converter.standardize_curie("NOPE:NOPE") is None True >>> converter.standardize_curie("NOPE:NOPE", passthrough=True) 'NOPE:NOPE' """ rt = self.parse_curie(curie) if rt is not None: return self.format_curie(*rt) if strict: raise CURIEStandardizationError(curie) if passthrough: return curie return None # docstr-coverage:excused `overload` @overload def standardize_uri( self, uri: str, *, strict: Literal[True] = True, passthrough: bool = False ) -> str: ... # docstr-coverage:excused `overload` @overload def standardize_uri( self, uri: str, *, strict: Literal[False] = False, passthrough: Literal[True] = True ) -> str: ... # docstr-coverage:excused `overload` @overload def standardize_uri( self, uri: str, *, strict: Literal[False] = False, passthrough: Literal[False] = False ) -> str | None: ... def standardize_uri( self, uri: str, *, strict: bool = False, passthrough: bool = False ) -> str | None: """Standardize a URI. :param uri: A string representing a valid uniform resource identifier (URI) :param strict: If true and the URI can't be standardized, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the URI can't be standardized, return the input. Defaults to false. :returns: A standardized version of the URI in case a URI prefix synonym was used. Note that this function is idempotent, i.e., if you give an already standard URI, it will just return it as is. If the URI can't be parsed with respect to the records in the converter, None is returned. :raises URIStandardizationError: If strict is true and the URI can't be standardized >>> from curies import Converter, Record >>> converter = Converter.from_extended_prefix_map( ... [ ... Record( ... prefix="CHEBI", ... uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", ... uri_prefix_synonyms=[ ... "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:", ... ], ... ), ... ] ... ) >>> converter.standardize_uri( ... "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:138488" ... ) 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.standardize_uri("http://purl.obolibrary.org/obo/CHEBI_138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.standardize_uri("http://example.org/NOPE") is None True >>> converter.standardize_uri("http://example.org/NOPE", passthrough=True) 'http://example.org/NOPE' """ reference: ReferenceTuple | None = self.parse_uri(uri, strict=False) if reference is not None: # prefix is ensured to be in self.prefix_map because of successful parse return self.prefix_map[reference.prefix] + reference.identifier if strict: raise URIStandardizationError(uri) if passthrough: return uri return None # docstr-coverage:excused `overload` @overload def standardize_reference( self, reference: Reference, *, strict: Literal[True] = ... ) -> Reference: ... # docstr-coverage:excused `overload` @overload def standardize_reference( self, reference: Reference, *, strict: Literal[False] = ... ) -> Reference | None: ... def standardize_reference( self, reference: Reference, *, strict: bool = False ) -> Reference | None: """Standardizes a reference.""" st_prefix = self.standardize_prefix(reference.prefix, strict=False) if st_prefix is None: if strict: raise PrefixStandardizationError(reference.prefix) return None st_identifier = self.standardize_identifier(st_prefix, reference.identifier, strict=False) if st_identifier is None: if strict: raise IdentifierStandardizationError(reference.curie) return None return reference.model_copy( update={"prefix": Prefix(st_prefix), "identifier": st_identifier} ) def pd_compress( self, df: pandas.DataFrame, column: str | int, target_column: str | int | None = None, strict: bool = False, passthrough: bool = False, ambiguous: bool = False, ) -> None: """Convert all URIs in the given column to CURIEs. :param df: A pandas DataFrame :param column: The column in the dataframe containing URIs to convert to CURIEs. :param target_column: The column to put the results in. Defaults to input column. :param strict: If true and the URI can't be compressed, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the URI can't be compressed, return the input. Defaults to false. :param ambiguous: If true, consider the column as containing either CURIEs or URIs. """ if ambiguous: func = partial(self.compress_or_standardize, strict=strict, passthrough=passthrough) else: func = partial(self.compress, strict=strict, passthrough=passthrough) df[column if target_column is None else target_column] = df[column].map(func) def pd_expand( self, df: pandas.DataFrame, column: str | int, target_column: str | int | None = None, strict: bool = False, passthrough: bool = False, ambiguous: bool = False, ) -> None: """Convert all CURIEs in the given column to URIs. :param df: A pandas DataFrame :param column: The column in the dataframe containing CURIEs to convert to URIs. :param target_column: The column to put the results in. Defaults to input column. :param strict: If true and the CURIE can't be expanded, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the CURIE can't be expanded, return the input. Defaults to false. :param ambiguous: If true, consider the column as containing either CURIEs or URIs. """ if ambiguous: func = partial(self.expand_or_standardize, strict=strict, passthrough=passthrough) else: func = partial(self.expand, strict=strict, passthrough=passthrough) df[column if target_column is None else target_column] = df[column].map(func) def pd_standardize_prefix( self, df: pandas.DataFrame, *, column: str | int, target_column: str | int | None = None, strict: bool = False, passthrough: bool = False, ) -> None: """Standardize all prefixes in the given column. :param df: A pandas DataFrame :param column: The column in the dataframe containing prefixes to standardize. :param target_column: The column to put the results in. Defaults to input column. :param strict: If true and any prefix can't be standardized, returns an error. Defaults to false. :param passthrough: If true, strict is false, and any prefix can't be standardized, return the input. Defaults to false. """ func = partial(self.standardize_prefix, strict=strict, passthrough=passthrough) df[column if target_column is None else target_column] = df[column].map(func) def pd_standardize_curie( self, df: pandas.DataFrame, *, column: str | int, target_column: str | int | None = None, strict: bool = False, passthrough: bool = False, ) -> None: r"""Standardize all CURIEs in the given column. :param df: A pandas DataFrame :param column: The column in the dataframe containing CURIEs to standardize. :param target_column: The column to put the results in. Defaults to input column. :param strict: If true and any CURIE can't be standardized, returns an error. Defaults to false. :param passthrough: If true, strict is false, and any CURIE can't be standardized, return the input. Defaults to false. The Disease Ontology curates mappings to other semantic spaces and distributes them in the tabular SSSOM format. However, they use a wide variety of non-standard prefixes for referring to external vocabularies like SNOMED-CT. The Bioregistry contains these synonyms to support reconciliation. The following example shows how the SSSOM mappings dataframe can be loaded and this function applied to the mapping ``object_id`` column (in place). >>> import curies >>> import pandas as pd >>> commit = "faca4fc335f9a61902b9c47a1facd52a0d3d2f8b" >>> url = f"https://raw.githubusercontent.com/mapping-commons/disease-mappings/{commit}/mappings/doid.sssom.tsv" >>> df = pd.read_csv(url, sep="\t", comment="#") >>> converter = curies.get_bioregistry_converter() >>> converter.pd_standardize_curie(df, column="object_id") """ func = partial(self.standardize_curie, strict=strict, passthrough=passthrough) df[column if target_column is None else target_column] = df[column].map(func) def pd_standardize_uri( self, df: pandas.DataFrame, *, column: str | int, target_column: str | int | None = None, strict: bool = False, passthrough: bool = False, ) -> None: """Standardize all URIs in the given column. :param df: A pandas DataFrame :param column: The column in the dataframe containing URIs to standardize. :param target_column: The column to put the results in. Defaults to input column. :param strict: If true and any URI can't be standardized, returns an error. Defaults to false. :param passthrough: If true, strict is false, and any URI can't be standardized, return the input. Defaults to false. """ func = partial(self.standardize_uri, strict=strict, passthrough=passthrough) df[column if target_column is None else target_column] = df[column].map(func) def file_compress( self, path: str | Path, column: int, *, sep: str | None = None, header: bool = True, strict: bool = False, passthrough: bool = False, ambiguous: bool = False, ) -> None: """Convert all URIs in the given column of a CSV file to CURIEs. :param path: A pandas DataFrame :param column: The column in the dataframe containing URIs to convert to CURIEs. :param sep: The delimiter of the CSV file, defaults to tab :param header: Does the file have a header row? :param strict: If true and the URI can't be compressed, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the URI can't be compressed, return the input. Defaults to false. :param ambiguous: If true, consider the column as containing either CURIEs or URIs. """ if ambiguous: func = partial(self.compress_or_standardize, strict=strict, passthrough=passthrough) else: func = partial(self.compress, strict=strict, passthrough=passthrough) self._file_helper(func, path=path, column=column, sep=sep, header=header) def file_expand( self, path: str | Path, column: int, *, sep: str | None = None, header: bool = True, strict: bool = False, passthrough: bool = False, ambiguous: bool = False, ) -> None: """Convert all CURIEs in the given column of a CSV file to URIs. :param path: A pandas DataFrame :param column: The column in the dataframe containing CURIEs to convert to URIs. :param sep: The delimiter of the CSV file, defaults to tab :param header: Does the file have a header row? :param strict: If true and the CURIE can't be expanded, returns an error. Defaults to false. :param passthrough: If true, strict is false, and the CURIE can't be expanded, return the input. Defaults to false. :param ambiguous: If true, consider the column as containing either CURIEs or URIs. """ if ambiguous: func = partial(self.expand_or_standardize, strict=strict, passthrough=passthrough) else: func = partial(self.expand, strict=strict, passthrough=passthrough) self._file_helper(func, path=path, column=column, sep=sep, header=header) @staticmethod def _file_helper( func: Callable[[str], str | None], path: str | Path, column: int, sep: str | None = None, header: bool = True, ) -> None: path = Path(path).expanduser().resolve() rows = [] delimiter = sep or "\t" with path.open() as file_in: reader = csv.reader(file_in, delimiter=delimiter) _header = next(reader) if header else None for row in reader: row[column] = func(row[column]) or "" rows.append(row) with path.open("w") as file_out: writer = csv.writer(file_out, delimiter=delimiter) if _header: writer.writerow(_header) writer.writerows(rows) # docstr-coverage:excused `overload` @overload def get_record(self, prefix: str, *, strict: Literal[True] = True) -> Record: ... # docstr-coverage:excused `overload` @overload def get_record(self, prefix: str, *, strict: Literal[False] = False) -> Record | None: ... def get_record(self, prefix: str, *, strict: bool = False) -> Record | None: """Get the record for the prefix.""" # TODO use self._prefix_to_record for record in self.records: if record.prefix == prefix or prefix in record.prefix_synonyms: return record if strict: raise KeyError(f"could not find prefix: {prefix}") return None def get_subconverter(self, prefixes: Iterable[str]) -> Converter: r"""Get a converter with a subset of prefixes. :param prefixes: A list of prefixes to keep from this converter. These can correspond either to preferred CURIE prefixes or CURIE prefix synonyms. :returns: A new, slimmed down converter This functionality is useful for downstream applications like the following: 1. You load a comprehensive extended prefix map, e.g., from the Bioregistry using :func:`curies.get_bioregistry_converter()`. 2. You load some data that conforms to this prefix map by convention. This is often the case for semantic mappings stored in the `SSSOM format `_. 3. You extract the list of prefixes *actually* used within your data 4. You subset the detailed extended prefix map to only include prefixes relevant for your data 5. You make some kind of output of the subsetted extended prefix map to go with your data. Effectively, this is a way of reconciling data. This is especially effective when using the Bioregistry or other comprehensive extended prefix maps. Here's a concrete example of doing this (which also includes a bit of data science) to do this on the SSSOM mappings from the `Disease Ontology `_ project. >>> import curies >>> import pandas as pd >>> import itertools as itt >>> commit = "faca4fc335f9a61902b9c47a1facd52a0d3d2f8b" >>> url = f"https://raw.githubusercontent.com/mapping-commons/disease-mappings/{commit}/mappings/doid.sssom.tsv" >>> df = pd.read_csv(url, sep="\t", comment="#") >>> prefixes = { ... curies.Reference.from_curie(curie).prefix ... for column in ["subject_id", "predicate_id", "object_id"] ... for curie in df[column] ... } >>> converter = curies.get_bioregistry_converter() >>> slim_converter = converter.get_subconverter(prefixes) """ prefixes = set(prefixes) records = [ record for record in self.records if any(prefix in prefixes for prefix in record._all_prefixes) ] return Converter(records) def hash_triple(self, triple: Triple, *, negate: bool = False) -> str: """Hash a triple using :func:`curies.triples.hash_triple`, implementing https://ts4nfdi.github.io/mapping-sameness-identifier. :param triple: A subject-predicate-object triple :param negate: If true, considers the triple as "negative" and postpends a ``~`` to the hash :returns: A hexadecimal digest of the SHA-256 hash of the space-joined expanded URI triple >>> import curies >>> from curies import Triple, Converter >>> converter = curies.load_prefix_map( ... { ... "mesh": "http://id.nlm.nih.gov/mesh/", ... "skos": "http://www.w3.org/2004/02/skos/core#", ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... } ... ) >>> triple = Triple( ... subject="mesh:C000089", ... predicate="skos:exactMatch", ... object="CHEBI:28646", ... ) >>> converter.hash_triple(triple) '36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a' >>> converter.hash_triple(triple, negate=True) '36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a~' """ from .triples import hash_triple return hash_triple(self, triple, negate=negate) def chain(converters: Sequence[Converter], *, case_sensitive: bool = True) -> Converter: """Chain several converters. :param converters: A list or tuple of converters :param case_sensitive: If false, will not allow case-sensitive duplicates :returns: A converter that looks up one at a time in the other converters. :raises ValueError: If there are no converters Chain is the perfect tool if you want to override parts of an existing extended prefix map. For example, if you want to use most of the Bioregistry, but you would like to specify a custom URI prefix (e.g., using Identifiers.org), you can do the following: >>> import curies >>> bioregistry_converter = curies.get_bioregistry_converter() >>> overrides = curies.load_prefix_map({"pubmed": "https://identifiers.org/pubmed:"}) >>> converter = curies.chain([overrides, bioregistry_converter]) >>> converter.bimap["pubmed"] 'https://identifiers.org/pubmed:' Similarly, this also works if you want to override a prefix. Keep in mind for this to work with a simple prefix map, you need to make sure the URI prefix matches in each converter, otherwise you will get duplicates: >>> overrides = curies.load_prefix_map({"PMID": "https://www.ncbi.nlm.nih.gov/pubmed/"}) >>> converter = chain([overrides, bioregistry_converter]) >>> converter.bimap["PMID"] 'https://www.ncbi.nlm.nih.gov/pubmed/' A safer way is to specify your override using an extended prefix map, which can tie together prefix synonyms and URI prefix synonyms: >>> import curies >>> from curies import Converter, chain, get_bioregistry_converter >>> overrides = curies.load_extended_prefix_map( ... [ ... { ... "prefix": "PMID", ... "prefix_synonyms": ["pubmed", "PubMed"], ... "uri_prefix": "https://www.ncbi.nlm.nih.gov/pubmed/", ... "uri_prefix_synonyms": [ ... "https://identifiers.org/pubmed:", ... "http://bio2rdf.org/pubmed:", ... ], ... }, ... ] ... ) >>> converter = curies.chain([overrides, bioregistry_converter]) >>> converter.bimap["PMID"] 'https://www.ncbi.nlm.nih.gov/pubmed/' Chain prioritizes based on the order given. Therefore, if two prefix maps having the same prefix but different URI prefixes are given, the first is retained >>> c1 = curies.load_prefix_map({"GO": "http://purl.obolibrary.org/obo/GO_"}) >>> c2 = curies.load_prefix_map({"GO": "https://identifiers.org/go:"}) >>> c3 = curies.chain([c1, c2]) >>> c3.prefix_map["GO"] 'http://purl.obolibrary.org/obo/GO_' """ converters = list(converters) if not converters: raise ValueError if len(converters) == 1: return converters[0] rv = Converter() for converter in converters: for record in converter.records: rv.add_record(record, case_sensitive=case_sensitive, merge=True) return rv def load_prefix_map(prefix_map: LocationOr[Mapping[str, str]], **kwargs: Any) -> Converter: """Get a converter from a simple prefix map. :param prefix_map: One of the following: - A mapping whose keys represent CURIE prefixes and values represent URI prefixes - A string containing a remote location of a JSON file containg a prefix map - A string or :class:`pathlib.Path` object corresponding to a local file path to a JSON file containing a prefix map :param kwargs: Keyword arguments to pass to :meth:`curies.Converter.__init__` :returns: A converter >>> import curies >>> converter = curies.load_prefix_map( ... { ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... } ... ) >>> converter.expand("CHEBI:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' >>> converter.compress("http://purl.obolibrary.org/obo/CHEBI_138488") 'CHEBI:138488' """ return Converter.from_prefix_map(prefix_map, **kwargs) def load_extended_prefix_map( records: LocationOr[Iterable[Record | dict[str, Any]]], **kwargs: Any ) -> Converter: """Get a converter from a list of dictionaries by creating records out of them. :param records: One of the following: - An iterable of :class:`curies.Record` objects or dictionaries that will get converted into record objects that together constitute an extended prefix map - A string containing a remote location of a JSON file containg an extended prefix map - A string or :class:`pathlib.Path` object corresponding to a local file path to a JSON file containing an extended prefix map :param kwargs: Keyword arguments to pass to :meth:`curies.Converter.__init__` :returns: A converter An extended prefix map is a list of dictionaries containing four keys: 1. A ``prefix`` string 2. A ``uri_prefix`` string 3. An optional list of strings ``prefix_synonyms`` 4. An optional list of strings ``uri_prefix_synonyms`` Across the whole list of dictionaries, there should be uniqueness within the union of all ``prefix`` and ``prefix_synonyms`` as well as uniqueness within the union of all ``uri_prefix`` and ``uri_prefix_synonyms``. >>> import curies >>> epm = [ ... { ... "prefix": "CHEBI", ... "prefix_synonyms": ["chebi", "ChEBI"], ... "uri_prefix": "http://purl.obolibrary.org/obo/CHEBI_", ... "uri_prefix_synonyms": ["https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:"], ... }, ... { ... "prefix": "GO", ... "uri_prefix": "http://purl.obolibrary.org/obo/GO_", ... }, ... ] >>> converter = curies.load_extended_prefix_map(epm) Expand using the preferred/canonical prefix: >>> converter.expand("CHEBI:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' Expand using a prefix synonym: >>> converter.expand("chebi:138488") 'http://purl.obolibrary.org/obo/CHEBI_138488' Compress using the preferred/canonical URI prefix: >>> converter.compress("http://purl.obolibrary.org/obo/CHEBI_138488") 'CHEBI:138488' Compressing using a URI prefix synonym: >>> converter.compress("https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:138488") 'CHEBI:138488' Example from a remote source: >>> url = "https://github.com/biopragmatics/bioregistry/raw/main/exports/contexts/bioregistry.epm.json" >>> converter = curies.load_extended_prefix_map(url) """ return Converter.from_extended_prefix_map(records, **kwargs) def load_jsonld_context(data: LocationOr[dict[str, Any]], **kwargs: Any) -> Converter: """Get a converter from a JSON-LD object, which contains a prefix map in its ``@context`` key. :param data: A JSON-LD object :param kwargs: Keyword arguments to pass to :meth:`curies.Converter.__init__` :returns: A converter Example from a remote context file: >>> base = "https://raw.githubusercontent.com" >>> url = f"{base}/biopragmatics/bioregistry/main/exports/contexts/semweb.context.jsonld" >>> converter = Converter.from_jsonld(url) >>> "rdf" in converter.prefix_map """ return Converter.from_jsonld(data, **kwargs) def load_shacl(data: LocationOr[rdflib.Graph], **kwargs: Any) -> Converter: """Get a converter from a JSON-LD object, which contains a prefix map in its ``@context`` key. :param data: A path to an RDF file or a RDFlib graph :param kwargs: Keyword arguments to pass to :meth:`curies.Converter.__init__` :returns: A converter """ return Converter.from_shacl(data, **kwargs) def write_extended_prefix_map(converter: Converter, path: str | Path) -> None: """Write an extended prefix map as JSON to a file.""" path = _ensure_path(path) path.write_text( json.dumps( [_record_to_dict(record) for record in converter.records], indent=4, sort_keys=True, ensure_ascii=False, ) ) def _record_to_dict(record: Record) -> Mapping[str, str | list[str]]: """Convert a record to a dict.""" rv: dict[str, str | list[str]] = { "prefix": record.prefix, "uri_prefix": record.uri_prefix, } if record.prefix_synonyms: rv["prefix_synonyms"] = sorted(record.prefix_synonyms) if record.uri_prefix_synonyms: rv["uri_prefix_synonyms"] = sorted(record.uri_prefix_synonyms) if record.pattern: rv["pattern"] = record.pattern return rv def _ensure_path(path: str | Path) -> Path: if isinstance(path, str): path = Path(path).resolve() return path def _get_jsonld_context( converter: Converter, *, expand: bool = False, include_synonyms: bool = False ) -> dict[str, Any]: """Get a JSON-LD context based on the converter.""" context = {} for record in converter.records: term = _get_expanded_term(record, expand=expand) context[record.prefix] = term if include_synonyms: for prefix_synonym in record.prefix_synonyms: context[prefix_synonym] = term return {"@context": context} def write_jsonld_context( converter: Converter, path: str | Path, *, include_synonyms: bool = False, expand: bool = False, ) -> None: """Write the converter's bijective map as a JSON-LD context to a file. :param converter: The converter to export :param path: The path to a file to write to :param include_synonyms: If true, includes CURIE prefix synonyms. URI prefix synonyms are not output. :param expand: If False, output a dictionary-like ``@context`` element. If True, use ``@prefix`` and ``@id`` as keys for the CURIE prefix and URI prefix, respectively, to maximize compatibility. The following example shows writing a JSON-LD context: .. code-block:: python import curies converter = curies.load_prefix_map( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) curies.write_jsonld_context(converter, "example_context.json") .. code-block:: json { "@context": { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_" } } Because some implementations of JSON-LD do not like URI prefixes that end with an underscore ``_``, we can use the ``expand`` keyword to turn on more verbose JSON-LD context output that contains explicit ``@prefix`` and ``@id`` annotations .. code-block:: python import curies converter = curies.load_prefix_map( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) curies.write_jsonld_context(converter, "example_context.json", expand=True) .. code-block:: json { "@context": { "CHEBI": { "@id": "http://purl.obolibrary.org/obo/CHEBI_", "@prefix": true } } } """ path = _ensure_path(path) obj = _get_jsonld_context(converter, include_synonyms=include_synonyms, expand=expand) with path.open("w") as file: json.dump(obj, file, indent=4, sort_keys=True) def _get_expanded_term(record: Record, *, expand: bool) -> str | dict[str, Any]: if not expand: return record.uri_prefix # Use expanded term definition described in https://www.w3.org/TR/json-ld11/#expanded-term-definition rv = {"@prefix": True, "@id": record.uri_prefix} # TODO add an @context inside here to somehow capture the pattern, if available # if record.pattern: # rv["@context"] = { # "sh": "http://www.w3.org/ns/shacl#", # "sh:pattern": record.pattern, # } return rv def write_shacl( converter: Converter, path: str | Path, *, include_synonyms: bool = False, ) -> None: """Write the converter's bijective map as SHACL in turtle RDF to a file. :param converter: The converter to export :param path: The path to a file to write to :param include_synonyms: If true, includes CURIE prefix synonyms. URI prefix synonyms are not output. .. seealso:: https://www.w3.org/TR/shacl/#sparql-prefixes .. code-block:: python import curies converter = curies.load_prefix_map( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) curies.write_shacl(converter, "example_shacl.ttl") .. code-block:: @prefix sh: . @prefix xsd: . [ sh:declare [ sh:prefix "CHEBI" ; sh:namespace "http://purl.obolibrary.org/obo/CHEBI_"^^xsd:anyURI ] ] . """ text = dedent( """\ @prefix sh: . @prefix xsd: . [ sh:declare {entries} ] . """ ) path = _ensure_path(path) lines = [] for record in converter.records: lines.append(_get_shacl_line(record.prefix, record.uri_prefix, pattern=record.pattern)) if include_synonyms: for prefix_synonym in record.prefix_synonyms: lines.append( _get_shacl_line(prefix_synonym, record.uri_prefix, pattern=record.pattern) ) path.write_text(text.format(entries=",\n".join(lines))) def write_tsv( converter: Converter, path: str | Path, *, header: tuple[str, str] = ("prefix", "base") ) -> None: """Write a simple prefix map CSV file. :param converter: The converter to export :param path: The path to a file to write to :param header: A 2-tuple of strings representing the header used in the file, where the first element is the label for CURIE prefixes and the second element is the label for URI prefixes .. code-block:: python import curies converter = curies.load_prefix_map( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) curies.write_tsv(converter, "example_context.tsv") .. code-block:: prefix base CHEBI http://purl.obolibrary.org/obo/CHEBI_ """ import csv path = _ensure_path(path) with path.open("w") as csvfile: writer = csv.writer(csvfile, delimiter="\t") writer.writerow(header) for record in converter.records: writer.writerow((record.prefix, record.uri_prefix)) def _get_shacl_line(prefix: str, uri_prefix: str, pattern: str | None = None) -> str: line = f' [ sh:prefix "{prefix}" ; sh:namespace "{uri_prefix}"^^xsd:anyURI ' if pattern: pattern = pattern.replace("\\", "\\\\") line += f'; sh:pattern "{pattern}"' return line + " ]" def upgrade_prefix_map(prefix_map: Mapping[str, str]) -> list[Record]: """Convert a (potentially problematic) prefix map (i.e., not bijective) into a list of records. A prefix map is bijective if it has no duplicate CURIE prefixes (i.e., keys in a dictionary) and no duplicate URI prefixes (i.e., values in a dictionary). Because of the way that dictionaries work in Python, we are always guaranteed that there are no duplicate keys. However, it is both possible and frequent to have duplicate values. This happens because many semantic spaces have multiple synonymous CURIE prefixes. For example, the `OBO in OWL `_ vocabulary has two common, interchangable prefixes: ``oio`` and ``oboInOwl`` (and the case variant ``oboinowl``). Therefore, a prefix map might contain the following parts that make it non-bijective: .. code-block:: json { "oio": "http://www.geneontology.org/formats/oboInOwl#", "oboInOwl": "http://www.geneontology.org/formats/oboInOwl#" } This is bad because this prefix map can't be used to determinstically compress a URI. For example, should ``http://www.geneontology.org/formats/oboInOwl#hasDbXref`` be compressed to ``oio:hasDbXref`` or ``oboInOwl:hasDbXref``? Neither is necessarily incorrect, but the issue here is that there is not an explicit choice by the data modeler, meaning that data compressed into CURIEs with this non-bijective map might not be readily integrable with other datasets. The best solution to this situation is not more code, but rather for the data modeler to address the issue upstream in the following steps: 1. Choose the which of prefix synonyms is going to be the primary prefix. If you're not sure, the `Bioregistry `_ is a comprehensive registry of prefixes and their syonyms applicable in the semantic web and the natural sciences. It gives a good suggestion of what the best prefix is. In the OBO in OWL case, it suggests ``oboInOwl``. 2. Update all related data artifacts to only use that preferred prefix 3. Either 1) remove the other synonyms (in this example, ``oio``) from the prefix map *or* 2) transition to using :ref:`epms`, a more modern data structure for supporting URI and CURIE interconversion. The first part of step 3 in this solution highlights one of the key shortcomings of prefix maps themselves - they can't keep track of synonyms, which are often useful in data integration, especially when a single prefix map is defined on the level of a project or community. The extended prefix map is a simple data structure proposed to address this. - - - This function is for people who are not in the position to make the sustainable fix, and want to automate the assignment of which is the preferred prefix. It uses a deterministic algorithm to choose from two or more CURIE prefixes that have the same URI prefix and generate an extended prefix map in which they have bene collapsed into a single record. More specitically, the algorithm is based on a case-sensitive lexical sort of the prefixes. The first in the sort order becomes the primary prefix and the others become synonyms in the resulting record. :param prefix_map: A mapping whose keys represent CURIE prefixes and values represent URI prefixes :returns: A list of :class:`curies.Record` objects that together constitute an extended prefix map >>> from curies import Converter, upgrade_prefix_map >>> pm = {"a": "https://example.com/a/", "b": "https://example.com/a/"} >>> records = upgrade_prefix_map(pm) >>> converter = Converter(records) >>> converter.expand("a:1") 'https://example.com/a/1' >>> converter.expand("b:1") 'https://example.com/a/1' >>> converter.compress("https://example.com/a/1") 'a:1' .. note:: Thanks to `Joe Flack `_ for proposing this algorithm `in this discussion `_. """ uri_prefix_to_curie_synonyms = defaultdict(list) for curie_prefix, uri_prefix in prefix_map.items(): uri_prefix_to_curie_synonyms[uri_prefix].append(curie_prefix) priority_prefix_map = { uri_prefix: sorted(curie_prefixes) for uri_prefix, curie_prefixes in uri_prefix_to_curie_synonyms.items() } return [ Record(prefix=prefix, prefix_synonyms=prefix_synonyms, uri_prefix=uri_prefix) for uri_prefix, (prefix, *prefix_synonyms) in sorted(priority_prefix_map.items()) ] def _converter_from_validation_info(info: core_schema.ValidationInfo) -> Converter | None: context = info.context or {} if isinstance(context, Converter): return context elif isinstance(context, dict): return context.get("converter") else: raise TypeError class Trie(UserDict[str, Record]): """A partial implementation of a string trie.""" root: TrieNode def __init__(self, initial_dict: dict[str, Record] | None = None) -> None: """Create a new string trie.""" super().__init__() self.root = TrieNode() if initial_dict: for key, value in initial_dict.items(): self[key] = value def __setitem__(self, key: str, item: Record) -> None: self.root._ensure_node(key).value = item def parse_uri(self, uri: str) -> ReferenceTuple | None: """Parse a URI into a prefix/identifier pair based prefixes in the trie.""" node: TrieNode = self.root record: Record | None = self.root.value max_non_null_index = -1 for i, character in enumerate(uri): new_node = node.children.get(character) if new_node is None: break if new_node.value is not None: record = new_node.value max_non_null_index = i node = new_node if record is None: return None identifier = uri[max_non_null_index + 1 :] return ReferenceTuple(record.prefix, identifier) def __contains__(self, key: Any) -> bool: node = self.root._find_node(key) return node is not None and node.value is not None class TrieNode: """Trie node class.""" __slots__ = ("children", "value") value: Record | None children: dict[str, TrieNode] def __init__(self, value: Record | None = None) -> None: """Initialize the node.""" self.value = value # this needs to be mutable, which is why this isn't a named tuple self.children = {} def _ensure_node(self, key: str) -> TrieNode: node = self for character in key: next_node: TrieNode | None = node.children.get(character) if next_node is None: node = node.children.setdefault(character, TrieNode()) else: node = next_node return node def _find_node(self, key: Sequence[str]) -> TrieNode | None: node = self for character in key: next_node = node.children.get(character) if next_node is None: return None node = next_node return node biopragmatics-curies-aa8bd37/src/curies/cli.py000066400000000000000000000126461525104130300215260ustar00rootroot00000000000000"""This package comes with a built-in CLI for running a resolver web application. .. code-block:: $ python -m curies resolver --host 0.0.0.0 --port 8764 bioregistry The positional argument can be one of the following: 1. A pre-defined prefix map to get from the web (bioregistry, go, obo, monarch, prefixcommons) 2. A local file path or URL to a prefix map, extended prefix map, or one of several formats. Requires specifying a `--format`. The framework can be swapped to use Flask (default) or FastAPI with `--framework`. The server can be swapped to use Werkzeug (default) or Uvicorn with `--server`. These functionalities are also available programmatically (see :func:`get_flask_app` and :func:`get_fastapi_app`). Similarly, there's a built-in CLI for running a mapper web application. .. code-block:: $ python -m curies mapper --host 0.0.0.0 --port 8764 bioregistry The same flags and arguments are applicable. """ from __future__ import annotations import sys from collections.abc import Callable, Mapping from typing import TYPE_CHECKING, TypeAlias import click from curies import Converter, sources if TYPE_CHECKING: import fastapi import flask __all__ = [ "main", ] AppHint: TypeAlias = "flask.Flask | fastapi.FastAPI" LOADERS: dict[str, Callable[[str], Converter]] = { "jsonld": Converter.from_jsonld, "prefix_map": Converter.from_prefix_map, "extended_prefix_map": Converter.from_extended_prefix_map, "reverse_prefix_map": Converter.from_reverse_prefix_map, "priority_prefix_map": Converter.from_priority_prefix_map, } CONVERTERS: Mapping[str, Callable[[], Converter]] = { "bioregistry": sources.get_bioregistry_converter, "go": sources.get_go_converter, "monarch": sources.get_monarch_converter, "obo": sources.get_obo_converter, "prefixcommons": sources.get_prefixcommons_converter, } def _get_converter(location: str, format: str | None) -> Converter: if location in CONVERTERS: return CONVERTERS[location]() if format is None: click.secho("--format is required with remote data", fg="red") return sys.exit(1) return LOADERS[format](location) def _get_resolver_app(converter: Converter, framework: str) -> AppHint: from curies import resolver_service if framework == "flask": return resolver_service.get_flask_app(converter) elif framework == "fastapi": return resolver_service.get_fastapi_app(converter) else: raise ValueError(f"Unhandled framework: {framework}") def _get_mapper_app(converter: Converter, framework: str) -> AppHint: from curies import mapping_service if framework == "flask": return mapping_service.get_flask_mapping_app(converter) elif framework == "fastapi": return mapping_service.get_fastapi_mapping_app(converter) else: raise ValueError(f"Unhandled framework: {framework}") def _run_app(app: AppHint, server: str, host: str, port: int) -> None: if server == "uvicorn": import uvicorn uvicorn.run(app, host=host, port=port) elif server == "werkzeug": import flask if not isinstance(app, flask.Flask): raise NotImplementedError app.run(host=host, port=port) elif server == "gunicorn": raise NotImplementedError else: raise ValueError(f"Unhandled server: {server}") LOCATION_ARGUMENT = click.argument("location") FRAMEWORK_OPTION = click.option( "--framework", default="flask", type=click.Choice(["flask", "fastapi"]), show_default=True, help="The framework used to implement the app. Note, each requires different packages to be installed.", ) SERVER_OPTION = click.option( "--server", default="werkzeug", type=click.Choice(["werkzeug", "uvicorn", "gunicorn"]), show_default=True, help="The web server used to run the app. Note, each requires different packages to be installed.", ) FORMAT_OPTION = click.option( "--format", type=click.Choice(list(LOADERS)), help="The data structure of the resolver data. Required if not giving a pre-defined converter name.", ) HOST_OPTION = click.option( "--host", default="0.0.0.0", # noqa:S104 show_default=True, help="The host where the resolver runs", ) PORT_OPTION = click.option( "--port", type=int, default=8764, show_default=True, help="The port where the resolver runs" ) @click.group() def main() -> None: """Run the `curies` CLI.""" @main.command( help="""\ Location can either be the name of a built-in converter, a file path, or a URL. """ ) @LOCATION_ARGUMENT @FRAMEWORK_OPTION @SERVER_OPTION @FORMAT_OPTION @HOST_OPTION @PORT_OPTION def resolver(location: str, host: str, port: int, framework: str, format: str, server: str) -> None: """Serve a resolver app.""" converter = _get_converter(location, format) app = _get_resolver_app(converter, framework=framework) _run_app(app, server=server, host=host, port=port) @main.command( help="""\ Location can either be the name of a built-in converter, a file path, or a URL. """ ) @LOCATION_ARGUMENT @FRAMEWORK_OPTION @SERVER_OPTION @FORMAT_OPTION @HOST_OPTION @PORT_OPTION def mapper(location: str, host: str, port: int, framework: str, format: str, server: str) -> None: """Serve a mapper app.""" converter = _get_converter(location, format) app = _get_mapper_app(converter, framework=framework) _run_app(app, server=server, host=host, port=port) if __name__ == "__main__": main() biopragmatics-curies-aa8bd37/src/curies/database.py000066400000000000000000000324501525104130300225160ustar00rootroot00000000000000"""Database adapters for :mod:`curies`. ####################### Using :mod:`sqlmodel` ####################### SQLModel is a joint abstraction over :mod:`pydantic` and :mod:`sqlalchemy`. If you want to use :class:`curies.Reference` within a SQLModel class, you can do so by setting the SQLAlchemy column type through the ``sa_column`` keyword argument to the results returned by :func:`get_reference_sa_column`, For example, this can be used to model a semantic triple, which has a subject reference, predicate reference, and object reference like in the following: .. code-block:: python from curies import Reference from curies.database import get_reference_sa_column from sqlmodel import Field, Session, SQLModel, create_engine, select class Edge(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) subject: Reference = Field(sa_column=get_reference_sa_column()) predicate: Reference = Field(sa_column=get_reference_sa_column()) object: Reference = Field(sa_column=get_reference_sa_column()) e1 = Edge(subject="CHEBI:135122", predicate="skos:exactMatch", object="mesh:C073738") e2 = Edge(subject="CHEBI:135125", predicate="skos:exactMatch", object="mesh:C073260") engine = create_engine("sqlite://") SQLModel.metadata.create_all(engine) # Add edges to the database with Session(engine) as session: session.add_all([e1, e2]) session.commit() # Query for edges with a given subject, by string with Session(engine) as session: statement = select(Edge).where(Edge.subject == "CHEBI:135122") edges = session.exec(statement).all() # Query for edges with a given subject, by string with Session(engine) as session: statement = select(Edge).where( Edge.subject == Reference(prefix="CHEBI", identifier="135125") ) edges = session.exec(statement).all() If you want to have a field that has an optional reference, you can do it as expected using the same column type, such as for the ``author`` field below: .. code-block:: python class Edge(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) subject: Reference = Field(sa_column=get_reference_sa_column()) predicate: Reference = Field(sa_column=get_reference_sa_column()) object: Reference = Field(sa_column=get_reference_sa_column()) author: Reference | None = Field(sa_column=get_reference_sa_column()) If you want to have a field with a list of references, you can do it using :func:`get_reference_list_sa_column`: .. code-block:: python class Record(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) authors: list[Reference] = Field( default_factory=list, sa_column=get_reference_list_sa_column() ) This also works how you would expect if you don't use a default_factory or if you choose to make it optional ``list[Reference] | None``. ######################### Using :mod:`sqlalchemy` ######################### SQLAlchemy is a combine high- and mid-level database abstraction layer and object-relational mapping. It has more opportunities for configuration over SQLModel. The following example re-formulates the above example for SQLModel using the slightly more direct SQLAlchemy formulation: .. code-block:: python from curies import Reference from sqlalchemy import Column, Integer, String, create_engine from sqlalchemy.orm import DeclarativeBase, Session class Base(DeclarativeBase): pass class Edge(Base): __tablename__ = "edge" id = Column(Integer, primary_key=True) subject = get_reference_sa_column() predicate = get_reference_sa_column() object = get_reference_sa_column() e1 = Edge(subject="CHEBI:135122", predicate="skos:exactMatch", object="mesh:C073738") e2 = Edge(subject="CHEBI:135125", predicate="skos:exactMatch", object="mesh:C073260") engine = create_engine("sqlite://") Base.metadata.create_all(engine) # Add edges to the database with Session(engine) as session: session.add_all([e1, e2]) session.commit() # Query for edges with a given subject, by string with Session(engine) as session: statement = select(Edge).where(Edge.subject == "CHEBI:135122") edges = session.exec(statement).all() # Query for edges with a given subject, by string with Session(engine) as session: statement = select(Edge).where( Edge.subject == Reference(prefix="CHEBI", identifier="135125") ) edges = session.exec(statement).all() In the following example, SQLAlchemy is used with a composite column, where the prefix and identifier are given their own column, then a :class:`sqlalchemy.Composite` object is used to tell the ORM to map both the prefix and identifier together into a single column, that exposes an appropriate :class:`curies.Reference` class. .. code-block:: python from curies import Reference from sqlalchemy import Column, Integer, String, create_engine from sqlalchemy.orm import DeclarativeBase, Session class Base(DeclarativeBase): pass class Edge(Base): __tablename__ = "edge" id = Column(Integer, primary_key=True) subject_prefix = Column(String, nullable=False) subject_identifier = Column(String, nullable=False) predicate_prefix = Column(String, nullable=False) predicate_identifier = Column(String, nullable=False) object_prefix = Column(String, nullable=False) object_identifier = Column(String, nullable=False) subject = get_reference_sa_composite(subject_prefix, subject_identifier) predicate = get_reference_sa_composite(predicate_prefix, predicate_identifier) object = get_reference_sa_composite(object_prefix, object_identifier) e1 = Edge(subject="CHEBI:135122", predicate="skos:exactMatch", object="mesh:C073738") e2 = Edge(subject="CHEBI:135125", predicate="skos:exactMatch", object="mesh:C073260") engine = create_engine("sqlite://") Base.metadata.create_all(engine) # Add edges to the database with Session(engine) as session: session.add_all([e1, e2]) session.commit() # Query for edges with a given subject, by string with Session(engine) as session: statement = select(Edge).where(Edge.subject == "CHEBI:135122") edges = session.exec(statement).all() # Query for edges with a given subject, by string with Session(engine) as session: statement = select(Edge).where( Edge.subject == Reference(prefix="CHEBI", identifier="135125") ) edges = session.exec(statement).all() Similarly to above, lists can be constructed using :func:`get_reference_list_sa_column`: .. code-block:: python class Record(Base): __tablename__ = "record" id = Column(Integer, primary_key=True) authors = get_reference_list_sa_column() """ from __future__ import annotations from typing import Any, ClassVar import sqlalchemy from sqlalchemy import Column from sqlalchemy.engine.interfaces import Dialect from sqlalchemy.orm import Composite, composite from sqlalchemy.sql.type_api import TypeEngine from sqlalchemy.types import JSON, TEXT, TypeDecorator from curies import Prefix, Reference __all__ = [ "SAReferenceListTypeDecorator", "SAReferenceTypeDecorator", "get_reference_list_sa_column", "get_reference_sa_column", "get_reference_sa_composite", ] class SAReferenceListTypeDecorator(TypeDecorator[list[Reference]]): """A SQLAlchemy type decorator for a list of :mod:`curies.Reference`.""" impl: ClassVar[type[TypeEngine[str]]] = JSON # type:ignore[misc] #: Set SQLAlchemy caching to true cache_ok: ClassVar[bool] = True # type:ignore[misc] def process_bind_param( self, value: str | Reference | list[Reference] | None, dialect: Dialect ) -> list[str] | None: """Convert the Python object into a database value.""" if value is None: return None if isinstance(value, str): return [value] elif isinstance(value, Reference): return [value.curie] else: return [v.curie for v in value] def process_result_value( self, value: list[str] | None, dialect: Dialect ) -> list[Reference] | None: """Convert the database value into a Python object.""" if value is None: return None return [Reference.from_curie(v) for v in value] class SAReferenceTypeDecorator(TypeDecorator[Reference]): """A SQLAlchemy type decorator for a :mod:`curies.Reference`.""" impl: ClassVar[type[TypeEngine[str]]] = TEXT # type:ignore[misc] #: Set SQLAlchemy caching to true cache_ok: ClassVar[bool] = True # type:ignore[misc] def process_bind_param(self, value: str | Reference | None, dialect: Dialect) -> str | None: """Convert the Python object into a database value.""" if value is None: return None if isinstance(value, str): return value return value.curie def process_result_value(self, value: str | None, dialect: Dialect) -> Reference | None: """Convert the database value into a Python object.""" if value is None: return None return Reference.from_curie(value) # TODO what about process literal param? def get_reference_sa_column(*args: Any, **kwargs: Any) -> sqlalchemy.Column[Reference]: """Get a SQLAlchemy column with the type decorator for a :mod:`curies.Reference`. :param args: positional arguments, passed to :class:`sqlalchemy.Column` :param kwargs: keyword arguments, passed to :class:`sqlalchemy.Column` :returns: A column object, parametrized with :class:`curies.Reference` For example, this can be used to model a semantic triple, which has a subject reference, predicate reference, and object reference like in the following: .. code-block:: python from curies import Reference from curies.database import get_reference_sa_column from sqlmodel import Field, SQLModel class Edge(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) subject: Reference = Field(sa_column=get_reference_sa_column()) predicate: Reference = Field(sa_column=get_reference_sa_column()) object: Reference = Field(sa_column=get_reference_sa_column()) """ return sqlalchemy.Column(SAReferenceTypeDecorator(), *args, **kwargs) def get_reference_list_sa_column(*args: Any, **kwargs: Any) -> sqlalchemy.Column[list[Reference]]: """Get a SQLAlchemy column with the type decorator for a :list of mod:`curies.Reference`. :param args: positional arguments, passed to :class:`sqlalchemy.Column` :param kwargs: keyword arguments, passed to :class:`sqlalchemy.Column` :returns: A column object, parametrized with list of :class:`curies.Reference` For example, this can be used to model an author list like in the following: .. code-block:: python from curies import Reference from curies.database import get_reference_list_sa_column from sqlmodel import Field, SQLModel class Edge(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) authors: list[Reference] = Field( default_factory=list, sa_column=get_reference_list_sa_column() ) """ return sqlalchemy.Column(SAReferenceListTypeDecorator(), *args, **kwargs) class _ReferenceAdapter(Reference): """A wrapper for SQLAlchemy for usage in composite().""" def __init__(self, prefix: str, identifier: str) -> None: """Initialize the SQLAlchemy model.""" super().__init__(prefix=Prefix(prefix), identifier=identifier) def get_reference_sa_composite( prefix_column: Column[str], identifier_column: Column[str], **kwargs: Any ) -> Composite[Reference]: """Get a composite for a reference. :param prefix_column: The column for the reference's prefix :param identifier_column: The column for the reference's identifier :param kwargs: keyword arguments passed to :func:`sqlalchemy.orm.composite` :returns: A Composite object for a reference .. code-block:: python from curies import Reference from curies.database import get_reference_sa_composite from sqlalchemy import Column from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): pass class Edge(Base): __tablename__ = "edge" id = Column(Integer, primary_key=True) subject_prefix = Column(String, nullable=False) subject_identifier = Column(String, nullable=False) predicate_prefix = Column(String, nullable=False) predicate_identifier = Column(String, nullable=False) object_prefix = Column(String, nullable=False) object_identifier = Column(String, nullable=False) subject = get_reference_sa_composite(subject_prefix, subject_identifier) predicate = get_reference_sa_composite(predicate_prefix, predicate_identifier) object = get_reference_sa_composite(object_prefix, object_identifier) """ return composite(_ReferenceAdapter, prefix_column, identifier_column, **kwargs) biopragmatics-curies-aa8bd37/src/curies/dataframe.py000066400000000000000000000322221525104130300226730ustar00rootroot00000000000000"""Dataframe operations.""" from __future__ import annotations from collections import defaultdict from collections.abc import Callable, Collection from typing import TYPE_CHECKING, Any, Literal, TypeAlias, TypeGuard, Union, cast from .utils import _prefix_from_curie if TYPE_CHECKING: import pandas as pd from .api import Converter __all__ = [ "PrefixIndexMethod", "filter_df_by_curies", "filter_df_by_prefixes", "get_df_curies_index", "get_df_prefixes_index", "get_df_unique_prefixes", "get_filter_df_by_curies_index", "get_filter_df_by_prefixes_index", ] DataframeOrSeries: TypeAlias = Union["pd.DataFrame", "pd.Series[str]"] def _get_prefix_checker(prefix: str | Collection[str]) -> Callable[[str], bool]: """Get a function that checks if a CURIE starts with a prefix.""" if isinstance(prefix, str): prefix_with_colon = prefix + ":" def _func(curie: str) -> bool: return curie.startswith(prefix_with_colon) else: prefixes_with_colons = {p + ":" for p in prefix} def _func(curie: str) -> bool: return any( curie.startswith(prefix_with_colon) for prefix_with_colon in prefixes_with_colons ) return _func def _get_prefixes_from_curie_column( df: DataframeOrSeries, *, column: int | str | None = None, converter: Converter | None = None, validate: bool, ) -> pd.Series[str]: return _get_series(df, column).map(_get_curie_parser(converter=converter, validate=validate)) def _get_curie_parser( *, converter: Converter | None = None, validate: bool = False ) -> Callable[[str], str]: # TODO what if it can't parse? # TODO handle None? # TODO handle invalid CURIEs? if not validate: return _prefix_from_curie elif converter is None: raise ValueError("converter is required for validation") else: def _func(curie: str) -> str: reference = converter.parse_curie(curie, strict=True) return reference.prefix return _func #: The method for filtering on prefixe PrefixIndexMethod: TypeAlias = Literal["iterative", "precalculated"] def get_filter_df_by_prefixes_index( df: DataframeOrSeries, *, column: str | int | None = None, prefixes: str | Collection[str], method: PrefixIndexMethod | None = None, validate: bool = False, converter: Converter | None = None, ) -> pd.Series[bool]: """Get an index of CURIEs in the given column that start with the prefix(es). :param df: A dataframe or series. If a dataframe is given, the ``column`` must not be none. :param column: The column to check, if a dataframe was passed. If a series was passed, this can be left as none. :param prefixes: The prefix or set of prefixes to identify :param method: The indexing method :param validate: Should the prefixes be validated against the converter? :param converter: A converter for validating CURIEs :returns: A pandas boolean series that corresponds to the rows of the dataframe or series provided :raises ValueError: If validation is set to true but no converter is passed Example usage: .. code-block:: python import pandas as pd from curies.dataframe import get_filter_df_by_prefixes_index rows = [ ("DOID:0080795", "skos:exactMatch", "EFO:0003029", "semapv:ManualMappingCuration"), ("DOID:0080795", "skos:exactMatch", "mesh:D015471", "semapv:ManualMappingCuration"), ("DOID:0080799", "skos:exactMatch", "EFO:1000527", "semapv:ManualMappingCuration"), ( "DOID:0080808", "skos:exactMatch", "mesh:D000069295", "semapv:ManualMappingCuration", ), ] df = pd.DataFrame( rows, columns=["subject_id", "predicate_id", "object_id", "mapping_justification"] ) idx = get_filter_df_by_prefixes_index(df, column="object_id", prefixes=["EFO"]) filtered_df = df[idx] """ if method == "iterative" or method is None: return _get_series(df, column).map(_get_prefix_checker(prefixes)) elif method == "precalculated": if converter is None: # pragma: no cover raise ValueError("a converter is required for method B") prefix_series = _get_prefixes_from_curie_column( df, column=column, converter=converter, validate=validate ) if isinstance(prefixes, str): return prefix_series == prefixes else: return prefix_series.isin(prefixes) else: # pragma: no cover raise ValueError(f"invalid method given: {method}") def filter_df_by_prefixes( df: pd.DataFrame, *, column: str | int, prefixes: str | Collection[str], method: PrefixIndexMethod | None = None, validate: bool = False, converter: Converter | None = None, ) -> pd.DataFrame: """Filter a dataframe based on CURIEs in a given column having a given prefix or set of prefixes. :param df: A dataframe :param column: The integer index or column name of a column containing CURIEs :param prefixes: The prefix (given as a string) or collection of prefixes (given as a list, set, etc.) to keep :param method: The implementation for getting the prefix index :param validate: Should the prefixes be validated against the converter? :param converter: A converter for validating CURIEs :returns: If not in place, return a new dataframe. Example usage: .. code-block:: python import pandas as pd from curies.dataframe import filter_df_by_prefixes rows = [ ("DOID:0080795", "skos:exactMatch", "EFO:0003029", "semapv:ManualMappingCuration"), ("DOID:0080795", "skos:exactMatch", "mesh:D015471", "semapv:ManualMappingCuration"), ("DOID:0080799", "skos:exactMatch", "EFO:1000527", "semapv:ManualMappingCuration"), ( "DOID:0080808", "skos:exactMatch", "mesh:D000069295", "semapv:ManualMappingCuration", ), ] df = pd.DataFrame( rows, columns=["subject_id", "predicate_id", "object_id", "mapping_justification"] ) filtered_df = filter_df_by_prefixes(df, column="object_id", prefixes=["EFO"]) This results in the following dataframe: ============ =============== =========== ============================ subject_id predicate_id object_id mapping_justification ============ =============== =========== ============================ DOID:0080795 skos:exactMatch EFO:0003029 semapv:ManualMappingCuration DOID:0080799 skos:exactMatch EFO:1000527 semapv:ManualMappingCuration ============ =============== =========== ============================ Internally, this function uses :func:`get_filter_df_by_prefixes_index`. """ idx = get_filter_df_by_prefixes_index( df=df, column=column, prefixes=prefixes, method=method, converter=converter, validate=validate, ) return df[idx] def get_filter_df_by_curies_index( df: DataframeOrSeries, *, column: str | int | None = None, curies: str | Collection[str], ) -> pd.Series[bool]: """Get an index of CURIEs in the given column that are the given CURIE(s).""" series = _get_series(df, column) if isinstance(curies, str): return series == curies else: return series.isin(set(curies)) def get_df_curies_index( df: DataframeOrSeries, *, column: str | int | None = None ) -> dict[str, list[int]]: """Get a dictionary from CURIEs that appear in the column to the row indexes where they appear.""" dd: defaultdict[str, list[int]] = defaultdict(list) for i, curie in enumerate(_get_series(df, column)): dd[curie].append(i) return dict(dd) def filter_df_by_curies( df: pd.DataFrame, *, column: str | int, curies: str | Collection[str], ) -> pd.DataFrame: """Filter a dataframe based on CURIEs in a given column having a given prefix or set of prefixes. :param df: A dataframe :param column: The integer index or column name of a column containing CURIEs :param curies: The CURIE (given as a string) or collection of CURIEs (given as a list, set, etc.) to keep :returns: If not in place, return a new dataframe. Example usage: .. code-block:: python import pandas as pd from curies.dataframe import filter_df_by_curies rows = [ ("DOID:0080795", "skos:exactMatch", "EFO:0003029", "semapv:ManualMappingCuration"), ("DOID:0080795", "skos:exactMatch", "mesh:D015471", "semapv:ManualMappingCuration"), ("DOID:0080799", "skos:exactMatch", "EFO:1000527", "semapv:ManualMappingCuration"), ( "DOID:0080808", "skos:exactMatch", "mesh:D000069295", "semapv:ManualMappingCuration", ), ] df = pd.DataFrame( rows, columns=["subject_id", "predicate_id", "object_id", "mapping_justification"] ) filtered_df = filter_df_by_curies(df, column="subject_id", prefixes=["DOID:0080795"]) This results in the following dataframe: ============ =============== ============ ============================ subject_id predicate_id object_id mapping_justification ============ =============== ============ ============================ DOID:0080795 skos:exactMatch EFO:0003029 semapv:ManualMappingCuration DOID:0080795 skos:exactMatch mesh:D015471 semapv:ManualMappingCuration ============ =============== ============ ============================ """ idx = get_filter_df_by_curies_index(df=df, column=column, curies=curies) return df[idx] def get_df_prefixes_index( df: DataframeOrSeries, *, column: str | int | None = None, converter: Converter | None = None, validate: bool = False, ) -> dict[str, list[int]]: """Get a dictionary from prefixes that appear in the column to the row indexes where they appear.""" dd: defaultdict[str, list[int]] = defaultdict(list) f = _get_curie_parser(converter=converter, validate=validate) for i, prefix in enumerate(_get_series(df, column).map(f)): dd[prefix].append(i) return dict(dd) def get_df_unique_prefixes( df: DataframeOrSeries, *, column: str | int | None = None, validate: bool = False, converter: Converter | None = None, ) -> set[str]: """Get unique prefixes. :param df: A dataframe or series. If a dataframe is given, the ``column`` must not be none. :param column: The column to check, if a dataframe was passed. If a series was passed, this can be left as none. :param validate: Should the prefixes be validated against the converter? :param converter: A converter for validating CURIEs :returns: A set of prefixes appearing in CURIEs in the given column .. code-block:: python import pandas as pd from curies.dataframe import get_df_unique_prefixes rows = [ ("DOID:0080795", "skos:exactMatch", "EFO:0003029", "semapv:ManualMappingCuration"), ("DOID:0080795", "skos:exactMatch", "mesh:D015471", "semapv:ManualMappingCuration"), ("DOID:0080799", "skos:exactMatch", "EFO:1000527", "semapv:ManualMappingCuration"), ( "DOID:0080808", "skos:exactMatch", "mesh:D000069295", "semapv:ManualMappingCuration", ), ] df = pd.DataFrame( rows, columns=["subject_id", "predicate_id", "object_id", "mapping_justification"] ) assert get_df_unique_prefixes(df, column="object_id") == {"EFO", "mesh"} """ series = _get_series(df, column) f = _get_curie_parser(converter=converter, validate=validate) return set(series.map(f).unique()) def _disallowed_dtype(series: pd.Series[Any] | str) -> TypeGuard[pd.Series[str]]: import numpy as np import pandas if isinstance(series, str): return False # pandas 3.0 introduced a new datatype - this code implicitly # checks if we're using pandas 3.0 if pandas.StringDtype is available if (string_dtype := getattr(pandas, "StringDtype", None)) and isinstance( series.dtype, string_dtype ): return False return series.dtype != np.str_ and series.dtype != np.dtype("O") def _get_series(df_or_series: DataframeOrSeries, column: str | int | None = None) -> pd.Series[str]: import pandas as pd if isinstance(df_or_series, pd.Series): if _disallowed_dtype(df_or_series): raise TypeError( f"passed series that does not have strings: {df_or_series.dtype=} {type(df_or_series.dtype)=}\n\n{df_or_series}" ) return df_or_series # ty:ignore if column is None: raise ValueError("must pass non-none column when using a dataframe directly") series = df_or_series[column] if _disallowed_dtype(series): raise TypeError( f"passed series that does not have strings: {series.dtype=} {type(series.dtype)=}\n\n{series}" ) return cast("pd.Series[str]", series) biopragmatics-curies-aa8bd37/src/curies/discovery.py000066400000000000000000000260721525104130300227640ustar00rootroot00000000000000"""Discovery new entries for a Converter. The :func:`curies.discover` functionality is intended to be used in a "data science" workflow. Its goal is to enable a data scientist to semi-interactively explore data (e.g., coming from an ontology, SSSOM, RDF) that doesn't come with a complete (extended) prefix map and identify common URI prefixes. It returns the discovered URI prefixes in a :class:`curies.Converter` object with "dummy" CURIE prefixes. This makes it possible to convert the URIs appearing in the data into CURIEs and therefore enables their usage in places where CURIEs are expected. However, it's suggested that after discovering URI prefixes, the data scientist more carefully constructs a meaningful prefix map based on the discovered one. This might include some or all of the following steps: 1. Replace dummy CURIE prefixes with meaningful ones 2. Remove spurious URI prefixes that appear but do not represent a semantic space. This happens often due to using ``_`` as a delimiter or having a frequency cutoff of zero (see the parameters for this function). 3. Consider chaining a comprehensive extended prefix map such as the Bioregistry (from :func:`curies.get_bioregistry_converter`) with onto the converter passed to this function so pre-existing URI prefixes are not *re-discovered*. Finally, you should save the prefix map that you create in a persistent place (i.e., inside a JSON file) such that it can be reused. ########### Algorithm ########### The :func:`curies.discover` function implements the following algorithm that does the following for each URI: 1. For each delimiter (in the priority order they are given) check if the delimiter is present. 2. If it's present, split the URI into two parts based on rightmost appearance of the delimiter. 3. If the right part after splitting is all alphanumeric characters, save the URI prefix (with delimiter attached) 4. If a delimiter is successfully used to identify a URI prefix, don't check any of the following delimiters After identifying putative URI prefixes, the second part of the algorithm does the following: 1. If a cutoff was provided, remove all putative URI prefixes for which there were fewer examples than the cutoff 2. Sort the URI prefixes lexicographically (i.e., with :func:`sorted`) 3. Assign a dummy CURIE prefix to each URI prefix, counting upwards from 1 4. Construct a converter from this prefix map and return it """ from __future__ import annotations from collections import defaultdict from collections.abc import Iterable, Mapping, Sequence from pathlib import PurePath from typing import IO, TYPE_CHECKING, Any, Literal, TextIO from curies import Converter, Record if TYPE_CHECKING: import rdflib import rdflib.parser GraphInput = IO[bytes] | TextIO | rdflib.parser.InputSource | str | bytes | PurePath __all__ = [ "discover", "discover_from_rdf", ] GraphFormats = Literal["turtle", "xml", "n3", "nt", "trix"] def discover_from_rdf( graph: GraphInput | rdflib.Graph, *, format: GraphFormats | None = None, **kwargs: Any, ) -> Converter: """Discover new URI prefixes from RDF content via :mod:`rdflib`. This function works the same as :func:`discover`, but gets its URI list from a triple store. See :func:`discover` for a more detailed explanation of how this algorithm works. :param graph: Either a pre-instantiated RDFlib graph, or an input type to the ``source`` keyword of :meth:`rdflib.Graph.parse`. This can be one of the following: - A string or bytes representation of a URL - A string, bytes, or Path representation of a local file - An I/O object that can be read directly - An open XML reader from RDFlib (:class:`rdflib.parser.InputSource`) :param format: If ``graph`` is given as a URL or I/O object, this is passed through to the ``format`` keyword of :meth:`rdflib.Graph.parse`. If none is given, defaults to ``turtle``. :param kwargs: Keyword arguments passed through to :func:`discover` :returns: A converter with dummy prefixes for URI prefixes appearing in the RDF content (i.e., triples). """ uris = get_uris_from_rdf(graph=graph, format=format) return discover(uris, **kwargs) def get_uris_from_rdf( graph: GraphInput | rdflib.Graph, *, format: GraphFormats | None = None ) -> set[str]: """Get a set of URIs from a graph.""" graph = _ensure_graph(graph=graph, format=format) return set(_yield_uris(graph=graph)) def _ensure_graph( *, graph: GraphInput | rdflib.Graph, format: GraphFormats | None = None ) -> rdflib.Graph: import rdflib if not isinstance(graph, rdflib.Graph): _temp_graph = rdflib.Graph() _temp_graph.parse(source=graph, format=format) graph = _temp_graph return graph def _yield_uris(*, graph: rdflib.Graph) -> Iterable[str]: import rdflib for parts in graph.triples((None, None, None)): for part in parts: if isinstance(part, rdflib.URIRef): yield str(part) def discover( uris: Iterable[str], *, delimiters: Sequence[str] | None = None, cutoff: int | None = None, metaprefix: str = "ns", converter: Converter | None = None, ) -> Converter: """Discover new URI prefixes and construct a converter with a unique dummy CURIE prefix for each. :param uris: An iterable of URIs to search through. Will be taken as a set and each unique entry is only considered once. :param delimiters: The character(s) that delimit a URI prefix from a local unique identifier. If none given, defaults to using ``/``, ``#``, and ``_``. For example: - ``/`` is the delimiter in ``https://www.ncbi.nlm.nih.gov/pubmed/37929212``, which separates the URI prefix ``https://www.ncbi.nlm.nih.gov/pubmed/`` from the local unique identifier `37929212 `_ for the article "New insights into osmobiosis and chemobiosis in tardigrades" in PubMed. - ``#`` is the delimiter in ``http://www.w3.org/2000/01/rdf-schema#label``, which separates the URI prefix ``http://www.w3.org/2000/01/rdf-schema#`` from the local unique identifier `label `_ for the term "label" in the RDF Schema. The ``#`` typically is used in a URL to denote a fragment and commonly appears in small semantic web vocabularies that are shown as a single HTML page. - ``_`` is the delimiter in ``http://purl.obolibrary.org/obo/GO_0032571``, which separates the URI prefix ``http://purl.obolibrary.org/obo/GO_`` from the local unique identifier `0032571 `_ for the term "response to vitamin K" in the Gene Ontology .. note:: The delimiter is itself a part of the URI prefix :param cutoff: If given, will require more than ``cutoff`` unique local unique identifiers associated with a given URI prefix to keep it. Defaults to zero, which increases recall (i.e., likelihood of getting all possible URI prefixes) but decreases precision (i.e., more of the results might be false positives / spurious). If you get a lot of false positives, try increasing first to 1, 2, then maybe higher. :param metaprefix: The beginning part of each dummy prefix, followed by a number. The default value is ``ns``, so dummy prefixes are named ``ns1``, ``ns2``, and so on. :param converter: If a pre-existing converter is passed, then URIs that can be parsed using the pre-existing converter are not considered during discovery. For example, if you're an OBO person working with URIs coming from an OBO ontology, it makes sense to pass the converter from :func:`curies.get_obo_converter` to reduce false positive discoveries. More generally, a comprehensive converter like the Bioregistry (from :func:`curies.get_bioregistry_converter`) can massively reduce false positive discoveries and ultimately reduce burden on the data scientist using this function when needing to understand the results and carefully curate a prefix map based on the discoveries. :returns: A converter with dummy prefixes >>> import curies >>> uris = [f"http://ran.dom/{i:03}" for i in range(30)] >>> discovered_converter = curies.discover(uris) >>> discovered_converter.records [Record(prefix="ns1", uri_prefix="http://ran.dom/", prefix_synonyms=[], uri_prefix_synonyms=[], pattern=None)] >>> discovered_converter.compress("http://ran.dom/002") 'ns1:002' """ uri_prefix_to_luids = _get_uri_prefix_to_luids( converter=converter, uris=uris, delimiters=delimiters ) uri_prefixes = [ uri_prefix for uri_prefix, luids in sorted(uri_prefix_to_luids.items()) # If the cutoff is 5, and only 3 unique LUIDs with the URI prefix # were identified, we're going to disregard this URI prefix. if cutoff is None or len(luids) >= cutoff ] records = [ Record(prefix=f"{metaprefix}{uri_prefix_index}", uri_prefix=uri_prefix) for uri_prefix_index, uri_prefix in enumerate(uri_prefixes, start=1) ] return Converter(records) #: The default delimiters used when guessing URI prefixes DEFAULT_DELIMITERS = ("#", "/", "_") def _get_uri_prefix_to_luids( *, converter: Converter | None = None, uris: Iterable[str], delimiters: Sequence[str] | None = None, ) -> Mapping[str, set[str]]: """Get a mapping from putative URI prefixes to corresponding putative local unique identifiers. :param converter: A converter with pre-existing definitions. URI prefixes are considered "new" if they can't already be validated by this converter :param uris: An iterable of URIs to search through. Will be taken as a set and each unique entry is only considered once. :param delimiters: The delimiters considered between a putative URI prefix and putative local unique identifier. By default, checks ``#`` first since this is commonly used for URL fragments, then ``/`` since many URIs are constructed with these. :returns: A dictionary of putative URI prefixes to sets of putative local unique identifiers """ if not delimiters: delimiters = DEFAULT_DELIMITERS uri_prefix_to_luids = defaultdict(set) for uri in uris: if converter is not None and converter.is_uri(uri): continue if uri.startswith("https://github.com") and "issues" in uri: # TODO it's not really the job of :mod:`curies` to incorporate special cases, # but the GitHub thing is such an annoyance... continue for delimiter in delimiters: if delimiter not in uri: continue uri_prefix, luid = uri.rsplit(delimiter, maxsplit=1) if luid.isalnum(): uri_prefix_to_luids[uri_prefix + delimiter].add(luid) break return dict(uri_prefix_to_luids) biopragmatics-curies-aa8bd37/src/curies/mapping_service/000077500000000000000000000000001525104130300235475ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/src/curies/mapping_service/__init__.py000066400000000000000000000105071525104130300256630ustar00rootroot00000000000000"""Identifier mappings service. This contains an implementation of the service described in `SPARQL-enabled identifier conversion with Identifiers.org `_. The idea here is that you can write a SPARQL query like the following: .. code-block:: sparql PREFIX biomodel: PREFIX bqbio: PREFIX sbmlrdf: PREFIX up: SELECT DISTINCT ?protein ?protein_domain WHERE { # The first part of this query extracts the proteins appearing in an RDF serialization # of the BioModels database (see https://www.ebi.ac.uk/biomodels/BIOMD0000000372) on # insulin/glucose feedback. Note that modelers call entities appearing in compartmental # models "species", and this does not refer to taxa. biomodel:BIOMD0000000372 sbmlrdf:species/bqbio:isVersionOf ?biomodels_protein . # The second part of this query maps BioModels protein IRIs to UniProt protein IRIs # using service XXX - that's what we're implementing here. SERVICE { ?biomodels_protein owl:sameAs ?uniprot_protein. } # The third part of this query gets links between UniProt proteins and their # domains. Since the service maps between the BioModels query, this only gets # us relevant protein domains to the insulin/glucose model. SERVICE { ?uniprot_protein a up:Protein; up:organism taxon:9606; rdfs:seeAlso ?protein_domain. } } The SPARQL endpoint running at the web address XXX takes in the bound values for `?biomodels_protein` one at a time and dynamically generates triples with `owl:sameAs` as the predicate mapping and other equivalent IRIs (based on the definition of the converter) as the objects. This allows for gluing together multiple services that use different URIs for the same entities - in this example, there are two ways of referring to UniProt Proteins: 1. The BioModels database example represents a SBML model on insulin-glucose feedback and uses legacy Identifiers.org URIs for proteins such as http://identifiers.org/uniprot/P01308. 2. The first-part UniProt database uses its own PURLs such as https://purl.uniprot.org/uniprot/P01308. .. seealso:: - Jerven Bolleman's implementation of this service in Java: https://github.com/JervenBolleman/sparql-identifiers - Vincent Emonet's `SPARQL endpoint for RDFLib generator `_ The following is an end-to-end example of using this function to create a small URI mapping application. .. code-block:: python # flask_example.py from flask import Flask from curies import Converter, get_bioregistry_converter from curies.mapping_service import get_flask_mapping_app # Create a converter converter: Converter = get_bioregistry_converter() # Create the Flask app from the converter app: Flask = get_flask_mapping_app(converter) if __name__ == "__main__": app.run() In the command line, either run your Python file directly, or via with :mod:`gunicorn`: .. code-block:: console $ pip install gunicorn $ gunicorn --bind 0.0.0.0:8764 flask_example:app Test a request in the Python REPL. .. code-block:: python import requests sparql = "SELECT ?s ?o WHERE { VALUES ?s { } ?s owl:sameAs ?o }" res = requests.get("http://localhost:8764/sparql", params={"query": sparql}) Test a request using a service, e.g. with :meth:`rdflib.Graph.query` .. code-block:: sparql SELECT ?s ?o WHERE { VALUES ?s { } SERVICE { ?s owl:sameAs ?child_mapped . } } """ from .api import ( MappingServiceGraph, get_fastapi_mapping_app, get_fastapi_router, get_flask_mapping_app, get_flask_mapping_blueprint, ) from .rdflib_custom import MappingServiceSPARQLProcessor __all__ = [ "MappingServiceGraph", "MappingServiceSPARQLProcessor", "get_fastapi_mapping_app", "get_fastapi_router", "get_flask_mapping_app", "get_flask_mapping_blueprint", ] biopragmatics-curies-aa8bd37/src/curies/mapping_service/api.py000066400000000000000000000201521525104130300246720ustar00rootroot00000000000000"""Implementation of mapping service.""" from __future__ import annotations import itertools as itt from collections.abc import Collection, Iterable from typing import TYPE_CHECKING, Any from rdflib import OWL, Graph, URIRef from rdflib.term import _is_valid_uri from .rdflib_custom import MappingServiceSPARQLProcessor from .utils import CONTENT_TYPE_TO_RDFLIB_FORMAT, handle_header from ..api import Converter if TYPE_CHECKING: import fastapi import flask def _prepare_predicates(predicates: str | Collection[str] | None = None) -> set[URIRef]: if predicates is None: return {OWL.sameAs} if isinstance(predicates, str): return {URIRef(predicates)} return {URIRef(predicate) for predicate in predicates} class MappingServiceGraph(Graph): """A service that implements identifier mapping based on a converter.""" converter: Converter query_predicates: set[URIRef] def __init__( self, *args: Any, converter: Converter, predicates: str | list[str] | None = None, **kwargs: Any, ) -> None: """Instantiate the graph. :param args: Positional arguments to pass to :meth:`rdflib.Graph.__init__` :param converter: A converter object :param predicates: A predicate or set of predicates. If not given, this service will use `owl:sameAs` as a predicate for mapping IRIs. :param kwargs: Keyword arguments to pass to :meth:`rdflib.Graph.__init__` In the following example, a service graph is instantiated using a small example converter, then an example SPARQL query is made directly to show how it makes results: .. code-block:: python from curies import Converter from curies.mapping_service import CURIEServiceGraph converter = Converter.from_priority_prefix_map( { "CHEBI": [ "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=", "http://identifiers.org/chebi/", "http://purl.obolibrary.org/obo/CHEBI_", ], "GO": ["http://purl.obolibrary.org/obo/GO_"], "OBO": ["http://purl.obolibrary.org/obo/"], # ... } ) graph = MappingServiceGraph(converter=converter) sparql = ( "SELECT ?o WHERE {" " VALUES ?s {" " " " }" " ?s owl:sameAs ?o" "}" ) res = graph.query(sparql) The results of this are: ====================================== ================================================= subject object http://purl.obolibrary.org/obo/CHEBI_1 http://purl.obolibrary.org/obo/CHEBI_1 http://purl.obolibrary.org/obo/CHEBI_1 http://identifiers.org/chebi/1 http://purl.obolibrary.org/obo/CHEBI_1 https://www.ebi.ac.uk/chebi/searchId.do?chebiId=1 ====================================== ================================================= """ self.converter = converter self.query_predicates = _prepare_predicates(predicates) super().__init__(*args, **kwargs) def _expand_pair_all(self, uri_in: str) -> list[URIRef]: reference = self.converter.parse_uri(uri_in) if reference is None: return [] uris = self.converter.expand_pair_all(reference.prefix, reference.identifier, strict=True) # do _is_valid_uri check because some configurations e.g. from Bioregistry might # produce invalid URIs e.g., containing spaces return [URIRef(uri) for uri in uris if _is_valid_uri(uri)] def triples( # type:ignore self, triple: tuple[URIRef | None, URIRef | None, URIRef | None] ) -> Iterable[tuple[URIRef, URIRef, URIRef]]: """Generate triples, overriden to dynamically generate mappings based on this graph's converter.""" subj_query, pred_query, obj_query = triple if pred_query in self.query_predicates: if subj_query is None and obj_query is not None: subjects = self._expand_pair_all(obj_query) for subj, pred in itt.product(subjects, self.query_predicates): yield subj, pred, obj_query elif subj_query is not None: objects = self._expand_pair_all(subj_query) if obj_query is None: for obj, pred in itt.product(objects, self.query_predicates): yield subj_query, pred, obj elif obj_query in objects: yield subj_query, pred_query, obj_query def get_flask_mapping_blueprint( converter: Converter, route: str = "/sparql", **kwargs: Any ) -> flask.Blueprint: """Get a blueprint for :class:`flask.Flask`. :param converter: A converter :param route: The route of the SPARQL service (relative to the base of the Blueprint) :param kwargs: Keyword arguments passed through to :class:`flask.Blueprint` :returns: A blueprint """ from flask import Blueprint, Response, request blueprint = Blueprint("mapping", __name__, **kwargs) graph = MappingServiceGraph(converter=converter) processor = MappingServiceSPARQLProcessor(graph=graph) # type:ignore[no-untyped-call] @blueprint.route(route, methods=["GET", "POST"]) def serve_sparql() -> Response: """Run a SPARQL query and serve the results.""" sparql = request.values.get("query") if not sparql: return Response( "Missing query (either in args for GET requests, or in form for POST requests)", 400 ) content_type = handle_header(request.headers.get("accept")) results = graph.query(sparql, processor=processor) response = results.serialize(format=CONTENT_TYPE_TO_RDFLIB_FORMAT[content_type]) return Response(response, content_type=content_type) return blueprint def get_fastapi_router( converter: Converter, route: str = "/sparql", **kwargs: Any ) -> fastapi.APIRouter: """Get a router for :class:`fastapi.FastAPI`. :param converter: A converter :param route: The route of the SPARQL service (relative to the base of the API router) :param kwargs: Keyword arguments passed through to :class:`fastapi.APIRouter` :returns: A router """ from fastapi import APIRouter, Form, Header, Query, Response api_router = APIRouter(**kwargs) graph = MappingServiceGraph(converter=converter) processor = MappingServiceSPARQLProcessor(graph=graph) # type:ignore[no-untyped-call] def _resolve(accept: str, sparql: str) -> Response: content_type = handle_header(accept) results = graph.query(sparql, processor=processor) response = results.serialize(format=CONTENT_TYPE_TO_RDFLIB_FORMAT[content_type]) return Response(response, media_type=content_type) @api_router.get(route) def resolve_get( query: str = Query(description="The SPARQL query to run"), accept: str = Header(), ) -> Response: """Run a SPARQL query and serve the results.""" return _resolve(accept, query) @api_router.post(route) def resolve_post( query: str = Form(description="The SPARQL query to run"), accept: str = Header(), ) -> Response: """Run a SPARQL query and serve the results.""" return _resolve(accept, query) return api_router def get_flask_mapping_app(converter: Converter) -> flask.Flask: """Get a Flask app for the mapping service.""" from flask import Flask blueprint = get_flask_mapping_blueprint(converter) app = Flask(__name__) app.register_blueprint(blueprint) return app def get_fastapi_mapping_app(converter: Converter) -> fastapi.FastAPI: """Get a FastAPI app. :param converter: A converter :returns: A FastAPI app """ from fastapi import FastAPI router = get_fastapi_router(converter) app = FastAPI() app.include_router(router) return app biopragmatics-curies-aa8bd37/src/curies/mapping_service/rdflib_custom.py000066400000000000000000000070351525104130300267620ustar00rootroot00000000000000"""A custom SPARQL processor that optimizes the query based on https://github.com/RDFLib/rdflib/pull/2257.""" from __future__ import annotations from collections.abc import Mapping from typing import Any from rdflib.plugins.sparql.algebra import translateQuery from rdflib.plugins.sparql.evaluate import evalQuery from rdflib.plugins.sparql.parser import parseQuery from rdflib.plugins.sparql.parserutils import CompValue from rdflib.plugins.sparql.processor import SPARQLProcessor from rdflib.plugins.sparql.sparql import Query from rdflib.term import Identifier __all__ = ["MappingServiceSPARQLProcessor"] class MappingServiceSPARQLProcessor(SPARQLProcessor): """A custom SPARQL processor that optimizes the query based on https://github.com/RDFLib/rdflib/pull/2257. Why is this necessary? Ideally, we get queries like .. code-block:: sparql SELECT * WHERE { VALUES ?s { :a :b ... } ?s owl:sameAs ?o } This is fine, since the way that RDFLib parses and constructs an abstract syntax tree, the values for ``?s`` get bound properly when calling a custom :func:`rdflib.Graph.triples`. However, it's also valid SPARQL to have the ``VALUES`` clause outside of the ``WHERE`` clause like .. code-block:: sparql SELECT * WHERE { ?s owl:sameAs ?o } VALUES ?s { :a :b ... } Unfortunately, this trips up RDFLib since it doesn't know to bind the values before calling ``triples()``, therefore thwarting our custom implementation that dynamically generates triples based on the bound values themselves. This processor, originally by Jerven Bolleman in https://github.com/RDFLib/rdflib/pull/2257, adds some additional logic between parsing + constructing the abstract syntax tree and evaluation of the syntax tree. Basically, the abstract syntax tree has nodes with two or more children. Jerven's clever code (see :func:`_optimize_node` below) finds *Join* nodes that have a ``VALUES`` clause in the second of its two arguments, then flips them around. It does this recursively for the whole tree. This gets us to the goal of having the ``VALUES`` clauses appear first, therefore making sure that their bound values are available to the ``triples`` function. """ def query( # type:ignore[override] self, strOrQuery: str | Query, # noqa:N803 initBindings: Mapping[str, Identifier] | None = None, # noqa:N803 initNs: Mapping[str, Any] | None = None, # noqa:N803 base: str | None = None, DEBUG: bool = False, # noqa:N803 ) -> Mapping[Any, Any]: """Evaluate a SPARQL query on this processor's graph.""" if isinstance(strOrQuery, str): parse_tree = parseQuery(strOrQuery) str_or_qury = translateQuery(parse_tree, base, initNs) return self.query(str_or_qury, initBindings=initBindings, base=base) strOrQuery.algebra = _optimize_node(strOrQuery.algebra) return evalQuery(self.graph, strOrQuery, initBindings or {}, base) # From Jerven's PR to RDFLib (https://github.com/RDFLib/rdflib/pull/2257) def _optimize_node(comp_value: CompValue) -> CompValue: if ( comp_value.name == "Join" and comp_value.p1.name != "ToMultiSet" and comp_value.p2.name == "ToMultiSet" ): comp_value.update(p1=comp_value.p2, p2=comp_value.p1) for inner_comp_value in comp_value.values(): if isinstance(inner_comp_value, CompValue): _optimize_node(inner_comp_value) return comp_value biopragmatics-curies-aa8bd37/src/curies/mapping_service/utils.py000066400000000000000000000115331525104130300252640ustar00rootroot00000000000000"""Utilities for the mapping service.""" from __future__ import annotations import csv import json import json.decoder import unittest from collections.abc import Callable, Iterable, Mapping from xml.etree.ElementTree import Element from defusedxml import ElementTree __all__ = [ "CONTENT_TYPE_TO_HANDLER", "get_sparql_records", "handle_csv", "handle_header", "handle_json", "handle_xml", "parse_header", "sparql_service_available", ] Record = Mapping[str, str] Records = list[Record] #: A SPARQL query used to ping a SPARQL endpoint PING_SPARQL = 'SELECT ?s ?o WHERE { BIND("hello" as ?s) . BIND("there" as ?o) . }' #: This is default for federated queries DEFAULT_CONTENT_TYPE = "application/sparql-results+xml" #: A mapping from content types to the keys used for serializing #: in :meth:`rdflib.Graph.serialize` and other serialization functions CONTENT_TYPE_TO_RDFLIB_FORMAT = { # https://www.w3.org/TR/sparql11-results-json/ "application/sparql-results+json": "json", # https://www.w3.org/TR/rdf-sparql-XMLres/ "application/sparql-results+xml": "xml", # https://www.w3.org/TR/sparql11-results-csv-tsv/ "application/sparql-results+csv": "csv", } #: A dictionary that maps synonym content types to the canonical ones CONTENT_TYPE_SYNONYMS = { "application/json": "application/sparql-results+json", "text/json": "application/sparql-results+json", "application/xml": "application/sparql-results+xml", "text/xml": "application/sparql-results+xml", "text/csv": "application/sparql-results+csv", } def handle_json(text: str) -> Records: """Parse bindings encoded in a JSON string.""" data = json.loads(text) return [ {key: value["value"] for key, value in record.items()} for record in data["results"]["bindings"] ] def handle_xml(text: str) -> Records: """Parse bindings encoded in an XML string.""" root = ElementTree.fromstring(text) results = root.find("{http://www.w3.org/2005/sparql-results#}results") if results is None: raise ValueError return [_handle_result(result) for result in results] def _handle_result(result: Iterable[Element]) -> Record: return { binding.attrib["name"]: value for binding in result if (value := binding.findtext("{http://www.w3.org/2005/sparql-results#}uri")) } def handle_csv(text: str) -> Records: """Parse bindings encoded in a CSV string.""" return list(csv.DictReader(text.splitlines())) #: A mapping from canonical content types to functions for parsing them CONTENT_TYPE_TO_HANDLER: Mapping[str, Callable[[str], Records]] = { "application/sparql-results+json": handle_json, "application/sparql-results+xml": handle_xml, "application/sparql-results+csv": handle_csv, } def get_sparql_records(endpoint: str, sparql: str, accept: str) -> Records: """Get a response from a given SPARQL query.""" import requests res = requests.get( endpoint, timeout=60, params={"query": sparql}, headers={"accept": accept}, ) res.raise_for_status() func = CONTENT_TYPE_TO_HANDLER[handle_header(accept)] return func(res.text) def get_sparql_record_so_tuples(records: Records) -> set[tuple[str, str]]: """Get subject/object pairs from records.""" return {(record["s"], record["o"]) for record in records} def sparql_service_available(endpoint: str) -> bool: """Test if a SPARQL service is running.""" try: records = get_sparql_records(endpoint, PING_SPARQL, "application/json") except (OSError, json.decoder.JSONDecodeError): return False return {("hello", "there")} == get_sparql_record_so_tuples(records) def _handle_part(part: str) -> tuple[str, float]: if ";q=" not in part: return part, 1.0 key, q = part.split(";q=", 1) return key, float(q) def parse_header(header: str) -> list[str]: """Parse the header and sort in descending order of q value.""" parts = dict(_handle_part(part) for part in header.split(",")) return sorted(parts, key=parts.__getitem__, reverse=True) def handle_header(header: str | None, default: str = DEFAULT_CONTENT_TYPE) -> str: """Canonicalize a header.""" if not header: return default for header_part in parse_header(header): header_part = CONTENT_TYPE_SYNONYMS.get(header_part, header_part) if header_part in CONTENT_TYPE_TO_RDFLIB_FORMAT: return header_part # What happens if encountering "*/*" that has a higher q than something else? # Is that even possible/coherent? return default def require_service( url: str, name: str ) -> Callable[[type[unittest.TestCase]], type[unittest.TestCase]]: """Skip a test unless the service is available.""" return unittest.skipUnless( sparql_service_available(url), reason=f"No {name} service is running on {url}" ) biopragmatics-curies-aa8bd37/src/curies/mixins.py000066400000000000000000000206671525104130300222700ustar00rootroot00000000000000"""Mixin classes.""" from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Iterable from typing import Generic, Literal, Self, TypeVar, overload from .api import Converter __all__ = [ "SemanticallyProcessable", "SemanticallyStandardizable", "process", "process_many", "standardize", "standardize_many", ] X = TypeVar("X") class SemanticallyProcessable(ABC, Generic[X]): """A class that can be processed with a converter. The goal of this class is to standardize objects that come with unprocessed URIs that can be processed into references with respect to a :class:`curies.Converter`. For example, this is useful for :mod:`obographs` and :mod:`jskos`. .. code-block:: python from pydantic import BaseModel from curies import SemanticallyProcessable class ProcessedEntity(BaseModel): reference: Reference class RawEntity(BaseModel, SemanticallyProcessable[ProcessedEntity]): uri: str def process(self, converter: Converter) -> ProcessedEntity: return ProcessedEntity( reference=converter.parse_uri(self.uri, strict=True).to_pydantic() ) :mod:`curies` provides a high-level interface for standardizing classes in :func:`curies.process`. .. code-block:: python from curies import Converter converter = Converter.from_prefix_map({"CHEBI": "http://purl.obolibrary.org/obo/CHEBI_"}) e1 = RawEntity(uri="http://purl.obolibrary.org/obo/CHEBI_1") e2 = RawEntity(uri="http://purl.obolibrary.org/obo/CHEBI_2") # can be used directly on an object assert ProcessedEntity(reference=Reference.from_curie("CHEBI:1")) == curies.standardize( e1, converter ) # can also be used on an iterable/collection assert [ ProcessedEntity(reference=Reference.from_curie("CHEBI:1")), ProcessedEntity(reference=Reference.from_curie("CHEBI:2")), ] == curies.process((e1, e2), converter) """ @abstractmethod def process(self, converter: Converter) -> X: """Process this raw instance.""" raise NotImplementedError # docstr-coverage:excused `overload` @overload def process(instances: None, converter: Converter, *, return_iterator: bool = ...) -> None: ... # docstr-coverage:excused `overload` @overload def process( instances: SemanticallyProcessable[X], converter: Converter, *, return_iterator: bool = ... ) -> X: ... # docstr-coverage:excused `overload` @overload def process( instances: Iterable[SemanticallyProcessable[X]], converter: Converter, *, return_iterator: Literal[False] = ..., ) -> list[X]: ... # docstr-coverage:excused `overload` @overload def process( instances: Iterable[SemanticallyProcessable[X]], converter: Converter, *, return_iterator: Literal[True] = ..., ) -> Iterable[X]: ... def process( instances: SemanticallyProcessable[X] | Iterable[SemanticallyProcessable[X]] | None, converter: Converter, *, return_iterator: bool = False, ) -> X | list[X] | Iterable[X] | None: """Process multiple semantically processable instances.""" if instances is None: return None elif isinstance(instances, Iterable): if return_iterator: return (instance.process(converter) for instance in instances) else: return [instance.process(converter) for instance in instances] else: return instances.process(converter) process_many = process class SemanticallyStandardizable(ABC): """An object that can be standardized. In the following example, a simple object is constructed: .. code-block:: python from typing import Self from curies import Converter, Reference, SemanticallyStandardizable class ReferenceHolder(SemanticallyStandardizable): def __init__(self, reference): self.reference = reference def standardize(self, converter: Converter) -> Self: return ReferenceHolder(converter.standardize_reference(self.reference, strict=True)) It's good form to make these operations return new objects, but there's no reason you couldn't update the object in place like in : .. code-block:: python from typing import Self from curies import Converter, Reference, SemanticallyStandardizable class ReferenceHolder(SemanticallyStandardizable): def __init__(self, reference): self.reference = reference def standardize(self, converter: Converter) -> Self: self.reference = converter.standardize_reference(self.reference, strict=True) return self In the following example, the :meth:`pydantic.BaseModel.model_copy` is used to automatically reuse all other fields that aren't updated, which creates a new object. .. code-block:: python import datetime from typing import Self from curies import Converter, Reference, SemanticallyStandardizable from pydantic import BaseModel class Triple(BaseModel, SemanticallyStandardizable): subject: Reference predicate: Reference object: Reference date_asserted: datetime.date def standardize(self, converter: Converter) -> Self: return self.model_copy( update={ "subject": converter.standardize_reference(self.subject, strict=True), "predicate": converter.standardize_reference(self.predicate, strict=True), "object": converter.standardize_reference(self.object, strict=True), } ) :mod:`curies` provides a high-level interface for standardizing classes in :func:`curies.standardize`. .. code-block:: python from curies import Converter converter = Converter() converter.add_prefix("CHEBI", "http://purl.obolibrary.org/obo/CHEBI_") converter.add_synonym("CHEBI", "chebi") r1 = ReferenceHolder(Reference.from_curie("chebi:1")) r2 = ReferenceHolder(Reference.from_curie("chebi:2")) # can be used directly on an object assert ReferenceHolder(Reference.from_curie("CHEBI:1")) == curies.standardize(r1, converter) # can also be used on an iterable/collection assert [ ReferenceHolder(Reference.from_curie("CHEBI:1")), ReferenceHolder(Reference.from_curie("CHEBI:2")), ] == curies.standardize((r1, r2), converter) """ @abstractmethod def standardize(self, converter: Converter) -> Self: """Standardize all references in the object.""" raise NotImplementedError SemanticallyStandardizableType = TypeVar( "SemanticallyStandardizableType", bound=SemanticallyStandardizable ) # docstr-coverage:excused `overload` @overload def standardize(instances: None, converter: Converter, *, return_iterator: bool = ...) -> None: ... # docstr-coverage:excused `overload` @overload def standardize( instances: SemanticallyStandardizableType, converter: Converter, *, return_iterator: bool = ... ) -> SemanticallyStandardizableType: ... # docstr-coverage:excused `overload` @overload def standardize( instances: Iterable[SemanticallyStandardizableType], converter: Converter, *, return_iterator: Literal[True] = ..., ) -> Iterable[SemanticallyStandardizableType]: ... # docstr-coverage:excused `overload` @overload def standardize( instances: Iterable[SemanticallyStandardizableType], converter: Converter, *, return_iterator: Literal[False] = ..., ) -> list[SemanticallyStandardizableType]: ... def standardize( instances: SemanticallyStandardizableType | Iterable[SemanticallyStandardizableType] | None, converter: Converter, *, return_iterator: bool = False, ) -> ( SemanticallyStandardizableType | Iterable[SemanticallyStandardizableType] | list[SemanticallyStandardizableType] | None ): """Standardize an instance.""" if instances is None: return None elif isinstance(instances, Iterable): if return_iterator: return (instance.standardize(converter) for instance in instances) else: return [instance.standardize(converter) for instance in instances] else: return instances.standardize(converter) standardize_many = standardize biopragmatics-curies-aa8bd37/src/curies/preprocessing.py000066400000000000000000000341641525104130300236410ustar00rootroot00000000000000"""Reusable configuration.""" from __future__ import annotations import json from collections.abc import Callable from pathlib import Path from typing import Any, Literal, Self, TypeAlias, TypeVar, overload from pydantic import BaseModel, Field from .api import Converter, Reference, ReferenceTuple, URIType __all__ = [ "BlockAction", "BlocklistError", "PostprocessingRewrites", "PreprocessingBlocklists", "PreprocessingConverter", "PreprocessingRewrites", "PreprocessingRules", ] #: The action taken when the blocklist is invoked BlockAction: TypeAlias = Literal["raise", "pass"] X = TypeVar("X", bound=Reference) class PreprocessingBlocklists(BaseModel): """A model for prefix and full blocklists.""" full: list[str] = Field(default_factory=list) resource_full: dict[str, list[str]] = Field(default_factory=dict) prefix: list[str] = Field(default_factory=list) resource_prefix: dict[str, list[str]] = Field(default_factory=dict) suffix: list[str] = Field(default_factory=list) def _sort(self) -> None: self.full.sort() self.prefix.sort() self.suffix.sort() for v in self.resource_full.values(): v.sort() for v in self.resource_prefix.values(): v.sort() def str_has_blocked_prefix( self, str_or_curie_or_uri: str, *, context: str | None = None ) -> bool: """Check if the CURIE string has a blocklisted prefix.""" if context: prefixes: list[str] = self.resource_prefix.get(context, []) if prefixes and any(str_or_curie_or_uri.startswith(prefix) for prefix in prefixes): return True return any(str_or_curie_or_uri.startswith(prefix) for prefix in self.prefix) def str_has_blocked_suffix(self, str_or_curie_or_uri: str) -> bool: """Check if the CURIE string has a blocklisted suffix.""" return any(str_or_curie_or_uri.endswith(suffix) for suffix in self.suffix) def str_is_blocked_full(self, str_or_curie_or_uri: str, *, context: str | None = None) -> bool: """Check if the full CURIE string is blocklisted.""" if context and str_or_curie_or_uri in self.resource_full.get(context, set()): return True return str_or_curie_or_uri in self.full def str_is_blocked(self, str_or_curie_or_uri: str, *, context: str | None = None) -> bool: """Check if the full CURIE string is blocklisted.""" return ( self.str_has_blocked_prefix(str_or_curie_or_uri, context=context) or self.str_has_blocked_suffix(str_or_curie_or_uri) or self.str_is_blocked_full(str_or_curie_or_uri, context=context) ) class PostprocessingRewrites(BaseModel): """A model for post-processing based on the prefix parsed.""" suffix: dict[str, list[str]] = Field(default_factory=dict) class PreprocessingRewrites(BaseModel): """A model for prefix and full rewrites.""" full: dict[str, str] = Field( default_factory=dict, description="Global remappings for an entire string" ) resource_full: dict[str, dict[str, str]] = Field( default_factory=dict, description="Resource-keyed remappings for an entire string" ) prefix: dict[str, str] = Field( default_factory=dict, description="Global remappings of just the prefix" ) resource_prefix: dict[str, dict[str, str]] = Field( default_factory=dict, description="Resource-keyed remappings for just a prefix" ) def remap_full( self, str_or_curie_or_uri: str, reference_cls: type[X], *, context: str | None = None, ) -> X | None: """Remap the string if possible otherwise return it.""" if context: resource_rewrites: dict[str, str] = self.resource_full.get(context, {}) if resource_rewrites and str_or_curie_or_uri in resource_rewrites: return reference_cls.from_curie(resource_rewrites[str_or_curie_or_uri]) if str_or_curie_or_uri in self.full: return reference_cls.from_curie(self.full[str_or_curie_or_uri]) return None def remap_prefix(self, str_or_curie_or_uri: str, *, context: str | None = None) -> str: """Remap a prefix.""" if context is not None: for old_prefix, new_prefix in self.resource_prefix.get(context, {}).items(): if str_or_curie_or_uri.startswith(old_prefix): return new_prefix + str_or_curie_or_uri[len(old_prefix) :] for old_prefix, new_prefix in self.prefix.items(): if str_or_curie_or_uri.startswith(old_prefix): return new_prefix + str_or_curie_or_uri[len(old_prefix) :] return str_or_curie_or_uri class PreprocessingRules(BaseModel): """A model for blocklists and rewrites.""" blocklists: PreprocessingBlocklists = Field(default_factory=PreprocessingBlocklists) rewrites: PreprocessingRewrites = Field(default_factory=PreprocessingRewrites) postprocessing: PostprocessingRewrites = Field(default_factory=PostprocessingRewrites) @classmethod def lint_file(cls, path: str | Path) -> None: """Lint a file, in place, given a file path.""" path = Path(path).expanduser().resolve() rules = cls.model_validate_json(path.read_text()) rules.blocklists._sort() path.write_text( json.dumps( rules.model_dump(exclude_unset=True, exclude_defaults=True), sort_keys=True, indent=2, ) ) def str_is_blocked(self, str_or_curie_or_uri: str, *, context: str | None = None) -> bool: """Check if the CURIE string is blocked.""" return self.blocklists.str_is_blocked(str_or_curie_or_uri, context=context) def remap_full( self, str_or_curie_or_uri: str, reference_cls: type[X], *, context: str | None = None, ) -> X | None: """Remap the string if possible otherwise return it.""" return self.rewrites.remap_full( str_or_curie_or_uri, reference_cls=reference_cls, context=context ) def remap_prefix(self, str_or_curie_or_uri: str, *, context: str | None = None) -> str: """Remap a prefix.""" return self.rewrites.remap_prefix(str_or_curie_or_uri, context=context) def _load_rules(rules: str | Path | PreprocessingRules) -> PreprocessingRules: # TODO load remote? if isinstance(rules, (str, Path)): rules = Path(rules).expanduser().resolve() rules = PreprocessingRules.model_validate_json(rules.read_text()) return rules class BlocklistError(ValueError): """An error for block list.""" def _identity(uri: URIType) -> str: return str(uri) class PreprocessingConverter(Converter): """A converter with pre-processing rules.""" def __init__( self, *args: Any, rules: PreprocessingRules | str | Path, reference_cls: type[X] | None = None, preclean: Callable[[URIType], str] | None = None, **kwargs: Any, ) -> None: """Instantiate a converter with a ruleset for pre-processing. :param args: Positional arguments passed to :meth:`curies.Converter.__init__` :param rules: A set of rules :param reference_cls: The reference class to use. Defaults to :class:`curies.Reference`. :param preclean: An optional function used to preprocess strings, CURIEs, and URIs before parsing :param kwargs: Keyword arguments passed to :meth:`curies.Converter.__init__` """ super().__init__(*args, **kwargs) self.rules = _load_rules(rules) self._reference_cls = Reference if reference_cls is None else reference_cls self._preclean = preclean if preclean is not None else _identity @classmethod def from_converter(cls, converter: Converter, rules: PreprocessingRules | str | Path) -> Self: """Wrap a converter with a ruleset. :param converter: A pre-instantiated converter :param rules: A pre-processing rules object, or path to a JSON file containing a pre-processing configuration :returns: A converter that uses the ruls for pre-processing when parsing URIs and CURIEs. """ return cls(records=converter.records, rules=rules) def _post_process(self, rt: ReferenceTuple | None) -> ReferenceTuple | None: if rt is None: return None if rt.prefix in self.rules.postprocessing.suffix: for s in self.rules.postprocessing.suffix[rt.prefix]: if rt.identifier.endswith(s): return ReferenceTuple( prefix=rt.prefix, identifier=rt.identifier.removesuffix(s) ) return rt # docstr-coverage:excused `overload` @overload def parse( self, str_or_uri_or_curie: str | URIType, *, strict: Literal[True] = True, context: str | None = ..., block_action: BlockAction = ..., ) -> ReferenceTuple: ... # docstr-coverage:excused `overload` @overload def parse( self, str_or_uri_or_curie: str | URIType, *, strict: Literal[False] = False, context: str | None = ..., block_action: BlockAction = ..., ) -> ReferenceTuple | None: ... def parse( self, str_or_uri_or_curie: str | URIType, *, strict: bool = False, context: str | None = None, block_action: BlockAction = "raise", ) -> ReferenceTuple | None: """Parse a string, CURIE, or URI.""" str_or_uri_or_curie = self._preclean(str_or_uri_or_curie) if r1 := self.rules.remap_full( str_or_uri_or_curie, reference_cls=self._reference_cls, context=context ): return r1.pair # Remap node's prefix (if necessary) str_or_uri_or_curie = self.rules.remap_prefix(str_or_uri_or_curie, context=context) if self.rules.str_is_blocked(str_or_uri_or_curie, context=context): if block_action == "raise": raise BlocklistError else: return None rv = super().parse(str_or_uri_or_curie, strict=strict) # type:ignore[call-overload] return self._post_process(rv) # docstr-coverage:excused `overload` @overload def parse_curie( self, curie: str, *, strict: Literal[False] = False, context: str | None = ..., block_action: BlockAction = ..., ) -> ReferenceTuple | None: ... # docstr-coverage:excused `overload` @overload def parse_curie( self, curie: str, *, strict: Literal[True] = True, context: str | None = ..., block_action: BlockAction = ..., ) -> ReferenceTuple: ... def parse_curie( self, curie: str, *, strict: bool = False, context: str | None = None, block_action: BlockAction = "raise", ) -> ReferenceTuple | None: """Parse and standardize a CURIE. :param curie: The CURIE to parse and standardize :param strict: If the CURIE can't be parsed, should an error be thrown? Defaults to false. :param context: Is there a context, e.g., an ontology prefix that should be applied to the remapping and blocklist rules? :param block_action: What action should be taken when the blocklist is invoked? - **raise** - raise an exception - **pass** - return ``None`` :returns: A tuple representing a parsed and standardized CURIE :raises BlocklistError: If the CURIE is blocked """ curie = self._preclean(curie) if r1 := self.rules.remap_full(curie, reference_cls=self._reference_cls, context=context): return r1.pair # Remap node's prefix (if necessary) curie = self.rules.remap_prefix(curie, context=context) if self.rules.str_is_blocked(curie, context=context): if block_action == "raise": raise BlocklistError else: return None rv = super().parse_curie(curie, strict=strict) # type:ignore[call-overload] return self._post_process(rv) # docstr-coverage:excused `overload` @overload def parse_uri( self, uri: URIType, *, strict: Literal[False] = ..., context: str | None = ..., block_action: BlockAction = ..., ) -> ReferenceTuple | None: ... # docstr-coverage:excused `overload` @overload def parse_uri( self, uri: URIType, *, strict: Literal[True] = True, context: str | None = ..., block_action: BlockAction = ..., ) -> ReferenceTuple: ... def parse_uri( self, uri: URIType, *, strict: bool = False, context: str | None = None, block_action: BlockAction = "raise", ) -> ReferenceTuple | None: """Parse and standardize a URI. :param uri: The URI to parse and standardize :param strict: If the URI can't be parsed, should an error be thrown? Defaults to false. :param context: Is there a context, e.g., an ontology prefix that should be applied to the remapping and blocklist rules? :param block_action: What action should be taken when the blocklist is invoked? - **raise** - raise an exception - **pass** - return ``None`` :returns: A tuple representing a parsed and standardized URI :raises BlocklistError: If the URI is blocked """ uri = self._preclean(uri) if r1 := self.rules.remap_full(uri, reference_cls=self._reference_cls, context=context): return r1.pair # Remap node's prefix (if necessary) uri = self.rules.remap_prefix(uri, context=context) if self.rules.str_is_blocked(uri, context=context): if block_action == "raise": raise BlocklistError else: return None rv: ReferenceTuple | None = super().parse_uri(uri, strict=strict) # type:ignore[call-overload] return self._post_process(rv) biopragmatics-curies-aa8bd37/src/curies/py.typed000066400000000000000000000000011525104130300220620ustar00rootroot00000000000000 biopragmatics-curies-aa8bd37/src/curies/reconciliation.py000066400000000000000000000227401525104130300237550ustar00rootroot00000000000000"""Reconciliation.""" from __future__ import annotations import logging from collections import Counter, defaultdict from collections.abc import Collection, Mapping from .api import Converter, Record __all__ = [ "remap_curie_prefixes", "remap_uri_prefixes", "rewire", ] logger = logging.getLogger(__name__) class TransitiveError(NotImplementedError): """An error when transitive mappings appear.""" def __init__(self, intersection: Collection[str]) -> None: """Initialize the exception. :param intersection: The strings that appeared both as keys and values in a remapping dictionary (either for CURIEs or URIs) """ self.intersection = intersection def __str__(self) -> str: return ( f"Transitive mapping has not been implemented. This is being thrown because " f"the following appear in both the keys and values of the remapping: {self.intersection}." "\n\nSee discussion at https://github.com/cthoyt/curies/issues/75." ) def remap_curie_prefixes(converter: Converter, remapping: Mapping[str, str]) -> Converter: """Apply CURIE prefix remappings. :param converter: A converter :param remapping: A mapping from CURIE prefixes to new CURIE prefixes. Old CURIE prefixes become synonyms in the records (i.e., they aren't forgotten). :returns: An upgraded converter """ ordering = _order_curie_remapping(converter, remapping) intersection = set(remapping).intersection(remapping.values()) records = {r.prefix: r for r in converter.records} modified_records = [] for old, new_prefix in ordering: _old_record = converter.get_record(old) _old = _old_record.prefix if _old_record else None if _old is None: logger.debug( "Remapping %s->%s can not be applied because %s does not appear in the converter. Skipping.", old, new_prefix, old, ) continue record = records.pop(_old) new_record = converter.get_record(new_prefix) if new_record is not None and record != new_record: logger.debug( "Remapping %s->%s would create a clash because of the existing record %r. Skipping.", old, new_prefix, new_record, ) elif old in intersection: record.prefix_synonyms = sorted( set(record.prefix_synonyms).difference({old, new_prefix}) ) record.prefix = new_prefix else: record.prefix_synonyms = sorted( set(record.prefix_synonyms).union({record.prefix}).difference({new_prefix}) ) record.prefix = new_prefix modified_records.append(record) return Converter([*records.values(), *modified_records]) def remap_uri_prefixes(converter: Converter, remapping: Mapping[str, str]) -> Converter: """Apply URI prefix remappings. :param converter: A converter :param remapping: A mapping from URI prefixes to new URI prefixes. Old URI prefixes become synonyms in the records (i.e., they aren't forgotten) :returns: An upgraded converter :raises TransitiveError: If there are any strings that appear in both the key and values of the remapping """ intersection = set(remapping).intersection(remapping.values()) if intersection: raise TransitiveError(intersection) records = [] for record in converter.records: new_uri_prefix = _get_uri_preferred_or_synonym(record, remapping) if new_uri_prefix is None: pass # nothing to upgrade elif ( new_uri_prefix in converter.reverse_prefix_map and new_uri_prefix not in record.uri_prefix_synonyms ): pass # would create a clash, don't do anything else: record.uri_prefix_synonyms = sorted( set(record.uri_prefix_synonyms) .union({record.uri_prefix}) .difference({new_uri_prefix}) ) record.uri_prefix = new_uri_prefix records.append(record) return Converter(records) def rewire(converter: Converter, rewiring: Mapping[str, str]) -> Converter: """Apply URI prefix upgrades. :param converter: A converter :param rewiring: A mapping from CURIE prefixes to new URI prefixes. If CURIE prefixes are not already in the converter, new records are created. If new URI prefixes clash with any existing ones, they are not added. :returns: An upgraded converter """ records = [] for record in converter.records: new_uri_prefix = _get_curie_preferred_or_synonym(record, rewiring) if new_uri_prefix is None: pass # nothing to upgrade elif new_uri_prefix == record.uri_prefix: pass # it's already the preferred prefix, nothing to do elif ( new_uri_prefix in converter.reverse_prefix_map and new_uri_prefix not in record.uri_prefix_synonyms ): logger.debug( "Rewiring %r to %s would create a clash because of the existing record %s. Skipping.", record, new_uri_prefix, converter.reverse_prefix_map[new_uri_prefix], ) else: record.uri_prefix_synonyms = sorted( set(record.uri_prefix_synonyms) .union({record.uri_prefix}) .difference({new_uri_prefix}) ) record.uri_prefix = new_uri_prefix records.append(record) # potential future functionality: add missing records # for prefix, new_uri_prefix in rewiring.items(): # if prefix not in converter.synonym_to_prefix: # records.append(Record(prefix=prefix, uri_prefix=new_uri_prefix)) return Converter(records) def _get_curie_preferred_or_synonym(record: Record, upgrades: Mapping[str, str]) -> str | None: if record.prefix in upgrades: return upgrades[record.prefix] for s in record.prefix_synonyms: if s in upgrades: return upgrades[s] return None def _get_uri_preferred_or_synonym(record: Record, upgrades: Mapping[str, str]) -> str | None: if record.uri_prefix in upgrades: return upgrades[record.uri_prefix] for s in record.uri_prefix_synonyms: if s in upgrades: return upgrades[s] return None class DuplicateValues(ValueError): """Raised when multiple values in the remapping correspond to the same preferred CURIE prefix.""" class DuplicateKeys(ValueError): """Raised when multiple keys in the remapping correspond to the same preferred CURIE prefix.""" class InconsistentMapping(ValueError): """Raised when inconsistent prefixes are used in the keys and values of the remapping.""" class CycleDetected(ValueError): """Raised when the remapping induces a cycle.""" def _order_curie_remapping( converter: Converter, curie_remapping: Mapping[str, str] ) -> list[tuple[str, str]]: # Check that no keys of the remapping actually correspond to the same primary prefix key_counter = defaultdict(list) for key in curie_remapping: key_counter[converter.standardize_prefix(key)].append(key) duplicate_keys = { k: Counter(values) for k, values in key_counter.items() if len(values) > 1 and k is not None } if duplicate_keys: raise DuplicateKeys(f"Duplicate keys in remapping: {duplicate_keys}") # Check that it's not the case that multiple prefixes are mapping # to the same new prefix. value_counter = defaultdict(list) for value in curie_remapping.values(): value_counter[converter.standardize_prefix(value)].append(value) duplicate_values = { k: Counter(values) for k, values in value_counter.items() if len(values) > 1 and k is not None } if duplicate_values: raise DuplicateValues(f"Duplicate values in remapping: {duplicate_values}") # Check that the correspondence is same for both correspondence_counter = defaultdict(set) for key, value in curie_remapping.items(): norm_key = converter.standardize_prefix(key) norm_val = converter.standardize_prefix(value) correspondence_counter[norm_key].add(key) # don't penalize synonym remappings if norm_key != norm_val: correspondence_counter[norm_val].add(value) duplicate_correspondence = { k: Counter(values) for k, values in correspondence_counter.items() if len(values) > 1 and k is not None } if duplicate_correspondence: raise InconsistentMapping( f"Inconsistent usage of prefixes in keys and values: {duplicate_correspondence}" ) # Given the two tests before, we don't have to worry about any clashes, and # we can work directly on primary prefixes if not set(curie_remapping).intersection(curie_remapping.values()): # No logic necessary, so just sort based on key to be consistent return sorted(curie_remapping.items()) # assume that there are no duplicates in the values rv = [] d = dict(curie_remapping) while d: no_outgoing = set(d.values()).difference(d) if not no_outgoing: raise CycleDetected("cycle detected in remapping") edges = sorted((k, v) for k, v in d.items() if v in no_outgoing) rv.extend(edges) d = {k: v for k, v in d.items() if v not in no_outgoing} return rv biopragmatics-curies-aa8bd37/src/curies/resolver_service.py000066400000000000000000000230771525104130300243400ustar00rootroot00000000000000"""A simple web service for resolving CURIEs.""" from __future__ import annotations from collections.abc import Mapping from typing import TYPE_CHECKING, Any from .api import Converter if TYPE_CHECKING: import fastapi import flask from werkzeug.wrappers import Response __all__ = [ "get_fastapi_app", "get_fastapi_router", "get_flask_app", "get_flask_blueprint", ] #: The code for `Unprocessable Entity `_ FAILURE_CODE = 422 def get_flask_blueprint(converter: Converter, **kwargs: Any) -> flask.Blueprint: """Get a blueprint for :class:`flask.Flask`. :param converter: A converter :param kwargs: Keyword arguments passed through to :class:`flask.Blueprint` :returns: A blueprint The following is an end-to-end example of using this function to create a small web resolver application. .. code-block:: python # flask_example.py from flask import Flask from curies import Converter, get_flask_blueprint, get_obo_converter # Create a converter converter: Converter = get_obo_converter() # Create a blueprint from the converter blueprint = get_flask_blueprint(converter) # Create the Flask app and mount the router app = Flask(__name__) app.register_blueprint(blueprint) if __name__ == "__main__": app.run() In the command line, either run your Python file directly, or via with :mod:`gunicorn`: .. code-block:: console $ pip install gunicorn $ gunicorn --bind 0.0.0.0:8764 flask_example:app Test a request in the Python REPL. .. code-block:: python import requests url = requests.get("http://localhost:8764/GO:0032571").url assert url == "http://amigo.geneontology.org/amigo/term/GO:0032571" """ from flask import Blueprint, abort, redirect blueprint = Blueprint("metaresolver", __name__, **kwargs) @blueprint.route(f"/{converter.delimiter}") def resolve(prefix: str, identifier: str) -> Response: """Resolve a CURIE.""" location = converter.expand_pair(prefix, identifier) if location is None: prefixes = "".join([f"\n- {p}" for p in sorted(converter.get_prefixes())]) return abort(FAILURE_CODE, f"Invalid prefix: {prefix}. Use one of:{prefixes}") return redirect(location) return blueprint def get_flask_app( converter: Converter, blueprint_kwargs: Mapping[str, Any] | None = None, flask_kwargs: Mapping[str, Any] | None = None, register_kwargs: Mapping[str, Any] | None = None, ) -> flask.Flask: """Get a Flask app. :param converter: A converter :param blueprint_kwargs: Keyword arguments passed through to :class:`flask.Blueprint` :param flask_kwargs: Keyword arguments passed through to :class:`flask.Flask` :param register_kwargs: Keyword arguments passed through to :meth:`flask.Flask.register_blueprint` :returns: A Flask app .. seealso:: This function wraps :func:`get_flask_blueprint`. If you already have your own Flask app, :func:`get_flask_blueprint` can be used to create a blueprint that you can mount using :meth:`flask.Flask.register_blueprint`. The following is an end-to-end example of using this function to create a small web resolver application. .. code-block:: python # flask_example.py from flask import Flask from curies import Converter, get_flask_app, get_obo_converter # Create a converter converter: Converter = get_obo_converter() # Create the Flask app app: Flask = get_flask_app(converter) if __name__ == "__main__": app.run() In the command line, either run your Python file directly to use Flask/Werkzeug's built-in development server, or run it with :mod:`gunicorn`: .. code-block:: console $ pip install gunicorn $ gunicorn --bind 0.0.0.0:8764 flask_example:app Alternatively, this package contains a CLI in :mod:`curies.cli` that can be used to quickly deploy a resolver based on one of the preset prefix maps, a local prefix map, or a remote one via URL. The one-line equivalent of the example file is: .. code-block:: console $ python -m curies --port 8764 --framework flask --server gunicorn obo Finally, test a request in the Python REPL. .. code-block:: python import requests url = requests.get("http://localhost:8764/GO:0032571").url assert url == "http://amigo.geneontology.org/amigo/term/GO:0032571" """ from flask import Flask blueprint = get_flask_blueprint(converter, **(blueprint_kwargs or {})) app = Flask(__name__, **(flask_kwargs or {})) app.register_blueprint(blueprint, **(register_kwargs or {})) return app def get_fastapi_router(converter: Converter, **kwargs: Any) -> fastapi.APIRouter: """Get a router for :class:`fastapi.FastAPI`. :param converter: A converter :param kwargs: Keyword arguments passed through to :class:`fastapi.APIRouter` :returns: A router The following is an end-to-end example of using this function to create a small web resolver application. Create a python file with your :class:`fastapi.FastAPI` instance: .. code-block:: python # fastapi_example.py from fastapi import FastAPI from curies import Converter, get_fastapi_router # Create a converter converter = Converter.get_obo_converter() # Create a router from the converter router = get_fastapi_router(converter) # Create the FastAPI and mount the router app = FastAPI() app.include_router(router) In the command line,, run your Python file with :mod:`uvicorn`: .. code-block:: console $ pip install uvicorn $ uvicorn fastapi_example:app --port 8764 --host 0.0.0.0 Test a request in the Python REPL. .. code-block:: python import requests url = requests.get("http://localhost:8764/GO:0032571").url assert url == "http://amigo.geneontology.org/amigo/term/GO:0032571" """ from fastapi import APIRouter, HTTPException, Path from fastapi.responses import RedirectResponse api_router = APIRouter(**kwargs) @api_router.get(f"/{{prefix}}{converter.delimiter}{{identifier}}") def resolve( prefix: str = Path( title="Prefix", description="The Bioregistry prefix corresponding to an identifier resource.", examples=["doid"], ), identifier: str = Path( title="Local Unique Identifier", description="The local unique identifier in the identifier resource referenced by the prefix.", ), ) -> RedirectResponse: """Resolve a CURIE.""" location = converter.expand_pair(prefix, identifier) if location is None: prefixes = ", ".join(sorted(converter.get_prefixes())) raise HTTPException( status_code=FAILURE_CODE, detail=f"Invalid prefix: {prefix}. Use one of: {prefixes}", ) return RedirectResponse(location, status_code=302) return api_router def get_fastapi_app( converter: Converter, router_kwargs: Mapping[str, Any] | None = None, fastapi_kwargs: Mapping[str, Any] | None = None, include_kwargs: Mapping[str, Any] | None = None, ) -> fastapi.FastAPI: """Get a FastAPI app. :param converter: A converter :param router_kwargs: Keyword arguments passed through to :class:`fastapi.APIRouter` :param fastapi_kwargs: Keyword arguments passed through to :class:`fastapi.FastAPI` :param include_kwargs: Keyword arguments passed through to :meth:`fastapi.FastAPI.include_router` :returns: A FastAPI app .. seealso:: This function wraps :func:`get_fastapi_router`. If you already have your own FastAPI app, :func:`get_fastapi_router` can be used to create a :class:`fastapi.APIRouter` that you can mount using :meth:`fastapi.FastAPI.include_router`. The following is an end-to-end example of using this function to create a small web resolver application. Create a python file with your :class:`fastapi.FastAPI` instance: .. code-block:: python # fastapi_example.py from fastapi import FastAPI from curies import Converter, get_fastapi_app # Create a converter converter = Converter.get_obo_converter() # Create the FastAPI app: FastAPI = get_fastapi_app(converter) In the command line,, run your Python file with :mod:`uvicorn`: .. code-block:: console $ pip install uvicorn $ uvicorn fastapi_example:app --port 8764 --host 0.0.0.0 Alternatively, this package contains a CLI in :mod:`curies.cli` that can be used to quickly deploy a resolver based on one of the preset prefix maps, a local prefix map, or a remote one via URL. The one-line equivalent of the example file is: .. code-block:: console $ python -m curies --framework fastapi --server uvicorn obo Finally, test a request in the Python REPL. .. code-block:: python import requests url = requests.get("http://localhost:8764/GO:0032571").url assert url == "http://amigo.geneontology.org/amigo/term/GO:0032571" """ from fastapi import FastAPI router = get_fastapi_router(converter, **(router_kwargs or {})) app = FastAPI(**(fastapi_kwargs or {})) app.include_router(router, **(include_kwargs or {})) return app biopragmatics-curies-aa8bd37/src/curies/sources.py000066400000000000000000000117461525104130300224420ustar00rootroot00000000000000"""External sources of contexts.""" from __future__ import annotations from typing import Any from .api import Converter __all__ = [ "get_bioregistry_converter", "get_go_converter", "get_monarch_converter", "get_obo_converter", "get_prefixcommons_converter", ] BIOREGISTRY_CONTEXTS = ( "https://raw.githubusercontent.com/biopragmatics/bioregistry/main/exports/contexts" ) def get_obo_converter() -> Converter: """Get the latest OBO Foundry context. :returns: A converter object representing the OBO Foundry's JSON-LD context, which contains a simple mapping from OBO Foundry preferred prefixes for ontologies that contain case stylization (e.g., ``GO``, not ``go``; ``VariO``, not ``vario``). It does not include synonyms nor any non-ontology prefixes - e.g., it does not include semantic web prefixes like ``rdfs``, it does not include other useful biomedical prefixes like ``hgnc``. If you want a more comprehensive prefix map, consider using the Bioregistry via :func:`get_bioregistry_converter` or by chaining the OBO converter in front of the Bioregistry depending on your personal/project preferences using :func:`curies.chain`. Provenance: - This JSON-LD context is generated programmatically by https://github.com/OBOFoundry/OBOFoundry.github.io/blob/master/util/processor.py. - The file is accessed via from http://purl.obolibrary.org/meta/obo_context.jsonld, which is configured through the OBO Foundry's PURL server with https://github.com/OBOFoundry/purl.obolibrary.org/blob/master/www/.htaccess and ultimately points to https://raw.githubusercontent.com/OBOFoundry/OBOFoundry.github.io/master/registry/obo_context.jsonl """ # See configuration on # to see where this PURL points url = "http://purl.obolibrary.org/meta/obo_context.jsonld" return Converter.from_jsonld(url) def get_prefixcommons_converter(name: str = "monarch_context") -> Converter: """Get a Prefix Commons-maintained context. :param name: The name of the JSON-LD file (e.g., defaults to ``monarch_context``). See the full list at https://github.com/prefixcommons/prefixcommons-py/tree/master/prefixcommons/registry. :returns: A converter """ url = ( "https://raw.githubusercontent.com/prefixcommons/prefixcommons-py/master/" f"prefixcommons/registry/{name}.jsonld" ) return Converter.from_jsonld(url) def get_monarch_converter() -> Converter: """Get the Prefix Commons-maintained Monarch context.""" return get_prefixcommons_converter("monarch_context") def get_go_converter() -> Converter: """Get the Prefix Commons-maintained GO context.""" return get_prefixcommons_converter("go_context") def get_bioregistry_converter(web: bool = False, **kwargs: Any) -> Converter: """Get the latest extended prefix map from the Bioregistry [hoyt2022]_. :param web: If false, tries to import :mod:`bioregistry` and use :func:`bioregistry.get_converter` to get the converter. Otherwise, falls back to using the GitHub-hosted EPM export. :param kwargs: Keyword arguments to pass to :meth`:curies.Converter.from_extended_prefix_map` when using web-based loading. :returns: A converter representing the Bioregistry, which includes a comprehensive collection of prefixes, prefix synonyms, and URI prefix synonyms. Short summary of the Bioregistry: 1. It deduplicates and harmonizes dozens of different resources that curate partially overlapping and conflicting prefix maps 2. It contains detailed CURIE prefix synonyms to support standardization 3. It enforces the generation of a self-consistent extended prefix map The Bioregistry's primary prefixes are all standardized to be lowercase, have minimal punctuation, and be the most idiomatic possible. When this conflicts with your personal preferences/community preferences, you can chain another converter in front of the Bioregistry converter using :func:`curies.chain`. However, the Bioregistry itself presents a more sustainable way of documenting these deviations in a community-oriented way using its "context" configurations. See https://bioregistry.io/context/ for more information. One excellent example of a community context is for the OBO community (see https://bioregistry.io/context/obo), which prioritizes OBO capitalized prefixes and makes a few minor changes for backwards compatibility (e.g., renaming Orphanet). .. [hoyt2022] `Unifying the identification of biomedical entities with the Bioregistry `_ """ if not web: try: import bioregistry except ImportError: # pragma: no cover pass else: return bioregistry.manager.get_converter() url = f"{BIOREGISTRY_CONTEXTS}/bioregistry.epm.json" return Converter.from_extended_prefix_map(url, **kwargs) biopragmatics-curies-aa8bd37/src/curies/triples/000077500000000000000000000000001525104130300220565ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/src/curies/triples/__init__.py000066400000000000000000000134661525104130300242010ustar00rootroot00000000000000"""Similarly to how the :mod:`curies` package enables the semantic representation of references (i.e., as CURIEs) with the :class:`curies.Reference` class, it enables the representation of semantic triples (i.e., as subject-predicate-object triples of CURIEs) with the :class:`curies.Triple` class. ###################### Constructing Triples ###################### Triples can be constructed either from strings representing CURIEs or pre-parsed :class:`Reference` objects representing CURIEs. .. code-block:: python from curies import Triple, Reference # construction with string representations of CURIEs triple = Triple( subject="mesh:C000089", predicate="skos:exactMatch", object="CHEBI:28646", ) # construction with object representations of CURIEs triple = Triple( subject=Reference(prefix="mesh", identifier="C000089"), predicate=Reference(prefix="skos", identifier="exactMatch"), object=Reference(prefix="CHEBI", identifier="28646"), ) Any reference objects can be used, including ones with names: .. code-block:: python from curies import NamableReference triple = Triple( subject=NamedReference(prefix="mesh", identifier="C000089", name="ammeline"), predicate=NamableReference(prefix="skos", identifier="exactMatch"), object=NamedReference(prefix="CHEBI", identifier="28646", name="ammeline"), ) The :class:`Triple` interface does not enforce any CURIE validation. The :meth:`Triple.from_uris` constructor implicitly performs validation against a converter while parsing. .. code-block:: python from curies import Triple, Reference, Converter converter = curies.load_prefix_map( { "mesh": "http://id.nlm.nih.gov/mesh/", "skos": "http://www.w3.org/2004/02/skos/core#", "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) triple = Triple.from_uris( subject="http://id.nlm.nih.gov/mesh/C000089", predicate="http://www.w3.org/2004/02/skos/core#exactMatch", object="http://purl.obolibrary.org/obo/CHEBI_28646", converter=converter, ) ########################### Identification of Triples ########################### The ``rdf`` namespace supports the explicit reification of triples. This means that an explicit identifier (typically, a blank node) can be used to refer to a triple itself, and the ``rdf:subject``, ``rdf:predicate`` and ``rdf:object`` predicates can be used to connect the identifier representing the triple to its respective subject, predicate, and object components. RDF enables explicit reification of triples with the following: .. code-block:: PREFIX rdf: PREFIX mesh: PREFIX skos: PREFIX CHEBI: mesh:C000089 skos:exactMatch CHEBI:28646 . [] rdf:type rdf:Statement ; rdf:subject mesh:C000089 ; rdf:predicate skos:exactMatch ; rdf:object CHEBI:28646 . It would be nice to have an implementation-agnostic way of assigning an identifier to the triple. This example imagines a namespace with the prefix ``triple`` that can host identifiers for triples: .. code-block:: PREFIX rdf: PREFIX mesh: PREFIX skos: PREFIX CHEBI: PREFIX triple: mesh:C000089 skos:exactMatch CHEBI:28646 . triple:36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a rdf:type rdf:Statement ; rdf:subject mesh:C000089 ; rdf:predicate skos:exactMatch ; rdf:object CHEBI:28646 . :meth:`curies.Converter.hash_triple` implements a deterministic, one-way hash of a triple based on the algorithm in https://ts4nfdi.github.io/mapping-sameness-identifier: .. code-block:: python import curies from curies import Triple, Converter converter = curies.load_prefix_map( { "mesh": "http://id.nlm.nih.gov/mesh/", "skos": "http://www.w3.org/2004/02/skos/core#", "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) triple = Triple( subject="mesh:C000089", predicate="skos:exactMatch", object="CHEBI:28646", ) triple_id = converter.hash_triple(triple) assert triple_id == "36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a" """ from __future__ import annotations from .filters import ( exclude_object_prefixes, exclude_prefixes_both, exclude_references_both, exclude_same_prefixes, exclude_subject_prefixes, exclude_triples, keep_object_prefixes, keep_predicates, keep_prefixes_both, keep_prefixes_either, keep_references_both, keep_references_either, keep_subject_prefixes, keep_triples_by_hash, ) from .hash_utils import encode_curie_triple, encode_uri_triple, hash_triple from .io import read_triples, write_triples from .model import StrTriple, Triple, TriplePredicate, TripleType from .ops import exclude_prefix_stratified_many_to_many __all__ = [ "StrTriple", "Triple", "TriplePredicate", "TripleType", "encode_curie_triple", "encode_uri_triple", "exclude_object_prefixes", "exclude_prefix_stratified_many_to_many", "exclude_prefixes_both", "exclude_references_both", "exclude_same_prefixes", "exclude_subject_prefixes", "exclude_triples", "hash_triple", "keep_object_prefixes", "keep_predicates", "keep_prefixes_both", "keep_prefixes_either", "keep_references_both", "keep_references_either", "keep_subject_prefixes", "keep_triples_by_hash", "read_triples", "write_triples", ] biopragmatics-curies-aa8bd37/src/curies/triples/filters.py000066400000000000000000000527171525104130300241140ustar00rootroot00000000000000"""Filter functions for triples.""" from __future__ import annotations import logging from collections.abc import Collection, Iterable from .model import Triple, TriplePredicate, TripleType from ..api import Converter, Reference __all__ = [ "exclude_object_prefixes", "exclude_prefixes_both", "exclude_references_both", "exclude_same_prefixes", "exclude_subject_prefixes", "exclude_triples", "keep_object_prefixes", "keep_predicates", "keep_prefixes_both", "keep_prefixes_either", "keep_references_both", "keep_references_either", "keep_subject_prefixes", "keep_triples_by_hash", ] logger = logging.getLogger(__name__) def _filter( func: TriplePredicate[TripleType], triples: Iterable[TripleType], progress: bool = False ) -> Iterable[TripleType]: if progress: # pragma: no cover try: from tqdm import tqdm except ImportError: logger.warning("tqdm is not installed, can't use progress bar for %s", func) else: triples = tqdm(triples, unit="triples", unit_scale=True) return filter(func, triples) def keep_prefixes_both( triples: Iterable[TripleType], prefixes: Iterable[str], *, progress: bool = False ) -> Iterable[TripleType]: """Keep triples whose subjects' and objects' prefixes are in the given prefixes. :param triples: An iterable of triples :param prefixes: A set of prefixes to use for filtering the triples :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose subjects' and objects' prefixes are in the given prefixes >>> from curies import Triple >>> from curies.vocabulary import exact_match >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "umls:C4551571" >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, exact_match.curie, c3) >>> assert list(keep_prefixes_both([m1, m2, m3], {"DOID", "mesh"})) == [m1] """ return _filter(_keep_prefixes_both_filter(prefixes), triples, progress=progress) def _keep_prefixes_both_filter(prefixes: Iterable[str]) -> TriplePredicate[TripleType]: prefixes = set(prefixes) if len(prefixes) < 2: raise ValueError def _func(triple: TripleType) -> bool: return triple.subject.prefix in prefixes and triple.object.prefix in prefixes return _func def keep_prefixes_either( triples: Iterable[TripleType], prefixes: str | Iterable[str], *, progress: bool = False ) -> Iterable[TripleType]: """Keep triples whose subjects' and objects' prefixes are in the given prefixes. :param triples: An iterable of triples :param prefixes: A set of prefixes to use for filtering the triples :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose subjects' and objects' prefixes are in the given prefixes >>> from curies import Triple >>> from curies.vocabulary import exact_match >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "umls:C4551571" >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, exact_match.curie, c3) >>> assert list(keep_prefixes_either([m1, m2, m3], {"DOID", "mesh"})) == [m1] """ return _filter(_keep_prefixes_either_filter(prefixes), triples, progress=progress) def _keep_prefixes_either_filter(prefixes: Iterable[str]) -> TriplePredicate[TripleType]: if isinstance(prefixes, str): def _func(triple: TripleType) -> bool: return triple.subject.prefix == prefixes or triple.object.prefix == prefixes else: prefixes = set(prefixes) def _func(triple: TripleType) -> bool: return triple.subject.prefix in prefixes or triple.object.prefix in prefixes return _func def keep_subject_prefixes( triples: Iterable[TripleType], prefixes: str | Iterable[str], *, progress: bool = False ) -> Iterable[TripleType]: """Keep triples whose subjects' prefixes are in the given prefixes. :param triples: An iterable of triples :param prefixes: A set of prefixes to use for filtering the triples :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose subjects' prefixes are in the given prefixes >>> from curies import Triple >>> from curies.vocabulary import exact_match >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "umls:C4551571" >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, exact_match.curie, c3) >>> assert list(keep_subject_prefixes([m1, m2, m3], {"DOID"})) == [m1, m3] """ return _filter(_keep_subject_prefixes_filter(prefixes), triples, progress=progress) def _keep_subject_prefixes_filter(prefixes: str | Iterable[str]) -> TriplePredicate[TripleType]: if isinstance(prefixes, str): def _func(triple: TripleType) -> bool: return triple.subject.prefix == prefixes else: prefixes = set(prefixes) def _func(triple: TripleType) -> bool: return triple.subject.prefix in prefixes return _func def keep_object_prefixes( triples: Iterable[TripleType], prefixes: str | Iterable[str], *, progress: bool = False ) -> Iterable[TripleType]: """Keep triples whose objects' prefixes are in the given prefixes. :param triples: An iterable of triples :param prefixes: A set of prefixes to use for filtering the triples :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose objects' prefixes are in the given prefixes >>> from curies import Triple >>> from curies.vocabulary import exact_match >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "umls:C4551571" >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, exact_match.curie, c3) >>> assert list(keep_object_prefixes([m1, m2, m3], {"umls"})) == [m2, m3] """ return _filter(_keep_object_prefixes_filter(prefixes), triples, progress=progress) def _keep_object_prefixes_filter(prefixes: str | Iterable[str]) -> TriplePredicate[TripleType]: if isinstance(prefixes, str): def _func(triple: TripleType) -> bool: return triple.object.prefix == prefixes else: prefixes = set(prefixes) def _func(triple: TripleType) -> bool: return triple.object.prefix in prefixes return _func def exclude_prefixes_both( triples: Iterable[TripleType], prefixes: str | Iterable[str], *, progress: bool = False ) -> Iterable[TripleType]: """Exclude triples whose subjects' and objects' prefixes are in the given prefixes. :param triples: An iterable of triples :param prefixes: A set of prefixes to use for filtering the triples :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose subjects' and objects' prefixes are not in the given prefixes >>> from curies import Triple >>> from curies.vocabulary import exact_match >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "umls:C4551571" >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, exact_match.curie, c3) >>> assert list(exclude_prefixes_both([m1, m2, m3], {"umls"})) == [m1] >>> assert list(exclude_prefixes_both([m1, m2, m3], {"DOID"})) == [m2] >>> assert list(exclude_prefixes_both([m1, m2, m3], {"mesh"})) == [m3] """ return _filter(_exclude_prefixes_filter(prefixes), triples, progress=progress) def _exclude_prefixes_filter(prefixes: str | Iterable[str]) -> TriplePredicate[TripleType]: if isinstance(prefixes, str): def _func(triple: TripleType) -> bool: return triple.subject.prefix != prefixes and triple.object.prefix != prefixes else: prefixes = set(prefixes) def _func(triple: TripleType) -> bool: return triple.subject.prefix not in prefixes and triple.object.prefix not in prefixes return _func def exclude_subject_prefixes( triples: Iterable[TripleType], prefixes: str | Iterable[str], *, progress: bool = False ) -> Iterable[TripleType]: """Exclude triples whose subjects' prefixes are in the given prefixes. :param triples: An iterable of triples :param prefixes: A set of prefixes to use for filtering the triples :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose subjects' prefixes are not in the given prefixes >>> from curies import Triple >>> from curies.vocabulary import exact_match >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "umls:C4551571" >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, exact_match.curie, c3) >>> assert list(exclude_subject_prefixes([m1, m2, m3], {"DOID"})) == [m2] >>> assert list(exclude_subject_prefixes([m1, m2, m3], {"umls"})) == [m1, m2, m3] >>> assert list(exclude_subject_prefixes([m1, m2, m3], {"mesh"})) == [m1, m3] """ return _filter(_exclude_subject_prefixes_filter(prefixes), triples, progress=progress) def _exclude_subject_prefixes_filter(prefixes: str | Iterable[str]) -> TriplePredicate[TripleType]: if isinstance(prefixes, str): def _func(triple: TripleType) -> bool: return triple.subject.prefix != prefixes else: prefixes = set(prefixes) def _func(triple: TripleType) -> bool: return triple.subject.prefix not in prefixes return _func def exclude_object_prefixes( triples: Iterable[TripleType], prefixes: str | Iterable[str], *, progress: bool = False ) -> Iterable[TripleType]: """Exclude triples whose objects' prefixes are in the given prefixes. :param triples: An iterable of triples :param prefixes: A set of prefixes to use for filtering the triples :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose objects' prefixes are not in the given prefixes >>> from curies import Triple >>> from curies.vocabulary import exact_match >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "umls:C4551571" >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, exact_match.curie, c3) >>> assert list(exclude_object_prefixes([m1, m2, m3], {"umls"})) == [m1] >>> assert list(exclude_object_prefixes([m1, m2, m3], {"mesh"})) == [m2, m3] >>> assert list(exclude_object_prefixes([m1, m2, m3], {"DOID"})) == [m1, m2, m3] """ return _filter(_exclude_object_prefixes_filter(prefixes), triples, progress=progress) def _exclude_object_prefixes_filter(prefixes: str | Iterable[str]) -> TriplePredicate[TripleType]: if isinstance(prefixes, str): def _func(triple: TripleType) -> bool: return triple.object.prefix != prefixes else: prefixes = set(prefixes) def _func(triple: TripleType) -> bool: return triple.object.prefix not in prefixes return _func def exclude_same_prefixes( triples: Iterable[TripleType], *, progress: bool = False ) -> Iterable[TripleType]: """Exclude triples whose subject and object prefixes are the same. :param triples: An iterable of triples :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose subject and object prefixes are not the same. >>> from curies import Triple >>> from curies.vocabulary import exact_match, subclass_of >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "DOID:225" >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, subclass_of.curie, c3) >>> assert list(exclude_same_prefixes([m1, m2, m3])) == [m1, m2] """ return _filter(_same_prefix_filter, triples, progress=progress) def _same_prefix_filter(triple: TripleType) -> bool: return triple.subject.prefix != triple.object.prefix def keep_triples_by_hash( triples: Iterable[TripleType], converter: Converter, triple_hashes: str | Iterable[str], *, progress: bool = False, ) -> Iterable[TripleType]: """Keep triples whose triple hash (under the given converter) is in the given collection. :param triples: An iterable of triples :param converter: A converter :param triple_hashes: A hash or hashs to check triples for :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose triple hash under the given constructor appears in the given collection >>> from curies import Triple, Converter >>> from curies.vocabulary import exact_match, subclass_of >>> converter = Converter.from_prefix_map( ... { ... "DOID": "http://purl.obolibrary.org/obo/DOID_", ... "skos": "http://www.w3.org/2004/02/skos/core#", ... "mesh": "http://id.nlm.nih.gov/mesh/", ... "umls": "https://uts.nlm.nih.gov/uts/umls/concept/", ... } ... ) >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "DOID:225" >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, subclass_of.curie, c3) >>> m1_hash = "081f943d3791dae3a85f8eb9190fee3fbdc47ba269a374e4a0a28a2b0b982625" >>> assert list(keep_triples_by_hash([m1, m2, m3], converter, m1_hash)) == [m1] """ return _filter(_triple_has_hash(converter, triple_hashes), triples, progress=progress) def _triple_has_hash( converter: Converter, triple_hashes: str | Iterable[str] ) -> TriplePredicate[TripleType]: if isinstance(triple_hashes, str): def _func(triple: TripleType) -> bool: return converter.hash_triple(triple) == triple_hashes else: triple_hashes = set(triple_hashes) def _func(triple: TripleType) -> bool: return converter.hash_triple(triple) in triple_hashes return _func def exclude_triples( triples: Iterable[TripleType], exclusion: TripleType | Collection[TripleType], *, progress: bool = False, ) -> Iterable[TripleType]: """Exclude triples in the given set. :param triples: An iterable of triples :param exclusion: A triple or collection of triples to exclude :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose subject and object prefixes are not the same. >>> from curies import Triple >>> from curies.vocabulary import exact_match, subclass_of >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "DOID:225" >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, subclass_of.curie, c3) >>> assert list(exclude_triples([m1, m2, m3], m3)) == [m1, m2] """ return _filter(_exclude_triples(exclusion), triples, progress=progress) def _exclude_triples( exclusion_triples: TripleType | Iterable[TripleType], ) -> TriplePredicate[TripleType]: if isinstance(exclusion_triples, Triple): exclusion_triples_ = {exclusion_triples} else: exclusion_triples_ = set(exclusion_triples) def _func(triple: TripleType) -> bool: return triple not in exclusion_triples_ return _func def keep_references_either( triples: Iterable[TripleType], references: Reference | Collection[Reference], *, progress: bool = False, ) -> Iterable[TripleType]: """Keep triples whose subject and object appear in the given references. :param triples: An iterable of triples :param references: A collection of references :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose subject and object appear in the given references. >>> from curies import Reference, Triple >>> from curies.vocabulary import exact_match, subclass_of >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "DOID:225" >>> r1, r2, r3 = (Reference.from_curie(c) for c in (c1, c2, c3)) >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, subclass_of.curie, c3) >>> assert list(keep_references_either([m1, m2, m3], [r2, r1])) == [m1] """ return _filter(_include_references_either(references), triples, progress=progress) def _include_references_either( references: Reference | Collection[Reference], ) -> TriplePredicate[TripleType]: if isinstance(references, Reference): def _func(triple: TripleType) -> bool: return triple.subject == references or triple.object == references else: references = set(references) def _func(triple: TripleType) -> bool: return triple.subject in references or triple.object in references return _func def keep_references_both( triples: Iterable[TripleType], references: Collection[Reference], *, progress: bool = False ) -> Iterable[TripleType]: """Keep triples whose subject and object appear in the given references. :param triples: An iterable of triples :param references: A collection of references :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose subject and object appear in the given references. >>> from curies import Reference, Triple >>> from curies.vocabulary import exact_match, subclass_of >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "DOID:225" >>> r1, r2, r3 = (Reference.from_curie(c) for c in (c1, c2, c3)) >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, subclass_of.curie, c3) >>> assert list(keep_references_both([m1, m2, m3], [r2, r1])) == [m1] """ return _filter(_include_references_both(references), triples, progress=progress) def _include_references_both(references: Collection[Reference]) -> TriplePredicate[TripleType]: references = set(references) if len(references) < 2: raise ValueError("two or more references are required") def _func(triple: TripleType) -> bool: return triple.subject in references and triple.object in references return _func def exclude_references_both( triples: Iterable[TripleType], references: Reference | Collection[Reference], *, progress: bool = False, ) -> Iterable[TripleType]: """Exclude triples whose subject and object appear in the given references. :param triples: An iterable of triples :param references: A collection of references :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose subject and object don't appear in the given references. >>> from curies import Reference, Triple >>> from curies.vocabulary import exact_match, subclass_of >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "DOID:225" >>> r1, r2, r3 = (Reference.from_curie(c) for c in (c1, c2, c3)) >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, subclass_of.curie, c3) >>> assert list(exclude_references_both([m1, m2, m3], [r1])) == [m2] >>> assert list(exclude_references_both([m1, m2, m3], [r2])) == [m3] >>> assert list(exclude_references_both([m1, m2, m3], [r3])) == [m1] """ return _filter(_exclude_references(references), triples, progress=progress) def _exclude_references( references: Reference | Collection[Reference], ) -> TriplePredicate[TripleType]: if isinstance(references, Reference): references = {references} else: references = set(references) def _func(triple: TripleType) -> bool: return triple.subject not in references and triple.object not in references return _func def keep_predicates( triples: Iterable[TripleType], predicates: Reference | Collection[Reference], *, progress: bool = False, ) -> Iterable[TripleType]: """Keep triples whose predicate appear in the given references. :param triples: An iterable of triples :param predicates: A collection of references :param progress: Should a progress bar be shown? :returns: A sub-iterable of triples whose predicate appear in the given references. >>> from curies import Reference, Triple >>> from curies.vocabulary import exact_match, subclass_of >>> c1, c2, c3 = "DOID:0050577", "mesh:C562966", "DOID:225" >>> r1, r2, r3 = (Reference.from_curie(c) for c in (c1, c2, c3)) >>> m1 = Triple.from_curies(c1, exact_match.curie, c2) >>> m2 = Triple.from_curies(c2, exact_match.curie, c3) >>> m3 = Triple.from_curies(c1, subclass_of.curie, c3) >>> assert list(keep_predicates([m1, m2, m3], exact_match)) == [m1, m2] """ return _filter(_include_predicates(predicates), triples, progress=progress) def _include_predicates( predicates: Reference | Collection[Reference], ) -> TriplePredicate[TripleType]: if isinstance(predicates, Reference): def _func(triple: TripleType) -> bool: return triple.predicate == predicates else: predicates = set(predicates) def _func(triple: TripleType) -> bool: return triple.predicate in predicates return _func biopragmatics-curies-aa8bd37/src/curies/triples/hash_utils.py000066400000000000000000000074461525104130300246060ustar00rootroot00000000000000"""Triple hashing utilities.""" from __future__ import annotations import hashlib from .model import Triple from ..api import Converter __all__ = [ "encode_curie_triple", "encode_uri_triple", "hash_triple", ] def hash_triple(converter: Converter, triple: Triple, *, negate: bool = False) -> str: """Encode a triple with URL-safe base64 encoding. :param converter: A converter :param triple: A triple of CURIE objects :param negate: If true, considers the triple as "negative" and postpends a ``~`` to the hash :returns: An encoded triple of URIs .. seealso:: https://ts4nfdi.github.io/mapping-sameness-identifier/ >>> import curies >>> from curies.triples import Triple >>> converter = curies.load_prefix_map( ... { ... "mesh": "http://id.nlm.nih.gov/mesh/", ... "skos": "http://www.w3.org/2004/02/skos/core#", ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... } ... ) >>> triple = Triple(subject="mesh:C000089", predicate="skos:exactMatch", object="CHEBI:28646") >>> hash_triple(converter, triple) '36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a' >>> hash_triple(converter, triple, negate=True) '36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a~' """ return encode_uri_triple(triple.as_uri_triple(converter), negate=negate) def encode_curie_triple( curie_triple: tuple[str, str, str], converter: Converter, *, negate: bool = False ) -> str: """Encode a subject-predicate-object CURIE triple. :param curie_triple: A triple of CURIEs represented as strings :param converter: A converter :param negate: If true, considers the triple as "negative" and postpends a ``~`` to the hash :returns: An encoded triple of URIs .. seealso:: https://ts4nfdi.github.io/mapping-sameness-identifier/ >>> import curies >>> from curies.triples import Triple >>> converter = curies.load_prefix_map( ... { ... "mesh": "http://id.nlm.nih.gov/mesh/", ... "skos": "http://www.w3.org/2004/02/skos/core#", ... "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", ... } ... ) >>> triple = ("mesh:C000089", "skos:exactMatch", "CHEBI:28646") >>> encode_curie_triple(triple, converter) '36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a' >>> encode_curie_triple(triple, converter, negate=True) '36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a~' """ uri_triple = ( converter.expand(curie_triple[0], strict=True), converter.expand(curie_triple[1], strict=True), converter.expand(curie_triple[2], strict=True), ) return encode_uri_triple(uri_triple, negate=negate) def encode_uri_triple(uri_triple: tuple[str, str, str], *, negate: bool = False) -> str: """Encode a subject-predicate-object URI triple. :param uri_triple: A triple of URIs represented as strings :param negate: If true, considers the triple as "negative" and postpends a ``~`` to the hash :returns: An encoded triple of URIs .. seealso:: https://ts4nfdi.github.io/mapping-sameness-identifier/ >>> triple = ( ... "http://id.nlm.nih.gov/mesh/C000089", ... "http://www.w3.org/2004/02/skos/core#exactMatch", ... "http://purl.obolibrary.org/obo/CHEBI_28646", ... ) >>> encode_uri_triple(triple) '36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a' >>> encode_uri_triple(triple, negate=True) '36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a~' """ delimited_uris = " ".join(uri_triple) digest = hashlib.sha256(delimited_uris.encode("utf-8")).hexdigest() if negate: digest += "~" return digest biopragmatics-curies-aa8bd37/src/curies/triples/io.py000066400000000000000000000030151525104130300230360ustar00rootroot00000000000000"""I/O operations for triples.""" from __future__ import annotations from collections.abc import Iterable, Sequence from pathlib import Path from typing import TextIO from pystow.utils import safe_open_reader, safe_open_writer from .model import Triple from ..api import Reference __all__ = [ "HEADER", "read_triples", "write_triples", ] #: the default header for a three-column file representing triples HEADER = list(Triple.model_fields) def write_triples( triples: Iterable[Triple], path: str | Path | TextIO, *, header: Sequence[str] | None = None ) -> None: """Write triples as a three-column TSV file.""" if header is None: header = HEADER with safe_open_writer(path) as writer: writer.writerow(header) writer.writerows( (triple.subject.curie, triple.predicate.curie, triple.object.curie) for triple in triples ) def read_triples( path: str | Path | TextIO, *, reference_cls: type[Reference] | None = None ) -> list[Triple]: """Read triples from a three-column TSV file.""" if reference_cls is None: reference_cls = Reference with safe_open_reader(path) as reader: _header = next(reader) return [ Triple( subject=reference_cls.from_curie(subject_curie), predicate=reference_cls.from_curie(predicate_curie), object=reference_cls.from_curie(object_curie), ) for subject_curie, predicate_curie, object_curie in reader ] biopragmatics-curies-aa8bd37/src/curies/triples/model.py000066400000000000000000000065271525104130300235420ustar00rootroot00000000000000"""Model definitions.""" from __future__ import annotations from collections.abc import Callable from typing import NamedTuple, Self, TypeAlias, TypeVar from pydantic import BaseModel, ConfigDict from ..api import Converter, Reference __all__ = [ "StrTriple", "Triple", "TriplePredicate", "TripleType", ] class StrTriple(NamedTuple): """A triple of curies.""" subject: str predicate: str object: str class Triple(BaseModel): """A Pydantic model for a subject-predicate-object triple. Triples can be constructed either from strings representing CURIEs or pre-parsed :class:`Reference` objects representing CURIEs. .. code-block:: python from curies import Triple, Reference # construction with string representations of CURIEs triple = Triple( subject="mesh:C000089", predicate="skos:exactMatch", object="CHEBI:28646", ) # construction with object representations of CURIEs triple = Triple( subject=Reference(prefix="mesh", identifier="C000089"), predicate=Reference(prefix="skos", identifier="exactMatch"), object=Reference(prefix="CHEBI", identifier="28646"), ) .. note:: It's up to you to validate your CURIEs are semantically sound, e.g., against the :mod:`bioregistry`. """ model_config = ConfigDict(frozen=True) subject: Reference predicate: Reference object: Reference def as_str_triple(self) -> StrTriple: """Get a three-tuple of strings representing this triple.""" return StrTriple(self.subject.curie, self.predicate.curie, self.object.curie) def as_uri_triple(self, converter: Converter) -> tuple[str, str, str]: """Get a three-tuple of strings representing the expanded URIs.""" return ( converter.expand_reference(self.subject, strict=True), converter.expand_reference(self.predicate, strict=True), converter.expand_reference(self.object, strict=True), ) def __lt__(self, other: Triple) -> bool: return self.as_str_triple() < other.as_str_triple() @classmethod def from_curies( cls, subject_curie: str, predicate_curie: str, object_curie: str, *, reference_cls: type[Reference] = Reference, ) -> Self: """Construct a triple from three CURIE strings.""" return cls( subject=reference_cls.from_curie(subject_curie), predicate=reference_cls.from_curie(predicate_curie), object=reference_cls.from_curie(object_curie), ) @classmethod def from_uris( cls, subject: str, predicate: str, object: str, *, converter: Converter, reference_cls: type[Reference] = Reference, ) -> Self: """Construct a triple from three URI strings.""" return cls( subject=reference_cls.from_reference(converter.parse_uri(subject, strict=True)), predicate=reference_cls.from_reference(converter.parse_uri(predicate, strict=True)), object=reference_cls.from_reference(converter.parse_uri(object, strict=True)), ) TripleType = TypeVar("TripleType", bound=Triple) #: A predicate over a triple TriplePredicate: TypeAlias = Callable[[TripleType], bool] biopragmatics-curies-aa8bd37/src/curies/triples/ops.py000066400000000000000000000142061525104130300232340ustar00rootroot00000000000000"""Operations on triples.""" from collections import defaultdict from collections.abc import Iterable from typing import TypeAlias from .filters import exclude_triples from .model import TripleType from .. import Reference __all__ = [ "PrefixPairStratifiedIndex", "exclude_prefix_stratified_many_to_many", "exclude_triples", "get_prefix_pair_stratified_indexes", "get_prefix_stratified_many_to_many", "get_reference_indexes", ] #: A doubly-nested adjacency dictionary whose first #: keys are subject/object local unique identifier, #: second level is the opposite side local unique #: identifier, and values are the list of triples AdjacencyDict = dict[str, dict[str, list[TripleType]]] #: A pair of prefixes PrefixPair: TypeAlias = tuple[str, str] #: A multi-leveled nested dictionary that represents many-to-many mappings. #: The first key is subject/object pairs, the second key is either a subject identifier or object identifier, #: the last key is the opposite object or subject identifier, and the values are a list of mappings. #: #: This data structure can be used to index either forward or backwards mappings, #: as done inside :func:`get_many_to_many` PrefixPairStratifiedIndex: TypeAlias = dict[PrefixPair, AdjacencyDict[TripleType]] def exclude_prefix_stratified_many_to_many( triples: Iterable[TripleType], *, progress: bool = False ) -> Iterable[TripleType]: """Exclude prefix pair-stratified many-to-many relationships. .. warning:: This function does not consider the predicate, so if you only want to make this operation based on specific predicate, then pre-group your triples based on predicate. :param triples: An iterable of triples :param progress: Whether to show a progress bar :returns: An iterable of triples .. warning:: This operation fully consumes the iterator since it requires two passes """ triples = list(triples) exclusion = get_prefix_stratified_many_to_many(triples) return exclude_triples(triples, exclusion, progress=progress) def get_prefix_stratified_many_to_many(triples: Iterable[TripleType]) -> set[TripleType]: """Get many-to-many relationships.""" forward, backward = get_prefix_pair_stratified_indexes(triples) forward_sliced = get_one_to_many(forward) backwards_sliced_flipped = flip_prefix_pair_stratified_index(get_one_to_many(backward)) rv: set[TripleType] = set() for prefix_pair, forward_adjacency_dict in forward_sliced.items(): if backward_adjacency_dict := backwards_sliced_flipped.get(prefix_pair): rv.update(_compare(forward_adjacency_dict, backward_adjacency_dict)) return rv def get_prefix_pair_stratified_indexes( triples: Iterable[TripleType], ) -> tuple[PrefixPairStratifiedIndex[TripleType], PrefixPairStratifiedIndex[TripleType]]: """Get a forward and backwards subject/object index. :param triples: An iterable of triples :returns: A pair of forward and backwards indexes, where: - A forward many-to-many index is a triply-nested dictionary from subject/predicate prefix pair to subject identifier to object identifier to list of triples. - A backward many-to-many index is a triply-nested dictionary from subject/predicate prefix pair to object identifier to subject identifier to list of triples. """ # forward index f: _DD[TripleType] = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) # backward index b: _DD[TripleType] = defaultdict(lambda: defaultdict(lambda: defaultdict(list))) for t in triples: f[t.subject.prefix, t.object.prefix][t.subject.identifier][t.object.identifier].append(t) b[t.object.prefix, t.subject.prefix][t.object.identifier][t.subject.identifier].append(t) return _downgrade_defaultdict(f), _downgrade_defaultdict(b) _DD = defaultdict[PrefixPair, defaultdict[str, defaultdict[str, list[TripleType]]]] def _downgrade_defaultdict(dd: _DD[TripleType]) -> PrefixPairStratifiedIndex[TripleType]: return {k1: {k2: dict(v2) for k2, v2 in v1.items()} for k1, v1 in dd.items()} def get_one_to_many( index: PrefixPairStratifiedIndex[TripleType], ) -> PrefixPairStratifiedIndex[TripleType]: """Filter an index to entities in each prefix pair with a one-to-many relationship.""" rv = {} for pair, inner in index.items(): filtered_inner = {k: v for k, v in inner.items() if len(v) > 1} if filtered_inner: rv[pair] = filtered_inner return rv def flip_prefix_pair_stratified_index( index: PrefixPairStratifiedIndex[TripleType], ) -> PrefixPairStratifiedIndex[TripleType]: """Flip a one-to-many relationship index to a many-to-one relationship index.""" rv = {} for (left, right), adjacency_dict in index.items(): flipped_adjacency_dict: defaultdict[str, dict[str, list[TripleType]]] = defaultdict(dict) for left_id, inner_dict in adjacency_dict.items(): for right_id, triples in inner_dict.items(): flipped_adjacency_dict[right_id][left_id] = triples rv[right, left] = {k: v for k, v in flipped_adjacency_dict.items() if len(v) > 1} return rv def _compare( left_adjacency_dict: AdjacencyDict[TripleType], right_adjacency_dict: AdjacencyDict[TripleType] ) -> set[TripleType]: rv = set() keys = set(left_adjacency_dict.keys()) & set(right_adjacency_dict.keys()) for key in keys: inner_keys = set(left_adjacency_dict[key]) & set(right_adjacency_dict[key]) for inner_key in inner_keys: rv.update(left_adjacency_dict[key][inner_key]) return rv #: A simple index from reference to references. This can #: either be subject to objects, or object to subjects, #: depending on the implementation. ReferenceIndex = dict[Reference, set[Reference]] def get_reference_indexes(triples: Iterable[TripleType]) -> tuple[ReferenceIndex, ReferenceIndex]: """Get simple entity indexes.""" forward = defaultdict(set) backward = defaultdict(set) for triple in triples: forward[triple.subject].add(triple.object) backward[triple.object].add(triple.subject) return dict(forward), dict(backward) biopragmatics-curies-aa8bd37/src/curies/utils.py000066400000000000000000000016051525104130300221100ustar00rootroot00000000000000"""Utilities for working with strings.""" from __future__ import annotations __all__ = [ "NoCURIEDelimiterError", "_split", ] class NoCURIEDelimiterError(ValueError): """An error thrown on a string with no delimiter.""" def __init__(self, curie: str): """Initialize the error.""" self.curie = curie def __str__(self) -> str: return f"{self.curie} does not appear to be a CURIE - missing a delimiter" def _split(curie: str, *, sep: str = ":") -> tuple[str, str]: """Split a CURIE string using string operations.""" prefix, delimiter, identifier = curie.partition(sep) if not delimiter: raise NoCURIEDelimiterError(curie) return prefix, identifier def _prefix_from_curie(curie: str, *, sep: str = ":") -> str: """Split a CURIE string using string operations and return the prefix.""" return _split(curie, sep=sep)[0] biopragmatics-curies-aa8bd37/src/curies/version.py000066400000000000000000000015721525104130300224400ustar00rootroot00000000000000"""Version information for :mod:`curies`.""" import os from subprocess import CalledProcessError, check_output __all__ = [ "VERSION", "get_version", ] VERSION = "0.15.1" def get_git_hash() -> str: """Get the :mod:`curies` git hash.""" with open(os.devnull, "w") as devnull: try: ret = check_output( ["git", "rev-parse", "HEAD"], cwd=os.path.dirname(__file__), stderr=devnull, ) except CalledProcessError: return "UNHASHED" else: return ret.strip().decode("utf-8")[:8] def get_version(with_git_hash: bool = False) -> str: """Get the :mod:`curies` version string, including a git hash.""" return f"{VERSION}-{get_git_hash()}" if with_git_hash else VERSION if __name__ == "__main__": print(get_version(with_git_hash=True)) # noqa:T201 biopragmatics-curies-aa8bd37/src/curies/vocabulary.py000066400000000000000000000410041525104130300231140ustar00rootroot00000000000000"""A module containing pre-defined references.""" from __future__ import annotations import datetime from collections.abc import Callable, Sequence from typing import Literal, TypeAlias from pydantic import AnyUrl from .api import NamedReference, Prefix, Reference def _r(p: str, i: str, n: str) -> NamedReference: return NamedReference(prefix=Prefix(p), identifier=i, name=n) # RDF Schema has_label = _r("rdfs", "label", "has label") see_also = _r("rdfs", "seeAlso", "see also") has_comment = _r("rdfs", "comment", "comment") is_a = subclass_of = _r("rdfs", "subClassOf", "subclass of") subproperty_of = _r("rdfs", "subPropertyOf", "subproperty of") rdf_type = _r("rdf", "type", "instance of") # XML Schema, see https://www.w3.org/TR/xmlschema11-2/ xsd_string = _r("xsd", "string", "string") xsd_double = _r("xsd", "double", "double-precision 64-bit floating point datatype") xsd_float = _r("xsd", "float", "float") xsd_decimal = _r("xsd", "decimal", "decimal") xsd_integer = _r("xsd", "integer", "integer") xsd_boolean = _r("xsd", "boolean", "boolean") xsd_day = _r("xsd", "gDay", "day") xsd_month = _r("xsd", "gMonth", "month") xsd_month_day = _r("xsd", "gMonthDay", "month and day") xsd_year = _r("xsd", "gYear", "year") xsd_year_month = _r("xsd", "gYearMonth", "year and month") xsd_uri = _r("xsd", "anyURI", "URI") xsd_date = _r("xsd", "date", "date") xsd_datetime = _r("xsd", "dateTime", "datetime") xsd_datetimestamp = _r("xsd", "dateTimeStamp", "datetime stamp") xsd_qname = _r("xsd", "QName", "qualified name") xsd_ncname = _r("xsd", "NCName", "non-colonized name") #: A set of common XSD type (note, not exhaustive) XSD_TYPES = { xsd_string, xsd_double, xsd_float, xsd_decimal, xsd_integer, xsd_boolean, xsd_day, xsd_month, xsd_month_day, xsd_year, xsd_year_month, xsd_uri, xsd_date, xsd_datetime, xsd_datetimestamp, xsd_qname, xsd_ncname, } def _parse_xsd_boolean_str(v: str) -> bool: if v == "true": return True elif v == "false": return False else: raise ValueError(f"value is not an XSD-conformant boolean: {v}") #: A union type for everything that can be parsed based on XSD XSDPrimitive: TypeAlias = str | float | int | datetime.datetime | datetime.date | bool | AnyUrl #: A map of XSD references to parser functions XSD_TO_PARSER: dict[Reference, Callable[[str], XSDPrimitive]] = { xsd_string: str, xsd_double: float, xsd_float: float, xsd_integer: int, xsd_uri: AnyUrl, # TODO check if pydantic is actually implementing URIs here xsd_date: datetime.date.fromisoformat, xsd_datetime: datetime.datetime.fromisoformat, xsd_boolean: _parse_xsd_boolean_str, } def parse_xsd(value: str, datatype: Reference) -> XSDPrimitive: """Parse an XSD-conformant string into a primitive type. :param value: The XSD-conformant string to parse :param datatype: A reference to an XSD term, must be inside :data:`XSD_TO_PARSER` :returns: An integer, float, date, datetime, boolean, URL, or string, depending on the datatype """ parser = XSD_TO_PARSER[datatype] return parser(value) # DC Terms has_description = _r("dcterms", "description", "description") has_license = _r("dcterms", "license", "license") has_title = _r("dcterms", "title", "title") has_contributor = _r("dcterms", "contributor", "contributor") has_creator = _r("dcterms", "creator", "creator") has_source = _r("dcterms", "source", "source") has_date = _r("dcterms", "date", "date") conforms_to = _r("dcterms", "conformsTo", "conforms to") # PAV has_author = _r("pav", "authoredBy", "authored by") # OBO in OWL obo_in_subset = _r("oboInOwl", "inSubset", "in subset") obo_has_namespace = _r("oboInOwl", "hasOBONamespace", "has OBO namespace") obo_autogenerated_by = _r("oboInOwl", "auto-generated-by", "auto-generated by") obo_creation_date = _r("oboInOwl", "creation_date", "creation date") obo_consider = _r("oboInOwl", "consider", "consider") obo_is_anonymous = _r("oboInOwl", "is_anonymous", "is anonymous") obo_builtin = _r("oboInOwl", "builtin", "built-in") obo_is_class_level = _r("oboInOwl", "is_class_level", "is class level") obo_is_cyclic = _r("oboInOwl", "is_cyclic", "is cyclic") # OWL owl_thing = _r("owl", "Thing", "thing") owl_deprecated = _r("owl", "deprecated", "is deprecated") owl_version_info = _r("owl", "versionInfo", "has version") owl_inverse_of = _r("owl", "inverseOf", "inverse of") owl_different_from = _r("owl", "differentFrom", "different from") owl_disjoint_with = _r("owl", "disjointWith", "disjoint with") owl_property_disjoint_with = _r("owl", "propertyDisjointWith", "property disjoint with") owl_complement_of = _r("owl", "complementOf", "complement of") owl_class = _r("owl", "Class", "class") owl_object_property = _r("owl", "ObjectProperty", "object property") owl_data_property = _r("owl", "DataProperty", "data property") owl_annotation_property = _r("owl", "AnnotationProperty", "annotation property") owl_named_individual = _r("owl", "NamedIndividual", "named individual") # SKOS skos_concept = _r("skos", "Concept", "concept") # RDFS rdfs_resource = _r("rdfs", "Resource", "resource") rdfs_class = _r("rdfs", "Class", "class") rdfs_literal = _r("rdfs", "Literal", "literal") rdfs_datatype = _r("rdfs", "Datatype", "datatype") rdfs_property = _r("rdfs", "Property", "property") # SSSOM composed_entity_expression = _r("sssom", "ComposedEntityExpression", "composed entity expression") # IAO has_ontology_root_term = _r("IAO", "0000700", "has ontology root term") has_term_editor = _r("IAO", "0000117", "term editor") has_obsolescence_reason = _r("IAO", "0000231", "has obsolescence reason") term_tracker_item = _r("IAO", "0000233", "term tracker item") # Document parts title_section = _r("IAO", "0000651", "title section") abstract_section = _r("IAO", "0000315", "abstract") #: A set of document parts that are subclasses of IAO:0000314 DOCUMENT_PARTS = { title_section, abstract_section, } # Synonyms ## Synonym Relations #: The parent property for having synonyms, see :data:`synonym_scopes` #: for a dictionary of all synonym types has_synonym = _r("oboInOwl", "hasSynonym", "has synonym") has_synonym_type = _r("oboInOwl", "hasSynonymType", "has synonym type") has_exact_synonym = _r("oboInOwl", "hasExactSynonym", "has exact synonym") has_narrow_synonym = _r("oboInOwl", "hasNarrowSynonym", "has narrow synonym") has_broad_synonym = _r("oboInOwl", "hasBroadSynonym", "has broad synonym") has_related_synonym = _r("oboInOwl", "hasRelatedSynonym", "has related synonym") #: A list of strings used to refer to synonym types in ``oboInOwl`` SynonymScope: TypeAlias = Literal["EXACT", "NARROW", "BROAD", "RELATED"] #: This is what happens if no specificity is given DEFAULT_SYNONYM_SCOPE: SynonymScope = "RELATED" # OBO Graph JSON uses the LUIDs from OBO in OWL, so we # keep track of these as well SynonymScopeOIO: TypeAlias = Literal[ "hasExactSynonym", "hasNarrowSynonym", "hasBroadSynonym", "hasRelatedSynonym", ] DEFAULT_SYNONYM_SCOPE_OIO: SynonymScopeOIO = "hasRelatedSynonym" #: A mapping from synonym scopes to references synonym_scopes: dict[SynonymScope, NamedReference] = { "EXACT": has_exact_synonym, "NARROW": has_narrow_synonym, "BROAD": has_broad_synonym, "RELATED": has_related_synonym, } synonym_scope_to_oio: dict[SynonymScope, SynonymScopeOIO] = { "EXACT": "hasExactSynonym", "NARROW": "hasNarrowSynonym", "BROAD": "hasBroadSynonym", "RELATED": "hasRelatedSynonym", } ## OMO Synonym Types has_ontology_hierarchical_property = _r("OMO", "0003014", "has ontology hierarchical property") #: The parent class for all synonym types, see :data:`synonym_types` #: for the set of all synonym types synonym_type = _r("oboInOwl", "SynonymType", "synonym type") abbreviation = _r("OMO", "0003000", "abbreviation") ambiguous_synonym = _r("OMO", "0003001", "ambiguous synonym") dubious_synonym = _r("OMO", "0003002", "dubious synonym") layperson_synonym = _r("OMO", "0003003", "layperson synonym") plural_form = _r("OMO", "0003004", "plural form") uk_spelling = _r("OMO", "0003005", "UK spelling synonym") misspelling = _r("OMO", "0003006", "misspelling") misnomer = _r("OMO", "0003007", "misnomer") previous_name = _r("OMO", "0003008", "previous name") legal_name = _r("OMO", "0003009", "legal name") inn = _r("OMO", "0003010", "International Nonproprietary Name") latin_term = _r("OMO", "0003011", "latin term") acronym = _r("OMO", "0003012", "acronym") #: Provisional, see https://github.com/information-artifact-ontology/ontology-metadata/pull/162/files brand_name = _r("OMO", "0003013", "brand name") #: A set of synonym types from OMO synonym_types: set[NamedReference] = { abbreviation, ambiguous_synonym, dubious_synonym, layperson_synonym, plural_form, uk_spelling, misspelling, misnomer, previous_name, legal_name, inn, latin_term, acronym, brand_name, } # LinkML #: See https://github.com/mapping-commons/sssom/pull/560 linkml_uri_or_curie = _r("linkml", "Uriorcurie", "URI or CURIE") # Semantic Mappings ## Mapping Relations #: The parent property for semantic mappings from ``skos``, see :data:`semantic_mapping_scopes` #: for a list of all usable properties match = _r("skos", "mappingRelation", "is in mapping relation with") has_dbxref = _r("oboInOwl", "hasDbXref", "has database cross-reference") same_as = _r("owl", "sameAs", "same as") equivalent_class = _r("owl", "equivalentClass", "equivalent class") equivalent_property = _r("owl", "equivalentProperty", "equivalent property") exact_match = _r("skos", "exactMatch", "exact match") narrow_match = _r("skos", "narrowMatch", "narrow match") broad_match = _r("skos", "broadMatch", "broad match") close_match = _r("skos", "closeMatch", "close match") related_match = _r("skos", "relatedMatch", "related match") editor_note = _r("IAO", "0000116", "editor note") # `alternate term` and `term replaced by` can also be considered as mapping relations alternative_term = _r("IAO", "0000118", "alternative term") term_replaced_by = _r("IAO", "0100001", "term replaced by") cross_species_exact = _r("semapv", "crossSpeciesExactMatch", "cross-species exact match") cross_species_narrow = _r("semapv", "crossSpeciesNarrowMatch", "cross-species narrow match") cross_species_broad = _r("semapv", "crossSpeciesBroadMatch", "cross-species broad match") #: A list of strings used to refer to mapping types in ``skos`` SemanticMappingScope: TypeAlias = Literal["EXACT", "NARROW", "BROAD", "CLOSE", "RELATED"] #: A mapping from mapping types to references semantic_mapping_scopes: dict[SemanticMappingScope, NamedReference] = { "EXACT": exact_match, "NARROW": narrow_match, "BROAD": broad_match, "CLOSE": close_match, "RELATED": related_match, } ## Matching Process Types #: The parent class for matching processes, see :data:`matching_processes` #: for the set of all matching processes. matching_process = _r("semapv", "Matching", "matching process") background_knowledge_based_matching_process = _r( "semapv", "BackgroundKnowledgeBasedMatching", "background knowledge-based matching process", ) composite_matching_process = _r("semapv", "CompositeMatching", "composite matching process") instance_based_matching_process = _r( "semapv", "InstanceBasedMatching", "instance-based matching process" ) lexical_matching_process = _r("semapv", "LexicalMatching", "lexical matching process") lexical_similarity_threshold_based_matching_process = _r( "semapv", "LexicalSimilarityThresholdMatching", "lexical similarity threshold-based matching process", ) logical_reasoning_matching_process = _r( "semapv", "LogicalReasoning", "logical reasoning matching process" ) manual_mapping_curation = _r("semapv", "ManualMappingCuration", "manual mapping curation") mapping_chaining = _r("semapv", "MappingChaining", "mapping chaining-based matching process") mapping_inversion = _r("semapv", "MappingInversion", "mapping inversion-based matching process") semantic_similarity = _r( "semapv", "SemanticSimilarityThresholdMatching", "semantic similarity threshold-based matching process", ) structural_matching = _r("semapv", "StructuralMatching", "structural matching process") unspecified_matching_process = _r("semapv", "UnspecifiedMatching", "unspecified matching process") #: A set of matching types from ``semapv`` matching_processes: set[NamedReference] = { background_knowledge_based_matching_process, composite_matching_process, instance_based_matching_process, lexical_matching_process, lexical_similarity_threshold_based_matching_process, logical_reasoning_matching_process, manual_mapping_curation, mapping_chaining, mapping_inversion, semantic_similarity, structural_matching, unspecified_matching_process, } #: See https://mapping-commons.github.io/sssom/spec-model/ match_typedefs: Sequence[NamedReference] = ( broad_match, close_match, exact_match, narrow_match, related_match, same_as, # for instances equivalent_class, # for classes equivalent_property, # for properties owl_disjoint_with, # for classes owl_property_disjoint_with, # for properties owl_different_from, # for instances owl_complement_of, # for classes owl_inverse_of, # for properties has_dbxref, see_also, subclass_of, subproperty_of, cross_species_broad, cross_species_exact, cross_species_narrow, ) # Extension past the SSSOM spec extended_match_typedefs: Sequence[NamedReference] = ( *match_typedefs, alternative_term, term_replaced_by, ) # Classes human = _r("NCBITaxon", "9606", "Homo sapiens") # Individuals #: the author of this package. It's useful to have this reference pre-defined #: for testing purposes charlie = _r("orcid", "0000-0003-4423-4370", "Charles Tapley Hoyt") part_of = _r("BFO", "0000050", "part of") has_part = _r("BFO", "0000051", "has part") located_in = _r("RO", "0001025", "located in") location_of = _r("RO", "0001015", "location of") enables = _r("RO", "0002327", "enables") enabled_by = _r("RO", "0002333", "enabled by") involved_in = _r("RO", "0002331", "involved in") colocalizes_with = _r("RO", "0002325", "colocalizes with") acts_upstream_of = _r("RO", "0002263", "acts upstream of") is_active_in = _r("RO", "0002432", "is active in") contributes_to = _r("RO", "0002326", "contributes to") acts_upstream_of_or_within = _r("RO", "0002264", "acts upstream of or within") acts_upstream_of_or_within_positive_effect = _r( "RO", "0004032", "acts upstream of or within, positive effect" ) acts_upstream_of_or_within_negative_effect = _r( "RO", "0004033", "acts upstream of or within, negative effect" ) acts_upstream_of_positive_effect = _r("RO", "0004034", "acts upstream of, positive effect") acts_upstream_of_negative_effect = _r("RO", "0004035", "acts upstream of, negative effect") derives_from = _r("RO", "0002202", "derives from") derives_into = _r("RO", "0001001", "derives into") contained_in = _r("RO", "0001018", "contained in") molecularly_interacts_with = _r("ro", "0002436", "molecularly interacts with") regulator_of = _r("RO", "0012004", "is small molecule regulator of") inhibitor_of = _r("RO", "0012006", "is small molecule inhibitor of") is_model_of = _r("RO", "0003301", "is model of") is_marker_for = _r("RO", "0002607", "is marker for") orthologous_to = _r("RO", "HOM0000017", "in orthology relationship with") deprotonated_form_of = _r("RO", "0018033", "is deprotonated form of") protonated_form_of = _r("RO", "0018034", "is protonated form of") has_suborganization = _r("org", "hasSubOrganization", "has suborganization") is_suborganization_of = _r("org", "subOrganizationOf", "is suborganization of") #: A mapping of inverse relationships that can be applied when inverting mappings inversions: dict[Reference, NamedReference] = { broad_match: narrow_match, narrow_match: broad_match, exact_match: exact_match, close_match: close_match, has_dbxref: has_dbxref, equivalent_class: equivalent_class, equivalent_property: equivalent_property, same_as: same_as, cross_species_exact: cross_species_exact, cross_species_narrow: cross_species_broad, cross_species_broad: cross_species_narrow, deprotonated_form_of: protonated_form_of, protonated_form_of: deprotonated_form_of, orthologous_to: orthologous_to, part_of: has_part, has_part: part_of, has_suborganization: is_suborganization_of, is_suborganization_of: has_suborganization, derives_from: derives_into, derives_into: derives_from, located_in: location_of, location_of: located_in, enabled_by: enables, enables: enabled_by, } semantic_mapping_inversions = inversions biopragmatics-curies-aa8bd37/src/curies/w3c.py000066400000000000000000000123621525104130300214460ustar00rootroot00000000000000"""Validation based on W3C standards. The Worldwide Web Consortium (W3C) provides standards for `prefixes `_ (i.e., ``NCName``), `CURIEs `_, and `IRIs `_, but they are highly obfuscated and spread across many documents. This module attempts to operationalize these standards, along with best practices of documentation and testing. .. seealso:: Some other work towards operationalizing these standards: - https://github.com/linkml/linkml-runtime/blob/main/linkml_runtime/utils/uri_validator.py - https://github.com/dgerber/rfc3987/blob/gh-archived/rfc3987.py """ import re __all__ = [ "CURIE_PATTERN", "CURIE_RE", "LOCAL_UNIQUE_IDENTIFIER_PATTERN", "LOCAL_UNIQUE_IDENTIFIER_RE", "NCNAME_PATTERN", "NCNAME_RE", "is_w3c_curie", "is_w3c_prefix", ] NCNAME_PATTERN = r"[A-Za-z_][A-Za-z0-9\.\-_]*" """A regex for prefixes, from https://www.w3.org/TR/1999/REC-xml-names-19990114/#NT-NCName. .. code-block:: prefix ::= NCName NCName ::= (Letter | '_') (NCNameChar)* NCNameChar ::= Letter | Digit | '.' | '-' | '_' """ NCNAME_RE = re.compile(f"^{NCNAME_PATTERN}$") LOCAL_UNIQUE_IDENTIFIER_PATTERN = r"(/[^\s/][^\s]*|[^\s/][^\s]*|[^\s]?)" """A regex for local unique identifiers in CURIEs, based on https://www.ietf.org/rfc/rfc3987.txt This pattern was adapted from https://gist.github.com/niklasl/2506955, which sort of implements RFC3987, """ LOCAL_UNIQUE_IDENTIFIER_RE = re.compile(LOCAL_UNIQUE_IDENTIFIER_PATTERN) CURIE_PATTERN = rf"^({NCNAME_PATTERN}?:)?{LOCAL_UNIQUE_IDENTIFIER_PATTERN}$" """A regex for CURIEs, based on https://www.w3.org/TR/2010/NOTE-curie-20101216. .. code-block:: curie := [ [ prefix ] ':' ] reference prefix := NCName reference := irelative-ref (as defined in `IRI `_) `irelative-ref` is defined/documented in :data:`curies.w3c.LOCAL_UNIQUE_IDENTIFIER_PATTERN`. """ CURIE_RE = re.compile(CURIE_PATTERN) def is_w3c_prefix(prefix: str) -> bool: """Return if the string is a valid prefix under the W3C specification. :param prefix: A string :returns: If the string is a valid prefix under the W3C specification. Validation is implemented as a regular expression match against :data:`curies.w3c.NCNAME_PATTERN`, as defined by the W3C `here `_. Examples -------- Strings containig numbers, letters, and underscores are valid prefixes. >>> is_w3c_prefix("GO") True The W3C specification states that the prefix '_' is reserved for use by languages that support RDF. For this reason, the prefix '_' SHOULD be avoided by authors. >>> is_w3c_prefix("_") True Strings starting with a number are not valid prefixes. >>> is_w3c_prefix("3dmet") Strings containing a colon or other characters are invalid >>> is_w3c_prefix("GO:") False """ return bool(NCNAME_RE.match(prefix)) def _is_w3c_luid(luid: str) -> bool: return bool(LOCAL_UNIQUE_IDENTIFIER_RE.match(luid)) def is_w3c_curie(curie: str) -> bool: """Return if the string is a valid CURIE under the W3C specification. :param curie: A string to check if it is a valid CURIE under the W3C specification. :returns: True if the string is a valid CURIE under the W3C specification. .. warning:: This is slightly different from the :meth:`curies.Converter.is_curie` function, which checks if a given CURIE is valid under the extended prefix map contained within the converter. Further, the base converter is slightly more lenient than the W3C specification by default to allow for the inclusion of CURIEs, e.g., for SMILES strings like ``smiles:CC(=O)NC([H])(C)C(=O)O``. These are useful, but not technically valid due to their inclusion of brackets. Examples -------- If no prefix is given, the host language chooses how to assign a default prefix. >>> is_w3c_curie(":test") True From the specification, regarding using an underscore as the prefix The CURIE prefix '_' is reserved for use by languages that support RDF. For this reason, the prefix '_' SHOULD be avoided by authors. >>> is_w3c_curie("_:test") True This is invalid because a CURIE prefix isn't allowed to start with a number. It has to start with either a letter, or an underscore. >>> is_w3c_curie("4cdn:test") False Empty strings are explicitly noted as being invalid. >>> is_w3c_curie("") False """ if "[" in curie or "]" in curie: return False # empty curie is invalid (for now) if not curie.strip(): return False # if there's no colon, then validate the whole thing against the LUID pattern. # this is because prefix, sep, identifier = curie.partition(":") if not sep: return _is_w3c_luid(curie) # it's okay for there to be no prefix in a CURIE, even though # the NCName definition is not itself allowed to be empty if not prefix: return _is_w3c_luid(identifier) return is_w3c_prefix(prefix) and _is_w3c_luid(identifier) biopragmatics-curies-aa8bd37/tests/000077500000000000000000000000001525104130300174555ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/tests/__init__.py000066400000000000000000000000371525104130300215660ustar00rootroot00000000000000"""Tests for :mod:`curies`.""" biopragmatics-curies-aa8bd37/tests/constants.py000066400000000000000000000002021525104130300220350ustar00rootroot00000000000000"""Constants for testing.""" import unittest RUN_SLOW = True SLOW = unittest.skipUnless(RUN_SLOW, reason="Skipping slow tests") biopragmatics-curies-aa8bd37/tests/resources/000077500000000000000000000000001525104130300214675ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/tests/resources/invalid_curies.txt000066400000000000000000000000771525104130300252340ustar00rootroot00000000000000 smiles:CC(=O)NC([H])(C)C(=O)O 4cdn:test #nope:nope ?nope:nope biopragmatics-curies-aa8bd37/tests/resources/invalid_prefixes.txt000066400000000000000000000000421525104130300255570ustar00rootroot00000000000000 4dn GO:GO: GO: #nope $ # : :luid biopragmatics-curies-aa8bd37/tests/resources/valid_curies.txt000066400000000000000000000002371525104130300247030ustar00rootroot00000000000000pfx:abc : pfx: abc :abc _:abc pfx:/abc pfx:/ :/ bioregistry:bioregistry GO:0000012 go:0123456 home:#start joseki: google:xforms+or+'xml+forms' isbn:0321154991 biopragmatics-curies-aa8bd37/tests/resources/valid_prefixes.txt000066400000000000000000000001001525104130300252230ustar00rootroot00000000000000go GO NCBITaxon ncbi.taxon ncbi_taxon _ _secret secret_ _secret biopragmatics-curies-aa8bd37/tests/test_api.py000066400000000000000000001523631525104130300216510ustar00rootroot00000000000000"""Trivial version test.""" from __future__ import annotations import datetime import json import tempfile import unittest from pathlib import Path from tempfile import TemporaryDirectory from typing import Literal, overload import httpx import pandas as pd import rdflib from pydantic import AnyUrl from rdflib import URIRef import curies from curies import vocabulary as v from curies.api import ( CompressionError, Converter, CURIEStandardizationError, DuplicatePrefixes, DuplicateURIPrefixes, ExpansionError, PrefixStandardizationError, Record, Reference, ReferenceTuple, URIStandardizationError, URIType, chain, upgrade_prefix_map, ) from curies.sources import ( BIOREGISTRY_CONTEXTS, get_bioregistry_converter, get_go_converter, get_monarch_converter, get_obo_converter, ) from curies.version import get_version from curies.vocabulary import parse_xsd from tests.constants import SLOW CHEBI_URI_PREFIX = "http://purl.obolibrary.org/obo/CHEBI_" GO_URI_PREFIX = "http://purl.obolibrary.org/obo/GO_" class TestAddRecord(unittest.TestCase): """Test adding records.""" def setUp(self) -> None: """Set up the test case.""" self.prefix = "CHEBI" self.uri_prefix = CHEBI_URI_PREFIX self.prefix_synonym = "p" self.uri_prefix_synonym = "u" self.converter = Converter.from_extended_prefix_map( [ { "prefix": self.prefix, "prefix_synonyms": [self.prefix_synonym], "uri_prefix": self.uri_prefix, "uri_prefix_synonyms": [self.uri_prefix_synonym], }, ] ) def test_duplicate_failure(self) -> None: """Test failure caused by double matching.""" self.converter.add_prefix("GO", GO_URI_PREFIX) with self.assertRaises(ValueError): self.converter.add_record(Record(prefix="GO", uri_prefix=CHEBI_URI_PREFIX)) def test_get_prefix_synonyms(self) -> None: """Test getting prefix synonyms.""" self.assertEqual({self.prefix}, self.converter.get_prefixes()) self.assertEqual({self.prefix}, self.converter.get_prefixes(include_synonyms=False)) self.assertEqual( {self.prefix, self.prefix_synonym}, self.converter.get_prefixes(include_synonyms=True), ) def test_get_uri_prefix_synonyms(self) -> None: """Test getting URI prefix synonyms.""" self.assertEqual({self.uri_prefix}, self.converter.get_uri_prefixes()) self.assertEqual({self.uri_prefix}, self.converter.get_uri_prefixes(include_synonyms=False)) self.assertEqual( {self.uri_prefix, self.uri_prefix_synonym}, self.converter.get_uri_prefixes(include_synonyms=True), ) def test_extend_on_prefix_match(self) -> None: """Test adding a new prefix in merge mode.""" s1, s2, s3 = "s1", "s2", "s3" for record in [ Record( prefix="CHEBI", prefix_synonyms=[s1], uri_prefix=s2, uri_prefix_synonyms=[s3], ), Record( prefix=s1, prefix_synonyms=["CHEBI"], uri_prefix=s2, uri_prefix_synonyms=[s3], ), ]: with self.assertRaises(ValueError): self.converter.add_record(record, merge=False) self.converter.add_record(record, merge=True) self.assertEqual(1, len(self.converter.records)) record = self.converter.records[0] self.assertEqual("CHEBI", record.prefix) self.assertEqual({s1, self.prefix_synonym}, set(record.prefix_synonyms)) self.assertEqual(CHEBI_URI_PREFIX, record.uri_prefix) self.assertEqual({s2, s3, self.uri_prefix_synonym}, set(record.uri_prefix_synonyms)) def test_extend_on_uri_prefix_match(self) -> None: """Test adding a new prefix in merge mode.""" s1, s2, s3 = "s1", "s2", "s3" for record in [ Record( prefix=s1, prefix_synonyms=[s3], uri_prefix=s2, uri_prefix_synonyms=[CHEBI_URI_PREFIX], ), Record( prefix=s1, prefix_synonyms=[s3], uri_prefix=CHEBI_URI_PREFIX, uri_prefix_synonyms=[s2], ), ]: with self.assertRaises(ValueError): self.converter.add_record(record, merge=False) self.converter.add_record(record, merge=True) self.assertEqual(1, len(self.converter.records)) record = self.converter.records[0] self.assertEqual("CHEBI", record.prefix) self.assertEqual({s1, s3, self.prefix_synonym}, set(record.prefix_synonyms)) self.assertEqual(CHEBI_URI_PREFIX, record.uri_prefix) self.assertEqual({s2, self.uri_prefix_synonym}, set(record.uri_prefix_synonyms)) def test_extend_on_prefix_synonym_match(self) -> None: """Test adding a new prefix in merge mode.""" s1, s2, s3 = "s1", "s2", "s3" for record in [ Record( prefix=self.prefix_synonym, prefix_synonyms=[s1], uri_prefix=s2, uri_prefix_synonyms=[s3], ), Record( prefix=s1, prefix_synonyms=[self.prefix_synonym], uri_prefix=s2, uri_prefix_synonyms=[s3], ), ]: self.converter.add_record(record, merge=True) self.assertEqual(1, len(self.converter.records)) record = self.converter.records[0] self.assertEqual("CHEBI", record.prefix) self.assertEqual({s1, self.prefix_synonym}, set(record.prefix_synonyms)) self.assertEqual(CHEBI_URI_PREFIX, record.uri_prefix) self.assertEqual({s2, s3, self.uri_prefix_synonym}, set(record.uri_prefix_synonyms)) def test_extend_on_uri_prefix_synonym_match(self) -> None: """Test adding a new prefix in merge mode.""" s1, s2, s3 = "s1", "s2", "s3" for record in [ Record( prefix=s1, prefix_synonyms=[s2], uri_prefix=self.uri_prefix_synonym, uri_prefix_synonyms=[s3], ), Record( prefix=s1, prefix_synonyms=[s2], uri_prefix=s3, uri_prefix_synonyms=[self.uri_prefix_synonym], ), ]: self.converter.add_record(record, merge=True) self.assertEqual(1, len(self.converter.records)) record = self.converter.records[0] self.assertEqual("CHEBI", record.prefix) self.assertEqual({s1, s2, self.prefix_synonym}, set(record.prefix_synonyms)) self.assertEqual(CHEBI_URI_PREFIX, record.uri_prefix) self.assertEqual({s3, self.uri_prefix_synonym}, set(record.uri_prefix_synonyms)) def test_extend_on_prefix_match_ci(self) -> None: """Test adding a new prefix in merge mode.""" s1, s2, s3 = "s1", "s2", "s3" record = Record( prefix="chebi", prefix_synonyms=[s1], uri_prefix=s2, uri_prefix_synonyms=[s3] ) self.converter.add_record(record, case_sensitive=False, merge=True) self.assertEqual(1, len(self.converter.records)) record = self.converter.records[0] self.assertEqual("CHEBI", record.prefix) self.assertEqual({"chebi", s1, self.prefix_synonym}, set(record.prefix_synonyms)) self.assertEqual(CHEBI_URI_PREFIX, record.uri_prefix) self.assertEqual({s2, s3, self.uri_prefix_synonym}, set(record.uri_prefix_synonyms)) class TestConverter(unittest.TestCase): """Test the converter class.""" def setUp(self) -> None: """Set up the converter test case.""" self.simple_obo_prefix_map = { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", "MONDO": "http://purl.obolibrary.org/obo/MONDO_", "GO": "http://purl.obolibrary.org/obo/GO_", "OBO": "http://purl.obolibrary.org/obo/", } self.converter = Converter.from_prefix_map(self.simple_obo_prefix_map) def test_iter(self) -> None: """Test the iterator.""" self.assertEqual(self.converter.records, list(self.converter)) def test_reference_tuple(self) -> None: """Test the reference tuple data type.""" t = ReferenceTuple("chebi", "1234") self.assertEqual("chebi:1234", t.curie) self.assertEqual(t, ReferenceTuple.from_curie("chebi:1234")) def test_reference_pydantic(self) -> None: """Test the reference Pydantic model.""" t = Reference(prefix="chebi", identifier="1234") self.assertEqual("chebi:1234", t.curie) self.assertEqual(t, Reference.from_curie("chebi:1234")) def test_invalid_record(self) -> None: """Test throwing an error for invalid records.""" with self.assertRaises(ValueError): Record( prefix="chebi", uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", prefix_synonyms=["chebi"], ) with self.assertRaises(ValueError): Record( prefix="chebi", uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", uri_prefix_synonyms=["http://purl.obolibrary.org/obo/CHEBI_"], ) def test_invalid_records(self) -> None: """Test throwing an error for duplicated URI prefixes.""" with self.assertRaises(DuplicateURIPrefixes) as e: curies.load_prefix_map( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", "nope": "http://purl.obolibrary.org/obo/CHEBI_", } ) self.assertIsInstance(str(e.exception), str) with self.assertRaises(DuplicatePrefixes) as e2: Converter( [ Record(prefix="chebi", uri_prefix="https://bioregistry.io/chebi:"), Record(prefix="chebi", uri_prefix="http://purl.obolibrary.org/obo/CHEBI_"), ], ) self.assertIsInstance(str(e2.exception), str) # No failure Converter.from_prefix_map( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", "nope": "http://purl.obolibrary.org/obo/CHEBI_", }, strict=False, ) def test_subset(self) -> None: """Test subsetting a converter.""" new_converter = self.converter.get_subconverter(["CHEBI"]) self.assertEqual(1, len(new_converter.records)) self.assertEqual({"CHEBI"}, new_converter.get_prefixes()) self.assertEqual( {"http://purl.obolibrary.org/obo/CHEBI_"}, new_converter.get_uri_prefixes() ) self.assertEqual({"CHEBI"}, set(new_converter.bimap)) self.assertEqual({"CHEBI"}, set(new_converter.prefix_map)) self.assertEqual( {"http://purl.obolibrary.org/obo/CHEBI_"}, set(new_converter.reverse_prefix_map) ) def test_empty_subset(self) -> None: """Test subsetting a converter and getting an empty one back.""" new_converter_2 = self.converter.get_subconverter(["NOPE"]) self.assertEqual(0, len(new_converter_2.records)) def test_predicates(self) -> None: """Add tests for predicates.""" self.assertFalse(self.converter.is_uri("")) self.assertFalse(self.converter.is_uri("nope")) self.assertFalse(self.converter.is_curie("")) self.assertFalse(self.converter.is_curie("nope")) self.assertFalse(self.converter.is_curie(":nope")) self.assertFalse(self.converter.is_curie("nope:")) def test_convert(self) -> None: """Test compression.""" self.assertEqual({"CHEBI", "MONDO", "GO", "OBO"}, self.converter.get_prefixes()) self.assertEqual( { "http://purl.obolibrary.org/obo/CHEBI_", "http://purl.obolibrary.org/obo/MONDO_", "http://purl.obolibrary.org/obo/GO_", "http://purl.obolibrary.org/obo/", }, self.converter.get_uri_prefixes(), ) self._assert_convert(self.converter) def _assert_convert(self, converter: Converter) -> None: self.assertIn("GO", converter.prefix_map) self.assertTrue(converter.has_prefix("GO")) self.assertFalse(converter.has_prefix("nope-nope-nope")) self.assertIn("GO", converter.bimap) self.assertIn("GO", converter.reverse_bimap.values()) self.assertIn("http://purl.obolibrary.org/obo/GO_", converter.reverse_prefix_map) self.assertIn("http://purl.obolibrary.org/obo/GO_", converter.reverse_bimap) self.assertIn("http://purl.obolibrary.org/obo/GO_", converter.trie) self.assertNotIn("http://purl.obolibrary.org/obo/XXX_", converter.trie) self.assertIn("http://purl.obolibrary.org/obo/GO_", converter.bimap.values()) for curie, uri in [ ("CHEBI:1", "http://purl.obolibrary.org/obo/CHEBI_1"), ("OBO:unnamespaced", "http://purl.obolibrary.org/obo/unnamespaced"), ]: self.assertTrue(converter.is_uri(uri)) self.assertTrue(converter.is_curie(curie)) self.assertFalse(converter.is_curie(uri)) self.assertFalse(converter.is_uri(curie)) self.assertEqual(curie, converter.compress(uri)) self.assertEqual(curie, converter.compress_strict(uri)) self.assertEqual(uri, converter.expand(curie)) self.assertEqual(uri, converter.expand_strict(curie)) self.assertIsNone(converter.compress("http://example.org/missing:00000")) self.assertEqual( "http://example.org/missing:00000", converter.compress("http://example.org/missing:00000", passthrough=True), ) with self.assertRaises(CompressionError): converter.compress_strict("http://example.org/missing:00000") self.assertIsNone(converter.expand("missing:00000")) self.assertEqual("missing:00000", converter.expand("missing:00000", passthrough=True)) with self.assertRaises(ExpansionError): converter.expand_strict("missing:00000") self.assertLess(0, len(converter.records), msg="converter has no records") self.assertIsNone(converter.get_record("nope")) self.assertIsNone(converter.get_record("go"), msg="synonym lookup is not allowed here") record = converter.get_record("GO", strict=True) self.assertIsNotNone(record, msg=f"records: {[r.prefix for r in converter.records]}") self.assertIsInstance(record, Record) self.assertEqual("GO", record.prefix) @SLOW @unittest.skip("problem with bioregistry getter") def test_bioregistry(self) -> None: """Test loading a remote JSON-LD context.""" for web in [True, False]: bioregistry_converter = get_bioregistry_converter(web=web) self.assert_bioregistry_converter(bioregistry_converter) c = Converter.from_reverse_prefix_map(f"{BIOREGISTRY_CONTEXTS}/bioregistry.rpm.json") self.assertIn("chebi", c.prefix_map) self.assertNotIn("CHEBI", c.prefix_map) def test_jsonld(self) -> None: """Test parsing JSON-LD context.""" context = { "@context": { "@version": "1.0.0", # should skip this "": "", # should skip this "hello": "https://example.org/hello:", "CHEBI": { "@prefix": True, "@id": "http://purl.obolibrary.org/CHEBI_", }, "nope": { "nope": "nope", }, }, } converter = Converter.from_jsonld(context) self.assertIn("hello", converter.prefix_map) self.assertIn("CHEBI", converter.prefix_map) @SLOW def test_from_github(self) -> None: """Test getting a JSON-LD map from GitHub.""" with self.assertRaises(ValueError): # missing end .jsonld file Converter.from_jsonld_github("biopragmatics", "bioregistry") semweb_converter = Converter.from_jsonld_github( "biopragmatics", "bioregistry", "exports", "contexts", "semweb.context.jsonld" ) self.assertIn("rdf", semweb_converter.prefix_map) @SLOW def test_obo(self) -> None: """Test the OBO converter.""" obo_converter = get_obo_converter() self.assertIn("CHEBI", obo_converter.prefix_map) self.assertTrue(obo_converter.has_prefix("CHEBI")) self.assertNotIn("chebi", obo_converter.prefix_map) self.assertFalse(obo_converter.has_prefix("chebi")) @SLOW def test_monarch(self) -> None: """Test the Monarch converter.""" monarch_converter = get_monarch_converter() self.assertIn("CHEBI", monarch_converter.prefix_map) self.assertNotIn("chebi", monarch_converter.prefix_map) @SLOW def test_go_registry(self) -> None: """Test the GO registry converter.""" go_converter = get_go_converter() self.assertIn("CHEBI", go_converter.prefix_map) self.assertNotIn("chebi", go_converter.prefix_map) def assert_bioregistry_converter(self, converter: Converter) -> None: """Assert the bioregistry converter has the right stuff in it.""" records = {records.prefix: records for records in converter.records} self.assertIn("chebi", records) record = records["chebi"] self.assertIsInstance(record, Record) self.assertEqual("chebi", record.prefix) self.assertIn("CHEBI", record.prefix_synonyms) self.assertIn("ChEBI", record.prefix_synonyms) self.assertIn("chebi", converter.prefix_map) self.assertIn("chebi", converter.bimap) # Synonyms that are non-conflicting also get added self.assertIn("CHEBI", converter.prefix_map) self.assertNotIn("CHEBI", converter.bimap) chebi_uri = converter.prefix_map["chebi"] self.assertIn(chebi_uri, converter.reverse_prefix_map) self.assertEqual("chebi", converter.reverse_prefix_map[chebi_uri]) self.assertTrue(converter.has_prefix("CHEBI")) self.assertTrue(converter.has_prefix("chebi")) def test_load_path(self) -> None: """Test loading from paths.""" with tempfile.TemporaryDirectory() as directory: path = Path(directory).joinpath("pm.json") with self.assertRaises(FileNotFoundError): Converter.from_prefix_map(path) with self.assertRaises(FileNotFoundError): Converter.from_prefix_map(str(path)) path.write_text(json.dumps(self.converter.prefix_map)) c1 = Converter.from_prefix_map(path) self.assertEqual(self.converter.prefix_map, c1.prefix_map) c2 = Converter.from_prefix_map(str(path)) self.assertEqual(self.converter.prefix_map, c2.prefix_map) def test_reverse_constructor(self) -> None: """Test constructing from a reverse prefix map.""" converter = Converter.from_reverse_prefix_map( { "http://purl.obolibrary.org/obo/CHEBI_": "CHEBI", "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=": "CHEBI", "http://purl.obolibrary.org/obo/MONDO_": "MONDO", } ) self.assertEqual( "http://purl.obolibrary.org/obo/CHEBI_138488", converter.expand("CHEBI:138488") ) self.assertEqual( "CHEBI:138488", converter.compress("http://purl.obolibrary.org/obo/CHEBI_138488") ) self.assertEqual( "CHEBI:138488", converter.compress("https://www.ebi.ac.uk/chebi/searchId.do?chebiId=138488"), ) def test_standardize_curie(self) -> None: """Test standardize CURIE.""" converter = Converter.from_extended_prefix_map( [ Record( prefix="CHEBI", prefix_synonyms=["chebi"], uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", uri_prefix_synonyms=[ "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:", ], ), ] ) self.assertEqual("CHEBI:138488", converter.standardize_curie("chebi:138488")) self.assertEqual("CHEBI:138488", converter.standardize_curie("CHEBI:138488")) self.assertIsNone(converter.standardize_curie("NOPE:NOPE")) self.assertEqual("NOPE:NOPE", converter.standardize_curie("NOPE:NOPE", passthrough=True)) with self.assertRaises(CURIEStandardizationError): converter.standardize_curie("NOPE:NOPE", strict=True) self.assertEqual( "http://purl.obolibrary.org/obo/CHEBI_138488", converter.standardize_uri( "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:138488" ), ) self.assertEqual( "http://purl.obolibrary.org/obo/CHEBI_138488", converter.standardize_uri("http://purl.obolibrary.org/obo/CHEBI_138488"), ) self.assertIsNone(converter.standardize_uri("NOPE")) self.assertEqual("NOPE", converter.standardize_uri("NOPE", passthrough=True)) with self.assertRaises(URIStandardizationError): converter.standardize_uri("NOPE:NOPE", strict=True) def test_standardize_reference(self) -> None: """Test standardize reference.""" converter = Converter.from_extended_prefix_map( [ Record( prefix="CHEBI", prefix_synonyms=["chebi"], uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", ), ] ) r1 = Reference.from_curie("chebi:138488") r2 = Reference.from_curie("CHEBI:138488") bad = Reference.from_curie("NOPE:NOPE") for r in [r1, r2]: self.assertEqual(r2, converter.standardize_reference(r)) self.assertEqual(r2, converter.standardize_reference(r, strict=True)) self.assertEqual(r2, converter.standardize_reference(r, strict=False)) self.assertIsNone(converter.standardize_reference(bad)) self.assertIsNone(converter.standardize_reference(bad, strict=False)) with self.assertRaises(PrefixStandardizationError): converter.standardize_reference(bad, strict=True) def test_combine(self) -> None: """Test chaining converters.""" with self.assertRaises(ValueError): chain([]) c1 = Converter.from_priority_prefix_map( { "CHEBI": ["http://purl.obolibrary.org/obo/CHEBI_", "https://bioregistry.io/chebi:"], "MONDO": ["http://purl.obolibrary.org/obo/MONDO_"], } ) c2 = Converter.from_priority_prefix_map( { "CHEBI": [ "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=", "http://identifiers.org/chebi/", "http://purl.obolibrary.org/obo/CHEBI_", ], "GO": ["http://purl.obolibrary.org/obo/GO_"], "OBO": ["http://purl.obolibrary.org/obo/"], } ) converter = chain([c1, c2], case_sensitive=True) self.assertEqual("CHEBI", converter.get_record("CHEBI").prefix) for url in [ "http://purl.obolibrary.org/obo/CHEBI_138488", "https://bioregistry.io/chebi:138488", "http://identifiers.org/chebi/138488", "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=138488", ]: self.assertEqual("CHEBI:138488", converter.compress(url)) self.assertEqual("GO", converter.get_record("GO").prefix) self.assertEqual( "GO:0000001", converter.compress("http://purl.obolibrary.org/obo/GO_0000001"), ) self.assertEqual( "http://purl.obolibrary.org/obo/CHEBI_", converter.get_record("CHEBI").uri_prefix ) self.assertIn("CHEBI", converter.prefix_map) self.assertEqual("http://purl.obolibrary.org/obo/CHEBI_", converter.prefix_map["CHEBI"]) self.assertEqual( "http://purl.obolibrary.org/obo/CHEBI_138488", converter.expand("CHEBI:138488"), ) self.assertNotIn("nope", converter.get_prefixes()) def test_combine_with_synonyms(self) -> None: """Test combination with synonyms.""" r1 = Record(prefix="GO", uri_prefix=GO_URI_PREFIX) r2 = Record(prefix="go", prefix_synonyms=["GO"], uri_prefix="https://identifiers.org/go:") c1 = Converter([]) c1.add_record(r1) self.assertEqual(c1.records, Converter([r1]).records) c1.add_record(r2, merge=True) self.assertEqual(1, len(c1.records)) r = c1.records[0] self.assertEqual("GO", r.prefix) self.assertEqual({"go"}, set(r.prefix_synonyms)) self.assertEqual("http://purl.obolibrary.org/obo/GO_", r.uri_prefix) self.assertEqual({"https://identifiers.org/go:"}, set(r.uri_prefix_synonyms)) c3 = chain([Converter([r1]), Converter([r2])]) self.assertEqual(1, len(c3.records)) self.assertIn("GO", c3.prefix_map) self.assertIn("go", c3.prefix_map, msg=f"PM: {c3.prefix_map}") self.assertNotIn("go", c3.bimap) self.assertIn("GO", c3.bimap) self.assertTrue(c3.has_prefix("go")) self.assertTrue(c3.has_prefix("GO")) self.assertFalse(c3.has_prefix("nope")) def test_combine_ci(self) -> None: """Test combining case-insensitive.""" c1 = Converter.from_priority_prefix_map( { "CHEBI": [ "http://purl.obolibrary.org/obo/CHEBI_", "https://bioregistry.io/chebi:", ], } ) c2 = Converter.from_reverse_prefix_map( { "http://identifiers.org/chebi/": "chebi", "http://identifiers.org/chebi:": "chebi", } ) converter = chain([c1, c2], case_sensitive=False) self.assertEqual({"CHEBI"}, converter.get_prefixes()) self.assertEqual({"CHEBI"}, converter.get_prefixes(include_synonyms=False)) self.assertEqual({"CHEBI", "chebi"}, converter.get_prefixes(include_synonyms=True)) for url in [ "http://purl.obolibrary.org/obo/CHEBI_138488", "http://identifiers.org/chebi/138488", "http://identifiers.org/chebi:138488", "https://bioregistry.io/chebi:138488", ]: self.assertEqual("CHEBI:138488", converter.compress(url)) # use the first prefix map for expansions self.assertEqual( "http://purl.obolibrary.org/obo/CHEBI_138488", converter.expand("CHEBI:138488"), ) def test_combine_with_patterns(self) -> None: """Test chaining with patterns.""" c1 = Converter([Record(prefix="a", uri_prefix="https://example.org/a/", pattern="^\\d{7}")]) c2 = Converter([Record(prefix="a", uri_prefix="https://example.org/a/", pattern="^\\d+")]) converter = chain([c1, c2]) self.assertEqual( [Record(prefix="a", uri_prefix="https://example.org/a/", pattern="^\\d{7}")], converter.records, ) def test_combine_with_patterns_via_synonym(self) -> None: """Test chaining with patterns.""" c1 = Converter([Record(prefix="a", uri_prefix="https://example.org/a/", pattern="^\\d{7}")]) c2 = Converter( [ Record( prefix="b", prefix_synonyms=["a"], uri_prefix="https://example.org/b/", pattern="^\\d+", ) ] ) converter = chain([c1, c2]) self.assertEqual( [ Record( prefix="a", prefix_synonyms=["b"], uri_prefix="https://example.org/a/", uri_prefix_synonyms=["https://example.org/b/"], pattern="^\\d{7}", ) ], converter.records, ) def test_df_bulk(self) -> None: """Test bulk processing in pandas dataframes.""" rows = [ ("CHEBI:1", "http://purl.obolibrary.org/obo/CHEBI_1"), ] df = pd.DataFrame(rows, columns=["curie", "uri"]) self.converter.pd_expand(df, "curie") self.assertTrue((df.curie == df.uri).all()) df = pd.DataFrame(rows, columns=["curie", "uri"]) self.converter.pd_compress(df, "uri") self.assertTrue((df.curie == df.uri).all()) def test_df_standardize(self) -> None: """Test standardizing dataframes.""" converter = Converter([]) converter.add_prefix( "chebi", "http://purl.obolibrary.org/obo/CHEBI_", prefix_synonyms=["CHEBI"], uri_prefix_synonyms=["https://bioregistry.io/chebi:"], ) self.assertEqual("chebi", converter.standardize_prefix("chebi")) self.assertEqual("chebi", converter.standardize_prefix("CHEBI")) self.assertIsNone(converter.standardize_prefix("nope")) self.assertEqual("nope", converter.standardize_prefix("nope", passthrough=True)) with self.assertRaises(PrefixStandardizationError): converter.standardize_prefix("nope", strict=True) rows = [ ("chebi", "CHEBI:1", "http://purl.obolibrary.org/obo/CHEBI_1"), ("CHEBI", "CHEBI:2", "https://bioregistry.io/chebi:2"), ] df = pd.DataFrame(rows, columns=["prefix", "curie", "uri"]) converter.pd_standardize_prefix(df, column="prefix") self.assertEqual(["chebi", "chebi"], list(df["prefix"]), msg=f"\n\n{df}") converter.pd_standardize_curie(df, column="curie") self.assertEqual(["chebi:1", "chebi:2"], list(df["curie"])) converter.pd_standardize_uri(df, column="uri") self.assertEqual( ["http://purl.obolibrary.org/obo/CHEBI_1", "http://purl.obolibrary.org/obo/CHEBI_2"], list(df["uri"]), ) def test_file_bulk(self) -> None: """Test bulk processing of files.""" with TemporaryDirectory() as directory: for rows, header in [ ( [ ("curie", "uri"), ("CHEBI:1", "http://purl.obolibrary.org/obo/CHEBI_1"), ], True, ), ( [ ("CHEBI:1", "http://purl.obolibrary.org/obo/CHEBI_1"), ], False, ), ]: path = Path(directory).joinpath("test.tsv") with path.open("w") as file: for row in rows: print(*row, sep="\t", file=file) idx = 1 if header else 0 self.converter.file_expand(path, 0, header=header) lines = [line.strip().split("\t") for line in path.read_text().splitlines()] self.assertEqual("http://purl.obolibrary.org/obo/CHEBI_1", lines[idx][0]) self.converter.file_compress(path, 0, header=header) lines = [line.strip().split("\t") for line in path.read_text().splitlines()] self.assertEqual("CHEBI:1", lines[idx][0]) def test_incremental(self) -> None: """Test building a converter from an incremental interface.""" converter = Converter([]) for prefix, uri_prefix in self.simple_obo_prefix_map.items(): converter.add_prefix(prefix, uri_prefix) converter.add_prefix( "hgnc", "https://bioregistry.io/hgnc:", prefix_synonyms=["HGNC"], uri_prefix_synonyms=["https://identifiers.org/hgnc:"], ) self._assert_convert(converter) self.assertEqual( "hgnc:1234", converter.compress("https://bioregistry.io/hgnc:1234"), ) self.assertEqual( "hgnc:1234", converter.compress("https://identifiers.org/hgnc:1234"), ) self.assertEqual("https://bioregistry.io/hgnc:1234", converter.expand("HGNC:1234")) with self.assertRaises(ValueError): converter.add_prefix("GO", "...") with self.assertRaises(ValueError): converter.add_prefix("...", "http://purl.obolibrary.org/obo/GO_") with self.assertRaises(ValueError): converter.add_prefix( "...", "...", uri_prefix_synonyms=["http://purl.obolibrary.org/obo/GO_"] ) with self.assertRaises(ValueError): converter.add_prefix("...", "...", prefix_synonyms=["GO"]) def test_rdflib(self) -> None: """Test parsing a converter from an RDFLib object.""" graph = rdflib.Graph() for prefix, uri_prefix in self.simple_obo_prefix_map.items(): graph.bind(prefix, uri_prefix) converter = Converter.from_rdflib(graph) self._assert_convert(converter) converter_2 = Converter.from_rdflib(graph.namespace_manager) self._assert_convert(converter_2) def test_parse_curie(self) -> None: """Tests for parse CURIE.""" converter = Converter( records=[ Record( prefix="GO", uri_prefix="http://purl.obolibrary.org/obo/GO_", prefix_synonyms=["go"], ) ] ) self.assertEqual( ReferenceTuple("GO", "1234567"), converter.parse_curie("GO:1234567", strict=True) ) self.assertEqual( ReferenceTuple("GO", "1234567"), converter.parse_curie("GO:1234567", strict=False) ) self.assertEqual( ReferenceTuple("GO", "1234567"), converter.parse_curie("go:1234567", strict=True) ) self.assertEqual( ReferenceTuple("GO", "1234567"), converter.parse_curie("go:1234567", strict=False) ) self.assertIsNone(converter.parse_curie("NOPE:NOPE", strict=False)) with self.assertRaises(PrefixStandardizationError): converter.parse_curie("NOPE:NOPE", strict=True) def test_parse(self) -> None: """Test parsing URI or CURIE.""" converter = Converter( records=[ Record( prefix="GO", uri_prefix="http://purl.obolibrary.org/obo/GO_", prefix_synonyms=["go"], uri_prefix_synonyms=["https://identifiers.org/GO:"], ) ] ) curie = "GO:1234567" curie2 = "go:1234567" uri = "http://purl.obolibrary.org/obo/GO_1234567" uri2 = "https://identifiers.org/GO:1234567" for s in [curie, curie2, uri, uri2]: self.assertEqual(ReferenceTuple("GO", "1234567"), converter.parse(s, strict=True)) self.assertEqual(ReferenceTuple("GO", "1234567"), converter.parse(s, strict=False)) # test invalid CURIE parsing self.assertIsNone(converter.parse("NOPE:NOPE", strict=False)) with self.assertRaises(ValueError): converter.parse_uri("NOPE:NOPE", strict=True) # test invalid URI parsing self.assertIsNone(converter.parse("https://example.org/nope", strict=False)) with self.assertRaises(ValueError): converter.parse_uri("https://example.org/nope", strict=True) # test whatever's left self.assertIsNone(converter.parse("1234567", strict=False)) with self.assertRaises(ValueError): converter.parse_uri("1234567", strict=True) def test_compress_or_standardize(self) -> None: """Test standardizing URI or CURIE.""" converter = Converter( records=[ Record( prefix="GO", uri_prefix="http://purl.obolibrary.org/obo/GO_", prefix_synonyms=["go"], uri_prefix_synonyms=["https://identifiers.org/GO:"], ) ] ) curie = "GO:1234567" curie2 = "go:1234567" uri = "http://purl.obolibrary.org/obo/GO_1234567" uri2 = "https://identifiers.org/GO:1234567" for s in [curie, curie2, uri, uri2]: self.assertEqual( "GO:1234567", converter.compress_or_standardize(s, strict=True, passthrough=True) ) self.assertEqual( "GO:1234567", converter.compress_or_standardize(s, strict=False, passthrough=True) ) self.assertEqual( "GO:1234567", converter.compress_or_standardize(s, strict=True, passthrough=False) ) self.assertEqual( "GO:1234567", converter.compress_or_standardize(s, strict=False, passthrough=False) ) self.assertIsNone( converter.compress_or_standardize("NOPE:NOPE", strict=False, passthrough=False) ) self.assertEqual( "NOPE:NOPE", converter.compress_or_standardize("NOPE:NOPE", strict=False, passthrough=True), ) with self.assertRaises(ValueError): converter.compress_or_standardize("NOPE:NOPE", strict=True, passthrough=True) with self.assertRaises(ValueError): converter.compress_or_standardize("NOPE:NOPE", strict=True, passthrough=False) def test_parse_uri(self) -> None: """Tests for parsing URIs.""" converter = Converter( records=[ Record( prefix="GO", uri_prefix="http://purl.obolibrary.org/obo/GO_", prefix_synonyms=["go"], uri_prefix_synonyms=["https://identifiers.org/GO:"], ) ] ) expected = ReferenceTuple("GO", "1234567") uri1 = "http://purl.obolibrary.org/obo/GO_1234567" uri2 = "https://identifiers.org/GO:1234567" uris: list[URIType] = [ uri1, uri2, AnyUrl(uri1), rdflib.URIRef(uri1), httpx.URL(uri1), ] for uri in uris: with self.subTest(uri=str(uri)): self.assertEqual(expected, converter.parse_uri(uri)) self.assertEqual(expected, converter.parse_uri(uri, strict=True)) self.assertEqual(expected, converter.parse_uri(uri, strict=False)) misses: list[URIType] = [ "123345", "https://example.com/12345", AnyUrl("https://example.com/12345"), URIRef("https://example.com/12345"), httpx.URL("https://example.com/12345"), ] for miss in misses: with self.subTest(uri=str(miss)): self.assertIsNone(converter.parse_uri(miss, strict=False)) with self.assertRaises(ValueError): converter.parse_uri(miss, strict=True) def test_expand(self) -> None: """Tests for expand.""" converter = Converter( records=[ Record( prefix="GO", uri_prefix="http://purl.obolibrary.org/obo/GO_", prefix_synonyms=["go"], ) ] ) uri = "http://purl.obolibrary.org/obo/GO_1234567" self.assertEqual(uri, converter.expand("GO:1234567", strict=True, passthrough=True)) self.assertEqual(uri, converter.expand("GO:1234567", strict=False, passthrough=True)) self.assertEqual(uri, converter.expand("GO:1234567", strict=True, passthrough=False)) self.assertEqual(uri, converter.expand("GO:1234567", strict=False, passthrough=False)) self.assertEqual(uri, converter.expand("go:1234567", strict=True, passthrough=True)) self.assertEqual(uri, converter.expand("go:1234567", strict=False, passthrough=True)) self.assertEqual(uri, converter.expand("go:1234567", strict=True, passthrough=False)) self.assertEqual(uri, converter.expand("go:1234567", strict=False, passthrough=False)) self.assertEqual("NOPE:NOPE", converter.expand("NOPE:NOPE", strict=False, passthrough=True)) self.assertIsNone(converter.expand("NOPE:NOPE", strict=False, passthrough=False)) with self.assertRaises(ExpansionError): converter.expand("NOPE:NOPE", strict=True, passthrough=True) with self.assertRaises(ExpansionError): converter.expand("NOPE:NOPE", strict=True, passthrough=False) def test_expand_pair_all(self) -> None: """Tests for expand.""" converter = Converter( records=[ Record( prefix="GO", uri_prefix="http://purl.obolibrary.org/obo/GO_", prefix_synonyms=["go"], uri_prefix_synonyms=["https://identifiers.org/GO:"], ) ] ) uris = ["http://purl.obolibrary.org/obo/GO_1234567", "https://identifiers.org/GO:1234567"] self.assertEqual(uris, converter.expand_pair_all("GO", "1234567", strict=True)) self.assertEqual(uris, converter.expand_pair_all("GO", "1234567", strict=False)) # with synonym as input self.assertEqual(uris, converter.expand_pair_all("go", "1234567", strict=True)) self.assertEqual(uris, converter.expand_pair_all("go", "1234567", strict=False)) self.assertIsNone(converter.expand_pair_all("NOPE", "NOPE", strict=False)) with self.assertRaises(ExpansionError): converter.expand_pair_all("NOPE", "NOPE", strict=True) def test_expand_reference(self) -> None: """Tests for expand.""" converter = Converter( records=[ Record( prefix="GO", uri_prefix="http://purl.obolibrary.org/obo/GO_", prefix_synonyms=["go"], ) ] ) uri = "http://purl.obolibrary.org/obo/GO_1234567" ref = ReferenceTuple("GO", "1234567") ref2 = ReferenceTuple("go", "1234567") nope = ReferenceTuple("NOPE", "NOPE") self.assertEqual(uri, converter.expand_reference(ref, strict=True, passthrough=True)) self.assertEqual(uri, converter.expand_reference(ref, strict=False, passthrough=True)) self.assertEqual(uri, converter.expand_reference(ref, strict=True, passthrough=False)) self.assertEqual(uri, converter.expand_reference(ref, strict=False, passthrough=False)) self.assertEqual(uri, converter.expand_reference(ref2, strict=True, passthrough=True)) self.assertEqual(uri, converter.expand_reference(ref2, strict=False, passthrough=True)) self.assertEqual(uri, converter.expand_reference(ref2, strict=True, passthrough=False)) self.assertEqual(uri, converter.expand_reference(ref2, strict=False, passthrough=False)) self.assertEqual( "NOPE:NOPE", converter.expand_reference(nope, strict=False, passthrough=True) ) self.assertIsNone(converter.expand_reference(nope, strict=False, passthrough=False)) with self.assertRaises(ExpansionError): converter.expand_reference(nope, strict=True, passthrough=True) with self.assertRaises(ExpansionError): converter.expand_reference(nope, strict=True, passthrough=False) def test_expand_all(self) -> None: """Test expand all.""" priority_prefix_map = { "CHEBI": [ "http://purl.obolibrary.org/obo/CHEBI_", "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:", ], } converter = Converter.from_priority_prefix_map(priority_prefix_map) self.assertEqual( [ "http://purl.obolibrary.org/obo/CHEBI_138488", "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:138488", ], converter.expand_all("CHEBI:138488"), ) self.assertIsNone(converter.expand_all("NOPE:NOPE")) with self.assertRaises(PrefixStandardizationError): converter.expand_all("NOPE:NOPE", strict=True) def test_expand_ambiguous(self) -> None: """Test expansion of URI or CURIEs.""" converter = Converter.from_extended_prefix_map( [ Record( prefix="CHEBI", prefix_synonyms=["chebi"], uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", uri_prefix_synonyms=["https://identifiers.org/chebi:"], ), ] ) self.assertEqual( "http://purl.obolibrary.org/obo/CHEBI_138488", converter.expand_or_standardize("CHEBI:138488"), ) self.assertEqual( "http://purl.obolibrary.org/obo/CHEBI_138488", converter.expand_or_standardize("chebi:138488"), ) self.assertEqual( "http://purl.obolibrary.org/obo/CHEBI_138488", converter.expand_or_standardize("http://purl.obolibrary.org/obo/CHEBI_138488"), ) self.assertEqual( "http://purl.obolibrary.org/obo/CHEBI_138488", converter.expand_or_standardize("https://identifiers.org/chebi:138488"), ) self.assertIsNone(converter.expand_or_standardize("missing:0000000")) with self.assertRaises(ExpansionError): converter.expand_or_standardize("missing:0000000", strict=True) self.assertEqual( "missing:0000000", converter.expand_or_standardize("missing:0000000", passthrough=True) ) self.assertIsNone(converter.expand_or_standardize("https://example.com/missing:0000000")) with self.assertRaises(ExpansionError): converter.expand_or_standardize("https://example.com/missing:0000000", strict=True) self.assertEqual( "https://example.com/missing:0000000", converter.expand_or_standardize( "https://example.com/missing:0000000", passthrough=True ), ) def test_compress_ambiguous(self) -> None: """Test compression of URI or CURIEs.""" converter = Converter.from_extended_prefix_map( [ Record( prefix="CHEBI", prefix_synonyms=["chebi"], uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", uri_prefix_synonyms=["https://identifiers.org/chebi:"], ), ] ) self.assertEqual("CHEBI:138488", converter.compress_or_standardize("CHEBI:138488")) self.assertEqual("CHEBI:138488", converter.compress_or_standardize("chebi:138488")) self.assertEqual( "CHEBI:138488", converter.compress_or_standardize("http://purl.obolibrary.org/obo/CHEBI_138488"), ) self.assertEqual( "CHEBI:138488", converter.compress_or_standardize("https://identifiers.org/chebi:138488"), ) self.assertIsNone(converter.expand_or_standardize("missing:0000000")) with self.assertRaises(ExpansionError): converter.expand_or_standardize("missing:0000000", strict=True) self.assertEqual( "missing:0000000", converter.expand_or_standardize("missing:0000000", passthrough=True) ) self.assertIsNone(converter.expand_or_standardize("https://example.com/missing:0000000")) with self.assertRaises(ExpansionError): converter.expand_or_standardize("https://example.com/missing:0000000", strict=True) self.assertEqual( "https://example.com/missing:0000000", converter.expand_or_standardize( "https://example.com/missing:0000000", passthrough=True ), ) def test_standardize_identifier(self) -> None: """Test standardizing identifiers.""" class BananaStripperConverter(Converter): """A converter that removes bananas from LUIDs.""" # docstr-coverage:excused `overload` @overload def standardize_identifier( self, standard_prefix: str, identifier: str, strict: Literal[True] = ... ) -> str: ... # docstr-coverage:excused `overload` @overload def standardize_identifier( self, standard_prefix: str, identifier: str, strict: Literal[False] = ... ) -> str | None: ... def standardize_identifier( self, standard_prefix: str, identifier: str, strict: bool = False ) -> str | None: """Standardize the identifier by removing a banana and checking it is numeric.""" norm_identifier = identifier.removeprefix(f"{standard_prefix}:") # now, do some validation if not norm_identifier.isnumeric(): return None return norm_identifier converter = BananaStripperConverter( records=[ Record( prefix="CHEBI", prefix_synonyms=["chebi"], uri_prefix="http://purl.obolibrary.org/obo/CHEBI_", uri_prefix_synonyms=["https://identifiers.org/chebi:"], ), ] ) self.assertEqual(ReferenceTuple("CHEBI", "1234"), converter.parse_curie("CHEBI:1234")) self.assertEqual(ReferenceTuple("CHEBI", "1234"), converter.parse_curie("chebi:1234")) self.assertEqual(ReferenceTuple("CHEBI", "1234"), converter.parse_curie("CHEBI:CHEBI:1234")) self.assertEqual(ReferenceTuple("CHEBI", "1234"), converter.parse_curie("chebi:CHEBI:1234")) self.assertIsNone(converter.parse_curie("NOPE:NOPE:1234", strict=False)) self.assertIsNone(converter.parse_curie("CHEBI:nope", strict=False)) # does not solve the problem of synonyms in the banana, this is specific # to the current implementation in this test self.assertIsNone(converter.parse_curie("chebi:chebi:1234", strict=False)) with self.assertRaises(ValueError): converter.parse_curie("CHEBI:nope", strict=True) def test_add_prefix_synonym(self) -> None: """Test adding a prefix synonym.""" c = Converter() c.add_prefix("a", "https://example.org/a/") c.add_prefix("b", "https://example.org/b/") c.add_prefix_synonym("a", "x") self.assertEqual( Record(prefix="a", uri_prefix="https://example.org/a/", prefix_synonyms=["x"]), c.get_record("a"), ) with self.assertRaises(ValueError): c.add_prefix_synonym("a", "b") with self.assertRaises(KeyError): c.add_prefix_synonym("A", "y") def test_add_uri_prefix_synonym(self) -> None: """Test adding a prefix synonym.""" c = Converter() c.add_prefix("a", "https://example.org/a/") c.add_prefix("b", "https://example.org/b/") c.add_uri_prefix_synonym("a", "https://example.org/a2/") self.assertEqual( Record( prefix="a", uri_prefix="https://example.org/a/", uri_prefix_synonyms=["https://example.org/a2/"], ), c.get_record("a"), ) with self.assertRaises(ValueError): c.add_uri_prefix_synonym("a", "https://example.org/b/") with self.assertRaises(KeyError): c.add_uri_prefix_synonym("A", "y") class TestVersion(unittest.TestCase): """Trivially test a version.""" def test_version_type(self) -> None: """Test the version is a string. This is only meant to be an example test. """ version = get_version() self.assertIsInstance(version, str) class TestUtils(unittest.TestCase): """Test utility functions.""" def test_clean(self) -> None: """Test clean.""" prefix_map = { "b": "https://example.com/a/", "a": "https://example.com/a/", "c": "https://example.com/c/", } records = upgrade_prefix_map(prefix_map) self.assertEqual(2, len(records)) a_record, c_record = records self.assertEqual("a", a_record.prefix) self.assertEqual(["b"], a_record.prefix_synonyms) self.assertEqual("https://example.com/a/", a_record.uri_prefix) self.assertEqual([], a_record.uri_prefix_synonyms) self.assertEqual("c", c_record.prefix) self.assertEqual([], c_record.prefix_synonyms) self.assertEqual("https://example.com/c/", c_record.uri_prefix) self.assertEqual([], c_record.uri_prefix_synonyms) def test_parse_xsd(self) -> None: """Test parsing XSD.""" with self.assertRaises(KeyError): parse_xsd("doesn't matter", Reference(prefix="something", identifier="wrong")) for expected, s, datatype in [ (True, "true", v.xsd_boolean), (False, "false", v.xsd_boolean), (datetime.date(2026, 7, 31), "2026-07-31", v.xsd_date), (5, "5", v.xsd_integer), (5.0, "5", v.xsd_float), (5.1, "5.1", v.xsd_float), (AnyUrl("https://example.org"), "https://example.org/", v.xsd_uri), ]: with self.subTest(value=s): self.assertEqual(expected, parse_xsd(s, datatype)) biopragmatics-curies-aa8bd37/tests/test_database.py000066400000000000000000000267571525104130300226530ustar00rootroot00000000000000"""Tests for database adapters.""" from __future__ import annotations import unittest from curies import Prefix, Reference from curies.database import ( get_reference_list_sa_column, get_reference_sa_column, get_reference_sa_composite, ) prefix = Prefix("hero") id_1 = "1" id_2 = "2" id_3 = "3" id_4 = "4" id_5 = "5" name_1 = "Name 1" name_2 = "Name 2" name_3 = "Name 3" class TestDatabase(unittest.TestCase): """Tests for database adapters.""" def test_sqlalchemy(self) -> None: """Test SQLalchemy.""" from sqlalchemy import Column, Integer, String, create_engine from sqlalchemy.orm import DeclarativeBase, Session class Base(DeclarativeBase): """A base.""" class MyModel(Base): """A SQLAlchemy model that uses a reference.""" __tablename__ = "my_model" id = Column(Integer, primary_key=True) reference = get_reference_sa_column() name = Column(String) engine = create_engine("sqlite://") Base.metadata.create_all(engine) model_1 = MyModel(reference=Reference(prefix=prefix, identifier=id_1), name=name_1) model_2 = MyModel(reference=Reference(prefix=prefix, identifier=id_2), name=name_2) model_3 = MyModel(reference=Reference(prefix=prefix, identifier=id_3), name=name_3) with Session(engine) as session: session.add(model_1) session.add(model_2) session.add(model_3) session.commit() # Test querying with reconstitution with Session(engine) as session: result = ( session.query(MyModel) .filter(MyModel.reference == Reference(prefix=prefix, identifier=id_1)) .one() ) self.assertIsInstance(result.reference, Reference) self.assertEqual(prefix, result.reference.prefix) self.assertEqual(id_1, result.reference.identifier) self.assertEqual(name_1, result.name) def test_sqlalchemy_composite(self) -> None: """Test SQLAlchemy composite models.""" from sqlalchemy import Column, Integer, String, create_engine from sqlalchemy.orm import DeclarativeBase, Session class Base(DeclarativeBase): """A base.""" class MyModel(Base): """A model for testing composite references.""" __tablename__ = "my_model" id = Column(Integer, primary_key=True) reference_prefix = Column(String, nullable=False) reference_identifier = Column(String, nullable=False) name = Column(String, nullable=False) reference = get_reference_sa_composite(reference_prefix, reference_identifier) engine = create_engine("sqlite://") Base.metadata.create_all(engine) model_1 = MyModel(reference=Reference(prefix=prefix, identifier=id_1), name=name_1) model_2 = MyModel(reference=Reference(prefix=prefix, identifier=id_2), name=name_2) model_3 = MyModel(reference=Reference(prefix=prefix, identifier=id_3), name=name_3) with Session(engine) as session: session.add(model_1) session.add(model_2) session.add(model_3) session.commit() # Test querying with reconstitution with Session(engine) as session: result = ( session.query(MyModel) .filter(MyModel.reference == Reference(prefix=prefix, identifier=id_1)) .one() ) self.assertIsInstance(result.reference, Reference) self.assertEqual(prefix, result.reference.prefix) self.assertEqual(id_1, result.reference.identifier) self.assertEqual(name_1, result.name) def test_sqlmodel(self) -> None: """Test SQLModel.""" from sqlmodel import Field, Session, SQLModel, create_engine, select class Model(SQLModel, table=True): """A class with a reference.""" id: int | None = Field(default=None, primary_key=True) reference: Reference = Field(sa_column=get_reference_sa_column()) optional_reference: Reference | None = Field(sa_column=get_reference_sa_column()) listed_references: list[Reference] = Field( sa_column=get_reference_list_sa_column(), default_factory=list ) name: str model_1 = Model(reference=Reference(prefix=prefix, identifier=id_1), name=name_1) model_2 = Model(reference=Reference(prefix=prefix, identifier=id_2), name=name_2) model_3 = Model( reference=Reference(prefix=prefix, identifier=id_3), optional_reference=Reference(prefix=prefix, identifier=id_3), name=name_3, listed_references=[ Reference(prefix=prefix, identifier="l1"), Reference(prefix=prefix, identifier="l2"), ], ) engine = create_engine("sqlite://") SQLModel.metadata.create_all(engine) with Session(engine) as session: session.add(model_1) session.add(model_2) session.add(model_3) session.commit() # Test querying with reconstitution with Session(engine) as session: statement = select(Model).where(Model.name == name_2) result_1 = session.exec(statement).first() if result_1 is None: raise TypeError # this shouldn't be possible self.assertIsInstance(result_1.reference, Reference) self.assertEqual(prefix, result_1.reference.prefix) self.assertEqual(id_2, result_1.reference.identifier) self.assertEqual(name_2, result_1.name) self.assertEqual([], result_1.listed_references) # Test querying using a string on the reference with Session(engine) as session: statement = select(Model).where(Model.reference == f"{prefix}:{id_3}") result_4 = session.exec(statement).first() if result_4 is None: raise TypeError # this shouldn't be possible self.assertIsInstance(result_4.reference, Reference) self.assertEqual(prefix, result_4.reference.prefix) self.assertEqual(id_3, result_4.reference.identifier) self.assertEqual(name_3, result_4.name) self.assertIsNotNone(result_4.optional_reference) self.assertEqual( [Reference(prefix=prefix, identifier="l1"), Reference(prefix=prefix, identifier="l2")], result_4.listed_references, ) # Tests looking up a reference that's missing with Session(engine) as session: statement = select(Model).where( Model.reference == Reference(prefix="nope", identifier="nope") ) self.assertIsNone(session.exec(statement).one_or_none()) # Tests looking a reference that's there with Session(engine) as session: statement = select(Model).where( Model.reference == Reference(prefix=prefix, identifier=id_1) ) result_3 = session.exec(statement).first() if result_3 is None: raise TypeError # this shouldn't be possible self.assertIsInstance(result_3.reference, Reference) self.assertEqual(prefix, result_3.reference.prefix) self.assertEqual(id_1, result_3.reference.identifier) self.assertEqual(name_1, result_3.name) self.assertEqual([], result_3.listed_references) def test_sqlmodel_reference_list_default(self) -> None: """Test SQLModel with a reference list with a default factory.""" from sqlmodel import Field, Session, SQLModel, create_engine, select class ModelWithReferenceList(SQLModel, table=True): """A class with a reference.""" id: int | None = Field(default=None, primary_key=True) authors: list[Reference] = Field( sa_column=get_reference_list_sa_column(), default_factory=list ) model_1 = ModelWithReferenceList(id=1, authors=[Reference(prefix=prefix, identifier=id_1)]) model_2 = ModelWithReferenceList(id=2) engine = create_engine("sqlite://") SQLModel.metadata.create_all(engine) with Session(engine) as session: session.add_all([model_1, model_2]) session.commit() with Session(engine) as session: statement = select(ModelWithReferenceList).where(ModelWithReferenceList.id == 1) result_1 = session.exec(statement).first() if result_1 is None: self.fail() self.assertEqual([Reference(prefix=prefix, identifier=id_1)], result_1.authors) with Session(engine) as session: statement = select(ModelWithReferenceList).where(ModelWithReferenceList.id == 2) result_2 = session.exec(statement).first() if result_2 is None: self.fail() self.assertEqual([], result_2.authors) def test_sqlmodel_reference_list_no_default(self) -> None: """Test SQLModel with a reference list with no default factory.""" from sqlmodel import Field, Session, SQLModel, create_engine, select class ModelReferenceListNoDefault(SQLModel, table=True): """A class with a reference.""" id: int | None = Field(default=None, primary_key=True) authors: list[Reference] = Field(sa_column=get_reference_list_sa_column()) model_1 = ModelReferenceListNoDefault( id=1, authors=[Reference(prefix=prefix, identifier=id_1)] ) engine = create_engine("sqlite://") SQLModel.metadata.create_all(engine) with Session(engine) as session: session.add(model_1) session.commit() with Session(engine) as session: statement = select(ModelReferenceListNoDefault).where( ModelReferenceListNoDefault.id == 1 ) result_1 = session.exec(statement).first() if result_1 is None: self.fail() self.assertEqual([Reference(prefix=prefix, identifier=id_1)], result_1.authors) def test_sqlmodel_reference_list_optional(self) -> None: """Test SQLModel with optional reference list.""" from sqlmodel import Field, Session, SQLModel, create_engine, select class ModelOptionalReferenceList(SQLModel, table=True): """A class with a reference.""" id: int | None = Field(default=None, primary_key=True) authors: list[Reference] | None = Field(None, sa_column=get_reference_list_sa_column()) model_1 = ModelOptionalReferenceList( id=1, authors=[Reference(prefix=prefix, identifier=id_1)] ) model_2 = ModelOptionalReferenceList(id=2) engine = create_engine("sqlite://") SQLModel.metadata.create_all(engine) with Session(engine) as session: session.add_all([model_1, model_2]) session.commit() with Session(engine) as session: statement = select(ModelOptionalReferenceList).where(ModelOptionalReferenceList.id == 1) result_1 = session.exec(statement).first() if result_1 is None: self.fail() self.assertEqual([Reference(prefix=prefix, identifier=id_1)], result_1.authors) with Session(engine) as session: statement = select(ModelOptionalReferenceList).where(ModelOptionalReferenceList.id == 2) result_2 = session.exec(statement).first() if result_2 is None: self.fail() self.assertIsNone(result_2.authors) biopragmatics-curies-aa8bd37/tests/test_dataframe.py000066400000000000000000000067411525104130300230220ustar00rootroot00000000000000"""Test dataframe utilities.""" import typing import unittest import pandas as pd from curies import Converter from curies.dataframe import ( PrefixIndexMethod, filter_df_by_curies, filter_df_by_prefixes, get_df_curies_index, get_df_prefixes_index, get_df_unique_prefixes, get_filter_df_by_prefixes_index, ) CONVERTER = Converter.from_prefix_map( { "a": "https://example.org/a/", "b": "https://example.org/b/", "c": "https://example.org/c/", } ) class TestDataframe(unittest.TestCase): """A test case for dataframe utilities.""" def test_get_prefix_index(self) -> None: """Test getting a prefix index.""" column = "curie" a_curies = [f"a:{i}" for i in range(5)] b_curies = [f"b:{i}" for i in range(5)] c_curies = [f"c:{i}" for i in range(5)] * 2 curies = [*a_curies, *b_curies, *c_curies] rows = [(curie,) for curie in curies] df = pd.DataFrame(rows, columns=[column]) with self.assertRaises(ValueError): get_filter_df_by_prefixes_index(df, prefixes="a", converter=CONVERTER) for method in typing.get_args(PrefixIndexMethod): with self.subTest(method=method): idx = get_filter_df_by_prefixes_index( df, column=column, prefixes="a", method=method, converter=CONVERTER ) self.assertEqual([0, 1, 2, 3, 4], _rr(idx)) idx = get_filter_df_by_prefixes_index( df[column], prefixes="a", method=method, converter=CONVERTER ) self.assertEqual([0, 1, 2, 3, 4], _rr(idx)) idx = get_filter_df_by_prefixes_index( df, column=column, prefixes=["a", "b"], method=method, converter=CONVERTER ) self.assertEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], _rr(idx)) df_a = filter_df_by_prefixes(df, column=column, prefixes="a") self.assertEqual(set(a_curies), set(df_a[column])) df_ab = filter_df_by_prefixes(df, column=column, prefixes=["a", "b"]) self.assertEqual({*a_curies, *b_curies}, set(df_ab[column])) df_a1 = filter_df_by_curies(df, column=column, curies="a:1") self.assertEqual({"a:1"}, set(df_a1[column])) df_a123 = filter_df_by_curies(df, column=column, curies=["a:1", "a:2", "b:1"]) self.assertEqual({"a:1", "a:2", "b:1"}, set(df_a123[column])) prefixes_index = { "a": [0, 1, 2, 3, 4], "b": [5, 6, 7, 8, 9], "c": [10, 11, 12, 13, 14, 15, 16, 17, 18, 19], } self.assertEqual(prefixes_index, get_df_prefixes_index(df, column=column)) self.assertEqual(prefixes_index, get_df_prefixes_index(df[column])) for curies_index in [ get_df_curies_index(df, column=column), get_df_curies_index(df[column]), ]: self.assertNotIn("a", curies_index) self.assertNotIn("b", curies_index) self.assertNotIn("c", curies_index) self.assertIn("a:0", curies_index) self.assertEqual([0], curies_index["a:0"]) self.assertEqual([10, 15], curies_index["c:0"]) self.assertEqual(set("abc"), get_df_unique_prefixes(df, column=column)) self.assertEqual(set("abc"), get_df_unique_prefixes(df[column])) def _rr(series: "pd.Series[bool]") -> list[int]: return [index for index, value in enumerate(series) if value] biopragmatics-curies-aa8bd37/tests/test_discovery.py000066400000000000000000000060041525104130300230750ustar00rootroot00000000000000"""Test discovering a prefix map from a list of URIs.""" import unittest from typing import ClassVar import rdflib from curies import Converter, Record from curies.discovery import discover, discover_from_rdf from tests.constants import SLOW class TestDiscovery(unittest.TestCase): """Test discovery of URI prefixes.""" converter: ClassVar[Converter] @classmethod def setUpClass(cls) -> None: """Set up the test case with a dummy converter.""" cls.converter = Converter( [ Record(prefix="GO", uri_prefix="http://purl.obolibrary.org/obo/GO_"), Record(prefix="rdfs", uri_prefix=str(rdflib.RDFS._NS)), ] ) def test_simple(self) -> None: """Test a simple case of discovering URI prefixes.""" uris = [f"http://ran.dom/{i:03}" for i in range(30)] uris.append("http://purl.obolibrary.org/obo/GO_0001234") converter = discover(uris, cutoff=3, converter=self.converter) self.assertEqual([Record(prefix="ns1", uri_prefix="http://ran.dom/")], converter.records) self.assertEqual("ns1:001", converter.compress("http://ran.dom/001")) self.assertIsNone( converter.compress("http://purl.obolibrary.org/obo/GO_0001234"), msg="discovered converter should not inherit reference converter's definitions", ) converter = discover(uris, cutoff=50, converter=self.converter) self.assertEqual([], converter.records) self.assertIsNone( converter.compress("http://ran.dom/001"), msg="cutoff was high, so discovered converter should not detect `http://ran.dom/`", ) def test_rdflib(self) -> None: """Test discovery in RDFlib.""" graph = rdflib.Graph() for i in range(30): graph.add( ( rdflib.URIRef(f"http://ran.dom/{i:03}"), rdflib.RDFS.subClassOf, rdflib.URIRef(f"http://ran.dom/{i + 1:03}"), ) ) graph.add( ( rdflib.URIRef(f"http://ran.dom/{i:03}"), rdflib.RDFS.label, rdflib.Literal(f"Node {i}"), ) ) converter = discover_from_rdf(graph, converter=self.converter) self.assertEqual([Record(prefix="ns1", uri_prefix="http://ran.dom/")], converter.records) self.assertEqual("ns1:001", converter.compress("http://ran.dom/001")) self.assertIsNone( converter.compress("http://purl.obolibrary.org/obo/GO_0001234"), msg="discovered converter should not inherit reference converter's definitions", ) @SLOW def test_remote(self) -> None: """Test parsing AEON.""" converter = discover_from_rdf( graph="https://raw.githubusercontent.com/tibonto/aeon/main/aeon.owl", format="xml", ) self.assertIn("http://purl.obolibrary.org/obo/AEON_", converter.reverse_prefix_map) biopragmatics-curies-aa8bd37/tests/test_federated_sparql.py000066400000000000000000000121611525104130300243740ustar00rootroot00000000000000"""Tests federated SPARQL queries with a locally deployed triple store.""" import itertools as itt import time import unittest from multiprocessing import Process from typing import ClassVar import fastapi import uvicorn from curies import Converter from curies.mapping_service import get_fastapi_mapping_app from curies.mapping_service.utils import ( get_sparql_record_so_tuples, get_sparql_records, require_service, sparql_service_available, ) from tests.test_mapping_service import PREFIX_MAP BLAZEGRAPH_ENDPOINT = "http://localhost:9999/blazegraph/namespace/kb/sparql" BLAZEGRAPH_JAR_URL = ( "https://github.com/blazegraph/database/releases/download/BLAZEGRAPH_2_1_6_RC/blazegraph.jar" ) SPARQL_VALUES_FMT = """\ PREFIX owl: SELECT DISTINCT ?s ?o WHERE {{ SERVICE <{mapping_service}> {{ VALUES ?s {{ }} ?s owl:sameAs ?o }} }} """.rstrip() SPARQL_VALUES_FMT_2 = """\ PREFIX owl: SELECT DISTINCT ?s ?o WHERE {{ VALUES ?s {{ }} SERVICE <{mapping_service}> {{ ?s owl:sameAs ?o }} }} """.rstrip() SPARQL_VALUES_FMT_3 = """\ PREFIX owl: SELECT DISTINCT ?s ?o WHERE {{ SERVICE <{mapping_service}> {{ ?s owl:sameAs ?o }} VALUES ?s {{ }} }} """.rstrip() SPARQL_SIMPLE_FMT = """\ PREFIX owl: SELECT DISTINCT ?s ?o WHERE {{ SERVICE <{mapping_service}> {{ owl:sameAs ?o . ?s owl:sameAs ?o . }} }} """.rstrip() SPARQL_SIMPLE_FMT_2 = """\ PREFIX owl: SELECT DISTINCT ?s ?o WHERE {{ SERVICE <{mapping_service}> {{ ?s owl:sameAs ?o . owl:sameAs ?o . }} }} """.rstrip() # TODO test SPARQL_SIMPLE_FMT_3 = """\ PREFIX owl: SELECT DISTINCT ?s ?o WHERE {{ owl:sameAs ?o . SERVICE <{mapping_service}> {{ ?s owl:sameAs ?o . }} }} """.rstrip() # TODO test SPARQL_SIMPLE_FMT_4 = """\ PREFIX owl: SELECT DISTINCT ?s ?o WHERE {{ SERVICE <{mapping_service}> {{ ?s owl:sameAs ?o . }} owl:sameAs ?o . }} """.rstrip() class FederationMixin(unittest.TestCase): """A shared mixin for testing.""" def assert_service_works(self, endpoint: str) -> None: """Assert that a service is able to accept a simple SPARQL query.""" self.assertTrue(sparql_service_available(endpoint)) def _get_app() -> fastapi.FastAPI: converter = Converter.from_priority_prefix_map(PREFIX_MAP) app = get_fastapi_mapping_app(converter) return app @require_service(BLAZEGRAPH_ENDPOINT, "Blazegraph") class TestFederatedSparql(FederationMixin): """Test the identifier mapping service.""" endpoint: ClassVar[str] = BLAZEGRAPH_ENDPOINT mimetypes: ClassVar[list[str]] = [ "application/sparql-results+json", "application/sparql-results+xml", "text/csv", # for some reason, Blazegraph wants this instead of application/sparql-results+csv ] query_formats: ClassVar[list[str]] = [ SPARQL_VALUES_FMT, SPARQL_VALUES_FMT_2, SPARQL_VALUES_FMT_3, SPARQL_SIMPLE_FMT, SPARQL_SIMPLE_FMT_2, ] host: ClassVar[str] = "localhost" port: ClassVar[int] = 8000 mapping_service_process: Process def setUp(self) -> None: """Set up the test case.""" # Start the curies mapping service SPARQL endpoint self.mapping_service_process = Process( target=uvicorn.run, # uvicorn.run accepts a zero-argument callable that returns an app args=(_get_app,), kwargs={"host": self.host, "port": self.port, "log_level": "info"}, daemon=True, ) self.mapping_service_process.start() time.sleep(5) self.mapping_service = f"http://{self.host}:{self.port}/sparql" self.queries = [ query_format.format(mapping_service=self.mapping_service) for query_format in self.query_formats ] self.assert_service_works(self.mapping_service) def tearDown(self) -> None: """Tear down the testing case.""" self.mapping_service_process.kill() def test_federated_local(self) -> None: """Test sending a federated query to a local mapping service from a local service.""" for mimetype, sparql in itt.product(self.mimetypes, self.queries): with self.subTest(mimetype=mimetype, sparql=sparql): records = get_sparql_records(self.endpoint, sparql, accept=mimetype) self.assertIn( ( "http://purl.obolibrary.org/obo/CHEBI_24867", "http://identifiers.org/chebi/24867", ), get_sparql_record_so_tuples(records), ) biopragmatics-curies-aa8bd37/tests/test_io.py000066400000000000000000000075171525104130300215070ustar00rootroot00000000000000"""Test writing I/O.""" import json import unittest from collections.abc import Iterable from pathlib import Path from tempfile import TemporaryDirectory from typing import cast import rdflib import curies from curies import Converter CHEBI_URI_PREFIX = "http://purl.obolibrary.org/obo/CHEBI_" class TestIO(unittest.TestCase): """Test I/O.""" def setUp(self) -> None: """Set up the test case.""" self.prefix = "CHEBI" self.uri_prefix = CHEBI_URI_PREFIX self.prefix_synonym = "p" self.uri_prefix_synonym = "u" self.pattern = "^\\d{7}$" self.converter = Converter.from_extended_prefix_map( [ { "prefix": self.prefix, "prefix_synonyms": [self.prefix_synonym], "uri_prefix": self.uri_prefix, "uri_prefix_synonyms": [self.uri_prefix_synonym], "pattern": self.pattern, }, ] ) def test_write_epm(self) -> None: """Test writing and reading an extended prefix map.""" with TemporaryDirectory() as d: path = Path(d).joinpath("test.json") curies.write_extended_prefix_map(self.converter, path) nc = curies.load_extended_prefix_map(path) self.assertEqual(self.converter.records, nc.records) self.assertEqual({self.prefix: self.pattern}, nc.pattern_map) def test_write_jsonld_with_bimap(self) -> None: """Test writing and reading a prefix map via JSON-LD.""" with TemporaryDirectory() as d: path = Path(d).joinpath("test.json") curies.write_jsonld_context(self.converter, path.as_posix()) nc = curies.load_jsonld_context(path) self.assertEqual({self.prefix: self.uri_prefix}, nc.prefix_map) self.assertEqual( {self.uri_prefix: self.prefix}, nc.reverse_prefix_map, msg="the prefix synonym should not survive round trip", ) self.assertEqual({self.prefix: self.uri_prefix}, nc.bimap) def test_write_jsonld_with_synonyms(self) -> None: """Test writing a JSON-LD with synonyms.""" # note: we don't test loading since loading a JSON-LD with synonyms is undefined for expand in [True, False]: with self.subTest(expand=expand): with TemporaryDirectory() as d: path = Path(d).joinpath("test.json") curies.write_jsonld_context(self.converter, path, include_synonyms=True) data = json.loads(path.read_text())["@context"] self.assertEqual({self.prefix, self.prefix_synonym}, set(data)) def test_shacl(self) -> None: """Test round-tripping SHACL.""" with TemporaryDirectory() as d: path = Path(d).joinpath("test.ttl") curies.write_shacl(self.converter, path) nc = curies.load_shacl(path) self.assertEqual(self.converter.bimap, nc.bimap) self.assertEqual({self.prefix: self.pattern}, nc.pattern_map) def test_shacl_with_synonyms(self) -> None: """Test writing SHACL with synonyms.""" # note: we don't test loading since loading SHACL with synonyms is undefined with TemporaryDirectory() as d: path = Path(d).joinpath("test.ttl") curies.write_shacl(self.converter, path, include_synonyms=True) graph = rdflib.Graph() graph.parse(location=path.as_posix(), format="turtle") query = """\ SELECT ?prefix WHERE { ?bnode sh:declare ?declaration . ?declaration sh:prefix ?prefix . } """ results = cast(Iterable[tuple[str]], graph.query(query)) self.assertEqual({self.prefix, self.prefix_synonym}, {str(prefix) for (prefix,) in results}) biopragmatics-curies-aa8bd37/tests/test_mapping_service.py000066400000000000000000000352071525104130300242500ustar00rootroot00000000000000"""Tests for the identifier mapping service.""" import unittest from urllib.parse import quote import httpx2 import werkzeug.test from fastapi.testclient import TestClient from flask.testing import FlaskClient from rdflib import OWL, SKOS from rdflib.query import Result from curies import Converter from curies.mapping_service import ( MappingServiceGraph, MappingServiceSPARQLProcessor, get_fastapi_mapping_app, get_flask_mapping_app, ) from curies.mapping_service.api import _prepare_predicates from curies.mapping_service.utils import ( CONTENT_TYPE_SYNONYMS, CONTENT_TYPE_TO_HANDLER, handle_header, sparql_service_available, ) from tests.constants import SLOW VALID_CONTENT_TYPES = { *CONTENT_TYPE_TO_HANDLER, "", "*/*", *CONTENT_TYPE_SYNONYMS, } PREFIX_MAP = { "CHEBI": [ "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=", "http://identifiers.org/chebi/", "http://purl.obolibrary.org/obo/CHEBI_", ], "GO": ["http://purl.obolibrary.org/obo/GO_"], "OBO": ["http://purl.obolibrary.org/obo/"], } SPARQL_SIMPLE = """\ SELECT DISTINCT ?s ?o WHERE { VALUES ?s { } ?s owl:sameAs ?o } """.rstrip() SPARQL_SIMPLE_BACKWARDS = """\ SELECT DISTINCT ?s ?o WHERE { VALUES ?o { } ?s owl:sameAs ?o } """.rstrip() #: This represents a SPARQL query that happens when a service generates it SPARQL_FROM_SERVICE = """\ SELECT REDUCED * WHERE { ?s owl:sameAs ?o . } VALUES (?s) { () () } """ SPARQL_FULLY_SPECIFIED_NOPE = """\ SELECT * WHERE { owl:sameAs } """ EXPECTED = { ( "http://purl.obolibrary.org/obo/CHEBI_1", "http://purl.obolibrary.org/obo/CHEBI_1", ), ("http://purl.obolibrary.org/obo/CHEBI_1", "http://identifiers.org/chebi/1"), ( "http://purl.obolibrary.org/obo/CHEBI_1", "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=1", ), ( "http://purl.obolibrary.org/obo/CHEBI_2", "http://purl.obolibrary.org/obo/CHEBI_2", ), ("http://purl.obolibrary.org/obo/CHEBI_2", "http://identifiers.org/chebi/2"), ( "http://purl.obolibrary.org/obo/CHEBI_2", "https://www.ebi.ac.uk/chebi/searchId.do?chebiId=2", ), } def _stm(rows: Result) -> set[tuple[str, str]]: return {(str(row.s), str(row.o)) for row in rows} # type: ignore class TestMappingService(unittest.TestCase): """Test the identifier mapping service.""" def setUp(self) -> None: """Set up the converter.""" self.converter = Converter.from_priority_prefix_map(PREFIX_MAP) self.graph = MappingServiceGraph(converter=self.converter) self.processor = MappingServiceSPARQLProcessor(self.graph) # type:ignore[no-untyped-call] def _query(self, sparql: str) -> Result: return self.graph.query(sparql, processor=self.processor) def test_parse_header(self) -> None: """Test parsing a rather complex header.""" example_header = ( "application/sparql-results+xml;q=0.8," "application/xml;q=0.8," "application/x-binary-rdf-results-table," "application/sparql-results+json;q=0.8," "application/json;q=0.8," "text/csv;q=0.8," "text/tab-separated-values;q=0.8" ) content_type = handle_header(example_header) self.assertEqual("application/sparql-results+xml", content_type) def test_prepare_predicates(self) -> None: """Test preparation of predicates.""" self.assertEqual({OWL.sameAs}, _prepare_predicates()) self.assertEqual({OWL.sameAs}, _prepare_predicates(OWL.sameAs)) self.assertEqual( {OWL.sameAs, SKOS.exactMatch}, _prepare_predicates({OWL.sameAs, SKOS.exactMatch}) ) def test_errors(self) -> None: """Test errors.""" for sparql in [ # errors because of unbound subject "SELECT ?s ?o WHERE { ?s owl:sameAs ?o }", # errors because of bad predicate "SELECT ?o WHERE { rdfs:seeAlso ?o }", "SELECT ?s WHERE { ?s rdfs:seeAlso }", # errors because of unknown URI "SELECT ?o WHERE { owl:sameAs ?o }", "SELECT ?s WHERE { ?s owl:sameAs }", # errors because predicate is given ( "SELECT * WHERE { " "owl:sameAs }" ), ]: with self.subTest(sparql=sparql): self.assertEqual([], list(self._query(sparql))) def test_sparql(self) -> None: """Test a sparql query on the graph.""" rows = _stm(self._query(SPARQL_SIMPLE)) self.assertNotEqual(0, len(rows), msg="No results were returned") self.assertEqual(EXPECTED, rows) def test_ask(self) -> None: """Test fully specified mapping triples.""" qq = [ ( True, "ASK WHERE { owl:sameAs }", ), ( False, "ASK WHERE { owl:sameAs }", ), ] for answer, sparql in qq: with self.subTest(sparql=sparql): self.assertEqual(answer, self._query(sparql).askAnswer) def test_sparql_backwards(self) -> None: """Test a sparql query on the graph.""" rows = _stm(self._query(SPARQL_SIMPLE_BACKWARDS)) self.assertNotEqual(0, len(rows), msg="No results were returned") expected = {(o, s) for s, o in EXPECTED} self.assertEqual(expected, rows) def test_service_sparql(self) -> None: """Test the SPARQL that gets sent when using this as a service.""" rows = _stm(self._query(SPARQL_FROM_SERVICE)) self.assertNotEqual(0, len(rows), msg="No results were returned") self.assertEqual(EXPECTED, rows) def test_fully_specified_nope(self) -> None: """Test the SPARQL that gets sent when using this as a service.""" rows = list(self._query(SPARQL_FULLY_SPECIFIED_NOPE)) self.assertEqual(0, len(rows), msg="No results were returned") def test_missing(self) -> None: """Test a sparql query on the graph where the URIs can't be parsed.""" sparql = """\ SELECT ?s ?o WHERE { VALUES ?s { } ?s owl:sameAs ?o } """ self.assertEqual([], list(self._query(sparql))) def test_safe_expand(self) -> None: """Test that expansion to invalid prefixes doesn't happen.""" ppm = { "CHEBI": [ "http://purl.obolibrary.org/obo/CHEBI_", "http://identifiers.org/chebi/", "http://identifiers.org/chebi/nope nope:", ], } converter = Converter.from_priority_prefix_map(ppm) graph = MappingServiceGraph(converter=converter) self.assertEqual( {"http://purl.obolibrary.org/obo/CHEBI_1", "http://identifiers.org/chebi/1"}, set(map(str, graph._expand_pair_all("http://purl.obolibrary.org/obo/CHEBI_1"))), ) class ConverterMixin(unittest.TestCase): """A mixin that has a converter.""" def setUp(self) -> None: """Set up the test case with a converter.""" super().setUp() self.converter = Converter.from_priority_prefix_map(PREFIX_MAP) def assert_mimetype( self, res: httpx2.Response | werkzeug.test.TestResponse, content_type: str ) -> None: """Assert the correct MIMETYPE.""" content_type = handle_header(content_type) mimetype = getattr(res, "mimetype", None) if hasattr(res, "mimetype"): # this is from Flask self.assertEqual(content_type, mimetype) else: # this is from FastAPI actual_content_type = res.headers.get("content-type") if actual_content_type is None: self.fail("content type was none") self.assertEqual(content_type, actual_content_type.split(";")[0].strip()) def assert_parsed( self, res: httpx2.Response | werkzeug.test.TestResponse, content_type: str ) -> None: """Test the result has the expected output.""" content_type = handle_header(content_type) parse_func = CONTENT_TYPE_TO_HANDLER[content_type] records = parse_func(res.text) pairs = {(record["s"], record["o"]) for record in records} self.assertEqual(EXPECTED, pairs) def assert_get_sparql_results(self, client: TestClient | FlaskClient, sparql: str) -> None: """Test a sparql query returns expected values.""" for content_type in sorted(VALID_CONTENT_TYPES): with self.subTest(content_type=content_type): res = client.get(f"/sparql?query={quote(sparql)}", headers={"accept": content_type}) self.assertEqual(200, res.status_code, msg=f"Response: {res}\n\n{res.text}") self.assert_mimetype(res, content_type) self.assert_parsed(res, content_type) def assert_post_sparql_results(self, client: TestClient | FlaskClient, sparql: str) -> None: """Test a sparql query returns expected values.""" for content_type in sorted(VALID_CONTENT_TYPES): with self.subTest(content_type=content_type): res = client.post( # note that we're using "data" and not JSON since this service # is posting "form data" and not a JSON payload "/sparql", data={"query": sparql}, headers={"accept": content_type}, ) self.assertEqual( 200, res.status_code, msg=f"Response: {res}", ) self.assert_mimetype(res, content_type) self.assert_parsed(res, content_type) class TestFlaskMappingWeb(ConverterMixin): """Test the Flask-based mapping service.""" def setUp(self) -> None: """Set up the test case with a converter and app.""" super().setUp() self.app = get_flask_mapping_app(self.converter) def test_get_missing_query(self) -> None: """Test error on missing query parameter.""" with self.app.test_client() as client: for content_type in sorted(VALID_CONTENT_TYPES): with self.subTest(content_type=content_type): res = client.get("/sparql", headers={"accept": content_type}) self.assertEqual(400, res.status_code, msg=f"Response: {res}") def test_post_missing_query(self) -> None: """Test error on missing query parameter.""" with self.app.test_client() as client: for content_type in sorted(VALID_CONTENT_TYPES): with self.subTest(content_type=content_type): res = client.post("/sparql", headers={"accept": content_type}) self.assertEqual(400, res.status_code, msg=f"Response: {res}") def test_get_query(self) -> None: """Test querying the app with GET.""" with self.app.test_client() as client: self.assert_get_sparql_results(client, SPARQL_SIMPLE) def test_post_query(self) -> None: """Test querying the app with POST.""" with self.app.test_client() as client: self.assert_post_sparql_results(client, SPARQL_SIMPLE) def test_get_service_query(self) -> None: """Test sparql generated by a service (that has values outside of where clause) with GET.""" with self.app.test_client() as client: self.assert_get_sparql_results(client, SPARQL_FROM_SERVICE) def test_post_service_query(self) -> None: """Test sparql generated by a service (that has values outside of where clause) with POST.""" with self.app.test_client() as client: self.assert_post_sparql_results(client, SPARQL_FROM_SERVICE) class TestFastAPIMappingApp(ConverterMixin): """Test the FastAPI-based mapping service.""" def setUp(self) -> None: """Set up the test case with a converter, blueprint, and app.""" super().setUp() self.app = get_fastapi_mapping_app(self.converter) self.client = TestClient(self.app) def test_get_missing_query(self) -> None: """Test error on missing query parameter.""" for content_type in sorted(VALID_CONTENT_TYPES): with self.subTest(content_type=content_type): res = self.client.get("/sparql", headers={"accept": content_type}) self.assertEqual(422, res.status_code, msg=f"Response: {res}") def test_post_missing_query(self) -> None: """Test error on missing query parameter.""" for content_type in sorted(VALID_CONTENT_TYPES): with self.subTest(content_type=content_type): res = self.client.post("/sparql", headers={"accept": content_type}) self.assertEqual(422, res.status_code, msg=f"Response: {res}") def test_get_query(self) -> None: """Test querying the app with GET.""" self.assert_get_sparql_results(self.client, SPARQL_SIMPLE) def test_post_query(self) -> None: """Test querying the app with POST.""" self.assert_post_sparql_results(self.client, SPARQL_SIMPLE) def test_get_service_query(self) -> None: """Test sparql generated by a service (that has values outside of where clause) with GET.""" self.assert_get_sparql_results(self.client, SPARQL_FROM_SERVICE) def test_post_service_query(self) -> None: """Test sparql generated by a service (that has values outside of where clause) with POST.""" self.assert_post_sparql_results(self.client, SPARQL_FROM_SERVICE) class TestUtils(unittest.TestCase): """Test utilities.""" @unittest.skip(reason="Wikidata query service is down") @SLOW def test_availability(self) -> None: """Test sparql service availability check.""" self.assertTrue( sparql_service_available("https://query.wikidata.org/bigdata/namespace/wdq/sparql") ) self.assertFalse(sparql_service_available("https://example.org")) biopragmatics-curies-aa8bd37/tests/test_mixins.py000066400000000000000000000051501525104130300223760ustar00rootroot00000000000000"""Test mixins.""" import unittest from typing import Self from pydantic import BaseModel import curies from curies import Converter, Reference from curies.mixins import ( SemanticallyProcessable, SemanticallyStandardizable, process_many, standardize_many, ) class TestMixins(unittest.TestCase): """Test mixins.""" def test_semantically_processable(self) -> None: """Test processing.""" class Processed(BaseModel): """A processed model, with a reference.""" reference: Reference class Raw(BaseModel, SemanticallyProcessable[Processed]): """A raw model, with a URI.""" uri: str def process(self, converter: Converter) -> Processed: """Process the raw model.""" return Processed(reference=converter.parse(self.uri, strict=True).to_pydantic()) converter = Converter.from_prefix_map({"GO": "http://purl.obolibrary.org/obo/GO_"}) initial = Raw(uri="http://purl.obolibrary.org/obo/GO_1234567") actual = initial.process(converter) self.assertIsInstance(actual, Processed) expected = Processed(reference=Reference(prefix="GO", identifier="1234567")) self.assertEqual(expected, actual) self.assertEqual([expected], process_many([initial], converter)) # check none handling self.assertIsNone(process_many(None, converter)) def test_standardizable(self) -> None: """Test standardizable.""" class HoldsReference(BaseModel, SemanticallyStandardizable): """A test class with a reference.""" reference: Reference def standardize(self, converter: Converter) -> Self: """Standardize the reference in the object.""" return self.model_copy( update={ "reference": converter.standardize_reference(self.reference, strict=True), } ) converter = Converter( [ curies.Record( prefix="TEST", prefix_synonyms=["test"], uri_prefix="https://example.org/" ) ] ) initial = HoldsReference(reference=Reference(prefix="test", identifier="1234567")) actual = initial.standardize(converter) expected = HoldsReference(reference=Reference(prefix="TEST", identifier="1234567")) self.assertEqual(expected, actual) self.assertEqual([expected], standardize_many([initial], converter)) # check none handling self.assertIsNone(standardize_many(None, converter)) biopragmatics-curies-aa8bd37/tests/test_preprocessing.py000066400000000000000000000211461525104130300237550ustar00rootroot00000000000000"""Tests for preprocessing converter.""" # coverage erase && coverage run -p -m pytest tests/test_wrapped.py --durations=20 && coverage combine && coverage html && open htmlcov/index.html import tempfile import unittest from pathlib import Path from typing import ClassVar from curies import Converter, ReferenceTuple from curies.preprocessing import ( BlocklistError, PostprocessingRewrites, PreprocessingBlocklists, PreprocessingConverter, PreprocessingRewrites, PreprocessingRules, ) EX1_RT = ReferenceTuple("GO", "1234567") EX1_URI = "http://purl.obolibrary.org/obo/GO_1234567" EX1_CURIE = "GO:1234567" DECOY_1_CURIE = "NOPE:NOPE" DECOY_1_URI = "https://example.org/NOPE/NOPE" class TestWrapped(unittest.TestCase): """Tests for preprocessing converter.""" rules: ClassVar[PreprocessingRules] inner_converter: ClassVar[Converter] converter: ClassVar[PreprocessingConverter] temporary_directory: ClassVar[tempfile.TemporaryDirectory[str]] directory: ClassVar[Path] rules_path: ClassVar[Path] @classmethod def setUpClass(cls) -> None: """Set up the test case.""" cls.rules = PreprocessingRules( rewrites=PreprocessingRewrites( full={ "is_a": "rdf:type", "http://creativecommons.org/licenses/by/3.0/": "spdx:CC-BY-3.0", }, prefix={ "OMIM:PS": "omim.ps:", "omim:PS": "omim.ps:", }, resource_prefix={ "clo": { "j": "NCIT:", }, }, resource_full={ "clo": { "nopeforever": "NCIT:5678", }, }, ), blocklists=PreprocessingBlocklists( full=["rdf:NOPE"], resource_prefix={ "chebi": [ "pubmed:" ] # this means that only throw away pubmed references in ChEBI }, resource_full={ "chebi": ["omim:1356"], # in case we just hate this CURIE/URI/string }, ), postprocessing=PostprocessingRewrites( suffix={ "emedicine": ["-overview"], } ), ) cls.inner_converter = Converter.from_prefix_map( { "GO": "http://purl.obolibrary.org/obo/GO_", "NCIT": "http://purl.obolibrary.org/obo/NCIT_", "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", "rdfs": "http://www.w3.org/2000/01/rdf-schema#", "omim": "https://omim.org/MIM:", "omim.ps": "https://omim.org/phenotypicSeries/PS", "pubmed": "http://rdf.ncbi.nlm.nih.gov/pubchem/reference/", "spdx": "https://spdx.org/licenses/", "emedicine": "http://emedicine.medscape.com/article/", } ) cls.temporary_directory = tempfile.TemporaryDirectory() cls.directory = Path(cls.temporary_directory.name) # write the rules just so we can test loading from a file cls.rules_path = cls.directory.joinpath("rules.json") cls.rules_path.write_text(cls.rules.model_dump_json(indent=2, exclude_unset=True)) cls.converter = PreprocessingConverter.from_converter( converter=cls.inner_converter, rules=cls.rules_path ) @classmethod def tearDownClass(cls) -> None: """Clean up the class.""" cls.temporary_directory.cleanup() def test_lint(self) -> None: """Run the linting code, just to make sure it works.""" self.rules.lint_file(self.rules_path) def test_unprocessed(self) -> None: """Tests that should be the same for both converters.""" for converter in (self.inner_converter, self.converter): self.assertEqual(EX1_CURIE, converter.compress(EX1_URI)) self.assertEqual(EX1_URI, converter.expand(EX1_CURIE)) self.assertEqual(EX1_RT, converter.parse(EX1_URI)) self.assertEqual(EX1_RT, converter.parse(EX1_CURIE)) self.assertEqual(EX1_RT, converter.parse_uri(EX1_URI)) self.assertEqual(EX1_RT, converter.parse_curie(EX1_CURIE)) self.assertIsNone(converter.compress(DECOY_1_URI)) self.assertIsNone(converter.expand(DECOY_1_CURIE)) self.assertIsNone(converter.parse_curie(DECOY_1_CURIE)) self.assertIsNone(converter.parse_uri(DECOY_1_URI)) self.assertIsNone(converter.parse(DECOY_1_URI)) self.assertIsNone(converter.parse(DECOY_1_CURIE)) def test_global_full_rewrite(self) -> None: """Test global full string rewrite.""" self.assertEqual(ReferenceTuple("rdf", "type"), self.converter.parse("is_a")) self.assertEqual(ReferenceTuple("rdf", "type"), self.converter.parse_curie("is_a")) self.assertEqual( ReferenceTuple("spdx", "CC-BY-3.0"), self.converter.parse_uri("http://creativecommons.org/licenses/by/3.0/"), ) def test_global_prefix_rewrite(self) -> None: """Test global prefix rewrite.""" self.assertEqual( ReferenceTuple("omim.ps", "1234"), self.converter.parse_curie("OMIM:PS1234") ) # test when there's a related rewrite rule, but not used self.assertEqual(ReferenceTuple("omim", "1234"), self.converter.parse_curie("omim:1234")) def test_resource_full_rewrite(self) -> None: """Test global full string rewrite.""" self.assertIsNone(self.converter.parse("nopeforever")) with self.assertRaises(ValueError): self.converter.parse_curie("nopeforever") self.assertEqual( ReferenceTuple("NCIT", "5678"), self.converter.parse_curie("nopeforever", context="clo"), ) def test_resource_prefix_rewrite(self) -> None: """Test resource-specific prefix rewrite.""" self.assertEqual( ReferenceTuple("NCIT", "1234"), self.converter.parse("j1234", context="clo"), ) # when we have rewrite rules for that ontology, but none apply self.assertIsNone(self.converter.parse("xyz", context="clo")) with self.assertRaises(ValueError): self.assertIsNone(self.converter.parse_curie("j1234")) with self.assertRaises(ValueError): self.assertIsNone(self.converter.parse_curie("j1234", context="chebi")) def test_resource_specific_blocklist(self) -> None: """Test resource-specific blocklist.""" self.assertEqual( ReferenceTuple("pubmed", "1234"), self.converter.parse_curie("pubmed:1234"), ) self.assertEqual( ReferenceTuple("pubmed", "1234"), self.converter.parse_curie("pubmed:1234", context="doid"), ) with self.assertRaises(BlocklistError): self.converter.parse_curie("pubmed:1234", context="chebi") self.assertIsNone( self.converter.parse_curie("pubmed:1234", context="chebi", block_action="pass") ) self.converter.parse_curie("omim:1234", context="chebi") # normally, OMIM works, but we configured a specific one for the blocklist with self.assertRaises(BlocklistError): self.converter.parse_curie("omim:1356", context="chebi") self.assertIsNone( self.converter.parse_curie("omim:1356", context="chebi", block_action="pass") ) def test_global_blocklist(self) -> None: """Test global blocklist.""" with self.assertRaises(BlocklistError): self.converter.parse("rdf:NOPE") self.assertIsNone(self.converter.parse("rdf:NOPE", block_action="pass")) with self.assertRaises(BlocklistError): self.converter.parse_curie("rdf:NOPE") self.assertIsNone(self.converter.parse_curie("rdf:NOPE", block_action="pass")) with self.assertRaises(BlocklistError): self.converter.parse_uri("rdf:NOPE") self.assertIsNone(self.converter.parse_uri("rdf:NOPE", block_action="pass")) def test_resource_specific_suffix_rewrite(self) -> None: """Test resource-specific suffix rewrites.""" target = ReferenceTuple("emedicine", "276512") for uri in [ "http://emedicine.medscape.com/article/276512", # clean "http://emedicine.medscape.com/article/276512-overview", # dirty ]: with self.subTest(uri=uri): self.assertEqual(target, self.converter.parse(uri)) biopragmatics-curies-aa8bd37/tests/test_reconciliation.py000066400000000000000000000347171525104130300241040ustar00rootroot00000000000000"""Tests for reconciliation.""" import unittest from curies import Converter, Record, remap_curie_prefixes, remap_uri_prefixes, rewire from curies.reconciliation import ( CycleDetected, DuplicateKeys, DuplicateValues, InconsistentMapping, _order_curie_remapping, ) #: The beginning of URIs used throughout examples P = "https://example.org" class TestUtils(unittest.TestCase): """Test utilities.""" def test_ordering(self) -> None: """Test ordering.""" converter = Converter( [ Record(prefix="a", uri_prefix=f"{P}/a/"), Record(prefix="b", uri_prefix=f"{P}/b/"), Record(prefix="c", uri_prefix=f"{P}/c/"), ] ) self.assertEqual( [("a", "a1"), ("b", "b1")], _order_curie_remapping(converter, {"a": "a1", "b": "b1"}) ) # we want to be as low down the chain first. Test both constructions of the dictionary self.assertEqual( [("c", "a"), ("b", "c")], _order_curie_remapping(converter, {"c": "a", "b": "c"}) ) self.assertEqual( [("c", "a"), ("b", "c")], _order_curie_remapping(converter, {"b": "c", "c": "a"}) ) def test_duplicate_values(self) -> None: """Test detecting bad mapping with duplicate.""" converter = Converter( [ Record(prefix="a", uri_prefix=f"{P}/a/"), Record(prefix="b", uri_prefix=f"{P}/b/"), Record(prefix="c", uri_prefix=f"{P}/c/"), ] ) curie_remapping = {"b": "c", "a": "c"} with self.assertRaises(DuplicateValues): _order_curie_remapping(converter, curie_remapping) def test_duplicate_keys(self) -> None: """Test detecting a bad mapping that contains multiple references to the same record in the keys.""" converter = Converter( [ Record(prefix="a", prefix_synonyms=["a1"], uri_prefix=f"{P}/a/"), Record(prefix="b", uri_prefix=f"{P}/b/"), Record(prefix="c", uri_prefix=f"{P}/c/"), ] ) curie_remapping = {"a": "c", "a1": "b"} with self.assertRaises(DuplicateKeys): _order_curie_remapping(converter, curie_remapping) def test_duplicate_correspondence(self) -> None: """Test detecting a bad mapping containing inconsistent references to the same record in the keys and values.""" converter = Converter( [ Record(prefix="a", prefix_synonyms=["a1"], uri_prefix=f"{P}/a/"), Record(prefix="b", uri_prefix=f"{P}/b/"), Record(prefix="c", uri_prefix=f"{P}/c/"), ] ) curie_remapping = {"a": "c", "b": "a1"} with self.assertRaises(InconsistentMapping): _order_curie_remapping(converter, curie_remapping) def test_cycles(self) -> None: """Test detecting bad mapping with cycles.""" converter = Converter( [ Record(prefix="a", uri_prefix=f"{P}/a/"), Record(prefix="b", uri_prefix=f"{P}/b/"), Record(prefix="c", uri_prefix=f"{P}/c/"), ] ) curie_remapping = {"b": "c", "c": "b"} with self.assertRaises(CycleDetected): remap_curie_prefixes(converter, curie_remapping) curie_remapping = {"a": "b", "b": "c", "c": "a"} with self.assertRaises(CycleDetected): _order_curie_remapping(converter, curie_remapping) class TestCURIERemapping(unittest.TestCase): """A test case for CURIE prefix remapping.""" def test_missing(self) -> None: """Test simple upgrade.""" records = [ Record(prefix="a", prefix_synonyms=["x"], uri_prefix=f"{P}/a/"), ] converter = Converter(records) curie_remapping = {"b": "c"} converter = remap_curie_prefixes(converter, curie_remapping) self.assertEqual(records, converter.records) def test_simple(self) -> None: """Test simple upgrade.""" records = [ Record(prefix="a", prefix_synonyms=["x"], uri_prefix=f"{P}/a/"), ] converter = Converter(records) curie_remapping = {"a": "a1"} converter = remap_curie_prefixes(converter, curie_remapping) self.assertEqual(1, len(converter)) self.assertEqual( Record(prefix="a1", prefix_synonyms=["a", "x"], uri_prefix=f"{P}/a/"), converter.records[0], ) def test_synonym(self) -> None: """Test that an upgrade configuration that would cause a clash does nothing.""" records = [ Record(prefix="a", prefix_synonyms=["x"], uri_prefix=f"{P}/a/"), ] converter = Converter(records) curie_remapping = {"a": "x"} converter = remap_curie_prefixes(converter, curie_remapping) self.assertEqual(1, len(converter)) self.assertEqual( Record(prefix="x", prefix_synonyms=["a"], uri_prefix=f"{P}/a/"), converter.records[0], ) def test_clash(self) -> None: """Test that an upgrade configuration that would cause a clash does nothing.""" records = [ Record(prefix="a", prefix_synonyms=["x"], uri_prefix=f"{P}/a/"), Record(prefix="b", prefix_synonyms=["y"], uri_prefix=f"{P}/b/"), ] converter = Converter(records) curie_remapping = {"a": "b"} converter = remap_curie_prefixes(converter, curie_remapping) self.assertEqual(2, len(converter)) self.assertEqual(records, converter.records) def test_clash_synonym(self) -> None: """Test a clash on a synonym.""" records = [ Record(prefix="a", prefix_synonyms=["x"], uri_prefix=f"{P}/a/"), Record(prefix="b", prefix_synonyms=["y"], uri_prefix=f"{P}/b/"), ] converter = Converter(records) curie_remapping = {"a": "y"} converter = remap_curie_prefixes(converter, curie_remapping) self.assertEqual(2, len(converter)) self.assertEqual(records, converter.records) def test_simultaneous(self) -> None: """Test simultaneous remapping.""" records = [ Record(prefix="geo", uri_prefix="https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc="), Record(prefix="geogeo", uri_prefix="http://purl.obolibrary.org/obo/GEO_"), ] converter = Converter(records) curie_remapping = {"geo": "ncbi.geo", "geogeo": "geo"} converter = remap_curie_prefixes(converter, curie_remapping) self.assertEqual( [ Record( prefix="geo", prefix_synonyms=["geogeo"], uri_prefix="http://purl.obolibrary.org/obo/GEO_", ), Record( prefix="ncbi.geo", uri_prefix="https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=", ), ], converter.records, ) def test_simultaneous_synonym(self) -> None: """Test simultaneous remapping with synonyms raises an error.""" records = [ Record( prefix="geo", prefix_synonyms=["ggg"], uri_prefix="https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc=", ), Record(prefix="geogeo", uri_prefix="http://purl.obolibrary.org/obo/GEO_"), ] converter = Converter(records) curie_remapping = {"ggg": "ncbi.geo", "geogeo": "geo"} with self.assertRaises(InconsistentMapping): remap_curie_prefixes(converter, curie_remapping) class TestURIRemapping(unittest.TestCase): """A test case for URI prefix remapping.""" def test_transitive_error(self) -> None: """Test error on transitive remapping.""" converter = Converter([]) uri_remapping = {f"{P}/nope/": f"{P}/more-nope/", f"{P}/more-nope/": f"{P}/more-more-nope/"} with self.assertRaises(NotImplementedError) as e: remap_uri_prefixes(converter, uri_remapping) # check that stringification works self.assertIn("75", str(e.exception)) def test_missing(self) -> None: """Test simple upgrade.""" records = [ Record(prefix="a", uri_prefix=f"{P}/a/"), ] converter = Converter(records) uri_remapping = {f"{P}/nope/": f"{P}/more-nope/"} converter = remap_uri_prefixes(converter, uri_remapping) self.assertEqual(records, converter.records) def test_simple(self) -> None: """Test simple upgrade.""" records = [ Record(prefix="a", uri_prefix=f"{P}/a/", uri_prefix_synonyms=[f"{P}/a1/"]), ] converter = Converter(records) uri_remapping = {f"{P}/a/": f"{P}/a2/"} converter = remap_uri_prefixes(converter, uri_remapping) self.assertEqual(1, len(converter)) self.assertEqual( Record( prefix="a", uri_prefix=f"{P}/a2/", uri_prefix_synonyms=[f"{P}/a/", f"{P}/a1/"], ), converter.records[0], ) def test_synonym(self) -> None: """Test that an upgrade configuration that would cause a clash does nothing.""" records = [ Record(prefix="a", uri_prefix=f"{P}/a/", uri_prefix_synonyms=[f"{P}/a1/"]), ] converter = Converter(records) uri_remapping = {f"{P}/a1/": f"{P}/a2/"} converter = remap_uri_prefixes(converter, uri_remapping) self.assertEqual(1, len(converter)) self.assertEqual( Record(prefix="a", uri_prefix=f"{P}/a2/", uri_prefix_synonyms=[f"{P}/a/", f"{P}/a1/"]), converter.records[0], ) def test_clash_preferred(self) -> None: """Test that an upgrade configuration that would cause a clash does nothing.""" records = [ Record(prefix="a", prefix_synonyms=["x"], uri_prefix=f"{P}/a/"), Record(prefix="b", prefix_synonyms=["y"], uri_prefix=f"{P}/b/"), ] converter = Converter(records) upgrades = {f"{P}/a/": f"{P}/b/"} converter = remap_uri_prefixes(converter, upgrades) self.assertEqual(2, len(converter)) self.assertEqual(records, converter.records) def test_clash_synonym(self) -> None: """Test clashing with a synonym.""" records = [ Record(prefix="a", uri_prefix=f"{P}/a/"), Record(prefix="b", uri_prefix=f"{P}/b/", uri_prefix_synonyms=[f"{P}/b1/"]), ] converter = Converter(records) upgrades = {f"{P}/a/": f"{P}/b1/"} converter = remap_uri_prefixes(converter, upgrades) self.assertEqual(2, len(converter)) self.assertEqual(records, converter.records) class TestRewire(unittest.TestCase): """A test case for rewiring.""" def test_idempotent(self) -> None: """Test that a redundant rewiring doesn't do anything.""" records = [ Record(prefix="a", uri_prefix=f"{P}/a/", uri_prefix_synonyms=["https://a.org/"]), ] converter = Converter(records) rewiring = {"a": f"{P}/a/"} converter = rewire(converter, rewiring) self.assertEqual(records, converter.records) def test_upgrade_uri_prefixes_simple(self) -> None: """Test simple upgrade.""" records = [ Record(prefix="a", uri_prefix=f"{P}/a/", uri_prefix_synonyms=["https://a.org/"]), ] converter = Converter(records) rewiring = {"a": f"{P}/a1/"} converter = rewire(converter, rewiring) self.assertEqual(1, len(converter)) self.assertEqual( Record( prefix="a", uri_prefix=f"{P}/a1/", uri_prefix_synonyms=["https://a.org/", f"{P}/a/"] ), converter.records[0], ) # def test_upgrade_uri_prefixes_add(self): # """Test an upgrade that adds an extra prefix.""" # records = [ # Record(prefix="a", uri_prefix=f"{P}/a/"), # ] # converter = Converter(records) # rewiring = {"b": f"{P}/b/"} # converter = rewire(converter, rewiring) # self.assertEqual(2, len(converter)) # self.assertEqual( # [ # Record(prefix="a", uri_prefix=f"{P}/a/"), # Record(prefix="b", uri_prefix=f"{P}/b/"), # ], # converter.records, # ) def test_upgrade_uri_prefixes_clash(self) -> None: """Test an upgrade that does nothing since it would create a clash.""" records = [ Record(prefix="a", uri_prefix=f"{P}/a/"), Record(prefix="b", uri_prefix=f"{P}/b/"), ] converter = Converter(records) rewiring = {"b": f"{P}/a/"} converter = rewire(converter, rewiring) self.assertEqual(2, len(converter)) self.assertEqual( [ Record(prefix="a", uri_prefix=f"{P}/a/"), Record(prefix="b", uri_prefix=f"{P}/b/"), ], converter.records, ) def test_upgrade_uri_upgrade(self) -> None: """Test an upgrade of an existing URI prefix synonym.""" records = [ Record(prefix="a", uri_prefix=f"{P}/a/", uri_prefix_synonyms=[f"{P}/a1/"]), ] converter = Converter(records) rewiring = {"a": f"{P}/a1/"} converter = rewire(converter, rewiring) self.assertEqual(1, len(converter)) self.assertEqual( [ Record( prefix="a", uri_prefix=f"{P}/a1/", uri_prefix_synonyms=[f"{P}/a/"], ), ], converter.records, ) def test_upgrade_uri_upgrade_with_curie_prefix(self) -> None: """Test an upgrade of an existing URI prefix synonym via a CURIE prefix synonym.""" records = [ Record( prefix="a", prefix_synonyms=["a1"], uri_prefix=f"{P}/a/", uri_prefix_synonyms=[f"{P}/a1/"], ), ] converter = Converter(records) rewiring = {"a1": f"{P}/a1/"} converter = rewire(converter, rewiring) self.assertEqual(1, len(converter)) self.assertEqual( [ Record( prefix="a", prefix_synonyms=["a1"], uri_prefix=f"{P}/a1/", uri_prefix_synonyms=[f"{P}/a/"], ), ], converter.records, ) biopragmatics-curies-aa8bd37/tests/test_resolver_service.py000066400000000000000000000063551525104130300244600ustar00rootroot00000000000000"""Tests for the simple web service.""" import unittest from typing import ClassVar from fastapi.testclient import TestClient from curies import Converter from curies.resolver_service import FAILURE_CODE, get_fastapi_app, get_flask_app class ConverterMixin(unittest.TestCase): """A mixin that has a converter.""" delimiter: ClassVar[str] = ":" def setUp(self) -> None: """Set up the test case with a converter.""" super().setUp() self.converter = Converter.from_prefix_map( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", "MONDO": "http://purl.obolibrary.org/obo/MONDO_", "GO": "http://purl.obolibrary.org/obo/GO_", "OBO": "http://purl.obolibrary.org/obo/", }, delimiter=self.delimiter, ) class TestFastAPI(ConverterMixin): """Test building a simple web service with FastAPI.""" def setUp(self) -> None: """Set up the test case with a converter, blueprint, and app.""" super().setUp() self.app = get_fastapi_app(self.converter) self.client = TestClient(self.app) def test_resolve_success(self) -> None: """Test resolution for a valid CURIE redirects properly.""" curie = self.converter.format_curie("GO", "1234567") res = self.client.get(f"/{curie}", follow_redirects=False) self.assertEqual(302, res.status_code, msg=res.text) def test_resolve_failure(self) -> None: """Test resolution for an invalid CURIE aborts with 404.""" curie = self.converter.format_curie("NOPREFIX", "NOIDENTIFIER") res = self.client.get(f"/{curie}", follow_redirects=False) self.assertEqual(FAILURE_CODE, res.status_code, msg=res.text) class TestFastAPISlashed(TestFastAPI): """Test the FastAPI router with an alternate delimiter.""" delimiter = "/" def test_delimiter(self) -> None: """Test the delimiter.""" self.assertEqual("/", self.converter.delimiter) class TestFlaskBlueprint(ConverterMixin): """Test building a simple web service with Flask.""" def setUp(self) -> None: """Set up the test case with a converter, blueprint, and app.""" super().setUp() self.app = get_flask_app(self.converter) def test_resolve_success(self) -> None: """Test resolution for a valid CURIE redirects properly.""" curie = self.converter.format_curie("GO", "1234567") with self.app.test_client() as client: res = client.get(f"/{curie}", follow_redirects=False) self.assertEqual(302, res.status_code, msg=res.text) def test_resolve_failure(self) -> None: """Test resolution for an invalid CURIE aborts with 404.""" curie = self.converter.format_curie("NOPREFIX", "NOIDENTIFIER") with self.app.test_client() as client: res = client.get(f"/{curie}", follow_redirects=False) self.assertEqual(FAILURE_CODE, res.status_code, msg=res.text) class TestFlaskBlueprintSlashed(TestFlaskBlueprint): """Test the flask blueprint with an alternate delimiter.""" delimiter = "/" def test_delimiter(self) -> None: """Test the delimiter.""" self.assertEqual("/", self.converter.delimiter) biopragmatics-curies-aa8bd37/tests/test_struct.py000066400000000000000000000161161525104130300224170ustar00rootroot00000000000000"""Tests for the structure.""" from __future__ import annotations import unittest from pydantic import ValidationError import curies from curies.api import ( Converter, NamableReference, NamedReference, Records, Reference, ReferenceTuple, ) from curies.utils import NoCURIEDelimiterError CHEBI_URI_PREFIX = "http://purl.obolibrary.org/obo/CHEBI_" class TestStruct(unittest.TestCase): """Test the data structures.""" def test_not_curie(self) -> None: """Test a malformed CURIE.""" with self.assertRaises(NoCURIEDelimiterError) as e: Reference.from_curie("not a curie") self.assertIn("does not appear to be a CURIE", str(e.exception)) def test_curie_with_space(self) -> None: """Test a malformed CURIE.""" with self.assertRaises(ValidationError) as e: Reference(prefix="test", identifier="not a curie") self.assertIn( "local identifiers can not", str(e.exception), ) with self.assertRaises(ValidationError) as e: Reference.from_curie("test:not a curie") self.assertIn("local identifiers can not", str(e.exception)) def test_default_prefix(self) -> None: """Test a default (empty) prefix.""" ref = Reference.from_curie(":something") self.assertEqual("", ref.prefix) self.assertEqual("something", ref.identifier) def test_default_identifier(self) -> None: """Test a default (empty) identifier.""" ref = Reference.from_curie("p1:") self.assertEqual("p1", ref.prefix) self.assertEqual("", ref.identifier) def test_multiple_delimiters(self) -> None: """Test a default (empty) identifier.""" ref = Reference.from_curie("a1:b2:c3") self.assertEqual("a1", ref.prefix) self.assertEqual("b2:c3", ref.identifier) def test_records(self) -> None: """Test a list of records.""" records = Records.model_validate([{"prefix": "chebi", "uri_prefix": CHEBI_URI_PREFIX}]) converter = Converter(records=records) self.assertEqual({"chebi"}, converter.get_prefixes()) def test_sort(self) -> None: """Test sorting.""" start = [ Reference.from_curie("def:1234"), Reference.from_curie("abc:1234"), Reference.from_curie("abc:1235"), ] expected = [ Reference.from_curie("abc:1234"), Reference.from_curie("abc:1235"), Reference.from_curie("def:1234"), ] self.assertEqual(expected, sorted(start)) def test_set_membership(self) -> None: """Test membership in sets.""" collection = { Reference.from_curie("def:1234"), Reference.from_curie("abc:1234"), Reference.from_curie("abc:1235"), } self.assertIn(Reference.from_curie("def:1234"), collection) self.assertNotIn(Reference.from_curie("xyz:1234"), collection) self.assertNotIn(Reference.from_curie(":1234"), collection) self.assertNotIn(Reference.from_curie("abc:"), collection) def test_named_set_membership(self) -> None: """Test membership in sets of named references.""" references = { NamedReference.from_curie("a:1", "name1"), NamedReference.from_curie("a:2", "name2"), } self.assertIn(Reference.from_curie("a:1"), references) self.assertIn(NamableReference.from_curie("a:1"), references) self.assertIn(NamedReference.from_curie("a:1", "name1"), references) self.assertIn(NamableReference.from_curie("a:1", "name1"), references) # the following is a weird case, but shows how this works self.assertIn(NamedReference.from_curie("a:1", "name2"), references) references_2 = { Reference.from_curie("a:1"), Reference.from_curie("a:2"), } self.assertIn(Reference.from_curie("a:1"), references_2) self.assertIn(NamableReference.from_curie("a:1", "name1"), references_2) self.assertIn(NamedReference.from_curie("a:1", "name1"), references_2) def test_tuple(self) -> None: """Test reference tuples.""" t = ReferenceTuple.from_curie("a:1") self.assertEqual(Reference(prefix="a", identifier="1"), t.to_pydantic()) self.assertEqual( NamedReference(prefix="a", identifier="1", name="name"), t.to_pydantic(name="name") ) with self.assertRaises(ValueError): t.to_pydantic(name="") def test_reference_constructor(self) -> None: """Test constructing a reference.""" r1 = Reference(prefix="a", identifier="1") r2 = NamableReference(prefix="a", identifier="2") r3 = NamableReference(prefix="a", identifier="3", name="item 3") r4 = NamedReference(prefix="a", identifier="4", name="item 4") self.assertEqual(Reference(prefix="a", identifier="1"), Reference.from_reference(r1)) self.assertEqual(Reference(prefix="a", identifier="2"), Reference.from_reference(r2)) self.assertEqual(Reference(prefix="a", identifier="3"), Reference.from_reference(r3)) self.assertEqual(Reference(prefix="a", identifier="4"), Reference.from_reference(r4)) self.assertEqual( NamableReference(prefix="a", identifier="1", name=None), NamableReference.from_reference(r1), ) self.assertEqual( NamableReference(prefix="a", identifier="2", name=None), NamableReference.from_reference(r2), ) self.assertEqual( NamableReference(prefix="a", identifier="3", name="item 3"), NamableReference.from_reference(r3), ) self.assertEqual( NamableReference(prefix="a", identifier="4", name="item 4"), NamableReference.from_reference(r4), ) with self.assertRaises(TypeError): NamedReference.from_reference(r1) with self.assertRaises(ValidationError): NamedReference.from_reference(r2) self.assertEqual( NamedReference(prefix="a", identifier="3", name="item 3"), NamedReference.from_reference(r3), ) self.assertEqual( NamedReference(prefix="a", identifier="4", name="item 4"), NamedReference.from_reference(r4), ) def test_without_name(self) -> None: """Test removing names.""" c1 = Reference.from_curie("a:1") c2 = NamableReference.from_curie("a:1") c3 = NamableReference.from_curie("a:1", name="test") c4 = NamedReference.from_curie("a:1", name="test") for reference in [c1, c2, c3, c4]: new = reference.without_name() self.assertIsInstance(new, Reference) self.assertNotIsInstance(new, NamableReference) def test_with_name(self) -> None: """Test with name.""" r1 = curies.Reference.from_curie("chebi:1234") r2 = r1.with_name("test") self.assertIsInstance(r2, NamedReference) r3 = r2.without_name() self.assertIsInstance(r3, Reference) self.assertNotIsInstance(r3, NamableReference) biopragmatics-curies-aa8bd37/tests/test_triples/000077500000000000000000000000001525104130300221765ustar00rootroot00000000000000biopragmatics-curies-aa8bd37/tests/test_triples/__init__.py000066400000000000000000000000311525104130300243010ustar00rootroot00000000000000"""Tests for triples.""" biopragmatics-curies-aa8bd37/tests/test_triples/test_filter.py000066400000000000000000000166721525104130300251100ustar00rootroot00000000000000"""Test filter functions.""" import unittest from collections.abc import Iterable from curies import Converter, Reference, Triple from curies.triples import ( exclude_object_prefixes, exclude_prefixes_both, exclude_references_both, exclude_same_prefixes, exclude_subject_prefixes, exclude_triples, keep_object_prefixes, keep_predicates, keep_prefixes_both, keep_prefixes_either, keep_references_both, keep_references_either, keep_subject_prefixes, keep_triples_by_hash, ) from curies.vocabulary import exact_match, subclass_of p1, p2, p3 = "DOID", "mesh", "umls" c1, c2, c3, c4 = "DOID:0050577", "mesh:C562966", "umls:C4551571", "DOID:225" r1, r2, r3, r4 = (Reference.from_curie(c) for c in [c1, c2, c3, c4]) m1 = Triple.from_curies(c1, exact_match.curie, c2) m2 = Triple.from_curies(c2, exact_match.curie, c3) m3 = Triple.from_curies(c1, exact_match.curie, c3) m4 = Triple.from_curies(c1, subclass_of.curie, c4) M123 = [m1, m2, m3] converter = Converter.from_prefix_map( { "DOID": "http://purl.obolibrary.org/obo/DOID_", "skos": "http://www.w3.org/2004/02/skos/core#", "mesh": "http://id.nlm.nih.gov/mesh/", "umls": "https://uts.nlm.nih.gov/uts/umls/concept/", } ) def _x(triples: Iterable[Triple]) -> str: return ", ".join("-".join(e.as_str_triple()) for e in triples) class TestFilters(unittest.TestCase): """Test filters.""" def assert_triple_lists(self, expected: list[Triple], actual: Iterable[Triple]) -> None: """Test two triple lists are the same.""" actual = list(actual) self.assertEqual(expected, actual, msg=f"\nExpected: {_x(expected)}\nActual: {_x(actual)}") def test_exclude_object_prefixes(self) -> None: """Test excluding object prefixes.""" self.assert_triple_lists([m1], exclude_object_prefixes(M123, "umls")) self.assert_triple_lists([m1], exclude_object_prefixes(M123, {"umls"})) self.assert_triple_lists([m2, m3], exclude_object_prefixes(M123, {"mesh"})) self.assert_triple_lists(M123, exclude_object_prefixes(M123, {"DOID"})) def test_exclude_prefixes(self) -> None: """Test excluding prefixes.""" self.assert_triple_lists([m1], exclude_prefixes_both(M123, "umls")) self.assert_triple_lists([m1], exclude_prefixes_both(M123, {"umls"})) self.assert_triple_lists([m2], exclude_prefixes_both(M123, {"DOID"})) self.assert_triple_lists([m3], exclude_prefixes_both(M123, {"mesh"})) def test_exclude_subject_prefixes(self) -> None: """Test excluding subject prefixes.""" self.assert_triple_lists([m2], exclude_subject_prefixes(M123, "DOID")) self.assert_triple_lists([m2], exclude_subject_prefixes(M123, {"DOID"})) self.assert_triple_lists(M123, exclude_subject_prefixes(M123, {"umls"})) self.assert_triple_lists([m1, m3], exclude_subject_prefixes(M123, {"mesh"})) def test_exclude_same_prefixes(self) -> None: """Test excluding same prefixes.""" self.assert_triple_lists(M123, exclude_same_prefixes([m1, m2, m3, m4])) def test_exclude_triples(self) -> None: """Test excluding triples.""" self.assert_triple_lists([m1, m2], exclude_triples(M123, m3)) self.assert_triple_lists([m1, m2], exclude_triples(M123, [m3])) def test_keep_object_prefixes(self) -> None: """Test keeping object prefixes.""" self.assert_triple_lists([m2, m3], keep_object_prefixes(M123, "umls")) self.assert_triple_lists([m2, m3], keep_object_prefixes(M123, {"umls"})) def test_keep_prefixes_both(self) -> None: """Test keeping prefixes.""" with self.assertRaises(ValueError): keep_prefixes_both(M123, {"DOID"}) self.assert_triple_lists([], keep_prefixes_both(M123, {"NOPE", "also nope"})) self.assert_triple_lists([m1], keep_prefixes_both(M123, {"DOID", "mesh"})) self.assert_triple_lists([m3], keep_prefixes_both(M123, {"DOID", "umls"})) self.assert_triple_lists([m2], keep_prefixes_both(M123, {"mesh", "umls"})) self.assert_triple_lists(M123, keep_prefixes_both(M123, {"DOID", "umls", "mesh"})) def test_keep_prefixes_either(self) -> None: """Test keeping prefixes.""" self.assert_triple_lists([], keep_prefixes_either(M123, {"NOPE", "also nope"})) self.assert_triple_lists([m1, m2], keep_prefixes_either(M123, "mesh")) self.assert_triple_lists([m1, m3], keep_prefixes_either(M123, "DOID")) self.assert_triple_lists([m2, m3], keep_prefixes_either(M123, "umls")) self.assert_triple_lists([m1, m2, m3], keep_prefixes_either(M123, {"DOID", "mesh"})) self.assert_triple_lists([m1, m2, m3], keep_prefixes_either(M123, {"DOID", "umls"})) self.assert_triple_lists([m1, m2, m3], keep_prefixes_either(M123, {"mesh", "umls"})) self.assert_triple_lists(M123, keep_prefixes_either(M123, {"DOID", "umls", "mesh"})) def test_keep_subject_prefixes(self) -> None: """Test keeping subject prefixes.""" self.assert_triple_lists([m1, m3], keep_subject_prefixes(M123, "DOID")) self.assert_triple_lists([m1, m3], keep_subject_prefixes(M123, {"DOID"})) def test_keep_triple_by_hash(self) -> None: """Test keeping triples by hash.""" self.assert_triple_lists( [m1], keep_triples_by_hash(M123, converter, converter.hash_triple(m1)) ) self.assert_triple_lists( [m1, m2], keep_triples_by_hash( M123, converter, [converter.hash_triple(m2), converter.hash_triple(m1)] ), ) def test_keep_references_either(self) -> None: """Test keeping references.""" self.assert_triple_lists([m1, m3], keep_references_either(M123, r1)) self.assert_triple_lists([m1, m3], keep_references_either(M123, [r1])) self.assert_triple_lists([m1, m2], keep_references_either(M123, [r2])) self.assert_triple_lists([m2, m3], keep_references_either(M123, [r3])) self.assert_triple_lists(M123, keep_references_either(M123, [r1, r2])) self.assert_triple_lists(M123, keep_references_either(M123, [r2, r3])) self.assert_triple_lists(M123, keep_references_either(M123, [r1, r2, r3])) def test_keep_references_both(self) -> None: """Test keeping references.""" with self.assertRaises(ValueError): keep_references_both(M123, [r1]) self.assert_triple_lists([m1], keep_references_both(M123, [r1, r2])) self.assert_triple_lists([m2], keep_references_both(M123, [r2, r3])) self.assert_triple_lists([m3], keep_references_both(M123, [r1, r3])) self.assert_triple_lists(M123, keep_references_both(M123, [r1, r2, r3])) def test_exclude_references(self) -> None: """Test exclude references.""" self.assert_triple_lists([m2], exclude_references_both(M123, r1)) self.assert_triple_lists([m2], exclude_references_both(M123, [r1])) self.assert_triple_lists([m3], exclude_references_both(M123, [r2])) self.assert_triple_lists([m1], exclude_references_both(M123, [r3])) def test_keep_predicate(self) -> None: """Test keep predicate.""" self.assert_triple_lists([m1, m2, m3], keep_predicates([m1, m2, m3, m4], exact_match)) self.assert_triple_lists([m1, m2, m3], keep_predicates([m1, m2, m3, m4], [exact_match])) self.assert_triple_lists( [m1, m2, m3, m4], keep_predicates([m1, m2, m3, m4], [exact_match, subclass_of]) ) biopragmatics-curies-aa8bd37/tests/test_triples/test_models.py000066400000000000000000000073371525104130300251040ustar00rootroot00000000000000"""Tests for triples.""" import itertools as itt import tempfile import unittest from pathlib import Path import pydantic import curies from curies import Reference, Triple from curies.triples import encode_curie_triple, encode_uri_triple, read_triples, write_triples T1 = Triple.from_curies("a:1", "a:2", "a:3") T2 = Triple.from_curies("a:1", "a:2", "a:4") class TestTriples(unittest.TestCase): """Test triples.""" def test_immutable(self) -> None: """Test immutable.""" with self.assertRaises(pydantic.ValidationError): T1.subject = Reference.from_curie("b:1") # type:ignore def test_as_curies(self) -> None: """Test stringifying.""" self.assertEqual( ("a:1", "a:2", "a:3"), T1.as_str_triple(), ) def test_roundtrip(self) -> None: """Test roundtrip.""" triples = [T1, T2] with tempfile.TemporaryDirectory() as directory: paths = [ Path(directory).joinpath("test.tsv.gz"), Path(directory).joinpath("test.tsv"), ] headers = [None, ("a", "b", "c")] for path, header in itt.product(paths, headers): with self.subTest(path=path): write_triples(triples, path, header=header) reconstituted = read_triples(path) self.assertEqual(triples, reconstituted) def test_sort(self) -> None: """Test sorting.""" self.assertEqual([T1, T2], sorted([T1, T2])) self.assertEqual([T1, T2], sorted([T2, T1])) def test_hash_uri_triple(self) -> None: """Test encoding triples of URI strings.""" examples: list[tuple[str, str, str, str]] = [ ( # example 1 from https://ts4nfdi.github.io/mapping-sameness-identifier/ "95a088082ab2b2a68638aebbcc3fe3e0f229da75a8b5bdbb9f3f8cd5e1e4286e", "http://example.org/feline", "http://www.w3.org/2002/07/owl#sameAs", "http://example.com/cat", ), ( "36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a", "http://id.nlm.nih.gov/mesh/C000089", "http://www.w3.org/2004/02/skos/core#exactMatch", "http://purl.obolibrary.org/obo/CHEBI_28646", ), ] for expected, s, p, o in examples: with self.subTest(): self.assertEqual(expected, encode_uri_triple((s, p, o))) def test_encode_curie_triple(self) -> None: """Test encoding triples of CURIE strings.""" converter = curies.load_prefix_map( { "mesh": "http://id.nlm.nih.gov/mesh/", "skos": "http://www.w3.org/2004/02/skos/core#", "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) triple = ("mesh:C000089", "skos:exactMatch", "CHEBI:28646") triple_id = encode_curie_triple(triple, converter) self.assertEqual( "36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a", triple_id, ) def test_hash_triple(self) -> None: """Test encoding Triple model.""" converter = curies.load_prefix_map( { "mesh": "http://id.nlm.nih.gov/mesh/", "skos": "http://www.w3.org/2004/02/skos/core#", "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } ) triple = Triple(subject="mesh:C000089", predicate="skos:exactMatch", object="CHEBI:28646") triple_id = converter.hash_triple(triple) self.assertEqual( "36a1f9244ea7641a90987c82f33c25c0c13712ee8f48207b2a0825f8a4e4e26a", triple_id, ) biopragmatics-curies-aa8bd37/tests/test_triples/test_ops.py000066400000000000000000000144141525104130300244140ustar00rootroot00000000000000"""Test triples operations.""" import unittest from curies import Reference from curies.triples import Triple from curies.triples.ops import ( exclude_prefix_stratified_many_to_many, flip_prefix_pair_stratified_index, get_one_to_many, get_prefix_pair_stratified_indexes, get_prefix_stratified_many_to_many, get_reference_indexes, ) from curies.vocabulary import exact_match predicate = exact_match p11 = Reference.from_curie("p1:1") p12 = Reference.from_curie("p1:2") p13 = Reference.from_curie("p1:3") p14 = Reference.from_curie("p1:4") p15 = Reference.from_curie("p1:5") p16 = Reference.from_curie("p1:6") p17 = Reference.from_curie("p1:7") p2a = Reference.from_curie("p2:A") p2b = Reference.from_curie("p2:B") p2c = Reference.from_curie("p2:C") p2d = Reference.from_curie("p2:D") p2e = Reference.from_curie("p2:E") p2f = Reference.from_curie("p2:F") p3gamma = Reference.from_curie("p3:gamma") def _triple(a: Reference, b: Reference, c: Reference) -> Triple: return Triple(subject=a, predicate=b, object=c) # one-to-one m1 = _triple(p11, predicate, p2a) # one-to-many m2 = _triple(p12, predicate, p2b) m3 = _triple(p12, predicate, p2c) # many-to-one m4 = _triple(p14, predicate, p2d) m5 = _triple(p15, predicate, p2d) # many-to-many m6 = _triple(p16, predicate, p2e) m7 = _triple(p16, predicate, p2f) m8 = _triple(p17, predicate, p2e) m9 = _triple(p17, predicate, p2f) m10 = _triple(p11, predicate, p3gamma) # duplicate of m1 m11 = _triple(p11, predicate, p2a) triples = [m1, m2, m3, m4, m5, m6, m7, m8, m9, m10, m11] class TestOperations(unittest.TestCase): """Test triples operations.""" def test_many_to_many_index(self) -> None: """Get getting a many-to-many index.""" forward, backward = get_prefix_pair_stratified_indexes(triples) with self.subTest(part="contents"): self.assertEqual( { ("p1", "p2"): { "1": {"A": [m1, m11]}, "2": {"B": [m2], "C": [m3]}, "4": {"D": [m4]}, "5": {"D": [m5]}, "6": {"E": [m6], "F": [m7]}, "7": {"E": [m8], "F": [m9]}, }, ("p1", "p3"): {"1": {"gamma": [m10]}}, }, forward, ) self.assertEqual( { ("p2", "p1"): { "A": {"1": [m1, m11]}, "B": {"2": [m2]}, "C": {"2": [m3]}, "D": {"4": [m4], "5": [m5]}, "E": {"6": [m6], "7": [m8]}, "F": {"6": [m7], "7": [m9]}, }, ("p3", "p1"): { "gamma": {"1": [m10]}, }, }, backward, ) with self.subTest(part="processing"): self.assertEqual( { ("p1", "p2"): { "2": {"B": [m2], "C": [m3]}, "6": {"E": [m6], "F": [m7]}, "7": {"E": [m8], "F": [m9]}, }, }, get_one_to_many(forward), ) self.assertEqual( { ("p2", "p1"): { "D": {"4": [m4], "5": [m5]}, "E": {"6": [m6], "7": [m8]}, "F": {"6": [m7], "7": [m9]}, } }, get_one_to_many(backward), ) self.assertEqual( { ("p1", "p2"): { "6": {"E": [m6], "F": [m7]}, "7": {"E": [m8], "F": [m9]}, }, }, flip_prefix_pair_stratified_index(get_one_to_many(backward)), ) def test_one_to_many(self) -> None: """Get getting a many-to-many index.""" triples = [m1, m2, m3] forward, backward = get_prefix_pair_stratified_indexes(triples) with self.subTest(part="contents"): self.assertEqual( { ("p1", "p2"): { "1": {"A": [m1]}, "2": {"B": [m2], "C": [m3]}, }, }, forward, ) self.assertEqual( { ("p2", "p1"): { "A": {"1": [m1]}, "B": {"2": [m2]}, "C": {"2": [m3]}, }, }, backward, ) with self.subTest(part="processing"): self.assertEqual( { ("p1", "p2"): { "2": {"B": [m2], "C": [m3]}, }, }, get_one_to_many(forward), ) self.assertEqual( {}, get_one_to_many(backward), ) self.assertEqual( {}, flip_prefix_pair_stratified_index(get_one_to_many(backward)), ) def test_simple_indexes(self) -> None: """Test getting many-to-many triples.""" forward, backward = get_reference_indexes(triples) self.assertEqual( { p11: {p2a, p3gamma}, p12: {p2b, p2c}, p14: {p2d}, p15: {p2d}, p16: {p2e, p2f}, p17: {p2e, p2f}, }, forward, ) self.assertEqual( { p2a: {p11}, p2b: {p12}, p2c: {p12}, p2d: {p14, p15}, p2e: {p16, p17}, p2f: {p16, p17}, p3gamma: {p11}, }, backward, ) def test_get_many_to_many(self) -> None: """Test getting many-to-many triples.""" many_to_many = {m6, m7, m8, m9} self.assertEqual(many_to_many, get_prefix_stratified_many_to_many(triples)) self.assertEqual( [m for m in triples if m not in many_to_many], list(exclude_prefix_stratified_many_to_many(triples)), ) biopragmatics-curies-aa8bd37/tests/test_types.py000066400000000000000000000110031525104130300222250ustar00rootroot00000000000000"""Test types.""" import unittest from pydantic import BaseModel, ValidationError from curies import Converter, Prefix, PrefixMap, Reference class WrappedPrefix(BaseModel): """A model wrapping a prefix.""" prefix: Prefix class WrappedPrefixMap(BaseModel): """A model wrapping a prefix map.""" prefix_map: PrefixMap class WrappedCURIE(BaseModel): """A model wrapping a reference.""" reference: Reference converter = Converter.from_extended_prefix_map( [ { "prefix": "CHEBI", "prefix_synonyms": ["chebi"], "uri_prefix": "http://purl.obolibrary.org/obo/CHEBI_", "uri_prefix_synonyms": [ "https://identifiers.org/chebi:", ], }, ] ) class TestTypes(unittest.TestCase): """Test types.""" def test_prefix(self) -> None: """Test instantiating prefixes.""" model_1 = WrappedPrefix.model_validate({"prefix": "hello"}) self.assertEqual("hello", model_1.prefix) model_2 = WrappedPrefix.model_validate({"prefix": "CHEBI"}) self.assertEqual("CHEBI", model_2.prefix) # Test that a synonym gets standardized properly model_3 = WrappedPrefix.model_validate({"prefix": "CHEBI"}, context=converter) self.assertEqual("CHEBI", model_3.prefix) # Test that a canonical prefix is passed through model_4 = WrappedPrefix.model_validate({"prefix": "chebi"}, context=converter) self.assertEqual("CHEBI", model_4.prefix) # Test that a synonym gets standardized properly, when passing context in a dict model_5 = WrappedPrefix.model_validate( {"prefix": "CHEBI"}, context={"converter": converter} ) self.assertEqual("CHEBI", model_5.prefix) # Test that a canonical prefix is passed through, when passing context in a dict model_6 = WrappedPrefix.model_validate( {"prefix": "chebi"}, context={"converter": converter} ) self.assertEqual("CHEBI", model_6.prefix) # Test an invalid prefix raises an error, when passing a converter directly with self.assertRaises(ValidationError): WrappedPrefix.model_validate({"prefix": "nope"}, context=converter) # Test an invalid prefix raises an error, when passing a converter in a dict with self.assertRaises(ValidationError): WrappedPrefix.model_validate({"prefix": "nope"}, context={"converter": converter}) def test_prefix_root_model(self) -> None: """Test the root model.""" dd = { "": "http://example.org", "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } prefix_map = PrefixMap.model_validate(dd) self.assertEqual(dd, prefix_map.root) prefix_map = PrefixMap.model_validate( { "chebi": "http://purl.obolibrary.org/obo/CHEBI_", }, context=converter, ) self.assertEqual( { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", }, prefix_map.root, ) with self.assertRaises(ValidationError): PrefixMap.model_validate( { "NOPE": "http://purl.obolibrary.org/obo/CHEBI_", }, context=converter, ) def test_prefix_map_wrapped(self) -> None: """Test a wrapped prefix map.""" dd = { "prefix_map": { "CHEBI": "http://purl.obolibrary.org/obo/CHEBI_", } } wpm = WrappedPrefixMap.model_validate(dd) self.assertIn("CHEBI", wpm.prefix_map.root) def test_curie(self) -> None: """Test a wrapped CURIE.""" wpm = WrappedCURIE.model_validate( { "reference": "CHEBI:1234", } ) self.assertIn("CHEBI", wpm.reference.prefix) self.assertIn("1234", wpm.reference.identifier) self.assertIn("CHEBI:1234", wpm.reference.curie) with self.assertRaises(ValidationError): WrappedCURIE.model_validate({"reference": "NOPENOPENOPE"}) dd = {"reference": "CHEBI:1234"} wpm = WrappedCURIE.model_validate(dd, context=converter) self.assertIn("CHEBI", wpm.reference.prefix) self.assertIn("1234", wpm.reference.identifier) self.assertIn("CHEBI:1234", wpm.reference.curie) with self.assertRaises(ValidationError): WrappedCURIE.model_validate({"reference": "MONDO:1234"}, context=converter) biopragmatics-curies-aa8bd37/tests/test_w3c.py000066400000000000000000000027201525104130300215630ustar00rootroot00000000000000"""Test W3C validation.""" import unittest from pathlib import Path from curies.w3c import is_w3c_curie, is_w3c_prefix HERE = Path(__file__).parent.resolve() DIRECTORY = HERE.joinpath("resources") VALID_CURIES_PATH = DIRECTORY.joinpath("valid_curies.txt") INVALID_CURIES_PATH = DIRECTORY.joinpath("invalid_curies.txt") VALID_PREFIXES_PATH = DIRECTORY.joinpath("valid_prefixes.txt") INVALID_PREFIXES_PATH = DIRECTORY.joinpath("invalid_prefixes.txt") def _read(path: Path) -> list[str]: return path.read_text().splitlines() class TestValidators(unittest.TestCase): """Test W3C validation.""" def test_prefixes(self) -> None: """Test prefixes validation.""" for prefix in _read(VALID_PREFIXES_PATH): with self.subTest(prefix=prefix): self.assertTrue(is_w3c_prefix(prefix)) for prefix in _read(INVALID_PREFIXES_PATH): with self.subTest(prefix=prefix): self.assertFalse(is_w3c_prefix(prefix)) def test_curies(self) -> None: """Test CURIE validation.""" for curie in _read(VALID_CURIES_PATH): with self.subTest(curie=curie): self.assertTrue(is_w3c_curie(curie), msg="CURIE should test as valid, but did not") for curie in _read(INVALID_CURIES_PATH): with self.subTest(curie=curie): self.assertFalse( is_w3c_curie(curie), msg="CURIE should test as invalid, but did not" ) biopragmatics-curies-aa8bd37/tox.ini000066400000000000000000000201521525104130300176260ustar00rootroot00000000000000# Tox (http://tox.testrun.org/) is a tool for running tests # in multiple virtualenvs. This configuration file will run the # test suite on all supported python versions. To use it, "pip install tox" # and then run "tox" from this directory. [tox] # To use a PEP 517 build-backend you are required to configure tox to use an isolated_build: # https://tox.readthedocs.io/en/latest/example/package.html isolated_build = True # These environments are run in order if you just use `tox`: envlist = # always keep coverage-clean first coverage-clean # code formatters format # format-docs # Code quality assessment pyroma lint mypy # Documentation quality assurance docs-lint docstr-coverage docs-test # the actual tests py-pandas2 py-pandas3 doctests # always keep coverage-report last coverage-report [testenv] description = Run unit and integration tests. # Runs on the "tests" directory by default, or passes the positional # arguments from `tox -e py ... commands = coverage run -p -m pytest --durations=20 {posargs:tests} coverage combine coverage xml extras = # See the [project.optional-dependencies] entry in pyproject.toml for "tests" pandas flask fastapi rdflib sqlalchemy sqlmodel dependency_groups = tests deps = pandas2: pandas<3.0 pandas3: pandas>=3.0 [testenv:coverage-clean] description = Remove testing coverage artifacts. deps = coverage skip_install = true commands = coverage erase [testenv:doctests] description = Test that documentation examples run properly. commands = xdoctest -m src/curies dependency_groups = doctests [testenv:treon] description = Test that notebooks can run to completion commands = treon notebooks/ deps = treon [testenv:format] description = Format the code in a deterministic way using ruff. Note that ruff check should come before ruff format when using --fix (ref: https://github.com/astral-sh/ruff-pre-commit/blob/main/README.md) deps = ruff skip_install = true commands = ruff check --fix ruff format [testenv:lint] description = Check code quality using ruff and other tools. skip_install = true deps = ruff # https://github.com/akaihola/darglint2 for more information darglint2 commands = ruff check ruff format --check darglint2 --strictness short --docstring-style sphinx -v 2 src/ tests/ notebooks/ [testenv:pyroma] dependency_groups = pyroma skip_install = true commands = pyroma --min=10 . description = Run the pyroma tool to check the package friendliness of the project. [testenv:mypy] description = Run the mypy tool to check static typing on the project. dependency_groups = typing extras = pandas flask fastapi rdflib sqlmodel sqlalchemy commands = mypy --ignore-missing-imports --strict src/ tests/ [testenv:docs-lint] skip_install = true dependency_groups = docs-lint commands = doc8 docs/source/ description = Run the doc8 tool to check the style of the RST files in the project docs. [testenv:docstr-coverage] description = Run the docstr-coverage tool to check documentation coverage. skip_install = true deps = docstr-coverage commands = docstr-coverage src/ tests/ --skip-private --skip-magic [testenv:docs] description = Build the documentation locally, allowing warnings. extras = # See the [project.optional-dependencies] entry in pyproject.toml for "docs" pandas flask fastapi rdflib sqlalchemy sqlmodel dependency_groups = docs commands = python -m sphinx -b html -d docs/build/doctrees docs/source docs/build/html [testenv:docs-test] description = Test building the documentation in an isolated environment. Warnings are considered as errors via -W. changedir = docs dependency_groups = {[testenv:docs]dependency_groups} extras = {[testenv:docs]extras} commands = mkdir -p {envtmpdir} cp -r source {envtmpdir}/source python -m sphinx -W -b html -d {envtmpdir}/build/doctrees {envtmpdir}/source {envtmpdir}/build/html # python -m sphinx -W -b coverage -d {envtmpdir}/build/doctrees {envtmpdir}/source {envtmpdir}/build/coverage # cat {envtmpdir}/build/coverage/c.txt # cat {envtmpdir}/build/coverage/python.txt allowlist_externals = cp cat mkdir [testenv:coverage-report] deps = coverage[toml] skip_install = true commands = coverage report #################### # Deployment tools # #################### [testenv:bumpversion] description = Bump the version number commands = bump-my-version bump {posargs} skip_install = true passenv = HOME deps = bump-my-version [testenv:bumpversion-release] description = Remove the -dev tag from the version commands = bump-my-version bump release --tag skip_install = true passenv = HOME deps = bump-my-version [testenv:build] skip_install = true deps = uv uv_build commands = uv build --sdist --wheel --no-build-isolation ############ # Releases # ############ # In order to make a release to PyPI, you'll need to take the following steps: # # 1. Navigate to https://pypi.org/account/register/ to register for Test PyPI # 2. Navigate to https://pypi.org/manage/account/ and request to re-send a verification email. # This is not sent by default, and is required to set up 2-Factor Authentication. # 3. Get account recovery codes # 4. Set up 2-Factor Authentication # 5. Get an API token from https://pypi.org/manage/account/token/ # 6. Install keyring with `uv tool install keyring` # 7. Add your token to keyring with `keyring set https://upload.pypi.org/legacy/ __token__` [testenv:release] description = Release the code to PyPI so users can pip install it, using credentials from keyring skip_install = true deps = {[testenv:build]deps} uv keyring commands = {[testenv:build]commands} uv publish --username __token__ --keyring-provider subprocess --publish-url https://upload.pypi.org/legacy/ [testenv:release-via-env] description = Release the code to PyPI so users can pip install it, using credentials from the environment. skip_install = true deps = {[testenv:build]deps} uv commands = {[testenv:build]commands} uv publish --publish-url https://upload.pypi.org/legacy/ passenv = UV_PUBLISH_USERNAME UV_PUBLISH_PASSWORD [testenv:finish] description = Run a workflow that removes -dev from the version, creates a tagged release on GitHub, creates a release on PyPI, and bumps the version again. skip_install = true passenv = HOME deps = {[testenv:release]deps} bump-my-version commands = {[testenv:bumpversion-release]commands} {[testenv:release]commands} git push --tags bump-my-version bump patch git push allowlist_externals = git ################# # Test Releases # ################# # In order to test making a release to Test PyPI, you'll need to take the following steps: # # 1. Navigate to https://test.pypi.org/account/register/ to register for Test PyPI # 2. Navigate to https://test.pypi.org/manage/account/ and request to re-send a verification email. # This is not sent by default, and is required to set up 2-Factor Authentication. # 3. Get account recovery codes # 4. Set up 2-Factor Authentication # 5. Get an API token from https://test.pypi.org/manage/account/token/ # 6. Install keyring with `uv tool install keyring` # 7. Add your token to keyring with `keyring set https://test.pypi.org/legacy/ __token__` [testenv:testrelease] description = Release the code to the test PyPI site skip_install = true deps = {[testenv:build]deps} uv keyring commands = {[testenv:build]commands} uv publish --username __token__ --keyring-provider subprocess --publish-url https://test.pypi.org/legacy/ [testenv:testfinish] description = Run a workflow that removes -dev from the version, creates a tagged release on GitHub, creates a release on Test PyPI, and bumps the version again. skip_install = true passenv = HOME deps = {[testenv:testrelease]deps} bump-my-version commands = {[testenv:bumpversion-release]commands} {[testenv:testrelease]commands} git push --tags bump-my-version bump patch git push allowlist_externals = git