pax_global_header00006660000000000000000000000064146431614730014523gustar00rootroot0000000000000052 comment=7125cf35e22ddcbc830b5c4cd7bbe6a65978109d curies-0.7.10/000077500000000000000000000000001464316147300131025ustar00rootroot00000000000000curies-0.7.10/.bumpversion.cfg000066400000000000000000000015261464316147300162160ustar00rootroot00000000000000[bumpversion] current_version = 0.7.10 commit = True tag = False 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} [bumpversion:part:release] optional_value = production first_value = dev values = dev production [bumpverion:part:build] values = [0-9A-Za-z-]+ [bumpversion:file:setup.cfg] search = version = {current_version} replace = version = {new_version} [bumpversion:file:docs/source/conf.py] search = release = "{current_version}" replace = release = "{new_version}" [bumpversion:file:src/curies/version.py] search = VERSION = "{current_version}" replace = VERSION = "{new_version}" curies-0.7.10/.github/000077500000000000000000000000001464316147300144425ustar00rootroot00000000000000curies-0.7.10/.github/CODE_OF_CONDUCT.md000066400000000000000000000125511464316147300172450ustar00rootroot00000000000000# 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 curies-0.7.10/.github/CONTRIBUTING.md000066400000000000000000000121001464316147300166650ustar00rootroot00000000000000# 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 [`cthoyt/curies`](https://github.com/cthoyt/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 encourages the use of optional static typing. It uses [`mypy`](http://mypy-lang.org/) as a type checker and [`sphinx_autodoc_typehints`](https://github.com/agronholm/sphinx-autodoc-typehints) to automatically generate documentation based on type hints. You can check if your code passes `mypy` with `tox -e mypy`. This project uses [`black`](https://github.com/psf/black) to automatically enforce a consistent code style. You can apply `black` and other pre-configured linters with `tox -e lint`. This project uses [`flake8`](https://flake8.pycqa.org) and several plugins for additional checks of documentation style, security issues, good variable nomenclature, and more ( see [`tox.ini`](tox.ini) for a list of flake8 plugins). You can check if your code passes `flake8` with `tox -e flake8`. 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`](https://github.com/jbkahn/flake8-print) plugin to `flake8`. 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 [`darglint`](https://github.com/terrencepreilly/darglint) plugin to `flake8` 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 cthoyt https://github.com/cthoyt/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 [`setup.cfg`](setup.cfg) 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. curies-0.7.10/.github/workflows/000077500000000000000000000000001464316147300164775ustar00rootroot00000000000000curies-0.7.10/.github/workflows/tests.yml000066400000000000000000000043701464316147300203700ustar00rootroot00000000000000name: Tests on: push: branches: [ main ] pull_request: branches: [ main ] jobs: lint: name: Lint runs-on: ubuntu-latest strategy: matrix: python-version: [ "3.8", "3.12" ] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: pip install tox tox-uv - name: Check manifest run: tox run -e manifest - name: Check code quality with flake8 run: tox run -e flake8 - name: Check package metadata with Pyroma run: tox run -e pyroma - name: Check static typing with MyPy run: tox run -e mypy docs: name: Documentation runs-on: ubuntu-latest strategy: matrix: python-version: [ "3.8", "3.12" ] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | pip install tox tox-uv sudo apt-get install graphviz - name: Check RST conformity with doc8 run: tox run -e doc8 - name: Check docstring coverage run: tox run -e docstr-coverage - name: Check documentation build with Sphinx run: tox run -e docs-test tests: name: Tests runs-on: ${{ matrix.os }} strategy: matrix: os: [ ubuntu-latest ] python-version: [ "3.8", "3.12" ] pydantic: [ "1", "2" ] steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: pip install tox tox-uv - name: Test with pytest and generate coverage file run: tox run -e py-pydantic${{ matrix.pydantic }} - name: Doctests run: tox run -e doctests - name: Upload coverage report to codecov uses: codecov/codecov-action@v1 if: success() with: file: coverage.xml curies-0.7.10/.gitignore000066400000000000000000000400441464316147300150740ustar00rootroot00000000000000# 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/ *.jnl *.jar curies-0.7.10/.readthedocs.yml000066400000000000000000000006411464316147300161710ustar00rootroot00000000000000# .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-20.04 tools: python: "3.9" python: install: - method: pip path: . extra_requirements: - docs - pandas - flask - fastapi - rdflib curies-0.7.10/LICENSE000066400000000000000000000020641464316147300141110ustar00rootroot00000000000000MIT License Copyright (c) 2022 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. curies-0.7.10/MANIFEST.in000066400000000000000000000007331464316147300146430ustar00rootroot00000000000000graft src graft tests prune scripts prune notebooks prune tests/.pytest_cache prune docs/build prune docs/source/api recursive-include docs/source *.py recursive-include docs/source *.rst recursive-include docs/source *.png recursive-include docs/source *.svg global-exclude *.py[cod] __pycache__ *.so *.dylib .DS_Store *.gpickle include README.md LICENSE exclude tox.ini .flake8 .bumpversion.cfg .readthedocs.yml codecov.yml exclude docs/make_schema.py docs/schema.json curies-0.7.10/README.md000066400000000000000000000162341464316147300143670ustar00rootroot00000000000000

curies

Tests PyPI PyPI - Python Version PyPI - License Documentation Status Codecov status Cookiecutter template from @cthoyt Code style: black 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: ```bash $ pip install curies ``` This package currently supports both Pydantic v1 and v2. See the [Pydantic migration guide](https://docs.pydantic.dev/2.0/migration/) for updating your code. > [!WARNING] > Pydantic v1 support will be dropped on October 31st, 2024, > coincident with the [obsolescence of Python 3.8](https://endoflife.date/python). > This will be accompanied by the v0.8.0 release of the `curies` package. ## 👐 Contributing Contributions, whether filing an issue, making a pull request, or forking, are appreciated. See [CONTRIBUTING.md](https://github.com/cthoyt/curies/blob/master/.github/CONTRIBUTING.md) for more information on getting involved. ## 👋 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: ```bash $ git clone git+https://github.com/cthoyt/curies.git $ cd curies $ pip install -e . ``` ### 🥼 Testing After cloning the repository and installing `tox` with `pip install tox`, the unit tests in the `tests/` folder can be run reproducibly with: ```shell $ tox ``` Additionally, these tests are automatically re-run with each commit in a [GitHub Action](https://github.com/cthoyt/curies/actions?query=workflow%3ATests). ### 📖 Building the Documentation The documentation can be built locally using the following: ```shell $ git clone git+https://github.com/cthoyt/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 [`setup.cfg`](setup.cfg). `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). ### 📦 Making a Release After installing the package in development mode and installing `tox` with `pip install tox`, the commands for making a new release are contained within the `finish` environment in `tox.ini`. Run the following from the shell: ```shell $ tox -e finish ``` This script does the following: 1. Uses [Bump2Version](https://github.com/c4urself/bump2version) to switch the version number in the `setup.cfg`, `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 [`build`](https://github.com/pypa/build) 3. Uploads to PyPI using [`twine`](https://github.com/pypa/twine). Be sure to have a `.pypirc` file configured to avoid the need for manual input at this step 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.
curies-0.7.10/codecov.yml000066400000000000000000000000751464316147300152510ustar00rootroot00000000000000ignore: - "src/curies/__main__.py" - "src/curies/cli.py" curies-0.7.10/docs/000077500000000000000000000000001464316147300140325ustar00rootroot00000000000000curies-0.7.10/docs/make_schema.py000066400000000000000000000025251464316147300166450ustar00rootroot00000000000000"""Generate a JSON schema for extended prefix maps.""" import json from pathlib import Path from curies import Records from curies._pydantic_compat import PYDANTIC_V1 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, } if PYDANTIC_V1: import pydantic.schema # see https://docs.pydantic.dev/latest/usage/json_schema/#general-notes-on-json-schema-generation schema_dict = pydantic.schema.schema( [Records], title=TITLE, description=DESCRIPTION, ) else: from pydantic.json_schema import models_json_schema _, 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() curies-0.7.10/docs/schema.json000066400000000000000000000035271464316147300161740ustar00rootroot00000000000000{ "$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." } curies-0.7.10/docs/source/000077500000000000000000000000001464316147300153325ustar00rootroot00000000000000curies-0.7.10/docs/source/api.rst000066400000000000000000000001371464316147300166360ustar00rootroot00000000000000API Reference ------------- .. automodapi:: curies :no-inheritance-diagram: :no-heading: curies-0.7.10/docs/source/conf.py000066400000000000000000000165131464316147300166370ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # 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.7.10" # The short X.Y version. parsed_version = re.match( "(?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("\g.\g.\g") if parsed_version.group("release"): tags.add("prerelease") # -- 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" # 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 = "curiesdoc" # -- 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", "Unopinionated conversion between URIs and compact URIs.", "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. 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/", None), "pydantic": ("https://docs.pydantic.dev/latest/", 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), } 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" curies-0.7.10/docs/source/discovery.rst000066400000000000000000000330421464316147300200750ustar00rootroot00000000000000URI 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! curies-0.7.10/docs/source/img/000077500000000000000000000000001464316147300161065ustar00rootroot00000000000000curies-0.7.10/docs/source/img/trie.png000066400000000000000000004665621464316147300176020ustar00rootroot00000000000000PNG  IHDRAsIDATx^}\e}&!j0 T/PAEj ZhUhˍ"U-PR(PVC$DL@BF2:$̹cLЎƤ&&OzocS.ԌJWS5&uxj=&xE4ؿJcͩRϧa^RO{ʟUv_D㕸ק|m\P.^5 SoN68Q[%S e4F/~Z`长]x ,5.kR+||镟+\B|@)ϥE>xEOdRjZGMm\SDs6SRs"/5ݝ:9$]?IMO=P9=4iRַ8YgUkկ~1cFO]]q76uo}k=הזFotA=v=w3U>H%*/#%vء8ciӦ]vY?Ž[,].K,.g:ujg?057ԄSZ9$iV\yO~ay7V9qW\Qz~^CXSPcrVʇrHq3g,.kwVXQy%\vƍ~AnM@4#rX̙3XfMu7Xz=]tQg WA^HnC&[rJqo<\ʷCD[[Ínرf*IEG?Ǯxnbs{uj:ҫRύըQN:~PZq2@+W<O<1>Λũm[>z:p}݋K.x駫[&h鲥Ō37M -KMK(\-W7Kٳ{>Ky_|h+π܄ s=XhQud,^ gFҺRK-|۠ve//}fR~V5\SHh9OзAM7TY?"}o 0:s{n]w-:o)k4iR|n`sjx50lVN Ao5)oWַUN>6#SOF> QF^xaVZcƌɞ _0,^8np ł !7Mqg6#UCfǺuAs̩tᄏ8`/3t|K.XfMu#uww3f(FlYŘّsc)~_WwAFEy{c*v|6"ۄ 뮿2]{eݜzUՇ"QGOSO{lv y0`oI-||7@lG_VF>۞{Y<՝5o޼b}TޣP+5-oNJ+WV=9Sj4)#ꪫ{lkfcKQԥjw}wuÃMrykm}-5&`"ivGO?tuAx4=48/Jg#Z}#)fj??dn%#H9xooV`H}K__#@3" n5jTv_>6*uecY;={vuaӟ^ޟhqRHg[mUqW8,b„ }=>t#lm-zՃ>XL81_yG>iGyAKxNJ׾}><&A]v٥Xpaus` W hc{| myu%r._^iEN`mV>og?9,uoWmǏ/~_V5h s-ƍtOj|@J}+ѫ1cs̩njVbOߌYǮ>[[YfezH@ ȇڦO^Р]uU}OY98ZU??U3sNvӓZ|ܪoona͚5 'ԘG8bʕ :s=WO ho~q{AG]?>;}&6 Y͚5AG۳sO^|=/m F/}Ky觯lD>^v] F׻ޕ~:*`20*k+,YR`DYti;d磏^0L.| T(~g磟. oIuG>Xes9 F|395`K=XV_0\xӛޔ>z(e|#UVWWW1o޼$s9#sGg ?H|ʺ袋 . ;7}2Av}T6@ʷG<5U,\c9SsVө g#MVݰM<;g}TBk|4>g:855jԨ'W&o~s筦_'G>6e} gyfv~N=T ŧz[`ѣsWC1}xg#Sֹ[ݬA~6;w}tt@n|`jsJS~vy֪篦;ju)T*`|C_PȇxSC`ܹXDŽȇNZݨ!tQGe簦z]R֏> Yfe簏>gJM.EwwwuPy^g籦^IY_0 .<ȇ'|Kà<{G#ԂȇU7)`=eMƙ`G[oGG?Qv.#őHMmŚ5k{038q|`Dz"|3- MϚ3]mF#lggoȇvqǢCAywatQ0̏|8jsΩnPftYge紦yGᄏ?/~fo -<'NkM'#MFMr) h'|rv^k>L=` ^uwZu_ךV^tC#.]Zݝdɒс@LCQSrHusZȁۚȇ/T&\pٹo:EMqս h!v[vnkz&5&X{E>e-_7-5`<;5g@+qqU7&EuwwcǎqMo :D> 5uaU7&z9]@ǹ aO~} hagqFvk|q07ӧg縦XPSw}wu_Zٳs\(&a˗W%-]4;5NmtE> 55~cf繦LRݕ6>d繦}BMp ] h|vk:>gE>5u9Tw% uYyc\ 7+";5]t"9sfuW7ߜ皮:O"뮻fϞ7#zGϟ :3BM-]+m駟sMKǠ5kd繏moȇ&LPݔ6[f约mȇvu׿ٹ@-!޻'md=uMoF>5uT$~ٹir|jI@y[ߚ8h{D>5uW$uMmȇwV$uQٹ@{[CPS{luOHy约 hsD>55u>:;5@{KCPS>aޛ#:C{F>\QCPSSLI@s]{CPS{wuO^{핝v D>5nU$ٹu@!!m٦'md뭷uM-#ʳ[=}#| jjٲe] hK.sMHPS>huW?cvPSsOuW]wݕ~t|j[nJ@n :3fd繦?;/;5t I'Tݕ6| ;5}  !Rݕ6pf繦CkPSm]uWV[m /|Zreu[Z؊+s\Sw%@GY0ܹs̙:έCM]{} has\ӏsqPS:0ԩSŽ<tBq|jjmK@ z뭳s\>@*a(kɒ%Ս hA/oMݩWDMr-՝ hA3goMumQS\ݙtye緦kDMva՝ hAzhv~k:-XD>55jԨbժUխ h!+W9[ӛGٳgg綦gEM]veս h!^zivnkq9򡨩SV&yٹPT٢=\usZ+tDN|,jj֬Y h3gkMSF#'?YݝO|";5"4iRuwZ.쒝ך>{G>eWnOfCe紏 FQ'"Xݟ__sZSy3ȇklF'OiM#ёGY[߃>>:2qƥ|&5uVw)`L2%;5͋YDR_ݦ!t}e簏>wS#:䓫0N:|ju|7a)k…Ս Y>N@aKY;0>f篏P1:ǥƌS,[Ug)b4/g2'E>0e]veս D^ziv>K&򑩩m٦xg0VXQlٹԖ8+)˫ah\r%yOԳMM?X|yu6Ay&LKm0uW+`\tE9 O|tjjܸqŲe˪.]Z;6;g5LuVw,`#uY/l9񩩮nYxGzR|T _|:{=\wR+"n mݖ>z6}&\CT{]YmXzu1yvWf礟0ĺRX5jԨ◿eui޼y=gzNh5rM|w}UVU0QV\Y>l0N|Ϯa0|ӟE?}$`I͊|onb0"|C?ݒZ5znczb̘1y裧S;lFUۮZ,[AGZ|y{d砟"|=1wEwwwu+R}{.h[~U^~zY@ ;\Vm\sMu3OϦ(g~{u;vw]]]ٽO/G>r6f̘bܹ Ҽycfy?]^IcWm;shѢme…Ŏ;WE>z6ybM SO=U}OOvh#oN=U{],YAK[lY~es?gb6tTjM#Xmx`|-g-=>_#֠hyVa?M]:d_| U2eݯ+Ԃh˷0B.XQit$usߧ&O\,ZfO{wv[R[X'_2FSF>"ۧ~Ճc|ȿOnł [ 4iRv^gB4ÌYO1^z89k/D[~곉's̩nr0,xbh|`zeߧηcj~Q]\1I͉٨Q3gV9Rf*FݏԶ14~k/s퐚XT5 r +oW^yeu!1cƌ@ذ} )UߺN_F>uS%ˆTJ z;Oի{ @?ImCSGJ`41?Jqٰoo{ۊ'|&YxqqGd*My/#c`#pwk{OP]km1߶n?yuÃ2{bĉ}6nsn{ mU#*_;qfՑ\UL>)?[zo jkhC"![ckuEŒ%Kkٲeʼn'OԨޕZWOh\@;:uWP]?MqY[6;wqGuZ彲g*_qjokh[~SRh\rJ-g^o'UVU>z /,gњ{ -|hwɩm+xwov R{Dk{o4^[٫bkhARG>5;e-90WX2¬\U]]]=2zU"*Gmuojem.;ӊ~ҡz#Hvl@OVh\jfN]~O"رc|+?_ V*.b-Ȟ |;Vho!S5.cV5iҤonns={*{ è'uMI=5?Ɜ:SAܹs"-|wgFVΌԟ^*ߖ6ufܱ窑|G"6;zCPmDN#g" ZzK5 r6򡦮rݩqو2!ߺ&d*ٳgWwG6i-?+b9+ǣxuY9^@SWi\6b95'fkXfMud̙3#<2{~6խgGU4.AkZ]O:oGʷ@e#ދSO=ŕW^Y,]O2D.]Z\qŤIc#[:=+uaQ]ڿq 0#(^ׂR -tYWWWq 'v[],<~ŨQ#+yFj`]n\ T_u-Lq}(ǫ"6vۭK V7L6Ђ K.y5GA_1neI 8{E '5.c=ʱ?\|k_+-ZT6Ou-Nٸ 6cT꫑Rol\F"W ;|7X<3===|{G b>zQ<Ƕ.æ00"^W ?l\ (GÿI͉C=g 9sfdɒ^ߩ߱]߹8 rO w `8TY6/|)?K>$M<8Sӧwyglٲڲ.[Zg?+oGվz[ߡPWF3`-oYCe3`]}RW18FjeÐN;w\꫋oxGn ^{m`x_)g˟᪫*;c-vqáUoh\ҵGG_"GZڻqUOs2qg~q f*bΜ9==C=mY 5v⢋.깶77mo{y7uJj`8?u-Hqtk—1Q3{a)_yzq4sԌԁm7r&秮GR;7.sO_h\B*U?gڸʷxnRV16ȟ@Z6_HuVE];Zˣ1X^iuv۴r.Wé@g(moӑuH֩H}-0TϋjjjAxQȟϺ~ڱ*EuۯdTC]i5Q=S+\˷7>#~ac7mg/Iwq] @VQgZB]P%t-R:Zs)-M{htqG57}2h_FGuۯdW!u\shʟߡ,w W7G~?@je~?rW]m/fD8%{r}G5\g▟|bxGG~ WOEg(gh>wd253Rs#"wkwr]R^}*GԱx;s/JM.O'ݓz0"{nnƿQ['2ߣ^#p{r޶qFGj~1}]SS{u—=R[7.Pj~1}ȟj{L50TfE~./|)lrﭺfv71|]jE~ʷ #;<=Qk/J`Ԛȟj[~+ ຽ/MR+1b/U50ʷܭއ _ lh|o>맩7.c1-L[C{ #m _ V׭5.c0-LxKV?{ "mͫRD~5+5q#h)`uTȟjp=nP(_]>'{[R/m\F3-ZG{u31M}W׏8R!>TkQ͵S"nh5<}n/~KaHnꗑ{u0e2:houzӟyk`su溍yKa^n aB'3m6]u0vH͋{aT`m;g#>x5 G~m:_ձϥ^^ԸbچsZӵhsǺ#p1mܙLykZh)ݿϬu0^z,{oh\F0my@V}:h5"_f`asz]˺Ji\F3*{"E (E4 1*v3 & kbCX *b׈E$%""HW&Jv{g~_?̺{34Ky1ϯ)@|*`9rqfS>@ ռ_m*mKCa3ȷϦ|:4N ~ Fo`6oeoM9*/7M@ghw߃R3k)+2hՑoz0R~vڔ, `34m#rDh EVs`(7u}[mC4. "߆9s0Z1r srhMBn*CU\|i=M,Ϩ) _玑o՜: #nrH:C[rqOS>AY]s5? V5发oMyWv:C;vL(Ϧ)G/ݵCt5? *} ~  tp7#Fo~64鶻G]Ws`(R|n50W4.3iR6,ˠv|ۮ滃z))ٯ}4n?OmGSMUYvȷjN Nxl~cv:Cr~ESGWs`(tb&kh `34i"r\e]"Ϋ)]#ߦ~  tplrnASEWs`(tSSn|n5,? `34N)D7 2+3K_ NzZ޷_:cFT~:|'e ##-#rSw=kS_:C/"ߛ))`,=&CӞ7_֯ayhJ9-)'lZz\~5 {V͑oMkXz@gh/-S~ݔCwR#a\ru_S5e$禬|[h_:CxYʩMqm2&)|).;ɑj O1z0cE>7')+˘@ύ|%gwj>0 c1Z~ KC W4{ܔ~ŨyTtSqOkP;/W{kX|@gh/S~۔Sqev|M>j  \/,> `34Nל?L#6fS_K5 ȷM=5, `34&)߉}m)w(˘`\1ۼ6g_G5 X{US/{<_:CxoE6)w\WŤȷ伔-/uTs`(#R4_:CxqN91)glSA6R )nyi䯡 OASnHyR t)ߌlY#"Fr{=nyy䯡 BS&ߔ%, `34ǭS')J*(vRG6znn}`a0&JДS~ C QW"딻VQ73]vP8o|hu)0@gpic"R4w(|[i.\Mʪ~M۽._ރ0/\~  tƨ `'4gV4{הwײ n2_׷z돟.]/hks0є Ȳy:けlڔrů0֔ RVzm}{iJ.ťɻ`ȟ{5/e$L|h5)07@g3~ +.{TWS.I٩,̱o3MTA߭l:ճ&e~MtD[\򠲌9:㎑⾜h~*)E^edeG>)L[#ռ`0&^>4)Es tF#?ٔ⾣eDdSS.MwYF jv|lS.J٠_VGy-#B~JS_ê0)?|"v/4 `МK$rqjJqGk`TD-5S#ưدi# ``,|_QW)_*b!e~M[<)MDO5);em\ef1/_3[986XCYuD_5W)w)xSnHybYOTS.N٠_3GE7S-Q\Uy`(0SՔt`Bڔ+ӯZrNUS֯#R߲-fjLw0 |bWSJ٦,n;M)s$/v5,Ul>?۔״#~k0MR>֔3S ZUnʍ)O\}1m)ߙ*7Frel"~8 SKSe2hWF~)7<_su(Քkk5կYnߏUsP`l3wS9#e )k#?]OѰ{v?"߾ru,q1򃩂eȟ[5w Q)?K}Y1z50\6֔5 ?ưܯYNDv0X@[0})? M`Z1%q4aدY(\4esqZϫS4gT&2X51دȷ|`3jS6,_FP`ة{Mqm2XZϋњť_T1lX^ٯYhrNϩ2/|/e Ƴsu=E%X(|[k n~ٔⲮȟS5 V1%٧rr,ŽWodu=EX(N")Gk "a~r Sƃ"+æo1z_5|{kKk"ܦ|_.T`(&MnhHrc'5~֔ߤ,[\w">լ ȝbsO;)e07B%mXԯYlF6%{ycs#/(/#?ݔk`1kw)KĨr=m`]sJog"7r|g7?E~)ob(.|]]SN,D4VX^wN9?)_ M`1zw~ ,WF U~Okt8`yo#GՔ[e0ce ,_F5̠?#?ܔk`}&93F?~5ů v1Foeo~ ,;6j^ #j~= =S.|mѡ @̓SrSݯ_8ӱ469w0h\~۔;ăRdrSKYQ+|;ܘu,E,威ٻ_#wᔕe)G~)G/K)o*ǭPߦ|(4&lN(0Pf8N9/K=[ͩ@ao6)^RүXwKY8ny\,"j~2 lnpx,Lhߏ/ȷa94eՑ?aB,"j~0 tS|_n! 0rI'Ue,[_R6eU~smM)k#jN :!1z]Ў)G~r)GlP{N(oG\ŰSWwCyh#ߧ~ c)E~R) _Wv9,ݯiMcob~mCyX5My{1P\O7s)e,v9,eȟ<_cUs`(A\ݔaO7( ȷa).fD+6s`(QMSwS_@9O6嫡,Flr1r8ޔ5)k҃"j :q1zx~ R4΋oSKٸ,eȷa)~)// #ϛ~ ]ʹmK{kkn?F5G cGn|oʾZ)gD~r)I٤,Vȷay~+v5 KŌT|b0#rS|=kS_@lrz'v(/K me oM*FZR4c֯YO1-#q \܃*mI)e|[ka9_߯P` =3z&K5,◓E~")'/2ȷaOY9ů_/_OS~QY6oe`x{nʚz&5,{j'p2h.Q\M%.{_iX׳": ћ/HQ'nۖe:_|t"Mrbf>]!+Fkcӯ` F~¶)?M]YsQ[e_۰دi^ռc0ȋ_zٯ`mO6唔ۗeJo|wk^ ;514 LћUX$E"?A۔YYQʥoòSYyS5)esV˳w9p0 |>hJ^{PY"?1۔3RPAk ;,׌Fe|i7 U M)6V?!۔_/06]d.Az_㰜ԯG7y`(0^ДSܯ`ŞئrDz Z^orYʭʲrruX_33 zMsCSnHyR9(ߌlSJ٦,ȷa9_3nksO}C CS&ߔ%ƭSצmYE>gT`(0#9)EFP\O6W)w)Q+C͑M.1Q1hmʯSv(Qn PzKsESMyTEˑ`moRZ 5ӯ^< pF>_4h?,jÔ/E~b)ܭ,LM 猦\~ Q6 զ\rϲ `FG>T[FoEE>o4h?,lR>Ԧ{e#yWsI5{d&M22T4? Ԧ\=CME>4hUY0Y#?qڔߥEY0+|N郡CMD>4劔]2ɰ2c0me))fj: 0壑#M)Y4E~)Nj4 0\ėW4gT\>X3]m #)|>iXchѦ\WewsL5h4LҔ{eh7M)]ё3y KJLS~CY~D~)ץ<_S}C)\Ӕ_ܥ(h7E~)7<_XSͽC'S9;e۲ }fSkIA8 `&7#srV6e@{ ͦ'k#`(*ߊ|iʙ)w\WDfSnLyr`)(򹨚;,MbKO)w(+#?ٔSկX*D>Uޛb,fTM``<M){kiIl= h6O砦,2)k#?iYO~ R+.Z`("凑CM)^`~SD~g`9TMѐX*Ŝ3ӽɧӔەeg[yN`TmCDqyS#ۖe y1Z4 euAsT5,-S~ԔlV,gEyIIz5j6 XR[ԔS6-)7E~2~ @\\U͊P%u/"rRh 1ze"j*U [>=9) .bk笩\_rNTSIY0IǦۯh"rMer.窦r `fOqMy]"˕qm}yWM9>e `7kڪD}}+ڢhՔBcSŦׯhU]\: *wNUVSruUF_\8 :wr]M964\ĦgVG>UPVG\ʆe0O"6.,ysCZkǔ"Ú 2`7ٯE>Us`(@3籦0ru' ~ @l|V郡wߔ"˚Ue0 rU' rHʊ S|NPN_#Ϛ򑔕#?IؔC"ת`(@g?iMPhX9F?axd8atm`0SfsLpLcȨ k`=C:g6Wu9"\lNq|g0ru[S M` I,M9*eUYyW9q07ݯ橸e\='ؔ\/S._SN٠, ťs]5' tڮ)|k;5V)=i ݵyocoצ<:hߋ"?הτ/0~v|k ~bu#U@W''MT-\c6s)ecAy3 0vK.)kZ-P"gW@g}:_SY&Ga|h롑{|a0`l缦_,隿SA+R.W5ů4_k,;c4Qޛ `\=2򹯚q1zx~|-袝"?Ք%pk#M){qY0/j ;O>)oը"@'4^2_or^)LJ/0_j>1 07_ׯ4S@7Mwߦ̥ |(OVSI:Ij>rH#ofTmN}Q۔4ّ|'e `"<9򹰚#Cߥ<ؔׯ\ENM[ymSr `b |XCc&ڔ 3o_Zk(pNyxSS9)oK9$~>rt-Ž;5MyASRr(x,%M%V))dzVsb5LgMk-R\`l.ʅ?{ʇSNL9?F;yP)뼔o|(Q>ϑ9*qij"۔S6-&G>/VS4MYXO.Xߩp;Y0Vo'ʓ7mQ>x-5.sKM-S~0)K٬,HʼnX;C&b&ʒm3XNֿWM7嘔+"_Tv=}d'>X*oS&pqY7nmj 89JS\w!E/ci((߃IY,G7n? E(09C&R-~[el>`l5ʓ7pSWt- ҧ""E7J/|Mcqd`!YN_+rr^ >>FyZ&? _G>WVPȥ?e;XܽSOe:[~rHbv9#eUϗռa0`2yrsx0ֿ _/@+lw/dasZ't4qRCPOsf5% xusb`TֿK_%"1)G\Bmɳjժ޽}{s{_{[>G?;د׿^?񏯫)jQ>v]yqoGGYl)[ܯ@E>oVSH`Q6ebf`/""lѳgއ?|38wWUW]1.C\c޶n=%Qjn{]n@~ϙT4O|\}Pgk 0򮔫#_-J^>O~-"w'>޾{ELټ3Aaߣ&0@nj^vHɶ\"Sֿ3讝S>rS Kqi?>o}wۿu]O;#]Nk_I9&A1^{TXߛ#+)0閳[wڭX<}=ՂX}~5k׍cx/zz{+ߗq)ߑKM`E>OVPߩL}/@;eW"_H;lIow񝸜R).uq^W/淣'R)##y-#&O[E&>ֿK`i')k#_<9mQ5yM邏NtM5{~wbx?8{?r?.w4Ij> 061~;`n6DNW?k6)^R 3S&o#m&00|N橃c"ώbaֿ3c/f7߼w.%rW6tc]t_M``}(' 7| ƃȭWEUvq>ޟzeR+K_Ry!6R\met?GM``|,򹰚' .4t>ֿcSFЙUve '[fM}EKqP 36F$ֿTR^r}䋛czo1ӻ}L6t[۔ GG>VPӵo=['/0I4#wk_bo?aw91so?=L{G>UP罶KsXN ])|3RV\{{޳:oq}6`ELhDܻwxrs_5 +ťF>=gD7X [`"_EK.~bLu>Y^9w! vls_5 +|Je˴&d be<~cNnv02YmtAϷ =fj0 0V6H9?y i1/#/0ƊE5/RfLq#8f͚1vG[jU]⾵όv[ϳ)])+`}#9Jms_h_fmL׿*I&#~v/0]vYoʶYچϏ1IX^)w jwJylʁ)'\ض>ֿۘ1u|"1oyد[_19ަnm/#ĔbEy;&SϏ8 0V<4eߔR|n\MC,?_Ԙ1SB!3xƺoB/{f͈9/eX^y-G4|rߏ|?$kkCYYcȣS|1mQMf<呱X<<2++fwuZ:V)_|1mgM7T_{oxo|%_!,#̩:+In%uAs5EcSmWC[Z-^S,~|0m?:l;!_K(Gwߜrzi)Gr őP%,!OFyQM_)ytt䋆YbEد[_8z+W̶R|I~sY&Tb0 `KXs2哑/f6r)5 ,9C>6"rseNf"_$ [l;3kX/{oyΐ_{L?FoT`Yiֿ`hp;?չz뭳u=ET`YyֿL|Q04;C .=.~l!&0++_ֿ _`|AИwܱw饗*\rl}0W0;ֿ@.Q^h̶nۥ"~};&eAU\: elYSvH$E@cb WP:a #?ȏGY_Dxߟ@W'yC _/t߳#?xoڵkŶ=//3.99C_/tS.=6lӻꪫk(W_}uo!$v@^C__讏E~ޘ0@}/?yPy5 !ֿ0Kֿ=NY{:vfɐ@<4/ ֿ0GֿS΍`=ˮڻ0nރlS6 ⑑|斑t/̃/t;"?HϲbŊyW?>{>Rom@W<^Qt/̓/=Sn=ˡZ?*9lRt"ǫ`(-g g2?pvyl/]j>rH/7SN N=l&ůh'G>Ws`(-f g"? rԏi~~4$lE>WPZ/+"?r;ܡw֏i\s5-2۟e@=+򹻚CCh)_X$ֿI]Y/׏|s!,e!򹻚CCh!_XdֿE~xo͚5z`žG=*ۯ@[cv5 aY<?p)dՐ\EF{E>oWs`(-c K|gy+^Q?/~q 6zasv52ֿ`y>ˊ+z^zi8.h>Uremyisv5o EaYxkY1<0ogCRBvyyu5~Nֿ `im}Veʕww`~߯۷[C&hWE>_WPZ/,N^;8lf>; @KX2*K /~,+jժlk)mj aYxVY>1;ַfݐ<3h"y`(-` -` `+VO xZ}k?y26ŵkY{:^Wfߐ<$h7G>GWP//,D~`嬳Ϊ!P0-S.Q^N;ecCN )~{as8g`_h!_XX`:ˑGY?6aC`ǥ9V0//,C#??~\,b߫C`:D>w[K:ZFqIK"?^//| k!GO0܇"?%Hg0/t/c"?r'׏ǁ%~7/t6NAgsCʣXBXzvm{k֬KzlllrishW6Xs9' Ki1_ R~z92!>]L!Qj>v1{3Cqv-Fa;4Ǘ/#?p^/w]zk׮r7|soʕYdv#}q>ӆjˁ8Ǥ Fa;4Ǘ/ݯ"?p^/tP83r*%Lr^0 S\ e`T|.]/ֿݣ<ެ`AsN;~MuQg9 n}Cr`T[\\֜s`4ֿcJxYzr-]^dT4'zsm9Ӷ$'lY) f wBe{;syNE^WekC֞ϧmu) fwLi?_]q#?`^/|kϲ ;fkC&zWsj&ilY1 g {BYq70,*\d`V|-9u`.إ^{K^2cgz)a ;0?~MG~Yxmݲ϶!sqs"W+:Fsb}oxx}hC9$lR )֥gRVsubszvmrm른0ݴ6dg5~6䚔 zjץƀ/쁑$gkte]}hbC`>ܺ).Ae0ֿ0`fy.f.K{0_ֿ0&`zypl:o{y֣<>mȧ4u)/ _Y N8~MGz4q} 9#XH"yv!ssSb c[zKٴZ\pAK\tEՐb8X /_իW׏i `֮]hϬ!k#g"o /_'?clZD'< g֐ eEϵIqʕB1c xMo_"~g֐w Ue oT1QfNbAQ Tj0;jb:wDo $KH^Dsayf[kf}[{}ޯU/Yk緟 3Y*mJdr2(2Կ@?3ϸk>}gyY%Edg Կ@]tؗ~_0 > zd jn*hYBk֬Q5(ZrWEQZp莱/n`vڥY@jLZ ~(m,& о*SK6mܾ5 ֭[ٳgf„ jgw݆"uj?dDnX쏒7&_4b_ !oڧAҥ*"nj?d5moPW {c/C%\! _s@.@6uƟ7. &ߘ`8~Dw}/! mݦs@n@}d6fIEYD //ӦMsՈ'T9 v}T ~֤uB 'C~5"zW~\&\d G /Ttؗ7|W#B˗oL0?Կzb_>#_a8֯_s@ E}- /[tؗ]vjDcSx 8PѝaZ_a8߯sHZ Pܨc #ˑG1 Gu@ Pܨcx*#K4"x;ԾW(nԿ1phI|F3a8~=;þjDk.R./XL3PtXu&"?Կ1pP~a_a8>C^5i2d0#&#$QC -!wqՈ7T9 KSW&66zZ/ 'ߘ`8~ߋ`_a8^}U@(3dI6wLn@: ߘ`8~;ľrnSWަM?u/Y'|]QC u!Cnp|mV|MpiJ[VV?|Ioʕ^UU۬F}+Vx≺a:v>obo%1=4*ߘ`8~Odb*Cgb?Lgˎ0Y&X:w?njB7sںnw{d8KM PB "c}xw~8  `n2;*ٶv8۰a7c oر^IIV[&#`7YIDAT_ _Dw}={ۿFD0O3gT8 ( g,N5+_͛7ϫpJԳ }Em3w;f L`8^`rwkDtm}{Scf 9s0w^|llx#_Կ1pP.1enpUa#FP8 GI_tԤٳnz;vM"hWiﯶw3b7äL ?cx$c ѕ0oq9ꨣ>jmrNmRڱ_r%믿gf}39EnM-`8^`mEwUvoڗaٻw:"hjށifNt{vʼɓ'{n,w?u/MZC /аDw}Y`F[N˰\]4|o@n3"J; f͚UWWMrlu7VB^ 7A 4l/կ~6"O>Q2,=:"讻R6 ۠2j(oɒ%nsh"sQ ?1Կ@~*~6"R˰tڵnyDgm@Ȟ.&'I+gyl2CZt7tPߙt"p|P ;YtٗRk#gXƍԸ/\uk@NȎ&E;)SOo ޻54@v,7_qT$omDc/Jݽ'x{w۷{{nZyys7vXoƍB-_\πsݞI1yZtrW^usjr@fQ9Hbe_&OsQ2Yng&MR) dVۛҢE ׿5!(Bޔ)S~`w{$̢-b /_(2zh?nZ3br@f0d&\wuuMm۶ͻkO#5&7 doc8ge_U/ln.,vr@}d6&p u@X߿:IL7E[.~Կ@ꎒYpFcU9af(  4O/E1n{r1DMMMݴXpc#<ԿEG g/?~7"7lΏ* oQ,h1&ۗF3tP;O6l9u& 4obQYtٗnݺnDzm۶j6%_XRz ]i4ӧ{nӅԩSs t7nW|&/~;*|FlݺJ?}iwQHH&3E)O>իf cTOypQ)/>{Ǣ;;ndgΜ]q^~sPމc۷7j(ou[h{ngoy{nst7Y,Mi4b#TUWW==RƵ@zōh;ξAAw< |d ,Y6U@JfG}:R&_HobQM3JtYaڵ ԝ`]t[`Z7[l ͤ@ŋh: ݢ;Ͼ)߄ {Ngx@*eKt[`RSSy8K!;TP!/"(`Կ@f(C}sYj*uz`MEo~6G@^M8Qd@0_QEI<7TrWsY4n8u>ϳuۍ<nS{Ld4_Q[k͛7}tY`5 ɋZ{uیp}㹑-G _ 7Dt'ۗ>ۻwg{:(ud0%1ۊWr R.u\7 ȝDwU Ȏ'-$#pпVfnDW_RaS _ igWtgۗ6mxn@3s#T[@HZmE`=\mHFs Կ@PC=4C3|X dv"0W^^6?@޽ׯ:&-<r&U;ݾ~ dȮ]֭[, &nNC͛( 6mڵkr@/cԿ@<"rwxMpm+$$|dvB[jEeŊuǺ{D_ )sVη/%%%އ~ᣏ>;+ $ V2uۉ̜9mz4gu7M>#@ /\|n@ƌΫ#0Mt[nmvv7Hrȿ/ W__WSHtۈ 2īrUWW{CUC9_:_p!#ү_?on@jjj޽{)$bKtҾ}{o֭n–-[>Z!aY_1θc=4`ĉ< &O ,ŋfKD/%Կ@Jiia s=Brȅۇu]n/~ u~4Կ@QdNgֺ}}I92bu䯒8Q&[D*乿@Jo< '&qG d/PN09W5kSMH9wӢ{gźuf5{NqԿ@PoGm۶0nnZ7!yR@d!0ӧOwԩS@&DwU;<;v:_BդCM։n#Tm?#>Hg*k&;*5kָ5K~:?:"s @u4)F<#n <Ο2;_ hѝvN8rk V*++#$* 2Etҿmz럺QH& PiZ~n=su^4DԊn#TV\6;o($M!@ & Ew/5 @6Bs)Կ@JdNJ-k׺P֭[lR!aY@tmm_n6=Rm6UV `_AԿ@qOt+//wk(UTTxzRAE*=Hĉy&%Կ@ _8Mݙ̅^ֺPT1}O d@pmqUVV4TUUyݺuSWH-@ $W[DwsPT&Lɧw!P?h4UT @/(nLf֬Yn^xA dI !CM&w- 4Hg!9K Dt?4-rk Җ,Y╔c\,p2ŋ@3,\Pg!Y"p/bGDwӲeKon DnZ !Mt;rg 6l:B-KԿ@*gt۲e[Kyfsn M8hBeҥn -[η8C חLwݚm۶y={TuؤLn& _F6A2謳R]@zA__/M_~Ν;(h@9q$텊}yy~Կ(zԿi_t{n{==c|v&Dd_=w //xх@h{ =FS&29h׊n/TȂ_|Q!{@__a] ^U{n JVm#aHAJLfұcGmd=:wÀ9 ._ _ ie2WtQAL${L|xm$MZ h&P[o)ϒ8 D#\xn ƌπ) Կ(ԿE C7d^vqB^1@j,Q3wLȃYf3$]RC+<'h0vZӧ l-J13LHxm/^EETvmj?6TS@7ԿrfEFHQ/WSǤT = D'|u+JWL<})(;%2_ =Կ)_]jG)++,Y8@ {tQG)Im޼yn6sLSIݗBKjĞ<@SP"Lv.>~3ʭw:555ޯkuܤr4H/vjGj>944 /@!m^th{n탘{wN8A/)fI/{Tt96](`VR0\zK@uYj!EFQ(d3b$ثA|F{sw&h:n_|Ṱrת}nm, #<]@>h_4/har~хI9cE5bbٲe^Ϟ=qbjMn1StvZCڽ{WZZayݗFZzw!@PɨD"ػtvG(RZidə1Nt;Kǎݦ 쩧R)ڵW[[< PYYL 32_QUҺuk󪫫z En VZFtȜ_Ə6i(`ڇnF[P.\< 5\]@_4@1i))jD.)[n/N_=e 9̛7mP.]_PmڴQ_ ٳg}uE@Կ E1)gʕ+: b oԨQjLNȼ/nsT6o6o(P]vnzUlwz[lq ƍ~ 2ugL]̤{իݺ  /P&dI[8OtK&>cDC7w}wvzg߂r}9 >w%Կ1F I k6cRGuGj5!0!]mP|Abαs3wSVV߿k(4\rwi@6Q/;R?][/_ܭg YGSL>+}n|yꩧ&vE?7]vwWe?ɷ ϔ)S~ -rԿw EO9餓YfqGNm?|oĈj41MNȍRrm/׭[7?;3*=RrA?_*++VZ傲i&ߺ(,v_,$r7 =L LeSRRR_ݫuk6df^iiMjI@#]_~nSqFһwow: ʄ UQ`>v[@=߈')<1Û4iyfCN'N_L͢&_ YD:.(>j^{M-vy555( ]vo@P0_,,L](5+C x o˖-nm7eNS3Yer=6ʗGym"Q`GywAw k߾Z>(Q@x2]  /d_K L]85;Æ +>=M춱nZgB0V`D3woA93U}NPF宊2|@a_HM7Lh%׳E[He$]v{/޽ۭ޽{eի>̇&W"Y09M(4 c6R,|gqoAyU}֯_ ˆ Q R|nBe{KXk/bdM5Y!XJJJ~@ŠsdΝny3f?3!yb֒-r/{m/^mmۜYF}YQQᮮ :T]dNs@ֿ͐׫6;ߐDa\;O^(Z1yEtO~M>{7r,Xwy˖-M]s5پMJquo H]m/={tWnA\yfΜ J۶m=tǪ}>7 ER%䂽:>C-l'-iǛL3w.]xcǎn-ZT7 duu[fozwu7fs} v_Lgxt6ŗѣGM/ e˖jz ;=d9o JnUtuשUQ l O7fky暤 դJtabU!ݢl0,͟*-M~?6HZRa3ܶė'J( W,(?jyaY~: >U@.%}DII@Vػ-]nQbsCr<@M;oMHɟ4f-\˗/W+,|z^'(z* sԾ G[Yf*i9ȸ;E#X|FlnJ Ltc^dW;aI@CnG|0]qj .pWM~}~^ee:>WyUP6/R l~dTCSHZe&OlI"L7m9)Lv*Ţ߻8R֬Y+ܹ+))Q+(]=%_|]y{:+R7%w{g 2pwE!6㓖+t&Ǜ\/+|K:I<94ʔL&JZ\Р*|rǕgޱUPNwk5NTKaB v*D&f ?+h%-W 쏁_7krL6{> ~b+mc/V0v?e˖ … վ |ஞ;|1Ǩ M7䮎<+--U) m@[]As1A|h;{+vvM6ydv%W&`ߋ}O%XH/ʦ]TJZ}YtK׮]$ /P :]I^Aiժw^wuQ.]~ H- 63i7 LZ$-g^&tZcrtIL;yGS'nukgr$[ܵ`/BpM3Wm/}qǓg)>׵.3gtWot?ﮎ<իGG;vMa6v;@ 2]hĔJ@٩_}؋[t{$ل ~ J-2z7瞫FP 䮊<߿G𰩠$-)9d"ʤ|;Ƥ\qj/^8-i9n/|9S$Quu׮];2~xwfy˪UՑ'r?Gs@T,{fɡ= Y(L#Ǫ:6II _ '!}՛kӦ;A33P' ' Ree6%- :E~0Jr@SA}؋ _F'!:,g7zoN?]QQᮎ<8s  ˓ M ۷ExdjwWψkתӧ#;YirXrL]LѤ$i9ًcfIۤr-oǎ3x`72`wU"e^,;掤N4n/'-*{D6$- :"nZퟠ\r%Ϫz]9"v^kv*I@#M>]D\P:};I_ '!yo²pBw---U7(W]u:r3P% ' 4^ZO%-2Yt`agDh=mn7E ~'|7A)++ﮞqv`AŻw#N9_G&nUrb;&eIQa/ZxI1mпn|9$؊+~ =㮞˗/W;,O>:rj_ov*o%- ښ|$hnr@t4.9;i9Mt'!RfƍYӳgO1_zR$ sjo6 SAb}"ضh٤GWm/s;'#Fgգ>CXx wuHΝH JM^g̙&lc^ ~>'=ExrW$,sWϪm۶qƹ#GRH 1-{䴤Ą .D^msOZ@<;%jDTVVcJȑ=z?m>6/[lqǕ/V"ʙ=i9E(I6cL>mF&--dw\ Y'P w5#w^u'PwI(Bψ.l^L^(R"طKZ@q)e޼ySr饗Y4gbgۿ,{fbryOL'-gE6v@@<<$ eĉh^YYŒ[Y(VMv9b&E6]C炽H@NOSw\ Y+}PLyݏ,O~@eln329Y?|ѣ;,ٰaŘ YpǪm>@q(l@C }b/fNɇ_#3pvEw2M&7JbUL'68;Jm{^؋&<Ĭa?:d"$qW1Orf~jnfϞ]0 @@{gC}S (n}F2wS΁D}bxŢ;nI(vP~W.%n꽆e˖-Ƞ7mr >.y"y!4Y76(Xa;sI3c7"fbdtlPX8`s>CcHDo\eRB1˂ %d؋/{X*w0}t^r#ϟy@⣃$$>Ev e}VيA"E>u|f(t|ڨ]*0ˤT)cǗ'KȰN;Mm[kѢzA)++qm{qrCnhF-SXޡ~wv6cgϲ3"rG !W !#4?6Lbc.KZ;Bp7>n|7n; z6f]{=ޥKx ~h(4(XA3 X&W%@S}s);%lǟfr$N~ Ȥ6M>>glO !R>I|m qW%FwT3YwwcƗ}KȠǫm;r![bza3f:2W^j{;@;U^V =,vu&ov].ݼ 3Y.ة7(gR$m4&'2N]6?vvϔ`/C۪Ny$j)b/XQEcB޽;irvB7p@òi&wu4See!/tri^*ii|%"m#{fI吺6gP~%1 b/CD%MN6dI%iD7{7<|ڮ %xyͣI6TcIAd_;-sr. ѯ;߮ 8wfWϯ\gBL:Um;`ƌ꽇e„ h¶v!FicK;Mp6bdzV䅐2{㍻-bgP<À}L>%M}{3 "^U~7wMz֭ήNC"4eM>pb  A L;NX֘R&Nj/O>;΄ ӧa K9ջ۵kԸ/fxv}/?N_p6SI ~ c,;漤ۧ>v\bY͂bt[6]=,o|n̻K KIjHSg N 5v*;mbdX-E-E\X cŗ.gB3-[Lm 4]]wu3:K.Q8 T/M?ԏATS lًsHOL'-_{aR)?AWR/Ww̽(NG^^-mNZ@jKsyT]#{mP܉ղ^6Xftcŗ:Lh/\mYnLDLKb6V;(.uk!U?m6MZ@9e@]㢷IXn_'Npqi&w M}vDm㠴l۳gOV%,6lpWGnېdWȹ9Ӵ @fSOAeIwؤа^n6AY^\\:0Nq墷g}n,$P2;UCR}޼}zlDWϳKxV7,7xz$<계;pWG̚5Kmۀt(~wDiiI?0\}C8{^/ەh;]tv/BK.{$'⾇c1 Jw!I?i9Ms]Za"z֯ esΩ_8`ėkoB߿;c իݗ{ײ{N|ر;ބ&xWն i)_La7o:P[[o^m׀B =lw.3H ~ >7m.LZ-mmEZ(pE_'>uX=[9v*բ5{F\g?,߮_'~AerH:/5kָNHӘ1cv 7{=2|pwu!mu-4mw2)OA6 ic-!\Ep~/MA|hY{Yp`(v?f6NZ@fϷ}E9G)ة>fuD'<CmӰn۷o3x`²~zwu(J&_iY7vZVf:;6$?]T;l/\ΕTс@a$<(̱WkAr`N|)))***ܱ'k۶ڦaYnw^UV*++ݗ@#vޭeH vL1ӃdPߡ6$-~~\+=gVk/QN>A]u`;aworc '-WlaRN>,Wra3iR%XeK/OHyԶ ˰a#oT1,/:1{lbm{@+{e_u&SϴRԽVT?6YwJKg>V)&_aB:`$rG:HӲf@؋-s&+&Aϗϧ&բWP:կglj/]t;A]w[e9ꑶzj2dwu4bرj;hũ-4L)eSw4 FpmPESaώu![NLr@.K<(;G>xց IbpFvg{w8M>;>џ5(b|[)}ez?}RZZZ7,R6m4ȧvaYf:B*U75 r@vL%-ʦ5^?KJA7nة&-կ+~Aa͛Vȣ%WPX!lrc]@٩샦4鑴\*r?kP{+쏆A?t4馛6 ^fΜ>kXHMP級Ҳn^ DpmPH&NY`R\ܥ3ur1!( E WP6X!nmln%- w9iTEΠ<}`kRjdv0e;  12%Mֿi-2%%az.s^g5? kT@<*,SO]4;H/->&XMI7LJC]@1lj_LfϞפwsI`mk]{C0-^ىb+*[,(>"Oj ^tcWP'(@gL.2>&OlÈ|L a-͹/ ,~ bob|8ԮPU{N^QiYb'vi{ǯw K~$X֜j$K~bj wkd=T*+mW$z 6~`P^ċU ԑ^ŢߩA RpePxFw)?ZHbs|ЯWYf." '۠f:i(+"^,Z,vES#iwi7oǴFM f{u6۸t׫TЁh"ћ>$l{=k`P{/->[Ӯ~]@.ŧyávE=5 K-ߡX:Mbu~دY}?"2]QdTG\:^jf@M\*"^,:-igz)}WZ <-{kN L$;bo}Jcvq ߡX^^"MƏ׭Tg>sE$X6_&kfjV@ML{V=C=ˤyb~P;ɥ7sǸ9C+b?*Cz$i5+p," `M?SA?&ڬY]ZQ5q؋xTسL=|ZDs=ˤўRx :s@: =5zHl/vݣe4~~~ @?uuu쳏9"rDmP;;JTлڥ`P]^ȋ7=ÏŮOTr=ˤz.s~_va Zŕj˝e4irλXo7WD_=Fa?shڥ `P3Oˣ;ĮO[@ Q'L uI6^,wFg%wc~- @tvv[o9"2^OAmi!i/C҆0¢z5X?W,,6Oj/@uTw=5z;}'Ų=i}.Y /4UDf 8lWu^Tлڥ `P3{!ʱ=җŮGTڳLϊH1R|*e)Svq{FS7inn|M z 4W+PdO^5Oj@ͬPżX]vv(}J8 Sb^)< fyeX]=.vEq^4o\󟁊}OZ0qDs,9;.-(T<*Y>(ŧ>.eR/u2@w=h&8;M:G=bc˯]* _33g_Pc=f1Qd6.J^vi@Nb/QgZZϏJܧB.=5 F,{>h3FH|z], F,b/v-X|_PĊ+=G SP?u_)K=*G*Qѧ2Ss,x#yePH:G媞ejWb~Vgftq?Ɂtuu5/ʕ+87:OA<(zyeP bK^أ2e%+c?,wپ{REU-yEҷ4繼OcbNHψϊ&u/W_m_6t͹vIEԍ>u_Ke4ȝ빜&)/u)svps=\:Ůs2`֒µm/ ua Zo&:ܹsО{L'' >Y,(j{q-e[)A~O)ܽ-e`X B@mPBӸb׷费 M<.)zN7'~w&Kn,µP.lx9^]3eH]PuSuMh!6?R ?e)؝|~ $]혦B7sF K9b׹/Yq2 .wJ4e)欳`@C/~a' ^߻^5Ƥ N ;$}ϋ\vӢÍb7b9(PڸU>X4>_ }g$' ~Ƹ,{rT]P_^+B_-4i _]5G@1)'w\:ĮwB5Z Oà 51!̟??6l9."%'6k^rj@l''_~H:Eop#(Ao!}P8Sb׽ѻﲎu~Agg_RmP"L EmO:bC풂0}*/b/Վw3I/}radfsfzQLr2_;?.@}MoOS>کj' F,{Y"P5N />m|v^]B)řNS~JOC]Of~ H3fDs@Gmo{׼a]Q7$G\V 5tƒ~QVNzIo)7ǁ'?#NcD5| ]VwM2LO2 U̙ӽD!67}E-bB0H}re)Ly].J)R(ߒyp9{.;%b;O$@]#ܢI{IR~nk^k^u)P)TbݢtM53 ^}`f/0p?7m}Av@}@ E j(&9<@d\n{͎;,^دt`]w5{Mz' -?yeP;Iմ|^9Fȥ%@˞{?ӯ|C1yP?ɠSA,_2#vN5:U&M%FO]=~Oh@tuuٿKDNCm#]hn3~1*ac)?h\ @L}E_~= Hs9׽k,ld9Jl@Ci7 :P9GK6@%\"@|}]qd9眠˯5Yge^> lFlBsC_NJlj^~P_Ş4dBė׻CqN8!X|_j38~Kt/ڡ6H&_jgc\hSw @mo(޼ \q>8Xd_jٲej^K-ltؾfKKP6}z.Lͅ@]Ŧ^Ux;B 2X̮k~W_}5q~Kz6H6} MF@~ syefPw=Wif4 7t'ku@E=Kt_' =6vyUl_Csf@6詟8X,C.=#d뮻ί1cƌ}z{. ~(~ E~CK6S?MlRP;}Tlj ƹ,{<)tvv;_:::gf?+#K]>!6H;44EHlgR03x_.T@P\2Ǐ,X>?~~Ძ' }(V`.tX`Ş4'H<+x.}W<̚5+6lٯʈlt>m\:=b;߇@ByLǸmdX'%4@9t_4iُ. >(~ }T(dG>y~=iuY+@2{Lw9;w_0o޼`=0O沮' O9&]qԩ$xbiSB$Ttd2)S.rફGL¾ ltϥKldvh`\i\G-H%z*B$aRÖlw? _|1`27N)6HKQ4s΢_tvMobm SAI_.ψ=ˊ絳ӯA,_<e~.9T]'4i}͏BB<黧{1b>堃 ~ވЩg͚x֏UL' \ @;0Ol'Psfjۻt=0H ].{9:5t{{{!Qc˗/S=JpT'?b:G] @J%-@sIa*h ƻ{9#G .`ɒ%~]UxK/ 6l3{gtPlTN~P;nR| #vt*Ş5z t'syKd2裏xQ=s N9mRGgI'ղ;N]LNP;h$۸+eP;鳱˹RbvmL<9Xp_D?-^{]wlDW@e(~ PM4H߈Ğ5rY7@:,(w=;MMMp L:Ӄ;l .; 62?R8 Ĕ/M~P9RP;IĞ-f<3Xl_DK3g 2åK칡ѢGTO<tvv5o;1bU{ofn(fGN}( zP;ĘNh@[LW-;c|'袋+xW+Wֺuѧu.c va=\,L}). @LAlgNs032QS3eHƴˉ.O=O$ܭ:0aBp'w^gΜ7vKw@M4=j. %sŞs];MpLmҔm{O" Nv(=P;ԐN'5ѡv%ŧj4s.LuyL 7zGIMQUy[󨶃3mľrߺ:'? d}<ͳ.F9E*bϵW\6 j>]k.Ş ,Llt9Q ?[AŌ~X&G qGlPP;gv4:^.0pzSbϹ?@\ se+z' oo]2,iP3KĿܶ`@CNP;TQ%27C\(98ЈFܺ^&w9 hXŎu5Ϲ @L&L tsua*Th\238Ľv`@ҩ;!UpNFP TL`ޜh$[޼N&zN&ۺS9 hh:%bǻ].NvMa!v4Lq[@\^d۾sC;P5;Y4e&m'== H?hsi[3.mU b*EL6?_ȴ4L Gڭ˹RFi?[,P)WNv+]Y bzs\͏BzOW@ܥ_ZO/jG֒][ZirnSFsqoJ.gZZoεާSLgrmgmtROm\^.iX*`RYw";cO)_se{o*wǖg}):ӵ]bWl.n@mIU)<+]QKPE)'o}gh4{MXu(g+?3oi)osJsvaG Sdf bǭ*_*:Nlѧ TO67 R9_Rini;`բrS?3iW4&-*j @Dli}_RQӳ=O}mu_ TܥbǮhw@t.џZ8KK8׾?3^^1-ͣ?5P1MR(V) @]"2@#rKB-%\guMng h;Ŏ[@]#2мm4m\{-hjf8KKp]?NO/_sY,v:L*'vH2H#JoY&_ @|vO]J FJ]JFydT~b RO׵yM-;mgrWڔOm9" 4ebǨ-PEOT#zWFHjMU,@|]3|[s˔oJ:w-+Imזl~L.)XA8GH\-vlZ/4A.#\uv9,s].) ]2'| B+b; ʫ.@U{bSɐp]~ͣ~;PcZGdZ~ms|d<0pyY츴Z#P P=E?u\ JsY.Xz.D 'WL.@R|eszI/?/%\۵%2}ws+b˛7v?gsO9 m ?Vv(@6Iz'./=5/Jau;wI]CUZ=*zM:?+-)ͣھ^UQu(7]8чr҆/4!.{2eؓjҳ^)\>粱$j%H+?*ߑqNy_ҔmEYUu4gR^d#hpZ 1O"_hI$sY)w~L @u;XmsߡVѻwsx;?Z:__(L wNe_VIZ1/_ D ߖ>=!6zzb׹yG Gw>(_qA[@Dޙl~{O{GnZ>e1M/V =y)D= rt')B.JJOԿR(וۦ۸5/e vUl~qW .OSޝ7 n5rꪫe3֟:EyR]T۱bawhRH IRSԺR(םۮsoiN=PҪY@s/r3bOJUO. ;3./w<ŋZyl]]C=49rYe .CzNsR"FzY쵢?$OcT ˶mo^k;P}k%g_ƿhqY,T7.8䓃)S3Pc/Z\,yFdwtvv8}{l6"R~>u6P5C]NtyY5IꦽS1 R{J󨩟?T.SxbSTZRxG[bOoz?a/Yᄏ)jbCϻ /O}СCI& ,OEw^0dgnr B'E E862.Ak?a T̷]{)cǎ wG}W 7Տ>SddbA&ͯwH}/S@0M1ƿlrOy睃o=XrAL[oԅo.O$xum[${݇A0J˩.=·?`39'f̘lsp7 94(?@Q]m[d˛7?hl\{@7<>}zb u]3~;woix -So/?(a7e%tX\G:tvvvaРA݇U `  n۞oҙb i.{>:xR7N9{c] @%b [\N2cOqii}%k P7Q_ s+`-+0g|zǃO~f?C岍P_P6?@%͝˨e~[l.7ڞvEӶ0}{N ƿ(_ =uY* 5:ɓy{ ۃf_%._PG .gr+B٦}ms-3*c#k?__ %,+vXꫯ4 Gu?vd-6`uQSׯ4g>N]M3,5H.w={СC7hp3f b2s&@uAEj(`l_Ri?t*g{ϔj/*/ :<a0aB@1 . ?pߔ\P#I.;M-7[b[d/[d5{j//owY$+GV@?TIDAT*# ]*P /7gGXFûz_dzI`b ]:t%;O?%=s{a2@e|]FUly\b={OjyTnzUuaa C.]b9SʢI'd2TI N&vyԣϪkwd)#/e__{X455^{<r 7wJ=# RPܵgN]{O9u*;u\X~wac ^˥b9rd00 O>d曛\2H(P?\HLw?VU&]޳?~yj"6TSr![o@E,Z(8蠃~WFڤOP BRRm[n}Wc+ڭm7?Y**"vշb9˗,PQ+VN;4\آdd;U%-3F)8F9zJeGM}K+kZOgKv؟U~s#U=")/?N ~XF첶&?H?HLB(WlsLKl@bz9EmsFɴ-?~fcD` T.}3co?/׻|= 5 Q 2˷M:M\ߛ;]grnd/e+cDa T=X"kgI}}?{R*Cor7m??Iijb@1E"1Fs؃$2Æ z)X_W0tPˤ$Qpm~“? dBO"bDc tMy PW>l0bQAAd(ϖ7Sw"N_T _$_&=("3f̘_= ^~`2m/9NR$Cx@sm_d )cڶT __|{@رc+Z_/+\>/FACihߗ.4Tc `FK;u>AGG{{KH?@ !(2$_" >%v59chh/q=@\(~b$؝d= :;;hhnsYOF?@b0/bwR}ܹsY7?/0@Dr> s5MD:\>(zT Is؝d~@4ew4ODn@*k a$їQ;e̞=cD>%ZT I0q;əgJlqYKB?@0/8;ɦn,[߿t`xP+* _J"wy]hr762L6OY2DF2?@,0/bwBq+Wkecg}5UDN@ *c8PhߧٳqE.P-_F"nwq 9s|E'?@ /PC-v[#MMM@?+ǔ[. 5F ƿ@1E\ZNgrg08qCPY:_ކHaq[#L&xǖc}T N5ƿL:,Pgy9"}T 5#ƿfghkDPXpE+y.0P:_^,v'34i_渋ȗ0PT_ yT_C5+0PT_ sL8WT~sEd FUƿ+X&O?s/" {^5jۯ6a _Pebw5r衇(*0a9a+?@ */CQ S f͚eÈ|G}E;<=pI 0JFe^F 8ݘA(nш *.D"H$F^3Upø*(,sϩ}RߧU3oU)~*!2D8#zBw^SS;?Ds}Qc@@2D/PDO&ߝb0ydu<Pƨ/4IDRYj;/{+QEC 1_Dd艔:˝bti2 _I1 Ȍ?@Q_'kG1zWq@6?@Zd(-ݺu Fܹ:>r*jE'}vڹQlmMV@ikݹƍπc`?@ F.#*(!'ܹsy{=u| ?@#`Dt艓={zMMM5B=b@bF.#J( DOtԉ?҅^NܹO-O Pl?@#`D .{S~ Pj̘1j>9b@(Py5_.&[DO /PE3DO_. J>:o}QW\qYvQI%?{W;o>3B+P*C\$zb;NXo~͘1[h*DRHˇ~/K,uWy衇ܗ<_55 jOTRcp j}YgE"O>Q5(ڵs+w}]4x{Wz=zP'{キw뭷z_} DI艑w~!@o={T5>{w'ww{7|s'tR ].L:}> v*nF\ӦMDs{7t7i$;Lcƌ׫r\555/yߘuxܹjA3~헑劑.[zI/r=ѓ"-;bժUgeĉjowLy˖-v>wI<78˗/O܊Lopݧ[PQ%?֣>KF͚5ۺuJ6 r:tP:7&q]O:ϻ&/]gPx wr)jL\-[Wͣ'DZN+'@~aX]Eja@r|'&ome}QYf׳gO͠mۖ`HC.߰^3(7|s2wqaw{ス8o}7vZ_bE6ϋi9s;PRݱcGbI:meҥ*@I)k̘1j[a4ix,b`HC.744ukMXH{qQHW[A엚̙M8իZO.7oZ/y[Hܹsy}v38]<2<~XIr<@I)럯Jm',C UD/VJ.]۷#  oLeΠ\qަM=z濎{'qA9_~g۫fʹ @liYx;PR>|NX|ޮe]k -ZHǀ|*P{~?L Oqr绋G~M65őfz8-[u墋. }F_G@~^nbo/m/J8F"-ׯw P 6r'G}aőڵk> :CR?KcEv]]m#,>x} &#AÐ?1*UmyW#g侎g1ow#w?@ u>*]g #\(_dUDHn)T'@:, a!psQG"oEvPĨTҘ[UEfc?^{]YoPcTX6 WWtG+"DOϝO(PNjA.ѣG!¯`ӧOKvPĨTG?]46?'NtE?@ocl?Zd:SvbԵa=2`w>@8矫eҤIy ܹsőa@/P;bTA?,O>xlr .,[n]M,^]E=rᇻ * g̚5]<6z?]zj@Q)wyG?,_~xl,X^OPsKqJF$ƨ\/_[]MoS믿."'BZ ''@|:7p828#> ȿ6mZPl\J۶mS),˗/wG?@o_"эϺ@s艐R>ڔ9眣mۺ.gr)cU0 ԎExJQ\qjAիho?o" pupZ\/ϙ3]Edz衶w(/HDHˉ'''@8tAq_WP9wQd0|pr@Q)#F裏v]sE?@o}\s=(/ =r' * P.wA)N;Mt][wľ@Q)ꟁh.2awQd@$ƨ//餓NR!(v( D0Gi }Wr݅^^WPڴi. N8b 'F:v_;vg" 7Fx8/q5%U!-<x>Рk]nb6dg]w߅d'AM6uncuY?@ q qE6DOlذW(@'@W^'ξueڵ~zAP$Q;,z87&\pb"-n8>e7]ZXQ410FlMH -Rop_|]<6z=a9s8B 8BT/ .O6CBQc`.lj,zbotT۷oڷoC9] HɷjO J]\zj;a9#s7CuE o "_ iC.\iX>#wV{vҶm[oɒ%*fڵ^Ϟ=vϻ@3gT0 'bl=ӦM<*>nXȈ7b\p\gDOzŶx~ӧOoj h"oj;2|w5>ݏD?+Z:;،7 v2oȈ7b\p\EOy۶mspѣG'̋Vw֜ݴiww'?S.wU;帟(E?ʡyԶe}^|V݉dګcǎjreB?@VB:\pQM$i<_~#<}lj{ M>#>nKyҥ2:նSNĉO?]] {Ӟlꪫ]vE/[N=VpU9Ǻ=QĠ\G}Tm+bs=ד-k~Wcz:tP{g7#w?@Vp? GB%ztϪ}Z,\TFVCU5bý/ܻ['Eov=3~sJ`ҥKq qEn=Q2bw!۷W):h RgȐ!\m?^Ӏ$OʩƏUܹ矻/9rB!.s8(p%-W[lq_gyZ_ry%~6mڨaQĠkڴi~fd.o5v7i=Y2{lw! vskkҚ9{oo>q"3sLo;QĠkܹ ;W_}uW( 3q QEk)z¤媫rdO8nZ{Բ:O~uF\}9(o~2K.cm6﮻*5z-%!O?@^#`.G5NIK߾}y̛7رڿ'@}_N:Im6  G+˪'2-?Qڵk;mvaÆyoP  /Di- 5_Fg}ߨjX]u-]Ի;Lm_?f̘[ZɞwyH'F\_+u]wU-$>|裏&+7߈p QE1ԙ=iboo;0`ץK©Ap ޥ^=É_lܸ]U$6lkw&~zzJJƿe}˾vZG]qc=բ쯖4 ض8 O {cٓO?w{gu7h G^Zj ;u;_x?~|KoEB7"\pQ,8i'4  PaF \/G]PO?T!9^a B7\pTQLL6=Ҷm[oÆ \PD7nڵk؇cPLP/Ja @= (Pekѓ,-:u6oYEi&owW]@V"֢J/Pb(DO4DS[HSB_QJM6liu]wh{Lnx .(DX%Br[Ne &MRYH@Q%@Ď(_l=ҡCoÆ P{۷WY@@Q1J/'M7c F?@1E9!w'_Zڴi}\ŋ'% =& *?@LbBrt r駻@N>du\6DPr@ QdIks@x uq&}gc=NB4k@8@Q-zl=iU9o۶mԔ[z|:>Bb}Pn(B3h ck2fСުUch5kxÆ S-*sPy"~lR.22i~?cCT_2cofTc0P{ܹg9eo RMgIТAɕQsmb_r( C+9;}peL6miӦ2l wn&@.=5?KJEo_Mvn dʬj}8Y֜y޺uhÆ ިQ|1 Ru7Y-ɡAʸkY6S r"Ve5 ѣ7gٹҭ[75r@%ˢ,6NxP rPJMP #uꪫm۶;coW&gA*4+DD?M:P d0k=Po{r/9ftlMɡ%AJѯŏ@l/l( ?L,`)@6mrPcnݚO6m1/*.&>/&[FZ~=Er( FVipx{KF̚޽{{o;x9>kziwz? &Zr?(X?~m~xP8_J_  `s%xk׮uPV^;V̓XsJz۾}~*m6ڵkț&Zg~-O?SUqcٽUkc z"*m;D9o߾ /w½m¯I gIj"PUjwL}#Fx}> 3o<NPysPm~%=ߏ-&V$PSggwZFH@o<.`+~`}ez߳L5(jd}?[hj'CTV/1&Eyeȑ|T z+ _/j E-+O ,9@!\I/y={}A[=avPng~ɡQ"Wr(*C[dCysayO=!&vϜ9;տOY`ri.?cCO eO,yˤ]hՈN&lwM2[a_{G}cr4P:I-~?/i]]*ON1!oser(*E[VWRBشi;쳽7xkjjr߳P6^zWWWnC)џ~VtOZ?'o׈l7 V_6 og}xo{l2K|ϭk& V# 5ÓCkOi&|fo5{ǓL>FѪ 2{G+Wmb ~YD[|>& ?ܛZSOQ}dP5(SGs1$>s=}bG~+R>5IjI;D.dזѵD"uɡjo(dϤM}#)Jg_xoƍaۼysy 7tS|arwU7lcjof?5sZE1_ξi_R}щ7Ù3gz֭s/+fFڿE_MN7i/Zuџ~.KE ")oKw?yEv8P@7ED{_|7m4w6lྦྷ{7uT.>n^2*j]GENyU O`FEɡjoTLlik/oȑ7͘1{7%Kx۷ow߃#gim_O@wWx'NL?u{Oco֡e4CdߤHqd4Ώ<\{PO|iloܕ iw^7ʒ})&I7/RX-d3`mM^9 oFPD;W~Nͱɡ{R(}zF8ZHv29Oo@6Di49P.?Ѳs~Vchhnc̡Pv N.fGw&#L%LELr O쉅D;p1Iml%Z&K7\ 7Z+&@8(7MI(dJ(&6~쭢'gt1d4{B>D>|7jzC>}gG?M2ARK{&Q6vurh(_@MF(ͷ[*ʹc_}oӒCQƨL>oÿP/ '&Ln6;&kDƕk?5r~@~>Q J%/XA P:/3;Yo'&LIm[dWOv].ka5Ҽ -MJ>S~^͉/[|"ͷm0Y!?H࣓CԈRszrh,9"],(Ϛ|l=WLėIv'C| ɡ@9ͻP8 PL>݄d@1 +[_ݢ0?MMZ\(zI9gIMnc;M[[ 3*9Hsbz Լo=OmҮe4ja$f(~>@2MMڴ].zؼ:04&z:9@v ({aO0?+Lq)c8sO@@ ݀M1ɡ@ Mwؼ:&E?M2@ncP7$ 3)95^y ^&D?ӓCT {aѵ6{0?L:$z[a~.zΤߓCTsE969P,nl3909(l.1u`ygIϖ*Eݕ (#nglr(M;~f=6%m/D7a~pvʹA@CMv?~~ , "E7a~֘Hrrdh E?M$(SME~.J˙Ԝ 췢R3ySɮ-6%]0?"PE'RYcAf~ɡmlɡġ.uj-sƿPղ2P>=lFo皟w;Ŕncu=^ٙ0u5Ztgc"jw6ܒQ@|ncu{L] p `@uv^ ܤsh  D=3RE?=^0 vOP 2 E?SRTg9gwZFWmlvvσW~ 0z3, 319bfd 0Y-z y=Gqb n49d4ߚ9|b{4i L^6ybmmA%c 3995EoA@ +$ye$*&L2~\?mc<:{bNMzۅD~VtO-nC<ۛ`sPwoH|cߘ-(gLoǞ ߵe4jQ)/׉ގRqܹɡ(ۃ9(?^ ' M1SuTjncΗw:]Qw"9(پj/^e7?GO~QuݧNWCf.@ys@ Sӿ@7 82ä\_P[\N~.ME +研q@c2zU<}E㠜/ϓyp)f쭣Xw6{A)^׷;6_p'Pla; ZGEyUfl߷q@U2P^oPHa"Q~4 k%?eySrϋg$wyJn)|hSqP|(.UI34M3 T v2( 祟m¯ԋEߠ/w;<gLudeɡ#Luz<6RC+ =[/f@ű'N.K{tNC^$z;~maRɻoFvor41qe,hP*ncu[Ώeic(}F}>B3?U8/]p+-/俥yI{er6qd ?' S}lٻCQ {ݯA_2࿇e4Q)#i>ai/͓P{ػlYfOݪϏP>)1u\ߜP&.et2i+"~~ceg-_o[Jyn8F969,GI]F93!Ϳ y7S%| ԒSDCRUP=o>VMz_C3ePJ3稟kvfg4(Kia_F8ϛ+u&'LbwtGCPsi?rW@m])q?35i*]pw`TY⒔N**Ax" ".HFQ䪈 (A$L"9( $H$9`Yv睚*qys쩪>UuޣJw)(; 'rFMwi:bF3@ЧE= stY*gIp#5׻L O]̕`PeXyHЦ*H]gT[.{٦scO *X,QZbW.tpay]m [3? v;aVZAÇDMQžr nITMS.cjcaÖw'uIW@ֺ//ʅ@8IAwrdF2 nTa4uapJ9Q֗|\//H!i2)# "|Rr%.7<2e`)k0nY)2D.AYs5:t2 RbXnyVǺV&iOz.Gh:}Wjczr52ih)T2E;U/@#H A<.qT.?zr1Q9<ǓaDt!J} d~hGMQB=:ES.5 Ȱj/j-]K\;/ <%пR-H>@q뿧a"/3L#?wuIVv4:hdpt#ɽhkzd\%5ʱƺk7?H;?ą&iDAN;Yg-QĜq0"K{Oby#Ȁ].qzOY&-} HbqdX._x\?/‰4NT&A i B0\?ؽB{%F[m.*EEB$x<{-.oX;.[H#TnW㍀rҘ>sd } EBeժ ̙^MQ%}$&=z.EW~2;:uu,_ <;pL|{:a1*|a<4u1m5EROEKN+l[  $4T%y-v;㍀)f>yZjÝFI˹b_C0 #5<_~:C+u;pk;.;94< { Qz6w ?%RX$}cq֗Ot*j 8YgX)$xx[<}[CKR}Iv4%tQ&\TY֟^>7 ߁wF ʚ| 6~\?R8_b~Mm\|i.]N{\wy1\`OT*[B*ң=}eй]⿗J$+bѧ*GR"9Wk(@*&66{ |{$ S?3Rٴkt{"=oSmdBԭ̓C:t]vvsЧks_H$j Л-ϱ\ ؤӧHPZV}Uv4:( dYc_2[Y=,sžRadMjcf\~ڔF>.k_y{) ӴT7}۝~_szuRӆil-fKE?O%nswnKWv43e+H0϶sKwظ3v sžRadIgUdRQnx^^71=4@FujvLE=^{+tPm]l9DX.YQZ/;@v4z:bwFM;*qL6;_%g4 s9Sˤw5(ݫb[1 W7$} $_wϲTܾeeIdbK od 0?5X G-{<[Q3es-=|J5|>,G+O{?r2VnRu}]v472L/]w<̣.ˎL =.O= Ӗr}GV@ӧ {/ЩVuK{v%= svRnR~dF*}J_L]oGs[q=sVԴ#1 6g'yntn!;po]¥2r hqءN,Y+dk$XO㲻\>i-\>ˆ.뻬벦$ J.KVWDŽIJ"g%lZ*xd%v;m`x;jqhj7&m\~5\0s>6Ejyw%_8[8?%6 ;p)@*r=y\3 :ks.tO$KražWatHG6i?R6\ %*xd}Ov47ĥbaf䣦`ƪVm율i gkG`x5jgh^S_ %&-HD쎼lޑCmY@E_{^-O)\|e*?r9&X$5zHᥒuhb%|C.՟h? 4N-Ɩm݆z/.q]t 7IPcn_r'u~[-;?p݉̀/3-mGIӝb_G$Gbс0}e|oDM;sc0Qm,(lא«}{k5/5ԚV-λ|o#*=0W7౻P{f:QSdAs-FI_G$\o_·QԴ#0 ժ5m_wի+?x9Vn_Km)̕I+λtz/#K{2L~0'-ۍl;MZ*oIsJ.KHNF@lއe`iY:L._x̿^9[Ԍ$?k)?WhCE%v%;u;Gˣ\`Nľr]f}\5E铬g[* ,ݎx# }M0FMfr7(]J4W$:_~]VmlݨiTz>YSz6OV U;0K&_V)iٵ|@9RA{3GId\! {42c}6Zʥ?X$Q 7@|]w?_QӶ0VYhZ?%vq C m<_% %p*@3iil.ށcU{^*$ґ:ʅ`}_,t*j;ϸT )[Yk)`D+=o.+~#f`}LY@q%:{;.g"D.?k3.P%}*r}; Y(k]@G{@q:ʥ|K㣦h>xNHY=/"*wF@[} ׁvV~`QgphJSZ$!sZib0T˒oPr܎;E GO{^*F_CtD<⣭uˉ夑eATG'ќkx%HZbJep=gէHUA-' Oi*j,!?u '])};{^.{,} g}?u@m` shYfLuH$'v;⍀Pix[.DMougrx%]4HP^jc'DM<_ Nf']>/IN8:eǑeҠs ?/5TJsߋxM;#4_OOҀh~ot/}!?}+D0 @V{V*DiH+S~zT;Xz*1NP6m:i[ډn@vkMt _Ѿb?r20O)I*_5t]xm} S⿟-m)]:bߗ0Zmlۨ).TJwT@t s^?9NA}0LvON\:ïatmè)ڔΓr2i?X$Q7ݎF@:_l3eݨi1 @{VkΑƔ֛@mKWJ6攨)ځ1 J{+lz Б{P_.Z>i`DM(vY(v[%(V*n%Hpǭ9,j6V]ߌ,63-,bA Щtʄ0Ip6 }cudFhkNt2H#= C6Tp~`t +op:^ }E}uK /9>E2ҮғJFڷ]wNs4Z>Je(\ e}-,nGx#Tvі07>q.@nh$KPg/ža6֦r1J:))Oa=MSw%7.Ů7̟%<Ŀ\Fi7^\&o꣭ =(ɢ3OH ;#㍀#}$V2jI\fdFjn\fNSQSbj.;I-F[%{S. WI%¼r'xtđEy`K)Gi7[5j%ZA<.o}-,;nGsD0vYnus`QF+8dc}Xee::U51ʑ4Hӄ§r,@ּ$Ia>X :߾\tZRƽW*߉GKޮ?霤YO7gf](f0;Ok)`D]v4̓ V%9wes`Q`m݌Ӧ VT33Y, ٔ-} \jc.1nP!L) 4m\* 擒 sb>pyX&Kʃh߉m*;Q.iO_b_C/,}:ߎfx#VpyRl iN94$߫GIUb_C.ƪU{T66> ,C'*r=7(n uN+p|_bOcfKo.RɊvdV("ѓB5TJRz\v4{x-]7afB= 0LZ.J% ?wX5O_FכMw|.)ܐ|﷢A湮OZv̠rd@z ';J:%8yK xsR/K^Jq}QSt~?JeeӠ|^*_Lb}Ol >\fdS=i@k(vVm쨨)|‘)̔lkz ;\):5\O՚$]kIQSt~˗Fi/v {^v40tʅJ7hyeF[z.@3 @2jd+5J;V6WXEn_kzjbw._x3@}NMS.[W5r!SAz>O/S'KPr<=Y]p-J5$SYe漨) )Fi[nZu{ yx#%)32i9Wk(v.{Pmkܱt._x^s/|&5ͤX=_+*-AOo{Fm ,%wTtFvkI$ohv7PbO<_gx`Ԝ+5J-o'Q3pEk2Oz\E.*@u~!WJWp@K8V X.[FMQ})kFi<%v_7Kv4(r}(>ehk@=iK+5J;W6v~t {k0{.ڪp@ dS:MwRj m$,|ZR,(Z2۪5rk3@E+K5J0P gxerP*6\ژȫ7V\\w`^-N7{ HAWkrbOhktEw7/ ջԓ~IKoGutq-!0FMnALjPmlrkIwFܔ]]ś!O“БN;沑4ybOqy_(iߝJyw^r|d ]䗖)I^bRy.pkDauxr.__{ށ5T~c{byxsRNZd!-h ;=@Y*EH粆oyF[NpVmظ) өͳۯ_;cwaԤU'aaEM>&z|H^)=^Y+.xn`=gRFmo[bTEM@cu^jcI4m\rk5;_ m6=ەf#k^t m[b\)4>g'aaqYq5P~>.<-KOK{l;>)KFƜ\[bTQSСn ժ5g67uHaQw6$O{[v[j@r};_)|f˧@ʹ bSi&>?б%T8n9X;ћ$_ye1|jc gcJ\O| y;%?} ks=GK ,3pAOW~bO$j m[~wf* ymPW0FM@bJ7SmZ;?0ÿ@^+_ripoM|/,8Rr_i w IDATҴZV? ewm Ͳ.׋~5*ZP۶T Hb惱vfG}+ps}Gjc{{r%g&]=u0MЕ/QWNA4oĞye)0.z\]K2O|VJIlR9 \ !Fp~bsfT 7Ojc\n/<_B ˮEV { 7۝u^x@jS'%0FMĬ'AIV6%o-.W*G $DJfX;c7!},.<=@a96*ֹj1Tlb ]& 1eG[vp)7̍tu\f= sv@m#ih(M-9+j C1HO;c[}P3'@/QS&n:4bO0!CFRqؾf -5$-Ꞔ~@ v5HJ.ωaf NV-Ɩm H.O= sKw@*5/H.0GDM@1ľ0ZmQS@RĞN@J$?j7S0\6 c89ժ5$eO'`|9j E_5OH.awYj5*,Tĭؓ0EMbF<tySl ˨)(qF5$!2CIX]6KCFp}3_QSA V_Sj EXl\oeVg(ĩZtuabۆ)[l?T M|[^* E0@ `*!NEq'N$Kgv}bx#maI{0?Xj 7mj`i;x>/䩞̔`WK[K@y?@ۘ>F=' { a6%|I TyeEn5h+瓌>cx5{T|Zyޱ6KOb}RyI"=Rش) ('QI']=-koLryElO"o @@o\)l-@oFMؓfVe@+[El sxȆ]J.ω=sKhk@]v{լN,XClX;oM.?7# hwCb¼5k_'[iFO,ɋa֎Z}c? `~fxQS(o19u4ˉ "Ȟ0}vG)".5*s,'qέZ>fYbÜ5IVwsI \W$\ԒADS3]ueE.p]>,7p~42r9eiea&6뿐`P덌޼5KHP]ཻ;\yYq\`n݆>EY V5Vuanښ ~r˃.o=AhFt%t˗]6tY\T_YrvA|˷\>鲤IE:l χY1@{IpC$}vo_(Uik׿A%(EovGd|7zbQSYFI0XZ%nv9e{U\&57ZHir"ZaՈ -K|'$bD/|(u[oYfH+b34kW+=?xBh1l(":o- Jm.8qѓ]7et-ybcͧ%]r\ҟx7m:NTI] uGB3[Z*'`|E]䲏ʒ>MD9C< LK+APov>rCbKޝekiMt,'TtP D+Ƴg@+u9L@)WTei~R%/HO340H@:W`'EoK^ _mڢe?ϓyh_zm [\^ʻLZ<{0t\):,O ۮ\$t~0Z6VсYbǗ-$M;:+ ejoG,&kF]6|:.# %⋛mZ8z.Hl6:7g`x+4e]syP~T VPdswwqK/=&L0Z8!Tv7PZO$F1l5@ic$K?xO-\wmwbP(^so#_zY-*b…_x>X曋W^ue .(z#8'Ol^Os䲂?-we sbћvpG<' @שsjŮ+,ŭڪO<~⥗^Z1cF.>_8o9]S׭m6uI9OjAZDw/oaM.Ӥ'E˲.[i?ӦM+R`s=7<<00P<ꨣ[o̕2>:簿]wY:̉7 6t9-mb]|C~ӧ{o6nܹG}t5viïQo_ yXhO$s04Zy=XOm3t œN:}eDu^bO< wn{#FP-={FXG.%)So/x]wo5Z>Z~}ߣO]`E F^߸#v3L8qT>K/o^+^wuӛ}J(ZF+0 drU4}\{`>r>S꫋f1ynP.͡C>GlC b5ZkU<{oޘX'ZF^s5@v9ͥGFm$ p:z#bVK_~9sגP/\<r}`(e/͓F0+B._BB"ۭ:]c̛.',#0vZ߿h7}b)cFmT<3x̞={xO|G岭6Fo.uѧ)2KAvx㍋\rI7^}gQ\yg2<csx#b+KƔ>x3ҥ%<@y#L@3<,vFKp\{`]wtn3f0gZ͟??&hы9TDsO~]ᄏ;p Ew@m%v9\qy[쾣,Λ7?$B5:OpB\}p3t8F=h9{'Of]wU`>:wj=PQb|S\Ϩ9'N.//|ԋν8ًV}jShqm1߅:rKȪߋY% +.Yz饋ӦM+.Z?AZpa/..Pccapl32[ K>jӧ]!b0an} >2_lB.Yeۇkʞ{9\W_oY.`<M3v9t-{rQGΝ_kAzW쳏}B >Ы Va .}jV]u_Cx^=1Ol*P.\&LqH`G{ؿ7.t߷\vu @u[ArL2xVUW_=r{Zui5gϭ:~>\5GydqΜ9! :ٳַw%Q$cUu/`[Mݶ}-x[\6C}iUex|wjޔ0Idb.3V^.}bÅskKw 7'Nh?5DH듸;-!:;}MHk*ľUŸb+j |yw<㲡-e2* {VyboNᄏ՘s%h/y =Yb֢$vol ž$ h,{\1k_U= 6|jk̏ ;tιbs+bnf{g@=.\Xy_; 9Vr9 o#$X_Q7[:ei$%]{0]1w"s)~_4I0Wߏ]'Zf.3ݲ8qb';K/V~ T] .ɦGZk[lp~.Ru]{vM6٤8yeHpJj{2w6(2#c`:byPW\_Jo6g58;Hm}ʛ.jCWݲYwuO<43V.s=|Rъ:mnVw[<3\sMo/>#ŗ_~o:t]N]Vm6f 6`/h#%Lt .FgEb輋zP]6z0c \Hyg5(?ۇ.; V~ݲz뭋3gTm|/DP/A%}7t!2}b8mf\~/`lC"@]$'C@ZG~-+'7o^q=0*yem-|PQxWG{w (88i$6 ]!Ay%\2{.TO{^6+B',a' ,X>8,ZUbm|k_+gN(Ҽ3# 9, , ,:\ # \ €Hs)Ǡ* !}4444_ՃKY]=y++"+:#3"G*$Ot]<ί oIbŊ#K+D'4hPd[<2} Up^jDtU&j4~\̦U0 xnduMWC6ٷo_^6;?'ES;H;,Oɩ:={X Uv`|FrJ*EAa+--M(` 7~|aATΧ$kx LB`dO?-m)^kag->Tݺu,Ybeee?~k׮ސ5k4'𬙝$T\9%6mڴ)?<کL`$p~Ni '}2H[vΒ#1o澸zj"`ocI;ZNGE)%-jm۶MO>:||?/߮h عDjKt_FK:JxFZCoĜ *XӦMΝ;vMH0~*xԩ:P%e%@drK0kwf~x8s; 34#*"^0~M6mK._F6ܲeKѾ䠝Zyd9z`<մiSQO / KHQ$cNJ6%%0A/}XgWdՆiTtYf͚GJ] l$~Sڷoo[N~k֬Qo ;$Sdmx9$0=#z>٥ L>|cfREDI[mJ,i߿_0tޣEJvl1uƍ.~XWEpGv*@ ^bmBkѢhQ'v"ه9 OJ$6=7|~–-["m\mQҁ©$ۼqL6mLޫcN!7`X;Fb+^fZjGroC(U#ݫ '[thQi2"=F ˗[իWc޵SBO$:S۳gUH\c> *og ?@6/uJƍ?JRk`g6)iiiք "+>AXoUhQQ1;@T!OUrc:O?T(NIxIVvaH;t A 'sv;#\%ٶ=eȐ!ӧծ!s)kРAc;O)L;אjdu| zF}!J&PXVRJ֙3gԯpP-KV*Kx`^e 6) 6mۦv[Z 4!,5@WgyFS+WX[}"J!(އo/X^fǎ7@J-jpn;Qrۭږ ^j1TKB%~h޼yd N7{)^c@6 Rf͚%EFfN6gZliٳG*8`s=xE;] El"9IKKۧRʕ+E7 'wNK;cy;s쬳}3^ 쬵3svz&vw"ݺuSRR=Dp &\xm7ֵk.)Ļ[_ Jr@͂ Ա3@:t#QO@|pʝpg 吝ET܋'vO"ʕ Vd2eʈ~v~JS|m6ԭ[ڹs5F1S u3.cfEz!u .]dիWO6TJJ쓼~^Aekm4 hfk ZJ(ARrWVQjy駱/xZmGN ^KX *XX m߾*T3.G~iTڏžvD?R՝C=Zzȑ#Eqe;5 x=$iԔ-[Zn <J.-ڕn$~/>+vF o)w9q /oOA6SRC!;I5kFV);;۪^7.)$SC;IϨiٲuI VESv@@ՁH߾}q1܀[Dqɧ_V{;s!O},;mW'_7_p׋~% y~o'dcg+WX?hgrߠ:`)Qu)uL oF(RO"{!/G;% )UլY3W^ /`͙3Zjw^jw>6W_}e͞=7n\\J,)ӧJ` nj[MӮ];џ\2!\mhc=|Ɂs=y֭[> |EtdOH&FY>5m4kɓ'ծ|n<=uT_~VÆ #eP˕lv@pմsd. v{%k@Ÿ3rY"''0`hyomI>Hzz:(;v~咋vTN;?lqM"EmZ&LL^tIv [oe#Z3$۬ȨQ16lW.G+Ajs o/hy'@!97o: 8P-Bfg%ٞ㒴4k׮֬YRIk׮EȰ:w %9cga"vl/^:{} V%D҄d]vΑlo8qڌjɢ[$]$",իW-Ĉ|> kRhB&~֭k=ڵk˗/]*Sk֬ƌcթSG|^qi;HIOA % ~rj;s U z7E{#⬐$"+WTǵO\\2{Bm Olr?'_!F}LC2JMTV-U~@drܹsզ (g6K#OOyx *վe; Xsdwj֬IڵkWdիϷ9bA; ?]p}\*m5  b]z=([XAr)n:u, 믋q^RP!Gի#{BlݺDr)矫cVNj??Jڙ`*v9<['OT=ͳ>`r,Am$6HJ/@X*TO^.mH{!4wjvD;v~A1?ϒ\:RfM,SNy|YM$۟p{5jTmx"xĈ^E;M $ٮDO6cHS璮]a;l;r-ֹs& `[=JI#suP)2w\u K}% 1~jg _nǎS8'N_ܕF~B[CM9RHTH,hѢjAhS\9ѣjs#GXeʔ>J"su@Hzzu%u ?~Td{x:pڴP|amV9Fq{Qۑߤ3f.~&C mj3[r%o$"&LPǧ`;v}QSv⣐vH5O]rJ9CH[.e\Ӈr@^&lC"x* _|S]A'r2Uۋ6W @(-^X(ggIÇcSH˗蒎Pp|$ۗ*Uʚ={6O\Vɒ%E;!"w|s䤶G:u6QOnTi&ɶ͈#ԦjÆ  Jf 1 k=ʕ+^ Yd=zXgΜQ0\fff,벓rW"#6#jFEȧN$ۈ6͛7._6=PɱZl)Cw,hCr(@r7NIM٩@y*TRꫯf )\hsr/>"^ᛤf)eˊ~bUN&6"mɓjH ǏJ.-KةH GGxw @rSs$\m۶}S]:^(PQ~l٢6SM6i`0}=D}:tHm~钮 vرcզF-G<@מQd|6|75e+@L 7\DΟ?_kT1ɶHVԦIS^RpdiԨK.Yz'.9a`KrHz'$O*THQM'j9@DMѢEŋM KZŊC-A*M1c z-?5>5d)\g47Ⓑ R^ ;Y$+SNjܵsdf͚YGU$'4i"ڕ6E&DN867HǏɶMFFڼ%-RZkDᄑrݑ*UR-mZ^H\!ffСX f;$ۂ#wǔ1+ /ɮcMC-nںvڴ}% eM"9Ht1ѶmDYdYYYt&'~*ɶnkS!@,\7%H1{e1aPvmѶ53A*WD<իWP|ل D{aTd]l޼YmJhӦM- K.2d?O. eErPȒ%K֭['rcp1 `ԨQmkA}R5fRf̘!ڥ'$ّbŊfY!o/^\gM ;IֹH"EB;@2=E%M R/ŤExo>,7 +Ѷ5ɶT?$B,ZHmbI73Ғ Uֳ#={T0f޽hϚFfIֹțo6ɓErZ;B/#7VǕk/A\L0=Z *$ڷ&8t!{q .l}gjH˗{v=$ m$ؑsHBXedd 6$[Zj֥K1|Uzuѿ\ЛCr ȑ#q1ʕ+'scp1 ¢Yf}k8ArHmxwժUj^7--M(ln"7ٳGm2F˜Y?'~pM駟BɁ#K.UǔF8~(\L3fhߚ'HdmxM6M P6l$Y;5 L֚d:RD ڵkjs1ƬfG=§-zi޼l w.K"J$"GUǔFr#.AX2j8.߰+ggɺ׆+LyHUqMnD`1$ԑN:HBuAiMFF,vZ@\R3#{I\:4رcEybũSDvIe*ig3:׆U/զhTy{$0'$ӑ7xCm"F˜nҤIMk LdhBm.Z%'$[GQċiW^vi͞=z駭Ν;[׏ WX/|󲭥Kj֬iozƍT'x)RJYjղڴicך Y"ׯW _WQK#ӧOWǑzjqӈlkٲe?Ho~“3gδN<]޽ۚ0aմiSQQF/l8p@}ի4JFIֵk,Y6,ZH|X} cq_U2aP5s]w~ j#k֬Qǐil /yJB}x'4ʕ+ŋnMk"r}yxg_]]aOuyz {0'%G!0E uHj&Xzc֯Z.Gڷoċiѣ} :vu-sHf̙IK3;Ad[m7nl]pAm27Dj\`DOԦX,8qB.ӧOwС9?|rǹdGhd|G^)_6Hk+VP)@rWZUKLXlc=6 ?Q78#&-'RR%qyfy;xz(.^8ON{D ?53XjH dA?&o̚-СC#7M^-Zz5n'&Ix9zդI>n whD/n%0Q;WH֧/RO*կ__ ةG$I֓#AX`̚1+C=. ܤwN۶mcǎO/_^-\\oZ_%YԮ][m#ߵ&A#ʕSNJF 4^N}?~iZ;W}?L2E=DݻW;Z֮]xBnݺˆ_T˦;LRη$R/ sHe<S_Sn`Hc\zݓ!C"OΝ;cZtz0a(& LS5u[o6 ѴiDԄrEc5#9s;TNJFI4W& 3VbE)R:{z=snyꩧԗM,Pa"=Nz$I֡6sU@J9sQ$AP%Y?,\Pncaz coA7N[xij̟?_C9@ g`Μ9F$xC+%<.oOx\8x[V-q\cټhz)->pV˦ISN6:ڵROtIpFGV^V0fus̚ MF^NzqjР[r(&f#ztK.js^A^$hǚ`GHy'qQy1}Ҽy.Rl]:~8[D[~x_s?d_ղi;dtM ҥKxɗr;vPXJ~Y\"Wx+(SW(Ne֭l!0ɿC,VCaj7i;GƏ̋i,wߩ/O#Gx~O>Q_{$?VdI޺9R}ڷo/ʦIK&YwS5 }DId;B^9zZ˜Uk̚ ^xA=L͚5K[߯<:$ʠA &YYYYjs4LCX;ww,/zΑӧD$bO6V\Y.sի8]xt钵o߾H,[Ȱ&Ly/]Z5+--ڸq$ŽޫH{eӤAЕsd݉^G% Y $ől1cX'yˏ=Lo.-VR_h< A^̑J57i0 agF 9sdѢE(ɺ˾e_~[n8]xr'rÂhW˨Kڵ՗ilRhmjq Pd}8RBj1cX'yR/Uʕ+'ʡɯ LЎd9K͚30I@C`/HQ&KŴ&MХo߾K}uLъ+Dt?€VV˦X [JD:vV?DѩS'я\1A2T#YԩSGVa.cX&Icy4nX.Y(&7`*ɺs$K&!.ў5y8IoQq1V}3k,q>}K=p͘1CKAu7GU8tG9E$ j4cYݓ1k,wС8]^z%W~}QM CI֝#|c0 a7eў5O`$vرC%6n(/Ν;“KX/ϧ;eΜ9\n`*b;%2i$0QKI#S=HӦM*5Ƭ=feӃ>(A+<>| A"Yo"vR0 a;οo$u޽[%222u' n*0L{M JDxo/Udgg[+V%?Hց#-[ThK5 /O^zsW_xwy(&#$CT;3?Rcxoa$9sd(ɸ6tPq|]Vwjի՗)&,Y"ʥL)jgɺYlZŋ~3$YYK5 u֩/OG}T.&N{wv Yo }(&/Acu#ү_? {߹˒&VM#uUh'b89ԩ#ʡI5BD._V=P +T$0§$s^Zi'NCV3P@% o(&_I֍?^ 3ƬI~ƬN+ APC_Z`oiM`IYG(~_L;{8vR|ydeeeB ֽk=֋/h;m?Ckg˭+WZk֬6l`mٲ%|w}g޽ڷou!رc֪Uı 1cƈri2 nsd8ҡC ڶm+&WT%H2$?sG+V0fO3fpr)RDC_pH>}j{E? #L!9sd̙(~_Lp8[=[nmDwՓ]r{I2ezZbu9q_H0ܿQ.M$X"vZ "+($NpcLNNZ}˜5>Ϙ㾫Aц`G2eZ`&MX #!9sW^QDŽFbիWűOق D … vA7&;u$ʥ /SQ/w5 A<^qNQD9M3wԩSj c<.A~ ȶ3Gf^H;vX3f̰|IUV-ӭ… GD۵kg 6,޽{C)>LcM!0$s1Bi,--M_^7.^htMODFITn֬(&'H{I։ŋ?.o&&qAUuѣGժ7/ʥKѢE՗/ԅ+K^QRRK*W [U?(x,3<9#cB$bQ_5M&-SNU6\zuQ.M$$đ%KZ.]RVRDCDYMvdƍj c֭['ʠ r 5~M☺Di4wGed:A&Y$?HH{Y֭S_t/^\SU^$?gGׯV0fMLvڢ 4$$ZMpLqL]ƍk&N(-[ʕ[n%WŊok׮7"RA`Α>P#"?&^uFp *$ʩ˄ ԗ'Ծ}9D,Z&M$$Ñ `$'#<"U_&9;㭰5y0/{'c2hR V/G+&y5*+Wvcw{~"۶^O㭪Ux֭[,$k֬e+nQsL4I#ؽ+C7/X>ܩS'Q&M$C=u!b Zd<-ϩCOiv.ዷa1k`_̶S \E'N `I=.C.\`UPA%|lիWEYŊǍ޽{Y?H<7# $9sgϞɺop w|QL-[>S P.]ZC>0d]8._V+L=^ IoH~FXa̚<իk N3Gx? 汆zL] `=3sڶmk;vL=tmذa<#Hy$~ L0:w,Yw :k%xØU/c0AuUo,&c=t=ܗ8*t"W(-wq?hj׮-ʨo*#FRDŽFoEYrE6x`>nӧߡۋ_~)3>|X=1Zh!ʣIsd]82gJr.&b SLgdds䤝4x)j'޽{2Ƭz^& f5H=duH֙#A ^xA=Ly\Y*/,4DZ>M9KhϞ=,nٶm;R.ę6mzu˒>R_'CL=[bz",[L;ԭ[7jժK 'ue $AѣjeLL:tHKࠏ=f.L 䦞& :s;PXAA,x+0ݲj*垥zP˧I]#G_VDžw Q'S+ZxDR$|>?|Xn)_u;e>޻رc=#MDMN'ږ-[DY4d~B. Ӓy~`8eլYS& i*ؑUeY}X&0\0d d9ҪU+ HX}o,^X}g^ Hٰa:. XUTIȒļL_ŊEz.YYY^/+Vx+Q8[\jٲ8;e ӥgϞ;/(&_$"uȘ1c*5c=&w=N-a9r8oM#n$?cGjժVq0fX&0\0ժUIg kJi۶Z c *eK=KǛs:GAujY#D͛7+/e|5pDHOOǏ!C3<#-P~%  $yc?Ǘ^zI.TL݂K.,^s/*^8^^ \8|pLqx•"?]@۷oZmڴQyYQ-?RJrhrd=sId'ԲL0Mwz.Fd1#?[Vq0fnjxYY,ο?D&qMaX?'> q20_^XfLH)VwZ Ţ%ٳgVyzgqbI"En޽{dy'xׯյkdhҥkbIٲe#K)/ekx]ve.#?zwDU-< Oٳ碥Gݻ`ǏWSy5;$C$‘ڵkUjpj 0I.5BO I~Ǝ=Z*`=2 1b8oM':,_|AqRA%gv9N%k,M~.1@+)S?oّ'S3NV敛nɪW^L>6m8oMv$4uȀ*5o!wc0Z6q4g-ܢVq0fxYcxpRykҟ n#YoZa /O^zsХ RAӢE Q>M(ߒ9/cC#)]{1eddļDr"{BuDR?ß-EhT_ ?5&!H-$‘UjnݺLMϝ;W&!Xȑ#jucIĘ5L9>Ad9ҴiS +~=&MiR(/9Kzk r1}IB˱'i&_'Tz\3j۸ VN6ɺpS\r|7k׮]5h' ^9MsvdjucV$j&g̙59Ei'Yw4lPm n{DP?h4h ʧɿ:'NC#ū… 7._lq=/;׮]RBܹo {i֯_/^k?;?'[MuzZFpj:e0Uۿ!$?cGx0ՙDYcxptI&3 LPd9% Cԗ'RASvmQ>M00O#9sd[nEx\L̙3ĉRJwnk͚5)$ŋc&aǎ7oEcŒ 6l8?MV$CGuHOE`8m5jH&Xq!IKKLTƬYcxpOrTY&B\  `oP?h*V(ʧI%,"9s+O?u\mxIcǎ.0@_D(Yx{þ;-ON0!ra@=XK=:tqAı=nDڵk^HJ cHօ# RX|Z>5N-a߿8MFӯ\#9;rJ1?bxp츝 m| YXq8RAR;Gx0x9uhaV>}[e˖Wվ}{k[oy ~.\h3޽u[*T/㠜tVjլ[o54,OΛ7ڲe60w}0`բE J*V"E~lkt:uDş޽{CO<m͛7LN]R?k>/sEC$ ySB  GbwX[jqJxQpܹs仁3.V؏;׿N<{S&ѣG#SytZȹuֵv_xq8}Z S^x!2QW<ӻw/S/.\,qI7Fue0NskM&x% s*$X|.M`m$Б Lzdl|K`$xs$O% "ɺpĴ}L'1 WzŪԼz^ Ŋ[Gq^؎N#}U W^j=iVGGLNLzzFr￯gO>蛚'QIև#I<^k1{eW^]ۯ>a\7xC=Ft1bqڵE4iHoϐ%ywH/FnrS&# L{ܫȒ%K&a$L3,e=!9sEX|=.a; Y:GtVm8!=:t^y~guMС(& ⍗U?gdpk_i^'YL;EԄ !Y<j0&ME4m`l*Ɂ#<:^lRIM)L,SI։#DIL~ıoڴid/QOӾˑ߯_H󸴥6#7nK6lh;q'NHӧլY3{Z6lOƍſbԩSE4BOE٣V]2DH֥#w, `&@SAҶm[Q6M#=GJ(a]tI3@ݻWG JNO 4K2~xQ>]J,)^-YYYi䉟@UKzeXw?_[cƌ/.W&M.\`UXQW^"ufff._l_4hPIJ*EIct1mxҥ , HJFEQF _tf͚j0&M7,ʦI]%9Yd:f}Dվ5;BLyi&zeQ˧ O &͛{^ŏZ%4ʕ+SZ0bݜ9s"N|a ʃ>$\s*;(ڵ>3fI[n^+ӄmx|\/;,7Jn)4 ,wڡO>yk%5Ž V^O ;}F5J>}ܾ#<2e.2 ^Lii(}}Tv`?ӵx[h^-"+v* lKd֬Yn9믫4a޲e:5.|]{+@L6M*'DF>|[EB V{WOjeYѝ>\M# ɓU }zVyij9)^^+(sye]?9sVI6l{8|A|_{(z{I'ڟOl]v׉>r%U$t.h۷:. _ 2Re2_tǯQ"HYe ;8ⶻ<&({>7Rƾwyzr ȮYwp ʹnL>lҥKݾ$f>}jo,(EF/~P+p6kKyv} /Pmf jR%~: ȷD*M@j\pjo9IPN>Ǎbg 3 3Z_ F'/ u;*_ۗLk A(rR;C_:txZz;؛=նm[U_N9wӼO*a>q䧂|/W)@%b$VQ>>ύrEU&T.h)ӧ:& H<',Y'4 /Y@^d*%G>Gr';Ԋ5S'RK.Q㗡Cd/eeذayvZ`;{>~#W9:,WPnN}}n  3Z_ 2Y ;* r>9x6_ J1Qy)b _^zݼ`F/zr7MNi_|<9A 駟'G .W_Ůs~mվdA \|gn  3Z_l|:62sA;;vj[-<[j젏{b ,Zݼ`2"Ӯ4?*#(8+BGP8|ꩧQB#W9ꨣjg4ۆzW@7<׿'4.h?^OR| 'JYO6f8 k;{N[N1\WW]>%e_ mĈj_?^OJKp-I&@&LU@5i!(WqQ*/ o|LPiTtPeԩn@n6զ"\5GP+^nZp쳏/̘14i^{nZS%(aOB:G9PСqFȀm;:uR* g YmX1\J?b9kNO,+EwUvm޼cHîSUUT@EF9Sjt3Zw@/wiA,X@KP8ܣGu>쓆>*s{Jd`С=dITP~$؁0b0~ŔE_ <n@sjKyZP}堃rO}c{|ݴ~;p7 Ժuk_.bwӂXrڗq[n8|N}TL/^h;n{ /Q~a*/&{=|,8 ^P( M/(U.i5ʾP+paʀ nw0۩bsrg@ {V( S!QzVP`0~dg r' * ;*|bm[k߾jCMPv}%k)^q.؋뙰AkK4U0gfZLP &-χʤIS O?O@lcq:(vi׻U1\J?bD/@Edرc&ǠAT ̞5!zخ1]9#Cl2mPiOiq8õ[ ^CevJLW ի3<7*$ 43[J/ &m\w`$EK.ukv" NힻFٰa[Bk֮QeC2]O?UŮU,Iww (o,}^T^.n;J({[J/ *hm%C(wynqLגi_|gnuB @nL׆裏ԶAyG K.jO>DO>}^|3m440^|E^RBA%(.4<& fҏ썴d3@PQ SAW_J@jvu?DF?[B˗/WeCv}wP}}j۠1ݼ`v/a;w:!(6{T'l :;=z6mT{ &UJd:(v0a0~0n8u EwU읿~*/I(EFyW*ZeCəXp6(?ykN_6gFGOon"ٶpYgvM&{*lQ씳apa@+NS|:ѝF=zx[lq@Ͻ]wU,0 L}enBݲb_ E.BP}e8q"\xwV㗰 ?S|2ZP*>?=z{4j(>RwJguE)gWZVpa@+ @.+2Cu@EOUqmNsAQl?OnBo۶M<,Y3(sydXm?=%(;}Tl5 {el[pG@6i)t54Џ>[m>m9&W0%蛙3g}Qb >\OF>2`jVh4ː!CMC gPu6mR _O Jl}Twmܸ=@E3ltM6M$;e[Ge}a}w_.}>gFtRUVޢE(Pf͚Ck WlN8U]r5׸^CC:Π 4ݼ -[%(a>18A,<淿{ځp饗" 'nPj.݁xBvL4i09PV?}NK.n b uQ|dnd:=g>}M bΜ9j_۫cނRcg.\=@Y1bj)b 1(4_V= 6 [FHvBJyfSm E pvϩ֭[ݪJn~9cMB,Sb)SԾ%LY<} i7բϗo2@%Oefn*NhK֮]VAyfLo}L4ݿL't@8p)IDv{n壏>rJ(cR]ve!JTC} J?>Y.(e}|c??/_V,]kӦ)+.b,DU~P֯_nzvjg8bڶmye8\"a4iO}C=۰a[`lUSmI [wT&LVIy0n82@,ػݱNAsfΗ7M|9n;ϭ6T`~r4U'MV>u]yضEkQtj9ھ}wfm\LуiP-ZnZ/LTn}e=pN(c:Sks7 9s ʝwnW/ڇT Įp8Vo7#FpJ٬nݤ. _ӦMS. D?4Y+N;5j˗/O u;Ez;; >*>mxAYjym޼YcP =~UW}H gdsA(Ʈ?O?u\/@v{[Ot'v{#h,Ӣ=L￿)dOAZ*7kV)￯^#(>ytA8b/ ~wNS%,v?}ɧL}.SiՁ\Ϫ&$FlEt}R K2N( /۶mۏJֺuC=Ti0]yorXXV7\اb:#?3fLN׺qPO-N4Ivޫ'] 7UUUK/V+y4aJϞ=ݪ 9r9'\q@۸qwǪ:&}fQD"on=NOLhʌ3* ~d9\wu mذ!14kRovء(OR-[t7m+Vd|Q} ٲwMWc>}իWi&/2`#n9gu_7(7w͜9S*cǎuh6tYg^+ӄa8[rk/c?@:ujVߍg[NJk*]v6oVcY}yTc/ص2픗@igM&TB\lꘂr]S}TzRWW^/(gsӼpܠاs9ewT:$ wo#('l}3~xE_gغW!Ω[VW_$)lɷnf~y#)-tg*(wE9Ùyj*gϞe!#롊ή ={f}U(|"2~{FOlٲf1Wɮ$BtQ @m+v-c [r*Ҷm[?tw7'`^k2/٧ݿK)ev7 V}SƮ=gj9agͰ7.Mv}kښ]'Ux?If&@ }֔SYMfkʺ\[L֊+R[[VPi~iUWWהiTmd8څB~aoAR$QJӘ~9O h;-)}b/x*s9}@Yw\3yGt5e욒/ /4iƿ@({[CŮik׶u9i?&np-tnS҅~z邏NR]vw~7ni8ꨣdҜcUt]H;|>Pl8p_dIokqm߾m?G}j7yOY%tѝה>׿c #СCU0_uFN9fv5;ӦVCCwt2}w֬Y.,Oc>} Yb.EK,QXI>և9#O?ԭn@F-Zva^e; P]G}fxU{IPr];iӧOFVc9Fե blR%@0&}ƭ?$e̛7kݺ:$_Zj?y:v-ޙ3gPp;s57v0o֭[俽kj; >ݴ$<#j_}`AǢEʴh›0a[倔&OiDҒo|xwT> P`ٴmݼ_/J3.mC&CҳgOzҊ+믿Ykv[t[t'_|E.vi'M=ΓO>9q7ŋݗ,:{NC=zx>`F7Xu2dwӒpᇫ}3J u#m7+IߖP6S'CKTtU9>m 7NL3%d_ mݺ;A/Xՙ ɡdN#O>ĭek߃upnݼSN94h7rē,͵?{^xabzM;qV}wo6m=;蠃38û[1cxoVb0k-X >0 Y>ؾ}{oO<=|{'NLJGvd6;w|M  FV֛ 7o P]o}S7fgqGJamػ$ΝQ^x [W2|=.|jܭO? nS'-6 X-LHrY&,7 6nܘƭ'Y~>{[}S Kk4}t.RYjJvWDwp3tÆ n_>ֿU7]jGwT'MlV]`g i۶j>/v:%mSNu$*/ܜ~ך*@xt2Y-.e&T%K$fNrE@V  ľfrnF}ݽ3f}j9;wQՉ c (@)n0ߟ}Vǀ؁>L扮3Ů rJj̭^:$[ޢo@Y7SagL6fK.$q'%߼y󼣏>ZՁ,\?ڭcһwo(N;MG,7)qd{Z<Oq:vyߎ_yO>X;ةN]?U_ݦ x>h^ֱk>ޚ5oW{1P硉u nR#Fs=M?>m@$"ݤ#TܱO>}{ތi|]`z;9Seo0kT{CS"6)9{݌3SK"w͛ 4(W<L_VC(i&"le2Etgɱ=͛~=2foܸq>;Q]gUl;O>jg/@؋|*.69FCzw}7vUbs oLnSWW6_$=S,V #v}੢;͊"شiS}WOmM0W]tUkymK]tQ, L^|gd~yÆ .\6 8-ZCQc\3$RP` ѣGz&Y٢;NV.(ފ+~EN;| ؁_>DXmN`Ĉ}de5Y)~6;￿w-$f/¾'v}rM~leSlqS`}Q%GQ5M CMVer;9yٳ7ʒ]ySk6kv 'nn;cې9m_>m$P*v1d躚؛:,/{|AElw1AMZ 8dom@Q >\4Ygr;5:8Lsc.4h7~x>sҲe˼ɓ'{_ףGuyf&{ ^ICnv鮾jծR/@$:Ӵh;餓Eۧbaeo޼yhN`aȅM6ng*6[ gl09TP|Tngl/3ƻO35c kخf/[g&zw7p ]vUYea4Z]Ů {zR) vV ,q%\ew?p.JL:z… k׺0g֬]x Zuuu۷ob_2=dRgrȧ^&[D؛Y~}ⷛ[d)&4/Ywڠ{wG&0;lkW&;SS6ۧѨz"gpI˭*={}@ vV ĮV-@1dz]{yw?nw?m ,.TQһ+T G&(_39VӚ]IvtL0Y%tL$Ǝv0aB.M [lbarI'`rۉofΜ^sgV&ED@KU|.u}OMZJ \͔ nh͛7{_g.?s*\(\h2I//UU&n7*{Wbmƍ^׮]U T$oZ(6n;#LZ 0.62={-[v!,Y;#U M PAwu$ɋYMSUY!)F{pI趤rM7 V% v͵ PJrZMlN5X179 ~`v2v3f #ӦMZnUyا R1&D k셴&#$΀/E/߼Kًqn;IvvYL֋n7a={L _{Ķiݶ6v^fAl]U=0v ,n8*HkJrnINg׃q,Rv?7'I$TTvuWoŊu "\24NO tKr2R$ы%9O8)7ۇLH{wݮ… :H՟ c`My'g;Hr`ɵ&<'ɧl^(l5Y"JSL0i#d89^*۽:K+ iv՞&IzY&I~eטi2ZWb;1CgGD"oԨQ>U۶m{DqM]u2bd! 'lr$&$ְ+o/ٿ?K\T'݋wSVG}ʩZk\}$/~g_v%Y-'dtiZ={L< X׫W/UO2F+v6Y(B} "̙3ǫR" KP ~$}/Q: nb˖-Û&{ %9-{AASNުUk@Y[z׹sgE3^ʰD}r&n4Cey8@Շ,IkHD_T͉'X r)ȵ*]{M~0`b +2|r/W?؛ [UF /0殻ree[oU?E^6 T-͘NEVtPv6%qv=sn!}v>ɓn BbƌakYmr4]/#Zj|;" dCVٳ7{l˂5sL﨣R y$&9`~^)V\^Bi^{y<'62r 'x}A S=O6;ykB!9d9c͛7APٴiSOk|l]HoE'w زeKbLeM@f%|s)p y晪^H&[d:x?vZk<[vCy۷W祉Y` (sE_L>}m۶GuVW9E%MҤ*D3f$"F̛74hP=wCdn}"0^z) {+T8M @}nM{ >nh͚5)ufdHPU&D_̵^(y^s5I \MNUUUby3Et)Ǎ~A3OlgE_'QUM@a2]tYs7nn:oĉgx1fs(!6/BF` 0J]K.Q5M昴DGr=zxÆ Kg[|'ȑ#N:)_6o,%5sYgy7ovE]p{&p'7Yڵk 00ah"TvO?8f{49^X>@y0Z?5ұHMmbΝy.MsI˅^mVodIڭmb{,R]~8K(|&G`8oŊu VZS4Yj(0Uħn&gSS@~p-y~]tE=ŋKj/eow׷o_o}UǑsBd/՟TUS;y `yH xsw@dҥpB W}{&L~,@90:osW3EZۿXӟ|=J$͑XP\]M>}$e 544v@lݺջU} Jr}k ԢN4ߔYB=[m۪I1JIu|1'K&M'P:=Hn{S{R=]MR裏/_Ml2﨣R,|`Y0TH,O6an)~)J?ro`Et4NT&Ju"5Mbeחwr@Au0#BJʴk{7ݱ< +vm6mڨA2/B,\GԔq8Z30>S'Y`7e5E,HM&}O.|=&e/,M}a%eZ=m6w\H֙x@թ 3[]&gOԜm8cރq~wbM'k/5@٫~HMZ}YYnguϪbM3z:$Pp-L,bK[d;uW(-W?-c:٤@E٭mv0}%۹/ty[R?$R&S^(~&D_I-[zL2kժ?d`ª3_}HN> Wwʹϣ^FG/'ESњskGr3 <}(!L5~^mNtرdWϝ$jƷ79G3ٛ"յ/2Y~lz/3Io^:Lcp=ܒ[}WS}LdۘnW&D_I+[v; fK/U%غxaձǦ3ǧb8]"յr=ؾ-:+jmW,y;-J\n|bx$gyE&l6 [-Ӗm_^+%v쾔2'G"A^}u쀲Xr4GBվ!lw_QakpP4/Nڴiƛ~'"ur3?Hhݲż>dnY>t$`ufvFm۶D:{-ZP!5m/N$80TS5{};MY|F۴Loeʔn>9I}4F?N0Pn:OtWHhܢˋTՌdێniXe2!X!ٳݻEa{s4VO&:hEu}Ow3IߺI&1_w@ B*ϧ:r bڶ*?iR'dDуvN h"w!g߿:YfI{B(Z]{ìu4%V&Z?Qe4\U-j&}I n9rUgdmhnAvO?UHM }Jr*L6(HĻ뮻k׺5kxwyg✺9l4'ɺ|ӟIhv}r-l7&2K_w*NO ZݧT[ Ֆ;i/7Gkǹ Lj꾧;8\ofp]%gODeh4~ Ç'xvkk!z855XN:Z GbK4V[N!U=Sl)͖<0iMA` "5u~|*?-̏?[صr`͢2Ύ;Lܸq;Έaoȑޮcb2DuHV 1H 7aMwGDQg}+eZtHnYߌjߨ=(7V7=Hɪ2XHMާ"2Eu}Oo#~dc2K _ViӦMb N+Ҳzj .ND2xTRӿat5e:-:Mٗ jR%-N+|։Щ-PbOv7Gk3uY$tz[<g:~peEחwdZ4Vw[ 1׊*vjAy~;[xqb-[Ԅ7h?-bϳ۔}*t1bVǧ:2!NYgX͞f_< U5c"Zszɫ-b%qTu L=פ\|oK"|Mo߾|4#cL: P")bI16)pcoLw2Cٿ⏹eB\(Y,Nrvc4VkSZZc'(U5N}Dc"vSw[P9-M>쓘zժUX%rN]__u]<p|F4;CBva8AԚ#n NUM bw{41s>V|?.y4$m[Rer"w4VhMlӱ"Rb})R];?R=nwb27J(I5nBǮwurMJ$~zgma"K=1cFI몪*~7#KM=BDp|tN;-oA^_}Ni{ɖGubF_ǩRUS;-"]ۘ{YFOTyӄ ?HR$D(iA@aӮ];OLW}vwlo t?3ȺW!Ğl|!85c~<~J,~[ko'͢X,|͉` ־馛:/d2@;\_bghqI*f4EGǩRU2+t֌>-*f^w)S]w[ TUIc$E5S2;M4th4;%KܱЊ{'O}QLFtC[m[ϛࢊ])}-NoSUSp%M0_1Anʎ4Or<5:Ȼ)Sx˗/wIƲe7p׽{w>8v{MvpQ~M{2[2`C_R멳I7Zx~|G}Ojr"vHV PQ캏Mhyv]zѣsz7ntRKކ 9sxuuu'|t颎3OYbbUEUUǩ=-ߟk$ik&Ȣ9zSU=n5cpN}&}MxksO .?&^`vZwܵ>;}6lw'/@ir$|c}Jv-P5t.n9E{~60-8GHtPQL0<iZjr!n{衇zʛT7.\\2Dn쿵mmSNƎ(۾䵯u{3}&K(s}}S'|dFb|??׃Q XwRn[4O^mi~p|~Hc[ edʒL4M cW™k;8.Hїo⏹e|U-(b%RoPThHMԉ/.|-MhAKҼ,2ۤ(>Ƽ7U2!o;F\W:S,U5uϸf{n4eGTLJNSIX2,7irI P:.kw۔/s=ul߹dDj}}10юǸe@~j˪)4Y+]۸E@ vҮ 4E;$oT&¼/mThǺz4ʱ2nh$V.ܢ KL3Y+zҲdɅ&1 \4VwN|tSHlOnfiODk⯸@溾SUM*Ne Rd?Sঢ়_H-vJ &%9MvT"4=:|0\Xl:joӟ);b^πۛ2 XO͏t/NOur<ɯ%~s&+D%v2{ X1u2RO(+ T$}OӄeK >v}[Fcbv`'4?<ft| H(&=M4yLt-t|(}zDxI{)G5c2 |+҆` ^wϴHM"њnr3Uյge@c~^0TIb޽IrYoD{ q @l\!N (TL;0~ww'rp%ÅDua<l!x!:6={8gvvo3OշVZ?S n|&<fXvӬTf6L _kpt\?*[`5\x4|sExr||HM͵cfhooP'{;.UA_2ߏkleo$^un\2.ʏYd||\O<{ʟ0SEx>x[!_Lv[&eK[UOk$C:ـ;Ex^@ «MږdMfhzu#7%yxt\[i˼?4,ϸV ώǹRSf';yxwZ,ӨKz:h3U4O/<~fc\S"?e,|9.4ƹBSf9Ǻdpx ̞nQ=e8h3U4\(^c7ޣZH'.?|2QYj06|fNtj1)0#v|@|e8h3U4ZVc|'zL>۲Ho[g)WHQ4L-hߑN.Oznӏׄ֬L wzyko&O%NzQC5\oIJ/Z\ 2[>pO̎nZОnoߍ0SExm׭~-O^$+-/#nKzW<$Ң|{s|$0mv%yxtkcO+“KӬze?Mepߚw0EwkЖoW8ſ `eկk3^&lݗ5atX76Fq9xd|wF}}"qqbtdOAa:Cr2L^n^ ]y8,`6k_7׆Q 7&L׸nFe^^P|y1fɞ#KzEi34S_7~=M' _yϸ,#ͪ+'9i\9si\`eG^˗LҸ,T߿C;,^XE.] {^=>..m YEaK5yUq `;ZJyq2"ݼ jϑs'|x¿KAY|jOu%`E{בkZn{럯{yqti~im>|k5ѻZK7+_?eG[q &`n[ɫ+߉0lC<|>~c(?d=qI6E39`<<<Mgxfl^}dT_k0tg͹$q;frx18N:yd}jEx|5ͪ9uiju- 50{ޫ?לhfjTK-k%`/rKs`TAlIp[ɮ~L4;$YV4uw%Y1<<[,z:odeݢiQ}‹#[qB& 09)ʷǏIY6+ .i?/{ʇ`-Oyhc}Z^epkmu{[,L@ݢewޣk,pZ?>_l/ͫ7{ϷWNw+ć~W[ I-{|"1)ꓣwT`,f$_Ċ4e3N_NJk-7h[owpyuRۉ4?Ob,` ?VJQ4.lD} 8LzlcѸ[W9_Kx}h3˦텟~a,+“[ˡ+Rxre&AZTnux:yu(~!Gk0ly;ÍsX!7$aq)eioe-k$Yq `+;vNY)޼J(l4c,&.y]sn$ܚG%Ʀ,ѻÀu#R0Ң|W˺9$WW47xl\g+өt[a)WVEY6 ന}:®kY;$ -_nnG2ext>|< ';> .KҜ_&/u,pz'㱏LQ4.$oвvKVj\Yi66+W? i|:l yx>.j̲ik9rz7G'TqnVvOR5 )x?FnHzW<$]iQ~el[[9zXĚh3˦\~3%{;947 n&ExQF~cޣm :ǛcZ1'q bh[ٺ,ǽ-@zeNrIQ+.LN66c=mx؝`dݗq5fMQ8-Ÿ]!okUiY?ύk&7' L Kn^Z0NN&; l_ѭgEY6- ࢿoxXmfu_ M7omnMn .]{ܻ>ch{܄7e\iQ9?Wǟ5 `fٔ4;Ex#S53?X?Gq `Rz}*ݼ`zevV9:k6,5q `#<oזou ዗l;'iQG5הs'G`-stk׎̄޽-{8 <~*5I<6~؆v>Ix'vST?]nן!w윸 0³񿡵 .|1Ç*O|NÃoc^?G6W=_ڜ%#~f¥p[\kڣ eExInS/nݐ+ߌ)-w54"YMtI퍍Kos`s떿Q4ppיhl?W5kӇƦߧ/d26ϧv9HxEd/9G%>+q\b]i3R 4.z=>G& _N.ܸ̆Z-ZSTt7'&7u{sK'-t{?~i3R 46SjCӫ^WHiQ~c #nMv_uj};ILhҫ?6_~9OIQ~:ɮ{\ci3R 4Fos 9~4<\rk:/' G'pcZ$/uqv\xչqY`=zUݼzAZOyҬT/:O]v(Zo?R?<\^[')'!DKN^卣 )Wpl ?{ͽx ?ǻRzCS}Yoܜd0C<卣! )4dݼ|f\cDNi7WdGݢySVp[s`d=i|%E񾽰4iwd7_xv<~fK67~`6S!0ҼB ;;vNg|[>rRIC`FyƛH `VU|e?9ͪW&{53,>3gOAiChWn`흼:[J5ό0S,J:7/1;Ϸ%a`m-} -{͵i<ˤxC@C|f4XCܫ^U3Eyutp޼>> converter.compress("http://purl.obolibrary.org/obo/CHEBI_1") 'CHEBI:1' >>> converter.expand("CHEBI:1") 'http://purl.obolibrary.org/obo/CHEBI_1' See the tutorial for more pre-defined converters, information on defining custom converters, chaining converters, and more. Installation ------------ The most recent release can be installed from `PyPI `_ 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 This package currently supports both Pydantic v1 and v2. See the `Pydantic migration guide `_ for updating your code. .. toctree:: :maxdepth: 2 :caption: Contents: tutorial reconciliation discovery struct api services/index curies-0.7.10/docs/source/reconciliation.rst000066400000000000000000000246541464316147300211010ustar00rootroot00000000000000Reconciliation ============== 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) >>> 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) curies-0.7.10/docs/source/services/000077500000000000000000000000001464316147300171555ustar00rootroot00000000000000curies-0.7.10/docs/source/services/index.rst000066400000000000000000000002121464316147300210110ustar00rootroot00000000000000Services ======== .. automodule:: curies.cli .. toctree:: :maxdepth: 1 :caption: Services: resolver_service mapping_service curies-0.7.10/docs/source/services/mapping_service.rst000066400000000000000000000002111464316147300230540ustar00rootroot00000000000000Identifier Mapping Service -------------------------- .. automodapi:: curies.mapping_service :no-inheritance-diagram: :no-heading: curies-0.7.10/docs/source/services/resolver_service.rst000066400000000000000000000001661464316147300232730ustar00rootroot00000000000000Resolver Service ---------------- .. automodapi:: curies.resolver_service :no-inheritance-diagram: :no-heading: curies-0.7.10/docs/source/struct.rst000066400000000000000000000127241464316147300174160ustar00rootroot00000000000000Data 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. curies-0.7.10/docs/source/syntax_demo.svg000066400000000000000000002775051464316147300204250ustar00rootroot00000000000000curies-0.7.10/docs/source/tutorial.rst000066400000000000000000000776531464316147300177510ustar00rootroot00000000000000Getting 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 >>> 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 .. code-block:: python 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]) >>> 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: .. code-block:: python 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. .. code-block:: 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``. .. code-block:: 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:", ... ], ), # And so on ... ]) >>> 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() >>> converter.is_curie("GO:1234567") True >>> converter.is_curie("http://purl.obolibrary.org/obo/GO_1234567") False # This is a valid CURIE, but not under this converter's definition >>> converter.is_curie("pdb:2gc4") False >>> converter.is_uri("http://purl.obolibrary.org/obo/GO_1234567") True >>> converter.is_uri("GO:1234567") False # This is a valid URI, but not under this converter's definition >>> converter.is_uri("http://proteopedia.org/wiki/index.php/2gc4") False 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 >>> 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' # standardize URIs >>> 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' # Handle cases that aren't valid w.r.t. the converter >>> converter.expand_or_standardize("missing:0000000") >>> converter.expand_or_standardize("https://example.com/missing:0000000") 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 >>> converter.compress_or_standardize("http://purl.obolibrary.org/obo/CHEBI_138488") 'CHEBI:138488' >>> converter.compress_or_standardize("https://identifiers.org/chebi:138488") 'CHEBI:138488' # standardize CURIEs >>> converter.compress_or_standardize("CHEBI:138488") 'CHEBI:138488' >>> converter.compress_or_standardize("chebi:138488") 'CHEBI:138488' # Handle cases that aren't valid w.r.t. the converter >>> converter.compress_or_standardize("missing:0000000") >>> converter.compress_or_standardize("https://example.com/missing:0000000") 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:: import curies, rdflib, rdflib.namespace converter = curies.get_obo_converter() graph = rdflib.Graph() for prefix, uri_prefix in converter.bimap.items(): graph.bind(prefix, rdflib.Namespace(uri_prefix)) A more flexible approach is to instantiate a namespace manager (:class:`rdflib.namespace.NamespaceManager`) and bind directly to that. .. code-block:: import curies, rdflib converter = curies.get_obo_converter() namespace_manager = rdflib.namespace.NamespaceManager(rdflib.Graph()) for prefix, uri_prefix in converter.bimap.items(): namespace_manager.bind(prefix, rdflib.Namespace(uri_prefix)) 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:: import curies, rdflib converter = curies.get_obo_converter() uri_ref = rdflib.URIRef(converter.expand("CHEBI:138488", strict=True)) curies-0.7.10/pyproject.toml000066400000000000000000000005711464316147300160210ustar00rootroot00000000000000# See https://setuptools.readthedocs.io/en/latest/build_meta.html [build-system] requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta:__legacy__" [tool.black] line-length = 100 target-version = ["py38", "py39", "py310", "py311"] [tool.isort] profile = "black" multi_line_output = 3 line_length = 100 include_trailing_comma = true reverse_relative = true curies-0.7.10/setup.cfg000066400000000000000000000067051464316147300147330ustar00rootroot00000000000000########################## # Setup.py Configuration # ########################## [metadata] name = curies version = 0.7.10 description = Idiomatic conversion between URIs and compact URIs (CURIEs). long_description = file: README.md long_description_content_type = text/markdown # URLs associated with the project url = https://github.com/cthoyt/curies download_url = https://github.com/cthoyt/curies/releases project_urls = Bug Tracker = https://github.com/cthoyt/curies/issues Source Code = https://github.com/cthoyt/curies # Author information author = Charles Tapley Hoyt author_email = cthoyt@gmail.com maintainer = Charles Tapley Hoyt maintainer_email = cthoyt@gmail.com # License Information license = MIT license_files = LICENSE # Search tags classifiers = Development Status :: 5 - Production/Stable Environment :: Console Intended Audience :: Developers License :: OSI Approved :: MIT License Operating System :: OS Independent Framework :: Pytest Framework :: tox Framework :: Sphinx Programming Language :: Python Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3 :: Only keywords = snekpack cookiecutter semantic web compact uniform resource identifiers uniform resource identifiers curies IRIs [options] install_requires = pytrie pydantic requests # Random options zip_safe = false include_package_data = True python_requires = >=3.8 # Where is my code packages = find: package_dir = = src [options.packages.find] where = src [options.extras_require] tests = pytest coverage pandas = pandas flask = flask defusedxml fastapi = fastapi python-multipart httpx defusedxml uvicorn rdflib = rdflib docs = sphinx sphinx-rtd-theme sphinx_automodapi ###################### # Doc8 Configuration # # (doc8.ini) # ###################### [doc8] max-line-length = 120 ########################## # Coverage Configuration # # (.coveragerc) # ########################## [coverage:run] branch = True source = curies omit = tests/* docs/* [coverage:paths] source = src/curies .tox/*/lib/python*/site-packages/curies [coverage:report] show_missing = True exclude_lines = pragma: no cover raise NotImplementedError if __name__ == "__main__": if TYPE_CHECKING: def __str__ def __repr__ ... ########################## # Darglint Configuration # ########################## [darglint] docstring_style = sphinx strictness = short ######################### # Flake8 Configuration # # (.flake8) # ######################### [flake8] ignore = # pickle S301 # pickle S403 S404 S603 # Line break before binary operator (conflicts with black) W503 # Multiple statements on one line (conflicts with black) E704 # whitespace before ':' (conflicts with black) E203 # Requests call without timeout S113 exclude = .tox, .git, __pycache__, docs/source/conf.py, build, dist, tests/fixtures/*, *.pyc, *.egg-info, .cache, .eggs, data per-file-ignores = src/curies/cli.py:DAR101,DAR201 max-line-length = 120 max-complexity = 20 import-order-style = pycharm application-import-names = curies tests curies-0.7.10/src/000077500000000000000000000000001464316147300136715ustar00rootroot00000000000000curies-0.7.10/src/curies/000077500000000000000000000000001464316147300151635ustar00rootroot00000000000000curies-0.7.10/src/curies/__init__.py000066400000000000000000000027731464316147300173050ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Idiomatic conversion between URIs and compact URIs (CURIEs).""" from .api import ( Converter, DuplicatePrefixes, DuplicateURIPrefixes, DuplicateValueError, 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 .discovery import discover, discover_from_rdf 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 .version import get_version __all__ = [ "Converter", "Record", "Records", "ReferenceTuple", "Reference", "DuplicateValueError", "DuplicateURIPrefixes", "DuplicatePrefixes", "chain", "remap_curie_prefixes", "remap_uri_prefixes", "rewire", "upgrade_prefix_map", "get_version", # i/o "load_prefix_map", "load_extended_prefix_map", "load_jsonld_context", "load_shacl", "write_extended_prefix_map", "write_jsonld_context", "write_shacl", "write_tsv", # sources "get_obo_converter", "get_prefixcommons_converter", "get_monarch_converter", "get_go_converter", "get_bioregistry_converter", # discovery "discover", "discover_from_rdf", ] curies-0.7.10/src/curies/__main__.py000066400000000000000000000002221464316147300172510ustar00rootroot00000000000000# -*- coding: utf-8 -*- # type:ignore """Command line interface for ``curies``.""" from .cli import main if __name__ == "__main__": main() curies-0.7.10/src/curies/_pydantic_compat.py000066400000000000000000000017321464316147300210550ustar00rootroot00000000000000"""A compatibility layer for pydantic 1 and 2.""" import warnings from pydantic import __version__ as pydantic_version __all__ = [ "PYDANTIC_V1", "field_validator", "get_field_validator_values", ] PYDANTIC_V1 = pydantic_version.startswith("1.") if PYDANTIC_V1: from pydantic import validator as field_validator warnings.warn( "The `curies` package will drop Pydantic V1 support on " "October 31st, 2024, coincident with the obsolescence of Python 3.8 " "(see https://endoflife.date/python). This will " "happen with the v0.8.0 release of the `curies` package.", DeprecationWarning, stacklevel=1, ) else: from pydantic import field_validator def get_field_validator_values(values, key: str): # type:ignore """Get the value for the key from a field validator object, cross-compatible with Pydantic 1 and 2.""" if PYDANTIC_V1: return values[key] else: return values.data[key] curies-0.7.10/src/curies/api.py000066400000000000000000002727421464316147300163240ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Data structures and algorithms for :mod:`curies`.""" import csv import itertools as itt import json import logging from collections import defaultdict from functools import partial from pathlib import Path from textwrap import dedent from typing import ( TYPE_CHECKING, Any, Callable, Collection, DefaultDict, Dict, Iterable, List, Literal, Mapping, NamedTuple, Optional, Sequence, Set, Tuple, TypeVar, Union, cast, overload, ) import requests from pydantic import BaseModel, Field from pytrie import StringTrie from ._pydantic_compat import PYDANTIC_V1, field_validator, get_field_validator_values if not PYDANTIC_V1: from pydantic import ConfigDict if TYPE_CHECKING: # pragma: no cover import pandas import rdflib __all__ = [ "Converter", "Reference", "ReferenceTuple", "Record", "Records", "DuplicateValueError", "DuplicatePrefixes", "DuplicateURIPrefixes", # Utilities "chain", "upgrade_prefix_map", # Loaders "load_extended_prefix_map", "load_prefix_map", "load_jsonld_context", "load_shacl", # Writers "write_extended_prefix_map", "write_jsonld_context", "write_shacl", "write_tsv", ] logger = logging.getLogger(__name__) X = TypeVar("X") LocationOr = Union[str, Path, X] 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. :return: 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 = ":") -> "ReferenceTuple": """Parse a CURIE string and populate a reference tuple. :param curie: A string representation of a compact URI (CURIE) :param sep: The separator :return: A reference tuple >>> ReferenceTuple.from_curie("chebi:1234") ReferenceTuple(prefix='chebi', identifier='1234') """ prefix, identifier = curie.split(sep, 1) return cls(prefix, identifier) class Reference(BaseModel): # type:ignore """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: str = Field( ..., description="The prefix used in a compact URI (CURIE).", ) identifier: str = Field( ..., description="The local unique identifier used in a compact URI (CURIE)." ) if PYDANTIC_V1: class Config: """Pydantic configuration for references.""" frozen = True else: model_config = ConfigDict(frozen=True) @property def curie(self) -> str: """Get the reference as a CURIE string. :return: 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) @classmethod def from_curie(cls, curie: str, sep: str = ":") -> "Reference": """Parse a CURIE string and populate a reference. :param curie: A string representation of a compact URI (CURIE) :param sep: The separator :return: A reference object >>> Reference.from_curie("chebi:1234") Reference(prefix='chebi', identifier='1234') """ prefix, identifier = curie.split(sep, 1) return cls(prefix=prefix, identifier=identifier) RecordKey = Tuple[str, str, str, str] class Record(BaseModel): # type:ignore """A record of some prefixes and their associated URI prefixes. .. seealso:: https://github.com/cthoyt/curies/issues/70 """ prefix: str = Field( ..., title="CURIE prefix", description="The canonical CURIE prefix, used in the reverse prefix map", ) uri_prefix: 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: Optional[str] = Field( default=None, description="The regular expression pattern for entries in this semantic space. " "Warning: this is an experimental feature.", ) @field_validator("prefix_synonyms") # type:ignore @classmethod def prefix_not_in_synonyms(cls, v: str, values: Mapping[str, Any]) -> str: # noqa:N805 """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: # noqa:N805 """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)), ) if PYDANTIC_V1: # An explanation of RootModels in Pydantic V1 can be found on # https://docs.pydantic.dev/1.10/usage/models/#custom-root-types from pydantic import BaseModel class Records(BaseModel): # type:ignore """A list of records.""" class Config: """Configuration for the records.""" arbitrary_types_allowed = True __root__: List[Record] def __iter__(self) -> Iterable[Record]: """Iterate over records.""" return cast(Iterable[Record], iter(self.__root__)) else: # An explanation of RootModels in Pydantic V2 can be found on # https://docs.pydantic.dev/latest/concepts/models/#rootmodel-and-custom-root-types from pydantic import RootModel class Records(RootModel[List[Record]]): # type:ignore """A list of records.""" def __iter__(self) -> Iterable[Record]: """Iterate over records.""" return cast(Iterable[Record], 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: # noqa:D105 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: # noqa:D105 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 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 _get_prefix_map(records: List[Record]) -> Dict[str, str]: rv = {} for record in records: rv[record.prefix] = record.uri_prefix for prefix_synonym in record.prefix_synonyms: rv[prefix_synonym] = record.uri_prefix return rv def _get_pattern_map(records: List[Record]) -> Dict[str, str]: return {record.prefix: record.pattern for record in records if record.pattern} def _get_reverse_prefix_map(records: List[Record]) -> Dict[str, str]: rv = {} for record in records: rv[record.uri_prefix] = record.prefix for uri_prefix_synonym in record.uri_prefix_synonyms: rv[uri_prefix_synonym] = record.prefix return rv def _get_prefix_synmap(records: List[Record]) -> Dict[str, str]: rv = {} for record in records: rv[record.prefix] = record.prefix for prefix_synonym in record.prefix_synonyms: rv[prefix_synonym] = record.prefix return rv 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://")): res = requests.get(data) res.raise_for_status() return cast(X, res.json()) with open(data) as file: return cast(X, json.load(file)) else: return 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") """ #: The expansion dictionary with prefixes as keys and priority URI prefixes as values prefix_map: Dict[str, str] #: The mapping from URI prefixes to prefixes reverse_prefix_map: Dict[str, str] #: A prefix trie for efficient parsing of URIs trie: StringTrie #: 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] def __init__(self, records: List[Record], *, 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. :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 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 = sorted(records, key=lambda r: r.prefix) self.prefix_map = _get_prefix_map(records) self.synonym_to_prefix = _get_prefix_synmap(records) self.reverse_prefix_map = _get_reverse_prefix_map(records) self.trie = StringTrie(self.reverse_prefix_map) self.pattern_map = _get_pattern_map(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} def _match_record( self, external: Record, case_sensitive: bool = True ) -> Mapping[RecordKey, List[str]]: """Match the given record to existing records.""" rv: DefaultDict[RecordKey, List[str]] = defaultdict(list) for record in self.records: # Match CURIE prefixes if _eq(external.prefix, record.prefix, case_sensitive=case_sensitive): rv[record._key].append("prefix match") if _in(external.prefix, record.prefix_synonyms, case_sensitive=case_sensitive): rv[record._key].append("prefix match") for prefix_synonym in external.prefix_synonyms: if _eq(prefix_synonym, record.prefix, case_sensitive=case_sensitive): rv[record._key].append("prefix match") if _in(prefix_synonym, record.prefix_synonyms, case_sensitive=case_sensitive): rv[record._key].append("prefix match") # Match URI prefixes if _eq(external.uri_prefix, record.uri_prefix, case_sensitive=case_sensitive): rv[record._key].append("URI prefix match") if _in(external.uri_prefix, record.uri_prefix_synonyms, case_sensitive=case_sensitive): rv[record._key].append("URI prefix match") for uri_prefix_synonym in external.uri_prefix_synonyms: if _eq(uri_prefix_synonym, record.uri_prefix, case_sensitive=case_sensitive): rv[record._key].append("URI prefix match") if _in( uri_prefix_synonym, record.uri_prefix_synonyms, case_sensitive=case_sensitive ): rv[record._key].append("URI prefix match") return dict(rv) 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: if not merge: raise ValueError(f"new record already exists and merge=False: {matched}") key = list(matched)[0] existing_record = next(r for r in self.records if r._key == key) 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: for prefix_synonym in itt.chain([record.prefix], record.prefix_synonyms): if prefix_synonym not in into._all_prefixes: into.prefix_synonyms.append(prefix_synonym) into.prefix_synonyms.sort() for uri_prefix_synonym in itt.chain([record.uri_prefix], record.uri_prefix_synonyms): if uri_prefix_synonym not in into._all_uri_prefixes: into.uri_prefix_synonyms.append(uri_prefix_synonym) into.uri_prefix_synonyms.sort() def _index(self, record: Record) -> None: self.prefix_map[record.prefix] = record.uri_prefix self.synonym_to_prefix[record.prefix] = record.prefix for prefix_synonym in record.prefix_synonyms: self.prefix_map[prefix_synonym] = record.uri_prefix self.synonym_to_prefix[prefix_synonym] = record.prefix self.reverse_prefix_map[record.uri_prefix] = record.prefix self.trie[record.uri_prefix] = record.prefix for uri_prefix_synonym in record.uri_prefix_synonyms: self.reverse_prefix_map[uri_prefix_synonym] = record.prefix self.trie[uri_prefix_synonym] = record.prefix if record.pattern and record.prefix not in self.pattern_map: self.pattern_map[record.prefix] = record.pattern def add_prefix( self, prefix: str, uri_prefix: str, prefix_synonyms: Optional[Collection[str]] = None, uri_prefix_synonyms: Optional[Collection[str]] = 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 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 []), ) self.add_record(record, case_sensitive=case_sensitive, merge=merge) @classmethod def from_extended_prefix_map( cls, records: LocationOr[Iterable[Union[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__` :return: 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(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__` :return: 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__` :return: 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: Union["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` :return: 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) @classmethod def from_shacl( cls, graph: Union[str, Path, "rdflib.Graph"], format: Optional[str] = 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__` :return: A converter """ if isinstance(graph, (str, Path)): import rdflib temporary_graph = rdflib.Graph() temporary_graph.parse(location=graph, 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 = 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. :return: 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. :return: 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, ) -> Optional[str]: ... def compress_or_standardize( self, uri_or_curie: str, *, strict: bool = False, passthrough: bool = False ) -> Optional[str]: """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") """ if self.is_uri(uri_or_curie): return self.compress(uri_or_curie, strict=True) if self.is_curie(uri_or_curie): return self.standardize_curie(uri_or_curie, strict=True) if strict: raise CompressionError(uri_or_curie) if passthrough: return uri_or_curie return None def compress_strict(self, uri: str) -> 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: str, *, strict: Literal[True] = True, passthrough: bool = ... ) -> str: ... # docstr-coverage:excused `overload` @overload def compress( self, uri: str, *, strict: Literal[False] = False, passthrough: Literal[True] = True ) -> str: ... # docstr-coverage:excused `overload` @overload def compress( self, uri: str, *, strict: Literal[False] = False, passthrough: Literal[False] = False ) -> Optional[str]: ... def compress( self, uri: str, *, strict: bool = False, passthrough: bool = False ) -> Optional[str]: """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``. """ prefix, identifier = self.parse_uri(uri) if prefix and identifier: return self.format_curie(prefix, identifier) if strict: raise CompressionError(uri) if passthrough: return uri return None def parse_uri(self, uri: str) -> Union[ReferenceTuple, Tuple[None, None]]: """Compress a URI to a CURIE pair. :param uri: A string representing a valid uniform resource identifier (URI) :returns: A CURIE pair if the URI could be parsed, otherwise a pair of None's >>> 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") (None, None) """ try: value, prefix = self.trie.longest_prefix_item(uri) except KeyError: return None, None else: return ReferenceTuple(prefix, uri[len(value) :]) 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, ) -> Optional[str]: ... def expand_or_standardize( self, curie_or_uri: str, *, strict: bool = False, passthrough: bool = False ) -> Optional[str]: """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") """ if self.is_curie(curie_or_uri): return self.expand(curie_or_uri, strict=True) if self.is_uri(curie_or_uri): return self.standardize_uri(curie_or_uri, strict=True) 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 ) -> Optional[str]: ... def expand( self, curie: str, *, strict: bool = False, passthrough: bool = False ) -> Optional[str]: """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") """ prefix, identifier = self.parse_curie(curie) rv = self.expand_pair(prefix, identifier) if rv: return rv if strict: raise ExpansionError(curie) if passthrough: return curie return None def expand_all(self, curie: str) -> Optional[Collection[str]]: """Expand a CURIE pair to all possible URIs. :param curie: A string representing a compact URI :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. >>> 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 """ prefix, identifier = self.parse_curie(curie) return self.expand_pair_all(prefix, identifier) def parse_curie(self, curie: str) -> ReferenceTuple: """Parse a CURIE.""" reference = Reference.from_curie(curie, sep=self.delimiter) return reference.pair def expand_pair(self, prefix: str, identifier: str) -> Optional[str]: """Expand a CURIE pair to the standard URI. :param prefix: The prefix of the CURIE :param identifier: The local unique identifier of the CURIE :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") """ uri_prefix = self.prefix_map.get(prefix) if uri_prefix is None: return None return uri_prefix + identifier def expand_pair_all(self, prefix: str, identifier: str) -> Optional[Collection[str]]: """Expand a CURIE pair to all possible URIs. :param prefix: The prefix of the CURIE :param identifier: The local unique identifier of the CURIE :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. >>> 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.get_record(prefix) if record is None: return None rv = [record.uri_prefix + identifier] for uri_prefix_synonyms in record.uri_prefix_synonyms: rv.append(uri_prefix_synonyms + identifier) return rv # 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 ) -> Optional[str]: ... def standardize_prefix( self, prefix: str, *, strict: bool = False, passthrough: bool = False ) -> Optional[str]: """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' """ rv = self.synonym_to_prefix.get(prefix) if rv: return rv 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 ) -> Optional[str]: ... def standardize_curie( self, curie: str, *, strict: bool = False, passthrough: bool = False ) -> Optional[str]: """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' """ prefix, identifier = self.parse_curie(curie) norm_prefix = self.standardize_prefix(prefix) if norm_prefix is not None: return self.format_curie(norm_prefix, identifier) 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 ) -> Optional[str]: ... def standardize_uri( self, uri: str, *, strict: bool = False, passthrough: bool = False ) -> Optional[str]: """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' """ prefix, identifier = self.parse_uri(uri) if prefix and identifier: # prefix is ensured to be in self.prefix_map because of successful parse return self.prefix_map[prefix] + identifier if strict: raise URIStandardizationError(uri) if passthrough: return uri return None def pd_compress( self, df: "pandas.DataFrame", column: Union[str, int], target_column: Union[None, str, int] = 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. """ pre_func = self.compress_or_standardize if ambiguous else self.compress func = partial(pre_func, strict=strict, passthrough=passthrough) # type:ignore df[column if target_column is None else target_column] = df[column].map(func) def pd_expand( self, df: "pandas.DataFrame", column: Union[str, int], target_column: Union[None, str, int] = 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. """ pre_func = self.expand_or_standardize if ambiguous else self.expand func = partial(pre_func, strict=strict, passthrough=passthrough) # type:ignore df[column if target_column is None else target_column] = df[column].map(func) def pd_standardize_prefix( self, df: "pandas.DataFrame", *, column: Union[str, int], target_column: Union[None, str, int] = 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: Union[str, int], target_column: Union[None, str, int] = 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: Union[str, int], target_column: Union[None, str, int] = 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: Union[str, Path], column: int, *, sep: Optional[str] = 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. """ pre_func = self.compress_or_standardize if ambiguous else self.compress func = partial(pre_func, strict=strict, passthrough=passthrough) # type:ignore self._file_helper(func, path=path, column=column, sep=sep, header=header) def file_expand( self, path: Union[str, Path], column: int, *, sep: Optional[str] = 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. """ pre_func = self.expand_or_standardize if ambiguous else self.expand func = partial(pre_func, strict=strict, passthrough=passthrough) # type:ignore self._file_helper(func, path=path, column=column, sep=sep, header=header) @staticmethod def _file_helper( func: Callable[[str], Optional[str]], path: Union[str, Path], column: int, sep: Optional[str] = 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) def get_record(self, prefix: str) -> Optional[Record]: """Get the record for the prefix.""" # TODO better data structure for this for record in self.records: if record.prefix == prefix or prefix in record.prefix_synonyms: return record 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 _eq(a: str, b: str, case_sensitive: bool) -> bool: if case_sensitive: return a == b return a.casefold() == b.casefold() def _in(a: str, bs: Iterable[str], case_sensitive: bool) -> bool: if case_sensitive: return a in bs nfa = a.casefold() return any(nfa == b.casefold() for b in bs) 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_' """ if not converters: raise ValueError 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[Union[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__` :return: 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__` :return: A converter """ return Converter.from_shacl(data, **kwargs) def write_extended_prefix_map(converter: Converter, path: Union[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, Union[str, List[str]]]: """Convert a record to a dict.""" rv: Dict[str, Union[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: Union[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: Union[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) -> Union[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: Union[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: Union[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: Optional[str] = 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 :return: 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()) ] curies-0.7.10/src/curies/cli.py000066400000000000000000000117701464316147300163120ustar00rootroot00000000000000# -*- coding: utf-8 -*- # type:ignore """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. """ import sys from typing import Callable, Mapping import click from curies import Converter, sources __all__ = [ "main", ] LOADERS = { "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, format) -> 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): 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): 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, server, host, port): if server == "uvicorn": import uvicorn uvicorn.run(app, host=host, port=port) elif server == "werkzeug": 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(): """Run the `curies` CLI.""" @main.command() @LOCATION_ARGUMENT @FRAMEWORK_OPTION @SERVER_OPTION @FORMAT_OPTION @HOST_OPTION @PORT_OPTION def resolver(location, host: str, port: int, framework: str, format: str, server: str): """Serve a resolver app. Location can either be the name of a built-in converter, a file path, or a URL. """ converter = _get_converter(location, format) app = _get_resolver_app(converter, framework=framework) _run_app(app, server=server, host=host, port=port) @main.command() @LOCATION_ARGUMENT @FRAMEWORK_OPTION @SERVER_OPTION @FORMAT_OPTION @HOST_OPTION @PORT_OPTION def mapper(location, host: str, port: int, framework: str, format: str, server: str): """Serve a mapper app. Location can either be the name of a built-in converter, a file path, or a URL. """ 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() curies-0.7.10/src/curies/discovery.py000066400000000000000000000262201464316147300175460ustar00rootroot00000000000000"""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 collections import defaultdict from pathlib import PurePath from typing import IO, TYPE_CHECKING, Any, Iterable, Mapping, Optional, Sequence, Set, TextIO, Union from typing_extensions import Literal from curies import Converter, Record if TYPE_CHECKING: import rdflib __all__ = [ "discover", "discover_from_rdf", ] GraphFormats = Literal["turtle", "xml", "n3", "nt", "trix"] GraphInput = Union[IO[bytes], TextIO, "rdflib.parser.InputSource", str, bytes, PurePath] def discover_from_rdf( graph: Union[GraphInput, "rdflib.Graph"], *, format: Optional[GraphFormats] = 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: Union[GraphInput, "rdflib.Graph"], *, format: Optional[GraphFormats] = 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: Union[GraphInput, "rdflib.Graph"], format: Optional[GraphFormats] = 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: Optional[Sequence[str]] = None, cutoff: Optional[int] = None, metaprefix: str = "ns", converter: Optional[Converter] = 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 .. code-block:: python >>> import curies # Generate some example URIs >>> 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/")] # Now, you can compress the URIs to dummy CURIEs >>> 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: Optional[Converter] = None, uris: Iterable[str], delimiters: Optional[Sequence[str]] = 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) curies-0.7.10/src/curies/mapping_service/000077500000000000000000000000001464316147300203365ustar00rootroot00000000000000curies-0.7.10/src/curies/mapping_service/__init__.py000066400000000000000000000105761464316147300224600ustar00rootroot00000000000000# -*- coding: utf-8 -*- """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:: # 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:: shell pip install gunicorn gunicorn --bind 0.0.0.0:8764 flask_example:app Test a request in the Python REPL. .. code-block:: 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 # type:ignore __all__ = [ "MappingServiceGraph", "MappingServiceSPARQLProcessor", "get_flask_mapping_blueprint", "get_flask_mapping_app", "get_fastapi_router", "get_fastapi_mapping_app", ] curies-0.7.10/src/curies/mapping_service/api.py000066400000000000000000000200401464316147300214550ustar00rootroot00000000000000"""Implementation of mapping service.""" import itertools as itt from typing import TYPE_CHECKING, Any, Collection, Iterable, List, Set, Tuple, Union, cast from rdflib import OWL, Graph, URIRef from rdflib.term import _is_valid_uri from .rdflib_custom import MappingServiceSPARQLProcessor # type: ignore 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: Union[None, str, Collection[str]] = 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): # type:ignore """A service that implements identifier mapping based on a converter.""" converter: Converter predicates: Set[URIRef] def __init__( self, *args: Any, converter: Converter, predicates: Union[None, str, List[str]] = 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) res = graph.query(''' SELECT ?o WHERE { VALUES ?s { } ?s owl:sameAs ?o } ''') 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.predicates = _prepare_predicates(predicates) super().__init__(*args, **kwargs) def _expand_pair_all(self, uri_in: str) -> List[URIRef]: prefix, identifier = self.converter.parse_uri(uri_in) if prefix is None or identifier is None: return [] uris = cast(Collection[str], self.converter.expand_pair_all(prefix, identifier)) # 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( self, triple: Tuple[URIRef, URIRef, URIRef] ) -> 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.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.predicates): yield subj, pred, obj_query elif subj_query is not None and obj_query is None: objects = self._expand_pair_all(subj_query) for obj, pred in itt.product(objects, self.predicates): yield subj_query, pred, obj 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` :return: A blueprint """ from flask import Blueprint, Response, request blueprint = Blueprint("mapping", __name__, **kwargs) graph = MappingServiceGraph(converter=converter) processor = MappingServiceSPARQLProcessor(graph=graph) @blueprint.route(route, methods=["GET", "POST"]) # type:ignore 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` :return: A router """ from fastapi import APIRouter, Form, Header, Query, Response api_router = APIRouter(**kwargs) graph = MappingServiceGraph(converter=converter) processor = MappingServiceSPARQLProcessor(graph=graph) def _resolve(accept: Header, 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) # type:ignore def resolve_get( query: str = Query(description="The SPARQL query to run"), # noqa:B008 accept: str = Header(), # noqa:B008 ) -> Response: """Run a SPARQL query and serve the results.""" return _resolve(accept, query) @api_router.post(route) # type:ignore def resolve_post( query: str = Form(description="The SPARQL query to run"), # noqa:B008 accept: str = Header(), # noqa:B008 ) -> 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 :return: A FastAPI app """ from fastapi import FastAPI router = get_fastapi_router(converter) app = FastAPI() app.include_router(router) return app curies-0.7.10/src/curies/mapping_service/rdflib_custom.py000066400000000000000000000064341464316147300235530ustar00rootroot00000000000000# -*- coding: utf-8 -*- # type: ignore """A custom SPARQL processor that optimizes the query based on https://github.com/RDFLib/rdflib/pull/2257.""" from typing import Union 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 __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( self, query: Union[str, Query], initBindings=None, # noqa:N803 initNs=None, # noqa:N803 base=None, DEBUG=False, # noqa:N803 ): """Evaluate a SPARQL query on this processor's graph.""" if isinstance(query, str): parse_tree = parseQuery(query) query = translateQuery(parse_tree, base, initNs) return self.query(query, initBindings=initBindings, base=base) query.algebra = _optimize_node(query.algebra) return evalQuery(self.graph, query, 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 curies-0.7.10/src/curies/mapping_service/utils.py000066400000000000000000000112751464316147300220560ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Utilities for the mapping service.""" import json import json.decoder import unittest from typing import Callable, List, Mapping, Optional, Set, Tuple import requests from defusedxml import ElementTree __all__ = [ "handle_csv", "handle_json", "handle_xml", "CONTENT_TYPE_TO_HANDLER", "get_sparql_records", "sparql_service_available", "parse_header", "handle_header", ] 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") return [ { binding.attrib["name"]: binding.find("{http://www.w3.org/2005/sparql-results#}uri").text for binding in result } for result in results ] def handle_csv(text: str) -> Records: """Parse bindings encoded in a CSV string.""" header, *lines = (line.strip().split(",") for line in text.splitlines()) return [dict(zip(header, line)) for line in lines] #: 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.""" res = requests.get( endpoint, 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 (requests.exceptions.ConnectionError, 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: Optional[str], 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): # type:ignore """Skip a test unless the service is available.""" return unittest.skipUnless( sparql_service_available(url), reason=f"No {name} service is running on {url}" ) curies-0.7.10/src/curies/py.typed000066400000000000000000000000011464316147300166510ustar00rootroot00000000000000 curies-0.7.10/src/curies/reconciliation.py000066400000000000000000000226251464316147300205460ustar00rootroot00000000000000"""Reconciliation.""" import logging from collections import Counter, defaultdict from typing import Collection, List, Mapping, Optional, Tuple 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 = converter.synonym_to_prefix.get(old) 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]) -> Optional[str]: 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]) -> Optional[str]: 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 curies-0.7.10/src/curies/resolver_service.py000066400000000000000000000227051464316147300211240ustar00rootroot00000000000000# -*- coding: utf-8 -*- """A simple web service for resolving CURIEs.""" from typing import TYPE_CHECKING, Any, Mapping, Optional from .api import Converter if TYPE_CHECKING: import fastapi import flask from werkzeug.wrappers import Response __all__ = [ "get_flask_blueprint", "get_flask_app", "get_fastapi_router", "get_fastapi_app", ] #: 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` :return: A blueprint The following is an end-to-end example of using this function to create a small web resolver application. .. code-block:: # 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:: shell pip install gunicorn gunicorn --bind 0.0.0.0:8764 flask_example:app Test a request in the Python REPL. .. code-block:: >>> import requests >>> requests.get("http://localhost:8764/GO:0032571").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}") # type:ignore 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: Optional[Mapping[str, Any]] = None, flask_kwargs: Optional[Mapping[str, Any]] = None, register_kwargs: Optional[Mapping[str, Any]] = 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` :return: 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:: # 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:: shell 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:: shell python -m curies --port 8764 --framework flask --server gunicorn obo Finally, test a request in the Python REPL. .. code-block:: >>> import requests >>> requests.get("http://localhost:8764/GO:0032571").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` :return: 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:: # 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:: shell pip install uvicorn uvicorn fastapi_example:app --port 8764 --host 0.0.0.0 Test a request in the Python REPL. .. code-block:: >>> import requests >>> requests.get("http://localhost:8764/GO:0032571").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}}") # type:ignore def resolve( prefix: str = Path( # noqa:B008 title="Prefix", description="The Bioregistry prefix corresponding to an identifier resource.", examples=["doid"], ), identifier: str = Path( # noqa:B008 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: Optional[Mapping[str, Any]] = None, fastapi_kwargs: Optional[Mapping[str, Any]] = None, include_kwargs: Optional[Mapping[str, Any]] = 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` :return: 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:: # 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:: shell 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:: shell python -m curies --framework fastapi --server uvicorn obo Finally, test a request in the Python REPL. .. code-block:: >>> import requests >>> requests.get("http://localhost:8764/GO:0032571").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 curies-0.7.10/src/curies/sources.py000066400000000000000000000117331464316147300172250ustar00rootroot00000000000000# -*- coding: utf-8 -*- """External sources of contexts.""" from typing import Any from .api import Converter __all__ = [ "get_obo_converter", "get_prefixcommons_converter", "get_monarch_converter", "get_go_converter", "get_bioregistry_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) -> Converter: """Get a Prefix Commons-maintained context. :param name: The name of the JSON-LD file (e.g., ``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() # type:ignore url = f"{BIOREGISTRY_CONTEXTS}/bioregistry.epm.json" return Converter.from_extended_prefix_map(url, **kwargs) curies-0.7.10/src/curies/version.py000066400000000000000000000003521464316147300172220ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Version information for :mod:`curies`.""" __all__ = [ "VERSION", "get_version", ] VERSION = "0.7.10" def get_version() -> str: """Get the :mod:`curies` version string.""" return VERSION curies-0.7.10/tests/000077500000000000000000000000001464316147300142445ustar00rootroot00000000000000curies-0.7.10/tests/__init__.py000066400000000000000000000000701464316147300163520ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Tests for :mod:`curies`.""" curies-0.7.10/tests/constants.py000066400000000000000000000002021464316147300166240ustar00rootroot00000000000000"""Constants for testing.""" import unittest RUN_SLOW = True SLOW = unittest.skipUnless(RUN_SLOW, reason="Skipping slow tests") curies-0.7.10/tests/test_api.py000066400000000000000000001111121464316147300164230ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Trivial version test.""" import json import tempfile import unittest from pathlib import Path from tempfile import TemporaryDirectory import pandas as pd import rdflib import curies from curies.api import ( CompressionError, Converter, CURIEStandardizationError, DuplicatePrefixes, DuplicateURIPrefixes, ExpansionError, PrefixStandardizationError, Record, Records, Reference, ReferenceTuple, URIStandardizationError, 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 tests.constants import SLOW CHEBI_URI_PREFIX = "http://purl.obolibrary.org/obo/CHEBI_" GO_URI_PREFIX = "http://purl.obolibrary.org/obo/GO_" class TestStruct(unittest.TestCase): """Test the data structures.""" def test_records(self): """Test a list of records.""" records = Records.parse_obj([{"prefix": "chebi", "uri_prefix": CHEBI_URI_PREFIX}]) converter = Converter(records=records) self.assertEqual({"chebi"}, converter.get_prefixes()) 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): """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): """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): """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): """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): """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): """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): """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): """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_reference_tuple(self): """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): """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): """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): """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 e: Converter( [ Record(prefix="chebi", uri_prefix="https://bioregistry.io/chebi:"), Record(prefix="chebi", uri_prefix="http://purl.obolibrary.org/obo/CHEBI_"), ], ) self.assertIsInstance(str(e.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): """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): """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): """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): """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): self.assertIn("GO", converter.prefix_map) self.assertIn("GO", converter.bimap) self.assertIn("http://purl.obolibrary.org/obo/GO_", converter.reverse_prefix_map) self.assertIn("http://purl.obolibrary.org/obo/GO_", 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") self.assertIsNotNone(record, msg=f"records: {[r.prefix for r in converter.records]}") self.assertIsInstance(record, Record) self.assertEqual("GO", record.prefix) @SLOW def test_bioregistry(self): """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): """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): """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): """Test the OBO converter.""" obo_converter = get_obo_converter() self.assertIn("CHEBI", obo_converter.prefix_map) self.assertNotIn("chebi", obo_converter.prefix_map) @SLOW def test_monarch(self): """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): """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]) def test_load_path(self): """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): """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): """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_combine(self): """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): """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) def test_combine_ci(self): """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): """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): """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): """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): """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): """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) # noqa:T201 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): """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): """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_expand_all(self): """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")) def test_expand_ambiguous(self): """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): """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 ), ) class TestVersion(unittest.TestCase): """Trivially test a version.""" def test_version_type(self): """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): """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) curies-0.7.10/tests/test_discovery.py000066400000000000000000000057541464316147300176770ustar00rootroot00000000000000"""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): """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): """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): """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) curies-0.7.10/tests/test_federated_sparql.py000066400000000000000000000121161464316147300211630ustar00rootroot00000000000000# -*- coding: utf-8 -*- """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, List 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): """Assert that a service is able to accept a simple SPARQL query.""" self.assertTrue(sparql_service_available(endpoint)) def _get_app(): 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): """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): """Tear down the testing case.""" self.mapping_service_process.kill() def test_federated_local(self): """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), ) curies-0.7.10/tests/test_io.py000066400000000000000000000073141464316147300162710ustar00rootroot00000000000000"""Test writing I/O.""" import json import unittest from pathlib import Path from tempfile import TemporaryDirectory 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): """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): """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): """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): """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): """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 = graph.query(query) self.assertEqual({self.prefix, self.prefix_synonym}, {str(prefix) for prefix, in results}) curies-0.7.10/tests/test_mapping_service.py000066400000000000000000000324011464316147300210300ustar00rootroot00000000000000# -*- coding: utf-8 -*- """Tests for the identifier mapping service.""" import unittest from typing import Iterable, Set, Tuple from urllib.parse import quote from fastapi.testclient import TestClient from rdflib import OWL, SKOS from rdflib.query import ResultRow 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) { () () } """ 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: Iterable[ResultRow]) -> Set[Tuple[str, str]]: return {(str(row.s), str(row.o)) for row in rows} 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) def test_parse_header(self): """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): """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): """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.graph.query(sparql, processor=self.processor))) def test_sparql(self): """Test a sparql query on the graph.""" rows = _stm(self.graph.query(SPARQL_SIMPLE, processor=self.processor)) self.assertNotEqual(0, len(rows), msg="No results were returned") self.assertEqual(EXPECTED, rows) def test_sparql_backwards(self): """Test a sparql query on the graph.""" rows = _stm(self.graph.query(SPARQL_SIMPLE_BACKWARDS, processor=self.processor)) 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): """Test the SPARQL that gets sent when using this as a service.""" rows = _stm(self.graph.query(SPARQL_FROM_SERVICE, processor=self.processor)) self.assertNotEqual(0, len(rows), msg="No results were returned") self.assertEqual(EXPECTED, rows) def test_missing(self): """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.graph.query(sparql, processor=self.processor))) def test_safe_expand(self): """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, content_type): """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") self.assertIsNotNone(actual_content_type) self.assertEqual(content_type, actual_content_type.split(";")[0].strip()) def assert_parsed(self, res, content_type: str): """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, sparql): """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, sparql): """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): """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): """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): """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): """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): """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): """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): """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): """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}") @unittest.skip(reason="Weird failures on CI") def test_get_query(self): """Test querying the app with GET.""" self.assert_get_sparql_results(self.client, SPARQL_SIMPLE) def test_post_query(self): """Test querying the app with POST.""" self.assert_post_sparql_results(self.client, SPARQL_SIMPLE) @unittest.skip(reason="Weird failures on CI") def test_get_service_query(self): """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): """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.""" @SLOW def test_availability(self): """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")) curies-0.7.10/tests/test_reconciliation.py000066400000000000000000000345771464316147300206770ustar00rootroot00000000000000"""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): """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): """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): """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): """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): """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): """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): """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.records)) self.assertEqual( Record(prefix="a1", prefix_synonyms=["a", "x"], uri_prefix=f"{P}/a/"), converter.records[0], ) def test_synonym(self): """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.records)) self.assertEqual( Record(prefix="x", prefix_synonyms=["a"], uri_prefix=f"{P}/a/"), converter.records[0], ) def test_clash(self): """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.records)) self.assertEqual(records, converter.records) def test_clash_synonym(self): """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.records)) self.assertEqual(records, converter.records) def test_simultaneous(self): """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): """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): """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): """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): """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.records)) 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): """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.records)) 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): """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.records)) self.assertEqual(records, converter.records) def test_clash_synonym(self): """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.records)) self.assertEqual(records, converter.records) class TestRewire(unittest.TestCase): """A test case for rewiring.""" def test_idempotent(self): """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): """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.records)) 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.records)) # 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): """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.records)) 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): """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.records)) 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): """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.records)) self.assertEqual( [ Record( prefix="a", prefix_synonyms=["a1"], uri_prefix=f"{P}/a1/", uri_prefix_synonyms=[f"{P}/a/"], ), ], converter.records, ) curies-0.7.10/tests/test_resolver_service.py000066400000000000000000000063261464316147300212450ustar00rootroot00000000000000# -*- coding: utf-8 -*- """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): """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): """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): """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): """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): """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): """Test the delimiter.""" self.assertEqual("/", self.converter.delimiter) curies-0.7.10/tox.ini000066400000000000000000000122621464316147300144200ustar00rootroot00000000000000# 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 linters/stylers lint manifest pyroma flake8 mypy # documentation linters/checkers doc8 docstr-coverage docs-test # the actual tests py-pydantic1 py-pydantic2 doctests # always keep coverage-report last # coverage-report [testenv] # 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 deps = pydantic1: pydantic<2.0 pydantic2: pydantic>=2.0 extras = tests pandas flask fastapi rdflib [testenv:doctests] commands = xdoctest -m src/curies/api.py deps = xdoctest pygments [testenv:coverage-clean] deps = coverage skip_install = true commands = coverage erase [testenv:lint] deps = black[jupyter] isort skip_install = true commands = black . isort . description = Run linters. [testenv:doclint] deps = rstfmt skip_install = true commands = rstfmt docs/source/ description = Run documentation linters. [testenv:manifest] deps = check-manifest skip_install = true commands = check-manifest description = Check that the MANIFEST.in is written properly and give feedback on how to fix it. [testenv:flake8] skip_install = true deps = darglint flake8 flake8-black flake8-bandit flake8-bugbear flake8-colors flake8-docstrings flake8-isort flake8-print pep8-naming pydocstyle commands = flake8 src/ tests/ description = Run the flake8 tool with several plugins (bandit, docstrings, import order, pep8 naming). See https://cthoyt.com/2020/04/25/how-to-code-with-me-flake8.html for more information. [testenv:pyroma] deps = pygments pyroma skip_install = true commands = pyroma --min=10 . description = Run the pyroma tool to check the package friendliness of the project. [testenv:mypy] deps = mypy types-requests skip_install = true commands = mypy --install-types --non-interactive --ignore-missing-imports --strict src/ description = Run the mypy tool to check static typing on the project. [testenv:doc8] skip_install = true deps = sphinx doc8 commands = doc8 docs/source/ description = Run the doc8 tool to check the style of the RST files in the project docs. [testenv:docstr-coverage] skip_install = true deps = docstr-coverage commands = docstr-coverage src/ tests/ --skip-private --skip-magic description = Run the docstr-coverage tool to check documentation coverage [testenv:docs] description = Build the documentation locally. extras = docs pandas flask fastapi rdflib commands = python -m sphinx -W -b html -d docs/build/doctrees docs/source docs/build/html [testenv:docs-test] description = Test building the documentation in an isolated environment. changedir = docs 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 = /bin/cp /bin/cat /bin/mkdir # for compatibility on GitHub actions /usr/bin/cp /usr/bin/cat /usr/bin/mkdir [testenv:coverage-report] deps = coverage skip_install = true commands = coverage combine coverage report #################### # Deployment tools # #################### [testenv:bumpversion] commands = bumpversion {posargs} skip_install = true passenv = HOME deps = bumpversion [testenv:build] skip_install = true deps = wheel build setuptools commands = python -m build --sdist --wheel --no-isolation [testenv:release] description = Release the code to PyPI so users can pip install it skip_install = true deps = {[testenv:build]deps} twine >= 1.5.0 commands = {[testenv:build]commands} twine upload --skip-existing dist/* [testenv:testrelease] description = Release the code to the test PyPI site skip_install = true deps = {[testenv:build]deps} twine >= 1.5.0 commands = {[testenv:build]commands} twine upload --skip-existing --repository-url https://test.pypi.org/simple/ dist/* [testenv:finish] skip_install = true passenv = HOME TWINE_USERNAME TWINE_PASSWORD deps = {[testenv:release]deps} bump2version commands = bump2version release --tag {[testenv:release]commands} git push --tags bump2version patch git push allowlist_externals = /usr/bin/git