diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..dd00c0845 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "" +labels: "" +assignees: "" +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Desktop (please complete the following information):** + +- OS: [e.g. iOS] +- Browser [e.g. chrome, safari] +- Version [e.g. 22] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..2bc5d5f71 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "" +labels: "" +assignees: "" +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/simple-issue.md b/.github/ISSUE_TEMPLATE/simple-issue.md new file mode 100644 index 000000000..80f542efe --- /dev/null +++ b/.github/ISSUE_TEMPLATE/simple-issue.md @@ -0,0 +1,9 @@ +--- +name: Simple Issue +about: Describe the issue +title: +labels: "" +assignees: "" +--- + +## Description diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..da8255dfa --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +# Description + +## Changes + +## Tests + +## Known Issues + +## Notes + +## Checklist + +- [ ] My name is on the list of contributors (`CONTRIBUTORS.md`) in the pull request source branch. +- [ ] I have updated the documentation to reflect my changes. diff --git a/.github/workflows/build_and_deploy_docs.yaml b/.github/workflows/build_and_deploy_docs.yaml new file mode 100644 index 000000000..e0b1fc7a9 --- /dev/null +++ b/.github/workflows/build_and_deploy_docs.yaml @@ -0,0 +1,21 @@ +# build docs from master branch and push to gh-pages branch to be deployed to repository GitHub pages + +name: Build & Deploy Docs +on: + push: + branches: + - develop + + # trunk-ignore(yamllint/empty-values) + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.x + - run: pip install -r requirements_docs.txt + - run: mkdocs gh-deploy --force diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..0cc7fe0ab --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,78 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: CodeQL + +on: + push: + branches: + - develop + - main + - trunk-merge/** + pull_request: + # The branches below must be a subset of the branches above + branches: [develop] + schedule: + - cron: 19 15 * * 2 + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [python] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: /language:${{matrix.language}} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 000000000..0b6d4d1fa --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,28 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement +name: Dependency Review +on: + push: + branches: + - main + - develop + pull_request: + branches: + - develop + - main + +permissions: + contents: read + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v3 + - name: Dependency Review + uses: actions/dependency-review-action@v2 diff --git a/.github/workflows/docs_check.yaml b/.github/workflows/docs_check.yaml new file mode 100644 index 000000000..627d2c932 --- /dev/null +++ b/.github/workflows/docs_check.yaml @@ -0,0 +1,38 @@ +# this CI workflow checks the documentation for any broken links or errors within documentation files/configuration +# and reports errors to catch errors and never deploy broken documentation +name: MkDocs CI Check + +on: + push: + branches: + - main + - develop + - "*" + - trunk-merge/** + pull_request: + branches: + - main + - develop + - "*" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v2 + + - name: Set Up Python + uses: actions/setup-python@v2 + with: + python-version: 3.11 + + - name: Install Python SDK + run: pip install -e . + + - name: Install Doc Dependencies + run: pip install -r requirements_docs.txt + + - name: Build and Test Documentation + run: mkdocs build diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml new file mode 100644 index 000000000..91c2a054c --- /dev/null +++ b/.github/workflows/mypy.yaml @@ -0,0 +1,40 @@ +# check code types with mypy to be sure the static types are correct and make sense + +name: MyPy Check + +on: + push: + branches: + - main + - develop + - trunk-merge/** + pull_request: + branches: + - main + - develop + +jobs: + mypy-test: + strategy: + matrix: + python-version: [3.11] + os: [ubuntu-latest] + + runs-on: ${{ matrix.os }} + + steps: + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Check out code + uses: actions/checkout@v2 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements_dev.txt + + - name: Run MyPy + run: mypy src/cript/ diff --git a/.github/workflows/test_coverage.yaml b/.github/workflows/test_coverage.yaml new file mode 100644 index 000000000..125f357b9 --- /dev/null +++ b/.github/workflows/test_coverage.yaml @@ -0,0 +1,49 @@ +# use pytest-cov to see what percentage of the code is being covered by tests +# WARNING: this workflow will fail if any of the tests within it fail + +name: Test Coverage + +on: + push: + branches: + - main + - develop + - trunk-merge/** + pull_request: + branches: + - main + - develop + +jobs: + test-coverage: + runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest] + python-version: [3.11] + + env: + CRIPT_HOST: https://lb-stage.mycriptapp.org/ + CRIPT_TOKEN: 125433546 + CRIPT_STORAGE_TOKEN: 987654321 + CRIPT_TESTS: False + + steps: + - uses: actions/checkout@v2 + + - name: Setup Python + uses: actions/setup-python@v2 + with: + python-version: 3.11 + + - name: upgrade pip + run: pip install --upgrade pip + + - name: Install CRIPT Python SDK + run: pip install -e . + + - name: Install requirements_dev.txt + run: pip install -r requirements_dev.txt + + - name: Test Coverage + run: pytest --cov --cov-fail-under=89 diff --git a/.github/workflows/test_examples.yml b/.github/workflows/test_examples.yml new file mode 100644 index 000000000..be4024782 --- /dev/null +++ b/.github/workflows/test_examples.yml @@ -0,0 +1,46 @@ +name: Test Jupyter Notebook + +on: + push: + branches: + - main + - develop + - trunk-merge/** + pull_request: + branches: + - main + - develop + +jobs: + test-examples: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + python-version: [3.11] + + env: + CRIPT_HOST: https://lb-stage.mycriptapp.org/ + CRIPT_TOKEN: 123456789 + CRIPT_STORAGE_TOKEN: 987654321 + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: install test dependency + run: python3 -m pip install -r requirements_docs.txt + + - name: install module + run: python3 -m pip install . + + - name: prepare notebook + run: | + jupytext --to py docs/examples/synthesis.md + jupytext --to py docs/examples/simulation.md + + - name: Run script + run: | + python3 docs/examples/synthesis.py + python3 docs/examples/simulation.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..4ed890252 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,48 @@ +# Runs all the Python SDK tests within the `tests/` directory to check our code + +name: Tests + +on: + # trunk-ignore(yamllint/empty-values) + workflow_dispatch: + + push: + branches: + - main + - develop + - trunk-merge/** + pull_request: + branches: + - main + - develop + - "*" + +jobs: + install: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + + matrix: + os: [ubuntu-latest, macos-latest] + python-version: [3.7, 3.11] + + env: + CRIPT_HOST: https://lb-stage.mycriptapp.org/ + CRIPT_TOKEN: 123456789 + CRIPT_STORAGE_TOKEN: 987654321 + CRIPT_TESTS: False + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: pip install CRIPT Python SDK local package + run: python3 -m pip install . + + - name: pip install requirements_dev.txt + run: python3 -m pip install -r requirements_dev.txt + + - name: Run pytest on tests/ + run: pytest ./tests/ diff --git a/.github/workflows/trunk.yml b/.github/workflows/trunk.yml new file mode 100644 index 000000000..c528f3b14 --- /dev/null +++ b/.github/workflows/trunk.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: + - main + - develop + - trunk-merge/** + pull_request: + branches: + - main + - develop + - "*" + +jobs: + trunk: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Trunk Check + uses: trunk-io/trunk-action@v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..14651984c --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# jet brains IDE config directory +.idea/ + +# vscode config directory +.vscode/ + +# ignore virtual environments +.env +.venv +config.json +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# pycache +__pycache__/ + +# distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# ignore mypy cache +.mypy_cache/ + +# pytest cache +.pytest_cache + +# ignore coverage.py files and directories +.coverage +/.coverage +htmlcov/ \ No newline at end of file diff --git a/.trunk/.gitignore b/.trunk/.gitignore new file mode 100644 index 000000000..695b51906 --- /dev/null +++ b/.trunk/.gitignore @@ -0,0 +1,8 @@ +*out +*logs +*actions +*notifications +plugins +user_trunk.yaml +user.yaml +tools diff --git a/.trunk/configs/.cspell.json b/.trunk/configs/.cspell.json new file mode 100644 index 000000000..f9dd36d6e --- /dev/null +++ b/.trunk/configs/.cspell.json @@ -0,0 +1,107 @@ +{ + "words": [ + "CRIPT", + "cript", + "orcid", + "uid", + "uids", + "barostat", + "sortkeys", + "forcefield", + "opls", + "issn", + "arxiv", + "pmid", + "ISSN", + "bigsmiles", + "funders", + "Elsevier", + "Müller", + "berendsen", + "setter", + "fwhm", + "ASTM", + "lammps", + "LAMMPS", + "pyclass", + "rdist", + "subobject", + "Subobject", + "fcller", + "criptapp", + "pubchem", + "Chlorophenyl", + "dichlorobenzoate", + "Dichloro", + "methylbutyl", + "benzamide", + "Chlorophenyl", + "dichlorobenzoate", + "polyacrylate", + "JSPS", + "subobjects", + "forcefields", + "LBCC", + "GROMACS", + "CHARMM", + "Forcefields", + "Debye", + "FTIR", + "Szwarc", + "homopolymer", + "polyolefins", + "hydrogels", + "polyisoprene", + "polystyrene", + "mcherry", + "autouse", + "CRIPTAPI", + "Verlet", + "pytest", + "mkdocs", + "docstrings", + "jsonschema", + "devs", + "unwritable", + "docstrings", + "runtimes", + "timestep", + "TLDR", + "codeql", + "Autobuild", + "buildscript", + "markdownlint", + "Numpy", + "ipynb", + "boto", + "beartype", + "mypy", + "ipynb", + "jupytext", + "kernelspec", + "OCCCC", + "endregion", + "vinylbenzene", + "multistep", + "mmol", + "inchi", + "LRHPLDYGYMQRHN", + "UHFFFAOYSA", + "Butan", + "Butyric", + "Methylolpropane", + "fontawesome", + "venv", + "deepdiff", + "rdkit", + "packmol", + "Packmol", + "openmm", + "equi", + "Navid", + "ipykernel", + "levelname", + "enylcyclopent", + "Polybeccarine" + ] +} diff --git a/.trunk/configs/.isort.cfg b/.trunk/configs/.isort.cfg new file mode 100644 index 000000000..b9fb3f3e8 --- /dev/null +++ b/.trunk/configs/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +profile=black diff --git a/.trunk/configs/.markdownlint.yaml b/.trunk/configs/.markdownlint.yaml new file mode 100644 index 000000000..276c23b5f --- /dev/null +++ b/.trunk/configs/.markdownlint.yaml @@ -0,0 +1,12 @@ +# Autoformatter friendly markdownlint config (all formatting rules disabled) +default: true +blank_lines: false +bullet: false +html: false +indentation: false +line_length: false +spaces: false +url: false +whitespace: false +MD041: false +MD046: false diff --git a/.trunk/configs/.yamllint.yaml b/.trunk/configs/.yamllint.yaml new file mode 100644 index 000000000..4d444662d --- /dev/null +++ b/.trunk/configs/.yamllint.yaml @@ -0,0 +1,10 @@ +rules: + quoted-strings: + required: only-when-needed + extra-allowed: ["{|}"] + empty-values: + forbid-in-block-mappings: true + forbid-in-flow-mappings: true + key-duplicates: {} + octal-values: + forbid-implicit-octal: true diff --git a/.trunk/configs/svgo.config.js b/.trunk/configs/svgo.config.js new file mode 100644 index 000000000..b257d1349 --- /dev/null +++ b/.trunk/configs/svgo.config.js @@ -0,0 +1,14 @@ +module.exports = { + plugins: [ + { + name: "preset-default", + params: { + overrides: { + removeViewBox: false, // https://github.com/svg/svgo/issues/1128 + sortAttrs: true, + removeOffCanvasPaths: true, + }, + }, + }, + ], +}; diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml new file mode 100644 index 000000000..91d4b99a9 --- /dev/null +++ b/.trunk/trunk.yaml @@ -0,0 +1,53 @@ +version: 0.1 +cli: + version: 1.13.0 +plugins: + sources: + - id: trunk + ref: v1.0.0 + uri: https://github.com/trunk-io/plugins +lint: + enabled: + - svgo@3.0.2 + - cspell@6.31.2 + - actionlint@1.6.25 + - black@23.7.0 + - git-diff-check + - gitleaks@8.17.0 + - isort@5.12.0 + - markdownlint@0.35.0 + - oxipng@8.0.0 + - prettier@3.0.0 + - ruff@0.0.280 + - taplo@0.8.1 + - yamllint@1.32.0 + ignore: + - linters: [prettier] + paths: + - site/** + - docs/** +runtimes: + enabled: + - go@1.19.5 + - node@18.12.1 + - python@3.10.8 +actions: + enabled: + - trunk-announce + - trunk-check-pre-push + - trunk-fmt-pre-commit + - trunk-upgrade-available +merge: + required_statuses: + - trunk + - Analyze (python) + - build + - install (ubuntu-latest, 3.7) + - install (ubuntu-latest, 3.11) + - install (macos-latest, 3.7) + - install (macos-latest, 3.11) + - test-coverage (ubuntu-latest, 3.7) + - test-coverage (ubuntu-latest, 3.11) + - mypy-test (3.7, ubuntu-latest) + - mypy-test (3.11, ubuntu-latest) + - test-examples (ubuntu-latest, 3.11) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..93259366c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# 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, 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 +cript_report@mit.edu. +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.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..1c6cff44e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,83 @@ +# Repository Contributing Guidelines + +Welcome to our GitHub repository! We appreciate your interest in contributing to our project. +We value the collaborative spirit of the open-source community and would love to have your contributions. +This document outlines the guidelines to help you get started. +For more detailed information, please refer to our wiki section. + +## How to Contribute + +1. Fork the repository [develop branch](https://github.com/C-Accel-CRIPT/Python-SDK/tree/develop) to your GitHub + account. + > [main branch](https://github.com/C-Accel-CRIPT/Python-SDK/tree/main) tries to mirror the CRIPT Pypi package, + > [develop branch](https://github.com/C-Accel-CRIPT/Python-SDK/tree/develop) has all the latest developments waiting + > for release +2. Create a new branch in your forked repository. Choose a descriptive name that summarizes your contribution. +3. Make the necessary changes or additions to the codebase. +4. Test your changes thoroughly to ensure they don't introduce any issues. +5. Commit your changes with a clear and concise commit message. +6. Push the changes to your forked repository. +7. Open a pull request (PR) in our repository to propose your changes. + > Please be sure to merge all of your incoming changes to the + > [develop branch](https://github.com/C-Accel-CRIPT/Python-SDK/tree/develop), we only update the + > [main branch](https://github.com/C-Accel-CRIPT/Python-SDK/tree/main) when going to make a release by + > merging [develop branch](https://github.com/C-Accel-CRIPT/Python-SDK/tree/develop) into main. + > For more information, please refer to + > [repository guidelines wiki](https://github.com/C-Accel-CRIPT/Python-SDK/wiki/Repository-Guidelines) + > and [deployment wiki](https://github.com/C-Accel-CRIPT/Python-SDK/wiki/Manually-Deploy-to-Pypi) + +## Submitting an Issue + +Before you submit an issue, please search the issue tracker, maybe an issue for your problem already exists, and the +discussion might inform you of workarounds readily available. + +We want to fix all the issues as soon as possible, but before fixing a bug, we need to reproduce and confirm it. In +order to reproduce bugs, we will systematically ask you to provide a minimal reproduction scenario using the custom +issue template. Please stick to the issue template. + +Unfortunately, we are not able to investigate/fix bugs without a minimal reproduction scenario, so if we don't hear +back from you, we may close the issue. + +## Submitting PR + +Search GitHub for an open or closed PR that relates to your submission. You +don't want to duplicate effort. If you do not find a related issue or PR, +go ahead. + +## PR Guidelines + +When submitting a pull request, please make sure to: + +- Clearly describe the purpose of your PR. +- Include any relevant information or context that helps us understand your changes. +- Make sure your changes adhere to our coding style and guidelines. +- Test your changes thoroughly and provide any necessary documentation or test cases. +- Ensure your PR does not include any unrelated or unnecessary changes. +- All CI must pass before a PR can be approved and merged into the code base. + +## Repositorty Wiki + +For more in-depth information about our project, development setup, coding conventions, and specific areas where you can +contribute, +please refer to our [wiki section](https://github.com/C-Accel-CRIPT/Python-SDK/wiki). +It contains valuable resources and documentation to help you understand our project better. + +We encourage you to explore the wiki before making contributions. It will provide you with the necessary background +knowledge and help you find areas where your expertise can make a difference. + +## Communication + +If you have any questions, concerns, or need clarification on anything related to the project or your contributions, +feel free to reach out to us. +You can use the [GitHub issue tracker](https://github.com/C-Accel-CRIPT/Python-SDK/issues) or +the [Discussion channels](https://github.com/C-Accel-CRIPT/Python-SDK/discussions). + +## Code of Conduct + +We expect all contributors to adhere to our +[code of conduct](https://github.com/C-Accel-CRIPT/Python-SDK/blob/develop/CODE_OF_CONDUCT.md), +which ensures a safe and inclusive environment for everyone. +Please review our [code of conduct](https://github.com/C-Accel-CRIPT/Python-SDK/blob/develop/CODE_OF_CONDUCT.md) +before making contributions. + +Thank you for considering contributing to our project! We appreciate your time and effort in making it better. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 000000000..d4034b833 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,7 @@ +# CRIPT DEVELOPMENT TEAM + +- [Navid Hariri](https://github.com/nh916) +- [Ludwig Schneider](https://github.com/InnocentBug/) +- [Dylan Walsh](https://github.com/dylanwal/) +- [Brilant Kasami](https://github.com/brili) +- [Fatjon Ismailaj](https://github.com/fatjon95) diff --git a/CRIPT_full_logo_colored_transparent.png b/CRIPT_full_logo_colored_transparent.png new file mode 100644 index 000000000..942727248 Binary files /dev/null and b/CRIPT_full_logo_colored_transparent.png differ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..f221901aa --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2023 Community Resource for Innovation in Polymer Technology (CRIPT) + +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. diff --git a/README.md b/README.md index b61cefae6..be1597df2 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,33 @@ # CRIPT Python SDK +[![License](./CRIPT_full_logo_colored_transparent.png)](https://github.com/C-Accel-CRIPT/Python-SDK/blob/develop/LICENSE.md) - -[![License](https://img.shields.io/github/license/C-Accel-CRIPT/cript?style=flat-square)](https://github.com/C-Accel-CRIPT/cript/blob/master/LICENSE.txt) -[![Python](https://img.shields.io/badge/Language-Python%203.9+-blue?style=flat-square&logo=python)](https://www.python.org/) +[![License](https://img.shields.io/github/license/C-Accel-CRIPT/cript?style=flat-square)](https://github.com/C-Accel-CRIPT/Python-SDK/blob/develop/LICENSE.md) +[![Python](https://img.shields.io/badge/Language-Python%203.7+-blue?style=flat-square&logo=python)](https://www.python.org/) [![Code style is black](https://img.shields.io/badge/Code%20Style-black-000000.svg?style=flat-square&logo=python)](https://github.com/psf/black) [![Link to CRIPT website](https://img.shields.io/badge/platform-criptapp.org-blueviolet?style=flat-square)](https://criptapp.org/) -[![CRIPT Blog Link](https://img.shields.io/badge/Blog-blog.criptapp.org-blueviolet?style=flat-square)](https://blog.criptapp.org) +[![Using Pytest](https://img.shields.io/badge/Dependencies-pytest-green?style=flat-square&logo=Pytest)](https://docs.pytest.org/en/7.2.x/) +[![Using JSONSchema](https://img.shields.io/badge/Dependencies-jsonschema-blueviolet?style=flat-square&logo=json)](https://python-JSONSchema.readthedocs.io/en/stable/) +[![Using Requests Library](https://img.shields.io/badge/Dependencies-Requests-blueviolet?style=flat-square&logo=python)](https://requests.readthedocs.io/en/latest/) [![Material MkDocs](https://img.shields.io/badge/Docs-mkdocs--material-blueviolet?style=flat-square&logo=markdown)](https://squidfunk.github.io/mkdocs-material/) +[![trunk CI](https://github.com/C-Accel-CRIPT/Python-SDK/actions/workflows/trunk.yml/badge.svg)](https://github.com/C-Accel-CRIPT/Python-SDK/actions/workflows/trunk.yml) +[![Tests](https://github.com/C-Accel-CRIPT/Python-SDK/actions/workflows/tests.yml/badge.svg)](https://github.com/C-Accel-CRIPT/Python-SDK/actions/workflows/tests.yml) +[![CodeQL](https://github.com/C-Accel-CRIPT/Python-SDK/actions/workflows/codeql.yml/badge.svg)](https://github.com/C-Accel-CRIPT/Python-SDK/actions/workflows/codeql.yml) +[![mypy](https://github.com/C-Accel-CRIPT/Python-SDK/actions/workflows/mypy.yaml/badge.svg)](https://github.com/C-Accel-CRIPT/Python-SDK/actions/workflows/mypy_check.yaml) + + + + + +## Disclaimer + +This is the successor to the original [CRIPT Python SDK](https://github.com/C-Accel-CRIPT/cript). The new CRIPT Python SDK is still under development, and we will officially release it as soon as it is ready. For now please use the [original CRIPT Python SDK](https://github.com/C-Accel-CRIPT/cript) + +--- + ## What is it? The CRIPT Python SDK allows programmatic access to the [CRIPT platform](https://criptapp.org). It can help automate uploading your data to CRIPT, and aims to allow for manipulation of your CRIPT data through the python language. This is a perfect tool for users who have python experience and have large amount of data to upload to [CRIPT](https://criptapp.org). @@ -17,7 +36,7 @@ The CRIPT Python SDK allows programmatic access to the [CRIPT platform](https:// ## Installation -CRIPT Python SDK requires Python 3.9+ +CRIPT Python SDK requires Python 3.7+ The latest released of CRIPT Python SDK is available on [Python Package Index (PyPI)](https://pypi.org/project/cript/) @@ -29,18 +48,33 @@ pip install cript ## Documentation -To learn more about the CRIPT Python SDK please check the [CRIPT-SDK documentation](https://c-accel-cript.github.io/cript/) +To learn more about the CRIPT Python SDK please check the [CRIPT-SDK documentation](https://c-accel-cript.github.io/Python-SDK/) --- ## Release Notes -For updates and release notes please visit the [CRIPT blog](https://blog.criptapp.org) +Please visit the [GitHub Releases page](https://github.com/C-Accel-CRIPT/Python-SDK/releases/latest) for a detailed release notes. + +--- + +## We Invite Contribution + +To get started, feel free to take a look at our [Contribution Guidelines](CONTRIBUTING.md) for +a detailed guide on how to contribute to our repository and become a part of our community. + +Whether you want to report a bug, propose a new feature, or submit a pull request, your contribution is highly valued. + +For development documentation to better understand the Python SDK code please visit the +[Python SDK Wiki](https://github.com/C-Accel-CRIPT/Python-SDK/wiki). +If you encounter any issues please let us know via +[issues section](https://github.com/C-Accel-CRIPT/Python-SDK/issues) or +[discussion sections](https://github.com/C-Accel-CRIPT/Python-SDK/discussions). + +To learn more about our great community and all the open source plugins made by our fantastic community available +for the [CRIPT Python SDK](https://github.com/C-Accel-CRIPT/Python-SDK) please take a look at the +[plugins section](https://github.com/C-Accel-CRIPT/Python-SDK/discussions/categories/plugins). -### Software development +We appreciate your interest in contributing to our project! Together, let's make it even better! 🚀 -You are welcome to contribute code via PR to this repository. -For the developmet, we are using [trunk.io](https://trunk.io) to achieve a consistent coding style. -You can run `./trunk fmt` to auto-format your contributions and `./trunk check` to verify your contribution complies with our standard via trunk. -We will run the same test automatically before we are able to merge the code. -Please, let us know if there are any issues. +Happy coding! diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..ac047cd0d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# Security Policy + + + +## Reporting a Vulnerability + +If you find any security issues or vulnerabilities please report them to cript_report@mit.edu diff --git a/docs/api/api.md b/docs/api/api.md new file mode 100644 index 000000000..496934ead --- /dev/null +++ b/docs/api/api.md @@ -0,0 +1 @@ +::: cript.api.api diff --git a/docs/api/controlled_vocabulary_categories.md b/docs/api/controlled_vocabulary_categories.md new file mode 100644 index 000000000..e851edaea --- /dev/null +++ b/docs/api/controlled_vocabulary_categories.md @@ -0,0 +1 @@ +::: cript.VocabCategories diff --git a/docs/api/paginator.md b/docs/api/paginator.md new file mode 100644 index 000000000..e24e116f6 --- /dev/null +++ b/docs/api/paginator.md @@ -0,0 +1 @@ +::: cript.api.paginator diff --git a/docs/api/search_modes.md b/docs/api/search_modes.md new file mode 100644 index 000000000..6169fd264 --- /dev/null +++ b/docs/api/search_modes.md @@ -0,0 +1 @@ +::: cript.SearchModes diff --git a/docs/examples/.gitignore b/docs/examples/.gitignore new file mode 100644 index 000000000..50621420c --- /dev/null +++ b/docs/examples/.gitignore @@ -0,0 +1,2 @@ +*ipynb +*.ipynb_checkpoints \ No newline at end of file diff --git a/docs/examples/simulation.md b/docs/examples/simulation.md new file mode 100644 index 000000000..9c385a106 --- /dev/null +++ b/docs/examples/simulation.md @@ -0,0 +1,392 @@ +--- +jupyter: + jupytext: + cell_metadata_filter: -all + formats: ipynb,md + text_representation: + extension: .md + format_name: markdown + format_version: "1.3" + jupytext_version: 1.13.6 + kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +!!! abstract + This tutorial guides you through an example material synthesis workflow using the + [CRIPT Python SDK](https://pypi.org/project/cript/). + + +## Installation + +Before you start, be sure the [cript python package](https://pypi.org/project/cript/) is installed. + +```bash +pip install cript +``` + +## Connect to CRIPT + +To connect to CRIPT, you must enter a `host` and an `API Token`. For most users, `host` will be `https://criptapp.org`. + +!!! Warning "Keep API Token Secure" + + To ensure security, avoid storing sensitive information like tokens directly in your code. + Instead, use environment variables. + Storing tokens in code shared on platforms like GitHub can lead to security incidents. + Anyone that possesses your token can impersonate you on the [CRIPT](https://criptapp.org/) platform. + Consider [alternative methods for loading tokens with the CRIPT API Client](https://c-accel-cript.github.io/Python-SDK/api/api/#cript.api.api.API.__init__). + In case your token is exposed be sure to immediately generate a new token to revoke the access of the old one + and keep the new token safe. + +```python +import cript + +with cript.API(host="https://api.criptapp.org/", api_token="123456", storage_token="987654") as api: + pass +``` + +!!! note + + You may notice, that we are not executing any code inside the context manager block. + If you were to write a python script, compared to a jupyter notebook, you would add all the following code inside that block. + Here in a jupyter notebook, we need to connect manually. We just have to remember to disconnect at the end. + +```python +api = cript.API(host="https://api.criptapp.org/", api_token=None, storage_token="123456") +api = api.connect() +``` + +## Create a Project + +All data uploaded to CRIPT must be associated with a [`Project`](../../nodes/primary_nodes/project) node. +[`Project`](../../nodes/primary_nodes/project) can be thought of as an overarching research goal. +For example, finding a replacement for an existing material from a sustainable feedstock. + +```python +# create a new project in the CRIPT database +project = cript.Project(name="My simulation project.") +``` + +## Create a [Collection node](../../nodes/primary_nodes/collection) + +For this project, you can create multiple collections, which represent a set of experiments. +For example, you can create a collection for a specific manuscript, +or you can create a collection for initial screening of candidates and one for later refinements etc. + +So, let's create a collection node and add it to the project. + +```python +collection = cript.Collection(name="Initial simulation screening") +# We add this collection to the project as a list. +project.collection += [collection] +``` + +!!! note "Viewing CRIPT JSON" + + Note, that if you are interested into the inner workings of CRIPT, + you can obtain a JSON representation of your data at any time to see what is being sent to the API + through HTTP JSON requests. + +```python +print(project.json) +``` + +!!! info "Format JSON in terminal" + Format the JSON within the terminal for easier reading + ```python + print(project.get_json(indent=2).json) + ``` + +## Create an [Experiment node](../../nodes/primary_nodes/experiment) + +The [Collection node](../../nodes/primary_nodes/collection) holds a series of +[Experiment nodes](../../nodes/primary_nodes/experiment) nodes. + +And we can add this experiment to the collection of the project. + +```python +experiment = cript.Experiment(name="Simulation for the first candidate") +collection.experiment += [experiment] +``` + +## Create relevant [Software nodes](../../nodes/primary_nodes/software) + +[`Software`](../../nodes/primary_nodes/software) nodes refer to software that you use during your simulation experiment. +In general [`Software`](../../nodes/primary_nodes/software) nodes can be shared between project, and it is encouraged to do so if the software you are using is already present in the CRIPT project use it. + +If They are not, you can create them as follows: + +```python +python = cript.Software(name="python", version="3.9") +rdkit = cript.Software(name="rdkit", version="2020.9") +stage = cript.Software(name="stage", source="https://doi.org/10.1021/jp505332p", version="N/A") +packmol = cript.Software(name="Packmol", source="http://m3g.iqm.unicamp.br/packmol", version="N/A") +openmm = cript.Software(name="openmm", version="7.5") +``` + +Generally, provide as much information about the software as possible this helps to make your results reproducible. +Even a software is not publicly available, like an in-house code, we encourage you to specify them in CRIPT. +If a version is not available, consider using git-hashes. + + + +## Create [Software Configuration](../../nodes/subobjects/software_configuration/) + +Now that we have our [`Software`](../../nodes/primary_nodes/software) nodes, we can create +[`SoftwareConfiguration`](../../nodes/subobjects/software_configuration/) nodes. [`SoftwareConfigurations`](../../nodes/subobjects/software_configuration/) nodes are designed to let you specify details, about which algorithms from the software package you are using and log parameters for these algorithms. + +The [`SoftwareConfigurations`](../../nodes/subobjects/software_configuration/) are then used for constructing our [`Computation`](../../nodes/primary_nodes/computation/) node, which describe the actual computation you are performing. + +We can also attach [`Algorithm`](../../nodes/subobjects/algorithm) nodes to a [`SoftwareConfiguration`](../../nodes/subobjects/software_configuration) +node. The [`Algorithm`](../../nodes/subobjects/algorithm) nodes may contain nested [`Parameter`](../../nodes/subobjects/parameter) nodes, as shown in the example below. + + + +```python +# create some software configuration nodes +python_config = cript.SoftwareConfiguration(software=python) +rdkit_config = cript.SoftwareConfiguration(software=rdkit) +stage_config = cript.SoftwareConfiguration(software=stage) + +# create a software configuration node with a child Algorithm node +openmm_config = cript.SoftwareConfiguration( + software=openmm, + algorithm=[ + cript.Algorithm( + key="energy_minimization", + type="initialization", + ), + ], +) +packmol_config = cript.SoftwareConfiguration(software=packmol) +``` + +!!! note "Algorithm keys" + The allowed [`Algorithm`](../../nodes/subobjects/algorithm/) keys are listed under [algorithm keys](https://criptapp.org/keys/algorithm-key/) in the CRIPT controlled vocabulary. + +!!! note "Parameter keys" + The allowed [`Parameter`](../../nodes/subobjects/property/) keys are listed under [parameter keys](https://criptapp.org/keys/parameter-key/) in the CRIPT controlled vocabulary. + + +## Create [Computations](../../nodes/primary_nodes/computation) + +Now that we've created some [`SoftwareConfiguration`](../../nodes/subobjects/software_configuration) nodes, we can used them to build full [`Computation`](../../nodes/primary_nodes/computation) nodes. +In some cases, we may also want to add [`Condition`](../../nodes/subobjects/condition) nodes to our computation, to specify the conditions at which the computation was carried out. An example of this is shown below. + + +```python +# Create a ComputationNode +# This block of code represents the computation involved in generating forces. +# It also details the initial placement of molecules within a simulation box. +init = cript.Computation( + name="Initial snapshot and force-field generation", + type="initialization", + software_configuration=[ + python_config, + rdkit_config, + stage_config, + packmol_config, + openmm_config, + ], +) + +# Initiate the simulation equilibration using a separate node. +# The equilibration process is governed by specific conditions and a set equilibration time. +# Given this is an NPT (Number of particles, Pressure, Temperature) simulation, conditions such as the number of chains, temperature, and pressure are specified. +equilibration = cript.Computation( + name="Equilibrate data prior to measurement", + type="MD", + software_configuration=[python_config, openmm_config], + condition=[ + cript.Condition(key="time_duration", type="value", value=100.0, unit="ns"), + cript.Condition(key="temperature", type="value", value=450.0, unit="K"), + cript.Condition(key="pressure", type="value", value=1.0, unit="bar"), + cript.Condition(key="number", type="value", value=31), + ], + prerequisite_computation=init, +) + +# This section involves the actual data measurement. +# Note that we use the previously computed data as a prerequisite. Additionally, we incorporate the input data at a later stage. +bulk = cript.Computation( + name="Bulk simulation for measurement", + type="MD", + software_configuration=[python_config, openmm_config], + condition=[ + cript.Condition(key="time_duration", type="value", value=50.0, unit="ns"), + cript.Condition(key="temperature", type="value", value=450.0, unit="K"), + cript.Condition(key="pressure", type="value", value=1.0, unit="bar"), + cript.Condition(key="number", type="value", value=31), + ], + prerequisite_computation=equilibration, +) + +# The following step involves analyzing the data from the measurement run to ascertain a specific property. +ana = cript.Computation( + name="Density analysis", + type="analysis", + software_configuration=[python_config], + prerequisite_computation=bulk, +) + +# Add all these computations to the experiment. +experiment.computation += [init, equilibration, bulk, ana] +``` + + +!!! note "Computation types" + The allowed [`Computation`](../../nodes/primary_nodes/computation) types are listed under [computation types](https://criptapp.org/keys/computation-type/) in the CRIPT controlled vocabulary. + +!!! note "Condition keys" + The allowed [`Condition`](../../nodes/subobjects/condition) keys are listed under [condition keys](https://criptapp.org/keys/condition-key/) in the CRIPT controlled vocabulary. + + +## Create and Upload [Files nodes](../../nodes/supporting_nodes/file) + +New we'd like to upload files associated with our simulation. First, we'll instantiate our File nodes under a specific project. + +```python +packing_file = cript.File(name="Initial simulation box snapshot with roughly packed molecules", type="computation_snapshot", source="path/to/local/file") +forcefield_file = cript.File(name="Forcefield definition file", type="data", source="path/to/local/file") +snap_file = cript.File(name="Bulk measurement initial system snap shot", type="computation_snapshot", source="path/to/local/file") +final_file = cript.File(name="Final snapshot of the system at the end the simulations", type="computation_snapshot", source="path/to/local/file") +``` + +!!! note + The [source field](../../nodes/supporting_nodes/file/#cript.nodes.supporting_nodes.file.File.source) should point to any file on your local filesystem + or a web URL to where the file can be found. + + > For example, + > [CRIPT protein JSON file on CRIPTScripts](https://criptscripts.org/cript_graph_json/JSON/cao_protein.json) + +Note, that we haven't uploaded the files to CRIPT yet, this is automatically performed, when the project is uploaded via `api.save(project)`. + + +## Create Data + +Next, we'll create a [`Data`](../../nodes/primary_nodes/data) node which helps organize our [`File`](../../nodes/supporting_nodes/file) nodes and links back to our [`Computation`](../../nodes/primary_nodes/computation) objects. + +```python +packing_data = cript.Data( + name="Loosely packed chains", + type="computation_config", + file=[packing_file], + computation=[init], + notes="PDB file without topology describing an initial system.", +) + +forcefield_data = cript.Data( + name="OpenMM forcefield", + type="computation_forcefield", + file=[forcefield_file], + computation=[init], + notes="Full forcefield definition and topology.", +) + +equilibration_snap = cript.Data( + name="Equilibrated simulation snapshot", + type="computation_config", + file=[snap_file], + computation=[equilibration], +) + +final_data = cript.Data( + name="Logged volume during simulation", + type="computation_trajectory", + file=[final_file], + computation=[bulk], +) +``` + +!!! note "Data types" + The allowed [`Data`](../../nodes/primary_nodes/data) types are listed under the [data types](https://criptapp.org/keys/data-type/) in the CRIPT controlled vocabulary. + +Next, we'll link these [`Data`](../../nodes/primary_nodes/data) nodes to the appropriate [`Computation`](../../nodes/primary_nodes/computation) nodes. + +```python + +# Observe how this step also forms a continuous graph, enabling data to flow from one computation to the next. +# The sequence initiates with the computation process and culminates with the determination of the material property. +init.output_data = [packing_data, forcefield_data] +equilibration.input_data = [packing_data, forcefield_data] +equilibration.output_data = [equilibration_snap] +ana.input_data = [final_data] +bulk.output_data = [final_data] +``` + +## Create a virtual Material + +First, we'll create a virtual material and add some +[`Identifiers`](../../nodes/primary_nodes/material/#cript.nodes.primary_nodes.material.Material.identifiers) +to the material to make it easier to identify and search. + +```python +# create identifier dictionaries and put it in `identifiers` variable +identifiers = [{"names": ["poly(styrene)", "poly(vinylbenzene)"]}] +identifiers += [{"bigsmiles": "[H]{[>][<]C(C[>])c1ccccc1[<]}C(C)CC"}] +identifiers += [{"chem_repeat": ["C8H8"]}] + +# create a material node object with identifiers +polystyrene = cript.Material(name="virtual polystyrene", identifiers=identifiers) +``` + +!!! note "Identifier keys" + The allowed [`Identifiers`](../../nodes/primary_nodes/material/#cript.nodes.primary_nodes.material.Material.identifiers) keys are listed in the [material identifier keys](https://criptapp.org/keys/material-identifier-key/) in the CRIPT controlled vocabulary. + +## Add [`Property`](../../nodes/subobjects/property) sub-objects +Let's also add some [`Property`](../../nodes/subobjects/property) nodes to the [`Material`](../../nodes/primary_nodes/material), which represent its physical or virtual (in the case of a simulated material) properties. + +```python +phase = cript.Property(key="phase", value="solid", type="none", unit=None) +color = cript.Property(key="color", value="white", type="none", unit=None) + +polystyrene.property += [phase] +polystyrene.property += [color] +``` + +!!! note "Material property keys" + The allowed material [`Property`](../../nodes/subobjects/property) keys are listed in the [material property keys](https://criptapp.org/keys/material-property-key/) in the CRIPT controlled vocabulary. + +## Create [`ComputationalForcefield`](../../nodes/subobjects/computational_forcefield) +Finally, we'll create a [`ComputationalForcefield`](../../nodes/subobjects/computational_forcefield) node and link it to the Material. + + +```python +forcefield = cript.ComputationalForcefield( + key="opls_aa", + building_block="atom", + source="Custom determination via STAGE", + data=[forcefield_data], +) + +polystyrene.computational_forcefield = forcefield +``` + +!!! note "Computational forcefield keys" + The allowed [`ComputationalForcefield`](../../nodes/subobjects/computational_forcefield/) keys are listed under the [computational forcefield keys](https://criptapp.org/keys/computational-forcefield-key/) in the CRIPT controlled vocabulary. + +Now we can save the project to CRIPT (and upload the files) or inspect the JSON output +## Validate CRIPT Project Node +```python +# Before we can save it, we should add all the orphaned nodes to the experiments. +# It is important to do this for every experiment separately, but here we only have one. +cript.add_orphaned_nodes_to_project(project, active_experiment=experiment) +project.validate() + +# api.save(project) +print(project.get_json(indent=2).json) + +# Let's not forget to close the API connection after everything is done. +api.disconnect() +``` + +## Conclusion + +You made it! We hope this tutorial has been helpful. + +Please let us know how you think it could be improved. +Feel free to reach out to us on our [CRIPT Python SDK GitHub](https://github.com/C-Accel-CRIPT/Python-SDK). +We'd love your inputs and contributions! \ No newline at end of file diff --git a/docs/examples/synthesis.md b/docs/examples/synthesis.md new file mode 100644 index 000000000..62bded0ef --- /dev/null +++ b/docs/examples/synthesis.md @@ -0,0 +1,296 @@ +--- +jupyter: + jupytext: + cell_metadata_filter: -all + formats: ipynb,md + text_representation: + extension: .md + format_name: markdown + format_version: "1.3" + jupytext_version: 1.13.6 + kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +!!! abstract + This tutorial guides you through an example material synthesis workflow using the + [CRIPT Python SDK](https://pypi.org/project/cript/). + + +## Installation + +Before you start, be sure the [cript python package](https://pypi.org/project/cript/) is installed. + +```bash +pip install cript +``` + +## Connect to CRIPT + +To connect to CRIPT, you must enter a `host` and an `API Token`. For most users, `host` will be `https://criptapp.org`. + +!!! Warning "Keep API Token Secure" + + To ensure security, avoid storing sensitive information like tokens directly in your code. + Instead, use environment variables. + Storing tokens in code shared on platforms like GitHub can lead to security incidents. + Anyone that possesses your token can impersonate you on the [CRIPT](https://criptapp.org/) platform. + Consider [alternative methods for loading tokens with the CRIPT API Client](https://c-accel-cript.github.io/Python-SDK/api/api/#cript.api.api.API.__init__). + In case your token is exposed be sure to immediately generate a new token to revoke the access of the old one + and keep the new token safe. + +```python +import cript + +with cript.API(host="https://api.criptapp.org/", api_token="123456", storage_token="987654") as api: + pass +``` + +!!! note + + You may notice, that we are not executing any code inside the context manager block. + If you were to write a python script, compared to a jupyter notebook, you would add all the following code inside that block. + Here in a jupyter notebook, we need to connect manually. We just have to remember to disconnect at the end. + +```python +api = cript.API(host="https://api.criptapp.org/", api_token=None, storage_token="123456") +api = api.connect() +``` + +## Create a Project + +All data uploaded to CRIPT must be associated with a [project](../../nodes/primary_nodes/project) node. +[Project](../../nodes/primary_nodes/project) can be thought of as an overarching research goal. +For example, finding a replacement for an existing material from a sustainable feedstock. + +```python +# create a new project in the CRIPT database +project = cript.Project(name="My first project.") +``` + +## Create a Collection node + +For this project, you can create multiple collections, which represent a set of experiments. +For example, you can create a collection for a specific manuscript, +or you can create a collection for initial screening of candidates and one for later refinements etc. + +So, let's create a collection node and add it to the project. + +```python +collection = cript.Collection(name="Initial screening") +# We add this collection to the project as a list. +project.collection += [collection] +``` + +!!! note "Viewing CRIPT JSON" + + Note, that if you are interested into the inner workings of CRIPT, + you can obtain a JSON representation of your data graph at any time to see what is being sent to the API. + +```python +print(project.json) +print("\nOr more pretty\n") +print(project.get_json(indent=2).json) +``` + +## Create an Experiment node + +The [collection node](../../nodes/primary_nodes/collection) holds a series of +[Experiment nodes](../../nodes/primary_nodes/experiment) nodes. + +And we can add this experiment to the collection of the project. + +```python +experiment = cript.Experiment(name="Anionic Polymerization of Styrene with SecBuLi") +collection.experiment += [experiment] +``` + +## Create an Inventory + +An [Inventory](../../nodes/primary_nodes/inventory) contains materials, +that are well known and usually not of polymeric nature. +They are for example the chemical you buy commercially and use as input into your synthesis. + +For this we create this inventory by adding the [Material](../../nodes/primary_nodes/material) we need one by one. + +```python +# create a list of identifiers as dictionaries to +# identify your material to the community and your team +my_solution_material_identifiers = [ + {"chemical_id": "598-30-1"} +] + +solution = cript.Material( + name="SecBuLi solution 1.4M cHex", + identifiers=my_solution_material_identifiers +) +``` + +These materials are simple, notice how we use the SMILES notation here as an identifier for the material. +Similarly, we can create more initial materials. + +```python +toluene = cript.Material(name="toluene", identifiers=[{"smiles": "Cc1ccccc1"}, {"pubchem_id": 1140}]) +styrene = cript.Material(name="styrene", identifiers=[{"smiles": "c1ccccc1C=C"}, {"inchi": "InChI=1S/C8H8/c1-2-8-6-4-3-5-7-8/h2-7H,1H2"}]) +butanol = cript.Material(name="1-butanol", identifiers=[{"smiles": "OCCCC"}, {"inchi_key": "InChIKey=LRHPLDYGYMQRHN-UHFFFAOYSA-N"}]) +methanol = cript.Material(name="methanol", identifiers=[{"smiles": "CO"}, {"names": ["Butan-1-ol", "Butyric alcohol", "Methylolpropane", "n-Butan-1-ol", "methanol"]}]) +``` + +Now that we defined those materials, we can combine them into an inventory +for easy access and sharing between experiments/projects. + +```python +inventory = cript.Inventory( + name="Common chemicals for poly-styrene synthesis", + material=[solution, toluene, styrene, butanol, methanol], +) +collection.inventory += [inventory] +``` + +## Create a Process node + +A [Process](../../nodes/primary_nodes/process) is a step in an experiment. +You decide how many [Process](../../nodes/primary_nodes/process) are required for your experiment, +so you can list details for your experiment as fine-grained as desired. +Here we use just one step to describe the entire synthesis. + +```python +process = cript.Process( + name="Anionic of Synthesis Poly-Styrene", + type="multistep", + description="In an argon filled glove box, a round bottom flask was filled with 216 ml of dried toluene. The " + "solution of secBuLi (3 ml, 3.9 mmol) was added next, followed by styrene (22.3 g, 176 mmol) to " + "initiate the polymerization. The reaction mixture immediately turned orange. After 30 min, " + "the reaction was quenched with the addition of 3 ml of methanol. The polymer was isolated by " + "precipitation in methanol 3 times and dried under vacuum.", +) +experiment.process += [process] +``` + +## Add Ingredients to a Process + +From a chemistry standpoint, most experimental processes, regardless of whether they are carried out in the lab +or simulated using computer code, consist of input ingredients that are transformed in some way. +Let's add ingredients to the [Process](../../nodes/primary_nodes/process) that we just created. +For this we use the materials from the inventory. +Next, define [Quantities](../../nodes/subobjects/quantity) nodes indicating the amount of each +[Ingredient](../../nodes/subobjects/ingredient) that we will use in the [Process](../../nodes/primary_nodes/process). + +```python +initiator_qty = cript.Quantity(key="volume", value=1.7e-8, unit="m**3") +solvent_qty = cript.Quantity(key="volume", value=1e-4, unit="m**3") +monomer_qty = cript.Quantity(key="mass", value=0.455e-3, unit="kg") +quench_qty = cript.Quantity(key="volume", value=5e-3, unit="m**3") +workup_qty = cript.Quantity(key="volume", value=0.1, unit="m**3") +``` + +Now we can create an [Ingredient](../../nodes/subobjects/ingredient) +node for each ingredient using the [Material](../../nodes/primary_nodes/material) +and [quantities](../../nodes/subobjects/quantity) attributes. + +```python +initiator = cript.Ingredient( + keyword=["initiator"], material=solution, quantity=[initiator_qty] +) + +solvent = cript.Ingredient( + keyword=["solvent"], material=toluene, quantity=[solvent_qty] +) + +monomer = cript.Ingredient( + keyword=["monomer"], material=styrene, quantity=[monomer_qty] +) + +quench = cript.Ingredient( + keyword=["quench"], material=butanol, quantity=[quench_qty] +) + +workup = cript.Ingredient( + keyword=["workup"], material=methanol, quantity=[workup_qty] +) + +``` + +Finally, we can add the `Ingredient` nodes to the `Process` node. + +```python +process.ingredient += [initiator, solvent, monomer, quench, workup] +``` + +## Add Conditions to the Process + +Its possible that our `Process` was carried out under specific physical conditions. We can codify this by adding +[Condition](../../nodes/subobjects/condition) nodes to the process. + +```python +temp = cript.Condition(key="temperature", type="value", value=25, unit="celsius") +time = cript.Condition(key="time_duration", type="value", value=60, unit="min") +process.condition = [temp, time] +``` + +## Add a Property to a Process + +We may also want to associate our process with certain properties. We can do this by adding +[Property](../../nodes/subobjects/property) nodes to the process. + +```python +yield_mass = cript.Property(key="yield_mass", type="number", value=47e-5, unit="kilogram", method="scale") +process.property += [yield_mass] +``` + +## Create a Material node (process product) + +Along with input [Ingredients](../../nodes/subobjects/ingredient), our [Process](../../nodes/primary_nodes/process) +may also produce product materials. + +First, let's create the [Material](../../nodes/primary_nodes/material) +that will serve as our product. We give the material a `name` attribute and add it to our +[Project]((../../nodes/primary_nodes/project). + +```python +polystyrene = cript.Material(name="polystyrene", identifiers=[]) +project.material += [polystyrene] +``` + +Let's add some `Identifiers` to the material to make it easier to identify and search. + +```python +# create a name identifier +polystyrene.identifiers += [{"names": ["poly(styrene)", "poly(vinylbenzene)"]}] + +# create a BigSMILES identifier +polystyrene.identifiers += [{"bigsmiles": "[H]{[>][<]C(C[>])c1ccccc1[<]}C(C)CC"}] +# create a chemical repeat unit identifier +polystyrene.identifiers += [{"chem_repeat": ["C8H8"]}] +``` + +Next, we'll add some [Property](../../nodes/subobjects/property) nodes to the +[Material](../../nodes/primary_nodes/material) , which represent its physical or virtual +(in the case of a simulated material) properties. + +```python +# create a phase property +phase = cript.Property(key="phase", value="solid", type="none", unit=None) +# create a color property +color = cript.Property(key="color", value="white", type="none", unit=None) + +# add the properties to the material +polystyrene.property += [phase, color] +``` + +**Congratulations!** You've just created a process that represents the polymerization reaction of Polystyrene, starting with a set of input ingredients in various quantities, and ending with a new polymer with specific identifiers and physical properties. + +Now we can save the project to CRIPT via the api object. + +```python +project.validate() +print(project.get_json(indent=2, condense_to_uuid={}).json) +# api.save(project) +``` + +```python +# Don't forget to disconnect once everything is done +api.disconnect() +``` diff --git a/docs/exceptions/api_exceptions.md b/docs/exceptions/api_exceptions.md new file mode 100644 index 000000000..ece28dddc --- /dev/null +++ b/docs/exceptions/api_exceptions.md @@ -0,0 +1,3 @@ +## API Client Exceptions + +::: cript.api.exceptions diff --git a/docs/exceptions/node_exceptions.md b/docs/exceptions/node_exceptions.md new file mode 100644 index 000000000..3f0e185e8 --- /dev/null +++ b/docs/exceptions/node_exceptions.md @@ -0,0 +1,3 @@ +# Node Exceptions + +::: cript.nodes.exceptions diff --git a/docs/extra.css b/docs/extra.css new file mode 100644 index 000000000..93eb0db23 --- /dev/null +++ b/docs/extra.css @@ -0,0 +1,3 @@ +.screenshot-border { + border: black solid 0.1rem; +} diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 000000000..638f0ba5e --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,87 @@ +# Frequently Asked Questions + +
+ +**Q:** Where can I find more information about the [CRIPT](https://criptapp.org) data model? + +**A:** _Please feel free to review the +[CRIPT data model document](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf) +and the [CRIPT research paper](https://pubs.acs.org/doi/10.1021/acscentsci.3c00011)_ + +--- + +**Q:** What does this error mean? + +**A:** _Please visit the Exceptions documentation_ + +--- + +**Q:** Where do I report an issue that I encountered? + +**A:** _Please feel free to report issues to our [GitHub repository](https://github.com/C-Accel-CRIPT/Python-SDK)._ +_We are always looking for ways to improve and create software that is a joy to use!_ + +--- + +**Q:** Where can I find more CRIPT examples? + +**A:** _Please visit [CRIPT Scripts](https://criptscripts.org) where there are many CRIPT examples ranging from CRIPT graphs drawn out from research papers, Python scripts, TypeScript scripts, and more!_ + +--- + +**Q:** Where can I find more example code? + +**A:** _We have written a lot of tests for our software, and if needed, those tests can be referred to as example code to work with the Python SDK software. The Python SDK tests are located within the [GitHub repository/tests](https://github.com/C-Accel-CRIPT/Python-SDK/tree/main/tests), and there they are broken down to different kinds of tests_ + +--- + +**Q:** How can I contribute to this project? + +**A:** _We would love to have you contribute. +Please read the[GitHub repository wiki](https://github.com/C-Accel-CRIPT/Python-SDK/wiki) +to understand more and get started. Feel free to contribute to any bugs you find, any issues within the +[GitHub repository](https://github.com/C-Accel-CRIPT/Python-SDK/issues), or any features you want._ + +--- + +**Q:** This repository is awesome, how can I build a plugin to add to it? + +**A:** _We have built this code with plugins in mind! Please visit the +[CRIPT Python SDK GitHub repository Wiki](https://github.com/C-Accel-CRIPT/Python-SDK/wiki) +tab for developer documentation._ + +--- + +**Q:** I have this question that is not covered anywhere, where can I ask it? + +**A:** _Please visit the [CRIPT Python SDK repository](https://github.com/C-Accel-CRIPT/Python-SDK) +and ask your question within the +[discussions tab Q/A section](https://github.com/C-Accel-CRIPT/Python-SDK/discussions/categories/q-a)_ + +--- + +**Q:** Where is the best place where I can contact the CRIPT Python SDK team for questions or support? + +**A:** _We would love to hear from you! Please visit our [CRIPT Python SDK Repository GitHub Discussions](https://github.com/C-Accel-CRIPT/cript-excel-uploader/discussions) to easily send us questions. +Our [repository's issue page](https://github.com/C-Accel-CRIPT/Python-SDK/issues) is also another good way to let us know about any issues or suggestions you might have. +A GitHub account is required._ + +--- + +**Q:** How can I report security issues? + +**A:** _Please visit the [CRIPT Python SDK GitHub repository security tab](https://github.com/C-Accel-CRIPT/Python-SDK/security) for any security issues._ + +--- + +**Q:** Besides the user documentation are there any developer documentation that I can read through on how +the code is written to get a better grasp of it? + +**A:** _You bet! There are documentation for developers within the +[CRIPT Python SDK Wiki](https://github.com/C-Accel-CRIPT/Python-SDK/wiki). +There you will find documentation on everything from how our code is structure, +how we aim to write our documentation, CI/CD, and more._ + +_We try to also have type hinting, comments, and docstrings for all the code that we work on so it is clear and easy for anyone reading it to easily understand._ + +_if all else fails, contact us on our [GitHub Repository](https://github.com/C-Accel-CRIPT/Python-SDK)._ diff --git a/docs/images/CRIPT_full_logo_colored_transparent.png b/docs/images/CRIPT_full_logo_colored_transparent.png new file mode 100644 index 000000000..942727248 Binary files /dev/null and b/docs/images/CRIPT_full_logo_colored_transparent.png differ diff --git a/docs/images/cript_token_page.png b/docs/images/cript_token_page.png new file mode 100644 index 000000000..a7d86d57f Binary files /dev/null and b/docs/images/cript_token_page.png differ diff --git a/docs/images/favicon.ico b/docs/images/favicon.ico new file mode 100644 index 000000000..38e43d6b5 Binary files /dev/null and b/docs/images/favicon.ico differ diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..458bf4e23 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,27 @@ +![CRIPT Logo](./images/CRIPT_full_logo_colored_transparent.png) + +**CRIPT** (the _Community Resource for Innovation in Polymer Technology_) is a web-based platform for capturing and sharing polymer data. In addition to a user interface, CRIPT enables programmatic access to the platform through the CRIPT Python SDK, which interfaces with a REST API. + +CRIPT offers multiple options to upload data, and scientists can pick the method that best suits them. Using the SDK to upload is a great choice if you have a large amount of data, stored it in an unconventional way, and know some python programming. You can easily use a library such as [Pandas](https://pandas.pydata.org/) or [Numpy](https://numpy.org/) to parse your data, create the needed CRIPT objects/nodes and upload them into CRIPT. + +Another great option can be the [Excel Uploader](https://c-accel-cript.github.io/cript-excel-uploader/) for scientists that do not have past Python experience or would rather easily input their data into the CRIPT Excel Template. + +--- + +## Resources + +??? info "CRIPT Resources" + + - [CRIPT Data Model](https://chemrxiv.org/engage/api-gateway/chemrxiv/assets/orp/resource/item/6322994103e27d9176d5b10c/original/main-supporting-information.pdf) + - The CRIPT Data Model is the back bone of the whole CRIPT project. Understanding it will make it a lot easier to use any part of the system + - [CRIPT Scripts Research paper](https://pubs.acs.org/doi/10.1021/acscentsci.3c00011) + - Learn about the CRIPT platform + - [CRIPTScripts](https://criptscripts.org/) + - CRIPT Scripts is a curated list of examples and tools for interacting with the CRIPT platform. + - [CRIPT Python SDK Internal Documentation](https://github.com/C-Accel-CRIPT/Python-SDK/wiki) + - Learn more about the internal workings of the CRIPT Python SDK + - [CRIPT Python SDK Discussions Tab](https://github.com/C-Accel-CRIPT/Python-SDK/discussions) + - Communicate with the CRIPT Python SDK team + - [CRIPT Python SDK Contributing Guidelines](https://github.com/C-Accel-CRIPT/Python-SDK/blob/develop/CONTRIBUTING.md) + - Learn how to contribute to the CRIPT Python SDK open-source project + - [CRIPT Python SDK Contributors](https://github.com/C-Accel-CRIPT/Python-SDK/blob/develop/CONTRIBUTORS.md) diff --git a/docs/nodes/primary_nodes/base_node.md b/docs/nodes/primary_nodes/base_node.md new file mode 100644 index 000000000..caf38df1e --- /dev/null +++ b/docs/nodes/primary_nodes/base_node.md @@ -0,0 +1 @@ +# Base node diff --git a/docs/nodes/primary_nodes/collection.md b/docs/nodes/primary_nodes/collection.md new file mode 100644 index 000000000..b9bfb24e0 --- /dev/null +++ b/docs/nodes/primary_nodes/collection.md @@ -0,0 +1 @@ +::: cript.nodes.primary_nodes.collection diff --git a/docs/nodes/primary_nodes/computation.md b/docs/nodes/primary_nodes/computation.md new file mode 100644 index 000000000..3e500dd53 --- /dev/null +++ b/docs/nodes/primary_nodes/computation.md @@ -0,0 +1 @@ +::: cript.nodes.primary_nodes.computation diff --git a/docs/nodes/primary_nodes/computation_process.md b/docs/nodes/primary_nodes/computation_process.md new file mode 100644 index 000000000..c9b594fd7 --- /dev/null +++ b/docs/nodes/primary_nodes/computation_process.md @@ -0,0 +1 @@ +::: cript.nodes.primary_nodes.computation_process diff --git a/docs/nodes/primary_nodes/data.md b/docs/nodes/primary_nodes/data.md new file mode 100644 index 000000000..76a48efb6 --- /dev/null +++ b/docs/nodes/primary_nodes/data.md @@ -0,0 +1 @@ +::: cript.nodes.primary_nodes.data diff --git a/docs/nodes/primary_nodes/experiment.md b/docs/nodes/primary_nodes/experiment.md new file mode 100644 index 000000000..96f684344 --- /dev/null +++ b/docs/nodes/primary_nodes/experiment.md @@ -0,0 +1 @@ +::: cript.nodes.primary_nodes.experiment diff --git a/docs/nodes/primary_nodes/inventory.md b/docs/nodes/primary_nodes/inventory.md new file mode 100644 index 000000000..fdd1e309d --- /dev/null +++ b/docs/nodes/primary_nodes/inventory.md @@ -0,0 +1 @@ +::: cript.nodes.primary_nodes.inventory diff --git a/docs/nodes/primary_nodes/material.md b/docs/nodes/primary_nodes/material.md new file mode 100644 index 000000000..fb0417719 --- /dev/null +++ b/docs/nodes/primary_nodes/material.md @@ -0,0 +1 @@ +::: cript.nodes.primary_nodes.material diff --git a/docs/nodes/primary_nodes/process.md b/docs/nodes/primary_nodes/process.md new file mode 100644 index 000000000..1fb86b54a --- /dev/null +++ b/docs/nodes/primary_nodes/process.md @@ -0,0 +1 @@ +::: cript.nodes.primary_nodes.process diff --git a/docs/nodes/primary_nodes/project.md b/docs/nodes/primary_nodes/project.md new file mode 100644 index 000000000..3aaa85b06 --- /dev/null +++ b/docs/nodes/primary_nodes/project.md @@ -0,0 +1 @@ +::: cript.nodes.primary_nodes.project diff --git a/docs/nodes/primary_nodes/reference.md b/docs/nodes/primary_nodes/reference.md new file mode 100644 index 000000000..dc4fe1fad --- /dev/null +++ b/docs/nodes/primary_nodes/reference.md @@ -0,0 +1 @@ +::: cript.nodes.primary_nodes.Reference diff --git a/docs/nodes/primary_nodes/software.md b/docs/nodes/primary_nodes/software.md new file mode 100644 index 000000000..603bcc50b --- /dev/null +++ b/docs/nodes/primary_nodes/software.md @@ -0,0 +1 @@ +::: cript.Software diff --git a/docs/nodes/subobjects/algorithm.md b/docs/nodes/subobjects/algorithm.md new file mode 100644 index 000000000..794860b48 --- /dev/null +++ b/docs/nodes/subobjects/algorithm.md @@ -0,0 +1 @@ +::: cript.nodes.subobjects.Algorithm diff --git a/docs/nodes/subobjects/citation.md b/docs/nodes/subobjects/citation.md new file mode 100644 index 000000000..7e5b5d522 --- /dev/null +++ b/docs/nodes/subobjects/citation.md @@ -0,0 +1 @@ +::: cript.nodes.subobjects.citation diff --git a/docs/nodes/subobjects/computational_forcefield.md b/docs/nodes/subobjects/computational_forcefield.md new file mode 100644 index 000000000..3896b9ad9 --- /dev/null +++ b/docs/nodes/subobjects/computational_forcefield.md @@ -0,0 +1 @@ +::: cript.nodes.subobjects.computational_forcefield diff --git a/docs/nodes/subobjects/condition.md b/docs/nodes/subobjects/condition.md new file mode 100644 index 000000000..2d1e05143 --- /dev/null +++ b/docs/nodes/subobjects/condition.md @@ -0,0 +1 @@ +::: cript.nodes.subobjects.condition diff --git a/docs/nodes/subobjects/equipment.md b/docs/nodes/subobjects/equipment.md new file mode 100644 index 000000000..662eaeba3 --- /dev/null +++ b/docs/nodes/subobjects/equipment.md @@ -0,0 +1 @@ +::: cript.nodes.subobjects.equipment diff --git a/docs/nodes/subobjects/identifier.md b/docs/nodes/subobjects/identifier.md new file mode 100644 index 000000000..8ebe64b88 --- /dev/null +++ b/docs/nodes/subobjects/identifier.md @@ -0,0 +1 @@ +# Identifier Subobject diff --git a/docs/nodes/subobjects/ingredient.md b/docs/nodes/subobjects/ingredient.md new file mode 100644 index 000000000..13ae0cd33 --- /dev/null +++ b/docs/nodes/subobjects/ingredient.md @@ -0,0 +1 @@ +::: cript.nodes.subobjects.ingredient diff --git a/docs/nodes/subobjects/parameter.md b/docs/nodes/subobjects/parameter.md new file mode 100644 index 000000000..f09929fad --- /dev/null +++ b/docs/nodes/subobjects/parameter.md @@ -0,0 +1 @@ +::: cript.nodes.subobjects.parameter diff --git a/docs/nodes/subobjects/property.md b/docs/nodes/subobjects/property.md new file mode 100644 index 000000000..1fba3646b --- /dev/null +++ b/docs/nodes/subobjects/property.md @@ -0,0 +1 @@ +::: cript.nodes.subobjects.property diff --git a/docs/nodes/subobjects/quantity.md b/docs/nodes/subobjects/quantity.md new file mode 100644 index 000000000..f42fe2ee4 --- /dev/null +++ b/docs/nodes/subobjects/quantity.md @@ -0,0 +1 @@ +::: cript.nodes.subobjects.quantity diff --git a/docs/nodes/subobjects/software_configuration.md b/docs/nodes/subobjects/software_configuration.md new file mode 100644 index 000000000..e6148efd3 --- /dev/null +++ b/docs/nodes/subobjects/software_configuration.md @@ -0,0 +1 @@ +::: cript.nodes.subobjects.software_configuration diff --git a/docs/nodes/supporting_nodes/file.md b/docs/nodes/supporting_nodes/file.md new file mode 100644 index 000000000..5a2e74555 --- /dev/null +++ b/docs/nodes/supporting_nodes/file.md @@ -0,0 +1 @@ +::: cript.nodes.supporting_nodes.file diff --git a/docs/nodes/supporting_nodes/group.md b/docs/nodes/supporting_nodes/group.md new file mode 100644 index 000000000..1d42e1bc4 --- /dev/null +++ b/docs/nodes/supporting_nodes/group.md @@ -0,0 +1 @@ +# Group Node diff --git a/docs/nodes/supporting_nodes/user.md b/docs/nodes/supporting_nodes/user.md new file mode 100644 index 000000000..f875c44aa --- /dev/null +++ b/docs/nodes/supporting_nodes/user.md @@ -0,0 +1 @@ +::: cript.nodes.supporting_nodes.user diff --git a/docs/tutorial/cript_installation_guide.md b/docs/tutorial/cript_installation_guide.md new file mode 100644 index 000000000..9608d4f2a --- /dev/null +++ b/docs/tutorial/cript_installation_guide.md @@ -0,0 +1,55 @@ +# How to Install CRIPT + +!!! abstract + + This page will give you a through guide on how to install the + [CRIPT Python SDK](https://pypi.org/project/cript/) on your system. + +## Steps + +1. Install [Python 3.7+](https://www.python.org/downloads/) +2. Create a virtual environment + + > It is best practice to create a dedicated [python virtual environment](https://docs.python.org/3/library/venv.html) for each python project + + === ":fontawesome-brands-windows: **_Windows:_**" + ```bash + python -m venv .\venv + ``` + + === ":fontawesome-brands-apple: **_Mac_** & :fontawesome-brands-linux: **_Linux:_**" + ```bash + python3 -m venv ./venv + ``` + +3. Activate your virtual environment + + === ":fontawesome-brands-windows: **_Windows:_**" + ```bash + .\venv\Scripts\activate + ``` + + === ":fontawesome-brands-apple: **_Mac_** & :fontawesome-brands-linux: **_Linux:_**" + ```bash + source venv/bin/activate + ``` + +4. Install [CRIPT from Python Package Index (PyPI)](https://pypi.org/project/cript/) + ```bash + pip install cript + ``` +5. Create your CRIPT Script! + + +??? info "Install Package From our [GitHub](https://github.com/C-Accel-CRIPT/Python-SDK)" + Please note that it is also possible to install this package from our + [GitHub](https://github.com/C-Accel-CRIPT/Python-SDK). + + Formula: `pip install git+[repository URL]@[branch or tag]` + + Install from [Main](https://github.com/C-Accel-CRIPT/Python-SDK/tree/main): + `pip install git+https://github.com/C-Accel-CRIPT/Python-SDK@main` + + or to download the latest in [development code](https://github.com/C-Accel-CRIPT/Python-SDK/tree/develop) + `pip install git+https://github.com/C-Accel-CRIPT/Python-SDK@develop` + diff --git a/docs/tutorial/how_to_get_api_token.md b/docs/tutorial/how_to_get_api_token.md new file mode 100644 index 000000000..7e38f156a --- /dev/null +++ b/docs/tutorial/how_to_get_api_token.md @@ -0,0 +1,43 @@ +!!! abstract + + This page shows the steps to acquiring an API Token to connect to the [CRIPT platform](https://criptapp.org) + +
+ +The token is needed because we need to authenticate the user before saving any of their data + +!!! Warning "Token Security" + It is **highly** recommended that you store your API tokens in a safe location and read it into your code + Hard-coding API tokens directly into the code can pose security risks, + as the token might be exposed if the code is shared or stored in a version control system. + + Anyone that has access to your tokens can impersonate you on the [CRIPT platform](https://criptapp.org) + +Screenshot of CRIPT security page where API token is found + + + [Security Settings](https://criptapp.org/security/) + under the profile icon dropdown + + + +To get your token: + +1. please visit your [Security Settings](https://criptapp.org/security/) under the profile + icon dropdown on the top right +2. Click on the **copy** button next to the API Token to copy it to clipboard +3. Now you can paste it into the `API Token` field + +Example: + + + + +```yaml +API Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c + +Storage Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gU21pdGgiLCJpYXQiOjE1MTYyMzkwMjJ9.Q_w2AVguPRU2KskCXwR7ZHl09TQXEntfEA8Jj2_Jyew +``` + + + diff --git a/docs/utility_functions.md b/docs/utility_functions.md new file mode 100644 index 000000000..2f9afbcf0 --- /dev/null +++ b/docs/utility_functions.md @@ -0,0 +1 @@ +::: cript.nodes.util diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000..1f5a3e13b --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,135 @@ +site_name: CRIPT Python SDK + +repo_url: https://github.com/C-Accel-CRIPT/Python-SDK +repo_name: C-Accel-CRIPT/Python-SDK + +nav: + - Home: index.md + - Tutorial: + - CRIPT Installation Guide: tutorial/cript_installation_guide.md + - CRIPT API Token: tutorial/how_to_get_api_token.md + - Example Code Walkthrough: + - Synthesis: examples/synthesis.md + - Simulation: examples/simulation.md + - API Client: + - API: api/api.md + - Search Modes: api/search_modes.md + - Paginator: api/paginator.md + - Controlled Vocabulary Categories: api/controlled_vocabulary_categories.md + - Primary Nodes: + - Collection: nodes/primary_nodes/collection.md + - Computation: nodes/primary_nodes/computation.md + - Computation Process: nodes/primary_nodes/computation_process.md + - Data: nodes/primary_nodes/data.md + - Experiment: nodes/primary_nodes/experiment.md + - Inventory: nodes/primary_nodes/inventory.md + - Material: nodes/primary_nodes/material.md + - Project: nodes/primary_nodes/project.md + - Process: nodes/primary_nodes/process.md + - Reference: nodes/primary_nodes/reference.md + - Software: nodes/primary_nodes/software.md + - Sub-objects: + - Algorithm: nodes/subobjects/algorithm.md + - Citation: nodes/subobjects/citation.md + - Computational Forcefield: nodes/subobjects/computational_forcefield.md + - Condition: nodes/subobjects/condition.md + - Equipment: nodes/subobjects/equipment.md + # - Identifier: nodes/subobjects/identifier.md + - Ingredient: nodes/subobjects/ingredient.md + - Parameter: nodes/subobjects/parameter.md + - Property: nodes/subobjects/property.md + - Quantity: nodes/subobjects/quantity.md + - Software Configuration: nodes/subobjects/software_configuration.md + - Supporting Nodes: + - User: nodes/supporting_nodes/user.md + # - Group: nodes/supporting_nodes/group.md + - File: nodes/supporting_nodes/file.md + - Utility Functions: utility_functions.md + - Exceptions: + - API Exceptions: exceptions/api_exceptions.md + - Node Exceptions: exceptions/node_exceptions.md + - FAQ: faq.md + - Internal Wiki Documentation: https://github.com/C-Accel-CRIPT/Python-SDK/wiki + - CRIPT Python SDK Discussions: https://github.com/C-Accel-CRIPT/Python-SDK/discussions + +theme: + name: material + # below is the favicon image and documentation logo + logo: ./images/CRIPT_full_logo_colored_transparent.png + favicon: ./images/favicon.ico + icon: + admonition: + alert: octicons/alert-16 + features: + - content.code.copy + - navigation.path + - nagivation.tracking + - navigation.footer + + palette: + # Palette toggle for light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: deep purple + accent: deep purple + toggle: + icon: material/brightness-7 + name: Switch to dark mode + # Palette toggle for dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: deep purple + accent: deep purple + toggle: + icon: material/brightness-4 + name: Switch to light mode + +# This links the CRIPT logo to the CRIPT homepage +extra: + homepage: https://criptapp.org +# social: +# - icon: fontawesome/brands/twitter +# link: https://twitter.com/squidfunk +# name: squidfunk on Twitter +copyright: © 2023 MIT | All Rights Reserved + +extra_css: + - extra.css + +plugins: + - search + - mkdocstrings: + default_handler: python + handlers: + python: + paths: [src, docs] + options: + show_bases: true + show_source: true + docstring_style: numpy + watch: + - src/ + +markdown_extensions: + - toc: + baselevel: 2 + permalink: True + - attr_list + - md_in_html + - admonition + - pymdownx.details + - pymdownx.superfences + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.critic + - pymdownx.caret + - pymdownx.keys + - pymdownx.mark + - pymdownx.tilde + - pymdownx.tabbed: + alternate_style: true + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..96c8f431e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=60", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 250 +include = '\.pyi?$' +exclude = ''' +/( + \.git + | \.hg + | \.ipynb + | \.mypy_cache + | \.pytest_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + | miniconda +)/ +''' + +[tool.ruff] +line-length = 250 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..d23e4db52 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests==2.31.0 +jsonschema==4.18.4 +boto3==1.28.17 +beartype==0.14.1 diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 000000000..b71f79050 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,10 @@ +-r requirements.txt +black==23.7.0 +mypy==1.4.1 +pytest==7.4.0 +pytest-cov==4.1.0 +coverage==7.2.7 +types-jsonschema==4.17.0.9 +types-requests==2.31.0.1 +types-boto3==1.0.2 +deepdiff==6.3.1 diff --git a/requirements_docs.txt b/requirements_docs.txt new file mode 100644 index 000000000..7174ca504 --- /dev/null +++ b/requirements_docs.txt @@ -0,0 +1,5 @@ +mkdocs==1.5.1 +mkdocs-material==9.1.21 +mkdocstrings[python]==0.22.0 +pymdown-extensions==10.1 +jupytext==1.15.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..aa8906c52 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,32 @@ +[metadata] +name = cript +version = 2.0.0 +description = CRIPT Python SDK +long_description = file: README.md +long_description_content_type = text/markdown +author = CRIPT Development Team +url = https://github.com/C-Accel-CRIPT/Python-SDK +license = MIT +license_files = LICENSE.md +platforms = any +classifiers = + Development Status :: 3 - Alpha + Topic :: Scientific/Engineering + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.7 + +[options] +package_dir = + =src +packages = find: +python_requires = >=3.7 +include_package_data = True +install_requires = + requests==2.31.0 + jsonschema==4.17.3 + beartype==0.14.1 + boto3==1.26.151 + +[options.packages.find] +where = src \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..7f1a1763c --- /dev/null +++ b/setup.py @@ -0,0 +1,4 @@ +from setuptools import setup + +if __name__ == "__main__": + setup() diff --git a/src/cript/__init__.py b/src/cript/__init__.py new file mode 100644 index 000000000..e4d49922d --- /dev/null +++ b/src/cript/__init__.py @@ -0,0 +1,40 @@ +# trunk-ignore-all(ruff/F401) +# trunk-ignore-all(ruff/E402) + +# TODO fix beartype warning for real +from warnings import filterwarnings + +from beartype.roar import BeartypeDecorHintPep585DeprecationWarning + +filterwarnings("ignore", category=BeartypeDecorHintPep585DeprecationWarning) + +from cript.api import API, SearchModes, VocabCategories +from cript.exceptions import CRIPTException +from cript.nodes import ( + Algorithm, + Citation, + Collection, + Computation, + ComputationalForcefield, + ComputationProcess, + Condition, + Data, + Equipment, + Experiment, + File, + Ingredient, + Inventory, + Material, + NodeEncoder, + Parameter, + Process, + Project, + Property, + Quantity, + Reference, + Software, + SoftwareConfiguration, + User, + add_orphaned_nodes_to_project, + load_nodes_from_json, +) diff --git a/src/cript/api/__init__.py b/src/cript/api/__init__.py new file mode 100644 index 000000000..fb3229f5c --- /dev/null +++ b/src/cript/api/__init__.py @@ -0,0 +1,5 @@ +# trunk-ignore-all(ruff/F401) + +from cript.api.api import API +from cript.api.valid_search_modes import SearchModes +from cript.api.vocabulary_categories import VocabCategories diff --git a/src/cript/api/api.py b/src/cript/api/api.py new file mode 100644 index 000000000..1beb49b6a --- /dev/null +++ b/src/cript/api/api.py @@ -0,0 +1,918 @@ +import copy +import json +import logging +import os +import uuid +import warnings +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + +import boto3 +import jsonschema +import requests +from beartype import beartype + +from cript.api.exceptions import ( + APIError, + CRIPTAPIRequiredError, + CRIPTAPISaveError, + CRIPTConnectionError, + InvalidHostError, + InvalidVocabulary, +) +from cript.api.paginator import Paginator +from cript.api.utils.get_host_token import resolve_host_and_token +from cript.api.utils.helper_functions import _get_node_type_from_json +from cript.api.utils.save_helper import ( + _fix_node_save, + _get_uuid_from_error_message, + _identify_suppress_attributes, + _InternalSaveValues, +) +from cript.api.utils.web_file_downloader import download_file_from_url +from cript.api.valid_search_modes import SearchModes +from cript.api.vocabulary_categories import VocabCategories +from cript.nodes.exceptions import CRIPTNodeSchemaError +from cript.nodes.primary_nodes.project import Project + +# Do not use this directly! That includes devs. +# Use the `_get_global_cached_api for access. +_global_cached_api = None + + +def _get_global_cached_api(): + """ + Read-Only access to the globally cached API object. + Raises an exception if no global API object is cached yet. + """ + if _global_cached_api is None: + raise CRIPTAPIRequiredError() + return _global_cached_api + + +class API: + """ + ## Definition + API Client class to communicate with the CRIPT API + + Attributes + ---------- + verbose : bool + A boolean flag that controls whether verbose logging is enabled or not. + + When `verbose` is set to `True`, the class will provide additional detailed logging + to the terminal. This can be useful for debugging and understanding the internal + workings of the class. + + When `verbose` is set to `False`, the class will only provide essential and concise + logging information, making the terminal output less cluttered and more user-friendly. + + ```python + # turn off the terminal logs + api.verbose = False + ``` + """ + + # dictates whether the user wants to see terminal log statements or not + verbose: bool = True + + _host: str = "" + _api_token: str = "" + _storage_token: str = "" + _http_headers: dict = {} + _vocabulary: dict = {} + _db_schema: dict = {} + _api_handle: str = "api" + _api_version: str = "v1" + + # trunk-ignore-begin(cspell) + # AWS S3 constants + _REGION_NAME: str = "us-east-1" + _IDENTITY_POOL_ID: str = "us-east-1:9426df38-994a-4191-86ce-3cb0ce8ac84d" + _COGNITO_LOGIN_PROVIDER: str = "cognito-idp.us-east-1.amazonaws.com/us-east-1_SZGBXPl2j" + _BUCKET_NAME: str = "cript-user-data" + _BUCKET_DIRECTORY_NAME: str = "python_sdk_files" + _internal_s3_client: Any = None # type: ignore + # trunk-ignore-end(cspell) + + @beartype + def __init__(self, host: Union[str, None] = None, api_token: Union[str, None] = None, storage_token: Union[str, None] = None, config_file_path: Union[str, Path] = ""): + """ + Initialize CRIPT API client with host and token. + Additionally, you can use a config.json file and specify the file path. + + !!! note "api client context manager" + It is necessary to use a `with` context manager for the API + + Examples + -------- + ### Create API client with host and token + ```Python + with cript.API( + host="https://api.criptapp.org/", + api_token="my api token", + storage_token="my storage token"), + ) as api: + # node creation, api.save(), etc. + ``` + + --- + + ### Creating API Client + !!! Warning "Token Security" + It is **highly** recommended that you store your API tokens in a safe location and read it into your code + Hard-coding API tokens directly into the code can pose security risks, + as the token might be exposed if the code is shared or stored in a version control system. + Anyone that has access to your tokens can impersonate you on the CRIPT platform + + ### Create API Client with + [Environment Variables](https://www.freecodecamp.org/news/python-env-vars-how-to-get-an-environment-variable-in-python/) + Another great way to keep sensitive information secure is by using + [environment variables](https://www.freecodecamp.org/news/python-env-vars-how-to-get-an-environment-variable-in-python/). + Sensitive information can be securely stored in environment variables and loaded into the code using + [os.getenv()](https://docs.python.org/3/library/os.html#os.getenv). + + #### Example + + ```python + import os + + # securely load sensitive data into the script + cript_host = os.getenv("cript_host") + cript_api_token = os.getenv("cript_api_token") + cript_storage_token = os.getenv("cript_storage_token") + + with cript.API(host=cript_host, api_token=cript_api_token, storage_token=cript_storage_token) as api: + # write your script + pass + ``` + + ### Create API Client with None + Alternatively you can configure your system to have an environment variable of + `CRIPT_TOKEN` for the API token and `CRIPT_STORAGE_TOKEN` for the storage token, then + initialize `cript.API` `api_token` and `storage_token` with `None`. + + The CRIPT Python SDK will try to read the API Token and Storage token from your system's environment variables. + + ```python + with cript.API(host=cript_host, api_token=None, storage_token=None) as api: + # write your script + pass + ``` + + ### Create API client with config.json + `config.json` + ```json + { + "host": "https://api.criptapp.org/", + "api_token": "I am API token", + "storage_token": "I am storage token" + } + ``` + + `my_script.py` + ```python + from pathlib import Path + + # create a file path object of where the config file is + config_file_path = Path(__file__) / Path('./config.json') + + with cript.API(config_file_path=config_file_path) as api: + # node creation, api.save(), etc. + ``` + + Parameters + ---------- + host : str, None + CRIPT host for the Python SDK to connect to such as https://api.criptapp.org/` + This host address is the same address used to login to cript website. + If `None` is specified, the host is inferred from the environment variable `CRIPT_HOST`. + api_token : str, None + CRIPT API Token used to connect to CRIPT and upload all data with the exception to file upload that needs + a different token. + You can find your personal token on the cript website at User > Security Settings. + The user icon is in the top right. + If `None` is specified, the token is inferred from the environment variable `CRIPT_TOKEN`. + storage_token: str + This token is used to upload local files to CRIPT cloud storage when needed + config_file_path: str + the file path to the config.json file where the token and host can be found + + + Notes + ----- + * if `host=None` and `token=None` + then the Python SDK will grab the host from the users environment variable of `"CRIPT_HOST"` + and `"CRIPT_TOKEN"` + + Warns + ----- + UserWarning + If `host` is using "http" it gives the user a warning that HTTP is insecure and the user should use HTTPS + + Raises + ------ + CRIPTConnectionError + If it cannot connect to CRIPT with the provided host and token a CRIPTConnectionError is thrown. + + Returns + ------- + None + Instantiate a new CRIPT API object + """ + + # if there is a config.json file or any of the parameters are None, then get the variables from file or env vars + if config_file_path or (host is None or api_token is None or storage_token is None): + authentication_dict: Dict[str, str] = resolve_host_and_token(host, api_token=api_token, storage_token=storage_token, config_file_path=config_file_path) + + host = authentication_dict["host"] + api_token = authentication_dict["api_token"] + storage_token = authentication_dict["storage_token"] + + self._host = self._prepare_host(host=host) # type: ignore + self._api_token = api_token # type: ignore + self._storage_token = storage_token # type: ignore + + # add Bearer to token for HTTP requests + self._http_headers = {"Authorization": f"Bearer {self._api_token}", "Content-Type": "application/json"} + + # check that api can connect to CRIPT with host and token + self._check_initial_host_connection() + + self._get_db_schema() + + def __str__(self) -> str: + """ + States the host of the CRIPT API client + + Returns + ------- + str + """ + return f"CRIPT API Client - Host URL: '{self.host}'" + + @beartype + def _prepare_host(self, host: str) -> str: + # strip ending slash to make host always uniform + host = host.rstrip("/") + host = f"{host}/{self._api_handle}/{self._api_version}" + + # if host is using unsafe "http://" then give a warning + if host.startswith("http://"): + warnings.warn("HTTP is an unsafe protocol please consider using HTTPS.") + + if not host.startswith("http"): + raise InvalidHostError() + + return host + + # Use a property to ensure delayed init of s3_client + @property + def _s3_client(self) -> boto3.client: # type: ignore + """ + creates or returns a fully authenticated and ready s3 client + + Returns + ------- + s3_client: boto3.client + fully prepared and authenticated s3 client ready to be used throughout the script + """ + + if self._internal_s3_client is None: + auth = boto3.client("cognito-identity", region_name=self._REGION_NAME) + identity_id = auth.get_id(IdentityPoolId=self._IDENTITY_POOL_ID, Logins={self._COGNITO_LOGIN_PROVIDER: self._storage_token}) + # TODO remove this temporary fix to the token, by getting is from back end. + aws_token = self._storage_token + + aws_credentials = auth.get_credentials_for_identity(IdentityId=identity_id["IdentityId"], Logins={self._COGNITO_LOGIN_PROVIDER: aws_token}) + aws_credentials = aws_credentials["Credentials"] + s3_client = boto3.client( + "s3", + aws_access_key_id=aws_credentials["AccessKeyId"], + aws_secret_access_key=aws_credentials["SecretKey"], + aws_session_token=aws_credentials["SessionToken"], + ) + self._internal_s3_client = s3_client + return self._internal_s3_client + + def __enter__(self): + self.connect() + return self + + @beartype + def __exit__(self, type, value, traceback): + self.disconnect() + + def connect(self): + """ + Connect this API globally as the current active access point. + It is not necessary to call this function manually if a context manager is used. + A context manager is preferred where possible. + Jupyter notebooks are a use case where this connection can be handled manually. + If this function is called manually, the `API.disconnect` function has to be called later. + + For manual connection: nested API object are discouraged. + """ + # Store the last active global API (might be None) + global _global_cached_api + self._previous_global_cached_api = copy.copy(_global_cached_api) + _global_cached_api = self + return self + + def disconnect(self): + """ + Disconnect this API from the active access point. + It is not necessary to call this function manually if a context manager is used. + A context manager is preferred where possible. + Jupyter notebooks are a use case where this connection can be handled manually. + This function has to be called manually if the `API.connect` function has to be called before. + + For manual connection: nested API object are discouraged. + """ + # Restore the previously active global API (might be None) + global _global_cached_api + _global_cached_api = self._previous_global_cached_api + + @property + def schema(self): + """ + Access the CRIPT Database Schema that is associated with this API connection. + The CRIPT Database Schema is used to validate a node's JSON so that it is compatible with the CRIPT API. + """ + return self._db_schema + + @property + def host(self): + """ + Read only access to the currently connected host. + + The term "host" designates the specific CRIPT instance to which you intend to upload your data. + + For most users, the host will be `criptapp.org` + + ```yaml + host: criptapp.org + ``` + + Examples + -------- + ```python + print(cript_api.host) + ``` + Output + ```Python + https://api.criptapp.org/api/v1 + ``` + """ + return self._host + + def _check_initial_host_connection(self) -> None: + """ + tries to create a connection with host and if the host does not respond or is invalid it raises an error + + Raises + ------- + CRIPTConnectionError + raised when the host does not give the expected response + + Returns + ------- + None + """ + try: + pass + except Exception as exc: + raise CRIPTConnectionError(self.host, self._api_token) from exc + + def _get_vocab(self) -> dict: + """ + gets the entire CRIPT controlled vocabulary and stores it in _vocabulary + + 1. loops through all controlled vocabulary categories + 1. if the category already exists in the controlled vocabulary then skip that category and continue + 1. if the category does not exist in the `_vocabulary` dict, + then request it from the API and append it to the `_vocabulary` dict + 1. at the end the `_vocabulary` should have all the controlled vocabulary and that will be returned + + Examples + -------- + The vocabulary looks like this + ```json + {'algorithm_key': + [ + { + 'description': "Velocity-Verlet integration algorithm. Parameters: 'integration_timestep'.", + 'name': 'velocity_verlet' + }, + } + ``` + """ + + # loop through all vocabulary categories and make a request to each vocabulary category + # and put them all inside of self._vocab with the keys being the vocab category name + for category in VocabCategories: + if category in self._vocabulary: + continue + + self._vocabulary[category.value] = self.get_vocab_by_category(category) + + return self._vocabulary + + @beartype + def get_vocab_by_category(self, category: VocabCategories) -> List[dict]: + """ + get the CRIPT controlled vocabulary by category + + Parameters + ---------- + category: str + category of + + Returns + ------- + List[dict] + list of JSON containing the controlled vocabulary + """ + + # check if the vocabulary category is already cached + if category.value in self._vocabulary: + return self._vocabulary[category.value] + + # if vocabulary category is not in cache, then get it from API and cache it + response = requests.get(f"{self.host}/cv/{category.value}/").json() + + if response["code"] != 200: + # TODO give a better CRIPT custom Exception + raise Exception(f"while getting controlled vocabulary from CRIPT for {category}, " f"the API responded with http {response} ") + + # add to cache + self._vocabulary[category.value] = response["data"] + + return self._vocabulary[category.value] + + @beartype + def _is_vocab_valid(self, vocab_category: VocabCategories, vocab_word: str) -> bool: + """ + checks if the vocabulary is valid within the CRIPT controlled vocabulary. + Either returns True or InvalidVocabulary Exception + + 1. if the vocabulary is custom (starts with "+") + then it is automatically valid + 2. if vocabulary is not custom, then it is checked against its category + if the word cannot be found in the category then it returns False + + Parameters + ---------- + vocab_category: VocabCategories + ControlledVocabularyCategories enums + vocab_word: str + the vocabulary word e.g. "CAS", "SMILES", "BigSmiles", "+my_custom_key" + + Returns + ------- + a boolean of if the vocabulary is valid + + Raises + ------ + InvalidVocabulary + If the vocabulary is invalid then the error gets raised + """ + + # check if vocab is custom + # This is deactivated currently, no custom vocab allowed. + if vocab_word.startswith("+"): + return True + + # get the entire vocabulary + controlled_vocabulary = self._get_vocab() + # get just the category needed + controlled_vocabulary = controlled_vocabulary[vocab_category.value] + + # TODO this can be faster with a dict of dicts that can do o(1) look up + # looping through an unsorted list is an O(n) look up which is slow + # loop through the list + for vocab_dict in controlled_vocabulary: + # check the name exists within the dict + if vocab_dict.get("name") == vocab_word: + return True + + raise InvalidVocabulary(vocab=vocab_word, possible_vocab=list(controlled_vocabulary)) + + def _get_db_schema(self) -> dict: + """ + Sends a GET request to CRIPT to get the database schema and returns it. + The database schema can be used for validating the JSON request + before submitting it to CRIPT. + + 1. checks if the db schema is already set + * if already exists then it skips fetching it from the API and just returns what it already has + 2. if db schema has not been set yet, then it fetches it from the API + * after getting it from the API it saves it in the `_schema` class variable, + so it can be easily and efficiently gotten next time + """ + + # check if db schema is already saved + if bool(self._db_schema): + return self._db_schema + + # fetch db_schema from API + else: + # fetch db schema, get the JSON body of it, and get the data of that JSON + response = requests.get(url=f"{self.host}/schema/").json() + + if response["code"] != 200: + raise APIError(api_error=response.json()) + + # get the data from the API JSON response + self._db_schema = response["data"] + return self._db_schema + + @beartype + def _is_node_schema_valid(self, node_json: str, is_patch: bool = False) -> bool: + """ + checks a node JSON schema against the db schema to return if it is valid or not. + + 1. get db schema + 1. convert node_json str to dict + 1. take out the node type from the dict + 1. "node": ["material"] + 1. use the node type from dict to tell the db schema which node schema to validate against + 1. Manipulates the string to be title case to work with db schema + + Parameters + ---------- + node_json: str + a node in JSON form string + is_patch: bool + a boolean flag checking if it needs to validate against `NodePost` or `NodePatch` + + Notes + ----- + This function does not take into consideration vocabulary validation. + For vocabulary validation please check `is_vocab_valid` + + Raises + ------ + CRIPTNodeSchemaError + in case a node is invalid + + Returns + ------- + bool + whether the node JSON is valid or not + """ + + db_schema = self._get_db_schema() + + node_type: str = _get_node_type_from_json(node_json=node_json) + + node_dict = json.loads(node_json) + + if self.verbose: + # logging out info to the terminal for the user feedback + # (improve UX because the program is currently slow) + logging.basicConfig(format="%(levelname)s: %(message)s", level=logging.INFO) + logging.info(f"Validating {node_type} graph...") + + # set the schema to test against http POST or PATCH of DB Schema + schema_http_method: str + + if is_patch: + schema_http_method = "Patch" + else: + schema_http_method = "Post" + + # set which node you are using schema validation for + db_schema["$ref"] = f"#/$defs/{node_type}{schema_http_method}" + + try: + jsonschema.validate(instance=node_dict, schema=db_schema) + except jsonschema.exceptions.ValidationError as error: + raise CRIPTNodeSchemaError(node_type=node_dict["node"], json_schema_validation_error=str(error)) from error + + # if validation goes through without any problems return True + return True + + def save(self, project: Project) -> None: + """ + This method takes a project node, serializes the class into JSON + and then sends the JSON to be saved to the API. + It takes Project node because everything is connected to the Project node, + and it can be used to send either a POST or PATCH request to API + + Parameters + ---------- + project: Project + the Project Node that the user wants to save + + Raises + ------ + CRIPTAPISaveError + If the API responds with anything other than an HTTP of `200`, the API error is displayed to the user + + Returns + ------- + A set of extra saved node UUIDs. + Just sends a `POST` or `Patch` request to the API + """ + try: + self._internal_save(project) + except CRIPTAPISaveError as exc: + if exc.pre_saved_nodes: + for node_uuid in exc.pre_saved_nodes: + # TODO remove all pre-saved nodes by their uuid. + pass + raise exc from exc + + def _internal_save(self, node, save_values: Optional[_InternalSaveValues] = None) -> _InternalSaveValues: + """ + Internal helper function that handles the saving of different nodes (not just project). + + If a "Bad UUID" error happens, we find that node with the UUID and save it first. + Then we recursively call the _internal_save again. + Because it is recursive, this repeats until no "Bad UUID" error happen anymore. + This works, because we keep track of "Bad UUID" handled nodes, and represent them in the JSON only as the UUID. + """ + + if save_values is None: + save_values = _InternalSaveValues() + + # saves all the local files to cloud storage right before saving the Project node + # Ensure that all file nodes have uploaded there payload before actual save. + for file_node in node.find_children({"node": ["File"]}): + file_node.ensure_uploaded(api=self) + + node.validate() + + # Dummy response to have a virtual do-while loop, instead of while loop. + response = {"code": -1} + # TODO remove once get works properly + force_patch = False + + while response["code"] != 200: + # Keep a record of how the state was before the loop + old_save_values = copy.deepcopy(save_values) + # We assemble the JSON to be saved to back end. + # Note how we exclude pre-saved uuid nodes. + json_data = node.get_json(known_uuid=save_values.saved_uuid, suppress_attributes=save_values.suppress_attributes).json + + # This checks if the current node exists on the back end. + # if it does exist we use `patch` if it doesn't `post`. + test_get_response: Dict = requests.get(url=f"{self._host}/{node.node_type_snake_case}/{str(node.uuid)}/", headers=self._http_headers).json() + patch_request = test_get_response["code"] == 200 + + # TODO remove once get works properly + if not patch_request and force_patch: + patch_request = True + force_patch = False + # TODO activate patch validation + # node.validate(is_patch=patch_request) + + # If all that is left is a UUID, we don't need to save it, we can just exit the loop. + if patch_request and len(json.loads(json_data)) == 1: + response = {"code": 200} + break + + if patch_request: + response: Dict = requests.patch(url=f"{self._host}/{node.node_type_snake_case}/{str(node.uuid)}/", headers=self._http_headers, data=json_data).json() # type: ignore + else: + response: Dict = requests.post(url=f"{self._host}/{node.node_type_snake_case}/", headers=self._http_headers, data=json_data).json() # type: ignore + + # If we get an error we may be able to fix, we to handle this extra and save the bad node first. + # Errors with this code, may be fixable + if response["code"] in (400, 409): + returned_save_values = _fix_node_save(self, node, response, save_values) + save_values += returned_save_values + + # Handle errors from patching with too many attributes + if patch_request and response["code"] in (400,): + suppress_attributes = _identify_suppress_attributes(node, response) + new_save_values = _InternalSaveValues(save_values.saved_uuid, suppress_attributes) + save_values += new_save_values + + # It is only worthwhile repeating the attempted save loop if our state has improved. + # Aka we did something to fix the occurring error + if not save_values > old_save_values: + # TODO remove once get works properly + if not patch_request and response["code"] == 409 and response["error"].strip().startswith("Duplicate uuid:"): # type: ignore + duplicate_uuid = _get_uuid_from_error_message(response["error"]) # type: ignore + if str(node.uuid) == duplicate_uuid: + force_patch = True + continue + + break + + if response["code"] != 200: + raise CRIPTAPISaveError(api_host_domain=self._host, http_code=response["code"], api_response=response["error"], patch_request=patch_request, pre_saved_nodes=save_values.saved_uuid, json_data=json_data) # type: ignore + + save_values.saved_uuid.add(str(node.uuid)) + return save_values + + def upload_file(self, file_path: Union[Path, str]) -> str: + # trunk-ignore-begin(cspell) + """ + uploads a file to AWS S3 bucket and returns a URL of the uploaded file in AWS S3 + The URL is has no expiration time limit and is available forever + + 1. take a file path of type path or str to the file on local storage + * see Example for more details + 1. convert the file path to pathlib object, so it is versatile and + always uniform regardless if the user passes in a str or path object + 1. get the file + 1. rename the file to avoid clash or overwriting of previously uploaded files + * change file name to `original_name_uuid4.extension` + * `document_42926a201a624fdba0fd6271defc9e88.txt` + 1. upload file to AWS S3 + 1. get the link of the uploaded file and return it + + + Parameters + ---------- + file_path: Union[str, Path] + file path as str or Path object. Path Object is recommended + + Examples + -------- + ```python + import cript + + api = cript.API(host, token) + + # programmatically create the absolute path of your file, so the program always works correctly + my_file_path = (Path(__file__) / Path('../upload_files/my_file.txt')).resolve() + + my_file_s3_url = api.upload_file(absolute_file_path=my_file_path) + ``` + + Raises + ------ + FileNotFoundError + In case the CRIPT Python SDK cannot find the file on your computer because the file does not exist + or the path to it is incorrect it raises + [FileNotFoundError](https://docs.python.org/3/library/exceptions.html#FileNotFoundError) + + Returns + ------- + object_name: str + object_name of the AWS S3 uploaded file to be put into the File node source attribute + """ + # trunk-ignore-end(cspell) + + # TODO consider using a new variable when converting `file_path` from parameter + # to a Path object with a new type + # convert file path from whatever the user passed in to a pathlib object + file_path = Path(file_path).resolve() + + # get file_name and file_extension from absolute file path + # file_extension includes the dot, e.g. ".txt" + file_name, file_extension = os.path.splitext(os.path.basename(file_path)) + + # generate a UUID4 string without dashes, making a cleaner file name + uuid_str: str = str(uuid.uuid4().hex) + + new_file_name: str = f"{file_name}_{uuid_str}{file_extension}" + + # e.g. "directory/file_name_uuid.extension" + object_name: str = f"{self._BUCKET_DIRECTORY_NAME}/{new_file_name}" + + # upload file to AWS S3 + self._s3_client.upload_file(Filename=file_path, Bucket=self._BUCKET_NAME, Key=object_name) # type: ignore + + # return the object_name within AWS S3 for easy retrieval + return object_name + + @beartype + def download_file(self, file_source: str, destination_path: str = ".") -> None: + """ + Download a file from CRIPT Cloud Storage (AWS S3) and save it to the specified path. + + ??? Info "Cloud Storage vs Web URL File Download" + + If the `object_name` does not starts with `http` then the program assumes the file is in AWS S3 storage, + and attempts to retrieve it via + [boto3 client](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html). + + If the `object_name` starts with `http` then the program knows that + it is a file stored on the web. The program makes a simple + [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) request to get the file, + then writes the contents of it to the specified destination. + + > Note: The current version of the program is designed to download files from the web in a straightforward + manner. However, please be aware that the program may encounter limitations when dealing with URLs that + require JavaScript or a session to be enabled. In such cases, the download method may fail. + + > We acknowledge these limitations and plan to enhance the method in future versions to ensure compatibility + with a wider range of web file URLs. Our goal is to develop a robust solution capable of handling any and + all web file URLs. + + Parameters + ---------- + file_source: str + `object_name`: file downloaded via object_name from cloud storage and saved to local storage + object_name e.g. `"Data/{file_name}"` + --- + `URL file source`: If the file source starts with `http` then it is downloaded via `GET` request and + saved to local storage + URL file source e.g. `https://criptscripts.org/cript_graph_json/JSON/cao_protein.json` + destination_path: str + please provide a path with file name of where you would like the file to be saved + on local storage. + > If no path is specified, then by default it will download the file + to the current working directory. + + > The destination path must include a file name and file extension + e.g.: `~/Desktop/my_example_file_name.extension` + + Examples + -------- + ```python + from pathlib import Path + + desktop_path = (Path(__file__).parent / "cript_downloads" / "my_downloaded_file.txt").resolve() + cript_api.download_file(file_url=my_file_source, destination_path=desktop_path) + ``` + + Raises + ------ + FileNotFoundError + In case the file could not be found because the file does not exist or the path given is incorrect + + Returns + ------- + None + Simply downloads the file + """ + + # if the file source is a URL + if file_source.startswith("http"): + download_file_from_url(url=file_source, destination_path=Path(destination_path).resolve()) + return + + # the file is stored in cloud storage and must be retrieved via object_name + self._s3_client.download_file(Bucket=self._BUCKET_NAME, Key=file_source, Filename=destination_path) # type: ignore + + @beartype + def search( + self, + node_type, + search_mode: SearchModes, + value_to_search: Union[None, str], + ) -> Paginator: + """ + This method is used to perform search on the CRIPT platform. + + Examples + -------- + ```python + # search by node type + materials_paginator = cript_api.search( + node_type=cript.Material, + search_mode=cript.SearchModes.NODE_TYPE, + value_to_search=None, + ) + ``` + + Parameters + ---------- + node_type : UUIDBaseNode + Type of node that you are searching for. + search_mode : SearchModes + Type of search you want to do. You can search by name, `UUID`, `EXACT_NAME`, etc. + Refer to [valid search modes](../search_modes) + value_to_search : Union[str, None] + What you are searching for can be either a value, and if you are only searching for + a `NODE_TYPE`, then this value can be empty or `None` + + Returns + ------- + Paginator + paginator object for the user to use to flip through pages of search results + """ + + # get node typ from class + node_type = node_type.node_type_snake_case + + # always putting a page parameter of 0 for all search URLs + page_number = 0 + + api_endpoint: str = "" + + # requesting a page of some primary node + if search_mode == SearchModes.NODE_TYPE: + api_endpoint = f"{self._host}/{node_type}" + + elif search_mode == SearchModes.CONTAINS_NAME: + api_endpoint = f"{self._host}/search/{node_type}" + + elif search_mode == SearchModes.EXACT_NAME: + api_endpoint = f"{self._host}/search/exact/{node_type}" + + elif search_mode == SearchModes.UUID: + api_endpoint = f"{self._host}/{node_type}/{value_to_search}" + # putting the value_to_search in the URL instead of a query + value_to_search = None + + assert api_endpoint != "" + + # TODO error handling if none of the API endpoints got hit + return Paginator(http_headers=self._http_headers, api_endpoint=api_endpoint, query=value_to_search, current_page_number=page_number) diff --git a/src/cript/api/exceptions.py b/src/cript/api/exceptions.py new file mode 100644 index 000000000..0dd7062c0 --- /dev/null +++ b/src/cript/api/exceptions.py @@ -0,0 +1,209 @@ +from typing import List, Optional, Set + +from cript.exceptions import CRIPTException + + +class CRIPTConnectionError(CRIPTException): + """ + ## Definition + Raised when the cript.API object cannot connect to CRIPT with the given host and token + + ## How to Fix + The best way to fix this error is to check that your host and token are written and used correctly within + the cript.API object. This error could also be shown if the API is unresponsive and the cript.API object + just cannot successfully connect to it. + """ + + def __init__(self, host, token): + self.host = host + # Do not store full token in stack trace for security reasons + uncovered_chars = len(token) // 4 + self.token = token[:uncovered_chars] + self.token += "*" * (len(token) - 2 * uncovered_chars) + self.token += token[-uncovered_chars:] + + def __str__(self) -> str: + error_message = f"Could not connect to CRIPT with the given host ({self.host}) and token ({self.token}). " f"Please be sure both host and token are entered correctly." + + return error_message + + +# TODO refactor +class InvalidVocabulary(CRIPTException): + """ + Raised when the CRIPT controlled vocabulary is invalid + """ + + vocab: str = "" + possible_vocab: List[str] = [] + + def __init__(self, vocab: str, possible_vocab: List[str]) -> None: + self.vocab = vocab + self.possible_vocab = possible_vocab + + def __str__(self) -> str: + error_message = f"The vocabulary '{self.vocab}' entered does not exist within the CRIPT controlled vocabulary." f" Please pick a valid CRIPT vocabulary from {self.possible_vocab}" + return error_message + + +class InvalidVocabularyCategory(CRIPTException): + """ + Raised when the CRIPT controlled vocabulary category is unknown + and gives the user a list of all valid vocabulary categories + """ + + def __init__(self, vocab_category: str, valid_vocab_category: List[str]): + self.vocab_category = vocab_category + self.valid_vocab_category = valid_vocab_category + + def __str__(self) -> str: + error_message = f"The vocabulary category {self.vocab_category} does not exist within the CRIPT controlled vocabulary. " f"Please pick a valid CRIPT vocabulary category from {self.valid_vocab_category}." + + return error_message + + +class CRIPTAPIRequiredError(CRIPTException): + """ + ## Definition + Exception to be raised when the API object is requested, but no cript.API object exists yet. + + The CRIPT Python SDK relies on a cript.API object for creation, validation, and modification of nodes. + The cript.API object may be explicitly called by the user to perform operations to the API, or + implicitly called by the Python SDK under the hood to perform some sort of validation. + + ## How to Fix + To fix this error please instantiate an api object + + ```python + import cript + + my_host = "https://api.criptapp.org/" + my_token = "123456" # To use your token securely, please consider using environment variables + + my_api = cript.API(host=my_host, token=my_token) + ``` + """ + + def __init__(self): + pass + + def __str__(self) -> str: + error_message = "cript.API object is required for an operation, but it does not exist." "Please instantiate a cript.API object to continue." "See the documentation for more details." + + return error_message + + +class CRIPTAPISaveError(CRIPTException): + """ + ## Definition + CRIPTAPISaveError is raised when the API responds with a http status code that is anything other than 200. + The status code and API response is shown to the user to help them debug the issue. + + ## How to Fix + This error is more of a case by case basis, but the best way to approach it to understand that the + CRIPT Python SDK sent an HTTP POST request with a giant JSON in the request body + to the CRIPT API. The API then read that request, and it responded with some sort of error either + to the that JSON or how the request was sent. + """ + + api_host_domain: str + http_code: str + api_response: str + + def __init__(self, api_host_domain: str, http_code: str, api_response: str, patch_request: bool, pre_saved_nodes: Optional[Set[str]] = None, json_data: Optional[str] = None): + self.api_host_domain = api_host_domain + self.http_code = http_code + self.api_response = api_response + self.patch_request = patch_request + self.pre_saved_nodes = pre_saved_nodes + self.json_data = json_data + + def __str__(self) -> str: + type = "POST" + if self.patch_request: + type = "PATCH" + error_message = f"API responded to {type} with 'http:{self.http_code} {self.api_response}'" + if self.json_data: + error_message += f" data: {self.json_data}" + + return error_message + + +class InvalidHostError(CRIPTException): + """ + ## Definition + Exception is raised when the host given to the API is invalid + + ## How to Fix + This is a simple error to fix, simply put `http://` or preferably `https://` in front of your domain + when passing in the host to the cript.API class such as `https://api.criptapp.org/` + + Currently, the only web protocol that is supported with the CRIPT Python SDK is `HTTP`. + + ### Example + ```python + import cript + + my_valid_host = "https://api.criptapp.org/" + my_token = "123456" # To use your token securely, please consider using environment variables + + my_api = cript.API(host=my_valid_host, token=my_token) + ``` + + Warnings + -------- + Please consider always using [HTTPS](https://developer.mozilla.org/en-US/docs/Glossary/HTTPS) + as that is a secure protocol and avoid using `HTTP` as it is insecure. + The CRIPT Python SDK will give a warning in the terminal when it detects a host with `HTTP` + + + """ + + def __init__(self) -> None: + pass + + def __str__(self) -> str: + return "The host must start with http or https" + + +class APIError(CRIPTException): + """ + ## Definition + This is a generic error made to display API errors to the user to troubleshoot. + + ## How to Fix + Please keep in mind that the CRIPT Python SDK turns the [Project](../../nodes/primary_nodes/project) + node into a giant JSON and sends that to the API to be processed. If there are any errors while processing + the giant JSON generated by the CRIPT Python SDK, then the API will return an error about the http request + and the JSON sent to it. Therefore, the error shown might be an error within the JSON and not particular + within the Python code that was created + + The best way to trouble shoot this is to figure out what the API error means and figure out where + in the Python SDK this error occurred and what have been the reason under the hood. + """ + + api_error: str = "" + + def __init__(self, api_error: str) -> None: + self.api_error = api_error + + def __str__(self) -> str: + error_message: str = f"The API responded with {self.api_error}" + + return error_message + + +class FileDownloadError(CRIPTException): + """ + ## Definition + This error is raised when the API wants to download a file from an AWS S3 URL + via the `cript.API.download_file()` method, but the status is something other than 200. + """ + + error_message: str = "" + + def __init__(self, error_message: str) -> None: + self.error_message = error_message + + def __str__(self) -> str: + return self.error_message diff --git a/src/cript/api/paginator.py b/src/cript/api/paginator.py new file mode 100644 index 000000000..50e7ab601 --- /dev/null +++ b/src/cript/api/paginator.py @@ -0,0 +1,221 @@ +from typing import List, Optional, Union +from urllib.parse import quote + +import requests +from beartype import beartype + + +class Paginator: + """ + Paginator is used to flip through different pages of data that the API returns when searching. + > Instead of the user manipulating the URL and parameters, this object handles all of that for them. + + When conducting any kind of search the API returns pages of data and each page contains 10 results. + This is equivalent to conducting a Google search when Google returns a limited number of links on the first page + and all other results are on the next pages. + + Using the Paginator object, the user can simply and easily flip through the pages of data the API provides. + + !!! Warning "Do not create paginator objects" + Please note that you are not required or advised to create a paginator object, and instead the + Python SDK API object will create a paginator for you, return it, and let you simply use it + + + Attributes + ---------- + current_page_results: List[dict] + List of JSON dictionary results returned from the API + ```python + [{result 1}, {result 2}, {result 3}, ...] + ``` + """ + + _http_headers: dict + + api_endpoint: str + + # if query or page number are None, then it means that api_endpoint does not allow for whatever that is None + # and that is not added to the URL + # by default the page_number and query are `None` and they can get filled in + query: Union[str, None] + _current_page_number: int + + current_page_results: List[dict] + + @beartype + def __init__( + self, + http_headers: dict, + api_endpoint: str, + query: Optional[str] = None, + current_page_number: int = 0, + ): + """ + create a paginator + + 1. set all the variables coming into constructor + 1. then prepare any variable as needed e.g. strip extra spaces or url encode query + + Parameters + ---------- + http_headers: dict + get already created http headers from API and just use them in paginator + api_endpoint: str + api endpoint to send the search requests to + it already contains what node the user is looking for + current_page_number: int + page number to start from. Keep track of current page for user to flip back and forth between pages of data + query: str + the value the user is searching for + + Returns + ------- + None + instantiate a paginator + """ + self._http_headers = http_headers + self.api_endpoint = api_endpoint + self.query = query + self._current_page_number = current_page_number + + # check if it is a string and not None to avoid AttributeError + if api_endpoint is not None: + # strip the ending slash "/" to make URL uniform and any trailing spaces from either side + self.api_endpoint = api_endpoint.rstrip("/").strip() + + # check if it is a string and not None to avoid AttributeError + if query is not None: + # URL encode query + self.query = quote(query) + + self.fetch_page_from_api() + + def next_page(self): + """ + flip to the next page of data. + + Examples + -------- + ```python + my_paginator.next_page() + ``` + """ + self.current_page_number += 1 + + def previous_page(self): + """ + flip to the next page of data. + + Examples + -------- + ```python + my_paginator.previous_page() + ``` + """ + self.current_page_number -= 1 + + @property + @beartype + def current_page_number(self) -> int: + """ + get the current page number that you are on. + + Setting the page will take you to that specific page of results + + Examples + -------- + ```python + my_paginator.current_page = 10 + ``` + + Returns + ------- + current page number: int + the current page number of the data + """ + return self._current_page_number + + @current_page_number.setter + @beartype + def current_page_number(self, new_page_number: int) -> None: + """ + flips to a specific page of data that has been requested + + sets the current_page_number and then sends the request to the API and gets the results of this page number + + Parameters + ---------- + new_page_number (int): specific page of data that the user wants to go to + + Examples + -------- + requests.get("https://api.criptapp.org//api?page=2) + requests.get(f"{self.query}?page={self.current_page_number - 1}") + + Raises + -------- + InvalidPageRequest, in case the user tries to get a negative page or a page that doesn't exist + """ + if new_page_number < 0: + error_message: str = f"Paginator current page number is invalid because it is negative: " f"{self.current_page_number} please set paginator.current_page_number " f"to a positive page number" + + # TODO replace with custom error + raise Exception(error_message) + + else: + self._current_page_number = new_page_number + # when new page number is set, it is then fetched from the API + self.fetch_page_from_api() + + @beartype + def fetch_page_from_api(self) -> List[dict]: + """ + 1. builds the URL from the query and page number + 1. makes the request to the API + 1. API responds with a JSON that has data or JSON that has data and result + 1. parses it and correctly sets the current_page_results property + + Raises + ------ + InvalidSearchRequest + In case the API responds with an error + + Returns + ------- + current page results: List[dict] + makes a request to the API and gets a page of data + """ + + # temporary variable to not overwrite api_endpoint + temp_api_endpoint: str = self.api_endpoint + + if self.query is not None: + temp_api_endpoint = f"{temp_api_endpoint}/?q={self.query}" + + elif self.query is None: + temp_api_endpoint = f"{temp_api_endpoint}/?q=" + + temp_api_endpoint = f"{temp_api_endpoint}&page={self.current_page_number}" + + response = requests.get( + url=temp_api_endpoint, + headers=self._http_headers, + ).json() + + # handling both cases in case there is result inside of data or just data + try: + self.current_page_results = response["data"]["result"] + except KeyError: + self.current_page_results = response["data"] + except TypeError: + self.current_page_results = response["data"] + + if response["code"] == 404 and response["error"] == "The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.": + self.current_page_results = [] + return self.current_page_results + + # TODO give a CRIPT error if HTTP response is anything other than 200 + if response["code"] != 200: + raise Exception(f"API responded with: {response['error']}") + + return self.current_page_results diff --git a/src/cript/api/utils/__init__.py b/src/cript/api/utils/__init__.py new file mode 100644 index 000000000..50e0528bb --- /dev/null +++ b/src/cript/api/utils/__init__.py @@ -0,0 +1,4 @@ +# trunk-ignore-all(ruff/F401) + +from .get_host_token import resolve_host_and_token +from .helper_functions import _get_node_type_from_json diff --git a/src/cript/api/utils/get_host_token.py b/src/cript/api/utils/get_host_token.py new file mode 100644 index 000000000..9d36ff550 --- /dev/null +++ b/src/cript/api/utils/get_host_token.py @@ -0,0 +1,62 @@ +import json +import os +from pathlib import Path +from typing import Dict + + +def resolve_host_and_token(host, api_token, storage_token, config_file_path) -> Dict[str, str]: + """ + resolves the host and token after passed into the constructor if it comes from env vars or config file + + ## priority level + 1. config file + 1. environment variable + 1. direct host and token + + Returns + ------- + Dict[str, str] + dict of host and token + """ + if config_file_path: + # convert str path or path object + config_file_path = Path(config_file_path).resolve() + + # TODO the reading from config file can be separated into another function + # read host and token from config.json + with open(config_file_path, "r") as file_handle: + config_file: Dict[str, str] = json.loads(file_handle.read()) + # set api host and token + host = config_file["host"] + api_token = config_file["api_token"] + storage_token = config_file["storage_token"] + + return {"host": host, "api_token": api_token, "storage_token": storage_token} + + # if host and token is none then it will grab host and token from user's environment variables + if host is None: + host = _read_env_var(env_var_name="CRIPT_HOST") + + if api_token is None: + api_token = _read_env_var(env_var_name="CRIPT_TOKEN") + + if storage_token is None: + storage_token = _read_env_var(env_var_name="CRIPT_STORAGE_TOKEN") + + return {"host": host, "api_token": api_token, "storage_token": storage_token} + + +def _read_env_var(env_var_name: str) -> str: + """ + reads the host or token from the env vars called `CRIPT_HOST` or `CRIPT_TOKEN` + + Returns + ------- + str + """ + env_var = os.environ.get(env_var_name) + + if env_var is None: + raise RuntimeError(f"API initialized with `host=None` and `token=None` but environment variable `{env_var_name}` " f"was not found.") + + return env_var diff --git a/src/cript/api/utils/helper_functions.py b/src/cript/api/utils/helper_functions.py new file mode 100644 index 000000000..862421bb8 --- /dev/null +++ b/src/cript/api/utils/helper_functions.py @@ -0,0 +1,43 @@ +import json +from typing import Dict, List, Union + +from cript.nodes.exceptions import CRIPTJsonNodeError +from cript.nodes.util import _is_node_field_valid + + +def _get_node_type_from_json(node_json: Union[Dict, str]) -> str: + """ + takes a node JSON and output the node_type `Project`, `Material`, etc. + + 1. convert node JSON dict or str to dict + 1. do check the node list to be sure it only has a single type in it + 1. get the node type and return it + + Parameters + ---------- + node_json: [Dict, str] + + Notes + ----- + Takes a str or dict to be more versatile + + Returns + ------- + str: + node type + """ + # convert all JSON node strings to dict for easier handling + if isinstance(node_json, str): + node_json = json.loads(node_json) + try: + node_type_list: List[str] = node_json["node"] # type: ignore + except KeyError: + raise CRIPTJsonNodeError(node_list=node_json["node"], json_str=json.dumps(node_json)) # type: ignore + + # check to be sure the node list has a single type "node": ["Material"] + if _is_node_field_valid(node_type_list=node_type_list): + return node_type_list[0] + + # if invalid then raise error + else: + raise CRIPTJsonNodeError(node_list=node_type_list, json_str=str(node_json)) diff --git a/src/cript/api/utils/save_helper.py b/src/cript/api/utils/save_helper.py new file mode 100644 index 000000000..4ef2c2bba --- /dev/null +++ b/src/cript/api/utils/save_helper.py @@ -0,0 +1,164 @@ +import json +import re +import uuid +from dataclasses import dataclass, field +from typing import Dict, Set + + +@dataclass +class _InternalSaveValues: + """ + Class that carries attributes to be carried through recursive calls of _internal_save. + """ + + saved_uuid: Set[str] = field(default_factory=set) + suppress_attributes: Dict[str, Set[str]] = field(default_factory=dict) + + def __add__(self, other: "_InternalSaveValues") -> "_InternalSaveValues": + """ + Implement a short hand to combine two of these save values, with `+`. + This unions, the `saved_uuid`. + And safely unions `suppress_attributes` too. + """ + # Make a manual copy of `self`. + return_value = _InternalSaveValues(self.saved_uuid.union(other.saved_uuid), self.suppress_attributes) + + # Union the dictionary. + for uuid_str in other.suppress_attributes: + try: + # If the uuid exists in both `suppress_attributes` union the value sets + return_value.suppress_attributes[uuid_str] = return_value.suppress_attributes[uuid_str].union(other.suppress_attributes[uuid_str]) + except KeyError: + # If it only exists in one, just copy the set into the new one. + return_value.suppress_attributes[uuid_str] = other.suppress_attributes[uuid_str] + return return_value + + def __gt__(self, other): + """ + A greater comparison to see if something was added to the info. + """ + if len(self.saved_uuid) > len(other.saved_uuid): + return True + if len(self.suppress_attributes) > len(other.suppress_attributes): + return True + # If the two dicts have the same key, make sure at least one key has more suppressed attributes + if self.suppress_attributes.keys() == other.suppress_attributes.keys(): + longer_set_found = False + for key in other.suppress_attributes: + if len(self.suppress_attributes[key]) < len(other.suppress_attributes[key]): + return False + if self.suppress_attributes[key] > other.suppress_attributes[key]: + longer_set_found = True + return longer_set_found + return False + + +def _fix_node_save(api, node, response, save_values: _InternalSaveValues) -> _InternalSaveValues: + """ + Helper function, that attempts to fix a bad node. + And if it is fixable, we resave the entire node. + + Returns set of known uuids, if fixable, otherwise False. + """ + if response["code"] not in (400, 409): + raise RuntimeError(f"The internal helper function `_fix_node_save` has been called for an error that is not yet implemented to be handled {response}.") + + if response["error"].startswith("Bad uuid:") or response["error"].strip().startswith("Duplicate uuid:"): + missing_uuid = _get_uuid_from_error_message(response["error"]) + missing_node = find_node_by_uuid(node, missing_uuid) + # If the missing node, is the same as the one we are trying to save, this not working. + # We end the infinite loop here. + if missing_uuid == str(node.uuid): + return save_values + # Now we save the bad node extra. + # So it will be known when we attempt to save the graph again. + # Since we pre-saved this node, we want it to be UUID edge only the next JSON. + # So we add it to the list of known nodes + returned_save_values = api._internal_save(missing_node, save_values) + save_values += returned_save_values + # The missing node, is now known to the API + save_values.saved_uuid.add(missing_uuid) + + # Handle all duplicate items warnings if possible + if response["error"].startswith("duplicate item"): + for search_dict_str in re.findall(r"\{(.*?)\}", response["error"]): # Regular expression finds all text elements enclosed in `{}`. In the error message this is the dictionary describing the duplicated item. + # The error message contains a description of the offending elements. + search_dict_str = "{" + search_dict_str + "}" + search_dict_str = search_dict_str.replace("'", '"') + search_dict = json.loads(search_dict_str) + # These are in the exact format to use with `find_children` so we find all the offending children. + all_duplicate_nodes = node.find_children(search_dict) + for duplicate_node in all_duplicate_nodes: + # Unfortunately, even patch errors if you patch with an offending element. + # So we remove the offending element from the JSON + # TODO IF THIS IS A TRUE DUPLICATE NAME ERROR, IT WILL ERROR AS THE NAME ATTRIBUTE IS MISSING. + try: + # the search_dict convenient list all the attributes that are offending in the keys. + # So if we haven't listed the current node in the suppress attribute dict, we add the node with the offending attributes to suppress. + save_values.suppress_attributes[str(duplicate_node.uuid)] = set(search_dict.keys()) + except KeyError: + # If we have the current node in the dict, we just add the new elements to the list of suppressed attributes for it. + save_values.suppress_attributes[str(duplicate_node.uuid)].add(set(search_dict.keys())) # type: ignore + + # Attempts to save the duplicate items element. + save_values += api._internal_save(duplicate_node, save_values) + # After the save, we can reduce it to just a UUID edge in the graph (avoiding the duplicate issues). + save_values.saved_uuid.add(str(duplicate_node.uuid)) + + return save_values + + +def _get_uuid_from_error_message(error_message: str) -> str: + """ + takes an CRIPTAPISaveError and tries to get the UUID that the API is having trouble with + and return that + + Parameters + ---------- + error_message: str + + Returns + ------- + UUID + the UUID the API had trouble with + """ + bad_uuid = None + if error_message.startswith("Bad uuid: "): + bad_uuid = error_message[len("Bad uuid: ") : -len(" provided")].strip() + if error_message.strip().startswith("Duplicate uuid:"): + bad_uuid = error_message[len(" Duplicate uuid:") : -len("provided")].strip() + if bad_uuid is None or len(bad_uuid) != len(str(uuid.uuid4())): # Ensure we found a full UUID describing string (here tested against a random new uuid length.) + raise RuntimeError(f"The internal helper function `_get_uuid_from_error_message` has been called for an error message that is not yet implemented to be handled. error message {error_message}, found uuid {bad_uuid}.") + + return bad_uuid + + +def find_node_by_uuid(node, uuid_str: str): + # Use the find_children functionality to find that node in our current tree + # We can have multiple occurrences of the node, + # but it doesn't matter which one we save + # TODO some error handling, for the BUG case of not finding the UUID + missing_node = node.find_children({"uuid": uuid_str})[0] + + return missing_node + + +def _identify_suppress_attributes(node, response: Dict) -> Dict[str, Set[str]]: + suppress_attributes: Dict[str, Set[str]] = {} + if response["error"].startswith("Additional properties are not allowed"): + # Find all the attributes, that are listed in the error message with regex + attributes = set(re.findall(r"'(.*?)'", response["error"])) # regex finds all attributes in enclosing `'`. This is how the error message lists them. + + # At the end of the error message the offending path is given. + # The structure of the error message is such, that is is after `path:`, so we find and strip the path out of the message. + path = response["error"][response["error"].rfind("path:") + len("path:") :].strip() + + if path != "/": + # TODO find the UUID this belongs to + raise RuntimeError("Fixing non-root objects for patch, not implemented yet. This is a bug, please report it on https://github.com/C-Accel-CRIPT/Python-SDK/ .") + + try: + suppress_attributes[str(node.uuid)].add(attributes) # type: ignore + except KeyError: + suppress_attributes[str(node.uuid)] = attributes + return suppress_attributes diff --git a/src/cript/api/utils/web_file_downloader.py b/src/cript/api/utils/web_file_downloader.py new file mode 100644 index 000000000..10b7f13fd --- /dev/null +++ b/src/cript/api/utils/web_file_downloader.py @@ -0,0 +1,97 @@ +import os +from pathlib import Path +from typing import Union + +import requests + + +def download_file_from_url(url: str, destination_path: Union[str, Path]) -> None: + """ + downloads a file from URL + + Warnings + --------- + This is a very basic implementation that does not handle all URL files, + and will likely throw errors. + For example, some file URLs require a session or JS enabled to navigate to them + such as "https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf" + in those cases this implementation will fail. + + Parameters + ---------- + url: str + web URL to download the file from + example: https://criptscripts.org/cript_graph/graph_ppt/CRIPT_Data_Structure_Template.pptx + destination_path: Union[str, Path] + which directory and file name the file should be written to after gotten from the web + + Returns + ------- + None + just downloads the file + """ + + response = requests.get(url=url) + + # if not HTTP 200, then throw error + response.raise_for_status() + + # get extension from URL + file_extension = get_file_extension_from_url(url=url) + + # add the file extension to file path and file name + destination_path = str(destination_path) + file_extension + + destination_path = Path(destination_path) + + # write contents to a file on user disk + write_file_to_disk(destination_path=destination_path, file_contents=response.content) + + +def get_file_extension_from_url(url: str) -> str: + """ + takes a file url and returns only the extension with the dot + + Parameters + ---------- + url: str + web URL + example: "https://criptscripts.org/cript_graph/graph_ppt/CRIPT_Data_Structure_Template.pptx" + + Returns + ------- + file extension: str + file extension with dot + example: ".pptx" + """ + file_extension = os.path.splitext(url)[1] + + return file_extension + + +def write_file_to_disk(destination_path: Union[str, Path], file_contents: bytes) -> None: + """ + simply writes the file to the given destination + + Parameters + ---------- + destination_path: Union[str, Path] + which directory and file name the file should be written to after gotten from the web + file_contents: bytes + content of file to write to disk + + Returns + ------- + None + just writes the file to disk + + Raises + ------ + FileNotFoundError + In case the destination given to write the file to was not found or does not exist + """ + # convert any type of path to a Path object + destination_path = Path(destination_path) + + with open(file=destination_path, mode="wb") as file_handle: + file_handle.write(file_contents) diff --git a/src/cript/api/valid_search_modes.py b/src/cript/api/valid_search_modes.py new file mode 100644 index 000000000..7d168450f --- /dev/null +++ b/src/cript/api/valid_search_modes.py @@ -0,0 +1,35 @@ +from enum import Enum + + +class SearchModes(Enum): + """ + Available search modes to use with the CRIPT API search + + Attributes + ---------- + NODE_TYPE : str + Search by node type. + EXACT_NAME : str + Search by exact node name. + CONTAINS_NAME : str + Search by node name containing a given string. + UUID : str + Search by node UUID. + + Examples + ------- + ```python + # search by node type + materials_paginator = cript_api.search( + node_type=cript.Material, + search_mode=cript.SearchModes.NODE_TYPE, + value_to_search=None, + ) + ``` + """ + + NODE_TYPE: str = "" + EXACT_NAME: str = "exact_name" + CONTAINS_NAME: str = "contains_name" + UUID: str = "uuid" + # UUID_CHILDREN = "uuid_children" diff --git a/src/cript/api/vocabulary_categories.py b/src/cript/api/vocabulary_categories.py new file mode 100644 index 000000000..c1969236e --- /dev/null +++ b/src/cript/api/vocabulary_categories.py @@ -0,0 +1,99 @@ +from enum import Enum + + +class VocabCategories(Enum): + """ + All available [CRIPT controlled vocabulary categories](https://app.criptapp.org/vocab/) + + Controlled vocabulary categories are used to classify data. + + Attributes + ---------- + ALGORITHM_KEY: str + Algorithm key. + ALGORITHM_TYPE: str + Algorithm type. + BUILDING_BLOCK: str + Building block. + CITATION_TYPE: str + Citation type. + COMPUTATION_TYPE: str + Computation type. + COMPUTATIONAL_FORCEFIELD_KEY: str + Computational forcefield key. + COMPUTATIONAL_PROCESS_PROPERTY_KEY: str + Computational process property key. + COMPUTATIONAL_PROCESS_TYPE: str + Computational process type. + CONDITION_KEY: str + Condition key. + DATA_LICENSE: str + Data license. + DATA_TYPE: str + Data type. + EQUIPMENT_KEY: str + Equipment key. + FILE_TYPE: str + File type. + INGREDIENT_KEYWORD: str + Ingredient keyword. + MATERIAL_IDENTIFIER_KEY: str + Material identifier key. + MATERIAL_KEYWORD: str + Material keyword. + MATERIAL_PROPERTY_KEY: str + Material property key. + PARAMETER_KEY: str + Parameter key. + PROCESS_KEYWORD: str + Process keyword. + PROCESS_PROPERTY_KEY: str + Process property key. + PROCESS_TYPE: str + Process type. + PROPERTY_METHOD: str + Property method. + QUANTITY_KEY: str + Quantity key. + REFERENCE_TYPE: str + Reference type. + SET_TYPE: str + Set type. + UNCERTAINTY_TYPE: str + Uncertainty type. + + Examples + -------- + ```python + algorithm_vocabulary = api.get_vocabulary_by_category( + cript.VocabCategories.ALGORITHM_KEY + ) + ``` + """ + + ALGORITHM_KEY: str = "algorithm_key" + ALGORITHM_TYPE: str = "algorithm_type" + BUILDING_BLOCK: str = "building_block" + CITATION_TYPE: str = "citation_type" + COMPUTATION_TYPE: str = "computation_type" + COMPUTATIONAL_FORCEFIELD_KEY: str = "computational_forcefield_key" + COMPUTATIONAL_PROCESS_PROPERTY_KEY: str = "computational_process_property_key" + COMPUTATIONAL_PROCESS_TYPE: str = "computational_process_type" + CONDITION_KEY: str = "condition_key" + DATA_LICENSE: str = "data_license" + DATA_TYPE: str = "data_type" + EQUIPMENT_KEY: str = "equipment_key" + FILE_TYPE: str = "file_type" + INGREDIENT_KEYWORD: str = "ingredient_keyword" + MATERIAL_IDENTIFIER_KEY: str = "material_identifier_key" + MATERIAL_KEYWORD: str = "material_keyword" + MATERIAL_PROPERTY_KEY: str = "material_property_key" + PARAMETER_KEY: str = "parameter_key" + PROCESS_KEYWORD: str = "process_keyword" + PROCESS_PROPERTY_KEY: str = "process_property_key" + PROCESS_TYPE: str = "process_type" + PROPERTY_METHOD: str = "property_method" + QUANTITY_KEY: str = "quantity_key" + REFERENCE_TYPE: str = "reference_type" + SET_TYPE: str = "set_type" + UNCERTAINTY_TYPE: str = "uncertainty_type" diff --git a/src/cript/exceptions.py b/src/cript/exceptions.py new file mode 100644 index 000000000..3891bf646 --- /dev/null +++ b/src/cript/exceptions.py @@ -0,0 +1,12 @@ +from abc import abstractmethod + + +class CRIPTException(Exception): + """ + Parent CRIPT exception. + All CRIPT exception inherit this class. + """ + + @abstractmethod + def __str__(self) -> str: + pass diff --git a/src/cript/nodes/__init__.py b/src/cript/nodes/__init__.py new file mode 100644 index 000000000..4c5dfe051 --- /dev/null +++ b/src/cript/nodes/__init__.py @@ -0,0 +1,32 @@ +# trunk-ignore-all(ruff/F401) +from cript.nodes.primary_nodes import ( + Collection, + Computation, + ComputationProcess, + Data, + Experiment, + Inventory, + Material, + Process, + Project, + Reference, +) +from cript.nodes.subobjects import ( + Algorithm, + Citation, + ComputationalForcefield, + Condition, + Equipment, + Ingredient, + Parameter, + Property, + Quantity, + Software, + SoftwareConfiguration, +) +from cript.nodes.supporting_nodes import File, User +from cript.nodes.util import ( + NodeEncoder, + add_orphaned_nodes_to_project, + load_nodes_from_json, +) diff --git a/src/cript/nodes/core.py b/src/cript/nodes/core.py new file mode 100644 index 000000000..0eb0d4f0d --- /dev/null +++ b/src/cript/nodes/core.py @@ -0,0 +1,456 @@ +import copy +import dataclasses +import json +import re +import uuid +from abc import ABC +from dataclasses import asdict, dataclass, replace +from typing import Dict, List, Optional, Set + +from cript.nodes.exceptions import ( + CRIPTAttributeModificationError, + CRIPTExtraJsonAttributes, + CRIPTJsonSerializationError, +) + +tolerated_extra_json = [] + + +def add_tolerated_extra_json(additional_tolerated_json: str): + """ + In case a node should be loaded from JSON (such as `getting` them from the API), + but the API sends additional JSON attributes, these can be set to tolerated temporarily with this routine. + """ + tolerated_extra_json.append(additional_tolerated_json) + + +def get_new_uid(): + return "_:" + str(uuid.uuid4()) + + +class classproperty(object): + def __init__(self, f): + self.f = f + + def __get__(self, obj, owner): + if obj is None: + return self.f(owner) + return self.f(obj) + + +class BaseNode(ABC): + """ + This abstract class is the base of all CRIPT nodes. + It offers access to a json attribute class, + which reflects the data model JSON attributes. + Also, some basic shared functionality is provided by this base class. + """ + + @dataclass(frozen=True) + class JsonAttributes: + node: List[str] = dataclasses.field(default_factory=list) + uid: str = "" + + _json_attrs: JsonAttributes = JsonAttributes() + + @classproperty + def node_type(self): + name = type(self).__name__ + if name == "ABCMeta": + name = self.__name__ + return name + + @classproperty + def node_type_snake_case(self): + camel_case = self.node_type + # Regex to convert camel case to snake case. + snake_case = re.sub(r"(? str: + """ + Return a string representation of a node data model attributes. + + Returns + ------- + str + A string representation of the node. + """ + return str(asdict(self._json_attrs)) + + @property + def uid(self): + return self._json_attrs.uid + + @property + def node(self): + return self._json_attrs.node + + def _update_json_attrs_if_valid(self, new_json_attr: JsonAttributes) -> None: + """ + tries to update the node if valid and then checks if it is valid or not + + 1. updates the node with the new information + 1. run db schema validation on it + 1. if db schema validation succeeds then update and continue + 1. else: raise an error and tell the user what went wrong + + Parameters + ---------- + new_json_attr + + Raises + ------ + Exception + + Returns + ------- + None + """ + old_json_attrs = self._json_attrs + self._json_attrs = new_json_attr + + try: + self.validate() + except Exception as exc: + self._json_attrs = old_json_attrs + raise exc + + def validate(self, api=None, is_patch=False) -> None: + """ + Validate this node (and all its children) against the schema provided by the data bank. + + Raises: + ------- + Exception with more error information. + """ + from cript.api.api import _get_global_cached_api + + if api is None: + api = _get_global_cached_api() + api._is_node_schema_valid(self.get_json(is_patch=is_patch).json, is_patch=is_patch) + + @classmethod + def _from_json(cls, json_dict: dict): + # TODO find a way to handle uuid nodes only + + # Child nodes can inherit and overwrite this. + # They should call super()._from_json first, and modified the returned object after if necessary + # We create manually a dict that contains all elements from the send dict. + # That eliminates additional fields and doesn't require asdict. + arguments = {} + default_dataclass = cls.JsonAttributes() + for field in json_dict: + try: + getattr(default_dataclass, field) + except AttributeError: + pass + else: + arguments[field] = json_dict[field] + + # add omitted fields from default (necessary if they are required) + for field_name in [field.name for field in dataclasses.fields(default_dataclass)]: + if field_name not in arguments: + arguments[field_name] = getattr(default_dataclass, field_name) + + # If a node with this UUID already exists, we don't create a new node. + # Instead we use the existing node from the cache and just update it. + from cript.nodes.uuid_base import UUIDBaseNode + + if "uuid" in json_dict and json_dict["uuid"] in UUIDBaseNode._uuid_cache: + node = UUIDBaseNode._uuid_cache[json_dict["uuid"]] + else: # Create a new node + try: + node = cls(**arguments) + # TODO we should not catch all exceptions if we are handling them, and instead let it fail + # to create a good error message that points to the correct place that it failed to make debugging easier + except Exception as exc: + print(cls, arguments) + raise exc + + attrs = cls.JsonAttributes(**arguments) + + # Handle default attributes manually. + for field in attrs.__dict__: + # Conserve newly assigned uid if uid is default (empty) + if getattr(attrs, field) == getattr(default_dataclass, field): + attrs = replace(attrs, **{str(field): getattr(node, field)}) + + try: # TODO remove this temporary solution + if not attrs.uid.startswith("_:"): + attrs = replace(attrs, uid="_:" + attrs.uid) + except AttributeError: + pass + # But here we force even usually unwritable fields to be set. + node._update_json_attrs_if_valid(attrs) + + return node + + def __deepcopy__(self, memo): + # Ideally I would call `asdict`, but that is not allowed inside a deepcopy chain. + # Making a manual transform into a dictionary here. + arguments = {} + for field in self.JsonAttributes().__dataclass_fields__: + arguments[field] = copy.deepcopy(getattr(self._json_attrs, field), memo) + # TODO URL handling + + # Since we excluded 'uuid' from arguments, + # a new uid will prompt the creation of a new matching uuid. + uid = get_new_uid() + arguments["uid"] = uid + + # Create node and init constructor attributes + node = self.__class__(**arguments) + # Update none constructor writable attributes. + node._update_json_attrs_if_valid(self.JsonAttributes(**arguments)) + return node + + @property + def json(self): + """ + Property to obtain a simple json string. + Calls `get_json` with default arguments. + """ + # We cannot validate in `get_json` because we call it inside `validate`. + # But most uses are probably the property, so we can validate the node here. + self.validate() + return self.get_json().json + + def get_json( + self, + handled_ids: Optional[Set[str]] = None, + known_uuid: Optional[Set[str]] = None, + suppress_attributes: Optional[Dict[str, Set[str]]] = None, + is_patch: bool = False, + condense_to_uuid: Dict[str, Set[str]] = { + "Material": {"parent_material", "component"}, + "Experiment": {"data"}, + "Inventory": {"material"}, + "Ingredient": {"material"}, + "Property": {"component"}, + "ComputationProcess": {"material"}, + "Data": {"material"}, + "Process": {"product", "waste"}, + "Project": {"member", "admin"}, + "Collection": {"member", "admin"}, + }, + **kwargs + ): + """ + User facing access to get the JSON of a node. + Opposed to the also available property json this functions allows further control. + Additionally, this function does not call `self.validate()` but the property `json` does. + + Returns named tuple with json and handled ids as result. + """ + + @dataclass(frozen=True) + class ReturnTuple: + json: str + handled_ids: set + + # Do not check for circular references, since we handle them manually + kwargs["check_circular"] = kwargs.get("check_circular", False) + + # Delayed import to avoid circular imports + from cript.nodes.util import NodeEncoder + + if handled_ids is None: + handled_ids = set() + previous_handled_nodes = copy.deepcopy(NodeEncoder.handled_ids) + NodeEncoder.handled_ids = handled_ids + + # Similar to uid, we handle pre-saved known uuid such that they are UUID edges only + if known_uuid is None: + known_uuid = set() + previous_known_uuid = copy.deepcopy(NodeEncoder.known_uuid) + NodeEncoder.known_uuid = known_uuid + previous_suppress_attributes = copy.deepcopy(NodeEncoder.suppress_attributes) + NodeEncoder.suppress_attributes = suppress_attributes + previous_condense_to_uuid = copy.deepcopy(NodeEncoder.condense_to_uuid) + NodeEncoder.condense_to_uuid = condense_to_uuid + + try: + return ReturnTuple(json.dumps(self, cls=NodeEncoder, **kwargs), NodeEncoder.handled_ids) + except Exception as exc: + # TODO this handling that doesn't tell the user what happened and how they can fix it + # this just tells the user that something is wrong + # this should be improved to tell the user what went wrong and where + raise CRIPTJsonSerializationError(str(type(self)), str(self._json_attrs)) from exc + finally: + NodeEncoder.handled_ids = previous_handled_nodes + NodeEncoder.known_uuid = previous_known_uuid + NodeEncoder.suppress_attributes = previous_suppress_attributes + NodeEncoder.condense_to_uuid = previous_condense_to_uuid + + def find_children(self, search_attr: dict, search_depth: int = -1, handled_nodes=None) -> List: + """ + Finds all the children in a given tree of nodes (specified by its root), + that match the criteria of search_attr. + If a node is present multiple times in the graph, it is only once in the search results. + + search_dept: Max depth of the search into the tree. Helpful if circles are expected. -1 specifies no limit + + search_attr: dict + Dictionary that specifies which JSON attributes have to be present in a given node. + If an attribute is a list, it it is sufficient if the specified attributes are in the list, + if others are present too, that does not exclude the child. + + Example: search_attr = `{"node": ["Parameter"]}` finds all "Parameter" nodes. + search_attr = `{"node": ["Algorithm"], "parameter": {"name" : "update_frequency"}}` + finds all "Algorithm" nodes, that have a parameter "update_frequency". + Since parameter is a list an alternative notation is + ``{"node": ["Algorithm"], "parameter": [{"name" : "update_frequency"}]}` + and Algorithms are not excluded they have more parameters. + search_attr = `{"node": ["Algorithm"], "parameter": [{"name" : "update_frequency"}, + {"name" : "cutoff_distance"}]}` + finds all algorithms that have a parameter "update_frequency" and "cutoff_distance". + + """ + + def is_attr_present(node: BaseNode, key, value): + """ + Helper function that checks if an attribute is present in a node. + """ + try: + attr_key = getattr(node._json_attrs, key) + except AttributeError: + return False + + # To save code paths, I convert non-lists into lists with one element. + if not isinstance(attr_key, list): + attr_key = [attr_key] + if not isinstance(value, list): + value = [value] + + # The definition of search is, that all values in a list have to be present. + # To fulfill this AND condition, we count the number of occurrences of that value condition + number_values_found = 0 + # Runtime contribution: O(m), where is is the number of search keys + for v in value: + # Test for simple values (not-nodes) + if v in attr_key: + number_values_found += 1 + + # Test if value is present in one of the specified attributes (OR condition) + # Runtime contribution: O(m), where m is the number of nodes in the attribute list. + for attr in attr_key: + # if the attribute is a node and the search value is a dictionary, + # we can verify that this condition is met if it finds the node itself with `find_children`. + if isinstance(attr, BaseNode) and isinstance(v, dict): + # Since we only want to test the node itself and not any of its children, we set recursion to 0. + # Runtime contribution: recursive call, with depth search depth of the search dictionary O(h) + if len(attr.find_children(v, 0)) > 0: + number_values_found += 1 + # Since this an OR condition, we abort early. + # This also doesn't inflate the number_values_count, + # since every OR condition should only add a max of 1. + break + # Check if the AND condition of the values is met + return number_values_found == len(value) + + if handled_nodes is None: + handled_nodes = [] + + # Protect against cycles in graph, by handling every instance of a node only once + if self in handled_nodes: + return [] + handled_nodes += [self] + + found_children = [] + + # In this search we include the calling node itself. + # We check for this node if all specified attributes are present by counting them (AND condition). + found_attr = 0 + for key, value in search_attr.items(): + if is_attr_present(self, key, value): + found_attr += 1 + # If exactly all attributes are found, it matches the search criterion + if found_attr == len(search_attr): + found_children += [self] + + # Recursion according to the recursion depth for all node children. + if search_depth != 0: + # Loop over all attributes, runtime contribution (none, or constant (max number of attributes of a node) + for field in self._json_attrs.__dataclass_fields__: + value = getattr(self._json_attrs, field) + # To save code paths, I convert non-lists into lists with one element. + if not isinstance(value, list): + value = [value] + # Run time contribution: number of elements in the attribute list. + for v in value: + try: # Try every attribute for recursion (duck-typing) + found_children += v.find_children(search_attr, search_depth - 1, handled_nodes=handled_nodes) + except AttributeError: + pass + # Total runtime, of non-recursive call: O(m*h) + O(k) where k is the number of children for this node, + # h being the depth of the search dictionary, m being the number of nodes in the attribute list. + # Total runtime, with recursion: O(n*(k+m*h). A full graph traversal O(n) with a cost per node, that scales with the number of children per node and the search depth of the search dictionary. + return found_children + + def remove_child(self, child) -> bool: + """ + This safely removes the first found child node from the parent. + This requires exact node as we test with `is` instead of `==`. + + returns True if child was found and deleted, False if child not found, + raise DB schema exception if deletion violates DB schema. + """ + + # If we delete a child, we have to replace that with a default value. + # The easiest way to access this default value is to get it from the the default JsonAttribute of that class + default_json_attrs = self.JsonAttributes() + new_attrs = self._json_attrs + for field in self._json_attrs.__dataclass_fields__: + value = getattr(self._json_attrs, field) + if value is child: + new_attrs = replace(new_attrs, **{field: getattr(default_json_attrs, field)}) + # We only want to delete the first found child + elif not isinstance(value, str): # Strings are iterable, but we don't want them + try: # Try if we are facing a list at the moment + new_attr_list = [element for element in value] + except TypeError: + pass # It is OK if this field is not a list + else: + found_child = False + for i, list_value in enumerate(value): + if list_value is child: + found_child = True + del new_attr_list[i] + # Only delete first child. + # Important to break loop here, since value and new_attr_list are not identical any more. + if found_child: + new_attrs = replace(new_attrs, **{field: new_attr_list}) + # Again only first found place is removed + break + # Let's see if we found the child aka the new_attrs are different than the old ones + if new_attrs is self._json_attrs: + return False + self._update_json_attrs_if_valid(new_attrs) + return True diff --git a/src/cript/nodes/exceptions.py b/src/cript/nodes/exceptions.py new file mode 100644 index 000000000..58f31fdb3 --- /dev/null +++ b/src/cript/nodes/exceptions.py @@ -0,0 +1,411 @@ +from abc import ABC, abstractmethod +from typing import List + +from cript.exceptions import CRIPTException + + +class CRIPTNodeSchemaError(CRIPTException): + """ + ## Definition + This error is raised when the CRIPT [json database schema](https://json-schema.org/) + validation fails for a node. + + Please keep in mind that the CRIPT Python SDK converts all the Python nodes inside the + [Project](../../nodes/primary_nodes/project) into a giant JSON + and sends an HTTP `POST` or `PATCH` request to the API to be processed. + + However, before a request is sent to the API, the JSON is validated against API database schema + via the [JSON Schema library](https://python-jsonschema.readthedocs.io/en/stable/), + and if the database schema validation fails for whatever reason this error is shown. + + ### Possible Reasons + + 1. There was a mistake in nesting of the nodes + 1. There was a mistake in creating the nodes + 1. Nodes are missing + 1. Nodes have invalid vocabulary + * The database schema wants something a different controlled vocabulary than what is provided + 1. There was an error with the way the JSON was created within the Python SDK + * The format of the JSON the CRIPT Python SDK created was invalid + 1. There is something wrong with the database schema + + ## How to Fix + The easiest way to troubleshoot this is to examine the JSON that the SDK created via printing out the + [Project](../../nodes/primary_nodes/project) node's JSON and checking the place that the schema validation + says failed + + ### Example + ```python + print(my_project.json) + ``` + """ + + node_type: str = "" + json_schema_validation_error: str = "" + + def __init__(self, node_type: str, json_schema_validation_error: str) -> None: + self.json_schema_validation_error: str = json_schema_validation_error + self.node_type = node_type + + def __str__(self) -> str: + error_message: str = f"JSON database schema validation for node {self.node_type} failed." + error_message += f"Error: {self.json_schema_validation_error}" + + return error_message + + +class CRIPTJsonDeserializationError(CRIPTException): + """ + ## Definition + This exception is raised when converting a node from JSON to Python class fails. + This process fails when the attributes within the JSON does not match the node's class + attributes within the `JsonAttributes` of that specific node + + ### Error Example + Invalid JSON that cannot be deserialized to a CRIPT Python SDK Node + + ```json + ``` + + + ### Valid Example + Valid JSON that can be deserialized to a CRIPT Python SDK Node + + ```json + ``` + + ## How to Fix + """ + + def __init__(self, node_type: str, json_str: str) -> None: + self.node_type = node_type + self.json_str = json_str + + def __str__(self) -> str: + return f"JSON deserialization failed for node type {self.node_type} with JSON str: {self.json_str}" + + +class CRIPTDeserializationUIDError(CRIPTException): + """ + ## Definition + This exception is raised when converting a node from JSON to Python class fails, + because a node is specified with its UID only, but not part of the data graph elsewhere. + + ### Error Example + Invalid JSON that cannot be deserialized to a CRIPT Python SDK Node + + ```json + { + "node": ["Algorithm"], + "key": "mc_barostat", + "type": "barostat", + "parameter": {"node": ["Parameter"], "uid": "uid-string"} + } + ``` + Here the algorithm has a parameter attribute, but the parameter is specified as uid only. + + ### Valid Example + Valid JSON that can be deserialized to a CRIPT Python SDK Node + + ```json + { + "node": ["Algorithm"], + "key": "mc_barostat", + "type": "barostat", + "parameter": {"node": ["Parameter"], "uid": "uid-string", + "key": "update_frequency", "value":1, "unit": "1/second"} + } + ``` + Now the node is fully specified. + + ## How to Fix + Specify the full node instead. This error might appear if you try to partially load previously generated JSON. + """ + + def __init__(self, node_type: str, uid: str) -> None: + self.node_type = node_type + self.uid = uid + + def __str__(self) -> str: + return f"JSON deserialization failed for node type {self.node_type} with unknown UID: {self.uid}" + + +class CRIPTJsonNodeError(CRIPTJsonDeserializationError): + """ + ## Definition + This exception is raised if a `node` attribute is present in JSON, + but the list has more or less than exactly one type of node type. + + > Note: It is expected that there is only a single node type per JSON object. + + ### Example + !!! Example "Valid JSON representation of a Material node" + ```json + { + "node": [ + "Material" + ], + "name": "Whey protein isolate", + "uid": "_:Whey protein isolate" + }, + ``` + + ??? Example "Invalid JSON representation of a Material node" + + ```json + { + "node": [ + "Material", + "Property" + ], + "name": "Whey protein isolate", + "uid": "_:Whey protein isolate" + }, + ``` + + --- + + ```json + { + "node": [], + "name": "Whey protein isolate", + "uid": "_:Whey protein isolate" + }, + ``` + + + ## How to Fix + Debugging skills are most helpful here as there is no one-size-fits-all approach. + + It is best to identify whether the invalid JSON was created in the Python SDK + or if the invalid JSON was given from the API. + + If the Python SDK created invalid JSON during serialization, then it is helpful to track down and + identify the point where the invalid JSON was started. + + You may consider, inspecting the python objects to see if the node type are written incorrectly in python + and the issue is only being caught during serialization or if the Python node is written correctly + and the issue is created during serialization. + + If the problem is with the Python SDK or API, it is best to leave an issue or create a discussion within the + [Python SDK GitHub repository](https://github.com/C-Accel-CRIPT/Python-SDK) for one of the members of the + CRIPT team to look into any issues that there could have been. + """ + + def __init__(self, node_list: List, json_str: str) -> None: + self.node_list = node_list + self.json_str = json_str + + def __str__(self) -> str: + error_message: str = f"The 'node' attribute in the JSON string must be a single element list with the node name " f" such as `'node: ['Material']`. The `node` attribute provided was: `{self.node_list}`" f"The full JSON was: {self.json_str}." + + return error_message + + +class CRIPTJsonSerializationError(CRIPTException): + """ + ## Definition + This Exception is raised if serialization of node from JSON to Python Object fails. + + ## How to Fix + """ + + def __init__(self, node_type: str, json_dict: str) -> None: + self.node_type = node_type + self.json_str = str(json_dict) + + def __str__(self) -> str: + return f"JSON Serialization failed for node type {self.node_type} with JSON dict: {self.json_str}" + + +class CRIPTAttributeModificationError(CRIPTException): + """ + Exception that is thrown when a node attribute is modified, that wasn't intended to be modified. + """ + + def __init__(self, name, key, value): + self.name = name + self.key = key + self.value = value + + def __str__(self): + return ( + f"Attempt to modify an attribute of a node ({self.name}) that wasn't intended to be modified.\n" + f"Here the non-existing attribute {self.key} of {self.name} was attempted to be modified.\n" + "Most likely this is due to a typo in the attribute that was intended to be modified i.e. `project.materials` instead of `project.material`.\n" + "To ensure compatibility with the underlying CRIPT data model we do not allow custom attributes.\n" + ) + + +class CRIPTExtraJsonAttributes(CRIPTException): + def __init__(self, name_type: str, extra_attribute: str): + self.name_type = name_type + self.extra_attribute = extra_attribute + + def __str__(self): + return ( + f"During the construction of a node {self.name_type} an additional attribute {self.extra_attribute} was detected.\n" + "This might be a typo or an extra delivered argument from the back end.\n" + f"In the latter case, you can disable this error temporarily by calling `cript.add_tolerated_extra_json('{self.extra_attribute}')`.\n" + ) + + +class CRIPTOrphanedNodesError(CRIPTException, ABC): + """ + ## Definition + This error is raised when a child node is not attached to the + appropriate parent node. For example, all material nodes used + within a project must belong to the project inventory or are explicitly listed as material of that project. + If there is a material node that is used within a project but not a part of the + inventory and the validation code finds it then it raises an `CRIPTOrphanedNodeError` + + ## How To Fix + Fixing this is simple and easy, just take the node that CRIPT Python SDK + found a problem with and associate it with the appropriate parent via + + ``` + my_project.material += my_orphaned_material_node + ``` + """ + + def __init__(self, orphaned_node): + self.orphaned_node = orphaned_node + + @abstractmethod + def __str__(self): + pass + + +class CRIPTOrphanedMaterialError(CRIPTOrphanedNodesError): + """ + ## Definition + CRIPTOrphanedNodesError, but specific for orphaned materials. + + ## How To Fix + Handle this error by adding the orphaned materials into the parent project or its inventories. + """ + + def __init__(self, orphaned_node): + from cript.nodes.primary_nodes.material import Material + + assert isinstance(orphaned_node, Material) + super().__init__(orphaned_node) + + def __str__(self): + ret_string = "While validating a project graph, an orphaned material node was found. " + ret_string += "This material is present in the graph, but not listed in the project. " + ret_string += "Please add the node like: `my_project.material += [orphaned_material]`. " + ret_string += f"The orphaned material was {self.orphaned_node}." + return ret_string + + +class CRIPTOrphanedExperimentError(CRIPTOrphanedNodesError): + """ + ## Definition + CRIPTOrphanedNodesError, but specific for orphaned nodes that should be listed in one of the experiments. + + ## How To Fix + Handle this error by adding the orphaned node into one the parent project's experiments. + """ + + def __init__(self, orphaned_node): + super().__init__(orphaned_node) + + def __str__(self) -> str: + node_name = self.orphaned_node.node_type.lower() + ret_string = f"While validating a project graph, an orphaned {node_name} node was found. " + ret_string += f"This {node_name} node is present in the graph, but not listed in any of the experiments of the project. " + ret_string += f"Please add the node like: `your_experiment.{node_name} += [orphaned_{node_name}]`. " + ret_string += f"The orphaned {node_name} was {self.orphaned_node}." + return ret_string + + +def get_orphaned_experiment_exception(orphaned_node): + """ + Return the correct specific Exception based in the orphaned node type for nodes not correctly listed in experiment. + """ + from cript.nodes.primary_nodes.computation import Computation + from cript.nodes.primary_nodes.computation_process import ComputationProcess + from cript.nodes.primary_nodes.data import Data + from cript.nodes.primary_nodes.process import Process + + if isinstance(orphaned_node, Data): + return CRIPTOrphanedDataError(orphaned_node) + if isinstance(orphaned_node, Process): + return CRIPTOrphanedProcessError(orphaned_node) + if isinstance(orphaned_node, Computation): + return CRIPTOrphanedComputationError(orphaned_node) + if isinstance(orphaned_node, ComputationProcess): + return CRIPTOrphanedComputationalProcessError(orphaned_node) + # Base case raise the parent exception. TODO add bug warning. + return CRIPTOrphanedExperimentError(orphaned_node) + + +class CRIPTOrphanedDataError(CRIPTOrphanedExperimentError): + """ + ## Definition + CRIPTOrphanedExperimentError, but specific for orphaned Data node that should be listed in one of the experiments. + + ## How To Fix + Handle this error by adding the orphaned node into one the parent project's experiments `data` attribute. + """ + + def __init__(self, orphaned_node): + from cript.nodes.primary_nodes.data import Data + + assert isinstance(orphaned_node, Data) + super().__init__(orphaned_node) + + +class CRIPTOrphanedProcessError(CRIPTOrphanedExperimentError): + """ + ## Definition + CRIPTOrphanedExperimentError, but specific for orphaned Process node that should be + listed in one of the experiments. + + ## How To Fix + Handle this error by adding the orphaned node into one the parent project's experiments + `process` attribute. + """ + + def __init__(self, orphaned_node): + from cript.nodes.primary_nodes.process import Process + + assert isinstance(orphaned_node, Process) + super().__init__(orphaned_node) + + +class CRIPTOrphanedComputationError(CRIPTOrphanedExperimentError): + """ + ## Definition + CRIPTOrphanedExperimentError, but specific for orphaned Computation node that should be + listed in one of the experiments. + + ## How To Fix + Handle this error by adding the orphaned node into one the parent project's experiments + `Computation` attribute. + """ + + def __init__(self, orphaned_node): + from cript.nodes.primary_nodes.computation import Computation + + assert isinstance(orphaned_node, Computation) + super().__init__(orphaned_node) + + +class CRIPTOrphanedComputationalProcessError(CRIPTOrphanedExperimentError): + """ + ## Definition + CRIPTOrphanedExperimentError, but specific for orphaned ComputationalProcess + node that should be listed in one of the experiments. + + ## How To Fix + Handle this error by adding the orphaned node into one the parent project's experiments + `ComputationalProcess` attribute. + """ + + def __init__(self, orphaned_node): + from cript.nodes.primary_nodes.computation_process import ComputationProcess + + assert isinstance(orphaned_node, ComputationProcess) + super().__init__(orphaned_node) diff --git a/src/cript/nodes/primary_nodes/__init__.py b/src/cript/nodes/primary_nodes/__init__.py new file mode 100644 index 000000000..0ac298ab8 --- /dev/null +++ b/src/cript/nodes/primary_nodes/__init__.py @@ -0,0 +1,11 @@ +# trunk-ignore-all(ruff/F401) +from cript.nodes.primary_nodes.collection import Collection +from cript.nodes.primary_nodes.computation import Computation +from cript.nodes.primary_nodes.computation_process import ComputationProcess +from cript.nodes.primary_nodes.data import Data +from cript.nodes.primary_nodes.experiment import Experiment +from cript.nodes.primary_nodes.inventory import Inventory +from cript.nodes.primary_nodes.material import Material +from cript.nodes.primary_nodes.process import Process +from cript.nodes.primary_nodes.project import Project +from cript.nodes.primary_nodes.reference import Reference diff --git a/src/cript/nodes/primary_nodes/collection.py b/src/cript/nodes/primary_nodes/collection.py new file mode 100644 index 000000000..cd13a0f4e --- /dev/null +++ b/src/cript/nodes/primary_nodes/collection.py @@ -0,0 +1,284 @@ +from dataclasses import dataclass, field, replace +from typing import Any, List, Optional + +from beartype import beartype + +from cript.nodes.primary_nodes.primary_base_node import PrimaryBaseNode +from cript.nodes.supporting_nodes import User + + +class Collection(PrimaryBaseNode): + """ + ## Definition + + A + [Collection node](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=8) + is nested inside a [Project](../project) node. + + A Collection node can be thought as a folder/bucket that can hold [experiment](../experiment) + or [Inventories](../inventory) node. + + | attribute | type | example | description | + |------------|------------------|---------------------|--------------------------------------------------------------------------------| + | experiment | list[Experiment] | | experiment that relate to the collection | + | inventory | list[Inventory] | | inventory owned by the collection | + | doi | str | `10.1038/1781168a0` | DOI: digital object identifier for a published collection; CRIPT generated DOI | + | citation | list[Citation] | | reference to a book, paper, or scholarly work | + + + ## JSON Representation + ```json + { + "name": "my collection JSON", + "node":["Collection"], + "uid":"_:fccd3549-07cb-4e23-ba79-323597ec9bfd", + "uuid":"fccd3549-07cb-4e23-ba79-323597ec9bfd" + + "experiment":[ + { + "name":"my experiment name", + "node":["Experiment"], + "uid":"_:8256b75b-1f4e-4f69-9fe6-3bcb2298e470", + "uuid":"8256b75b-1f4e-4f69-9fe6-3bcb2298e470" + } + ], + "inventory":[], + "citation":[], + } + ``` + + + """ + + @dataclass(frozen=True) + class JsonAttributes(PrimaryBaseNode.JsonAttributes): + """ + all Collection attributes + """ + + # TODO add proper typing in future, using Any for now to avoid circular import error + member: List[User] = field(default_factory=list) + admin: List[User] = field(default_factory=list) + experiment: List[Any] = field(default_factory=list) + inventory: List[Any] = field(default_factory=list) + doi: str = "" + citation: List[Any] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, name: str, experiment: Optional[List[Any]] = None, inventory: Optional[List[Any]] = None, doi: str = "", citation: Optional[List[Any]] = None, notes: str = "", **kwargs) -> None: + """ + create a Collection with a name + add list of experiment, inventory, citation, doi, and notes if available. + + Parameters + ---------- + name: str + name of the Collection you want to make + experiment: Optional[List[Experiment]], default=None + list of experiment within the Collection + inventory: Optional[List[Inventory]], default=None + list of inventories within this collection + doi: str = "", default="" + cript doi + citation: Optional[List[Citation]], default=None + List of citations for this collection + + Returns + ------- + None + Instantiates a Collection node + """ + super().__init__(name=name, notes=notes, **kwargs) + + if experiment is None: + experiment = [] + + if inventory is None: + inventory = [] + + if citation is None: + citation = [] + + self._json_attrs = replace( + self._json_attrs, + name=name, + experiment=experiment, + inventory=inventory, + doi=doi, + citation=citation, + ) + + self.validate() + + @property + @beartype + def member(self) -> List[User]: + return self._json_attrs.member.copy() + + @property + @beartype + def admin(self) -> List[User]: + return self._json_attrs.admin + + @property + @beartype + def experiment(self) -> List[Any]: + """ + List of all [experiment](../experiment) within this Collection + + Examples + -------- + ```python + my_collection.experiment = [my_first_experiment] + ``` + + Returns + ------- + List[Experiment] + list of all [experiment](../experiment) within this Collection + """ + return self._json_attrs.experiment.copy() # type: ignore + + @experiment.setter + @beartype + def experiment(self, new_experiment: List[Any]) -> None: + """ + sets the Experiment list within this collection + + Parameters + ---------- + new_experiment: List[Experiment] + list of experiment + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, experiment=new_experiment) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def inventory(self) -> List[Any]: + """ + List of [inventory](../inventory) that belongs to this collection + + Examples + -------- + ```python + material_1 = cript.Material( + name="material 1", + identifiers=[{"alternative_names": "material 1 alternative name"}], + ) + + material_2 = cript.Material( + name="material 2", + identifiers=[{"alternative_names": "material 2 alternative name"}], + ) + + my_inventory = cript.Inventory( + name="my inventory name", materials_list=[material_1, material_2] + ) + + my_collection.inventory = [my_inventory] + ``` + + Returns + ------- + inventory: List[Inventory] + list of inventories in this collection + """ + return self._json_attrs.inventory.copy() # type: ignore + + @inventory.setter + @beartype + def inventory(self, new_inventory: List[Any]) -> None: + """ + Sets the List of inventories within this collection to a new list + + Parameters + ---------- + new_inventory: List[Inventory] + new list of inventories for the collection to overwrite the current list + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, inventory=new_inventory) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def doi(self) -> str: + """ + The CRIPT DOI for this collection + + ```python + my_collection.doi = "10.1038/1781168a0" + ``` + + Returns + ------- + doi: str + the CRIPT DOI e.g. `10.1038/1781168a0` + """ + return self._json_attrs.doi + + @doi.setter + @beartype + def doi(self, new_doi: str) -> None: + """ + set the CRIPT DOI for this collection to new CRIPT DOI + + Parameters + ---------- + new_doi: str + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, doi=new_doi) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def citation(self) -> List[Any]: + """ + List of Citations within this Collection + + Examples + -------- + ```python + my_citation = cript.Citation(type="derived_from", reference=simple_reference_node) + + my_collections.citation = my_citations + ``` + + Returns + ------- + citation: List[Citation]: + list of Citations within this Collection + """ + return self._json_attrs.citation.copy() # type: ignore + + @citation.setter + @beartype + def citation(self, new_citation: List[Any]) -> None: + """ + set the list of citations for this Collection + + Parameters + ---------- + new_citation: List[Citation] + set the list of citations for this Collection + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, citation=new_citation) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/primary_nodes/computation.py b/src/cript/nodes/primary_nodes/computation.py new file mode 100644 index 000000000..435eeaa47 --- /dev/null +++ b/src/cript/nodes/primary_nodes/computation.py @@ -0,0 +1,456 @@ +from dataclasses import dataclass, field, replace +from typing import Any, List, Optional + +from beartype import beartype + +from cript.nodes.primary_nodes.primary_base_node import PrimaryBaseNode + + +class Computation(PrimaryBaseNode): + """ + ## Definition + + The + [Computation node](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=14) + describes the transformation of data or the creation of a computational data + set. + + **Common computations for simulations** are energy minimization, annealing, quenching, or + NPT/NVT (isothermal-isobaric/canonical ensemble) simulations. + + **Common computations for experimental** data include fitting a reaction model to kinetic data + to determine rate constants, a plateau modulus from a time-temperature-superposition, or calculating radius of + gyration with the Debye function from small angle scattering data. + + + + ## Attributes + | attribute | type | example | description | required | vocab | + |--------------------------|-------------------------------|---------------------------------------|-----------------------------------------------|----------|-------| + | type | str | general molecular dynamics simulation | category of computation | True | True | + | input_data | list[Data] | | input data nodes | | | + | output_data | list[Data] | | output data nodes | | | + | software_ configurations | list[Software Configuration] | | software and algorithms used | | | + | condition | list[Condition] | | setup information | | | + | prerequisite_computation | Computation | | prior computation method in chain | | | + | citation | list[Citation] | | reference to a book, paper, or scholarly work | | | + | notes | str | | additional description of the step | | | + + ## JSON Representation + ```json + { + "name":"my computation name", + "node":["Computation"], + "type":"analysis", + "uid":"_:69f29bec-e30a-4932-b78d-2e4585b37d74", + "uuid":"69f29bec-e30a-4932-b78d-2e4585b37d74" + "citation":[], + } + ``` + + + ## Available Subobjects + * [Software Configuration](../../subobjects/software_configuration) + * [Condition](../../subobjects/condition) + * [Citation](../../subobjects/citation) + + """ + + @dataclass(frozen=True) + class JsonAttributes(PrimaryBaseNode.JsonAttributes): + """ + all computation nodes attributes + """ + + type: str = "" + # TODO add proper typing in future, using Any for now to avoid circular import error + input_data: List[Any] = field(default_factory=list) + output_data: List[Any] = field(default_factory=list) + software_configuration: List[Any] = field(default_factory=list) + condition: List[Any] = field(default_factory=list) + prerequisite_computation: Optional["Computation"] = None + citation: List[Any] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__( + self, + name: str, + type: str, + input_data: Optional[List[Any]] = None, + output_data: Optional[List[Any]] = None, + software_configuration: Optional[List[Any]] = None, + condition: Optional[List[Any]] = None, + prerequisite_computation: Optional["Computation"] = None, + citation: Optional[List[Any]] = None, + notes: str = "", + **kwargs + ) -> None: + """ + create a computation node + + Parameters + ---------- + name: str + name of computation node + type: str + type of computation node. Computation type must come from CRIPT controlled vocabulary + input_data: List[Data] default=None + input data (data node) + output_data: List[Data] default=None + output data (data node) + software_configuration: List[SoftwareConfiguration] default=None + software configuration of computation node + condition: List[Condition] default=None + condition for the computation node + prerequisite_computation: Computation default=None + prerequisite computation + citation: List[Citation] default=None + list of citations + notes: str = "" + any notes for this computation node + **kwargs + for internal use of deserialize JSON from API to node + + Examples + -------- + ```python + my_computation = cript.Computation(name="my computation name", type="analysis") + ``` + + Returns + ------- + None + instantiate a computation node + + """ + super().__init__(name=name, notes=notes, **kwargs) + + if input_data is None: + input_data = [] + + if output_data is None: + output_data = [] + + if software_configuration is None: + software_configuration = [] + + if condition is None: + condition = [] + + if citation is None: + citation = [] + + self._json_attrs = replace( + self._json_attrs, + type=type, + input_data=input_data, + output_data=output_data, + software_configuration=software_configuration, + condition=condition, + prerequisite_computation=prerequisite_computation, + citation=citation, + ) + + self.validate() + + # ------------------ Properties ------------------ + + @property + @beartype + def type(self) -> str: + """ + The type of computation + + The [computation type](https://app.criptapp.org/vocab/computation_type) + must come from CRIPT controlled vocabulary + + Examples + -------- + ```python + my_computation.type = type="analysis" + ``` + + Returns + ------- + str + type of computation + """ + return self._json_attrs.type + + @type.setter + @beartype + def type(self, new_computation_type: str) -> None: + """ + set the computation type + + the computation type must come from CRIPT controlled vocabulary + + Parameters + ---------- + new_computation_type: str + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, type=new_computation_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def input_data(self) -> List[Any]: + """ + List of input data (data nodes) for this node + + Examples + -------- + ```python + # create file node + my_file = cript.File( + source="https://criptapp.org", + type="calibration", + extension=".csv", + data_dictionary="my file's data dictionary" + ) + + # create a data node + my_input_data = cript.Data(name="my data name", type="afm_amp", files=[my_file]) + + my_computation.input_data = [my_input_data] + ``` + + Returns + ------- + List[Data] + list of input data for this computation + """ + return self._json_attrs.input_data.copy() + + @input_data.setter + @beartype + def input_data(self, new_input_data_list: List[Any]) -> None: + """ + set the input data list + + Parameters + ---------- + new_input_data_list: List[Data] + list of input data (data nodes) to replace the current + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, input_data=new_input_data_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def output_data(self) -> List[Any]: + """ + List of output data (data nodes) + + Examples + -------- + ```python + # create file node + my_file = cript.File( + source="https://criptapp.org", + type="calibration", + extension=".csv", + data_dictionary="my file's data dictionary" + ) + + # create a data node + my_output_data = cript.Data(name="my data name", type="afm_amp", files=[my_file]) + + my_computation.output_data = [my_output_data] + ``` + + Returns + ------- + List[Data] + list of output data for this computation + """ + return self._json_attrs.output_data.copy() + + @output_data.setter + @beartype + def output_data(self, new_output_data_list: List[Any]) -> None: + """ + set the list of output data (data nodes) for this node + + Parameters + ---------- + new_output_data_list: List[Data] + replace the current list of output data for this node + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, output_data=new_output_data_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def software_configuration(self) -> List[Any]: + """ + List of software_configuration for this computation node + + Examples + -------- + ```python + # create software configuration node + my_software_configuration = cript.SoftwareConfiguration(software=simple_software_node) + + my_computation.software_configuration = my_software_configuration + ``` + + Returns + ------- + List[SoftwareConfiguration] + list of software configurations + """ + return self._json_attrs.software_configuration.copy() + + @software_configuration.setter + @beartype + def software_configuration(self, new_software_configuration_list: List[Any]) -> None: + """ + set the list of software_configuration for this computation node + + Parameters + ---------- + new_software_configuration_list: List[software_configuration] + new_software_configuration_list to replace the current one + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, software_configuration=new_software_configuration_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def condition(self) -> List[Any]: + """ + List of condition for this computation node + + Examples + -------- + ```python + # create a condition node + my_condition = cript.Condition(key="atm", type="min", value=1) + + my_computation.condition = my_condition + ``` + + Returns + ------- + List[Condition] + list of condition for the computation node + """ + return self._json_attrs.condition.copy() + + @condition.setter + @beartype + def condition(self, new_condition_list: List[Any]) -> None: + """ + set the list of condition for this node + + Parameters + ---------- + new_condition_list: List[Condition] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, condition=new_condition_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def prerequisite_computation(self) -> Optional["Computation"]: + """ + prerequisite computation + + Examples + -------- + ```python + # create computation node for prerequisite_computation + my_prerequisite_computation = cript.Computation(name="my prerequisite computation name", type="data_fit") + + my_computation.prerequisite_computation = my_prerequisite_computation + ``` + + Returns + ------- + Computation + prerequisite computation + """ + return self._json_attrs.prerequisite_computation + + @prerequisite_computation.setter + @beartype + def prerequisite_computation(self, new_prerequisite_computation: Optional["Computation"]) -> None: + """ + set new prerequisite_computation + + Parameters + ---------- + new_prerequisite_computation: "Computation" + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, prerequisite_computation=new_prerequisite_computation) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def citation(self) -> List[Any]: + """ + List of citations + + Examples + -------- + ```python + # create a reference node for the citation + my_reference = cript.Reference(type="journal_article", title="'Living' Polymers") + + # create a reference + my_citation = cript.Citation(type="derived_from", reference=my_reference) + + my_computation.citation = [my_citation] + ``` + + Returns + ------- + List[Citation] + list of citations for this computation node + """ + return self._json_attrs.citation.copy() # type: ignore + + @citation.setter + @beartype + def citation(self, new_citation_list: List[Any]) -> None: + """ + set the List of citations + + Parameters + ---------- + new_citation_list: List[Citation] + list of citations for this computation node + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, citation=new_citation_list) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/primary_nodes/computation_process.py b/src/cript/nodes/primary_nodes/computation_process.py new file mode 100644 index 000000000..56e1ad2cb --- /dev/null +++ b/src/cript/nodes/primary_nodes/computation_process.py @@ -0,0 +1,589 @@ +from dataclasses import dataclass, field, replace +from typing import Any, List, Optional + +from beartype import beartype + +from cript.nodes.primary_nodes.primary_base_node import PrimaryBaseNode + + +class ComputationProcess(PrimaryBaseNode): + """ + ## Definition + + A + [Computational_Process](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=15) + is a simulation that processes or changes a virtual material. Examples + include simulations of chemical reactions, chain scission, cross-linking, strong shear, etc. A + computational process may also encapsulate any computation that dramatically changes the + materials properties, molecular topology, and physical aspects like molecular orientation, etc. The + computation_forcefield of a simulation is associated with a material. As a consequence, if the + forcefield changes or gets refined via a computational procedure (density functional theory, + iterative Boltzmann inversion for coarse-graining etc.) this forcefield changing step must be + described as a computational_process and a new material node with a different + computation_forcefield needs to be created. + + ## Attributes + | attribute | type | example | description | required | vocab | + |--------------------------|-------------------------------|---------------------------------------|-------------------------------------------------|----------|-------| + | type | str | general molecular dynamics simulation | category of computation | True | True | + | input_data | list[Data] | | input data nodes | True | | + | output_data | list[Data] | | output data nodes | | | + | ingredient | list[Ingredient] | | ingredients | True | | + | software_ configurations | list[Software Configuration] | | software and algorithms used | | | + | condition | list[Condition] | | setup information | | | + | property | list[Property] | | computation process properties | | | + | citation | list[Citation] | | reference to a book, paper, or scholarly work | | | + | notes | str | | additional description of the step | | | + + + ## Available Subobjects + * [ingredient](../../subobjects/ingredient) + * [software_configuration](../../subobjects/software_configuration) + * [property](../../subobjects/property) + * [condition](../../subobjects/condition) + * [citation](../../subobjects/citation) + + ## JSON Representation + ```json + { + "name":"my computational process node name", + "node":["ComputationProcess"], + "type":"cross_linking", + "uid":"_:b88ac0a5-b5c0-4197-a63d-b37e1fe8c6c6", + "uuid":"b88ac0a5-b5c0-4197-a63d-b37e1fe8c6c6" + "ingredient":[ + { + "node":["Ingredient"], + "uid":"_:f68d6fff-9327-48b1-9249-33ce498005e8", + "uuid":"f68d6fff-9327-48b1-9249-33ce498005e8" + "keyword":["catalyst"], + "material":{ + "name":"my material name", + "node":["Material"], + "uid":"_:3b12f92c-2121-4520-920e-b4c5622de34a", + "uuid":"3b12f92c-2121-4520-920e-b4c5622de34a", + "bigsmiles":"[H]{[>][<]C(C[>])c1ccccc1[]}", + }, + + "quantity":[ + { + "key":"mass", + "node":["Quantity"], + "uid":"_:07c4a6a9-9385-4505-a30a-ca3549cedcd8", + "uuid":"07c4a6a9-9385-4505-a30a-ca3549cedcd8", + "uncertainty":0.2, + "uncertainty_type":"stdev", + "unit":"kg", + "value":11.2 + } + ] + } + ], + "input_data":[ + { + "name":"my data name", + "node":["Data"], + "type":"afm_amp", + "uid":"_:3c16bb05-ded1-4f52-9d02-c88c1a1de915", + "uuid":"3c16bb05-ded1-4f52-9d02-c88c1a1de915" + "file":[ + { + "name":"my file node name", + "node":["File"], + "source":"https://criptapp.org", + "type":"calibration", + "data_dictionary":"my file's data dictionary", + "extension":".csv", + "uid":"_:ee8153db-4108-49e4-8c5b-ffc26d4e6f71", + "uuid":"ee8153db-4108-49e4-8c5b-ffc26d4e6f71" + } + ], + } + ], + } + ``` + + """ + + @dataclass(frozen=True) + class JsonAttributes(PrimaryBaseNode.JsonAttributes): + """ + all computational_process nodes attributes + """ + + type: str = "" + # TODO add proper typing in future, using Any for now to avoid circular import error + input_data: List[Any] = field(default_factory=list) + output_data: List[Any] = field(default_factory=list) + ingredient: List[Any] = field(default_factory=list) + software_configuration: List[Any] = field(default_factory=list) + condition: List[Any] = field(default_factory=list) + property: List[Any] = field(default_factory=list) + citation: List[Any] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__( + self, + name: str, + type: str, + input_data: List[Any], + ingredient: List[Any], + output_data: Optional[List[Any]] = None, + software_configuration: Optional[List[Any]] = None, + condition: Optional[List[Any]] = None, + property: Optional[List[Any]] = None, + citation: Optional[List[Any]] = None, + notes: str = "", + **kwargs + ): + """ + create a computational_process node + + Examples + -------- + ```python + + # create file node for input data node + data_files = cript.File( + source="https://criptapp.org", + type="calibration", + extension=".csv", + data_dictionary="my file's data dictionary" + ) + + # create input data node + input_data = cript.Data(name="my data name", type="afm_amp", files=[data_files]) + + # Material node for Quantity node + my_material = cript.Material( + name="my material", + identifiers=[{"alternative_names": "my material alternative name"}] + ) + + # create quantity node + my_quantity = cript.Quantity(key="mass", value=1.23, unit="gram") + + # create ingredient node + ingredient = cript.Ingredient( + material=my_material, + quantities=[my_quantity], + ) + + # create computational process node + my_computational_process = cript.ComputationalProcess( + name="my computational process name", + type="cross_linking", + input_data=[input_data], + ingredient=[ingredient], + ) + ``` + + + Parameters + ---------- + name: str + computational process name + type: str + type of computation process from CRIPT controlled vocabulary + input_data: List[Data] + list of input data for computational process + ingredient: List[Ingredient] + list of ingredients for this computational process node + output_data: List[Data] default=None + list of output data for this computational process node + software_configuration: List[SoftwareConfiguration] default=None + list of software configurations for this computational process node + condition: List[Condition] default=None + list of condition for this computational process node + property: List[Property] default=None + list of properties for this computational process node + citation: List[Citation] default=None + list of citation for this computational process node + notes: str default="" + optional notes for the computational process node + + Returns + ------- + None + instantiate computationalProcess node + """ + super().__init__(name=name, notes=notes, **kwargs) + + # TODO validate type from vocab + + if input_data is None: + input_data = [] + + if ingredient is None: + ingredient = [] + + if output_data is None: + output_data = [] + + if software_configuration is None: + software_configuration = [] + + if condition is None: + condition = [] + + if property is None: + property = [] + + if citation is None: + citation = [] + + self._json_attrs = replace( + self._json_attrs, + type=type, + input_data=input_data, + ingredient=ingredient, + output_data=output_data, + software_configuration=software_configuration, + condition=condition, + property=property, + citation=citation, + ) + + # self.validate() + + @property + @beartype + def type(self) -> str: + """ + The [computational process type](https://app.criptapp.org/vocab/computational_process_type) + must come from CRIPT Controlled vocabulary + + Examples + -------- + ```python + my_computational_process.type = "DPD" + ``` + + Returns + ------- + str + computational process type + """ + return self._json_attrs.type + + @type.setter + @beartype + def type(self, new_type: str) -> None: + """ + set the computational_process type + + computational_process type must come from CRIPT controlled vocabulary + + Parameters + ---------- + new_type: str + new computational process type. + computational process type must come from CRIPT controlled vocabulary + + Returns + ------- + None + """ + # TODO check computational_process type with CRIPT controlled vocabulary + new_attrs = replace(self._json_attrs, type=new_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def input_data(self) -> List[Any]: + """ + List of input data for the computational process node + + Examples + -------- + ```python + # create file node for the data node + my_file = cript.File( + source="https://criptapp.org", + type="calibration", + extension=".csv", + data_dictionary="my file's data dictionary" + ) + + # create input data node + my_input_data = cript.Data(name="my input data name", type="afm_amp", files=[my_file]) + + # set computational process data node + my_computation.input_data = my_input_data + ``` + + Returns + ------- + List[Data] + list of input data for this computational process node + """ + return self._json_attrs.input_data.copy() + + @input_data.setter + @beartype + def input_data(self, new_input_data_list: List[Any]) -> None: + """ + set the input data for this computational process + + Parameters + ---------- + new_input_data_list: List[Data] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, input_data=new_input_data_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def output_data(self) -> List[Any]: + """ + List of the output data for the computational_process + + Examples + -------- + ```python + # create file node for the data node + my_file = cript.File( + source="https://criptapp.org", + type="calibration", + extension=".csv", + data_dictionary="my file's data dictionary" + ) + + # create input data node + my_output_data = cript.Data(name="my output data name", type="afm_amp", files=[my_file]) + + # set computational process data node + my_computation.output_data = my_input_data + ``` + + Returns + ------- + List[Data] + list of output data from this computational process node + """ + return self._json_attrs.output_data.copy() + + @output_data.setter + @beartype + def output_data(self, new_output_data_list: List[Any]) -> None: + """ + set the output_data list for the computational_process + + Parameters + ---------- + new_output_data_list: List[Data] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, output_data=new_output_data_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def ingredient(self) -> List[Any]: + """ + List of ingredients for the computational_process + + Examples + -------- + ```python + # create ingredient node + my_ingredient = cript.Ingredient( + material=simple_material_node, + quantities=[simple_quantity_node], + ) + + my_computational_process.ingredient = my_ingredient + ``` + + Returns + ------- + List[Ingredient] + list of ingredients for this computational process + """ + return self._json_attrs.ingredient.copy() + + @ingredient.setter + @beartype + def ingredient(self, new_ingredient_list: List[Any]) -> None: + """ + set the ingredients list for this computational process + + Parameters + ---------- + new_ingredient_list: List[Ingredient] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, ingredient=new_ingredient_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def software_configuration(self) -> List[Any]: + """ + List of software_configuration for the computational process + + Examples + -------- + ```python + # create software configuration node + my_software_configuration = cript.SoftwareConfiguration(software=simple_software_node) + + my_computational_process.software_configuration = my_software_configuration + ``` + + Returns + ------- + List[SoftwareConfiguration] + List of software configurations used for this computational process node + """ + return self._json_attrs.software_configuration.copy() + + @software_configuration.setter + @beartype + def software_configuration(self, new_software_configuration_list: List[Any]) -> None: + """ + set the list of software_configuration for the computational process + + Parameters + ---------- + new_software_configuration_list: List[SoftwareConfiguration] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, software_configuration=new_software_configuration_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def condition(self) -> List[Any]: + """ + List of condition for the computational process + + Examples + -------- + ```python + # create condition node + my_condition = cript.Condition(key="atm", type="min", value=1) + + my_computational_process.condition = [my_condition] + + ``` + + Returns + ------- + List[Condition] + list of condition for this computational process node + """ + return self._json_attrs.condition.copy() + + @condition.setter + @beartype + def condition(self, new_condition: List[Any]) -> None: + """ + set the condition for the computational process + + Parameters + ---------- + new_condition: List[Condition] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, condition=new_condition) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def citation(self) -> List[Any]: + """ + List of citation for the computational process + + Examples + -------- + ```python + # create a reference node for the citation + my_reference = cript.Reference(type="journal_article", title="'Living' Polymers") + + # create a reference + my_citation = cript.Citation(type="derived_from", reference=my_reference) + + my_computational_process.citation = [my_citation] + ``` + + Returns + ------- + List[Citation] + list of citation for this computational process + """ + return self._json_attrs.citation.copy() + + @citation.setter + @beartype + def citation(self, new_citation_list: List[Any]) -> None: + """ + set the citation list for the computational process node + + Parameters + ---------- + new_citation_list: List[Citation] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, citation=new_citation_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def property(self) -> List[Any]: + """ + List of properties + + Examples + -------- + ```python + # create a property node + my_property = cript.Property(key="modulus_shear", type="min", value=1.23, unit="gram") + + my_computational_process.property = [my_property] + ``` + + Returns + ------- + List[Property] + list of properties for this computational process node + """ + return self._json_attrs.property.copy() + + @property.setter + @beartype + def property(self, new_property_list: List[Any]) -> None: + """ + set the properties list for the computational process + + Parameters + ---------- + new_property_list: List[Property] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, property=new_property_list) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/primary_nodes/data.py b/src/cript/nodes/primary_nodes/data.py new file mode 100644 index 000000000..a6134d05c --- /dev/null +++ b/src/cript/nodes/primary_nodes/data.py @@ -0,0 +1,431 @@ +from dataclasses import dataclass, field, replace +from typing import Any, List, Optional, Union + +from beartype import beartype + +from cript.nodes.primary_nodes.primary_base_node import PrimaryBaseNode + + +class Data(PrimaryBaseNode): + """ + ## Definition + A [Data node](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=13) + node contains the meta-data to describe raw data that is beyond a single value, (i.e. n-dimensional data). + Each `Data` node must be linked to a single `Experiment` node. + + ## Available Sub-Objects + * [Citation](../../subobjects/citation) + + ## Attributes + | Attribute | Type | Example | Description | Required | + |---------------------|---------------------------------------------------|----------------------------|-----------------------------------------------------------------------------------------|----------| + | experiment | [Experiment](experiment.md) | | Experiment the data belongs to | True | + | name | str | `"my_data_name"` | Name of the data node | True | + | type | str | `"nmr_h1"` | Pick from [CRIPT data type controlled vocabulary](https://app.criptapp.org/keys/data-type/) | True | + | file | List[[File](../supporting_nodes/file.md)] | `[file_1, file_2, file_3]` | list of file nodes | False | + | sample_preparation | [Process](process.md) | | | False | + | computation | List[[Computation](computation.md)] | | data produced from this Computation method | False | + | computation_process | [Computational Process](./computation_process.md) | | data was produced from this computation process | False | + | material | List[[Material](./material.md)] | | materials with attributes associated with the data node | False | + | process | List[[Process](./process.md)] | | processes with attributes associated with the data node | False | + | citation | [Citation](../subobjects/citation.md) | | reference to a book, paper, or scholarly work | False | + + Example + -------- + ```python + # create file node + cript.File( + source="https://criptapp.org", + type="calibration", + extension=".csv", + data_dictionary="my file's data dictionary" + ) + + + # create data node with required arguments + my_data = cript.Data(name="my data name", type="afm_amp", file=[simple_file_node]) + ``` + + ## JSON Representation + ```json + { + "name":"my data name", + "node":["Data"], + "type":"afm_amp", + "uid":"_:80b02470-73d0-416e-8d93-12fdf69e481a", + "uuid":"80b02470-73d0-416e-8d93-12fdf69e481a" + "file":[ + { + "node":["File"], + "name":"my file node name", + "uid":"_:535779ea-0d1f-4b23-b3e8-60052f717307", + "uuid":"535779ea-0d1f-4b23-b3e8-60052f717307" + "type":"calibration", + "source":"https://criptapp.org", + "extension":".csv", + "data_dictionary":"my file's data dictionary", + } + ] + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(PrimaryBaseNode.JsonAttributes): + """ + all Data attributes + """ + + type: str = "" + # TODO add proper typing in future, using Any for now to avoid circular import error + file: List[Any] = field(default_factory=list) + sample_preparation: Any = field(default_factory=list) + computation: List[Any] = field(default_factory=list) + computation_process: Any = field(default_factory=list) + material: List[Any] = field(default_factory=list) + process: List[Any] = field(default_factory=list) + citation: List[Any] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__( + self, + name: str, + type: str, + file: List[Any], + sample_preparation: Any = None, + computation: Optional[List[Any]] = None, + computation_process: Optional[Any] = None, + material: Optional[List[Any]] = None, + process: Optional[List[Any]] = None, + citation: Optional[List[Any]] = None, + notes: str = "", + **kwargs + ): + super().__init__(name=name, notes=notes, **kwargs) + + if file is None: + file = [] + + if sample_preparation is None: + sample_preparation = [] + + if computation is None: + computation = [] + + if computation_process is None: + computation_process = [] + + if material is None: + material = [] + + if process is None: + process = [] + + if citation is None: + citation = [] + + self._json_attrs = replace( + self._json_attrs, + type=type, + file=file, + sample_preparation=sample_preparation, + computation=computation, + computation_process=computation_process, + material=material, + process=process, + citation=citation, + ) + + self.validate() + + @property + @beartype + def type(self) -> str: + """ + The data type must come from [CRIPT data type vocabulary](https://app.criptapp.org/vocab/data_type) + + Example + ------- + ```python + data.type = "afm_height" + ``` + + Returns + ------- + data type: str + data type for the data node must come from CRIPT controlled vocabulary + """ + return self._json_attrs.type + + @type.setter + @beartype + def type(self, new_data_type: str) -> None: + """ + set the data type. + The data type must come from [CRIPT data type vocabulary]() + + Parameters + ---------- + new_data_type: str + new data type to replace the current data type + + Returns + ------- + None + """ + # TODO validate that the data type is valid from CRIPT controlled vocabulary + new_attrs = replace(self._json_attrs, type=new_data_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def file(self) -> List[Any]: + """ + get the list of files for this data node + + Examples + -------- + ```python + # create a list of file nodes + my_new_files = [ + # file with link source + cript.File( + source="https://pubs.acs.org/doi/10.1021/acscentsci.3c00011", + type="computation_config", + extension=".pdf", + data_dictionary="my second file data dictionary", + ), + ] + + data_node.file = my_new_files + ``` + + Returns + ------- + List[File] + list of files for this data node + """ + return self._json_attrs.file.copy() + + @file.setter + @beartype + def file(self, new_file_list: List[Any]) -> None: + """ + set the list of file for this data node + + Parameters + ---------- + new_files_list: List[File] + new list of file nodes to replace the current list + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, file=new_file_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def sample_preparation(self) -> Union[Any, None]: + """ + The sample preparation for this data node + + Returns + ------- + sample_preparation: Process + sample preparation for this data node + """ + return self._json_attrs.sample_preparation + + @sample_preparation.setter + @beartype + def sample_preparation(self, new_sample_preparation: Union[Any, None]) -> None: + """ + set sample_preparation + + Parameters + ---------- + new_sample_preparation: Process + new_sample_preparation to replace the current one for this node + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, sample_preparation=new_sample_preparation) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def computation(self) -> List[Any]: + """ + list of computation nodes for this material node + + Returns + ------- + None + list of computation nodes + """ + return self._json_attrs.computation.copy() + + @computation.setter + @beartype + def computation(self, new_computation_list: List[Any]) -> None: + """ + set list of computation for this data node + + Parameters + ---------- + new_computation_list: List[Computation] + new computation list to replace the current one + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, computation=new_computation_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def computation_process(self) -> Union[Any, None]: + """ + The computation_process for this data node + + Returns + ------- + ComputationalProcess + computational process node for this data node + """ + return self._json_attrs.computation_process + + @computation_process.setter + @beartype + def computation_process(self, new_computation_process: Union[Any, None]) -> None: + """ + set the computational process + + Parameters + ---------- + new_computation_process: ComputationalProcess + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, computation_process=new_computation_process) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def material(self) -> List[Any]: + """ + List of materials for this node + + Returns + ------- + List[Material] + list of material + """ + return self._json_attrs.material.copy() + + @material.setter + @beartype + def material(self, new_material_list: List[Any]) -> None: + """ + set the list of materials for this data node + + Parameters + ---------- + new_material_list: List[Material] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, material=new_material_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def process(self) -> List[Any]: + """ + list of [Process nodes](./process.md) for this data node + + Notes + ----- + Please note that while the process attribute of the data node is currently set to `Any` + the software still expects a Process node in the data's process attribute + > It is currently set to `Any` to avoid the circular import error + + Returns + ------- + List[Process] + list of process for the data node + """ + return self._json_attrs.process.copy() + + @process.setter + @beartype + def process(self, new_process_list: List[Any]) -> None: + """ + set the list of process for this data node + + Parameters + ---------- + new_process_list: List[Process] + new list of Process + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, process=new_process_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def citation(self) -> List[Any]: + """ + List of [citation](../../subobjects/citation) within the data node + + Example + ------- + ```python + # create a reference node + my_reference = cript.Reference(type="journal_article", title="'Living' Polymers") + + # create a citation list to house all the reference nodes + my_citation = cript.Citation(type="derived_from", reference=my_reference) + + # add citations to data node + my_data.citation = my_citations + ``` + + Returns + ------- + List[Citation] + list of citations for this data node + """ + return self._json_attrs.citation.copy() + + @citation.setter + @beartype + def citation(self, new_citation_list: List[Any]) -> None: + """ + set the list of citation + + Parameters + ---------- + new_citation_list: List[Citation] + new list of citation to replace the current one + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, citation=new_citation_list) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/primary_nodes/experiment.py b/src/cript/nodes/primary_nodes/experiment.py new file mode 100644 index 000000000..b010aa894 --- /dev/null +++ b/src/cript/nodes/primary_nodes/experiment.py @@ -0,0 +1,402 @@ +from dataclasses import dataclass, field, replace +from typing import Any, List, Optional + +from beartype import beartype + +from cript.nodes.primary_nodes.primary_base_node import PrimaryBaseNode + + +class Experiment(PrimaryBaseNode): + """ + ## Definition + An + [Experiment node](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=9) + is nested inside a [Collection](../collection) node. + + ## Attributes + + | attribute | type | description | required | + |---------------------|------------------------------|-----------------------------------------------------------|----------| + | collection | Collection | collection associated with the experiment | True | + | process | List[Process] | process nodes associated with this experiment | False | + | computations | List[Computation] | computation method nodes associated with this experiment | False | + | computation_process | List[Computational Process] | computation process nodes associated with this experiment | False | + | data | List[Data] | data nodes associated with this experiment | False | + | funding | List[str] | funding source for experiment | False | + | citation | List[Citation] | reference to a book, paper, or scholarly work | False | + + + ## Subobjects + An + [Experiment node](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=9) + can be thought as a folder/bucket that can hold: + + * [Process](../process) + * [Computations](../computation) + * [Computation_Process](../computation_process) + * [Data](../data) + * [Funding](./#cript.nodes.primary_nodes.experiment.Experiment.funding) + * [Citation](../../subobjects/citation) + + + Warnings + -------- + !!! warning "Experiment names" + Experiment names **MUST** be unique within a [Collection](../collection) + + --- + + ## JSON Representation + ```json + { + "name":"my experiment name", + "node":["Experiment"], + "uid":"_:886c4deb-2186-4f11-8134-a37111200b83", + "uuid":"886c4deb-2186-4f11-8134-a37111200b83" + } + ``` + + """ + + @dataclass(frozen=True) + class JsonAttributes(PrimaryBaseNode.JsonAttributes): + """ + all Collection attributes + """ + + process: List[Any] = field(default_factory=list) + computation: List[Any] = field(default_factory=list) + computation_process: List[Any] = field(default_factory=list) + data: List[Any] = field(default_factory=list) + funding: List[str] = field(default_factory=list) + citation: List[Any] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__( + self, + name: str, + process: Optional[List[Any]] = None, + computation: Optional[List[Any]] = None, + computation_process: Optional[List[Any]] = None, + data: Optional[List[Any]] = None, + funding: Optional[List[str]] = None, + citation: Optional[List[Any]] = None, + notes: str = "", + **kwargs + ): + """ + create an Experiment node + + Parameters + ---------- + name: str + name of Experiment + process: List[Process] + list of Process nodes for this Experiment + computation: List[Computation] + list of computation nodes for this Experiment + computation_process: List[ComputationalProcess] + list of computational_process nodes for this Experiment + data: List[Data] + list of data nodes for this experiment + funding: List[str] + list of the funders names for this Experiment + citation: List[Citation] + list of Citation nodes for this experiment + notes: str default="" + notes for the experiment node + + Examples + -------- + ```python + # create an experiment node with all possible arguments + my_experiment = cript.Experiment(name="my experiment name") + ``` + + Returns + ------- + None + Instantiate an Experiment node + """ + + if process is None: + process = [] + if computation is None: + computation = [] + if computation_process is None: + computation_process = [] + if data is None: + data = [] + if funding is None: + funding = [] + if citation is None: + citation = [] + + super().__init__(name=name, notes=notes, **kwargs) + + self._json_attrs = replace( + self._json_attrs, + name=name, + process=process, + computation=computation, + computation_process=computation_process, + data=data, + funding=funding, + citation=citation, + notes=notes, + ) + + # check if the code is still valid + self.validate() + + @property + @beartype + def process(self) -> List[Any]: + """ + List of process for experiment + + ```python + # create a simple process node + my_process = cript.Process(name="my process name", type="affinity_pure") + + my_experiment.process = [my_process] + ``` + + Returns + ------- + List[Process] + List of process that were performed in this experiment + """ + return self._json_attrs.process.copy() + + @process.setter + @beartype + def process(self, new_process_list: List[Any]) -> None: + """ + set the list of process for this experiment + + Parameters + ---------- + new_process_list: List[Process] + new process list to replace the current process list + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, process=new_process_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def computation(self) -> List[Any]: + """ + List of the [computations](../computation) in this experiment + + Examples + -------- + ```python + # create computation node + my_computation = cript.Computation(name="my computation name", type="analysis") + + # add computation node to experiment node + simple_experiment_node.computation = [simple_computation_node] + ``` + + Returns + ------- + List[Computation] + List of [computations](../computation) for this experiment + """ + return self._json_attrs.computation.copy() + + @computation.setter + @beartype + def computation(self, new_computation_list: List[Any]) -> None: + """ + set the list of computations for this experiment + + Parameters + ---------- + new_computation_list: List[Computation] + new list of computations to replace the current list of experiments + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, computation=new_computation_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def computation_process(self) -> List[Any]: + """ + List of [computation_process](../computation_process) for this experiment + + Examples + -------- + ```python + my_computation_process = cript.ComputationalProcess( + name="my computational process name", + type="cross_linking", # must come from CRIPT Controlled Vocabulary + input_data=[input_data], # input data is another data node + ingredients=[ingredients], # output data is another data node + ) + + # add computation_process node to experiment node + my_experiment.computation_process = [my_computational_process] + ``` + + Returns + ------- + List[ComputationalProcess] + computational process that were performed in this experiment + """ + return self._json_attrs.computation_process.copy() + + @computation_process.setter + @beartype + def computation_process(self, new_computation_process_list: List[Any]) -> None: + """ + set the list of computation_process for this experiment + + Parameters + ---------- + new_computation_process_list: List[ComputationalProcess] + new list of computations to replace the current for the experiment + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, computation_process=new_computation_process_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def data(self) -> List[Any]: + """ + List of [data nodes](../data) for this experiment + + Examples + -------- + ```python + # create a simple file node + my_file = cript.File( + source="https://criptapp.org", + type="calibration", + extension=".csv", + data_dictionary="my file's data dictionary", + ) + + # create a simple data node + my_data = cript.Data(name="my data name", type="afm_amp", files=[my_file]) + + my_experiment.data = my_data + ``` + + Returns + ------- + List[Data] + list of [data nodes](../data) that belong to this experiment + """ + return self._json_attrs.data.copy() + + @data.setter + @beartype + def data(self, new_data_list: List[Any]) -> None: + """ + set the list of data for this experiment + + Parameters + ---------- + new_data_list: List[Data] + new list of data to replace the current list for this experiment + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, data=new_data_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def funding(self) -> List[str]: + """ + List of strings of all the funders for this experiment + + Examples + -------- + ```python + my_experiment.funding = ["National Science Foundation", "IRIS", "NIST"] + ``` + + Returns + ------- + List[str] + List of funders for this experiment + """ + return self._json_attrs.funding.copy() + + @funding.setter + @beartype + def funding(self, new_funding_list: List[str]) -> None: + """ + set the list of funders for this experiment + + Parameters + ---------- + new_funding_list: List[str] + replace the current list of funders + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, funding=new_funding_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def citation(self) -> List[Any]: + """ + List of [citation](../../subobjects/citation) for this experiment + + Examples + -------- + ```python + # create citation node + my_citation = cript.Citation(type="derived_from", reference=simple_reference_node) + + # add citation to experiment + my_experiment.citations = [my_citation] + ``` + + Returns + ------- + List[Citation] + list of citations of scholarly work that was used in this experiment + """ + return self._json_attrs.citation.copy() + + @citation.setter + @beartype + def citation(self, new_citation_list: List[Any]) -> None: + """ + set the list of citations for this experiment + + Parameters + ---------- + new_citations_list: List[Citation] + replace the list of citations for this experiment with a new list of citations + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, citation=new_citation_list) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/primary_nodes/inventory.py b/src/cript/nodes/primary_nodes/inventory.py new file mode 100644 index 000000000..d35b56207 --- /dev/null +++ b/src/cript/nodes/primary_nodes/inventory.py @@ -0,0 +1,148 @@ +from dataclasses import dataclass, field, replace +from typing import List + +from beartype import beartype + +from cript.nodes.primary_nodes.material import Material +from cript.nodes.primary_nodes.primary_base_node import PrimaryBaseNode + + +class Inventory(PrimaryBaseNode): + """ + ## Definition + An + [Inventory Node](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=9) + is a list of material nodes. + An example of an inventory can be a grouping of materials that were extracted from literature + and curated into a group for machine learning, or it can be a subset of chemicals that are used for a + certain type of synthesis. + + ## Attributes + + | Attribute | Type | Example | Description | + |------------|---------------------------------|---------------------|-------------------------------------------| + | material | list[[Material](./material.md)] | | material that you like to group together | + + + ## JSON Representation + ```json + { + "name":"my inventory name", + "node":["Inventory"], + "uid":"_:90f45778-b7c9-4b77-8b83-a6ea9671a937", + "uuid":"90f45778-b7c9-4b77-8b83-a6ea9671a937", + "material":[ + { + "node":["Material"], + "name":"my material 1", + "uid":"_:9679ff12-f9b4-41f4-be95-080b78fa71fd", + "uuid":"9679ff12-f9b4-41f4-be95-080b78fa71fd" + "bigsmiles":"[H]{[>][<]C(C[>])c1ccccc1[]}", + }, + { + "node":["Material"], + "name":"my material 2", + "uid":"_:1ee41708-3531-43eb-8049-4bb91ad73df6", + "uuid":"1ee41708-3531-43eb-8049-4bb91ad73df6" + "bigsmiles":"654321", + } + ] + } + ``` + + + """ + + @dataclass(frozen=True) + class JsonAttributes(PrimaryBaseNode.JsonAttributes): + """ + all Inventory attributes + """ + + material: List[Material] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, name: str, material: List[Material], notes: str = "", **kwargs) -> None: + """ + Instantiate an inventory node + + Examples + -------- + ```python + material_1 = cript.Material( + name="material 1", + identifiers=[{"alternative_names": "material 1 alternative name"}], + ) + + material_2 = cript.Material( + name="material 2", + identifiers=[{"alternative_names": "material 2 alternative name"}], + ) + + # instantiate inventory node + my_inventory = cript.Inventory( + name="my inventory name", material=[material_1, material_2] + ) + ``` + + Parameters + ---------- + material: List[Material] + list of materials in this inventory + + Returns + ------- + None + instantiate an inventory node + """ + + if material is None: + material = [] + + super().__init__(name=name, notes=notes, **kwargs) + + self._json_attrs = replace(self._json_attrs, material=material) + + @property + @beartype + def material(self) -> List[Material]: + """ + List of [material](../material) in this inventory + + Examples + -------- + ```python + material_3 = cript.Material( + name="new material 3", + identifiers=[{"alternative_names": "new material 3 alternative name"}], + ) + + my_inventory.material = [my_material_3] + ``` + + Returns + ------- + List[Material] + list of material representing the inventory within the collection + """ + return self._json_attrs.material.copy() + + @material.setter + @beartype + def material(self, new_material_list: List[Material]): + """ + set the list of material for this inventory node + + Parameters + ---------- + new_material_list: List[Material] + new list of material to replace the current list of material nodes for this inventory node + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, material=new_material_list) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/primary_nodes/material.py b/src/cript/nodes/primary_nodes/material.py new file mode 100644 index 000000000..fae97926c --- /dev/null +++ b/src/cript/nodes/primary_nodes/material.py @@ -0,0 +1,445 @@ +from dataclasses import dataclass, field, replace +from typing import Any, Dict, List, Optional + +from beartype import beartype + +from cript.nodes.primary_nodes.primary_base_node import PrimaryBaseNode +from cript.nodes.primary_nodes.process import Process + + +class Material(PrimaryBaseNode): + """ + ## Definition + A [Material node](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=10) + is a collection of the identifiers and properties of a chemical, mixture, or substance. + + ## Attributes + | attribute | type | example | description | required | vocab | + |---------------------------|----------------------------------------------------------------------|---------------------------------------------------|----------------------------------------------|-------------|-------| + | identifiers | list[Identifier] | | material identifiers | True | | + | component | list[[Material](./)] | | list of component that make up the mixture | | | + | property | list[[Property](../../subobjects/property)] | | material properties | | | + | process | [Process](../process) | | process node that made this material | | | + | parent_material | [Material](./) | | material node that this node was copied from | | | + | computational_ forcefield | [Computation Forcefield](../../subobjects/computational_forcefield) | | computation forcefield | Conditional | | + | keyword | list[str] | [thermoplastic, homopolymer, linear, polyolefins] | words that classify the material | | True | + + ## Navigating to Material + Materials can be easily found on the [CRIPT](https://app.criptapp.org) home screen in the + under the navigation within the [Materials link](https://app.criptapp.org/material/) + + ## Available Sub-Objects for Material + * [Identifier](../../subobjects/identifier) + * [Property](../../subobjects/property) + * [Computational_forcefield](../../subobjects/computational_forcefield) + + Example + ------- + water, brine (water + NaCl), polystyrene, polyethylene glycol hydrogels, vulcanized polyisoprene, mcherry (protein), and mica + + + Warnings + ------- + !!! warning "Material names" + Material names Must be unique within a [Project](../project) + + ```json + { + "node":["Material"], + "name":"my unique material name", + "uid":"_:9679ff12-f9b4-41f4-be95-080b78fa71fd", + "uuid":"9679ff12-f9b4-41f4-be95-080b78fa71fd" + "bigsmiles":"[H]{[>][<]C(C[>])c1ccccc1[]}", + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(PrimaryBaseNode.JsonAttributes): + """ + all Material attributes + """ + + # identifier sub-object for the material + identifiers: List[Dict[str, str]] = field(default_factory=dict) # type: ignore + # TODO add proper typing in future, using Any for now to avoid circular import error + component: List["Material"] = field(default_factory=list) + process: Optional[Process] = None + property: List[Any] = field(default_factory=list) + parent_material: Optional["Material"] = None + computational_forcefield: Optional[Any] = None + keyword: List[str] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__( + self, + name: str, + identifiers: List[Dict[str, str]], + component: Optional[List["Material"]] = None, + process: Optional[Process] = None, + property: Optional[List[Any]] = None, + parent_material: Optional["Material"] = None, + computational_forcefield: Optional[Any] = None, + keyword: Optional[List[str]] = None, + notes: str = "", + **kwargs + ): + """ + create a material node + + Parameters + ---------- + name: str + identifiers: List[Dict[str, str]] + component: List["Material"], default=None + property: Optional[Process], default=None + process: List[Process], default=None + parent_material: "Material", default=None + computational_forcefield: ComputationalForcefield, default=None + keyword: List[str], default=None + + Returns + ------- + None + Instantiate a material node + """ + + super().__init__(name=name, notes=notes, **kwargs) + + if component is None: + component = [] + + if property is None: + property = [] + + if keyword is None: + keyword = [] + + self._json_attrs = replace( + self._json_attrs, + name=name, + identifiers=identifiers, + component=component, + process=process, + property=property, + parent_material=parent_material, + computational_forcefield=computational_forcefield, + keyword=keyword, + ) + + @property + @beartype + def name(self) -> str: + """ + material name + + Examples + ```python + my_material.name = "my new material" + ``` + + Returns + ------- + str + material name + """ + return self._json_attrs.name + + @name.setter + @beartype + def name(self, new_name: str) -> None: + """ + set the name of the material + + Parameters + ---------- + new_name: str + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, name=new_name) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def identifiers(self) -> List[Dict[str, str]]: + """ + get the identifiers for this material + + ```python + my_material.identifier = {"alternative_names": "my material alternative name"} + ``` + + [material identifier key](https://app.criptapp.org/vocab/material_identifier_key) + must come from CRIPT controlled vocabulary + + Returns + ------- + List[Dict[str, str]] + list of dictionary that has identifiers for this material + """ + return self._json_attrs.identifiers.copy() + + @identifiers.setter + @beartype + def identifiers(self, new_identifiers_list: List[Dict[str, str]]) -> None: + """ + set the list of identifiers for this material + + the identifier keys must come from the + material identifiers keyword within the CRIPT controlled vocabulary + + Parameters + ---------- + new_identifiers_list: List[Dict[str, str]] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, identifiers=new_identifiers_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def component(self) -> List["Material"]: + """ + list of components ([material nodes](./)) that make up this material + + Examples + -------- + ```python + # material component + my_component = [ + # create material node + cript.Material( + name="my component material 1", + identifiers=[{"alternative_names": "component 1 alternative name"}], + ), + + # create material node + cript.Material( + name="my component material 2", + identifiers=[{"alternative_names": "component 2 alternative name"}], + ), + ] + + + identifiers = [{"alternative_names": "my material alternative name"}] + my_material = cript.Material(name="my material", component=my_component, identifiers=identifiers) + ``` + + Returns + ------- + List[Material] + list of component that make up this material + """ + return self._json_attrs.component + + @component.setter + @beartype + def component(self, new_component_list: List["Material"]) -> None: + """ + set the list of component (material nodes) that make up this material + + Parameters + ---------- + new_component_list: List["Material"] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, component=new_component_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def parent_material(self) -> Optional["Material"]: + """ + List of parent materials + + Returns + ------- + List["Material"] + list of parent materials + """ + return self._json_attrs.parent_material + + @parent_material.setter + @beartype + def parent_material(self, new_parent_material: "Material") -> None: + """ + set the [parent materials](./) for this material + + Parameters + ---------- + new_parent_material: "Material" + + Returns + ------- + None + """ + + new_attrs = replace(self._json_attrs, parent_material=new_parent_material) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def computational_forcefield(self) -> Any: + """ + list of [computational_forcefield](../../subobjects/computational_forcefield) for this material node + + Returns + ------- + List[ComputationForcefield] + list of computational_forcefield that created this material + """ + return self._json_attrs.computational_forcefield + + @computational_forcefield.setter + @beartype + def computational_forcefield(self, new_computational_forcefield_list: Any) -> None: + """ + sets the list of computational forcefields for this material + + Parameters + ---------- + new_computation_forcefield_list: List[ComputationalForcefield] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, computational_forcefield=new_computational_forcefield_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def keyword(self) -> List[str]: + """ + List of keyword for this material + + the material keyword must come from the + [CRIPT controlled vocabulary](https://app.criptapp.org/vocab/material_keyword) + + ```python + identifiers = [{"alternative_names": "my material alternative name"}] + + # keyword + material_keyword = ["acetylene", "acrylate", "alternating"] + + my_material = cript.Material( + name="my material", keyword=material_keyword, identifiers=identifiers + ) + ``` + + Returns + ------- + List[str] + list of material keyword + """ + return self._json_attrs.keyword + + @keyword.setter + @beartype + def keyword(self, new_keyword_list: List[str]) -> None: + """ + set the keyword for this material + + the material keyword must come from the CRIPT controlled vocabulary + + Parameters + ---------- + new_keyword_list + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, keyword=new_keyword_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def process(self) -> Optional[Process]: + return self._json_attrs.process # type: ignore + + @process.setter + def process(self, new_process: Process) -> None: + new_attrs = replace(self._json_attrs, process=new_process) + self._update_json_attrs_if_valid(new_attrs) + + @property + def property(self) -> List[Any]: + """ + list of material [property](../../subobjects/property) + + ```python + # property subobject + my_property = cript.Property(key="modulus_shear", type="min", value=1.23, unit="gram") + + my_material.property = my_property + ``` + + Returns + ------- + List[Property] + list of property that define this material + """ + return self._json_attrs.property.copy() + + @property.setter + @beartype + def property(self, new_property_list: List[Any]) -> None: + """ + set the list of properties for this material + + Parameters + ---------- + new_property_list: List[Property] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, property=new_property_list) + self._update_json_attrs_if_valid(new_attrs) + + @classmethod + @beartype + def _from_json(cls, json_dict: Dict): + """ + Create a new instance of a node from a JSON representation. + + Parameters + ---------- + json_dict : Dict + A JSON dictionary representing a node + + Returns + ------- + node + A new instance of a node. + + Notes + ----- + required fields in JSON: + * `name`: The name of the node + + optional fields in JSON: + * `identifiers`: A list of material identifiers. + * If the `identifiers` property is not present in the JSON dictionary, + it will be set to an empty list. + """ + from cript.nodes.util.material_deserialization import ( + _deserialize_flattened_material_identifiers, + ) + + json_dict = _deserialize_flattened_material_identifiers(json_dict) + + return super()._from_json(json_dict) diff --git a/src/cript/nodes/primary_nodes/primary_base_node.py b/src/cript/nodes/primary_nodes/primary_base_node.py new file mode 100644 index 000000000..7b4dc86c6 --- /dev/null +++ b/src/cript/nodes/primary_nodes/primary_base_node.py @@ -0,0 +1,119 @@ +from abc import ABC +from dataclasses import dataclass, replace + +from beartype import beartype + +from cript.nodes.uuid_base import UUIDBaseNode + + +class PrimaryBaseNode(UUIDBaseNode, ABC): + """ + Abstract class that defines what it means to be a PrimaryNode, + and other primary nodes can inherit from. + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + """ + All shared attributes between all Primary nodes and set to their default values + """ + + locked: bool = False + model_version: str = "" + public: bool = False + name: str = "" + notes: str = "" + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, name: str, notes: str, **kwargs): + # initialize Base class with node + super().__init__(**kwargs) + # replace name and notes within PrimaryBase + self._json_attrs = replace(self._json_attrs, name=name, notes=notes) + + @beartype + def __str__(self) -> str: + """ + Return a string representation of a primary node dataclass attributes. + Every node that inherits from this class should overwrite it to best fit + their use case, but this provides a nice default value just in case + + Examples + -------- + { + 'locked': False, + 'model_version': '', + 'public': False, + 'notes': '' + } + + + Returns + ------- + str + A string representation of the primary node common attributes. + """ + return super().__str__() + + @property + @beartype + def locked(self): + return self._json_attrs.locked + + @property + @beartype + def model_version(self): + return self._json_attrs.model_version + + @property + @beartype + def updated_by(self): + return self._json_attrs.updated_by + + @property + @beartype + def created_by(self): + return self._json_attrs.created_by + + @property + @beartype + def public(self): + return self._json_attrs.public + + @property + @beartype + def name(self): + return self._json_attrs.name + + @name.setter + @beartype + def name(self, new_name: str) -> None: + """ + set the PrimaryBaseNode name + + Parameters + ---------- + new_name: str + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, name=new_name) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def notes(self): + return self._json_attrs.notes + + @notes.setter + @beartype + def notes(self, new_notes: str) -> None: + """ + allow every node that inherits base attributes to set its notes + """ + new_attrs = replace(self._json_attrs, notes=new_notes) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/primary_nodes/process.py b/src/cript/nodes/primary_nodes/process.py new file mode 100644 index 000000000..67ba1667f --- /dev/null +++ b/src/cript/nodes/primary_nodes/process.py @@ -0,0 +1,590 @@ +from dataclasses import dataclass, field, replace +from typing import Any, List, Optional + +from beartype import beartype + +from cript.nodes.primary_nodes.primary_base_node import PrimaryBaseNode + + +class Process(PrimaryBaseNode): + """ + ## Definition + The process node contains a list of ingredients, quantities, and procedure information for an experimental material + transformation (chemical and physical). + + ## Attributes + + | attribute | type | example | description | required | vocab | + |-------------------------|------------------|---------------------------------------------------------------------------------|---------------------------------------------------------------------|----------|-------| + | type | str | mix | type of process | True | True | + | ingredient | list[Ingredient] | | ingredients | | | + | description | str | To oven-dried 20 mL glass vial, 5 mL of styrene and 10 ml of toluene was added. | explanation of the process | | | + | equipment | list[Equipment] | | equipment used in the process | | | + | product | list[Material] | | desired material produced from the process | | | + | waste | list[Material] | | material sent to waste | | | + | prerequisite_ processes | list[Process] | | processes that must be completed prior to the start of this process | | | + | condition | list[Condition] | | global process condition | | | + | property | list[Property] | | process properties | | | + | keyword | list[str] | | words that classify the process | | True | + | citation | list[Citation] | | reference to a book, paper, or scholarly work | | | + + ## Can be added to + * [Experiment](../experiment) + + ## Available Subobjects + * [Ingredient](../../subobjects/ingredient) + * [Equipment](../../subobjects/equipment) + * [Property](../../subobjects/property) + * [Condition](../../subobjects/condition) + * [Citation](../../subobjects/citation) + + ## JSON Representation + ```json + { + "name":"my minimal process name", + "node":["Process"], + "type":"affinity_pure", + "keyword":[], + "uid":"_:f8ef33f3-677a-40f3-b24e-65ab2c99d796", + "uuid":"f8ef33f3-677a-40f3-b24e-65ab2c99d796" + } + ``` + + """ + + @dataclass(frozen=True) + class JsonAttributes(PrimaryBaseNode.JsonAttributes): + """ + all Process attributes + """ + + type: str = "" + # TODO add proper typing in future, using Any for now to avoid circular import error + ingredient: List[Any] = field(default_factory=list) + description: str = "" + equipment: List[Any] = field(default_factory=list) + product: List[Any] = field(default_factory=list) + waste: List[Any] = field(default_factory=list) + prerequisite_process: List["Process"] = field(default_factory=list) + condition: List[Any] = field(default_factory=list) + property: List[Any] = field(default_factory=list) + keyword: List[str] = field(default_factory=list) + citation: List[Any] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__( + self, + name: str, + type: str, + ingredient: Optional[List[Any]] = None, + description: str = "", + equipment: Optional[List[Any]] = None, + product: Optional[List[Any]] = None, + waste: Optional[List[Any]] = None, + prerequisite_process: Optional[List[Any]] = None, + condition: Optional[List[Any]] = None, + property: Optional[List[Any]] = None, + keyword: Optional[List[str]] = None, + citation: Optional[List[Any]] = None, + notes: str = "", + **kwargs + ) -> None: + """ + create a process node + + ```python + my_process = cript.Process(name="my process name", type="affinity_pure") + ``` + + Parameters + ---------- + ingredient: List[Ingredient] + [ingredient](../../subobjects/ingredient) used in this process + type: str = "" + Process type must come from + [CRIPT Controlled vocabulary process type](https://app.criptapp.org/vocab/process-type/) + description: str = "" + description of this process + equipment: List[Equipment] = None + list of [equipment](../../subobjects/equipment) used in this process + product: List[Material] = None + product that this process created + waste: List[Material] = None + waste that this process created + condition: List[Condition] = None + list of [condition](../../subobjects/condition) that this process was created under + property: List[Property] = None + list of [properties](../../subobjects/property) for this process + keyword: List[str] = None + list of keywords for this process must come from + [CRIPT process keyword controlled keyword](https://app.criptapp.org/vocab/process-keyword/) + citation: List[Citation] = None + list of [citation](../../subobjects/citation) + + Returns + ------- + None + instantiate a process node + """ + + if ingredient is None: + ingredient = [] + + if equipment is None: + equipment = [] + + if product is None: + product = [] + + if waste is None: + waste = [] + + if prerequisite_process is None: + prerequisite_process = [] + + if condition is None: + condition = [] + + if property is None: + property = [] + + if keyword is None: + keyword = [] + + if citation is None: + citation = [] + + super().__init__(name=name, notes=notes, **kwargs) + + new_attrs = replace( + self._json_attrs, + ingredient=ingredient, + type=type, + description=description, + equipment=equipment, + product=product, + waste=waste, + condition=condition, + prerequisite_process=prerequisite_process, + property=property, + keyword=keyword, + citation=citation, + ) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def type(self) -> str: + """ + [Process type](https://app.criptapp.org/vocab/process_type) must come from the CRIPT controlled vocabulary + + Examples + -------- + ```python + my_process.type = "affinity_pure" + ``` + + Returns + ------- + str + Select a [Process type](https://app.criptapp.org/vocab/process-type/) from CRIPT controlled vocabulary + """ + return self._json_attrs.type + + @type.setter + @beartype + def type(self, new_process_type: str) -> None: + """ + set process type from CRIPT controlled vocabulary + + Parameters + ---------- + new_process_type: str + new process type from CRIPT controlled vocabulary + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, type=new_process_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def ingredient(self) -> List[Any]: + """ + List of [ingredient](../../subobjects/ingredient) for this process + + Examples + --------- + ```python + my_ingredients = cript.Ingredient( + material=simple_material_node, + quantities=[simple_quantity_node], + ) + + my_process.ingredient = [my_ingredients] + ``` + + Returns + ------- + List[Ingredient] + list of ingredients for this process + """ + return self._json_attrs.ingredient.copy() + + @ingredient.setter + @beartype + def ingredient(self, new_ingredient_list: List[Any]) -> None: + """ + set the list of the ingredients for this process + + Parameters + ---------- + new_ingredient_list + list of ingredients to replace the current list + + Returns + ------- + None + """ + # TODO need to validate with CRIPT controlled vocabulary + # and if invalid then raise an error immediately + new_attrs = replace(self._json_attrs, ingredient=new_ingredient_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def description(self) -> str: + """ + description of this process + + Examples + -------- + ```python + my_process.description = "To oven-dried 20 mL glass vial, 5 mL of styrene and 10 ml of toluene was added" + ``` + + Returns + ------- + str + description of this process + """ + return self._json_attrs.description + + @description.setter + @beartype + def description(self, new_description: str) -> None: + """ + set the description of this process + + Parameters + ---------- + new_description: str + new process description to replace the current one + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, description=new_description) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def equipment(self) -> List[Any]: + """ + List of [equipment](../../subobjects/equipment) used for this process + + Returns + ------- + List[Equipment] + list of equipment used for this process + """ + return self._json_attrs.equipment.copy() + + @equipment.setter + @beartype + def equipment(self, new_equipment_list: List[Any]) -> None: + """ + set the list of equipment used for this process + + Parameters + ---------- + new_equipment_list + new equipment list to replace the current equipment list for this process + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, equipment=new_equipment_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def product(self) -> List[Any]: + """ + List of product (material nodes) for this process + + Returns + ------- + List[Material] + List of process product (Material nodes) + """ + return self._json_attrs.product.copy() + + @product.setter + @beartype + def product(self, new_product_list: List[Any]) -> None: + """ + set the product list for this process + + Parameters + ---------- + new_product_list: List[Material] + replace the current list of process product + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, product=new_product_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def waste(self) -> List[Any]: + """ + List of waste that resulted from this process + + Examples + -------- + ```python + my_process.waste = my_waste_material + ``` + + Returns + ------- + List[Material] + list of waste materials that resulted from this product + """ + return self._json_attrs.waste.copy() + + @waste.setter + @beartype + def waste(self, new_waste_list: List[Any]) -> None: + """ + set the list of waste (Material node) for that resulted from this process + + Parameters + ---------- + new_waste_list: List[Material] + replace the list waste that resulted from this process + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, waste=new_waste_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def prerequisite_process(self) -> List["Process"]: + """ + list of prerequisite process nodes + + Examples + -------- + ```python + + my_prerequisite_process = [ + cript.Process(name="prerequisite processes 1", type="blow_molding"), + cript.Process(name="prerequisite processes 2", type="centrifugation"), + ] + + my_process.prerequisite_process = my_prerequisite_process + ``` + + Returns + ------- + List[Process] + list of process that had to happen before this process + """ + return self._json_attrs.prerequisite_process.copy() + + @prerequisite_process.setter + @beartype + def prerequisite_process(self, new_prerequisite_process_list: List["Process"]) -> None: + """ + set the prerequisite_process for the process node + + Parameters + ---------- + new_prerequisite_process_list: List["Process"] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, prerequisite_process=new_prerequisite_process_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def condition(self) -> List[Any]: + """ + List of condition present for this process + + Examples + ------- + ```python + # create condition node + my_condition = cript.Condition(key="atm", type="min", value=1) + + my_process.condition = [my_condition] + ``` + + Returns + ------- + List[Condition] + list of condition for this process node + """ + return self._json_attrs.condition.copy() + + @condition.setter + @beartype + def condition(self, new_condition_list: List[Any]) -> None: + """ + set the list of condition for this process + + Parameters + ---------- + new_condition_list: List[Condition] + replace the condition list + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, condition=new_condition_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def keyword(self) -> List[str]: + """ + List of keyword for this process + + [Process keyword](https://app.criptapp.org/vocab/process-keyword/) must come from CRIPT controlled vocabulary + + Returns + ------- + List[str] + list of keywords for this process nod + """ + return self._json_attrs.keyword.copy() # type: ignore + + @keyword.setter + @beartype + def keyword(self, new_keyword_list: List[str]) -> None: + """ + set the list of keyword for this process from CRIPT controlled vocabulary + + Parameters + ---------- + new_keyword_list: List[str] + replace the current list of keyword + + Returns + ------- + None + """ + # TODO validate with CRIPT controlled vocabulary + new_attrs = replace(self._json_attrs, keyword=new_keyword_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def citation(self) -> List[Any]: + """ + List of citation for this process + + Examples + -------- + ```python + # crate reference node for this citation + my_reference = cript.Reference(type="journal_article", title="'Living' Polymers") + + # create citation node + my_citation = cript.Citation(type="derived_from", reference=my_reference) + + my_process.citation = [my_citation] + ``` + + Returns + ------- + List[Citation] + list of citation for this process node + """ + return self._json_attrs.citation.copy() + + @citation.setter + @beartype + def citation(self, new_citation_list: List[Any]) -> None: + """ + set the list of citation for this process + + Parameters + ---------- + new_citation_list: List[Citation] + replace the current list of citation + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, citation=new_citation_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def property(self) -> List[Any]: + """ + List of [Property nodes](../../subobjects/property) for this process + + Examples + -------- + ```python + # create property node + my_property = cript.Property(key="modulus_shear", type="min", value=1.23, unit="gram") + + my_process.properties = [my_property] + ``` + + Returns + ------- + List[Property] + list of properties for this process + """ + return self._json_attrs.property.copy() + + @property.setter + @beartype + def property(self, new_property_list: List[Any]) -> None: + """ + set the list of Property nodes for this process + + Parameters + ---------- + new_property_list: List[Property] + replace the current list of properties + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, property=new_property_list) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/primary_nodes/project.py b/src/cript/nodes/primary_nodes/project.py new file mode 100644 index 000000000..06805251f --- /dev/null +++ b/src/cript/nodes/primary_nodes/project.py @@ -0,0 +1,232 @@ +from dataclasses import dataclass, field, replace +from typing import List, Optional + +from beartype import beartype + +from cript.nodes.primary_nodes.collection import Collection +from cript.nodes.primary_nodes.material import Material +from cript.nodes.primary_nodes.primary_base_node import PrimaryBaseNode +from cript.nodes.supporting_nodes import User + + +class Project(PrimaryBaseNode): + """ + ## Definition + A [Project](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=7) + is the highest level node that is Not nested inside any other node. + A Project can be thought of as a folder that can contain [Collections](../collection) and + [Materials](../material). + + + | attribute | type | description | + |-------------|------------------|----------------------------------------| + | collection | List[Collection] | collections that relate to the project | + | materials | List[Materials] | materials owned by the project | + + ## JSON Representation + ```json + { + "name":"my project name", + "node":["Project"], + "uid":"_:270168b7-fc29-4c37-aa93-334212e1d962", + "uuid":"270168b7-fc29-4c37-aa93-334212e1d962", + "collection":[ + { + "name":"my collection name", + "node":["Collection"], + "uid":"_:c60955a5-4de0-4da5-b2c8-77952b1d9bfa", + "uuid":"c60955a5-4de0-4da5-b2c8-77952b1d9bfa", + "experiment":[ + { + "name":"my experiment name", + "node":["Experiment"], + "uid":"_:a8cbc083-506e-45ce-bb8f-5e50917ab361", + "uuid":"a8cbc083-506e-45ce-bb8f-5e50917ab361" + } + ], + "inventory":[], + "citation":[] + } + ] + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(PrimaryBaseNode.JsonAttributes): + """ + all Project attributes + """ + + member: List[User] = field(default_factory=list) + admin: List[User] = field(default_factory=list) + collection: List[Collection] = field(default_factory=list) + material: List[Material] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, name: str, collection: Optional[List[Collection]] = None, material: Optional[List[Material]] = None, notes: str = "", **kwargs): + """ + Create a Project node with Project name and Group + + Parameters + ---------- + name: str + project name + collection: List[Collection] + list of Collections that belongs to this Project + material: List[Material] + list of materials that belongs to this project + notes: str + notes for this project + + Returns + ------- + None + instantiate a Project node + """ + super().__init__(name=name, notes=notes, **kwargs) + + if collection is None: + collection = [] + + if material is None: + material = [] + + self._json_attrs = replace(self._json_attrs, name=name, collection=collection, material=material) + self.validate() + + def validate(self, api=None, is_patch=False): + from cript.nodes.exceptions import ( + CRIPTOrphanedMaterialError, + get_orphaned_experiment_exception, + ) + + # First validate like other nodes + super().validate(api=api, is_patch=is_patch) + + # Check graph for orphaned nodes, that should be listed in project + # Project.materials should contain all material nodes + project_graph_materials = self.find_children({"node": ["Material"]}) + # Combine all materials listed in the project inventories + project_inventory_materials = [] + for inventory in self.find_children({"node": ["Inventory"]}): + for material in inventory.material: + project_inventory_materials.append(material) + for material in project_graph_materials: + if material not in self.material and material not in project_inventory_materials: + raise CRIPTOrphanedMaterialError(material) + + # Check graph for orphaned nodes, that should be listed in the experiments + project_experiments = self.find_children({"node": ["Experiment"]}) + # There are 4 different types of nodes Experiments are collecting. + node_types = ("Process", "Computation", "ComputationProcess", "Data") + # We loop over them with the same logic + for node_type in node_types: + # All in the graph has to be in at least one experiment + project_graph_nodes = self.find_children({"node": [node_type]}) + node_type_attr = node_type.lower() + # Non-consistent naming makes this necessary for Computation Process + if node_type == "ComputationProcess": + node_type_attr = "computation_process" + + # Concatenation of all experiment attributes (process, computation, etc.) + # Every node of the graph must be present somewhere in this concatenated list. + experiment_nodes = [] + for experiment in project_experiments: + for ex_node in getattr(experiment, node_type_attr): + experiment_nodes.append(ex_node) + for node in project_graph_nodes: + if node not in experiment_nodes: + raise get_orphaned_experiment_exception(node) + + @property + @beartype + def member(self) -> List[User]: + return self._json_attrs.member.copy() + + @property + @beartype + def admin(self) -> List[User]: + return self._json_attrs.admin + + @property + @beartype + def collection(self) -> List[Collection]: + """ + Collection is a Project node's property that can be set during creation in the constructor + or later by setting the project's property + + Examples + -------- + ```python + my_new_collection = cript.Collection( + name="my collection name", experiments=[my_experiment_node] + ) + + my_project.collection = my_new_collection + ``` + + Returns + ------- + Collection: List[Collection] + the list of collections within this project + """ + return self._json_attrs.collection + + @collection.setter + @beartype + def collection(self, new_collection: List[Collection]) -> None: + """ + set list of collections for the project node + + Parameters + ---------- + new_collection: List[Collection] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, collection=new_collection) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def material(self) -> List[Material]: + """ + List of Materials that belong to this Project. + + Examples + -------- + ```python + identifiers = [{"alternative_names": "my material alternative name"}] + my_material = cript.Material(name="my material", identifiers=identifiers) + + my_project.material = [my_material] + ``` + + Returns + ------- + Material: List[Material] + List of materials that belongs to this project + """ + return self._json_attrs.material + + @material.setter + @beartype + def material(self, new_materials: List[Material]) -> None: + """ + set the list of materials for this project + + Parameters + ---------- + new_materials: List[Material] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, material=new_materials) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/primary_nodes/reference.py b/src/cript/nodes/primary_nodes/reference.py new file mode 100644 index 000000000..e4ba0603f --- /dev/null +++ b/src/cript/nodes/primary_nodes/reference.py @@ -0,0 +1,689 @@ +from dataclasses import dataclass, field, replace +from typing import List, Optional, Union + +from beartype import beartype + +from cript.nodes.uuid_base import UUIDBaseNode + + +class Reference(UUIDBaseNode): + """ + ## Definition + + The + [Reference node](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=15) + + contains the metadata for a literature publication, book, or anything external to CRIPT. + The reference node does NOT contain the base attributes. + + The reference node is always used inside the citation + sub-object to enable users to specify the context of the reference. + + ## Attributes + | attribute | type | example | description | required | vocab | + |-----------|-----------|--------------------------------------------|-----------------------------------------------|---------------|-------| + | type | str | journal_article | type of literature | True | True | + | title | str | 'Living' Polymers | title of publication | True | | + | author | list[str] | Michael Szwarc | list of authors | | | + | journal | str | Nature | journal of the publication | | | + | publisher | str | Springer | publisher of publication | | | + | year | int | 1956 | year of publication | | | + | volume | int | 178 | volume of publication | | | + | issue | int | 0 | issue of publication | | | + | pages | list[int] | [1168, 1169] | page range of publication | | | + | doi | str | 10.1038/1781168a0 | DOI: digital object identifier | Conditionally | | + | issn | str | 1476-4687 | ISSN: international standard serial number | Conditionally | | + | arxiv_id | str | 1501 | arXiv identifier | | | + | pmid | int | ######## | PMID: PubMed ID | | | + | website | str | https://www.nature.com/artic les/1781168a0 | website where the publication can be accessed | | | + + + ## Can be added to + * [Citation](../../subobjects/citation) + + ## Available Subobjects + * None + + !!! warning "Reference will always be public" + Reference node is meant to always be public and static to allow globally link data to the reference + + ## JSON Representation + ```json + { + "node":["Reference"], + "uid":"_:c681a947-0554-4acd-a01c-06ad76e34b87", + "uuid":"c681a947-0554-4acd-a01c-06ad76e34b87", + "author":["Ludwig Schneider","Marcus Müller"], + "doi":"10.1016/j.cpc.2018.08.011", + "issn":"0010-4655", + "journal":"Computer Physics Communications", + "pages":[463,476], + "publisher":"Elsevier", + "title":"Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: SOft coarse grained Monte-Carlo Acceleration (SOMA)", + "type":"journal_article", + "website":"https://www.sciencedirect.com/science/article/pii/S0010465518303072", + "year":2019 + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + """ + all reference nodes attributes + + all int types are also None type in case they are not present it should be properly shown as None + instead of a placeholder number such as 0 or -1 + """ + + type: str = "" + title: str = "" + author: List[str] = field(default_factory=list) + journal: str = "" + publisher: str = "" + year: Optional[int] = None + volume: Optional[int] = None + issue: Optional[int] = None + pages: List[int] = field(default_factory=list) + doi: str = "" + issn: str = "" + arxiv_id: str = "" + pmid: Optional[int] = None + website: str = "" + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__( + self, + type: str, + title: str, + author: Optional[List[str]] = None, + journal: str = "", + publisher: str = "", + year: Optional[int] = None, + volume: Optional[int] = None, + issue: Optional[int] = None, + pages: Optional[List[int]] = None, + doi: str = "", + issn: str = "", + arxiv_id: str = "", + pmid: Optional[int] = None, + website: str = "", + **kwargs, + ): + """ + create a reference node + + reference type must come from CRIPT controlled vocabulary + + Parameters + ---------- + type: str + type of literature. + The reference type must come from CRIPT controlled vocabulary + title: str + title of publication + author: List[str] default="" + list of authors + journal: str default="" + journal of publication + publisher: str default="" + publisher of publication + year: int default=None + year of publication + volume: int default=None + volume of publication + issue: int default=None + issue of publication + pages: List[int] default=None + page range of publication + doi: str default="" + DOI: digital object identifier + issn: str default="" + ISSN: international standard serial number + arxiv_id: str default="" + arXiv identifier + pmid: int default=None + PMID: PubMed ID + website: str default="" + website where the publication can be accessed + + + Examples + -------- + ```python + my_reference = cript.Reference(type="journal_article", title="'Living' Polymers") + ``` + + Returns + ------- + None + Instantiate a reference node + """ + if author is None: + author = [] + + if pages is None: + pages = [] + + super().__init__(**kwargs) + + new_attrs = replace(self._json_attrs, type=type, title=title, author=author, journal=journal, publisher=publisher, year=year, volume=volume, issue=issue, pages=pages, doi=doi, issn=issn, arxiv_id=arxiv_id, pmid=pmid, website=website) + + self._update_json_attrs_if_valid(new_attrs) + self.validate() + + @property + @beartype + def type(self) -> str: + """ + Type of reference. + + The [reference type](https://app.criptapp.org/vocab/reference_type) + must come from the CRIPT controlled vocabulary + + Examples + -------- + ```python + my_reference.type = "journal_article" + ``` + + Returns + ------- + str + reference type + """ + return self._json_attrs.type + + @type.setter + @beartype + def type(self, new_reference_type: str) -> None: + """ + set the reference type attribute + + reference type must come from the CRIPT controlled vocabulary + + Parameters + ---------- + new_reference_type: str + + Returns + ------- + None + """ + # TODO validate the reference type with CRIPT controlled vocabulary + new_attrs = replace(self._json_attrs, type=new_reference_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def title(self) -> str: + """ + title of publication + + Examples + -------- + ```python + my_reference.title = "my new title" + ``` + + Returns + ------- + str + title of publication + """ + return self._json_attrs.title + + @title.setter + @beartype + def title(self, new_title: str) -> None: + """ + set the title for the reference node + + Parameters + ---------- + new_title: str + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, title=new_title) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def author(self) -> List[str]: + """ + List of authors for this reference node + + Examples + -------- + ```python + my_reference.author = ["Bradley D. Olsen", "Dylan Walsh"] + ``` + + Returns + ------- + List[str] + list of authors + """ + return self._json_attrs.author.copy() + + @author.setter + @beartype + def author(self, new_author: List[str]) -> None: + """ + set the list of authors for the reference node + + Parameters + ---------- + new_author: List[str] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, author=new_author) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def journal(self) -> str: + """ + journal of publication + + Examples + -------- + ```python + my_reference.journal = "my new journal" + ``` + + Returns + ------- + str + journal of publication + """ + return self._json_attrs.journal + + @journal.setter + @beartype + def journal(self, new_journal: str) -> None: + """ + set the journal attribute for this reference node + + Parameters + ---------- + new_journal: str + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, journal=new_journal) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def publisher(self) -> str: + """ + publisher for this reference node + + Examples + -------- + ```python + my_reference.publisher = "my new publisher" + ``` + + Returns + ------- + str + publisher of this publication + """ + return self._json_attrs.publisher + + @publisher.setter + @beartype + def publisher(self, new_publisher: str) -> None: + """ + set the publisher for this reference node + + Parameters + ---------- + new_publisher: str + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, publisher=new_publisher) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def year(self) -> Union[int, None]: + """ + year for the scholarly work + + Examples + -------- + ```python + my_reference.year = 2023 + ``` + + Returns + ------- + int + """ + return self._json_attrs.year + + @year.setter + @beartype + def year(self, new_year: Union[int, None]) -> None: + """ + set the year for the scholarly work within the reference node + + Parameters + ---------- + new_year: int + + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, year=new_year) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def volume(self) -> Union[int, None]: + """ + Volume of the scholarly work from the reference node + + Examples + -------- + ```python + my_reference.volume = 1 + ``` + + Returns + ------- + int + volume number of the publishing + """ + return self._json_attrs.volume + + @volume.setter + @beartype + def volume(self, new_volume: Union[int, None]) -> None: + """ + set the volume of the scholarly work for this reference node + + Parameters + ---------- + new_volume: int + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, volume=new_volume) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def issue(self) -> Union[int, None]: + """ + issue of the scholarly work for the reference node + + Examples + -------- + ```python + my_reference.issue = 2 + ``` + + Returns + ------- + None + """ + return self._json_attrs.issue + + @issue.setter + @beartype + def issue(self, new_issue: Union[int, None]) -> None: + """ + set the issue of the scholarly work + + Parameters + ---------- + new_issue: int + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, issue=new_issue) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def pages(self) -> List[int]: + """ + pages of the scholarly work used in the reference node + + Examples + -------- + ```python + my_reference.pages = [123, 456] + ``` + + Returns + ------- + int + """ + return self._json_attrs.pages.copy() + + @pages.setter + @beartype + def pages(self, new_pages_list: List[int]) -> None: + """ + set the list of pages of the scholarly work for this reference node + + Parameters + ---------- + new_pages_list: List[int] + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, pages=new_pages_list) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def doi(self) -> str: + """ + get the digital object identifier (DOI) for this reference node + + Examples + -------- + ```python + my_reference.doi = "100.1038/1781168a0" + ``` + + Returns + ------- + str + digital object identifier (DOI) for this reference node + """ + return self._json_attrs.doi + + @doi.setter + @beartype + def doi(self, new_doi: str) -> None: + """ + set the digital object identifier (DOI) for the scholarly work for this reference node + + Parameters + ---------- + new_doi: str + + Examples + -------- + ```python + my_reference.doi = "100.1038/1781168a0" + ``` + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, doi=new_doi) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def issn(self) -> str: + """ + The international standard serial number (ISSN) for this reference node + + Examples + ```python + my_reference.issn = "1456-4687" + ``` + + Returns + ------- + str + ISSN for this reference node + """ + return self._json_attrs.issn + + @issn.setter + @beartype + def issn(self, new_issn: str) -> None: + """ + set the international standard serial number (ISSN) for the scholarly work for this reference node + + Parameters + ---------- + new_issn: str + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, issn=new_issn) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def arxiv_id(self) -> str: + """ + The arXiv identifier for the scholarly work for this reference node + + Examples + -------- + ```python + my_reference.arxiv_id = "1501" + ``` + + Returns + ------- + str + arXiv identifier for the scholarly work for this publishing + """ + return self._json_attrs.arxiv_id + + @arxiv_id.setter + @beartype + def arxiv_id(self, new_arxiv_id: str) -> None: + """ + set the arXiv identifier for the scholarly work for this reference node + + Parameters + ---------- + new_arxiv_id: str + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, arxiv_id=new_arxiv_id) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def pmid(self) -> Union[int, None]: + """ + The PubMed ID (PMID) for this reference node + + Examples + -------- + ```python + my_reference.pmid = 12345678 + ``` + + Returns + ------- + int + the PubMedID of this publishing + """ + return self._json_attrs.pmid + + @pmid.setter + @beartype + def pmid(self, new_pmid: Union[int, None]) -> None: + """ + + Parameters + ---------- + new_pmid + + Returns + ------- + + """ + # TODO can possibly add validations, possibly in forms of length checking + # to be sure its the correct length + new_attrs = replace(self._json_attrs, pmid=new_pmid) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def website(self) -> str: + """ + The website URL for the scholarly work + + Examples + -------- + ```python + my_reference.website = "https://criptapp.org" + ``` + + Returns + ------- + str + the website URL of this publishing + """ + return self._json_attrs.website + + @website.setter + @beartype + def website(self, new_website: str) -> None: + """ + set the website URL for the scholarly work + + Parameters + ---------- + new_website: str + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, website=new_website) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/subobjects/__init__.py b/src/cript/nodes/subobjects/__init__.py new file mode 100644 index 000000000..f14afc47a --- /dev/null +++ b/src/cript/nodes/subobjects/__init__.py @@ -0,0 +1,12 @@ +# trunk-ignore-all(ruff/F401) +from cript.nodes.subobjects.algorithm import Algorithm +from cript.nodes.subobjects.citation import Citation +from cript.nodes.subobjects.computational_forcefield import ComputationalForcefield +from cript.nodes.subobjects.condition import Condition +from cript.nodes.subobjects.equipment import Equipment +from cript.nodes.subobjects.ingredient import Ingredient +from cript.nodes.subobjects.parameter import Parameter +from cript.nodes.subobjects.property import Property +from cript.nodes.subobjects.quantity import Quantity +from cript.nodes.subobjects.software import Software +from cript.nodes.subobjects.software_configuration import SoftwareConfiguration diff --git a/src/cript/nodes/subobjects/algorithm.py b/src/cript/nodes/subobjects/algorithm.py new file mode 100644 index 000000000..9f05dcd17 --- /dev/null +++ b/src/cript/nodes/subobjects/algorithm.py @@ -0,0 +1,268 @@ +from dataclasses import dataclass, field, replace +from typing import List, Optional + +from cript.nodes.subobjects.citation import Citation +from cript.nodes.subobjects.parameter import Parameter +from cript.nodes.uuid_base import UUIDBaseNode + + +class Algorithm(UUIDBaseNode): + """ + ## Definition + + An [algorithm sub-object](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=25) + is a set of instructions that define a computational process. + An algorithm consists of parameters that are used in the computation and the computational process itself. + + + ## Attributes + + | Keys | Type | Example | Description | Required | Vocab | + |-----------|-----------------|----------------------------------------------|--------------------------------------------------------|----------|-------| + | key | str | ensemble, thermo-barostat | system configuration, algorithms used in a computation | True | True | + | type | str | NPT for ensemble, Nose-Hoover for thermostat | specific type of configuration, algorithm | True | | + | parameter | list[Parameter] | | setup associated parameters | | | + | citation | Citation | | reference to a book, paper, or scholarly work | | | + + ## Can be Added To + * [SoftwareConfiguration](../software_configuration) + + ## Available sub-objects + * [Parameter](../parameter) + * [Citation](../citation) + + ## JSON Representation + ```json + { + "node": ["Algorithm"], + "key": "mc_barostat", + "type": "barostat", + "parameter": { + "node": ["Parameter"], + "key": "update_frequency", + "value": 1000.0, + "unit": "1/second" + }, + "citation": { + "node": ["Citation"], + "type": "reference" + "reference": { + "node": ["Reference"], + "type": "journal_article", + "title": "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: SOft coarse grained Monte-Carlo Acceleration (SOMA)", + "author": ["Ludwig Schneider", "Marcus Müller"], + "journal": "Computer Physics Communications", + "publisher": "Elsevier", + "year": 2019, + "pages": [463, 476], + "doi": "10.1016/j.cpc.2018.08.011", + "issn": "0010-4655", + "website": "https://www.sciencedirect.com/science/article/pii/S0010465518303072", + }, + }, + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + key: str = "" + type: str = "" + + parameter: List[Parameter] = field(default_factory=list) + citation: List[Citation] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + def __init__(self, key: str, type: str, parameter: Optional[List[Parameter]] = None, citation: Optional[List[Citation]] = None, **kwargs): # ignored + """ + create algorithm sub-object + + Parameters + ---------- + key : str + algorithm key must come from [CRIPT controlled vocabulary]() + type : str + algorithm type must come from [CRIPT controlled vocabulary]() + parameter : List[Parameter], optional + parameter sub-object, by default None + citation : List[Citation], optional + citation sub-object, by default None + + Examples + -------- + ```python + # create algorithm sub-object + algorithm = cript.Algorithm(key="mc_barostat", type="barostat") + ``` + + Returns + ------- + None + instantiate an algorithm node + """ + if parameter is None: + parameter = [] + if citation is None: + citation = [] + super().__init__(**kwargs) + self._json_attrs = replace(self._json_attrs, key=key, type=type, parameter=parameter) + self.validate() + + @property + def key(self) -> str: + """ + Algorithm key + + Algorithm key must come from [CRIPT controlled vocabulary](https://app.criptapp.org/vocab/algorithm_key) + + Examples + -------- + ```python + algorithm.key = "amorphous_cell_module" + ``` + + Returns + ------- + str + algorithm key + """ + return self._json_attrs.key + + @key.setter + def key(self, new_key: str) -> None: + """ + set the algorithm key + + > Algorithm key must come from [CRIPT Controlled Vocabulary]() + + Parameters + ---------- + new_key : str + algorithm key + """ + new_attrs = replace(self._json_attrs, key=new_key) + self._update_json_attrs_if_valid(new_attrs) + + @property + def type(self) -> str: + """ + Algorithm type + + > Algorithm type must come from [CRIPT controlled vocabulary]() + + Examples + -------- + ```python + my_algorithm.type = "integration" + ``` + + Returns + ------- + str + algorithm type + """ + return self._json_attrs.type + + @type.setter + def type(self, new_type: str) -> None: + new_attrs = replace(self._json_attrs, type=new_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + def parameter(self) -> List[Parameter]: + """ + list of [Parameter](../parameter) sub-objects for the algorithm sub-object + + Examples + -------- + ```python + # create parameter sub-object + my_parameter = [ + cript.Parameter("update_frequency", 1000.0, "1/second") + cript.Parameter("damping_time", 1.0, "second") + ] + + # add parameter sub-object to algorithm sub-object + algorithm.parameter = my_parameter + ``` + + Returns + ------- + List[Parameter] + list of parameters for the algorithm sub-object + """ + return self._json_attrs.parameter.copy() + + @parameter.setter + def parameter(self, new_parameter: List[Parameter]) -> None: + """ + set a list of cript.Parameter sub-objects + + Parameters + ---------- + new_parameter : List[Parameter] + list of Parameter sub-objects for the algorithm sub-object + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, parameter=new_parameter) + self._update_json_attrs_if_valid(new_attrs) + + @property + def citation(self) -> Citation: + """ + [citation](../citation) subobject for algorithm subobject + + Examples + -------- + ```python + title = "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: " + title += "SOft coarse grained Monte-Carlo Acceleration (SOMA)" + + # create reference node + my_reference = cript.Reference( + type="journal_article", + title=title, + author=["Ludwig Schneider", "Marcus Müller"], + journal="Computer Physics Communications", + publisher="Elsevier", + year=2019, + pages=[463, 476], + doi="10.1016/j.cpc.2018.08.011", + issn="0010-4655", + website="https://www.sciencedirect.com/science/article/pii/S0010465518303072", + ) + + # create citation sub-object and add reference to it + my_citation = cript.Citation(type="reference, reference==my_reference) + + # add citation to algorithm node + algorithm.citation = my_citation + ``` + + Returns + ------- + citation node: Citation + get the algorithm citation node + """ + return self._json_attrs.citation.copy() # type: ignore + + @citation.setter + def citation(self, new_citation: Citation) -> None: + """ + set the algorithm citation subobject + + Parameters + ---------- + new_citation : Citation + new citation subobject to replace the current + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, citation=new_citation) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/subobjects/citation.py b/src/cript/nodes/subobjects/citation.py new file mode 100644 index 000000000..e3aae8bac --- /dev/null +++ b/src/cript/nodes/subobjects/citation.py @@ -0,0 +1,201 @@ +from dataclasses import dataclass, replace +from typing import Optional, Union + +from beartype import beartype + +from cript.nodes.primary_nodes.reference import Reference +from cript.nodes.uuid_base import UUIDBaseNode + + +class Citation(UUIDBaseNode): + """ + ## Definition + The [Citation sub-object](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=26) + essentially houses [Reference nodes](../../primary_nodes/reference). The citation subobject can then be added to CRIPT Primary nodes. + + ## Attributes + | attribute | type | example | description | required | vocab | + |-----------|-----------|--------------|-----------------------------------------------|----------|-------| + | type | str | derived_from | key for identifier | True | True | + | reference | Reference | | reference to a book, paper, or scholarly work | True | | + + ## Can Be Added To + * [Collection node](../../primary_nodes/collection) + * [Computation node](../../primary_nodes/computation) + * [Computation Process Node](../../primary_nodes/computation_process) + * [Data node](../../primary_nodes/data) + + * [Computational Forcefield subobjects](../computational_forcefield) + * [Property subobject](../property) + * [Algorithm subobject](../algorithm) + * [Equipment subobject](../equipment) + + --- + + ## Available Subobjects + * `None` + + ## JSON Representation + ```json + "citation": { + "node": ["Citation"], + "type": "reference", + "reference": { + "node": ["Reference"], + "type": "journal_article", + "title": "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: SOft coarse grained Monte-Carlo Acceleration (SOMA)", + "author": ["Ludwig Schneider", "Marcus Müller"], + "journal": "Computer Physics Communications", + "publisher": "Elsevier", + "year": 2019, + "pages": [463, 476], + "doi": "10.1016/j.cpc.2018.08.011", + "issn": "0010-4655", + "website": "https://www.sciencedirect.com/science/article/pii/S0010465518303072", + }, + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + type: str = "" + reference: Optional[Reference] = None + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, type: str, reference: Reference, **kwargs): + """ + create a Citation subobject + + Parameters + ---------- + type : citation type + citation type must come from [CRIPT Controlled Vocabulary]() + reference : Reference + Reference node + + Examples + ------- + ```python + title = "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: " + title += "SOft coarse grained Monte-Carlo Acceleration (SOMA)" + + # create a Reference node for the Citation subobject + my_reference = Reference( + "journal_article", + title=title, + author=["Ludwig Schneider", "Marcus Müller"], + journal="Computer Physics Communications", + publisher="Elsevier", + year=2019, + pages=[463, 476], + doi="10.1016/j.cpc.2018.08.011", + issn="0010-4655", + website="https://www.sciencedirect.com/science/article/pii/S0010465518303072", + ) + + # create Citation subobject + my_citation = cript.Citation("reference", my_reference) + ``` + + Returns + ------- + None + Instantiate citation subobject + """ + super().__init__(**kwargs) + self._json_attrs = replace(self._json_attrs, type=type, reference=reference) + self.validate() + + @property + @beartype + def type(self) -> str: + """ + Citation type subobject + + Citation type must come from [CRIPT Controlled Vocabulary](https://app.criptapp.org/vocab/citation_type) + + Examples + -------- + ```python + my_citation.type = "extracted_by_algorithm" + ``` + + Returns + ------- + str + Citation type + """ + return self._json_attrs.type + + @type.setter + @beartype + def type(self, new_type: str) -> None: + """ + set the citation subobject type + + > Note: citation subobject must come from [CRIPT Controlled Vocabulary]() + + Parameters + ---------- + new_type : str + citation type + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, type=new_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def reference(self) -> Union[Reference, None]: + """ + citation reference node + + Examples + -------- + ```python + # create a Reference node for the Citation subobject + my_reference = Reference( + "journal_article", + title="my title", + author=["Ludwig Schneider", "Marcus Müller"], + journal="Computer Physics Communications", + publisher="Elsevier", + year=2019, + pages=[463, 476], + doi="10.1016/j.cpc.2018.08.011", + issn="0010-4655", + website="https://www.sciencedirect.com/science/article/pii/S0010465518303072", + ) + + my_citation.reference = my_reference + ``` + + Returns + ------- + Reference + Reference node + """ + return self._json_attrs.reference + + @reference.setter + @beartype + def reference(self, new_reference: Reference) -> None: + """ + replace the current Reference node for the citation subobject + + Parameters + ---------- + new_reference : Reference + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, reference=new_reference) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/subobjects/computational_forcefield.py b/src/cript/nodes/subobjects/computational_forcefield.py new file mode 100644 index 000000000..45416f0da --- /dev/null +++ b/src/cript/nodes/subobjects/computational_forcefield.py @@ -0,0 +1,478 @@ +from dataclasses import dataclass, field, replace +from typing import List, Optional + +from beartype import beartype + +from cript.nodes.primary_nodes.data import Data +from cript.nodes.subobjects.citation import Citation +from cript.nodes.uuid_base import UUIDBaseNode + + +class ComputationalForcefield(UUIDBaseNode): + """ + ## Definition + A [Computational Forcefield Subobject](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=23) + is a mathematical model that describes the forces between atoms and molecules. + It is used in computational chemistry and molecular dynamics simulations to predict the behavior of materials. + Forcefields are typically based on experimental data or quantum mechanical calculations, + and they are often used to study the properties of materials such as their structure, dynamics, and reactivity. + + ## Attributes + | attribute | type | example | description | required | vocab | + |------------------------|----------------|------------------------------------------------------------------------|--------------------------------------------------------------------------|----------|-------| + | key | str | CHARMM27 | type of forcefield | True | True | + | building_block | str | atom | type of building block | True | True | + | coarse_grained_mapping | str | SC3 beads in MARTINI forcefield | atom to beads mapping | | | + | implicit_solvent | str | water | Name of implicit solvent | | | + | source | str | package in GROMACS | source of forcefield | | | + | description | str | OPLS forcefield with partial charges calculated via the LBCC algorithm | description of the forcefield and any modifications that have been added | | | + | data | Data | | details of mapping schema and forcefield parameters | | | + | citation | list[Citation] | | reference to a book, paper, or scholarly work | | | + + + ## Can be Added To Primary Node: + * Material node + + ## JSON Representation + ```json + { + "node": ["ComputationalForcefield"], + "key": "opls_aa", + "building_block": "atom", + "coarse_grained_mapping": "atom -> atom", + "implicit_solvent": "no implicit solvent", + "source": "local LigParGen installation", + "description": "this is a test forcefield", + "data": { + "node":["Data"], + "name":"my data name", + "type":"afm_amp", + "file":[ + { + "node":["File"], + "type":"calibration", + "source":"https://criptapp.org", + "extension":".csv", + "data_dictionary":"my file's data dictionary" + } + ] + }, + "citation": { + "node": ["Citation"], + "type": "reference" + "reference": { + "node": ["Reference"], + "type": "journal_article", + "title": "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: SOft coarse grained Monte-Carlo Acceleration (SOMA)", + "author": ["Ludwig Schneider", "Marcus Müller"], + "journal": "Computer Physics Communications", + "publisher": "Elsevier", + "year": 2019, + "pages": [463, 476], + "doi": "10.1016/j.cpc.2018.08.011", + "issn": "0010-4655", + "website": "https://www.sciencedirect.com/science/article/pii/S0010465518303072", + } + } + + + ``` + + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + key: str = "" + building_block: str = "" + coarse_grained_mapping: str = "" + implicit_solvent: str = "" + source: str = "" + description: str = "" + data: List[Data] = field(default_factory=list) + citation: List[Citation] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, key: str, building_block: str, coarse_grained_mapping: str = "", implicit_solvent: str = "", source: str = "", description: str = "", data: Optional[List[Data]] = None, citation: Optional[List[Citation]] = None, **kwargs): + """ + instantiate a computational_forcefield subobject + + Parameters + ---------- + key : str + type of forcefield key must come from [CRIPT Controlled Vocabulary]() + building_block : str + type of computational_forcefield building_block must come from [CRIPT Controlled Vocabulary]() + coarse_grained_mapping : str, optional + atom to beads mapping, by default "" + implicit_solvent : str, optional + Name of implicit solvent, by default "" + source : str, optional + source of forcefield, by default "" + description : str, optional + description of the forcefield and any modifications that have been added, by default "" + data : List[Data], optional + details of mapping schema and forcefield parameters, by default None + citation : Union[List[Citation], None], optional + reference to a book, paper, or scholarly work, by default None + + + Examples + -------- + ```python + my_computational_forcefield = cript.ComputationalForcefield( + key="opls_aa", + building_block="atom", + ) + ``` + + Returns + ------- + None + Instantiate a computational_forcefield subobject + """ + if citation is None: + citation = [] + super().__init__(**kwargs) + + if data is None: + data = [] + + self._json_attrs = replace( + self._json_attrs, + key=key, + building_block=building_block, + coarse_grained_mapping=coarse_grained_mapping, + implicit_solvent=implicit_solvent, + source=source, + description=description, + data=data, + citation=citation, + ) + self.validate() + + @property + @beartype + def key(self) -> str: + """ + type of forcefield + + Computational_Forcefield key must come from + [CRIPT Controlled Vocabulary](https://app.criptapp.org/vocab/computational_forcefield_key) + + Examples + -------- + ```python + my_computational_forcefield.key = "amber" + ``` + + Returns + ------- + str + type of forcefield + """ + return self._json_attrs.key + + @key.setter + @beartype + def key(self, new_key: str) -> None: + """ + set key for this computational_forcefield + + Parameters + ---------- + new_key : str + computational_forcefield key + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, key=new_key) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def building_block(self) -> str: + """ + type of building block + + Computational_Forcefield building_block must come from + [CRIPT Controlled Vocabulary](https://app.criptapp.org/vocab/building_block) + + Examples + -------- + ```python + my_computational_forcefield.building_block = "atom" + ``` + + Returns + ------- + str + type of building block + """ + return self._json_attrs.building_block + + @building_block.setter + @beartype + def building_block(self, new_building_block: str) -> None: + """ + type of building block + + Parameters + ---------- + new_building_block : str + new type of building block + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, building_block=new_building_block) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def coarse_grained_mapping(self) -> str: + """ + atom to beads mapping + + Examples + -------- + ```python + my_computational_forcefield.coarse_grained_mapping = "SC3 beads in MARTINI forcefield" + ``` + + Returns + ------- + str + coarse_grained_mapping + """ + return self._json_attrs.coarse_grained_mapping + + @coarse_grained_mapping.setter + @beartype + def coarse_grained_mapping(self, new_coarse_grained_mapping: str) -> None: + """ + atom to beads mapping + + Parameters + ---------- + new_coarse_grained_mapping : str + new coarse_grained_mapping + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, coarse_grained_mapping=new_coarse_grained_mapping) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def implicit_solvent(self) -> str: + """ + Name of implicit solvent + + Examples + -------- + ```python + my_computational_forcefield.implicit_solvent = "water" + ``` + + Returns + ------- + str + _description_ + """ + return self._json_attrs.implicit_solvent + + @implicit_solvent.setter + @beartype + def implicit_solvent(self, new_implicit_solvent: str) -> None: + """ + set the implicit_solvent + + Parameters + ---------- + new_implicit_solvent : str + new implicit_solvent + """ + new_attrs = replace(self._json_attrs, implicit_solvent=new_implicit_solvent) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def source(self) -> str: + """ + source of forcefield + + Examples + -------- + ```python + my_computational_forcefield.source = "package in GROMACS" + ``` + + Returns + ------- + str + source of forcefield + """ + return self._json_attrs.source + + @source.setter + @beartype + def source(self, new_source: str) -> None: + """ + set the computational_forcefield + + Parameters + ---------- + new_source : str + new source of forcefield + """ + new_attrs = replace(self._json_attrs, source=new_source) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def description(self) -> str: + """ + description of the forcefield and any modifications that have been added + + Examples + -------- + ```python + my_computational_forcefield.description = "OPLS forcefield with partial charges calculated via the LBCC algorithm" + ``` + + Returns + ------- + str + description of the forcefield and any modifications that have been added + """ + return self._json_attrs.description + + @description.setter + @beartype + def description(self, new_description: str) -> None: + """ + set this computational_forcefields description + + Parameters + ---------- + new_description : str + new computational_forcefields description + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, description=new_description) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def data(self) -> List[Data]: + """ + details of mapping schema and forcefield parameters + + Examples + -------- + ```python + # create file nodes for the data node + my_file = cript.File( + source="https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf", + type="calibration", + extension=".pdf", + ) + + # create data node and add the file node to it + my_data = cript.Data( + name="my data node name", + type="afm_amp", + file=my_file, + ) + + # add data node to computational_forcefield subobject + my_computational_forcefield.data = [my_data] + ``` + + Returns + ------- + List[Data] + list of data nodes for this computational_forcefield subobject + """ + return self._json_attrs.data.copy() + + @data.setter + @beartype + def data(self, new_data: List[Data]) -> None: + """ + set the data attribute of this computational_forcefield node + + Parameters + ---------- + new_data : List[Data] + new list of data nodes + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, data=new_data) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def citation(self) -> List[Citation]: + """ + reference to a book, paper, or scholarly work + + Examples + -------- + ```python + # create reference node for the citation node + title = "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: " + title += "SOft coarse grained Monte-Carlo Acceleration (SOMA)" + + my_reference = cript.Reference( + "journal_article", + title=title, + author=["Ludwig Schneider", "Marcus Müller"], + journal="Computer Physics Communications", + publisher="Elsevier", + year=2019, + pages=[463, 476], + doi="10.1016/j.cpc.2018.08.011", + issn="0010-4655", + website="https://www.sciencedirect.com/science/article/pii/S0010465518303072", + ) + + # create citation node and add reference node to it + my_citation = cript.Citation(type="reference", reference=my_reference) + + my_computational_forcefield.citation = [my_citation] + ``` + + Returns + ------- + List[Citation] + computational_forcefield list of citations + """ + return self._json_attrs.citation.copy() + + @citation.setter + @beartype + def citation(self, new_citation: List[Citation]) -> None: + """ + set the citation subobject of the computational_forcefield subobject + + Parameters + ---------- + new_citation : List[Citation] + new citation subobject + """ + new_attrs = replace(self._json_attrs, citation=new_citation) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/subobjects/condition.py b/src/cript/nodes/subobjects/condition.py new file mode 100644 index 000000000..1e06dc8e6 --- /dev/null +++ b/src/cript/nodes/subobjects/condition.py @@ -0,0 +1,537 @@ +from dataclasses import dataclass, field, replace +from numbers import Number +from typing import List, Optional, Union + +from beartype import beartype + +from cript.nodes.primary_nodes.data import Data +from cript.nodes.uuid_base import UUIDBaseNode + + +class Condition(UUIDBaseNode): + """ + ## Definition + + A [Condition](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=21) sub-object + is the conditions under which the experiment was conducted. + Some examples include temperature, mixing_rate, stirring, time_duration. + + ---- + + ## Can Be Added To: + ### Primary Nodes + * [Process](../../primary_nodes/process) + * [Computation_Process](../../primary_nodes/computation_process) + + ### Subobjects + * [Property](../property) + * [Equipment](../equipment) + + --- + + ## Attributes + + | attribute | type | example | description | required | vocab | + |------------------|--------|-------------------------|----------------------------------------------------------------------------------------|----------|-------| + | key | str | temp | type of condition | True | True | + | type | str | min | type of value stored, 'value' is just the number, 'min', 'max', 'avg', etc. for series | True | True | + | descriptor | str | upper temperature probe | freeform description for condition | | | + | value | Number | 1.23 | value or quantity | True | | + | unit | str | gram | unit for value | | | + | uncertainty | Number | 0.1 | uncertainty of value | | | + | uncertainty_type | str | std | type of uncertainty | | True | + | set_id | int | 0 | ID of set (used to link measurements in as series) | | | + | measurement _id | int | 0 | ID for a single measurement (used to link multiple condition at a single instance) | | | + | data | List[Data] | | detailed data associated with the condition | | | + + ## JSON Representation + ```json + { + "node": ["Condition"], + "key": "temperature", + "type": "value", + "descriptor": "room temperature of lab", + "value": 22, + "unit": "C", + "uncertainty": 5, + "uncertainty_type": "stdev", + "set_id": 0, + "measurement_id": 2, + "data": [{ + "node":["Data"], + "name":"my data name", + "type":"afm_amp", + "file":[ + { + "node":["File"], + "type":"calibration", + "source":"https://criptapp.org", + "extension":".csv", + "data_dictionary":"my file's data dictionary" + } + ] + }], + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + key: str = "" + type: str = "" + descriptor: str = "" + value: Optional[Union[Number, str]] = None + unit: str = "" + uncertainty: Optional[Union[Number, str]] = None + uncertainty_type: str = "" + set_id: Optional[int] = None + measurement_id: Optional[int] = None + data: List[Data] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__( + self, + key: str, + type: str, + value: Union[Number, str], + unit: str = "", + descriptor: str = "", + uncertainty: Optional[Union[Number, str]] = None, + uncertainty_type: str = "", + set_id: Optional[int] = None, + measurement_id: Optional[int] = None, + data: Optional[List[Data]] = None, + **kwargs + ): + """ + create Condition sub-object + + Parameters + ---------- + key : str + type of condition + type : str + type of value stored + value : Number + value or quantity + unit : str, optional + unit for value, by default "" + descriptor : str, optional + freeform description for condition, by default "" + uncertainty : Union[Number, None], optional + uncertainty of value, by default None + uncertainty_type : str, optional + type of uncertainty, by default "" + set_id : Union[int, None], optional + ID of set (used to link measurements in as series), by default None + measurement_id : Union[int, None], optional + ID for a single measurement (used to link multiple condition at a single instance), by default None + data : List[Data], optional + detailed data associated with the condition, by default None + + + Examples + -------- + ```python + # instantiate a Condition sub-object + my_condition = cript.Condition( + key="temperature", + type="value", + value=22, + unit="C", + ) + ``` + + Returns + ------- + None + """ + super().__init__(**kwargs) + + if data is None: + data = [] + + self._json_attrs = replace( + self._json_attrs, + key=key, + type=type, + value=value, + descriptor=descriptor, + unit=unit, + uncertainty=uncertainty, + uncertainty_type=uncertainty_type, + set_id=set_id, + measurement_id=measurement_id, + data=data, + ) + self.validate() + + @property + @beartype + def key(self) -> str: + """ + type of condition + + > Condition key must come from [CRIPT Controlled Vocabulary](https://app.criptapp.org/vocab/condition_key) + + Examples + -------- + ```python + my_condition.key = "energy_threshold" + ``` + + Returns + ------- + condition key: str + type of condition + """ + return self._json_attrs.key + + @key.setter + @beartype + def key(self, new_key: str) -> None: + """ + set this Condition sub-object key + + > Condition key must come from [CRIPT Controlled Vocabulary]() + + Parameters + ---------- + new_key : str + type of condition + + Returns + -------- + None + """ + new_attrs = replace(self._json_attrs, key=new_key) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def type(self) -> str: + """ + description for the value stored for this Condition node + + Examples + -------- + ```python + my_condition.type = "min" + ``` + + Returns + ------- + condition type: str + description for the value + """ + return self._json_attrs.type + + @type.setter + @beartype + def type(self, new_type: str) -> None: + """ + set the type attribute for this Condition node + + Parameters + ---------- + new_type : str + new description of the Condition value + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, type=new_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def descriptor(self) -> str: + """ + freeform description for Condition + + Examples + -------- + ```python + my_condition.description = "my condition description" + ``` + + Returns + ------- + description: str + description of this Condition sub-object + """ + return self._json_attrs.descriptor + + @descriptor.setter + @beartype + def descriptor(self, new_descriptor: str) -> None: + """ + set the description of this Condition sub-object + + Parameters + ---------- + new_descriptor : str + new description describing the Condition subobject + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, descriptor=new_descriptor) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def value(self) -> Optional[Union[Number, str]]: + """ + value or quantity + + Examples + ------- + ```python + my_condition.value = 10 + ``` + + Returns + ------- + Union[Number, None] + new value or quantity + """ + return self._json_attrs.value + + def set_value(self, new_value: Union[Number, str], new_unit: str) -> None: + """ + set the value for this Condition subobject + + Parameters + ---------- + new_value : Number + new value + new_unit : str + units for the new value + + Examples + -------- + ```python + my_condition.set_value(new_value=1, new_unit="gram") + ``` + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, value=new_value, unit=new_unit) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def unit(self) -> str: + """ + set units for this Condition subobject + + Examples + -------- + ```python + my_condition.unit = "gram" + ``` + + Returns + ------- + unit: str + units + """ + return self._json_attrs.unit + + @property + @beartype + def uncertainty(self) -> Optional[Union[Number, str]]: + """ + set uncertainty value for this Condition subobject + + Examples + -------- + ```python + my_condition.uncertainty = "0.1" + ``` + + Returns + ------- + uncertainty: Union[Number, None] + uncertainty + """ + return self._json_attrs.uncertainty + + @beartype + def set_uncertainty(self, new_uncertainty: Union[Number, str], new_uncertainty_type: str) -> None: + """ + set uncertainty and uncertainty type + + Parameters + ---------- + new_uncertainty : Number + new uncertainty value + new_uncertainty_type : str + new uncertainty type + + Examples + -------- + ```python + my_condition.set_uncertainty(new_uncertainty="0.2", new_uncertainty_type="std") + ``` + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, uncertainty=new_uncertainty, uncertainty_type=new_uncertainty_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def uncertainty_type(self) -> str: + """ + Uncertainty type for the uncertainty value + + [Uncertainty type](https://app.criptapp.org/vocab/uncertainty_type) must come from CRIPT controlled vocabulary + + Examples + -------- + ```python + my_condition.uncertainty_type = "std" + ``` + + Returns + ------- + uncertainty_type: str + uncertainty type + """ + return self._json_attrs.uncertainty_type + + @property + @beartype + def set_id(self) -> Union[int, None]: + """ + ID of set (used to link measurements in as series) + + Examples + -------- + ```python + my_condition.set_id = 0 + ``` + + Returns + ------- + set_id: Union[int, None] + ID of set + """ + return self._json_attrs.set_id + + @set_id.setter + @beartype + def set_id(self, new_set_id: Union[int, None]) -> None: + """ + set this Condition subobjects set_id + + Parameters + ---------- + new_set_id : Union[int, None] + ID of set + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, set_id=new_set_id) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def measurement_id(self) -> Union[int, None]: + """ + ID for a single measurement (used to link multiple condition at a single instance) + + Examples + -------- + ```python + my_condition.measurement_id = 0 + ``` + + Returns + ------- + measurement_id: Union[int, None] + ID for a single measurement + """ + return self._json_attrs.measurement_id + + @measurement_id.setter + @beartype + def measurement_id(self, new_measurement_id: Union[int, None]) -> None: + """ + set the set_id for this Condition subobject + + Parameters + ---------- + new_measurement_id : Union[int, None] + ID for a single measurement + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, measurement_id=new_measurement_id) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def data(self) -> List[Data]: + """ + detailed data associated with the condition + + Examples + -------- + ```python + # create file nodes for the data node + my_file = cript.File( + source="https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf", + type="calibration", + extension=".pdf", + ) + + # create data node and add the file node to it + my_data = cript.Data( + name="my data node name", + type="afm_amp", + file=my_file, + ) + + # add data node to Condition subobject + my_condition.data = [my_data] + ``` + + Returns + ------- + Condition: Union[Data, None] + detailed data associated with the condition + """ + return self._json_attrs.data.copy() + + @data.setter + @beartype + def data(self, new_data: List[Data]) -> None: + """ + set the data node for this Condition Subobject + + Parameters + ---------- + new_data : List[Data] + new Data node + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, data=new_data) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/subobjects/equipment.py b/src/cript/nodes/subobjects/equipment.py new file mode 100644 index 000000000..625ae2648 --- /dev/null +++ b/src/cript/nodes/subobjects/equipment.py @@ -0,0 +1,327 @@ +from dataclasses import dataclass, field, replace +from typing import List, Union + +from beartype import beartype + +from cript.nodes.subobjects.citation import Citation +from cript.nodes.subobjects.condition import Condition +from cript.nodes.supporting_nodes.file import File +from cript.nodes.uuid_base import UUIDBaseNode + + +class Equipment(UUIDBaseNode): + """ + ## Definition + An [Equipment](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=23) + sub-object specifies the physical instruments, tools, glassware, etc. used in a process. + + --- + + ## Can Be Added To: + * [Process node](../../primary_nodes/process) + + ## Available sub-objects: + * [Condition](../condition) + * [Citation](../citation) + + --- + + ## Attributes + + | attribute | type | example | description | required | vocab | + |-------------|-----------------|-----------------------------------------------|--------------------------------------------------------------------------------|----------|-------| + | key | str | hot plate | material | True | True | + | description | str | Hot plate with silicon oil bath with stir bar | additional details about the equipment | | | + | condition | list[Condition] | | conditions under which the property was measured | | | + | files | list[File] | | list of file nodes to link to calibration or equipment specification documents | | | + | citation | list[Citation] | | reference to a book, paper, or scholarly work | | | + + ## JSON Representation + ```json + { + "node":["Equipment"], + "description": "my equipment description", + "key":"burner", + "uid":"_:19708284-1bd7-42e4-b8b2-da7ea0bc2ac9", + "uuid":"19708284-1bd7-42e4-b8b2-da7ea0bc2ac9" + } + ``` + + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + key: str = "" + description: str = "" + condition: List[Condition] = field(default_factory=list) + file: List[File] = field(default_factory=list) + citation: List[Citation] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, key: str, description: str = "", condition: Union[List[Condition], None] = None, file: Union[List[File], None] = None, citation: Union[List[Citation], None] = None, **kwargs) -> None: + """ + create equipment sub-object + + Parameters + ---------- + key : str + Equipment key must come from [CRIPT Controlled Vocabulary]() + description : str, optional + additional details about the equipment, by default "" + condition : Union[List[Condition], None], optional + Conditions under which the property was measured, by default None + file : Union[List[File], None], optional + list of file nodes to link to calibration or equipment specification documents, by default None + citation : Union[List[Citation], None], optional + reference to a scholarly work, by default None + + Example + ------- + ```python + my_equipment = cript.Equipment(key="burner") + ``` + + Returns + ------- + None + instantiate equipment sub-object + """ + if condition is None: + condition = [] + if file is None: + file = [] + if citation is None: + citation = [] + super().__init__(**kwargs) + self._json_attrs = replace(self._json_attrs, key=key, description=description, condition=condition, file=file, citation=citation) + self.validate() + + @property + @beartype + def key(self) -> str: + """ + scientific instrument + + Equipment key must come from [CRIPT Controlled Vocabulary](https://app.criptapp.org/vocab/equipment_key) + + Examples + -------- + ```python + my_equipment = cript.Equipment(key="burner") + ``` + + Returns + ------- + Equipment: str + + """ + return self._json_attrs.key + + @key.setter + @beartype + def key(self, new_key: str) -> None: + """ + set the equipment key + + > Equipment key must come from [CRIPT Controlled Vocabulary]() + + Parameters + ---------- + new_key : str + equipment sub-object key + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, key=new_key) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def description(self) -> str: + """ + description of the equipment + + Examples + -------- + ```python + my_equipment.description = "additional details about the equipment" + ``` + + Returns + ------- + str + additional description of the equipment + """ + return self._json_attrs.description + + @description.setter + @beartype + def description(self, new_description: str) -> None: + """ + set this equipments description + + Parameters + ---------- + new_description : str + equipment description + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, description=new_description) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def condition(self) -> List[Condition]: + """ + conditions under which the property was measured + + Examples + -------- + ```python + # create a Condition sub-object + my_condition = cript.Condition( + key="temperature", + type="value", + value=22, + unit="C", + ) + + # add Condition sub-object to Equipment sub-object + my_equipment.condition = [my_condition] + ``` + + Returns + ------- + List[Condition] + list of Condition sub-objects + """ + return self._json_attrs.condition.copy() + + @condition.setter + @beartype + def condition(self, new_condition: List[Condition]) -> None: + """ + set a list of Conditions for the equipment sub-object + + Parameters + ---------- + new_condition : List[Condition] + list of Condition sub-objects + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, condition=new_condition) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def file(self) -> List[File]: + """ + list of file nodes to link to calibration or equipment specification documents + + Examples + -------- + ```python + # create a file node to be added to the equipment sub-object + my_file = cript.File( + source="https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf", + type="calibration", + extension=".pdf", + ) + + # add file node to equipment sub-object + my_equipment.file = [my_file] + + ``` + + Returns + ------- + List[File] + list of file nodes + """ + return self._json_attrs.file.copy() + + @file.setter + @beartype + def file(self, new_file: List[File]) -> None: + """ + set the file node for the equipment subobject + + Parameters + ---------- + new_file : List[File] + list of File nodes + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, file=new_file) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def citation(self) -> List[Citation]: + """ + reference to a book, paper, or scholarly work + + Examples + -------- + ```python + # create reference node for the citation node + title = "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: " + title += "SOft coarse grained Monte-Carlo Acceleration (SOMA)" + + my_reference = cript.Reference( + type="journal_article", + title=title, + author=["Ludwig Schneider", "Marcus Müller"], + journal="Computer Physics Communications", + publisher="Elsevier", + year=2019, + pages=[463, 476], + doi="10.1016/j.cpc.2018.08.011", + issn="0010-4655", + website="https://www.sciencedirect.com/science/article/pii/S0010465518303072", + ) + + # create citation node and add reference node to it + my_citation = cript.Citation(type="reference", reference=my_reference) + + # add citation subobject to equipment + my_equipment.citation = [my_citation] + ``` + + Returns + ------- + List[Citation] + list of Citation subobjects + """ + return self._json_attrs.citation.copy() + + @citation.setter + @beartype + def citation(self, new_citation: List[Citation]) -> None: + """ + set the citation subobject for this equipment subobject + + Parameters + ---------- + new_citation : List[Citation] + list of Citation subobjects + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, citation=new_citation) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/subobjects/ingredient.py b/src/cript/nodes/subobjects/ingredient.py new file mode 100644 index 000000000..43188a461 --- /dev/null +++ b/src/cript/nodes/subobjects/ingredient.py @@ -0,0 +1,222 @@ +from dataclasses import dataclass, field, replace +from typing import List, Optional, Union + +from beartype import beartype + +from cript.nodes.primary_nodes.material import Material +from cript.nodes.subobjects.quantity import Quantity +from cript.nodes.uuid_base import UUIDBaseNode + + +class Ingredient(UUIDBaseNode): + """ + ## Definition + An [Ingredient](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=22) + sub-objects are links to material nodes with the associated quantities. + + --- + + ## Can Be Added To: + * [process](../../primary_nodes/process) + * [computation_process](../../primary_nodes/computation_process) + + ## Available sub-objects: + * [Quantity](../quantity) + + --- + + ## Attributes + + | attribute | type | example | description | required | vocab | + |------------|----------------|----------|------------------------|----------|-------| + | material | Material | | material | True | | + | quantity | list[Quantity] | | quantities | True | | + | keyword | list(str) | catalyst | keyword for ingredient | | True | + + ## JSON Representation + ```json + { + "node":["Ingredient"], + "keyword":["catalyst"], + "uid":"_:32f173ab-a98a-449b-a528-1b656f652dd3", + "uuid":"32f173ab-a98a-449b-a528-1b656f652dd3" + "material":{ + "name":"my material 1", + "node":["Material"], + "bigsmiles":"[H]{[>][<]C(C[>])c1ccccc1[]}", + "uid":"_:029367a8-aee7-493a-bc08-991e0f6939ae", + "uuid":"029367a8-aee7-493a-bc08-991e0f6939ae" + }, + "quantity":[ + { + "node":["Quantity"], + "key":"mass", + "value":11.2 + "uncertainty":0.2, + "uncertainty_type":"stdev", + "unit":"kg", + "uid":"_:c95ee781-923b-4699-ba3b-923ce186ac5d", + "uuid":"c95ee781-923b-4699-ba3b-923ce186ac5d", + } + ] + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + material: Optional[Material] = None + quantity: List[Quantity] = field(default_factory=list) + keyword: List[str] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, material: Material, quantity: List[Quantity], keyword: Optional[List[str]] = None, **kwargs): + """ + create an ingredient sub-object + + Examples + -------- + ```python + import cript + + # create material and identifier for the ingredient sub-object + my_identifiers = [{"bigsmiles": "123456"}] + my_material = cript.Material(name="my material", identifier=my_identifiers) + + # create quantity sub-object + my_quantity = cript.Quantity(key="mass", value=11.2, unit="kg", uncertainty=0.2, uncertainty_type="stdev") + + # create ingredient sub-object and add all appropriate nodes/sub-objects + my_ingredient = cript.Ingredient(material=my_material, quantity=my_quantity, keyword="catalyst") + ``` + + Parameters + ---------- + material : Material + material node + quantity : List[Quantity] + list of quantity sub-objects + keyword : List[str], optional + ingredient keyword must come from [CRIPT Controlled Vocabulary](), by default "" + + Returns + ------- + None + Create new Ingredient sub-object + """ + super().__init__(**kwargs) + if keyword is None: + keyword = [] + self._json_attrs = replace(self._json_attrs, material=material, quantity=quantity, keyword=keyword) + self.validate() + + @classmethod + def _from_json(cls, json_dict: dict): + # TODO: remove this temporary fix, once back end is working correctly + if isinstance(json_dict["material"], list): + assert len(json_dict["material"]) == 1 + json_dict["material"] = json_dict["material"][0] + return super(Ingredient, cls)._from_json(json_dict) + + @property + @beartype + def material(self) -> Union[Material, None]: + """ + current material in this ingredient sub-object + + Returns + ------- + Material + Material node within the ingredient sub-object + """ + return self._json_attrs.material + + @property + @beartype + def quantity(self) -> List[Quantity]: + """ + quantity for the ingredient sub-object + + Returns + ------- + List[Quantity] + list of quantities for the ingredient sub-object + """ + return self._json_attrs.quantity.copy() + + @beartype + def set_material(self, new_material: Material, new_quantity: List[Quantity]) -> None: + """ + update ingredient sub-object with new material and new list of quantities + + Examples + -------- + ```python + my_identifiers = [{"bigsmiles": "123456"}] + my_new_material = cript.Material(name="my material", identifier=my_identifiers) + + my_new_quantity = cript.Quantity( + key="mass", value=11.2, unit="kg", uncertainty=0.2, uncertainty_type="stdev" + ) + + # set new material and list of quantities + my_ingredient.set_material(new_material=my_new_material, new_quantity=[my_new_quantity]) + + ``` + + Parameters + ---------- + new_material : Material + new material node to replace the current + new_quantity : List[Quantity] + new list of quantity sub-objects to replace the current quantity subobject on this node + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, material=new_material, quantity=new_quantity) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def keyword(self) -> List[str]: + """ + ingredient keyword must come from the + [CRIPT controlled vocabulary](https://app.criptapp.org/vocab/ingredient_keyword) + + Examples + -------- + ```python + # set new ingredient keyword + my_ingredient.keyword = "computation" + ``` + + Returns + ------- + str + get the current ingredient keyword + """ + return self._json_attrs.keyword.copy() + + @keyword.setter + @beartype + def keyword(self, new_keyword: List[str]) -> None: + """ + set new ingredient keyword to replace the current + + ingredient keyword must come from the [CRIPT controlled vocabulary]() + + Parameters + ---------- + new_keyword : str + new ingredient keyword + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, keyword=new_keyword) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/subobjects/parameter.py b/src/cript/nodes/subobjects/parameter.py new file mode 100644 index 000000000..55726e7fd --- /dev/null +++ b/src/cript/nodes/subobjects/parameter.py @@ -0,0 +1,221 @@ +from dataclasses import dataclass, replace +from numbers import Number +from typing import Optional, Union + +from beartype import beartype + +from cript.nodes.uuid_base import UUIDBaseNode + + +class Parameter(UUIDBaseNode): + """ + ## Definition + + A [parameter](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=25) + is an input value to an algorithm. + + ??? note "Difference between `Parameter` and `Condition`" + For typical computations, the difference between + parameter and condition lies in whether it changes the thermodynamic state of the simulated + system: Variables that are part of defining a thermodynamic state should be defined as a condition + in a parent node. + + Therefore, `number` and `volume` need to be listed as conditions while + `boundaries` and `origin` are parameters of ensemble size + + --- + ## Can Be Added To: + * [Algorithm sub-object](../algorithm) + + ## Available sub-objects: + * None + + --- + + ## Attributes + + | attribute | type | example | description | required | vocab | + |-----------|------|---------|--------------------|----------|-------| + | key | str | | key for identifier | True | True | + | value | Any | | value | True | | + | unit | str | | unit for parameter | | | + + + ## JSON Representation + ```json + { + "key":"update_frequency", + "node":["Parameter"], + "unit":"1/second", + "value":1000.0 + "uid":"_:6af3b3aa-1dbc-4ce7-be8b-1896b375001c", + "uuid":"6af3b3aa-1dbc-4ce7-be8b-1896b375001c", + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + key: str = "" + value: Optional[Number] = None + # We explicitly allow None for unit here (instead of empty str), + # this presents number without physical unit, like counting + # particles or dimensionless numbers. + unit: Union[str, None] = None + + _json_attrs: JsonAttributes = JsonAttributes() + + # Note that the key word args are ignored. + # They are just here, such that we can feed more kwargs in that we get from the back end. + @beartype + def __init__(self, key: str, value: Number, unit: Optional[str] = None, **kwargs): + """ + create new Parameter sub-object + + Parameters + ---------- + key : str + Parameter key must come from [CRIPT Controlled Vocabulary]() + value : Union[int, float] + Parameter value + unit : Union[str, None], optional + Parameter unit, by default None + + Examples + -------- + ```python + import cript + + my_parameter = cript.Parameter("update_frequency", 1000.0, "1/second") + ``` + + Returns + ------- + None + create Parameter sub-object + """ + super().__init__(**kwargs) + self._json_attrs = replace(self._json_attrs, key=key, value=value, unit=unit) + self.validate() + + @classmethod + def _from_json(cls, json_dict: dict): + # TODO: remove this temporary fix, once back end is working correctly + try: + json_dict["value"] = float(json_dict["value"]) + except KeyError: + pass + return super(Parameter, cls)._from_json(json_dict) + + @property + @beartype + def key(self) -> str: + """ + Parameter key must come from the [CRIPT Controlled Vocabulary](https://app.criptapp.org/vocab/parameter_key) + + Examples + -------- + ```python + my_parameter.key = "bond_type" + ``` + + Returns + ------- + str + parameter key + """ + return self._json_attrs.key + + @key.setter + @beartype + def key(self, new_key: str) -> None: + """ + set new key for the Parameter sub-object + + Parameter key must come from [CRIPT Controlled Vocabulary]() + + Parameters + ---------- + new_key : str + new Parameter key + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, key=new_key) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def value(self) -> Optional[Number]: + """ + Parameter value + + Examples + -------- + ```python + my_parameter.value = 1 + ``` + + Returns + ------- + Union[int, float, str] + parameter value + """ + return self._json_attrs.value + + @value.setter + @beartype + def value(self, new_value: Number) -> None: + """ + set the Parameter value + + Parameters + ---------- + new_value : Union[int, float, str] + new parameter value + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, value=new_value) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def unit(self) -> Union[str, None]: + """ + Parameter unit + + Examples + -------- + ```python + my_parameter.unit = "gram" + ``` + + Returns + ------- + str + parameter unit + """ + return self._json_attrs.unit + + @unit.setter + @beartype + def unit(self, new_unit: str) -> None: + """ + set the unit attribute for the Parameter sub-object + + Parameters + ---------- + new_unit : str + new Parameter unit + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, unit=new_unit) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/subobjects/property.py b/src/cript/nodes/subobjects/property.py new file mode 100644 index 000000000..1da686b54 --- /dev/null +++ b/src/cript/nodes/subobjects/property.py @@ -0,0 +1,753 @@ +from dataclasses import dataclass, field, replace +from numbers import Number +from typing import List, Optional, Union + +from beartype import beartype + +from cript.nodes.primary_nodes.computation import Computation +from cript.nodes.primary_nodes.data import Data +from cript.nodes.primary_nodes.material import Material +from cript.nodes.primary_nodes.process import Process +from cript.nodes.subobjects.citation import Citation +from cript.nodes.subobjects.condition import Condition +from cript.nodes.uuid_base import UUIDBaseNode + + +class Property(UUIDBaseNode): + """ + ## Definition + [Property](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=18) sub-objects + are qualities/traits of a [material](../../primary_nodes/material) or or [Process](../../primary_nodes/process) + + --- + + ## Can Be Added To: + * [Material](../../primary_nodes/material) + * [Process](../../primary_nodes/process) + * [Computation_Process](../../primary_nodes/computation_process) + + ## Available sub-objects: + * [Condition](../condition) + * [Citation](../citation) + + --- + + ## Attributes + + | attribute | type | example | description | required | vocab | + |--------------------|-------------------|------------------------------------------------------------------------|------------------------------------------------------------------------------|----------|-------| + | key | str | modulus_shear | type of property | True | True | + | type | str | min | type of value stored | True | True | + | value | Any | 1.23 | value or quantity | True | | + | unit | str | gram | unit for value | True | | + | uncertainty | Number | 0.1 | uncertainty of value | | | + | uncertainty_type | str | standard_deviation | type of uncertainty | | True | + | component | list[Material] | | material that the property relates to** | | | + | structure | str | {\[\]\[$\]\[C:1\]\[C:1\]\[$\], \[$\]\[C:2\]\[C:2\](\[C:2\]) \[$\]\[\]} | specific chemical structure associate with the property with atom mappings** | | | + | method | str | sec | approach or source of property data | | True | + | sample_preparation | Process | | sample preparation | | | + | condition | list[Condition] | | conditions under which the property was measured | | | + | data | Data | | data node | | | + | computation | list[Computation] | | computation method that produced property | | | + | citation | list[Citation] | | reference to a book, paper, or scholarly work | | | + | notes | str | | miscellaneous information, or custom data structure (e.g.; JSON) | | | + + + ## JSON Representation + ```json + { + "key":"modulus_shear", + "node":["Property"], + "type":"value", + "unit":"GPa", + "value":5.0 + "uid":"_:bc3abb68-25b5-4144-aa1b-85d82b7c77e1", + "uuid":"bc3abb68-25b5-4144-aa1b-85d82b7c77e1", + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + key: str = "" + type: str = "" + value: Union[Number, str, None] = None + unit: str = "" + uncertainty: Optional[Number] = None + uncertainty_type: str = "" + component: List[Material] = field(default_factory=list) + structure: str = "" + method: str = "" + sample_preparation: Optional[Process] = None + condition: List[Condition] = field(default_factory=list) + data: List[Data] = field(default_factory=list) + computation: List[Computation] = field(default_factory=list) + citation: List[Citation] = field(default_factory=list) + notes: str = "" + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__( + self, + key: str, + type: str, + value: Union[Number, str, None], + unit: Union[str, None], + uncertainty: Optional[Number] = None, + uncertainty_type: str = "", + component: Optional[List[Material]] = None, + structure: str = "", + method: str = "", + sample_preparation: Optional[Process] = None, + condition: Optional[List[Condition]] = None, + data: Optional[List[Data]] = None, + computation: Optional[List[Computation]] = None, + citation: Optional[List[Citation]] = None, + notes: str = "", + **kwargs + ): + """ + create a property sub-object + + Parameters + ---------- + key : str + type of property, Property key must come from the [CRIPT Controlled Vocabulary]() + type : str + type of value stored, Property type must come from the [CRIPT Controlled Vocabulary]() + value : Union[Number, None] + value or quantity + unit : str + unit for value + uncertainty : Union[Number, None], optional + uncertainty value of the value, by default None + uncertainty_type : str, optional + type of uncertainty, by default "" + component : Union[List[Material], None], optional + List of Material nodes, by default None + structure : str, optional + specific chemical structure associate with the property with atom mappings**, by default "" + method : str, optional + approach or source of property data, by default "" + sample_preparation : Union[Process, None], optional + sample preparation, by default None + condition : Union[List[Condition], None], optional + conditions under which the property was measured, by default None + data : Union[List[Data], None], optional + Data node, by default None + computation : Union[List[Computation], None], optional + computation method that produced property, by default None + citation : Union[List[Citation], None], optional + reference scholarly work, by default None + notes : str, optional + miscellaneous information, or custom data structure (e.g.; JSON), by default "" + + + Examples + -------- + ```python + import cript + + my_property = cript.Property(key="air_flow", type="min", value=1.00, unit="gram") + ``` + + Returns + ------- + None + create a Property sub-object + """ + if component is None: + component = [] + if condition is None: + condition = [] + if computation is None: + computation = [] + if data is None: + data = [] + if citation is None: + citation = [] + + super().__init__(**kwargs) + self._json_attrs = replace( + self._json_attrs, + key=key, + type=type, + value=value, + unit=unit, + uncertainty=uncertainty, + uncertainty_type=uncertainty_type, + component=component, + structure=structure, + method=method, + sample_preparation=sample_preparation, + condition=condition, + data=data, + computation=computation, + citation=citation, + notes=notes, + ) + self.validate() + + @property + @beartype + def key(self) -> str: + """ + Property key must come from [CRIPT Controlled Vocabulary](https://app.criptapp.org/vocab/) + + Examples + -------- + ```python + my_parameter.key = "angle_rdist" + ``` + + Returns + ------- + str + Property Key + """ + return self._json_attrs.key + + @key.setter + @beartype + def key(self, new_key: str) -> None: + """ + set the key for this Property sub-object + + Parameters + ---------- + new_key : str + new Property key + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, key=new_key) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def type(self) -> str: + """ + type of value for this Property sub-object + + [property type](https://app.criptapp.org/vocab/) must come from CRIPT controlled vocabulary + + Examples + ```python + my_property.type = "max" + ``` + + Returns + ------- + str + type of value for this Property sub-object + """ + return self._json_attrs.type + + @type.setter + @beartype + def type(self, new_type: str) -> None: + """ + set the Property type for this subobject + + Parameters + ---------- + new_type : str + new Property type + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, type=new_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def value(self) -> Union[Number, str, None]: + """ + get the Property value + + Returns + ------- + Union[Number, None] + Property value + """ + return self._json_attrs.value + + @beartype + def set_value(self, new_value: Union[Number, str], new_unit: str) -> None: + """ + set the value attribute of the Property subobject + + Examples + --------- + ```python + my_property.set_value(new_value=1, new_unit="gram") + ``` + + Parameters + ---------- + new_value : Number + new value + new_unit : str + new unit for the value + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, value=new_value, unit=new_unit) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def unit(self) -> str: + """ + get the Property unit for the value + + Returns + ------- + str + unit + """ + return self._json_attrs.unit + + @property + @beartype + def uncertainty(self) -> Union[Number, None]: + """ + get the uncertainty value of the Property node + + Returns + ------- + Union[Number, None] + uncertainty value + """ + return self._json_attrs.uncertainty + + @beartype + def set_uncertainty(self, new_uncertainty: Number, new_uncertainty_type: str) -> None: + """ + set the uncertainty value and type + + Uncertainty type must come from [CRIPT Controlled Vocabulary] + + Parameters + ---------- + new_uncertainty : Number + new uncertainty value + new_uncertainty_type : str + new uncertainty type + + Examples + -------- + ```python + my_property.set_uncertainty(new_uncertainty=2, new_uncertainty_type="fwhm") + ``` + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, uncertainty=new_uncertainty, uncertainty_type=new_uncertainty_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def uncertainty_type(self) -> str: + """ + get the uncertainty_type for this Property subobject + + [Uncertainty type](https://app.criptapp.org/vocab/uncertainty_type) + must come from CRIPT Controlled Vocabulary + + Returns + ------- + str + Uncertainty type + """ + return self._json_attrs.uncertainty_type + + @property + @beartype + def component(self) -> List[Material]: + """ + list of Materials that the Property relates to + + Examples + --------- + ```python + + my_identifiers = [{"bigsmiles": "123456"}] + my_material = cript.Material(name="my material", identifier=my_identifiers) + + # add material node as component to Property subobject + my_property.component = my_material + ``` + + Returns + ------- + List[Material] + list of Materials that the Property relates to + """ + return self._json_attrs.component.copy() + + @component.setter + @beartype + def component(self, new_component: List[Material]) -> None: + """ + set the list of Materials as components for the Property subobject + + Parameters + ---------- + new_component : List[Material] + new list of Materials to for the Property subobject + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, component=new_component) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def structure(self) -> str: + """ + specific chemical structure associate with the property with atom mappings + + Examples + -------- + ```python + my_property.structure = "{[][$][C:1][C:1][$],[$][C:2][C:2]([C:2])[$][]}" + ``` + + Returns + ------- + str + Property structure string + """ + return self._json_attrs.structure + + @structure.setter + @beartype + def structure(self, new_structure: str) -> None: + """ + set the this Property's structure + + Parameters + ---------- + new_structure : str + new structure + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, structure=new_structure) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def method(self) -> str: + """ + approach or source of property data True sample_preparation Process sample preparation + + [Property method](https://app.criptapp.org/vocab/property_method) must come from CRIPT Controlled Vocabulary + + Examples + -------- + ```python + my_property.method = "ASTM_D3574_Test_A" + ``` + + Returns + ------- + str + Property method + """ + return self._json_attrs.method + + @method.setter + @beartype + def method(self, new_method: str) -> None: + """ + set the Property method + + Property method must come from [CRIPT Controlled Vocabulary]() + + Parameters + ---------- + new_method : str + new Property method + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, method=new_method) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def sample_preparation(self) -> Union[Process, None]: + """ + sample_preparation + + Examples + -------- + ```python + my_process = cript.Process(name="my process name", type="affinity_pure") + + my_property.sample_preparation = my_process + ``` + + Returns + ------- + Union[Process, None] + Property linking back to the Process that has it as subobject + """ + return self._json_attrs.sample_preparation + + @sample_preparation.setter + @beartype + def sample_preparation(self, new_sample_preparation: Union[Process, None]) -> None: + """ + set the sample_preparation for the Property subobject + + Parameters + ---------- + new_sample_preparation : Union[Process, None] + back link to the Process that has this Property as its subobject + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, sample_preparation=new_sample_preparation) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def condition(self) -> List[Condition]: + """ + list of Conditions under which the property was measured + + Examples + -------- + ```python + my_condition = cript.Condition(key="atm", type="max", value=1) + + my_property.condition = [my_condition] + ``` + + Returns + ------- + List[Condition] + list of Conditions + """ + return self._json_attrs.condition.copy() + + @condition.setter + @beartype + def condition(self, new_condition: List[Condition]) -> None: + """ + set the list of Conditions for this property subobject + + Parameters + ---------- + new_condition : List[Condition] + new list of Condition Subobjects + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, condition=new_condition) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def data(self) -> List[Data]: + """ + List of Data nodes for this Property subobjects + + Examples + -------- + ```python + # create file node for the Data node + my_file = cript.File( + source="https://criptapp.org", + type="calibration", + extension=".csv", + data_dictionary="my file's data dictionary", + ) + + # create data node for the property subobject + my_data = cript.Data(name="my data name", type="afm_amp", file=[my_file]) + + # add data node to Property subobject + my_property.data = my_data + ``` + + Returns + ------- + List[Data] + list of Data nodes + """ + return self._json_attrs.data.copy() + + @data.setter + @beartype + def data(self, new_data: List[Data]) -> None: + """ + set the Data node for the Property subobject + + Parameters + ---------- + new_data : List[Data] + new list of Data nodes + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, data=new_data) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def computation(self) -> List[Computation]: + """ + list of Computation nodes that produced this property + + Examples + -------- + ```python + my_computation = cript.Computation(name="my computation name", type="analysis") + + my_property.computation = [my_computation] + ``` + + Returns + ------- + List[Computation] + list of Computation nodes + """ + return self._json_attrs.computation.copy() + + @computation.setter + @beartype + def computation(self, new_computation: List[Computation]) -> None: + """ + set the list of Computation nodes that produced this property + + Parameters + ---------- + new_computation : List[Computation] + new list of Computation nodes + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, computation=new_computation) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def citation(self) -> List[Citation]: + """ + list of Citation subobjects for this Property subobject + + Examples + -------- + ```python + # create reference node for the citation node + title = "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: " + title += "Soft coarse grained Monte-Carlo Acceleration (SOMA)" + + my_reference = cript.Reference( + type="journal_article", + title=title, + author=["Ludwig Schneider", "Marcus Müller"], + journal="Computer Physics Communications", + publisher="Elsevier", + year=2019, + pages=[463, 476], + doi="10.1016/j.cpc.2018.08.011", + issn="0010-4655", + website="https://www.sciencedirect.com/science/article/pii/S0010465518303072", + ) + + # create citation node and add reference node to it + my_citation = cript.Citation(type="reference", reference=my_reference) + + # add citation to Property subobject + my_property.citation = [my_citation] + ``` + + Returns + ------- + List[Citation] + list of Citation subobjects for this Property subobject + """ + return self._json_attrs.citation.copy() + + @citation.setter + @beartype + def citation(self, new_citation: List[Citation]) -> None: + """ + set the list of Citation subobjects for the Property subobject + + Parameters + ---------- + new_citation : List[Citation] + new list of Citation subobjects + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, citation=new_citation) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def notes(self) -> str: + """ + notes for this Property subobject + + Examples + -------- + ```python + my_property.notes = "these are my notes" + ``` + + Returns + ------- + str + notes for this property subobject + """ + return self._json_attrs.notes + + @notes.setter + @beartype + def notes(self, new_notes: str) -> None: + """ + set the notes for this Property subobject + + Parameters + ---------- + new_notes : str + new notes + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, notes=new_notes) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/subobjects/quantity.py b/src/cript/nodes/subobjects/quantity.py new file mode 100644 index 000000000..ae24ea464 --- /dev/null +++ b/src/cript/nodes/subobjects/quantity.py @@ -0,0 +1,258 @@ +from dataclasses import dataclass, replace +from numbers import Number +from typing import Optional, Union + +from beartype import beartype + +from cript.nodes.uuid_base import UUIDBaseNode + + +class Quantity(UUIDBaseNode): + """ + ## Definition + The [Quantity](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=22) + sub-objects are the amount of material involved in a process + + --- + + ## Can Be Added To: + * [Ingredient](../ingredient) + + ## Available sub-objects + * None + + ---- + + ## Attributes + + | attribute | type | example | description | required | vocab | + |------------------|---------|---------|----------------------|----------|-------| + | key | str | mass | type of quantity | True | True | + | value | Any | 1.23 | amount of material | True | | + | unit | str | gram | unit for quantity | True | | + | uncertainty | Number | 0.1 | uncertainty of value | | | + | uncertainty_type | str | std | type of uncertainty | | True | + + + + + ## JSON Representation + ```json + { + "node":["Quantity"], + "key":"mass", + "value":11.2 + "uncertainty":0.2, + "uncertainty_type":"stdev", + "unit":"kg", + "uid":"_:c95ee781-923b-4699-ba3b-923ce186ac5d", + "uuid":"c95ee781-923b-4699-ba3b-923ce186ac5d", + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + key: str = "" + value: Optional[Number] = None + unit: str = "" + uncertainty: Optional[Number] = None + uncertainty_type: str = "" + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, key: str, value: Number, unit: str, uncertainty: Optional[Number] = None, uncertainty_type: str = "", **kwargs): + """ + create Quantity sub-object + + Parameters + ---------- + key : str + type of quantity. Quantity key must come from [CRIPT Controlled Vocabulary]() + value : Number + amount of material + unit : str + unit for quantity + uncertainty : Union[Number, None], optional + uncertainty of value, by default None + uncertainty_type : str, optional + type of uncertainty. Quantity uncertainty type must come from [CRIPT Controlled Vocabulary](), by default "" + + Examples + -------- + ```python + import cript + + my_quantity = cript.Quantity( + key="mass", value=11.2, unit="kg", uncertainty=0.2, uncertainty_type="stdev" + ) + ``` + + Returns + ------- + None + create Quantity sub-object + """ + super().__init__(**kwargs) + self._json_attrs = replace(self._json_attrs, key=key, value=value, unit=unit, uncertainty=uncertainty, uncertainty_type=uncertainty_type) + self.validate() + + @classmethod + def _from_json(cls, json_dict: dict): + # TODO: remove this temporary fix, once back end is working correctly + for key in ["value", "uncertainty"]: + try: + json_dict[key] = float(json_dict[key]) + except KeyError: + pass + return super(Quantity, cls)._from_json(json_dict) + + @beartype + def set_key_unit(self, new_key: str, new_unit: str) -> None: + """ + set the Quantity key and unit attributes + + Quantity key must come from [CRIPT Controlled Vocabulary]() + + Examples + -------- + ```python + my_quantity.set_key_unit(new_key="mass", new_unit="gram") + ``` + + Parameters + ---------- + new_key : str + new Quantity key. Quantity key must come from [CRIPT Controlled Vocabulary]() + new_unit : str + new unit + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, key=new_key, unit=new_unit) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def key(self) -> str: + """ + get the Quantity sub-object key attribute + + [Quantity type](https://app.criptapp.org/vocab/quantity_key) must come from CRIPT controlled vocabulary + + Returns + ------- + str + this Quantity key attribute + """ + return self._json_attrs.key + + @property + @beartype + def value(self) -> Union[int, float, str]: + """ + amount of Material + + Examples + -------- + ```python + my_quantity.value = 1 + ``` + + Returns + ------- + Union[int, float, str] + amount of Material + """ + return self._json_attrs.value # type: ignore + + @value.setter + @beartype + def value(self, new_value: Union[int, float, str]) -> None: + """ + set the amount of Material + + Parameters + ---------- + new_value : Union[int, float, str] + amount of Material + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, value=new_value) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def unit(self) -> str: + """ + get the Quantity unit attribute + + Returns + ------- + str + unit for the Quantity value attribute + """ + return self._json_attrs.unit + + @property + @beartype + def uncertainty(self) -> Optional[Number]: + """ + get the uncertainty value + + Returns + ------- + Number + uncertainty value + """ + return self._json_attrs.uncertainty # type: ignore + + @property + @beartype + def uncertainty_type(self) -> str: + """ + get the uncertainty type attribute for the Quantity sub-object + + [Uncertainty type](https://app.criptapp.org/vocab/uncertainty_type) must come from CRIPT controlled vocabulary + + Returns + ------- + str + uncertainty type + """ + return self._json_attrs.uncertainty_type + + @beartype + def set_uncertainty(self, uncertainty: Number, type: str) -> None: + """ + set the `uncertainty value` and `uncertainty_type` + + Uncertainty and uncertainty type are set at the same time to keep the value and type in sync + + `uncertainty_type` must come from [CRIPT Controlled Vocabulary]() + + Examples + -------- + ```python + my_property.set_uncertainty(uncertainty=1, type="stderr") + ``` + + Parameters + ---------- + uncertainty : Number + uncertainty value + type : str + type of uncertainty, uncertainty_type must come from [CRIPT Controlled Vocabulary]() + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, uncertainty=uncertainty, uncertainty_type=type) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/subobjects/software.py b/src/cript/nodes/subobjects/software.py new file mode 100644 index 000000000..4ee60ad35 --- /dev/null +++ b/src/cript/nodes/subobjects/software.py @@ -0,0 +1,194 @@ +from dataclasses import dataclass, replace + +from beartype import beartype + +from cript.nodes.uuid_base import UUIDBaseNode + + +class Software(UUIDBaseNode): + """ + ## Definition + + The [Software](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=16) + node contains metadata for a computation tool, code, programming language, or software package. + + Similar to the [reference](../../primary_nodes/reference) node, the software node does not contain the base + attributes and is meant to always be public and static. + + --- + + ## Can Be Added To: + * [Software_Configuration](../../subobjects/software_configuration) + + ## Available sub-objects + * None + + --- + + ## Attributes + + | attribute | type | example | description | required | vocab | + |-----------|------|------------|-------------------------------|----------|-------| + | name | str | LAMMPS | type of literature | True | | + | version | str | 23Jun22 | software version | True | | + | source | str | lammps.org | source of software | | | + + ## JSON Representation + ```json + { + "name":"SOMA", + "node":["Software"], + "version":"0.7.0" + "source":"https://gitlab.com/InnocentBug/SOMA", + "uid":"_:f2ec4bf2-96aa-48a3-bfbc-d1d3f090583b", + "uuid":"f2ec4bf2-96aa-48a3-bfbc-d1d3f090583b", + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + name: str = "" + version: str = "" + source: str = "" + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, name: str, version: str, source: str = "", **kwargs): + """ + create Software node + + Parameters + ---------- + name : str + Software name + version : str + Software version + source : str, optional + Software source, by default "" + + Examples + -------- + ```python + my_software = cript.Software( + name="my software name", version="v1.0.0", source="https://myurl.com" + ) + ``` + + Returns + ------- + None + create Software node + """ + super().__init__(**kwargs) + + self._json_attrs = replace(self._json_attrs, name=name, version=version, source=source) + self.validate() + + @property + @beartype + def name(self) -> str: + """ + Software name + + Examples + -------- + ```python + my_software.name = "my software name" + ``` + + Returns + ------- + str + Software name + """ + return self._json_attrs.name + + @name.setter + @beartype + def name(self, new_name: str) -> None: + """ + set the name of the Software node + + Parameters + ---------- + new_name : str + new Software name + + Returns + ------- + None + """ + new_attr = replace(self._json_attrs, name=new_name) + self._update_json_attrs_if_valid(new_attr) + + @property + @beartype + def version(self) -> str: + """ + Software version + + my_software.version = "1.2.3" + + Returns + ------- + str + Software version + """ + return self._json_attrs.version + + @version.setter + @beartype + def version(self, new_version: str) -> None: + """ + set the Software version + + Parameters + ---------- + new_version : str + new Software version + + Returns + ------- + None + """ + new_attr = replace(self._json_attrs, version=new_version) + self._update_json_attrs_if_valid(new_attr) + + @property + @beartype + def source(self) -> str: + """ + Software source + + Examples + -------- + ```python + my_software.source = "https://mywebsite.com" + ``` + + Returns + ------- + str + Software source + """ + return self._json_attrs.source + + @source.setter + @beartype + def source(self, new_source: str) -> None: + """ + set the Software source + + Parameters + ---------- + new_source : str + new Software source + + Returns + ------- + None + """ + new_attr = replace(self._json_attrs, source=new_source) + self._update_json_attrs_if_valid(new_attr) diff --git a/src/cript/nodes/subobjects/software_configuration.py b/src/cript/nodes/subobjects/software_configuration.py new file mode 100644 index 000000000..8e727f83a --- /dev/null +++ b/src/cript/nodes/subobjects/software_configuration.py @@ -0,0 +1,286 @@ +from dataclasses import dataclass, field, replace +from typing import List, Optional, Union + +from beartype import beartype + +from cript.nodes.subobjects.algorithm import Algorithm +from cript.nodes.subobjects.citation import Citation +from cript.nodes.subobjects.software import Software +from cript.nodes.uuid_base import UUIDBaseNode + + +class SoftwareConfiguration(UUIDBaseNode): + """ + ## Definition + + The [software_configuration](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=24) + sub-object includes software and the set of algorithms to execute computation or computational_process. + + --- + + ## Can Be Added To: + * [Computation](../../primary_nodes/computation) + * [Computation_Process](../../primary_nodes/computation_process) + + ## Available sub-objects: + * [Algorithm](../algorithm) + * [Citation](../citation) + + --- + + ## Attributes + + | keys | type | example | description | required | vocab | + |--------------------------------------------------|-----------------|---------|------------------------------------------------------------------|----------|-------| + | software | Software | | software used | True | | + | algorithms | list[Algorithm] | | algorithms used | | | + | notes | str | | miscellaneous information, or custom data structure (e.g.; JSON) | | | + | citation | list[Citation] | | reference to a book, paper, or scholarly work | | | + + + ## JSON Representation + ```json + { + "node":["SoftwareConfiguration"], + "uid":"_:f0dc3415-635d-4590-8b1f-cd65ad8ab3fe" + "software":{ + "name":"SOMA", + "node":["Software"], + "source":"https://gitlab.com/InnocentBug/SOMA", + "uid":"_:5bf9cb33-f029-4d1b-ba53-3602036e4f75", + "uuid":"5bf9cb33-f029-4d1b-ba53-3602036e4f75", + "version":"0.7.0" + } + } + ``` + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + software: Union[Software, None] = None + algorithm: List[Algorithm] = field(default_factory=list) + notes: str = "" + citation: List[Citation] = field(default_factory=list) + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, software: Software, algorithm: Optional[List[Algorithm]] = None, notes: str = "", citation: Union[List[Citation], None] = None, **kwargs): + """ + Create Software_Configuration sub-object + + + Parameters + ---------- + software : Software + Software node used for the Software_Configuration + algorithm : Union[List[Algorithm], None], optional + algorithm used for the Software_Configuration, by default None + notes : str, optional + plain text notes, by default "" + citation : Union[List[Citation], None], optional + list of Citation sub-object, by default None + + Examples + --------- + ```python + import cript + + my_software = cript.Software(name="LAMMPS", version="23Jun22", source="lammps.org") + + my_software_configuration = cript.SoftwareConfiguration(software=my_software) + ``` + + Returns + ------- + None + Create Software_Configuration sub-object + """ + if algorithm is None: + algorithm = [] + if citation is None: + citation = [] + super().__init__(**kwargs) + self._json_attrs = replace(self._json_attrs, software=software, algorithm=algorithm, notes=notes, citation=citation) + self.validate() + + @property + @beartype + def software(self) -> Union[Software, None]: + """ + Software used + + Examples + -------- + ```python + my_software = cript.Software( + name="my software name", version="v1.0.0", source="https://myurl.com" + ) + + my_software_configuration.software = my_software + ``` + + Returns + ------- + Union[Software, None] + Software node used + """ + return self._json_attrs.software + + @software.setter + @beartype + def software(self, new_software: Union[Software, None]) -> None: + """ + set the Software used + + Parameters + ---------- + new_software : Union[Software, None] + new Software node + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, software=new_software) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def algorithm(self) -> List[Algorithm]: + """ + list of Algorithms used + + Examples + -------- + ```python + my_algorithm = cript.Algorithm(key="mc_barostat", type="barostat") + + my_software_configuration.algorithm = [my_algorithm] + ``` + + Returns + ------- + List[Algorithm] + list of algorithms used + """ + return self._json_attrs.algorithm.copy() + + @algorithm.setter + @beartype + def algorithm(self, new_algorithm: List[Algorithm]) -> None: + """ + set the list of Algorithms + + Parameters + ---------- + new_algorithm : List[Algorithm] + list of algorithms + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, algorithm=new_algorithm) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def notes(self) -> str: + """ + miscellaneous information, or custom data structure (e.g.; JSON). Notes can be written in plain text or JSON + + Examples + -------- + ### Plain Text + ```json + my_software_configuration.notes = "these are my awesome notes!" + ``` + + ### JSON Notes + ```python + my_software_configuration.notes = "{'notes subject': 'notes contents'}" + ``` + + Returns + ------- + str + notes + """ + return self._json_attrs.notes + + @notes.setter + @beartype + def notes(self, new_notes: str) -> None: + """ + set notes for Software_configuration + + Parameters + ---------- + new_notes : str + new notes in plain text or JSON + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, notes=new_notes) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def citation(self) -> List[Citation]: + """ + list of Citation sub-objects for the Software_Configuration + + Examples + -------- + ```python + title = "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: " + title += "SOft coarse grained Monte-Carlo Acceleration (SOMA)" + + # create reference node + my_reference = cript.Reference( + type"journal_article", + title=title, + author=["Ludwig Schneider", "Marcus Müller"], + journal="Computer Physics Communications", + publisher="Elsevier", + year=2019, + pages=[463, 476], + doi="10.1016/j.cpc.2018.08.011", + issn="0010-4655", + website="https://www.sciencedirect.com/science/article/pii/S0010465518303072", + ) + + # create citation sub-object and add reference to it + my_citation = Citation("reference", my_reference) + + # add citation to algorithm node + my_software_configuration.citation = [my_citation] + ``` + + Returns + ------- + List[Citation] + list of Citations + """ + return self._json_attrs.citation.copy() + + @citation.setter + @beartype + def citation(self, new_citation: List[Citation]) -> None: + """ + set the Citation sub-object + + Parameters + ---------- + new_citation : List[Citation] + new list of Citation sub-objects + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, citation=new_citation) + self._update_json_attrs_if_valid(new_attrs) diff --git a/src/cript/nodes/supporting_nodes/__init__.py b/src/cript/nodes/supporting_nodes/__init__.py new file mode 100644 index 000000000..dc07c7eef --- /dev/null +++ b/src/cript/nodes/supporting_nodes/__init__.py @@ -0,0 +1,3 @@ +# trunk-ignore-all(ruff/F401) +from cript.nodes.supporting_nodes.file import File +from cript.nodes.supporting_nodes.user import User diff --git a/src/cript/nodes/supporting_nodes/file.py b/src/cript/nodes/supporting_nodes/file.py new file mode 100644 index 000000000..d540b8a9b --- /dev/null +++ b/src/cript/nodes/supporting_nodes/file.py @@ -0,0 +1,446 @@ +import os +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Union + +from beartype import beartype + +from cript.nodes.primary_nodes.primary_base_node import PrimaryBaseNode + + +def _is_local_file(file_source: str) -> bool: + """ + Determines if the file the user is uploading is a local file or a link. + + It basically tests if the path exists, and it is specifically a file + on the local storage and not just a valid directory + + Notes + ----- + since checking for URL is very easy because it has to start with HTTP it checks that as well + if it starts with http then it makes the work easy, and it is automatically web URL + + Parameters + ---------- + file_source: str + The source of the file. + + Returns + ------- + bool + True if the file is local, False if it's a link or s3 object_name. + """ + + # convert local or relative file path str into a path object and resolve it to always get an absolute path + file_source_abs_path: str = str(Path(file_source).resolve()) + + # if it doesn't start with HTTP and exists on disk + # checking "http" so it works with both "https://" and "http://" + if not file_source.startswith("http") and os.path.isfile(file_source_abs_path): + return True + + else: + return False + + +def _upload_file_and_get_object_name(source: Union[str, Path], api=None) -> str: + """ + uploads file to cloud storage and returns the file link + + 1. checks if the source is a local file path and not a web url + 1. if it is a local file path, then it uploads it to cloud storage + * returns the file link in cloud storage + 1. else it returns the same file link because it is already on the web + + Parameters + ---------- + source: str + file source can be a relative or absolute file string or pathlib object + + Returns + ------- + str + file AWS S3 link + """ + from cript.api.api import _get_global_cached_api + + # convert source to str for `_is_local_file` and to return str + source = str(source) + + if _is_local_file(file_source=source): + if api is None: + api = _get_global_cached_api() + object_name = api.upload_file(file_path=source) + # always getting a string for object_name + source = str(object_name) + + # always returning a string + return source + + +class File(PrimaryBaseNode): + """ + ## Definition + + The [File node](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001 + .pdf#page=28) provides a link to scholarly work and allows users to specify in what way the work relates to that + data. More specifically, users can specify that the data was directly extracted from, inspired by, derived from, + etc. + + The file node is held in the [Data node](../../primary_nodes/data). + + ## Attributes + + | Attribute | Type | Example | Description | Required | + |-----------------|------|-------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------|----------| + | source | str | `"path/to/my/file"` or `"https://en.wikipedia.org/wiki/Simplified_molecular-input_line-entry_system"` | source to the file can be URL or local path | True | + | type | str | `"logs"` | Pick from [CRIPT File Types](https://app.criptapp.org/vocab/file-type/) | True | + | extension | str | `".csv"` | file extension | False | + | data_dictionary | str | `"my extra info in my data dictionary"` | set of information describing the contents, format, and structure of a file | False | + + ## JSON + ``` json + { + "node": ["File"], + "source": "https://criptapp.org", + "type": "calibration", + "extension": ".csv", + "data_dictionary": "my file's data dictionary", + } + ``` + + """ + + @dataclass(frozen=True) + class JsonAttributes(PrimaryBaseNode.JsonAttributes): + """ + all file attributes + """ + + source: str = "" + type: str = "" + extension: str = "" + data_dictionary: str = "" + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, name: str, source: str, type: str, extension: str = "", data_dictionary: str = "", notes: str = "", **kwargs): + """ + create a File node + + Parameters + ---------- + name: str + File node name + source: str + link or path to local file + type: str + Pick a file type from CRIPT controlled vocabulary [File types]() + extension:str + file extension + data_dictionary:str + extra information describing the file + notes: str + notes for the file node + **kwargs:dict + for internal use. Any extra data needed to create this file node + when deserializing the JSON response from the API + + Examples + -------- + ??? Example "Minimal File Node" + ```python + my_file = cript.File( + source="https://criptapp.org", + type="calibration", + ) + ``` + + ??? Example "Maximal File Node" + ```python + my_file = cript.File( + source="https://criptapp.org", + type="calibration", + extension=".csv", + data_dictionary="my file's data dictionary" + notes="my notes for this file" + ) + ``` + """ + + super().__init__(name=name, notes=notes, **kwargs) + + # TODO check if vocabulary is valid or not + # is_vocab_valid("file type", type) + + # setting every attribute except for source, which will be handled via setter + self._json_attrs = replace( + self._json_attrs, + type=type, + # always giving the function the required str regardless if the input `Path` or `str` + source=str(source), + extension=extension, + data_dictionary=data_dictionary, + ) + + self.validate() + + def ensure_uploaded(self, api=None): + """ + Ensure that a local file is being uploaded into CRIPT accessible cloud storage. + After this call, non-local files (file names that do not start with `http`) are uploaded. + It is not necessary to call this function manually. + A saved project automatically ensures uploaded files, it is recommend to rely on the automatic upload. + + Parameters: + ----------- + + api: cript.API, optional + API object that performs the upload. + If None, the globally cached object is being used. + + Examples + -------- + ??? Example "Minimal File Node" + ```python + my_file = cript.File(source="/local/path/to/file", type="calibration") + my_file.ensure_uploaded() + my_file.source # Starts with http now + ``` + + """ + + if _is_local_file(file_source=self.source): + # upload file source if local file + self.source = _upload_file_and_get_object_name(source=self.source) + + # TODO can be made into a function + + # --------------- Properties --------------- + @property + @beartype + def source(self) -> str: + """ + The File node source can be set to be either a path to a local file on disk + or a URL path to a file on the web. + + Example + -------- + URL File Source + ```python + my_file.source = "https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf" + ``` + Local File Path + ```python + my_file.source = "/home/user/project/my_file.csv" + ``` + + Returns + ------- + source: str + A string representing the file source. + """ + return self._json_attrs.source + + @source.setter + @beartype + def source(self, new_source: str) -> None: + """ + sets the source of the file node + the source can either be a path to a file on local storage or a link to a file + + 1. checks if the file source is a link or a local file path + 2. if the source is a link such as `https://wikipedia.com` then it sets the URL as the file source + 3. if the file source is a local file path such as + `C:\\Users\\my_username\\Desktop\\cript\\file.txt` + 1. then it opens the file and reads it + 2. uploads it to the cloud storage + 3. gets back a URL from where in the cloud the file is found + 4. sets that as the source + + Parameters + ---------- + new_source: str + + Example + ------- + ```python + my_file.source = "https://pubs.acs.org/doi/10.1021/acscentsci.3c00011" + ``` + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, source=new_source) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def type(self) -> str: + """ + The [File type](https://app.criptapp.org/vocab/file_type) must come from CRIPT controlled vocabulary + + Example + ------- + ```python + my_file.type = "calibration" + ``` + + Returns + ------- + file type: str + file type must come from [CRIPT controlled vocabulary]() + """ + return self._json_attrs.type + + @type.setter + @beartype + def type(self, new_type: str) -> None: + """ + set the file type + + file type must come from CRIPT controlled vocabulary + + Parameters + ----------- + new_type: str + + Example + ------- + ```python + my_file.type = "computation_config" + ``` + + Returns + ------- + None + """ + # TODO check vocabulary is valid + # is_vocab_valid("file type", self._json_attrs.type) + new_attrs = replace(self._json_attrs, type=new_type) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def extension(self) -> str: + """ + The file extension property explicitly states what is the file extension of the file node. + + Example + ------- + ```python + my_file_node.extension = ".csv"` + ``` + + Returns + ------- + extension: str + file extension + """ + return self._json_attrs.extension + + @extension.setter + @beartype + def extension(self, new_extension) -> None: + """ + sets the new file extension + + Parameters + ---------- + new_extension: str + new file extension to overwrite the current file extension + + Example + ------- + ```python + my_file.extension = ".pdf" + ``` + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, extension=new_extension) + self._update_json_attrs_if_valid(new_attrs) + + @property + @beartype + def data_dictionary(self) -> str: + # TODO data dictionary needs documentation describing it and how to use it + """ + The data dictionary contains additional information + that the scientist needs to describe their file. + + Notes + ------ + It is advised for this field to be written in JSON format + + Examples + ------- + ```python + my_file.data_dictionary = "{'notes': 'This is something that describes my file node.'}" + ``` + + Returns + ------- + data_dictionary: str + the file data dictionary attribute + """ + return self._json_attrs.data_dictionary + + @data_dictionary.setter + @beartype + def data_dictionary(self, new_data_dictionary: str) -> None: + """ + Sets the data dictionary for the file node. + + Parameters + ---------- + new_data_dictionary: str + The new data dictionary to be set. + + Returns + ------- + None + """ + new_attrs = replace(self._json_attrs, data_dictionary=new_data_dictionary) + self._update_json_attrs_if_valid(new_attrs) + + @beartype + def download( + self, + destination_directory_path: Union[str, Path] = ".", + ) -> None: + """ + download this file to current working directory or a specific destination. + The file name will come from the file_node.name and the extension will come from file_node.extension + + Notes + ----- + Whether the file extension is written like `.csv` or `csv` the program will work correctly + + Parameters + ---------- + destination_directory_path: Union[str, Path] + where you want the file to be stored and what you want the name to be + by default it is the current working directory + + Returns + ------- + None + """ + from cript.api.api import _get_global_cached_api + + api = _get_global_cached_api() + + # convert the path from str to Path in case it was given as a str and resolve it to get the absolute path + existing_folder_path = Path(destination_directory_path).resolve() + + # stripping dot from extension to make all extensions uniform, in case a user puts `.csv` or `csv` it will work + file_name = f"{self.name}.{self.extension.lstrip('.')}" + + absolute_file_path = str((existing_folder_path / file_name).resolve()) + + api.download_file(file_source=self.source, destination_path=absolute_file_path) diff --git a/src/cript/nodes/supporting_nodes/user.py b/src/cript/nodes/supporting_nodes/user.py new file mode 100644 index 000000000..c5374d0e6 --- /dev/null +++ b/src/cript/nodes/supporting_nodes/user.py @@ -0,0 +1,150 @@ +from dataclasses import dataclass, replace +from typing import Optional, Union + +from beartype import beartype + +from cript.nodes.uuid_base import UUIDBaseNode + + +class User(UUIDBaseNode): + """ + The [User node](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=27) + represents any researcher or individual who interacts with the CRIPT platform. + It serves two main purposes: + 1. It plays a core role in permissions (access control) + 1. It provides a traceable link to the individual who has contributed or edited data within the database + + + | attribute | type | example | description | required | vocab | + |------------|-------------|----------------------------|--------------------------------------------|----------|-------| + | url | str | | unique ID of the node | True | | + | username | str | "john_doe" | User’s name | True | | + | email | str | "user@cript.com" | email of the user | True | | + | orcid | str | "0000-0000-0000-0000" | ORCID ID of the user | True | | + | updated_at | datetime* | 2023-03-06 18:45:23.450248 | last date the node was modified (UTC time) | True | | + | created_at | datetime* | 2023-03-06 18:45:23.450248 | date it was created (UTC time) | True | | + + + ## JSON + ```json + { + "node": "User", + "username": "my username", + "email": "user@email.com", + "orcid": "0000-0000-0000-0001", + } + ``` + + Warnings + ------- + * A User cannot be created or modified using the Python SDK. + * A User node is a **read-only** node that can only be deserialized from API JSON response to Python node. + * The User node cannot be instantiated and within the Python SDK. + * Attempting to edit the user node will result in an `Attribute Error` + + """ + + @dataclass(frozen=True) + class JsonAttributes(UUIDBaseNode.JsonAttributes): + """ + all User attributes + """ + + email: Optional[str] = "" + model_version: str = "" + orcid: Optional[str] = "" + picture: str = "" + username: str = "" + + _json_attrs: JsonAttributes = JsonAttributes() + + @beartype + def __init__(self, username: str, email: Optional[str] = "", orcid: Optional[str] = "", **kwargs): + """ + Json from CRIPT API to be converted to a node + optionally the group can be None if the user doesn't have a group + + Parameters + ---------- + username: str + user username + email: str + user email + orcid: str + user ORCID + """ + super().__init__(**kwargs) + self._json_attrs = replace(self._json_attrs, username=username, email=email, orcid=orcid) + + self.validate() + + @property + @beartype + def created_at(self) -> str: + return self._json_attrs.created_at + + @property + @beartype + def email(self) -> Union[str, None]: + """ + user's email + + Raises + ------ + AttributeError + + Returns + ------- + user email: str + User node email + """ + return self._json_attrs.email + + @property + @beartype + def model_version(self) -> str: + return self._json_attrs.model_version + + @property + @beartype + def orcid(self) -> Union[str, None]: + """ + users [ORCID](https://orcid.org/) + + Raises + ------ + AttributeError + + Returns + ------- + ORCID: str + user's ORCID + """ + return self._json_attrs.orcid + + @property + @beartype + def picture(self) -> str: + return self._json_attrs.picture + + @property + @beartype + def updated_at(self) -> str: + return self._json_attrs.updated_at + + @property + @beartype + def username(self) -> str: + """ + username of the User node + + Raises + ------ + AttributeError + + Returns + ------- + username: str + username of the User node + """ + return self._json_attrs.username diff --git a/src/cript/nodes/util/__init__.py b/src/cript/nodes/util/__init__.py new file mode 100644 index 000000000..a9b4522c6 --- /dev/null +++ b/src/cript/nodes/util/__init__.py @@ -0,0 +1,508 @@ +import dataclasses +import inspect +import json +import uuid +from typing import Dict, List, Optional, Set, Union + +import cript.nodes +from cript.nodes.core import BaseNode +from cript.nodes.exceptions import ( + CRIPTDeserializationUIDError, + CRIPTJsonDeserializationError, + CRIPTJsonNodeError, + CRIPTOrphanedComputationalProcessError, + CRIPTOrphanedComputationError, + CRIPTOrphanedDataError, + CRIPTOrphanedMaterialError, + CRIPTOrphanedProcessError, +) +from cript.nodes.primary_nodes.experiment import Experiment +from cript.nodes.primary_nodes.project import Project + + +class NodeEncoder(json.JSONEncoder): + """ + Custom JSON encoder for serializing CRIPT nodes to JSON. + + This encoder is used to convert CRIPT nodes into JSON format while handling unique identifiers (UUIDs) and + condensed representations to avoid redundancy in the JSON output. + It also allows suppressing specific attributes from being included in the serialized JSON. + + Attributes + ---------- + handled_ids : Set[str] + A set to store the UIDs of nodes that have been processed during serialization. + known_uuid : Set[str] + A set to store the UUIDs of nodes that have been previously encountered in the JSON. + condense_to_uuid : Dict[str, Set[str]] + A set to store the node types that should be condensed to UUID edges in the JSON. + suppress_attributes : Optional[Dict[str, Set[str]]] + A dictionary that allows suppressing specific attributes for nodes with the corresponding UUIDs. + + Methods + ------- + ```python + default(self, obj: Any) -> Any: + # Convert CRIPT nodes and other objects to their JSON representation. + ``` + + ```python + _apply_modifications(self, serialize_dict: dict) -> Tuple[dict, List[str]]: + # Apply modifications to the serialized dictionary based on node types + # and attributes to be condensed. This internal function handles node + # condensation and attribute suppression during serialization. + ``` + """ + + handled_ids: Set[str] = set() + known_uuid: Set[str] = set() + condense_to_uuid: Dict[str, Set[str]] = dict() + suppress_attributes: Optional[Dict[str, Set[str]]] = None + + def default(self, obj): + """ + Convert CRIPT nodes and other objects to their JSON representation. + + This method is called during JSON serialization. + It customizes the serialization process for CRIPT nodes and handles unique identifiers (UUIDs) + to avoid redundant data in the JSON output. + It also allows for attribute suppression for specific nodes. + + Parameters + ---------- + obj : Any + The object to be serialized to JSON. + + Returns + ------- + dict + The JSON representation of the input object, which can be a string, a dictionary, or any other JSON-serializable type. + + Raises + ------ + CRIPTJsonDeserializationError + If there is an issue with the JSON deserialization process for CRIPT nodes. + + Notes + ----- + * If the input object is a UUID, it is converted to a string representation and returned. + * If the input object is a CRIPT node (an instance of `BaseNode`), it is serialized into a dictionary + representation. The node is first checked for uniqueness based on its UID (unique identifier), and if + it has already been serialized, it is represented as a UUID edge only. If not, the node's attributes + are added to the dictionary representation, and any default attribute values are removed to reduce + redundancy in the JSON output. + * The method `_apply_modifications()` is called to check if further modifications are needed before + considering the dictionary representation done. This includes condensing certain node types to UUID edges + and suppressing specific attributes for nodes. + """ + if isinstance(obj, uuid.UUID): + return str(obj) + if isinstance(obj, BaseNode): + try: + uid = obj.uid + except AttributeError: + pass + else: + if uid in NodeEncoder.handled_ids: + return {"uid": uid} + + # When saving graphs, some nodes can be pre-saved. + # If that happens, we want to represent them as a UUID edge only + try: + uuid_str = str(obj.uuid) + except AttributeError: + pass + else: + if uuid_str in NodeEncoder.known_uuid: + return {"uuid": uuid_str} + + default_dataclass = obj.JsonAttributes() + serialize_dict = {} + # Remove default values from serialization + for field_name in [field.name for field in dataclasses.fields(default_dataclass)]: + if getattr(default_dataclass, field_name) != getattr(obj._json_attrs, field_name): + serialize_dict[field_name] = getattr(obj._json_attrs, field_name) + # add the default node type + serialize_dict["node"] = obj._json_attrs.node + + # check if further modifications to the dict is needed before considering it done + serialize_dict, condensed_uid = self._apply_modifications(serialize_dict) + if uid not in condensed_uid: # We can uid (node) as handled if we don't condense it to uuid + NodeEncoder.handled_ids.add(uid) + + # Remove suppressed attributes + if NodeEncoder.suppress_attributes is not None and str(obj.uuid) in NodeEncoder.suppress_attributes: + for attr in NodeEncoder.suppress_attributes[str(obj.uuid)]: + del serialize_dict[attr] + + return serialize_dict + return json.JSONEncoder.default(self, obj) + + def _apply_modifications(self, serialize_dict: Dict): + """ + Checks the serialize_dict to see if any other operations are required before it + can be considered done. If other operations are required, then it passes it to the other operations + and at the end returns the fully finished dict. + + This function is essentially a big switch case that checks the node type + and determines what other operations are required for it. + + Parameters + ---------- + serialize_dict: dict + + Returns + ------- + serialize_dict: dict + """ + + def process_attribute(attribute): + def strip_to_edge_uuid(element): + # Extracts UUID and UID information from the element + try: + uuid = getattr(element, "uuid") + except AttributeError: + uuid = element["uuid"] + if len(element) == 1: # Already a condensed element + return element, None + try: + uid = getattr(element, "uid") + except AttributeError: + uid = element["uid"] + + element = {"uuid": str(uuid)} + return element, uid + + # Processes an attribute based on its type (list or single element) + if isinstance(attribute, list): + processed_elements = [] + for element in attribute: + processed_element, uid = strip_to_edge_uuid(element) + if uid is not None: + uid_of_condensed.append(uid) + processed_elements.append(processed_element) + return processed_elements + else: + processed_attribute, uid = strip_to_edge_uuid(attribute) + if uid is not None: + uid_of_condensed.append(uid) + return processed_attribute + + uid_of_condensed: List = [] + + nodes_to_condense = serialize_dict["node"] + for node_type in nodes_to_condense: + if node_type in self.condense_to_uuid: + attributes_to_process = self.condense_to_uuid[node_type] # type: ignore + for attribute in attributes_to_process: + if attribute in serialize_dict: + attribute_to_condense = serialize_dict[attribute] + processed_attribute = process_attribute(attribute_to_condense) + serialize_dict[attribute] = processed_attribute + + # Check if the node is "Material" and convert the identifiers list to JSON fields + if serialize_dict["node"] == ["Material"]: + serialize_dict = _material_identifiers_list_to_json_fields(serialize_dict) + + return serialize_dict, uid_of_condensed + + +class _UIDProxy: + """ + Proxy class for unresolvable UID nodes. + This is going to be replaced by actual nodes. + + Report a bug if you find this class in production. + """ + + def __init__(self, uid: str): + self.uid = uid + print("proxy", uid) + + +class _NodeDecoderHook: + def __init__(self, uid_cache: Optional[Dict] = None): + """ + Initialize the custom JSON object hook used for CRIPT node deserialization. + + Parameters + ---------- + uid_cache : Optional[Dict], optional + A dictionary to cache Python objects with shared UIDs, by default None. + + Notes + ----- + The `_NodeDecoderHook` class is used as an object hook for JSON deserialization, + handling the conversion of JSON nodes into Python objects based on their node types. + The `uid_cache` is an optional dictionary to store cached objects with shared UIDs + to never create two different python nodes with the same uid. + """ + if uid_cache is None: + uid_cache = {} + self._uid_cache = uid_cache + + def __call__(self, node_str: Union[dict, str]) -> dict: + """ + Internal function, used as a hook for json deserialization. + + This function is called recursively to convert every JSON of a node and its children from node to JSON. + + If given a JSON without a "node" field then it is assumed that it is not a node + and just a key value pair data, and the JSON string is then just converted from string to dict and returned + applicable for places where the data is something like + + ```json + { "bigsmiles": "123456" } + ``` + + no serialization is needed in this case and just needs to be converted from str to dict + + if the node field is present, then continue and convert the JSON node into a Python object + + Parameters + ---------- + node_str : Union[dict, str] + The JSON representation of a node or a regular dictionary. + + Returns + ------- + Union[CRIPT Node, dict] + Either returns a regular dictionary if the input JSON or input dict is NOT a node. + If it is a node, it returns the appropriate CRIPT node object, such as `cript.Material` + + Raises + ------ + CRIPTJsonNodeError + If there is an issue with the JSON structure or the node type is invalid. + CRIPTJsonDeserializationError + If there is an error during deserialization of a specific node type. + CRIPTDeserializationUIDError + If there is an issue with the UID used for deserialization, like circular references. + """ + node_dict = dict(node_str) # type: ignore + + # Handle UID objects. + if len(node_dict) == 1 and "uid" in node_dict: + try: + return self._uid_cache[node_dict["uid"]] + except KeyError: + # TODO if we convince beartype to accept Proxy temporarily, enable return instead of raise + raise CRIPTDeserializationUIDError("Unknown", node_dict["uid"]) + # return _UIDProxy(node_dict["uid"]) + + try: + node_type_list = node_dict["node"] + except KeyError: # Not a node, just a regular dictionary + return node_dict + + # TODO consider putting this into the try because it might need error handling for the dict + if _is_node_field_valid(node_type_list): + node_type_str = node_type_list[0] + else: + raise CRIPTJsonNodeError(node_type_list, str(node_str)) + + # Iterate over all nodes in cript to find the correct one here + for key, pyclass in inspect.getmembers(cript.nodes, inspect.isclass): + if BaseNode in inspect.getmro(pyclass): + if key == node_type_str: + try: + json_node = pyclass._from_json(node_dict) + self._uid_cache[json_node.uid] = json_node + return json_node + except Exception as exc: + raise CRIPTJsonDeserializationError(key, str(node_type_str)) from exc + # Fall back + return node_dict + + +def _material_identifiers_list_to_json_fields(serialize_dict: dict) -> dict: + """ + input: + ```json + { + "node":["Material"], + "name":"my material", + "identifiers":[ {"cas":"my material cas"} ], + "uid":"_:a78203cb-82ea-4376-910e-dee74088cd37" + } + ``` + + output: + ```json + { + "node":["Material"], + "name":"my material", + "cas":"my material cas", + "uid":"_:08018f4a-e8e3-4ac0-bdad-fa704fdc0145" + } + ``` + + Parameters + ---------- + serialize_dict: dict + the serialized dictionary of the node + + Returns + ------- + serialized_dict = dict + new dictionary that has converted the list of dictionary identifiers into the dictionary as fields + + """ + + # TODO this if statement might not be needed in future + if "identifiers" in serialize_dict: + for identifier in serialize_dict["identifiers"]: + for key, value in identifier.items(): + serialize_dict[key] = value + + # remove identifiers list of objects after it has been flattened + del serialize_dict["identifiers"] + + return serialize_dict + + +def _rename_field(serialize_dict: dict, old_name: str, new_name: str) -> dict: + """ + renames `property_` to `property` the JSON + """ + if "property_" in serialize_dict: + serialize_dict[new_name] = serialize_dict.pop(old_name) + + return serialize_dict + + +def _is_node_field_valid(node_type_list: list) -> bool: + """ + a simple function that checks if the node field has only a single node type in there + and not 2 or None + + Parameters + ---------- + node_type_list: list + e.g. "node": ["Material"] + + Returns + ------ + bool + if all tests pass then it returns true, otherwise false + """ + + # TODO consider having exception handling for the dict + if isinstance(node_type_list, list) and len(node_type_list) == 1 and isinstance(node_type_list[0], str) and len(node_type_list[0]) > 0: + return True + else: + return False + + +def load_nodes_from_json(nodes_json: str): + """ + User facing function, that return a node and all its children from a json string input. + + Parameters + ---------- + nodes_json: str + JSON string representation of a CRIPT node + + Examples + -------- + ```python + # get project node from API + my_paginator = cript_api.search( + node_type=cript.Project, + search_mode=cript.SearchModes.EXACT_NAME, + value_to_search=project_node.name + ) + + # get the project from paginator + my_project_from_api_dict = my_paginator.current_page_results[0] + + # convert API JSON to CRIPT Project node + my_project_from_api = cript.load_nodes_from_json(json.dumps(my_project_from_api_dict)) + ``` + + Raises + ------ + CRIPTJsonNodeError + If there is an issue with the JSON of the node field. + CRIPTJsonDeserializationError + If there is an error during deserialization of a specific node. + CRIPTDeserializationUIDError + If there is an issue with the UID used for deserialization, like circular references. + + Notes + ----- + This function uses a custom `_NodeDecoderHook` to convert JSON nodes into Python objects. + The `_NodeDecoderHook` class is responsible for handling the deserialization of nodes + and caching objects with shared UIDs to avoid redundant deserialization. + + The function is intended for deserializing CRIPT nodes and should not be used for generic JSON. + + + Returns + ------- + Union[CRIPT Node, List[CRIPT Node]] + Typically returns a single CRIPT node, + but if given a list of nodes, then it will serialize them and return a list of CRIPT nodes + """ + node_json_hook = _NodeDecoderHook() + json_nodes = json.loads(nodes_json, object_hook=node_json_hook) + + # TODO: enable this logic to replace proxies, once beartype is OK with that. + # def recursive_proxy_replacement(node, handled_nodes): + # if isinstance(node, _UIDProxy): + # try: + # node = node_json_hook._uid_cache[node.uid] + # except KeyError as exc: + # raise CRIPTDeserializationUIDError(node.node_type, node.uid) + # return node + # handled_nodes.add(node.uid) + # for field in node._json_attrs.__dict__: + # child_node = getattr(node._json_attrs, field) + # if not isinstance(child_node, list): + # if hasattr(cn, "__bases__") and BaseNode in child_node.__bases__: + # child_node = recursive_proxy_replacement(child_node, handled_nodes) + # node._json_attrs = replace(node._json_attrs, field=child_node) + # else: + # for i, cn in enumerate(child_node): + # if hasattr(cn, "__bases__") and BaseNode in cn.__bases__: + # if cn.uid not in handled_nodes: + # child_node[i] = recursive_proxy_replacement(cn, handled_nodes) + + # return node + # handled_nodes = set() + # recursive_proxy_replacement(json_nodes, handled_nodes) + return json_nodes + + +def add_orphaned_nodes_to_project(project: Project, active_experiment: Experiment, max_iteration: int = -1): + """ + Helper function that adds all orphaned material nodes of the project graph to the + `project.materials` attribute. + Material additions only is permissible with `active_experiment is None`. + This function also adds all orphaned data, process, computation and computational process nodes + of the project graph to the `active_experiment`. + This functions call `project.validate` and might raise Exceptions from there. + """ + if active_experiment is not None and active_experiment not in project.find_children({"node": ["Experiment"]}): + raise RuntimeError(f"The provided active experiment {active_experiment} is not part of the project graph. Choose an active experiment that is part of a collection of this project.") + + counter = 0 + while True: + if counter > max_iteration >= 0: + break # Emergency stop + try: + project.validate() + except CRIPTOrphanedMaterialError as exc: + # because calling the setter calls `validate` we have to force add the material. + project._json_attrs.material.append(exc.orphaned_node) + except CRIPTOrphanedDataError as exc: + active_experiment.data += [exc.orphaned_node] + except CRIPTOrphanedProcessError as exc: + active_experiment.process += [exc.orphaned_node] + except CRIPTOrphanedComputationError as exc: + active_experiment.computation += [exc.orphaned_node] + except CRIPTOrphanedComputationalProcessError as exc: + active_experiment.computation_process += [exc.orphaned_node] + else: + break + counter += 1 diff --git a/src/cript/nodes/util/material_deserialization.py b/src/cript/nodes/util/material_deserialization.py new file mode 100644 index 000000000..2bbf5d142 --- /dev/null +++ b/src/cript/nodes/util/material_deserialization.py @@ -0,0 +1,74 @@ +from typing import Dict, List + +import cript + + +def _deserialize_flattened_material_identifiers(json_dict: Dict) -> Dict: + """ + takes a material node in JSON format that has its identifiers as attributes and convert it to have the + identifiers within the identifiers field of a material node + + 1. gets the material identifiers controlled vocabulary from the API + 1. converts the API response from list[dicts] to just a list[str] + 1. loops through all the material identifiers and checks if they exist within the JSON dict + 1. if a material identifier is spotted in json dict, then that material identifier is moved from JSON attribute + into an identifiers field + + + ## Input + ```python + { + "node": ["Material"], + "name": "my cool material", + "uuid": "_:my cool material", + "smiles": "CCC", + "bigsmiles": "my big smiles" + } + ``` + + ## Output + ```python + { + "node":["Material"], + "name":"my cool material", + "uuid":"_:my cool material", + "identifiers":[ + {"smiles":"CCC"}, + {"bigsmiles":"my big smiles"} + ] + } + ``` + + Parameters + ---------- + json_dict: Dict + A JSON dictionary representing a node + + Returns + ------- + json_dict: Dict + A new JSON dictionary with the material identifiers moved from attributes to the identifiers field + """ + from cript.api.api import _get_global_cached_api + + api = _get_global_cached_api() + + # get material identifiers keys from API and create a simple list + # eg ["smiles", "bigsmiles", etc.] + all_identifiers_list: List[str] = [identifier.get("name") for identifier in api.get_vocab_by_category(cript.VocabCategories.MATERIAL_IDENTIFIER_KEY)] + + # pop "name" from identifiers list because the node has to have a name + all_identifiers_list.remove("name") + + identifier_argument: List[Dict] = [] + + # move material identifiers from JSON attribute to identifiers attributes + for identifier in all_identifiers_list: + if identifier in json_dict: + identifier_argument.append({identifier: json_dict[identifier]}) + # delete identifiers from the API JSON response as they are added to the material node + del json_dict[identifier] + + json_dict["identifiers"] = identifier_argument + + return json_dict diff --git a/src/cript/nodes/uuid_base.py b/src/cript/nodes/uuid_base.py new file mode 100644 index 000000000..899b278ed --- /dev/null +++ b/src/cript/nodes/uuid_base.py @@ -0,0 +1,76 @@ +import uuid +from abc import ABC +from dataclasses import dataclass, field, replace +from typing import Any, Dict + +from cript.nodes.core import BaseNode + + +def get_uuid_from_uid(uid): + return str(uuid.UUID(uid[2:])) + + +class UUIDBaseNode(BaseNode, ABC): + """ + Base node that handles UUIDs and URLs. + """ + + # Class attribute that caches all nodes created + _uuid_cache: Dict = {} + + @dataclass(frozen=True) + class JsonAttributes(BaseNode.JsonAttributes): + """ + All shared attributes between all Primary nodes and set to their default values + """ + + uuid: str = field(default_factory=lambda: str(uuid.uuid4())) + updated_by: Any = None + created_by: Any = None + created_at: str = "" + updated_at: str = "" + + _json_attrs: JsonAttributes = JsonAttributes() + + def __init__(self, **kwargs): + # initialize Base class with node + super().__init__(**kwargs) + # Respect uuid if passed as argument, otherwise construct uuid from uid + uuid = kwargs.get("uuid", get_uuid_from_uid(self.uid)) + # replace name and notes within PrimaryBase + self._json_attrs = replace(self._json_attrs, uuid=uuid) + + # Place successfully created node in the UUID cache + self._uuid_cache[uuid] = self + + @property + def uuid(self) -> uuid.UUID: + return uuid.UUID(self._json_attrs.uuid) + + @property + def url(self): + from cript.api.api import _get_global_cached_api + + api = _get_global_cached_api() + return f"{api.host}/{self.uuid}" + + def __deepcopy__(self, memo): + node = super().__deepcopy__(memo) + node._json_attrs = replace(node._json_attrs, uuid=get_uuid_from_uid(node.uid)) + return node + + @property + def updated_by(self): + return self._json_attrs.updated_by + + @property + def created_by(self): + return self._json_attrs.created_by + + @property + def updated_at(self): + return self._json_attrs.updated_at + + @property + def created_at(self): + return self._json_attrs.created_at diff --git a/tests/api/test_api.py b/tests/api/test_api.py new file mode 100644 index 000000000..7809f16da --- /dev/null +++ b/tests/api/test_api.py @@ -0,0 +1,412 @@ +import datetime +import json +import os +import tempfile +import uuid +from pathlib import Path +from typing import Dict + +import pytest +import requests +from conftest import HAS_INTEGRATION_TESTS_ENABLED + +import cript +from cript.api.exceptions import InvalidVocabulary +from cript.api.paginator import Paginator +from cript.nodes.exceptions import CRIPTNodeSchemaError + + +def test_create_api(cript_api: cript.API) -> None: + """ + tests that an API object can be successfully created with host and token + """ + # api = cript.API(host=None, api_token=None) + # + # # assertions + # assert api is not None + # assert isinstance(api, cript.API) + + pass + + +def test_api_with_invalid_host() -> None: + """ + this mostly tests the _prepare_host() function to be sure it is working as expected + * attempting to create an api client with invalid host appropriately throws a `CRIPTConnectionError` + * giving a host that does not start with http such as "criptapp.org" should throw an InvalidHostError + """ + with pytest.raises((requests.ConnectionError, cript.api.exceptions.CRIPTConnectionError)): + cript.API(host="https://some_invalid_host", api_token="123456789", storage_token="123456") + + with pytest.raises(cript.api.exceptions.InvalidHostError): + cript.API(host="no_http_host.org", api_token="123456789", storage_token="987654321") + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="skipping because API client needs API token") +def test_api_context(cript_api: cript.API) -> None: + assert cript.api.api._global_cached_api is not None + assert cript.api.api._get_global_cached_api() is not None + + +def test_api_cript_env_vars() -> None: + """ + tests that when the cript.API is given None for host, api_token, storage_token that it can correctly + retrieve things from the env variable + """ + host_value = "http://development.api.mycriptapp.org/" + api_token_value = "my cript API token value" + storage_token_value = "my cript storage token value" + + # set env vars + os.environ["CRIPT_HOST"] = host_value + os.environ["CRIPT_TOKEN"] = api_token_value + os.environ["CRIPT_STORAGE_TOKEN"] = storage_token_value + + api = cript.API(host=None, api_token=None, storage_token=None) + + # host/api/v1 + assert api._host == f"{host_value}api/v1" + assert api._api_token == api_token_value + assert api._storage_token == storage_token_value + + +def test_config_file() -> None: + """ + test if the api can read configurations from `config.json` + """ + + config_file_texts = {"host": "https://development.api.mycriptapp.org", "api_token": "I am token", "storage_token": "I am storage token"} + + with tempfile.NamedTemporaryFile(mode="w+t", suffix=".json", delete=False) as temp_file: + # absolute file path + config_file_path = temp_file.name + + # write JSON to temporary file + temp_file.write(json.dumps(config_file_texts)) + + # force text to be written to file + temp_file.flush() + + api = cript.API(config_file_path=config_file_path) + + assert api._host == config_file_texts["host"] + "/api/v1" + assert api._api_token == config_file_texts["api_token"] + + +@pytest.mark.skip(reason="too early to write as there are higher priority tasks currently") +def test_api_initialization_stress() -> None: + """ + tries to put the API configuration under as much stress as it possibly can + it tries to give it mixed options and try to trip it up and create issues for it + + ## scenarios + 1. if there is a config file and other inputs, then config file wins + 1. if config file, but is missing an attribute, and it is labeled as None, then should get it from env var + 1. if there is half from input and half from env var, then both should work happily + """ + pass + + +def test_get_db_schema_from_api(cript_api: cript.API) -> None: + """ + tests that the Python SDK can successfully get the db schema from API + """ + db_schema = cript_api._get_db_schema() + + assert bool(db_schema) + assert isinstance(db_schema, dict) + + # db schema should have at least 30 fields + assert len(db_schema["$defs"]) > 30 + + +def test_is_node_schema_valid(cript_api: cript.API) -> None: + """ + test that a CRIPT node can be correctly validated and invalidated with the db schema + + * test a couple of nodes to be sure db schema validation is working fine + * material node + * file node + * test db schema validation with an invalid node, and it should be invalid + + Notes + ----- + * does not test if serialization/deserialization works correctly, + just tests if the node schema can work correctly if serialization was correct + + # TODO the tests here only test POST db schema and not PATCH yet, those tests must be added + """ + + # ------ invalid node schema------ + invalid_schema = {"invalid key": "invalid value", "node": ["Material"]} + + with pytest.raises(CRIPTNodeSchemaError): + cript_api._is_node_schema_valid(node_json=json.dumps(invalid_schema), is_patch=False) + + # ------ valid material schema ------ + # valid material node + valid_material_dict = {"node": ["Material"], "name": "0.053 volume fraction CM gel", "uid": "_:0.053 volume fraction CM gel"} + + # convert dict to JSON string because method expects JSON string + assert cript_api._is_node_schema_valid(node_json=json.dumps(valid_material_dict), is_patch=False) is True + # ------ valid file schema ------ + valid_file_dict = { + "node": ["File"], + "source": "https://criptapp.org", + "type": "calibration", + "extension": ".csv", + "data_dictionary": "my file's data dictionary", + } + + # convert dict to JSON string because method expects JSON string + assert cript_api._is_node_schema_valid(node_json=json.dumps(valid_file_dict), is_patch=False) is True + + +def test_get_vocabulary_by_category(cript_api: cript.API) -> None: + """ + tests if a vocabulary can be retrieved by category + 1. tests response is a list of dicts as expected + 1. create a new list of just material identifiers + 1. tests that the fundamental identifiers exist within the API vocabulary response + + Warnings + -------- + This test only gets the vocabulary category for "material_identifier_key" and does not test all the possible + CRIPT controlled vocabulary + """ + + material_identifier_vocab_list = cript_api.get_vocab_by_category(cript.VocabCategories.MATERIAL_IDENTIFIER_KEY) + + # test response is a list of dicts + assert isinstance(material_identifier_vocab_list, list) + + material_identifiers = [identifier["name"] for identifier in material_identifier_vocab_list] + + # assertions + assert "bigsmiles" in material_identifiers + assert "smiles" in material_identifiers + assert "pubchem_cid" in material_identifiers + + +def test_get_controlled_vocabulary_from_api(cript_api: cript.API) -> None: + """ + checks if it can successfully get the controlled vocabulary list from CRIPT API + """ + number_of_vocab_categories = 26 + vocab = cript_api._get_vocab() + + # assertions + # check vocabulary list is not empty + assert bool(vocab) is True + assert len(vocab) == number_of_vocab_categories + + +def test_is_vocab_valid(cript_api: cript.API) -> None: + """ + tests if the method for vocabulary is validating and invalidating correctly + + * test with custom key to check it automatically gives valid + * test with a few vocabulary_category and vocabulary_words + * valid category and valid vocabulary word + * test that invalid category throws the correct error + * invalid category and valid vocabulary word + * test that invalid vocabulary word throws the correct error + * valid category and invalid vocabulary word + tests invalid category and invalid vocabulary word + """ + # custom vocab + assert cript_api._is_vocab_valid(vocab_category=cript.VocabCategories.ALGORITHM_KEY, vocab_word="+my_custom_key") is True + + # valid vocab category and valid word + assert cript_api._is_vocab_valid(vocab_category=cript.VocabCategories.FILE_TYPE, vocab_word="calibration") is True + assert cript_api._is_vocab_valid(vocab_category=cript.VocabCategories.QUANTITY_KEY, vocab_word="mass") is True + assert cript_api._is_vocab_valid(vocab_category=cript.VocabCategories.UNCERTAINTY_TYPE, vocab_word="fwhm") is True + + # valid vocab category but invalid vocab word + with pytest.raises(InvalidVocabulary): + cript_api._is_vocab_valid(vocab_category=cript.VocabCategories.FILE_TYPE, vocab_word="some_invalid_word") + + +def test_download_file_from_url(cript_api: cript.API, tmp_path) -> None: + """ + downloads the file from a URL and writes it to disk + then opens, reads, and compares that the file was gotten and written correctly + """ + url_to_download_file: str = "https://criptscripts.org/cript_graph_json/JSON/cao_protein.json" + + # `download_file()` will get the file extension from the end of the URL and add it onto the name + # the path it will save it to will be `tmp_path/downloaded_file_name.json` + path_to_save_file: Path = tmp_path / "downloaded_file_name" + + cript_api.download_file(url_to_download_file, str(path_to_save_file)) + + # add file extension to file path and convert it to file path object + path_to_read_file = Path(str(path_to_save_file) + ".json").resolve() + + # open the file that was just saved and read the contents + saved_file_contents = json.loads(path_to_read_file.read_text()) + + # make a request manually to get the contents and check that the contents are the same + response: Dict = requests.get(url=url_to_download_file).json() + + # assert that the file I've save and the one on the web are the same + assert response == saved_file_contents + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real storage_token from a real frontend") +def test_upload_and_download_local_file(cript_api, tmp_path_factory) -> None: + """ + tests file upload to cloud storage + test by uploading a local file to AWS S3 using cognito mode + and then downloading the same file and checking their contents are the same + proving that the file was uploaded and downloaded correctly + + 1. create a temporary file + 1. write a unique string to the temporary file via UUID4 and date + so when downloading it later the downloaded file cannot possibly be a mistake and we know + for sure that it is the correct file uploaded and downloaded + 1. upload to AWS S3 `tests/` directory + 1. we can be sure that the file has been correctly uploaded to AWS S3 if we can download the same file + and assert that the file contents are the same as original + """ + file_text: str = ( + f"This is an automated test from the Python SDK within `tests/api/test_api.py` " f"within the `test_upload_file_to_aws_s3()` test function " f"on UTC time of '{datetime.datetime.utcnow()}' " f"with the unique UUID of '{str(uuid.uuid4())}'" + ) + + # Create a temporary file with unique contents + upload_test_file = tmp_path_factory.mktemp("test_api_file_upload") / "temp_upload_file.txt" + upload_test_file.write_text(file_text) + + # upload file to AWS S3 + my_file_cloud_storage_object_name = cript_api.upload_file(file_path=upload_test_file) + + # temporary file path and new file to write the cloud storage file contents to + download_test_file = tmp_path_factory.mktemp("test_api_file_download") / "temp_download_file.txt" + + # download file from cloud storage + cript_api.download_file(file_source=my_file_cloud_storage_object_name, destination_path=str(download_test_file)) + + # read file contents + downloaded_file_contents = download_test_file.read_text() + + # assert download file contents are the same as uploaded file contents + assert downloaded_file_contents == file_text + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") +def test_api_search_node_type(cript_api: cript.API) -> None: + """ + tests the api.search() method with just a node type material search + + just testing that something comes back from the server + + Notes + ----- + * also tests that it can go to the next page and previous page + * later this test should be expanded to test things that it should expect an error for as well. + * test checks if there are at least 5 things in the paginator + * each page should have a max of 10 results and there should be close to 5k materials in db, + * more than enough to at least have 5 in the paginator + """ + materials_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.NODE_TYPE, value_to_search=None) + + # test search results + assert isinstance(materials_paginator, Paginator) + assert len(materials_paginator.current_page_results) > 5 + first_page_first_result = materials_paginator.current_page_results[0]["name"] + + # just checking that the word has a few characters in it + assert len(first_page_first_result) > 3 + + # tests that it can correctly go to the next page + materials_paginator.next_page() + assert len(materials_paginator.current_page_results) > 5 + second_page_first_result = materials_paginator.current_page_results[0]["name"] + + assert len(second_page_first_result) > 3 + + # tests that it can correctly go to the previous page + materials_paginator.previous_page() + assert len(materials_paginator.current_page_results) > 5 + + assert len(first_page_first_result) > 3 + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") +def test_api_search_contains_name(cript_api: cript.API) -> None: + """ + tests that it can correctly search with contains name mode + searches for a material that contains the name "poly" + """ + contains_name_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.CONTAINS_NAME, value_to_search="poly") + + assert isinstance(contains_name_paginator, Paginator) + assert len(contains_name_paginator.current_page_results) > 5 + + contains_name_first_result = contains_name_paginator.current_page_results[0]["name"] + + # just checking that the result has a few characters in it + assert len(contains_name_first_result) > 3 + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") +def test_api_search_exact_name(cript_api: cript.API) -> None: + """ + tests search method with exact name search + searches for material "Sodium polystyrene sulfonate" + """ + exact_name_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.EXACT_NAME, value_to_search="Sodium polystyrene sulfonate") + + assert isinstance(exact_name_paginator, Paginator) + assert len(exact_name_paginator.current_page_results) == 1 + assert exact_name_paginator.current_page_results[0]["name"] == "Sodium polystyrene sulfonate" + + +@pytest.mark.skipif(not HAS_INTEGRATION_TESTS_ENABLED, reason="requires a real cript_api_token") +def test_api_search_uuid(cript_api: cript.API) -> None: + """ + tests search with UUID + searches for Sodium polystyrene sulfonate material that has a UUID of "fcc6ed9d-22a8-4c21-bcc6-25a88a06c5ad" + """ + # try develop result + try: + uuid_to_search = "fcc6ed9d-22a8-4c21-bcc6-25a88a06c5ad" + + uuid_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.UUID, value_to_search=uuid_to_search) + + assert isinstance(uuid_paginator, Paginator) + assert len(uuid_paginator.current_page_results) == 1 + assert uuid_paginator.current_page_results[0]["name"] == "Sodium polystyrene sulfonate" + assert uuid_paginator.current_page_results[0]["uuid"] == uuid_to_search + + # if fail try staging result + except AssertionError: + uuid_to_search = "e1b41d34-3bf2-4cd8-9a19-6412df7e7efc" + + uuid_paginator = cript_api.search(node_type=cript.Material, search_mode=cript.SearchModes.UUID, value_to_search=uuid_to_search) + + assert isinstance(uuid_paginator, Paginator) + assert len(uuid_paginator.current_page_results) == 1 + assert uuid_paginator.current_page_results[0]["name"] == "Sodium polystyrene sulfonate" + assert uuid_paginator.current_page_results[0]["uuid"] == uuid_to_search + + +def test_get_my_user_node_from_api(cript_api: cript.API) -> None: + """ + tests that the Python SDK can successfully get the user node associated with the API Token + """ + pass + + +def test_get_my_group_node_from_api(cript_api: cript.API) -> None: + """ + tests that group node that is associated with their API Token can be gotten correctly + """ + pass + + +def test_get_my_projects_from_api(cript_api: cript.API) -> None: + """ + get a page of project nodes that is associated with the API token + """ + pass diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..4e6fe124c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,56 @@ +# trunk-ignore-all(ruff/F403) +""" +This conftest file contains simple nodes (nodes with minimal required arguments) +and complex node (nodes that have all possible arguments), to use for testing. + +Since nodes often depend on other nodes copying and pasting nodes is not ideal, +and keeping all nodes in one file makes it easier/cleaner to create tests. + +The fixtures are all functional fixtures that stay consistent between all tests. +""" +import os + +import pytest +from fixtures.primary_nodes import * +from fixtures.subobjects import * +from fixtures.supporting_nodes import * + +import cript + + +def _get_cript_tests_env() -> bool: + """ + Gets `CRIPT_TESTS` value from env variable and converts it to boolean. + If `CRIPT_TESTS` env var does not exist then it will default it to False. + """ + try: + has_integration_tests_enabled = os.getenv("CRIPT_TESTS").title().strip() == "True" + except AttributeError: + has_integration_tests_enabled = True + + return has_integration_tests_enabled + + +# flip integration tests ON or OFF with this boolean +# automatically gets value env vars to run integration tests +HAS_INTEGRATION_TESTS_ENABLED: bool = _get_cript_tests_env() + + +@pytest.fixture(scope="session", autouse=True) +def cript_api(): + """ + Create an API instance for the rest of the tests to use. + + Returns + ------- + API: cript.API + The created CRIPT API instance. + """ + storage_token = os.getenv("CRIPT_STORAGE_TOKEN") + + assert cript.api.api._global_cached_api is None + with cript.API(host=None, api_token=None, storage_token=storage_token) as api: + # using the tests folder name within our cloud storage + api._BUCKET_DIRECTORY_NAME = "tests" + yield api + assert cript.api.api._global_cached_api is None diff --git a/tests/fixtures/primary_nodes.py b/tests/fixtures/primary_nodes.py new file mode 100644 index 000000000..04b219c02 --- /dev/null +++ b/tests/fixtures/primary_nodes.py @@ -0,0 +1,321 @@ +import copy +import json +import uuid + +import pytest +from util import strip_uid_from_dict + +import cript + + +@pytest.fixture(scope="function") +def simple_project_node(simple_collection_node) -> cript.Project: + """ + create a minimal Project node with only required arguments for other tests to use + + Returns + ------- + cript.Project + """ + + return cript.Project(name="my Project name", collection=[simple_collection_node]) + + +@pytest.fixture(scope="function") +def complex_project_dict(complex_collection_node, simple_material_node, complex_user_node) -> dict: + project_dict = {"node": ["Project"]} + project_dict["locked"] = True + project_dict["model_version"] = "1.0.0" + project_dict["updated_by"] = json.loads(copy.deepcopy(complex_user_node).get_json(condense_to_uuid={}).json) + project_dict["created_by"] = json.loads(complex_user_node.get_json(condense_to_uuid={}).json) + project_dict["public"] = True + project_dict["name"] = "my project name" + project_dict["notes"] = "my project notes" + project_dict["member"] = [json.loads(complex_user_node.get_json(condense_to_uuid={}).json)] + project_dict["admin"] = [json.loads(complex_user_node.get_json(condense_to_uuid={}).json)] + project_dict["collection"] = [json.loads(complex_collection_node.get_json(condense_to_uuid={}).json)] + project_dict["material"] = [json.loads(copy.deepcopy(simple_material_node).get_json(condense_to_uuid={}).json)] + return project_dict + + +@pytest.fixture(scope="function") +def complex_project_node(complex_project_dict) -> cript.Project: + """ + a complex Project node that includes all possible optional arguments that are themselves complex as well + """ + complex_project = cript.load_nodes_from_json(json.dumps(complex_project_dict)) + return complex_project + + +@pytest.fixture(scope="function") +def simple_collection_node(simple_experiment_node) -> cript.Collection: + """ + create a simple collection node for other tests to be able to easily and cleanly reuse + + Notes + ----- + * [Collection](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=8) + has no required attributes. + * The Python SDK only requires Collections to have `name` + * Since it doesn't make sense to have an empty Collection I added an Experiment to the Collection as well + """ + my_collection_name = "my collection name" + + my_collection = cript.Collection(name=my_collection_name, experiment=[simple_experiment_node]) + + return my_collection + + +@pytest.fixture(scope="function") +def complex_collection_node(simple_experiment_node, simple_inventory_node, complex_citation_node) -> cript.Collection: + """ + Collection node with all optional arguments + """ + my_collection_name = "my complex collection name" + my_cript_doi = "10.1038/1781168a0" + + my_collection = cript.Collection( + name=my_collection_name, + experiment=[simple_experiment_node], + inventory=[simple_inventory_node], + doi=my_cript_doi, + citation=[complex_citation_node], + ) + + return my_collection + + +@pytest.fixture(scope="function") +def simple_experiment_node() -> cript.Experiment: + """ + minimal experiment node to use for other tests + + Returns + ------- + Experiment + """ + + return cript.Experiment(name="my experiment name") + + +@pytest.fixture(scope="function") +def simple_computation_process_node(complex_ingredient_node, simple_data_node) -> cript.ComputationProcess: + """ + simple Computational Process node with only required arguments to use in other tests + """ + my_computational_process_type = "cross_linking" + + my_computational_process = cript.ComputationProcess( + name="my computational process name", + type=my_computational_process_type, + input_data=[copy.deepcopy(simple_data_node)], + ingredient=[complex_ingredient_node], + ) + + return my_computational_process + + +@pytest.fixture(scope="function") +def simple_data_node(complex_file_node) -> cript.Data: + """ + minimal data node + """ + my_data = cript.Data(name="my data name", type="afm_amp", file=[complex_file_node]) + + return my_data + + +@pytest.fixture(scope="function") +def complex_data_node( + complex_file_node, + simple_process_node, + simple_computation_node, + simple_computation_process_node, + simple_material_node, + complex_citation_node, +) -> None: + """ + create a complex data node with all possible arguments for all tests to use when needed + """ + my_complex_data = cript.Data( + name="my complex data node name", + type="afm_amp", + file=[copy.deepcopy(complex_file_node)], + sample_preparation=copy.deepcopy(simple_process_node), + computation=[simple_computation_node], + computation_process=[simple_computation_process_node], + material=[simple_material_node], + process=[copy.deepcopy(simple_process_node)], + citation=[copy.deepcopy(complex_citation_node)], + ) + + return my_complex_data + + +@pytest.fixture(scope="function") +def simple_process_node() -> cript.Process: + """ + simple process node to use in other tests to keep tests clean + """ + my_process = cript.Process(name="my process name", type="affinity_pure") + + return my_process + + +@pytest.fixture(scope="function") +def complex_process_node(complex_ingredient_node, simple_equipment_node, complex_citation_node, simple_property_node, simple_condition_node, simple_material_node, simple_process_node) -> None: + """ + create a process node with all possible arguments + + Notes + ----- + * indirectly tests the vocabulary as well, as it gives it valid vocabulary + """ + + my_process_name = "my complex process node name" + my_process_type = "affinity_pure" + my_process_description = "my simple material description" + + process_waste = [ + cript.Material(name="my process waste material 1", identifiers=[{"bigsmiles": "process waste bigsmiles"}]), + ] + + my_process_keywords = [ + "anionic", + "annealing_sol", + ] + + my_complex_process = cript.Process( + name=my_process_name, + type=my_process_type, + ingredient=[complex_ingredient_node], + description=my_process_description, + equipment=[simple_equipment_node], + product=[simple_material_node], + waste=process_waste, + prerequisite_process=[simple_process_node], + condition=[simple_condition_node], + property=[simple_property_node], + keyword=my_process_keywords, + citation=[complex_citation_node], + ) + + return my_complex_process + + +@pytest.fixture(scope="function") +def simple_computation_node() -> cript.Computation: + """ + simple computation node to use between tests + """ + my_computation = cript.Computation(name="my computation name", type="analysis") + + return my_computation + + +@pytest.fixture(scope="function") +def simple_material_node() -> cript.Material: + """ + simple material node to use between tests + """ + identifiers = [{"bigsmiles": "123456"}] + # Use a unique name + my_material = cript.Material(name="my test material " + str(uuid.uuid4()), identifiers=identifiers) + + return my_material + + +@pytest.fixture(scope="function") +def simple_material_dict() -> dict: + """ + the dictionary that `simple_material_node` produces + putting it in one location to make updating it easy + """ + simple_material_dict: dict = {"node": ["Material"], "name": "my material", "bigsmiles": "123456"} + + return simple_material_dict + + +@pytest.fixture(scope="function") +def complex_material_dict(simple_property_node, simple_process_node, complex_computational_forcefield_node, simple_material_node) -> cript.Material: + """ + complex Material node with all possible attributes filled + """ + my_material_keyword = ["acetylene"] + + material_dict = {"node": ["Material"]} + material_dict["name"] = "my complex material" + material_dict["property"] = [json.loads(simple_property_node.get_json(condense_to_uuid={}).json)] + material_dict["process"] = json.loads(simple_process_node.get_json(condense_to_uuid={}).json) + material_dict["parent_material"] = json.loads(simple_material_node.get_json(condense_to_uuid={}).json) + material_dict["computational_forcefield"] = json.loads(complex_computational_forcefield_node.get_json(condense_to_uuid={}).json) + material_dict["bigsmiles"] = "my complex_material_node" + material_dict["keyword"] = my_material_keyword + + return strip_uid_from_dict(material_dict) + + +@pytest.fixture(scope="function") +def complex_material_node(simple_property_node, simple_process_node, complex_computational_forcefield_node, simple_material_node) -> cript.Material: + """ + complex Material node with all possible attributes filled + """ + my_identifier = [{"bigsmiles": "my complex_material_node"}] + my_material_keyword = ["acetylene"] + + my_complex_material = cript.Material( + name="my complex material", + identifiers=my_identifier, + property=[simple_property_node], + process=copy.deepcopy(simple_process_node), + parent_material=simple_material_node, + computational_forcefield=complex_computational_forcefield_node, + keyword=my_material_keyword, + ) + + return my_complex_material + + +@pytest.fixture(scope="function") +def simple_inventory_node(simple_material_node) -> None: + """ + minimal inventory node to use for other tests + """ + # set up inventory node + + material_2 = cript.Material(name="material 2 " + str(uuid.uuid4()), identifiers=[{"bigsmiles": "my big smiles"}]) + + my_inventory = cript.Inventory(name="my inventory name", material=[simple_material_node, material_2]) + + # use my_inventory in another test + return my_inventory + + +@pytest.fixture(scope="function") +def simple_computational_process_node(simple_data_node, complex_ingredient_node) -> None: + """ + simple/minimal computational_process node with only required arguments + """ + my_computational_process = cript.ComputationProcess( + name="my computational process node name", + type="cross_linking", + input_data=[simple_data_node], + ingredient=[complex_ingredient_node], + ) + + return my_computational_process + + +@pytest.fixture(scope="function") +def simplest_computational_process_node(simple_data_node, simple_ingredient_node) -> cript.ComputationProcess: + """ + minimal computational_process node + """ + my_simplest_computational_process = cript.ComputationProcess( + name="my computational process node name", + type="cross_linking", + input_data=[simple_data_node], + ingredient=[simple_ingredient_node], + ) + + return my_simplest_computational_process diff --git a/tests/fixtures/subobjects.py b/tests/fixtures/subobjects.py new file mode 100644 index 000000000..41eff8508 --- /dev/null +++ b/tests/fixtures/subobjects.py @@ -0,0 +1,371 @@ +import copy +import json +import uuid + +import pytest +from util import strip_uid_from_dict + +import cript + + +@pytest.fixture(scope="function") +def complex_parameter_node() -> cript.Parameter: + """ + maximal parameter sub-object that has all possible node attributes + """ + parameter = cript.Parameter(key="update_frequency", value=1000.0, unit="1/second") + + return parameter + + +@pytest.fixture(scope="function") +def complex_parameter_dict() -> dict: + ret_dict = {"node": ["Parameter"], "key": "update_frequency", "value": 1000.0, "unit": "1/second"} + return ret_dict + + +# TODO this fixture should be renamed because it is simple_algorithm_subobject not complex +@pytest.fixture(scope="function") +def simple_algorithm_node() -> cript.Algorithm: + """ + minimal algorithm sub-object + """ + algorithm = cript.Algorithm(key="mc_barostat", type="barostat") + + return algorithm + + +@pytest.fixture(scope="function") +def simple_algorithm_dict() -> dict: + ret_dict = {"node": ["Algorithm"], "key": "mc_barostat", "type": "barostat"} + return ret_dict + + +@pytest.fixture(scope="function") +def complex_reference_node() -> cript.Reference: + title = "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: " + title += "SOft coarse grained Monte-Carlo Acceleration (SOMA)" + + reference = cript.Reference( + type="journal_article", + title=title, + author=["Ludwig Schneider", "Marcus Müller"], + journal="Computer Physics Communications", + publisher="Elsevier", + year=2019, + pages=[463, 476], + doi="10.1016/j.cpc.2018.08.011", + issn="0010-4655", + website="https://www.sciencedirect.com/science/article/pii/S0010465518303072", + ) + return reference + + +@pytest.fixture(scope="function") +def complex_reference_dict() -> dict: + ret_dict = { + "node": ["Reference"], + "type": "journal_article", + "title": "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: SOft coarse grained Monte-Carlo Acceleration (SOMA)", + "author": ["Ludwig Schneider", "Marcus Müller"], + "journal": "Computer Physics Communications", + "publisher": "Elsevier", + "year": 2019, + "pages": [463, 476], + "doi": "10.1016/j.cpc.2018.08.011", + "issn": "0010-4655", + "website": "https://www.sciencedirect.com/science/article/pii/S0010465518303072", + } + return ret_dict + + +@pytest.fixture(scope="function") +def complex_citation_node(complex_reference_node) -> cript.Citation: + """ + maximal citation sub-object with all possible node attributes + """ + citation = cript.Citation(type="reference", reference=complex_reference_node) + return citation + + +@pytest.fixture(scope="function") +def complex_citation_dict(complex_reference_dict) -> dict: + ret_dict = {"node": ["Citation"], "reference": complex_reference_dict, "type": "reference"} + return ret_dict + + +@pytest.fixture(scope="function") +def complex_quantity_node() -> cript.Quantity: + quantity = cript.Quantity(key="mass", value=11.2, unit="kg", uncertainty=0.2, uncertainty_type="stdev") + return quantity + + +@pytest.fixture(scope="function") +def complex_quantity_dict() -> dict: + return {"node": ["Quantity"], "key": "mass", "value": 11.2, "unit": "kg", "uncertainty": 0.2, "uncertainty_type": "stdev"} + + +@pytest.fixture(scope="function") +def complex_software_node() -> cript.Software: + software = cript.Software("SOMA", "0.7.0", "https://gitlab.com/InnocentBug/SOMA") + return software + + +@pytest.fixture(scope="function") +def complex_software_dict() -> dict: + ret_dict = {"node": ["Software"], "name": "SOMA", "version": "0.7.0", "source": "https://gitlab.com/InnocentBug/SOMA"} + return ret_dict + + +@pytest.fixture(scope="function") +def complex_property_node(complex_material_node, complex_condition_node, complex_citation_node, complex_data_node, simple_process_node, simple_computation_node): + """ + a maximal property sub-object with all possible fields filled + """ + my_complex_property = cript.Property( + key="modulus_shear", + type="value", + value=5.0, + unit="GPa", + uncertainty=0.1, + uncertainty_type="stdev", + structure="structure", + method="comp", + sample_preparation=copy.deepcopy(simple_process_node), + condition=[complex_condition_node], + computation=[copy.deepcopy(simple_computation_node)], + data=[copy.deepcopy(complex_data_node)], + citation=[complex_citation_node], + notes="my complex_property_node notes", + ) + return my_complex_property + + +@pytest.fixture(scope="function") +def complex_property_dict(complex_material_node, complex_condition_dict, complex_citation_dict, complex_data_node, simple_process_node, simple_computation_node) -> dict: + ret_dict = { + "node": ["Property"], + "key": "modulus_shear", + "type": "value", + "value": 5.0, + "unit": "GPa", + "uncertainty": 0.1, + "uncertainty_type": "stdev", + "structure": "structure", + "sample_preparation": json.loads(simple_process_node.get_json(condense_to_uuid={}).json), + "method": "comp", + "condition": [complex_condition_dict], + "data": [json.loads(complex_data_node.get_json(condense_to_uuid={}).json)], + "citation": [complex_citation_dict], + "computation": [json.loads(simple_computation_node.get_json(condense_to_uuid={}).json)], + "notes": "my complex_property_node notes", + } + return strip_uid_from_dict(ret_dict) + + +@pytest.fixture(scope="function") +def simple_property_node() -> cript.Property: + my_property = cript.Property( + key="modulus_shear", + type="value", + value=5.0, + unit="GPa", + ) + return my_property + + +@pytest.fixture(scope="function") +def simple_property_dict() -> dict: + ret_dict = { + "node": ["Property"], + "key": "modulus_shear", + "type": "value", + "value": 5.0, + "unit": "GPa", + } + return strip_uid_from_dict(ret_dict) + + +@pytest.fixture(scope="function") +def complex_condition_node(complex_data_node) -> cript.Condition: + my_complex_condition = cript.Condition( + key="temperature", + type="value", + value=22, + unit="C", + descriptor="room temperature of lab", + uncertainty=5, + uncertainty_type="stdev", + set_id=0, + measurement_id=2, + data=[copy.deepcopy(complex_data_node)], + ) + return my_complex_condition + + +@pytest.fixture(scope="function") +def complex_condition_dict(complex_data_node) -> dict: + ret_dict = { + "node": ["Condition"], + "key": "temperature", + "type": "value", + "descriptor": "room temperature of lab", + "value": 22, + "unit": "C", + "uncertainty": 5, + "uncertainty_type": "stdev", + "set_id": 0, + "measurement_id": 2, + "data": [json.loads(complex_data_node.get_json(condense_to_uuid={}).json)], + } + return ret_dict + + +@pytest.fixture(scope="function") +def complex_ingredient_node(complex_material_node, complex_quantity_node) -> cript.Ingredient: + """ + complex ingredient node with all possible parameters filled + """ + complex_ingredient_node = cript.Ingredient(material=complex_material_node, quantity=[complex_quantity_node], keyword=["catalyst"]) + + return complex_ingredient_node + + +@pytest.fixture(scope="function") +def complex_ingredient_dict(complex_material_node, complex_quantity_dict) -> dict: + ret_dict = {"node": ["Ingredient"], "material": json.loads(complex_material_node.json), "quantity": [complex_quantity_dict], "keyword": ["catalyst"]} + return ret_dict + + +@pytest.fixture(scope="function") +def simple_ingredient_node(simple_material_node, complex_quantity_node) -> cript.Ingredient: + """ + minimal ingredient sub-object used for testing + + Notes + ---- + The main difference is that this uses a simple material with less chance of getting any errors + """ + + simple_material_node.name = f"{simple_material_node.name}_{uuid.uuid4().hex}" + + my_simple_ingredient = cript.Ingredient(material=simple_material_node, quantity=[complex_quantity_node], keyword=["catalyst"]) + + return my_simple_ingredient + + +@pytest.fixture(scope="function") +def complex_equipment_node(complex_condition_node, complex_citation_node) -> cript.Equipment: + """ + maximal equipment node with all possible attributes + """ + my_complex_equipment = cript.Equipment( + key="hot_plate", + description="fancy hot plate for complex_equipment_node", + condition=[complex_condition_node], + citation=[complex_citation_node], + ) + return my_complex_equipment + + +@pytest.fixture(scope="function") +def simple_equipment_node() -> cript.Equipment: + """ + simple and minimal equipment + """ + my_equipment = cript.Equipment(key="burner", description="my simple equipment fixture description") + return my_equipment + + +@pytest.fixture(scope="function") +def complex_equipment_dict(complex_condition_dict, complex_citation_dict) -> dict: + ret_dict = { + "node": ["Equipment"], + "key": "hot_plate", + "description": "fancy hot plate for complex_equipment_node", + "condition": [complex_condition_dict], + "citation": [complex_citation_dict], + } + return ret_dict + + +@pytest.fixture(scope="function") +def complex_computational_forcefield_node(simple_data_node, complex_citation_node) -> cript.ComputationalForcefield: + """ + maximal computational_forcefield sub-object with all possible arguments included in it + """ + my_complex_computational_forcefield_node = cript.ComputationalForcefield( + key="opls_aa", + building_block="atom", + coarse_grained_mapping="atom -> atom", + implicit_solvent="no implicit solvent", + source="local LigParGen installation", + description="this is a test forcefield for complex_computational_forcefield_node", + data=[simple_data_node], + citation=[complex_citation_node], + ) + return my_complex_computational_forcefield_node + + +@pytest.fixture(scope="function") +def complex_computational_forcefield_dict(simple_data_node, complex_citation_dict) -> dict: + ret_dict = { + "node": ["ComputationalForcefield"], + "key": "opls_aa", + "building_block": "atom", + "coarse_grained_mapping": "atom -> atom", + "implicit_solvent": "no implicit solvent", + "source": "local LigParGen installation", + "description": "this is a test forcefield for complex_computational_forcefield_node", + "citation": [complex_citation_dict], + "data": [json.loads(simple_data_node.json)], + } + return ret_dict + + +@pytest.fixture(scope="function") +def complex_software_configuration_node(complex_software_node, simple_algorithm_node, complex_citation_node) -> cript.SoftwareConfiguration: + """ + maximal software_configuration sub-object with all possible attributes + """ + my_complex_software_configuration_node = cript.SoftwareConfiguration(software=complex_software_node, algorithm=[simple_algorithm_node], notes="my_complex_software_configuration_node notes", citation=[complex_citation_node]) + return my_complex_software_configuration_node + + +@pytest.fixture(scope="function") +def complex_software_configuration_dict(complex_software_dict, simple_algorithm_dict, complex_citation_dict) -> dict: + ret_dict = { + "node": ["SoftwareConfiguration"], + "software": complex_software_dict, + "algorithm": [simple_algorithm_dict], + "notes": "my_complex_software_configuration_node notes", + "citation": [complex_citation_dict], + } + return ret_dict + + +@pytest.fixture(scope="function") +def simple_software_configuration(complex_software_node) -> cript.SoftwareConfiguration: + """ + minimal software configuration node with only required arguments + """ + my_software_configuration = cript.SoftwareConfiguration(software=complex_software_node) + + return my_software_configuration + + +@pytest.fixture(scope="function") +def simple_computational_forcefield_node(): + """ + simple minimal computational forcefield node + """ + + return cript.ComputationalForcefield(key="amber", building_block="atom") + + +@pytest.fixture(scope="function") +def simple_condition_node() -> cript.Condition: + """ + simple and minimal condition node + """ + return cript.Condition(key="atm", type="max", value=1) diff --git a/tests/fixtures/supporting_nodes.py b/tests/fixtures/supporting_nodes.py new file mode 100644 index 000000000..cca09e2cb --- /dev/null +++ b/tests/fixtures/supporting_nodes.py @@ -0,0 +1,35 @@ +import datetime +import json + +import pytest + +import cript + + +@pytest.fixture(scope="function") +def complex_file_node() -> cript.File: + """ + complex file node with only required arguments + """ + my_file = cript.File(name="my complex file node fixture", source="https://criptapp.org", type="calibration", extension=".csv", data_dictionary="my file's data dictionary") + + return my_file + + +@pytest.fixture(scope="function") +def complex_user_dict() -> dict: + user_dict = {"node": ["User"]} + user_dict["created_at"] = str(datetime.datetime.now()) + user_dict["model_version"] = "1.0.0" + user_dict["picture"] = "/my/picture/path" + user_dict["updated_at"] = str(datetime.datetime.now()) + user_dict["username"] = "testuser" + user_dict["email"] = "test@emai.com" + user_dict["orcid"] = "0000-0002-0000-0000" + return user_dict + + +@pytest.fixture(scope="function") +def complex_user_node(complex_user_dict) -> cript.User: + user_node = cript.load_nodes_from_json(json.dumps(complex_user_dict)) + return user_node diff --git a/tests/integration_test_helper.py b/tests/integration_test_helper.py new file mode 100644 index 000000000..e3b34d902 --- /dev/null +++ b/tests/integration_test_helper.py @@ -0,0 +1,98 @@ +import json + +import pytest +from conftest import HAS_INTEGRATION_TESTS_ENABLED +from deepdiff import DeepDiff + +import cript + + +def integrate_nodes_helper(cript_api: cript.API, project_node: cript.Project): + """ + integration test between Python SDK and API Client + tests both POST and GET + + comparing JSON because it is easier to compare than an object + + test both the project node: + * node serialization + * POST to API + * GET from API + * deserialization from API JSON to node JSON + * compare the JSON of what was sent and what was deserialized from the API + * the fields they have in common should be the same + + Parameters + ---------- + cript_api: cript.API + pass in the cript_api client that is already available as a fixture + project_node: cript.Project + the desired project to use for integration test + + 1. create a project with the desired node to test + * pass in the project to this function + 1. save the project + 1. get the project + 1. deserialize the project to node + 1. convert the new node to JSON + 1. compare the project node JSON that was sent to API and the node the API gave, have the same JSON + + Notes + ----- + * using deepdiff library to do the nested JSON comparisons + * ignoring the UID field through all the JSON because those the API changes when responding + """ + + if not HAS_INTEGRATION_TESTS_ENABLED: + pytest.skip("Integration tests with API requires real API and Storage token") + return + + print("\n\n=================== Project Node ============================") + print(project_node.get_json(sort_keys=False, condense_to_uuid={}, indent=2).json) + print("==============================================================") + + cript_api.save(project_node) + + # get the project that was just saved + my_paginator = cript_api.search(node_type=cript.Project, search_mode=cript.SearchModes.EXACT_NAME, value_to_search=project_node.name) + + # get the project from paginator + my_project_from_api_dict = my_paginator.current_page_results[0] + + print("\n\n================= API Response Node ============================") + print(json.dumps(my_project_from_api_dict, sort_keys=False, indent=2)) + print("==============================================================") + + # Configure keys and blocks to be ignored by deepdiff using exclude_regex_path + # ignores all UID within the JSON because those will always be different + # and ignores elements that the back ends to graphs. + exclude_regex_paths = [ + r"root(\[.*\])?\['uid'\]", + r"root\['\w+_count'\]", # All the attributes that end with _count + r"root(\[.*\])?\['\w+_count'\]", # All the attributes that end with _count + r"root(\[.*\])?\['locked'\]", + r"root(\[.*\])?\['admin'\]", + r"root(\[.*\])?\['created_at'\]", + r"root(\[.*\])?\['created_by'\]", + r"root(\[.*\])?\['updated_at'\]", + r"root(\[.*\])?\['updated_by'\]", + r"root(\[.*\])?\['public'\]", + r"root(\[.*\])?\['notes'\]", + r"root(\[.*\])?\['model_version'\]", + ] + # Compare the JSONs + diff = DeepDiff(json.loads(project_node.json), my_project_from_api_dict, exclude_regex_paths=exclude_regex_paths) + # with open("la", "a") as file_handle: + # file_handle.write(str(diff) + "\n") + + print("diff", diff) + # assert not list(diff.get("values_changed", [])) + # assert not list(diff.get("dictionary_item_removed", [])) + # assert not list(diff.get("dictionary_item_added", [])) + + # try to convert api JSON project to node + my_project_from_api = cript.load_nodes_from_json(json.dumps(my_project_from_api_dict)) + print("\n\n=================== Project Node Deserialized =========================") + print(my_project_from_api.get_json(sort_keys=False, condense_to_uuid={}, indent=2).json) + print("==============================================================") + print("\n\n\n######################################## TEST Passed ########################################\n\n\n") diff --git a/tests/nodes/primary_nodes/test_collection.py b/tests/nodes/primary_nodes/test_collection.py new file mode 100644 index 000000000..f4e7bb304 --- /dev/null +++ b/tests/nodes/primary_nodes/test_collection.py @@ -0,0 +1,170 @@ +import copy +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_create_simple_collection(simple_experiment_node) -> None: + """ + test to see a simple collection node can be created with only required arguments + + Notes + ----- + * [Collection](https://pubs.acs.org/doi/suppl/10.1021/acscentsci.3c00011/suppl_file/oc3c00011_si_001.pdf#page=8) + has no required attributes. + * The Python SDK only requires Collections to have `name` + * Since it doesn't make sense to have an empty Collection I added an Experiment to the Collection as well + """ + my_collection_name = "my collection name" + + my_collection = cript.Collection(name=my_collection_name, experiment=[simple_experiment_node]) + + # assertions + assert isinstance(my_collection, cript.Collection) + assert my_collection.name == my_collection_name + assert my_collection.experiment == [simple_experiment_node] + + +def test_create_complex_collection(simple_experiment_node, simple_inventory_node, complex_citation_node) -> None: + """ + test to see if Collection can be made with all the possible optional arguments + """ + my_collection_name = "my complex collection name" + my_cript_doi = "10.1038/1781168a0" + + my_collection = cript.Collection( + name=my_collection_name, + experiment=[simple_experiment_node], + inventory=[simple_inventory_node], + doi=my_cript_doi, + citation=[complex_citation_node], + ) + + # assertions + assert isinstance(my_collection, cript.Collection) + assert my_collection.name == my_collection_name + assert my_collection.experiment == [simple_experiment_node] + assert my_collection.inventory == [simple_inventory_node] + assert my_collection.doi == my_cript_doi + assert my_collection.citation == [complex_citation_node] + + +def test_collection_getters_and_setters(simple_experiment_node, simple_inventory_node, complex_citation_node) -> None: + """ + test that Collection getters and setters are working properly + + 1. create a simple Collection node + 2. use the setter to set the Collection node's attributes + 3. use the getter to get the Collection's attributes + 4. assert that what was set and what was gotten are the same + """ + my_collection = cript.Collection(name="my collection name") + + new_collection_name = "my new collection name" + new_cript_doi = "my new cript doi" + + # set Collection attributes + my_collection.name = new_collection_name + my_collection.experiment = [simple_experiment_node] + my_collection.inventory = [simple_inventory_node] + my_collection.doi = new_cript_doi + my_collection.citation = [complex_citation_node] + + # assert getters and setters are the same + assert isinstance(my_collection, cript.Collection) + assert my_collection.name == new_collection_name + assert my_collection.experiment == [simple_experiment_node] + assert my_collection.inventory == [simple_inventory_node] + assert my_collection.doi == new_cript_doi + assert my_collection.citation == [complex_citation_node] + + +def test_serialize_collection_to_json(complex_user_node) -> None: + """ + test that Collection node can be correctly serialized to JSON + + 1. create a simple Collection node with all required arguments + 1. convert Collection to JSON and back to dict + 1. compare expected_collection dict and Collection dict, and they should be the same + + Notes + ----- + * Compare dicts instead of JSON string because dict comparison is more accurate + """ + + expected_collection_dict = { + "node": ["Collection"], + "name": "my collection name", + "experiment": [{"node": ["Experiment"], "name": "my experiment name"}], + "member": [json.loads(copy.deepcopy(complex_user_node).json)], + "admin": [json.loads(complex_user_node.json)], + } + + collection_node = cript.load_nodes_from_json(json.dumps(expected_collection_dict)) + print(collection_node.get_json(indent=2).json) + # assert + ref_dict = json.loads(collection_node.get_json(condense_to_uuid={}).json) + ref_dict = strip_uid_from_dict(ref_dict) + + assert ref_dict == strip_uid_from_dict(expected_collection_dict) + + +def test_uuid(complex_collection_node): + collection_node = complex_collection_node + + # Deep copies should not share uuid (or uids) or urls + collection_node2 = copy.deepcopy(complex_collection_node) + assert collection_node.uuid != collection_node2.uuid + assert collection_node.uid != collection_node2.uid + assert collection_node.url != collection_node2.url + + # Loads from json have the same uuid and url + collection_node3 = cript.load_nodes_from_json(collection_node.get_json(condense_to_uuid={}).json) + assert collection_node3.uuid == collection_node.uuid + assert collection_node3.url == collection_node.url + + +def test_integration_collection(cript_api, simple_project_node, simple_collection_node): + """ + integration test between Python SDK and API Client + + ## Create + 1. Serialize SDK Nodes to JSON + 1. POST to API + 1. GET from API + 1. Deserialize API JSON to SDK Nodes + 1. assert they're both equal + + ## Update + 1. Change JSON + 1. POST/PATCH to API + 1. GET from API + 1. Deserialize API JSON to SDK Nodes + 1. assert they're both equal + + Notes + ----- + - [x] Create + - [x] Read + - [x] Update + """ + + # rename project and collection to not bump into duplicate issues + simple_project_node.name = f"test_integration_collection_project_name_{uuid.uuid4().hex}" + simple_collection_node.name = f"test_integration_collection_name_{uuid.uuid4().hex}" + + simple_project_node.collection = [simple_collection_node] + + # ========= test create ========= + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + simple_project_node.collection[0].doi = "my doi UPDATED" + # TODO enable later + # simple_project_node.collection[0].notes = "my collection notes UPDATED" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/primary_nodes/test_computation.py b/tests/nodes/primary_nodes/test_computation.py new file mode 100644 index 000000000..802168e03 --- /dev/null +++ b/tests/nodes/primary_nodes/test_computation.py @@ -0,0 +1,130 @@ +import copy +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_create_simple_computation_node() -> None: + """ + test that a simple computation node with all possible arguments can be created successfully + """ + my_computation_type = "analysis" + my_computation_name = "this is my computation name" + + my_computation_node = cript.Computation(name=my_computation_name, type=my_computation_type) + + # assertions + assert isinstance(my_computation_node, cript.Computation) + assert my_computation_node.name == my_computation_name + assert my_computation_node.type == my_computation_type + + +def test_create_complex_computation_node(simple_data_node, complex_software_configuration_node, complex_condition_node, simple_computation_node, complex_citation_node) -> None: + """ + test that a complex computation node with all possible arguments can be created + """ + my_computation_type = "analysis" + + citation = copy.deepcopy(complex_citation_node) + condition = copy.deepcopy(complex_condition_node) + my_computation_node = cript.Computation( + name="my complex computation node name", + type="analysis", + input_data=[simple_data_node], + output_data=[simple_data_node], + software_configuration=[complex_software_configuration_node], + condition=[condition], + prerequisite_computation=simple_computation_node, + citation=[citation], + ) + + # assertions + assert isinstance(my_computation_node, cript.Computation) + assert my_computation_node.type == my_computation_type + assert my_computation_node.input_data == [simple_data_node] + assert my_computation_node.output_data == [simple_data_node] + assert my_computation_node.software_configuration == [complex_software_configuration_node] + assert my_computation_node.condition == [condition] + assert my_computation_node.prerequisite_computation == simple_computation_node + assert my_computation_node.citation == [citation] + + +def test_computation_type_invalid_vocabulary() -> None: + """ + tests that setting the Computation type to an invalid vocabulary word gives the expected error + + Returns + ------- + None + """ + pass + + +def test_computation_getters_and_setters(simple_computation_node, simple_data_node, complex_software_configuration_node, complex_condition_node, complex_citation_node) -> None: + """ + tests that all the getters and setters are working fine + + Notes + ----- + indirectly tests setting the data type to correct vocabulary + """ + new_type: str = "data_fit" + new_notes: str = "my computation node note" + + # since the simple_computation_node only has type, the rest of them I can just set and test + simple_computation_node.type = new_type + simple_computation_node.input_data = [simple_data_node] + simple_computation_node.output_data = [simple_data_node] + simple_computation_node.software_configuration = [complex_software_configuration_node] + condition = copy.deepcopy(complex_condition_node) + simple_computation_node.condition = [condition] + citation = copy.deepcopy(complex_citation_node) + simple_computation_node.citation = [citation] + simple_computation_node.notes = new_notes + + # assert getter and setter are same + assert simple_computation_node.type == new_type + assert simple_computation_node.input_data == [simple_data_node] + assert simple_computation_node.output_data == [simple_data_node] + assert simple_computation_node.software_configuration == [complex_software_configuration_node] + assert simple_computation_node.condition == [condition] + assert simple_computation_node.citation == [citation] + assert simple_computation_node.notes == new_notes + + +def test_serialize_computation_to_json(simple_computation_node) -> None: + """ + tests that it can correctly turn the computation node into its equivalent JSON + """ + # TODO test this more vigorously + expected_dict = {"node": ["Computation"], "name": "my computation name", "type": "analysis"} + + # comparing dicts for better test + ref_dict = json.loads(simple_computation_node.json) + ref_dict = strip_uid_from_dict(ref_dict) + assert ref_dict == expected_dict + + +def test_integration_computation(cript_api, simple_project_node, simple_computation_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert they're both equal + """ + # --------- test create --------- + simple_project_node.name = f"test_integration_computation_name_{uuid.uuid4().hex}" + simple_project_node.collection[0].experiment[0].computation = [simple_computation_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # --------- test update --------- + # change simple computation attribute to trigger update + simple_project_node.collection[0].experiment[0].computation[0].type = "data_fit" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/primary_nodes/test_computational_process.py b/tests/nodes/primary_nodes/test_computational_process.py new file mode 100644 index 000000000..edc93d842 --- /dev/null +++ b/tests/nodes/primary_nodes/test_computational_process.py @@ -0,0 +1,133 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_create_simple_computational_process(simple_data_node, complex_ingredient_node) -> None: + """ + create a simple computational_process node with required arguments + """ + + my_computational_process = cript.ComputationProcess( + name="my computational process node name", + type="cross_linking", + input_data=[simple_data_node], + ingredient=[complex_ingredient_node], + ) + + # assertions + assert isinstance(my_computational_process, cript.ComputationProcess) + assert my_computational_process.type == "cross_linking" + assert my_computational_process.input_data == [simple_data_node] + assert my_computational_process.ingredient == [complex_ingredient_node] + + +def test_create_complex_computational_process( + simple_data_node, + complex_ingredient_node, + complex_software_configuration_node, + complex_condition_node, + simple_property_node, + complex_citation_node, +) -> None: + """ + create a complex computational process with all possible arguments + """ + + computational_process_name = "my computational process name" + computational_process_type = "cross_linking" + + ingredient = complex_ingredient_node + data = simple_data_node + my_computational_process = cript.ComputationProcess( + name=computational_process_name, + type=computational_process_type, + input_data=[data], + ingredient=[ingredient], + output_data=[simple_data_node], + software_configuration=[complex_software_configuration_node], + condition=[complex_condition_node], + property=[simple_property_node], + citation=[complex_citation_node], + ) + + # assertions + assert isinstance(my_computational_process, cript.ComputationProcess) + assert my_computational_process.name == computational_process_name + assert my_computational_process.type == computational_process_type + assert my_computational_process.input_data == [data] + assert my_computational_process.ingredient == [ingredient] + assert my_computational_process.output_data == [simple_data_node] + assert my_computational_process.software_configuration == [complex_software_configuration_node] + assert my_computational_process.condition == [complex_condition_node] + assert my_computational_process.property == [simple_property_node] + assert my_computational_process.citation == [complex_citation_node] + + +def test_serialize_computational_process_to_json(simple_computational_process_node) -> None: + """ + tests that a computational process node can be correctly serialized to JSON + """ + expected_dict: dict = { + "node": ["ComputationProcess"], + "name": "my computational process node name", + "type": "cross_linking", + "input_data": [ + { + "node": ["Data"], + "name": "my data name", + "type": "afm_amp", + "file": [{"node": ["File"], "name": "my complex file node fixture", "source": "https://criptapp.org", "type": "calibration", "extension": ".csv", "data_dictionary": "my file's data dictionary"}], + } + ], + "ingredient": [ + { + "node": ["Ingredient"], + "material": {}, + "quantity": [{"node": ["Quantity"], "key": "mass", "value": 11.2, "unit": "kg", "uncertainty": 0.2, "uncertainty_type": "stdev"}], + "keyword": ["catalyst"], + } + ], + } + + ref_dict = json.loads(simple_computational_process_node.json) + ref_dict = strip_uid_from_dict(ref_dict) + assert ref_dict == expected_dict + + +def test_integration_computational_process(cript_api, simple_project_node, simple_collection_node, simple_experiment_node, simplest_computational_process_node, simple_material_node, simple_data_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert they're both equal + """ + # ========= test create ========= + # renaming to avoid duplicate node errors + simple_project_node.name = f"test_integration_computation_process_name_{uuid.uuid4().hex}" + + simple_material_node.name = f"{simple_material_node.name}_{uuid.uuid4().hex}" + + simple_project_node.material = [simple_material_node] + + simple_project_node.collection = [simple_collection_node] + + simple_project_node.collection[0].experiment = [simple_experiment_node] + + # fixing orphanedDataNodeError + simple_project_node.collection[0].experiment[0].data = [simple_data_node] + + simple_project_node.collection[0].experiment[0].computation_process = [simplest_computational_process_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change computational_process to trigger update + simple_project_node.collection[0].experiment[0].computation_process[0].type = "DPD" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/primary_nodes/test_data.py b/tests/nodes/primary_nodes/test_data.py new file mode 100644 index 000000000..90afda6e2 --- /dev/null +++ b/tests/nodes/primary_nodes/test_data.py @@ -0,0 +1,167 @@ +import copy +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_create_simple_data_node(complex_file_node) -> None: + """ + create a simple data node with only required arguments + """ + my_data_type = "afm_amp" + + my_data = cript.Data(name="my data name", type=my_data_type, file=[complex_file_node]) + + # assertions + assert isinstance(my_data, cript.Data) + assert my_data.type == my_data_type + assert my_data.file == [complex_file_node] + + +def test_create_complex_data_node( + complex_file_node, + simple_process_node, + simple_computation_node, + simple_computational_process_node, + simple_material_node, + complex_citation_node, +) -> None: + """ + create a complex data node with all possible arguments + """ + + file_node = copy.deepcopy(complex_file_node) + my_complex_data = cript.Data( + name="my complex data node name", + type="afm_amp", + file=[file_node], + sample_preparation=simple_process_node, + computation=[simple_computation_node], + computation_process=[simple_computational_process_node], + material=[simple_material_node], + process=[simple_process_node], + # citation=[complex_citation_node], + ) + + # assertions + assert isinstance(my_complex_data, cript.Data) + assert my_complex_data.type == "afm_amp" + assert my_complex_data.file == [file_node] + assert my_complex_data.sample_preparation == simple_process_node + assert my_complex_data.computation == [simple_computation_node] + assert my_complex_data.computation_process == [simple_computational_process_node] + assert my_complex_data.material == [simple_material_node] + assert my_complex_data.process == [simple_process_node] + # assert my_complex_data.citation == [complex_citation_node] + + +def test_data_getters_and_setters( + simple_data_node, + complex_file_node, + simple_process_node, + simple_computation_node, + simple_computational_process_node, + simple_material_node, + complex_citation_node, +) -> None: + """ + tests that all the getters and setters are working fine + + Notes + ----- + indirectly tests setting the data type to correct vocabulary + + Returns + ------- + None + """ + my_data_type = "afm_height" + + my_new_files = [ + complex_file_node, + cript.File( + name="my data file node", + source="https://bing.com", + type="computation_config", + extension=".pdf", + data_dictionary="my second file data dictionary", + ), + ] + + # use setters + comp_process = simple_computational_process_node + simple_data_node.type = my_data_type + simple_data_node.file = my_new_files + simple_data_node.sample_preparation = simple_process_node + simple_data_node.computation = [simple_computation_node] + simple_data_node.computation_process = [comp_process] + simple_data_node.material = [simple_material_node] + simple_data_node.process = [simple_process_node] + simple_data_node.citation = [complex_citation_node] + + # assertions check getters and setters + assert simple_data_node.type == my_data_type + assert simple_data_node.file == my_new_files + assert simple_data_node.sample_preparation == simple_process_node + assert simple_data_node.computation == [simple_computation_node] + assert simple_data_node.computation_process == [comp_process] + assert simple_data_node.material == [simple_material_node] + assert simple_data_node.process == [simple_process_node] + assert simple_data_node.citation == [complex_citation_node] + + +def test_serialize_data_to_json(simple_data_node) -> None: + """ + tests that it can correctly turn the data node into its equivalent JSON + """ + + # TODO should Base attributes should be in here too like notes, public, model version, etc? + expected_data_dict = { + "node": ["Data"], + "type": "afm_amp", + "name": "my data name", + "file": [ + { + "node": ["File"], + "name": "my complex file node fixture", + "data_dictionary": "my file's data dictionary", + "extension": ".csv", + "source": "https://criptapp.org", + "type": "calibration", + } + ], + } + + ref_dict = json.loads(simple_data_node.json) + ref_dict = strip_uid_from_dict(ref_dict) + assert ref_dict == expected_data_dict + + +def test_integration_data(cript_api, simple_project_node, simple_data_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert they're both equal + + Notes + ----- + indirectly tests complex file as well because every data node must have a file node + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_project_name_{uuid.uuid4().hex}" + + simple_project_node.collection[0].experiment[0].data = [simple_data_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # update a simple attribute of data to trigger update + simple_project_node.collection[0].experiment[0].data[0].type = "afm_height" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/primary_nodes/test_experiment.py b/tests/nodes/primary_nodes/test_experiment.py new file mode 100644 index 000000000..4f6435f6f --- /dev/null +++ b/tests/nodes/primary_nodes/test_experiment.py @@ -0,0 +1,214 @@ +import copy +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_create_simple_experiment() -> None: + """ + test just to see if a minimal experiment can be made without any issues + """ + experiment_name = "my experiment name" + + my_experiment = cript.Experiment(name=experiment_name) + + # assertions + assert isinstance(my_experiment, cript.Experiment) + + +def test_create_complex_experiment(simple_process_node, simple_computation_node, simple_computational_process_node, simple_data_node, complex_citation_node) -> None: + """ + test to see if Collection can be made with all the possible options filled + """ + experiment_name = "my experiment name" + experiment_funders = ["National Science Foundation", "IRIS", "NIST"] + + citation = copy.deepcopy(complex_citation_node) + my_experiment = cript.Experiment( + name=experiment_name, + process=[simple_process_node], + computation=[simple_computation_node], + computation_process=[simple_computational_process_node], + data=[simple_data_node], + funding=experiment_funders, + citation=[citation], + ) + + # assertions + assert isinstance(my_experiment, cript.Experiment) + assert my_experiment.name == experiment_name + assert my_experiment.process == [simple_process_node] + assert my_experiment.computation == [simple_computation_node] + assert my_experiment.computation_process == [simple_computational_process_node] + assert my_experiment.data == [simple_data_node] + assert my_experiment.funding == experiment_funders + assert my_experiment.citation[-1] == citation + + +def test_all_getters_and_setters_for_experiment( + simple_experiment_node, + simple_process_node, + simple_computation_node, + simple_computational_process_node, + simple_data_node, + complex_citation_node, +) -> None: + """ + tests all the getters and setters for the experiment + + 1. create a node with only the required arguments + 2. set all the properties for the experiment + 3. get all the properties for the experiment + 4. assert that what you set in the setter and the getter are equal to each other + """ + experiment_name = "my new experiment name" + experiment_funders = ["MIT", "European Research Council (ERC)", "Japan Society for the Promotion of Science (JSPS)"] + + # set experiment properties + simple_experiment_node.name = experiment_name + simple_experiment_node.process = [simple_process_node] + simple_experiment_node.computation = [simple_computation_node] + simple_experiment_node.computation_process = [simple_computational_process_node] + simple_experiment_node.data = [simple_data_node] + simple_experiment_node.funding = experiment_funders + citation = copy.deepcopy(complex_citation_node) + simple_experiment_node.citation = [citation] + + # assert getters and setters are equal + assert isinstance(simple_experiment_node, cript.Experiment) + assert simple_experiment_node.name == experiment_name + assert simple_experiment_node.process == [simple_process_node] + assert simple_experiment_node.computation == [simple_computation_node] + assert simple_experiment_node.computation_process == [simple_computational_process_node] + assert simple_experiment_node.data == [simple_data_node] + assert simple_experiment_node.funding == experiment_funders + assert simple_experiment_node.citation[-1] == citation + + +def test_experiment_json(simple_process_node, simple_computation_node, simple_computational_process_node, simple_data_node, complex_citation_node, complex_citation_dict) -> None: + """ + tests that the experiment JSON is functioning correctly + + 1. create an experiment with all possible attributes + 2. convert the experiment into a JSON + 3. assert that the JSON is that it produces is equal to what you expected + + Notes + ----- + indirectly tests that the notes attribute also works within the experiment node. + All nodes inherit from the base node, so if the base node attribute is working in this test + there is a good chance that it will work correctly for all other nodes that inherit from it as well + """ + experiment_name = "my experiment name" + experiment_funders = ["National Science Foundation", "IRIS", "NIST"] + + citation = copy.deepcopy(complex_citation_node) + my_experiment = cript.Experiment( + name=experiment_name, + process=[simple_process_node], + computation=[simple_computation_node], + computation_process=[simple_computational_process_node], + data=[simple_data_node], + funding=experiment_funders, + citation=[citation], + ) + + # adding notes to test base node attributes + my_experiment.notes = "these are all of my notes for this experiment" + + # TODO this is unmaintainable and we should figure out a strategy for a better way + expected_experiment_dict = { + "node": ["Experiment"], + "name": "my experiment name", + "notes": "these are all of my notes for this experiment", + "process": [{"node": ["Process"], "name": "my process name", "type": "affinity_pure"}], + "computation": [{"node": ["Computation"], "name": "my computation name", "type": "analysis"}], + "computation_process": [ + { + "node": ["ComputationProcess"], + "name": "my computational process node name", + "type": "cross_linking", + "input_data": [ + { + "node": ["Data"], + "name": "my data name", + "type": "afm_amp", + "file": [{"node": ["File"], "name": "my complex file node fixture", "source": "https://criptapp.org", "type": "calibration", "extension": ".csv", "data_dictionary": "my file's data dictionary"}], + } + ], + "ingredient": [ + { + "node": ["Ingredient"], + "material": {}, + "quantity": [{"node": ["Quantity"], "key": "mass", "value": 11.2, "unit": "kg", "uncertainty": 0.2, "uncertainty_type": "stdev"}], + "keyword": ["catalyst"], + } + ], + } + ], + "data": [{}], + "funding": ["National Science Foundation", "IRIS", "NIST"], + "citation": [ + { + "node": ["Citation"], + "type": "reference", + "reference": { + "node": ["Reference"], + "type": "journal_article", + "title": "Multi-architecture Monte-Carlo (MC) simulation of soft coarse-grained polymeric materials: SOft coarse grained Monte-Carlo Acceleration (SOMA)", + "author": ["Ludwig Schneider", "Marcus M\u00fcller"], + "journal": "Computer Physics Communications", + "publisher": "Elsevier", + "year": 2019, + "pages": [463, 476], + "doi": "10.1016/j.cpc.2018.08.011", + "issn": "0010-4655", + "website": "https://www.sciencedirect.com/science/article/pii/S0010465518303072", + }, + } + ], + } + + ref_dict = json.loads(my_experiment.json) + ref_dict = strip_uid_from_dict(ref_dict) + + assert len(ref_dict) == len(expected_experiment_dict) + assert ref_dict == expected_experiment_dict + + +# -------- Integration Tests -------- +def test_integration_experiment(cript_api, simple_project_node, simple_collection_node, simple_experiment_node): + """ + integration test between Python SDK and API Client + + tests both POST and GET + + 1. create a project + 1. create a collection + 1. add collection to project + 1. save the project + 1. get the project + 1. deserialize the project to node + 1. convert the new node to JSON + 1. compare the project node JSON that was sent to API and the node the API gave, have the same JSON + + Notes + ----- + comparing JSON because it is easier to compare than an object + """ + # ========= test create ========= + # rename project and collection to not bump into duplicate issues + simple_project_node.name = f"test_integration_experiment_project_name_{uuid.uuid4().hex}" + simple_project_node.collection = [simple_collection_node] + simple_project_node.collection[0].experiment = [simple_experiment_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # update simple attribute to trigger update + simple_project_node.collection[0].experiment[0].funding = ["update1", "update2", "update3"] + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/primary_nodes/test_inventory.py b/tests/nodes/primary_nodes/test_inventory.py new file mode 100644 index 000000000..2eaa36df4 --- /dev/null +++ b/tests/nodes/primary_nodes/test_inventory.py @@ -0,0 +1,72 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_get_and_set_inventory(simple_inventory_node) -> None: + """ + tests that a material list for the inventory node can be gotten and set correctly + + 1. create new material node + 2. set the material's list + 3. get the material's list + 1. originally in simple_inventory it has 2 materials, but after the setter it should have only 1 + 4. assert that the materials list set and the one gotten are the same + """ + # create new materials + material_1 = cript.Material(name="new material 1", identifiers=[{"names": ["new material 1 alternative name"]}]) + + # set inventory materials + simple_inventory_node.material = [material_1] + + # get and check inventory materials + assert isinstance(simple_inventory_node, cript.Inventory) + assert simple_inventory_node.material[-1] == material_1 + + +def test_inventory_serialization(simple_inventory_node, simple_material_dict) -> None: + """ + test that the inventory is correctly serializing into JSON + + 1. converts inventory json string to dict + 2. strips the UID from all the nodes within that dict + 3. compares the expected_dict written to what JSON deserializes + """ + expected_dict = {"node": ["Inventory"], "name": "my inventory name", "material": [simple_material_dict, {"node": ["Material"], "name": "material 2", "bigsmiles": "my big smiles"}]} + + # TODO this needs better testing + # force not condensing to edge uuid during json serialization + deserialized_inventory: dict = json.loads(simple_inventory_node.get_json(condense_to_uuid={}).json) + deserialized_inventory = strip_uid_from_dict(deserialized_inventory) + deserialized_inventory["material"][0]["name"] = "my material" + deserialized_inventory["material"][1]["name"] = "material 2" + + assert expected_dict == deserialized_inventory + + +def test_integration_inventory(cript_api, simple_project_node, simple_inventory_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert they're both equal + """ + # ========= test create ========= + # putting UUID in name so it doesn't bump into uniqueness errors + simple_project_node.name = f"project_name_{uuid.uuid4().hex}" + simple_project_node.collection[0].name = f"collection_name_{uuid.uuid4().hex}" + simple_inventory_node.name = f"inventory_name_{uuid.uuid4().hex}" + + simple_project_node.collection[0].inventory = [simple_inventory_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + simple_project_node.collection[0].inventory[0].notes = "inventory notes UPDATED" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/primary_nodes/test_material.py b/tests/nodes/primary_nodes/test_material.py new file mode 100644 index 000000000..b552919db --- /dev/null +++ b/tests/nodes/primary_nodes/test_material.py @@ -0,0 +1,134 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_create_complex_material(simple_material_node, simple_computational_forcefield_node, simple_process_node) -> None: + """ + tests that a simple material can be created with only the required arguments + """ + + material_name = "my material name" + identifiers = [{"bigsmiles": "1234"}, {"bigsmiles": "4567"}] + keyword = ["acetylene"] + + component = [simple_material_node] + forcefield = [simple_computational_forcefield_node] + + my_property = [cript.Property(key="modulus_shear", type="min", value=1.23, unit="gram")] + + my_material = cript.Material(name=material_name, identifiers=identifiers, keyword=keyword, component=component, process=simple_process_node, property=my_property, computational_forcefield=forcefield) + + assert isinstance(my_material, cript.Material) + assert my_material.name == material_name + assert my_material.identifiers == identifiers + assert my_material.keyword == keyword + assert my_material.component == component + assert my_material.process == simple_process_node + assert my_material.property == my_property + assert my_material.computational_forcefield == forcefield + + +def test_invalid_material_keywords() -> None: + """ + tries to create a material with invalid keywords and expects to get an Exception + """ + # with pytest.raises(InvalidVocabulary): + pass + + +def test_all_getters_and_setters(simple_material_node, simple_property_node, simple_process_node, simple_computational_forcefield_node) -> None: + """ + tests the getters and setters for the simple material object + + 1. sets every possible attribute for the simple_material object + 2. gets every possible attribute for the simple_material object + 3. asserts that what was set and what was gotten are the same + """ + # new attributes + new_name = "new material name" + + new_identifiers = [{"bigsmiles": "6789"}] + + new_parent_material = cript.Material( + name="my parent material", + identifiers=[ + {"bigsmiles": "9876"}, + ], + ) + + new_material_keywords = ["acetylene"] + + new_components = [ + cript.Material( + name="my component material 1", + identifiers=[ + {"bigsmiles": "654321"}, + ], + ), + ] + + # set all attributes for Material node + simple_material_node.name = new_name + simple_material_node.identifiers = new_identifiers + simple_material_node.property = [simple_property_node] + simple_material_node.parent_material = new_parent_material + simple_material_node.computational_forcefield = simple_computational_forcefield_node + simple_material_node.keyword = new_material_keywords + simple_material_node.component = new_components + + # get all attributes and assert that they are equal to the setter + assert simple_material_node.name == new_name + assert simple_material_node.identifiers == new_identifiers + assert simple_material_node.property == [simple_property_node] + assert simple_material_node.parent_material == new_parent_material + assert simple_material_node.computational_forcefield == simple_computational_forcefield_node + assert simple_material_node.keyword == new_material_keywords + assert simple_material_node.component == new_components + + +def test_serialize_material_to_json(complex_material_dict, complex_material_node) -> None: + """ + tests that it can correctly turn the material node into its equivalent JSON + """ + # the JSON that the material should serialize to + + # compare dicts because that is more accurate + ref_dict = json.loads(complex_material_node.get_json(condense_to_uuid={}).json) + ref_dict = strip_uid_from_dict(ref_dict) + + assert ref_dict == complex_material_dict + + +def test_integration_material(cript_api, simple_project_node, simple_material_node) -> None: + """ + integration test between Python SDK and API Client + + tests both POST and GET + + 1. create a project + 1. create a material + 1. add a material to project + 1. save the project + 1. get the project + 1. deserialize the project + 1. compare the project node that was sent to API and the one API gave, that they are the same + """ + # ========= test create ========= + # creating unique name to not bump into unique errors + simple_project_node.name = f"test_integration_project_name_{uuid.uuid4().hex}" + simple_material_node.name = f"test_integration_material_name_{uuid.uuid4().hex}" + + simple_project_node.material = [simple_material_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # update material attribute to trigger update + simple_project_node.material[0].identifiers = [{"bigsmiles": "my bigsmiles UPDATED"}] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/primary_nodes/test_process.py b/tests/nodes/primary_nodes/test_process.py new file mode 100644 index 000000000..183b1b9cf --- /dev/null +++ b/tests/nodes/primary_nodes/test_process.py @@ -0,0 +1,192 @@ +import copy +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_simple_process() -> None: + """ + tests that a simple process node can be correctly created + """ + + # process fields + my_process_type = "affinity_pure" + my_process_description = "my simple material description" + my_process_keywords = ["anionic"] + + # create process node + my_process = cript.Process(name="my process name", type=my_process_type, description=my_process_description, keyword=my_process_keywords) + + # assertions + assert isinstance(my_process, cript.Process) + assert my_process.type == my_process_type + assert my_process.description == my_process_description + assert my_process.keyword == my_process_keywords + + +def test_complex_process_node(complex_ingredient_node, simple_equipment_node, complex_citation_node, simple_property_node, simple_condition_node, simple_material_node, simple_process_node, complex_equipment_node, complex_condition_node) -> None: + """ + create a process node with all possible arguments + + Notes + ----- + * indirectly tests the vocabulary as well, as it gives it valid vocabulary + """ + # TODO clean up this test and use fixtures from conftest.py + + my_process_name = "my complex process node name" + my_process_type = "affinity_pure" + my_process_description = "my simple material description" + + process_waste = [ + cript.Material(name="my process waste material 1", identifiers=[{"bigsmiles": "process waste bigsmiles"}]), + ] + + my_process_keywords = [ + "anionic", + "annealing_sol", + ] + + # create complex process + citation = copy.deepcopy(complex_citation_node) + prop = cript.Property("n_neighbor", "value", 2.0, None) + + my_complex_process = cript.Process( + name=my_process_name, + type=my_process_type, + ingredient=[complex_ingredient_node], + description=my_process_description, + equipment=[complex_equipment_node], + product=[simple_material_node], + waste=process_waste, + prerequisite_process=[simple_process_node], + condition=[complex_condition_node], + property=[prop], + keyword=my_process_keywords, + citation=[citation], + ) + # assertions + assert my_complex_process.type == my_process_type + assert my_complex_process.ingredient == [complex_ingredient_node] + assert my_complex_process.description == my_process_description + assert my_complex_process.equipment == [complex_equipment_node] + assert my_complex_process.product == [simple_material_node] + assert my_complex_process.waste == process_waste + assert my_complex_process.prerequisite_process[-1] == simple_process_node + assert my_complex_process.condition[-1] == complex_condition_node + assert my_complex_process.property[-1] == prop + assert my_complex_process.keyword[-1] == my_process_keywords[-1] + assert my_complex_process.citation[-1] == citation + + +def test_process_getters_and_setters( + simple_process_node, + complex_ingredient_node, + complex_equipment_node, + simple_material_node, + complex_condition_node, + simple_property_node, + complex_citation_node, +) -> None: + """ + test getters and setters and be sure they are working correctly + + 1. set simple_process_node attributes to something new + 2. get all attributes and check that they have been set correctly + + Notes + ----- + indirectly tests setting the data type to correct vocabulary + """ + new_process_type = "blow_molding" + new_process_description = "my new process description" + new_process_keywords = "annealing_sol" + + # test setters + simple_process_node.type = new_process_type + simple_process_node.ingredient = [complex_ingredient_node] + simple_process_node.description = new_process_description + equipment = complex_equipment_node + simple_process_node.equipment = [equipment] + product = simple_material_node + simple_process_node.product = [product] + simple_process_node.waste = [simple_material_node] + simple_process_node.prerequisite_process = [simple_process_node] + simple_process_node.condition = [complex_condition_node] + prop = cript.Property("n_neighbor", "value", 2.0, None) + simple_process_node.property += [prop] + simple_process_node.keyword = [new_process_keywords] + citation = copy.deepcopy(complex_citation_node) + simple_process_node.citation = [citation] + + # test getters + assert simple_process_node.type == new_process_type + assert simple_process_node.ingredient == [complex_ingredient_node] + assert simple_process_node.description == new_process_description + assert simple_process_node.equipment[-1] == equipment + assert simple_process_node.product[-1] == product + assert simple_process_node.waste == [simple_material_node] + assert simple_process_node.prerequisite_process == [simple_process_node] + assert simple_process_node.condition == [complex_condition_node] + assert simple_process_node.property[-1] == prop + assert simple_process_node.keyword == [new_process_keywords] + assert simple_process_node.citation[-1] == citation + + +def test_serialize_process_to_json(simple_process_node) -> None: + """ + test serializing process node to JSON + """ + expected_process_dict = {"node": ["Process"], "name": "my process name", "type": "affinity_pure"} + + # comparing dicts because they are more accurate + ref_dict = json.loads(simple_process_node.json) + ref_dict = strip_uid_from_dict(ref_dict) + assert ref_dict == expected_process_dict + + +def test_integration_simple_process(cript_api, simple_project_node, simple_process_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert JSON sent and JSON received are the same + """ + simple_project_node.name = f"test_integration_process_name_{uuid.uuid4().hex}" + + simple_project_node.collection[0].experiment[0].process = [simple_process_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + +def test_integration_complex_process(cript_api, simple_project_node, simple_process_node, simple_material_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert JSON sent and JSON received are the same + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_process_name_{uuid.uuid4().hex}" + + # rename material to not get duplicate error + simple_material_node.name += f"{simple_material_node.name} {uuid.uuid4().hex}" + + # add material to the project to not get OrphanedNodeError + simple_project_node.material += [simple_material_node] + + simple_project_node.collection[0].experiment[0].process = [simple_process_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.collection[0].experiment[0].process[0].description = "process description UPDATED" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/primary_nodes/test_project.py b/tests/nodes/primary_nodes/test_project.py new file mode 100644 index 000000000..65d2e63a1 --- /dev/null +++ b/tests/nodes/primary_nodes/test_project.py @@ -0,0 +1,79 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_create_simple_project(simple_collection_node) -> None: + """ + test that a project node with only required arguments can be created + """ + my_project_name = "my Project name" + + my_project = cript.Project(name=my_project_name, collection=[simple_collection_node]) + + # assertions + assert isinstance(my_project, cript.Project) + assert my_project.name == my_project_name + assert my_project.collection == [simple_collection_node] + + +def test_project_getters_and_setters(simple_project_node, simple_collection_node, complex_collection_node, simple_material_node) -> None: + """ + tests that a Project node getters and setters are working as expected + + 1. use a simple project node + 2. set all of its attributes to something new + 3. get all of its attributes + 4. what was set and what was gotten should be equivalent + """ + new_project_name = "my new project name" + + # set attributes + simple_project_node.name = new_project_name + simple_project_node.collection = [complex_collection_node] + simple_project_node.material = [simple_material_node] + + # get attributes and assert that they are the same + assert simple_project_node.name == new_project_name + assert simple_project_node.collection == [complex_collection_node] + assert simple_project_node.material == [simple_material_node] + + +def test_serialize_project_to_json(complex_project_node, complex_project_dict) -> None: + """ + tests that a Project node can be correctly converted to a JSON + """ + expected_dict = complex_project_dict + + # Since we condense those to UUID we remove them from the expected dict. + expected_dict["admin"] = [{}] + expected_dict["member"] = [{}] + + # comparing dicts instead of JSON strings because dict comparison is more accurate + serialized_project: dict = json.loads(complex_project_node.get_json(condense_to_uuid={}).json) + serialized_project = strip_uid_from_dict(serialized_project) + + assert serialized_project == strip_uid_from_dict(expected_dict) + + +def test_integration_project(cript_api, simple_project_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert they're both equal + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_project_name_{uuid.uuid4().hex}" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + simple_project_node.notes = "project notes UPDATED" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/primary_nodes/test_reference.py b/tests/nodes/primary_nodes/test_reference.py new file mode 100644 index 000000000..05374e998 --- /dev/null +++ b/tests/nodes/primary_nodes/test_reference.py @@ -0,0 +1,196 @@ +import json +import uuid +import warnings + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_create_simple_reference() -> None: + """ + tests to see if a simple reference node with only minimal arguments can be successfully created + """ + my_reference_type = "journal_article" + my_reference_title = "'Living' Polymers" + + my_reference = cript.Reference(type=my_reference_type, title=my_reference_title) + + assert isinstance(my_reference, cript.Reference) + assert my_reference.type == my_reference_type + assert my_reference.title == my_reference_title + + +def test_complex_reference() -> None: + """ + tests that a complex reference node with all optional parameters can be made + """ + + # reference attributes + reference_type = "journal_article" + title = "'Living' Polymers" + authors = ["Dylan J. Walsh", "Bradley D. Olsen"] + journal = "Nature" + publisher = "Springer" + year = 2019 + volume = 3 + issue = 5 + pages = [123, 456, 789] + doi = "10.1038/1781168a0" + issn = "1476-4687" + arxiv_id = "1501" + pmid = 12345678 + website = "https://criptapp.org" + + # create complex reference node + my_reference = cript.Reference( + type=reference_type, + title=title, + author=authors, + journal=journal, + publisher=publisher, + year=year, + volume=volume, + issue=issue, + pages=pages, + doi=doi, + issn=issn, + arxiv_id=arxiv_id, + pmid=pmid, + website=website, + ) + + # assertions + assert isinstance(my_reference, cript.Reference) + assert my_reference.type == reference_type + assert my_reference.title == title + assert my_reference.author == authors + assert my_reference.journal == journal + assert my_reference.publisher == publisher + assert my_reference.year == year + assert my_reference.volume == volume + assert my_reference.issue == issue + assert my_reference.pages == pages + assert my_reference.doi == doi + assert my_reference.issn == issn + assert my_reference.arxiv_id == arxiv_id + assert my_reference.pmid == pmid + assert my_reference.website == website + + +def test_getters_and_setters_reference(complex_reference_node) -> None: + """ + testing that the complex reference node is working correctly + """ + + # new attributes for the setter + reference_type = "journal_article" + title = "my title" + authors = ["Ludwig Schneider"] + journal = "my journal" + publisher = "my publisher" + year = 2023 + volume = 1 + issue = 2 + pages = [123, 456] + doi = "100.1038/1781168a0" + issn = "1456-4687" + arxiv_id = "1501" + pmid = 12345678 + website = "https://criptapp.org" + + # set reference attributes + complex_reference_node.type = reference_type + complex_reference_node.title = title + complex_reference_node.author = authors + complex_reference_node.journal = journal + complex_reference_node.publisher = publisher + complex_reference_node.publisher = publisher + complex_reference_node.year = year + complex_reference_node.volume = volume + complex_reference_node.issue = issue + complex_reference_node.pages = pages + complex_reference_node.doi = doi + complex_reference_node.issn = issn + complex_reference_node.arxiv_id = arxiv_id + complex_reference_node.pmid = pmid + complex_reference_node.website = website + + # assertions: test getter and setter + assert isinstance(complex_reference_node, cript.Reference) + assert complex_reference_node.type == reference_type + assert complex_reference_node.title == title + assert complex_reference_node.author == authors + assert complex_reference_node.journal == journal + assert complex_reference_node.publisher == publisher + assert complex_reference_node.publisher == publisher + assert complex_reference_node.year == year + assert complex_reference_node.volume == volume + assert complex_reference_node.issue == issue + assert complex_reference_node.pages == pages + assert complex_reference_node.doi == doi + assert complex_reference_node.issn == issn + assert complex_reference_node.arxiv_id == arxiv_id + assert complex_reference_node.pmid == pmid + assert complex_reference_node.website == website + + +def test_reference_vocabulary() -> None: + """ + tests that a reference node type with valid CRIPT controlled vocabulary runs successfully + and invalid reference type gives the correct errors + """ + pass + + +def test_reference_conditional_attributes() -> None: + """ + test conditional attributes (DOI and ISSN) that they are validating correctly + and that an error is correctly raised when they are needed but not provided + """ + pass + + +def test_serialize_reference_to_json(complex_reference_node, complex_reference_dict) -> None: + """ + tests that it can correctly turn the data node into its equivalent JSON + """ + + # convert reference to json and then to dict for better comparison + reference_dict = json.loads(complex_reference_node.json) + reference_dict = strip_uid_from_dict(reference_dict) + + assert reference_dict == complex_reference_dict + + +def test_integration_reference(cript_api, simple_project_node, complex_citation_node, complex_reference_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert they're both equal + + Notes + ----- + indirectly tests citation node along with reference node + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_reference_name_{uuid.uuid4().hex}" + + simple_project_node.collection[0].citation = [complex_citation_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # TODO deserialization with citation in collection is wrong + # raise Exception("Citation is missing from collection node from API") + warnings.warn("Uncomment the Reference integration test Exception and check the API response has citation on collection") + + # ========= test update ========= + # change simple attribute to trigger update + # TODO can enable this later + # complex_reference_node.type = "book" + simple_project_node.collection[0].citation[0].reference.title = "reference title UPDATED" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_algorithm.py b/tests/nodes/subobjects/test_algorithm.py new file mode 100644 index 000000000..86f106343 --- /dev/null +++ b/tests/nodes/subobjects/test_algorithm.py @@ -0,0 +1,53 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_setter_getter(simple_algorithm_node, complex_citation_node): + a = simple_algorithm_node + a.key = "berendsen" + assert a.key == "berendsen" + a.type = "integration" + assert a.type == "integration" + a.citation += [complex_citation_node] + assert strip_uid_from_dict(json.loads(a.citation[0].json)) == strip_uid_from_dict(json.loads(complex_citation_node.json)) + + +def test_json(simple_algorithm_node, simple_algorithm_dict, complex_citation_node): + a = simple_algorithm_node + a_dict = json.loads(a.json) + assert strip_uid_from_dict(a_dict) == simple_algorithm_dict + print(a.get_json(indent=2).json) + a2 = cript.load_nodes_from_json(a.json) + assert strip_uid_from_dict(json.loads(a2.json)) == strip_uid_from_dict(a_dict) + + +def test_integration_algorithm(cript_api, simple_project_node, simple_collection_node, simple_experiment_node, simple_computation_node, simple_software_configuration, simple_algorithm_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert JSON sent and JSON received are the same + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_software_configuration_{uuid.uuid4().hex}" + + simple_project_node.collection = [simple_collection_node] + + simple_project_node.collection[0].experiment = [simple_experiment_node] + simple_project_node.collection[0].experiment[0].computation = [simple_computation_node] + simple_project_node.collection[0].experiment[0].computation[0].software_configuration = [simple_software_configuration] + simple_project_node.collection[0].experiment[0].computation[0].software_configuration[0].algorithm = [simple_algorithm_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change a simple attribute to trigger update + simple_project_node.collection[0].experiment[0].computation[0].software_configuration[0].algorithm[0].type = "integration" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_citation.py b/tests/nodes/subobjects/test_citation.py new file mode 100644 index 000000000..5d02c9735 --- /dev/null +++ b/tests/nodes/subobjects/test_citation.py @@ -0,0 +1,50 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_json(complex_citation_node, complex_citation_dict): + c = complex_citation_node + c_dict = strip_uid_from_dict(json.loads(c.json)) + assert c_dict == complex_citation_dict + c2 = cript.load_nodes_from_json(c.json) + c2_dict = strip_uid_from_dict(json.loads(c2.json)) + assert c_dict == c2_dict + + +def test_setter_getter(complex_citation_node, complex_reference_node): + c = complex_citation_node + c.type = "replicated" + assert c.type == "replicated" + new_ref = complex_reference_node + new_ref.title = "foo bar" + c.reference = new_ref + assert c.reference == new_ref + + +def test_integration_citation(cript_api, simple_project_node, simple_collection_node, complex_citation_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert JSON sent and JSON received are the same + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_citation_{uuid.uuid4().hex}" + + simple_project_node.collection = [simple_collection_node] + + simple_project_node.collection[0].citation = [complex_citation_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.collection[0].citation[0].type = "extracted_by_human" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_computational_forcefiled.py b/tests/nodes/subobjects/test_computational_forcefiled.py new file mode 100644 index 000000000..f3f9b9eee --- /dev/null +++ b/tests/nodes/subobjects/test_computational_forcefiled.py @@ -0,0 +1,68 @@ +import copy +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_computational_forcefield(complex_computational_forcefield_node, complex_computational_forcefield_dict): + cf = complex_computational_forcefield_node + cf_dict = strip_uid_from_dict(json.loads(cf.json)) + assert cf_dict == strip_uid_from_dict(complex_computational_forcefield_dict) + cf2 = cript.load_nodes_from_json(cf.json) + assert strip_uid_from_dict(json.loads(cf.json)) == strip_uid_from_dict(json.loads(cf2.json)) + + +def test_setter_getter(complex_computational_forcefield_node, complex_citation_node, simple_data_node): + cf2 = complex_computational_forcefield_node + cf2.key = "opls_ua" + assert cf2.key == "opls_ua" + + cf2.building_block = "united_atoms" + assert cf2.building_block == "united_atoms" + + cf2.implicit_solvent = "" + assert cf2.implicit_solvent == "" + + cf2.source = "Iterative Boltzmann inversion" + assert cf2.source == "Iterative Boltzmann inversion" + + cf2.description = "generic polymer model" + assert cf2.description == "generic polymer model" + + data = simple_data_node + cf2.data += [data] + assert cf2.data[-1] is data + + assert len(cf2.citation) == 1 + citation2 = copy.deepcopy(complex_citation_node) + cf2.citation += [citation2] + assert cf2.citation[1] == citation2 + + +def test_integration_computational_forcefield(cript_api, simple_project_node, simple_material_node, simple_computational_forcefield_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert JSON sent and JSON received are the same + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_computational_forcefield_{uuid.uuid4().hex}" + + # renaming to avoid API duplicate node error + simple_material_node.name = f"{simple_material_node.name}_{uuid.uuid4().hex}" + + simple_project_node.material = [simple_material_node] + simple_project_node.material[0].computational_forcefield = simple_computational_forcefield_node + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.material[0].computational_forcefield.description = "material computational_forcefield description UPDATED" + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_condition.py b/tests/nodes/subobjects/test_condition.py new file mode 100644 index 000000000..4881d9563 --- /dev/null +++ b/tests/nodes/subobjects/test_condition.py @@ -0,0 +1,71 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + + +def test_json(complex_condition_node, complex_condition_dict): + c = complex_condition_node + c_dict = json.loads(c.get_json(condense_to_uuid={}).json) + assert strip_uid_from_dict(c_dict) == strip_uid_from_dict(complex_condition_dict) + ## TODO address deserialization of uid and uuid nodes + # c_deepcopy = copy.deepcopy(c) + # c2 = cript.load_nodes_from_json(c_deepcopy.get_json(condense_to_uuid={}).json) + # assert strip_uid_from_dict(json.loads(c2.get_json(condense_to_uuid={}).json)) == strip_uid_from_dict(json.loads(c.get_json(condense_to_uuid={}).json)) + + +def test_setter_getters(complex_condition_node, complex_data_node): + c2 = complex_condition_node + c2.key = "pressure" + assert c2.key == "pressure" + c2.type = "avg" + assert c2.type == "avg" + + c2.set_value(1, "bar") + assert c2.value == 1 + assert c2.unit == "bar" + + c2.descriptor = "ambient pressure" + assert c2.descriptor == "ambient pressure" + + c2.set_uncertainty(0.1, "stdev") + assert c2.uncertainty == 0.1 + assert c2.uncertainty_type == "stdev" + + c2.set_id = None + assert c2.set_id is None + c2.measurement_id = None + assert c2.measurement_id is None + + c2.data = [complex_data_node] + assert c2.data[0] is complex_data_node + + +def test_integration_process_condition(cript_api, simple_project_node, simple_collection_node, simple_experiment_node, simple_computation_node, simple_condition_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert they're both equal + """ + # ========= test create ========= + # renamed project node to avoid duplicate project node API error + simple_project_node.name = f"{simple_project_node.name}_{uuid.uuid4().hex}" + + simple_project_node.collection = [simple_collection_node] + + simple_project_node.collection[0].experiment = [simple_experiment_node] + + simple_project_node.collection[0].experiment[0].computation = [simple_computation_node] + + simple_project_node.collection[0].experiment[0].computation[0].condition = [simple_condition_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.collection[0].experiment[0].computation[0].condition[0].descriptor = "condition descriptor UPDATED" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_equipment.py b/tests/nodes/subobjects/test_equipment.py new file mode 100644 index 000000000..c0cf45356 --- /dev/null +++ b/tests/nodes/subobjects/test_equipment.py @@ -0,0 +1,62 @@ +import copy +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + + +def test_json(complex_equipment_node, complex_equipment_dict): + e = complex_equipment_node + e_dict = strip_uid_from_dict(json.loads(e.get_json(condense_to_uuid={}).json)) + assert strip_uid_from_dict(e_dict) == strip_uid_from_dict(complex_equipment_dict) + e2 = copy.deepcopy(e) + + assert strip_uid_from_dict(json.loads(e.get_json(condense_to_uuid={}).json)) == strip_uid_from_dict(json.loads(e2.get_json(condense_to_uuid={}).json)) + + +def test_setter_getter(complex_equipment_node, complex_condition_node, complex_file_node, complex_citation_node): + e2 = complex_equipment_node + e2.key = "glass_beaker" + assert e2.key == "glass_beaker" + e2.description = "Fancy glassware" + assert e2.description == "Fancy glassware" + + assert len(e2.condition) == 1 + c2 = complex_condition_node + e2.condition += [c2] + assert e2.condition[1] == c2 + + assert len(e2.file) == 0 + e2.file += [complex_file_node] + assert e2.file[-1] is complex_file_node + + cit2 = copy.deepcopy(complex_citation_node) + assert len(e2.citation) == 1 + e2.citation += [cit2] + assert e2.citation[1] == cit2 + + +def test_integration_equipment(cript_api, simple_project_node, simple_collection_node, simple_experiment_node, simple_process_node, simple_equipment_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert JSON sent and JSON received are the same + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_equipment_{uuid.uuid4().hex}" + + simple_project_node.collection = [simple_collection_node] + simple_project_node.collection[0].experiment = [simple_experiment_node] + simple_project_node.collection[0].experiment[0].process = [simple_process_node] + simple_project_node.collection[0].experiment[0].process[0].equipment = [simple_equipment_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.collection[0].experiment[0].process[0].equipment[0].description = "equipment description UPDATED" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_ingredient.py b/tests/nodes/subobjects/test_ingredient.py new file mode 100644 index 000000000..ce18b97e4 --- /dev/null +++ b/tests/nodes/subobjects/test_ingredient.py @@ -0,0 +1,68 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_json(complex_ingredient_node, complex_ingredient_dict): + i = complex_ingredient_node + i_dict = json.loads(i.json) + i_dict["material"] = {} + j_dict = strip_uid_from_dict(complex_ingredient_dict) + j_dict["material"] = {} + assert strip_uid_from_dict(i_dict) == j_dict + i2 = cript.load_nodes_from_json(i.get_json(condense_to_uuid={}).json) + ref_dict = strip_uid_from_dict(json.loads(i.get_json(condense_to_uuid={}).json)) + ref_dict["material"] = {} + ref_dictB = strip_uid_from_dict(json.loads(i2.get_json(condense_to_uuid={}).json)) + ref_dictB["material"] = {} + assert ref_dict == ref_dictB + + +def test_getter_setter(complex_ingredient_node, complex_quantity_node, simple_material_node): + i2 = complex_ingredient_node + q2 = complex_quantity_node + i2.set_material(simple_material_node, [complex_quantity_node]) + assert i2.material is simple_material_node + assert i2.quantity[-1] is q2 + + i2.keyword = ["monomer"] + assert i2.keyword == ["monomer"] + + +def test_integration_ingredient(cript_api, simple_project_node, simple_collection_node, simple_experiment_node, simple_process_node, simple_ingredient_node, simple_material_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + Project with material + Material has ingredient sub-object + 1. GET JSON from API + 1. check their fields equal + + Notes + ---- + since `ingredient` requires a `quantity` this test also indirectly tests `quantity` + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_ingredient_{uuid.uuid4().hex}" + + # assemble needed nodes + simple_project_node.collection = [simple_collection_node] + simple_project_node.collection[0].experiment = [simple_experiment_node] + simple_project_node.collection[0].experiment[0].process = [simple_process_node] + simple_project_node.collection[0].experiment[0].process[0].ingredient = [simple_ingredient_node] + + # add orphaned material node to project + simple_project_node.material = [simple_material_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.collection[0].experiment[0].process[0].ingredient[0].keyword = ["polymer"] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_parameter.py b/tests/nodes/subobjects/test_parameter.py new file mode 100644 index 000000000..0ef433653 --- /dev/null +++ b/tests/nodes/subobjects/test_parameter.py @@ -0,0 +1,53 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_parameter_setter_getter(complex_parameter_node): + p = complex_parameter_node + p.key = "damping_time" + assert p.key == "damping_time" + p.value = 15.0 + assert p.value == 15.0 + p.unit = "m" + assert p.unit == "m" + + +def test_parameter_json_serialization(complex_parameter_node, complex_parameter_dict): + p = complex_parameter_node + p_str = p.json + p2 = cript.load_nodes_from_json(p_str) + p_dict = json.loads(p2.json) + assert strip_uid_from_dict(p_dict) == complex_parameter_dict + assert p2.json == p.json + + +def test_integration_parameter(cript_api, simple_project_node, simple_collection_node, simple_experiment_node, simple_computation_node, simple_software_configuration, simple_algorithm_node, complex_parameter_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert JSON sent and JSON received are the same + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_parameter_{uuid.uuid4().hex}" + + simple_project_node.collection = [simple_collection_node] + simple_project_node.collection[0].experiment = [simple_experiment_node] + simple_project_node.collection[0].experiment[0].computation = [simple_computation_node] + simple_project_node.collection[0].experiment[0].computation[0].software_configuration = [simple_software_configuration] + simple_project_node.collection[0].experiment[0].computation[0].software_configuration[0].algorithm = [simple_algorithm_node] + simple_project_node.collection[0].experiment[0].computation[0].software_configuration[0].algorithm[0].parameter = [complex_parameter_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # update simple attribute to trigger update + simple_project_node.collection[0].experiment[0].computation[0].software_configuration[0].algorithm[0].parameter[0].value = 123456789 + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_property.py b/tests/nodes/subobjects/test_property.py new file mode 100644 index 000000000..63a01030b --- /dev/null +++ b/tests/nodes/subobjects/test_property.py @@ -0,0 +1,85 @@ +import copy +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_json(complex_property_node, complex_property_dict): + p = complex_property_node + p_dict = strip_uid_from_dict(json.loads(p.get_json(condense_to_uuid={}).json)) + assert p_dict == complex_property_dict + p2 = cript.load_nodes_from_json(p.get_json(condense_to_uuid={}).json) + + assert strip_uid_from_dict(json.loads(p2.get_json(condense_to_uuid={}).json)) == strip_uid_from_dict(json.loads(p.get_json(condense_to_uuid={}).json)) + + +def test_setter_getter(complex_property_node, simple_material_node, simple_process_node, complex_condition_node, simple_data_node, simple_computation_node, complex_citation_node): + p2 = complex_property_node + p2.key = "modulus_loss" + assert p2.key == "modulus_loss" + p2.type = "min" + assert p2.type == "min" + p2.set_value(600.1, "MPa") + assert p2.value == 600.1 + assert p2.unit == "MPa" + + p2.set_uncertainty(10.5, "stdev") + assert p2.uncertainty == 10.5 + assert p2.uncertainty_type == "stdev" + + p2.component += [simple_material_node] + assert p2.component[-1] is simple_material_node + p2.structure = "structure2" + assert p2.structure == "structure2" + + p2.method = "scale" + assert p2.method == "scale" + + p2.sample_preparation = simple_process_node + assert p2.sample_preparation is simple_process_node + assert len(p2.condition) == 1 + p2.condition += [complex_condition_node] + assert len(p2.condition) == 2 + p2.data = [simple_data_node] + assert p2.data[0] is simple_data_node + + p2.computation += [simple_computation_node] + assert p2.computation[-1] is simple_computation_node + + assert len(p2.citation) == 1 + cit2 = copy.deepcopy(complex_citation_node) + p2.citation += [cit2] + assert len(p2.citation) == 2 + assert p2.citation[-1] == cit2 + p2.notes = "notes2" + assert p2.notes == "notes2" + + +def test_integration_material_property(cript_api, simple_project_node, simple_material_node, simple_property_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + Project with material + Material has property sub-object + 1. GET JSON from API + 1. check their fields equal + """ + # ========= test create ========= + # rename property and material to avoid duplicate node API error + simple_project_node.name = f"test_integration_material_property_{uuid.uuid4().hex}" + simple_material_node.name = f"{simple_material_node.name}_{uuid.uuid4().hex}" + + simple_project_node.material = [simple_material_node] + simple_project_node.material[0].property = [simple_property_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.material[0].property[0].notes = "property sub-object notes UPDATED" + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_quantity.py b/tests/nodes/subobjects/test_quantity.py new file mode 100644 index 000000000..87542fbc9 --- /dev/null +++ b/tests/nodes/subobjects/test_quantity.py @@ -0,0 +1,58 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_json(complex_quantity_node, complex_quantity_dict): + q = complex_quantity_node + q_dict = json.loads(q.json) + assert strip_uid_from_dict(q_dict) == complex_quantity_dict + q2 = cript.load_nodes_from_json(q.json) + assert q2.json == q.json + + +def test_getter_setter(complex_quantity_node): + q = complex_quantity_node + q.value = 0.5 + assert q.value == 0.5 + q.set_uncertainty(0.1, "stderr") + assert q.uncertainty == 0.1 + assert q.uncertainty_type == "stderr" + + q.set_key_unit("volume", "m**3") + assert q.key == "volume" + assert q.unit == "m**3" + + +def test_integration_quantity(cript_api, simple_project_node, simple_collection_node, simple_experiment_node, simple_process_node, simple_ingredient_node, simple_material_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + Project with material + Material has ingredient sub-object + 1. GET JSON from API + 1. check their fields equal + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_quantity_{uuid.uuid4().hex}" + + # assemble needed nodes + simple_project_node.collection = [simple_collection_node] + simple_project_node.collection[0].experiment = [simple_experiment_node] + simple_project_node.collection[0].experiment[0].process = [simple_process_node] + simple_project_node.collection[0].experiment[0].process[0].ingredient = [simple_ingredient_node] + + # add orphaned material node to project + simple_project_node.material = [simple_material_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.collection[0].experiment[0].process[0].ingredient[0].quantity[0].value = 123456789 + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_software.py b/tests/nodes/subobjects/test_software.py new file mode 100644 index 000000000..ba557ad48 --- /dev/null +++ b/tests/nodes/subobjects/test_software.py @@ -0,0 +1,68 @@ +import copy +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_json(complex_software_node, complex_software_dict): + s = complex_software_node + s_dict = strip_uid_from_dict(json.loads(s.json)) + assert s_dict == complex_software_dict + s2 = cript.load_nodes_from_json(s.json) + assert s2.json == s.json + + +def test_setter_getter(complex_software_node): + s2 = complex_software_node + s2.name = "PySAGES" + assert s2.name == "PySAGES" + s2.version = "v0.3.0" + assert s2.version == "v0.3.0" + s2.source = "https://github.com/SSAGESLabs/PySAGES" + assert s2.source == "https://github.com/SSAGESLabs/PySAGES" + + +def test_uuid(complex_software_node): + s = complex_software_node + + # Deep copies should not share uuid (or uids) or urls + s2 = copy.deepcopy(complex_software_node) + assert s.uuid != s2.uuid + assert s.uid != s2.uid + assert s.url != s2.url + + # Loads from json have the same uuid and url + s3 = cript.load_nodes_from_json(s.json) + assert s3.uuid == s.uuid + assert s3.url == s.url + + +def test_integration_software(cript_api, simple_project_node, simple_computation_node, simple_software_configuration, complex_software_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert they're both equal + + Notes + ----- + indirectly tests citation node along with reference node + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_software_name_{uuid.uuid4().hex}" + + simple_project_node.collection[0].experiment[0].computation = [simple_computation_node] + simple_project_node.collection[0].experiment[0].computation[0].software_configuration = [simple_software_configuration] + simple_project_node.collection[0].experiment[0].computation[0].software_configuration[0].software = complex_software_node + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.collection[0].experiment[0].computation[0].software_configuration[0].software.version = "software version UPDATED" + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/subobjects/test_software_configuration.py b/tests/nodes/subobjects/test_software_configuration.py new file mode 100644 index 000000000..eddd7388e --- /dev/null +++ b/tests/nodes/subobjects/test_software_configuration.py @@ -0,0 +1,63 @@ +import json +import uuid + +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_json(complex_software_configuration_node, complex_software_configuration_dict): + sc = complex_software_configuration_node + sc_dict = strip_uid_from_dict(json.loads(sc.json)) + assert sc_dict == complex_software_configuration_dict + sc2 = cript.load_nodes_from_json(sc.json) + + assert strip_uid_from_dict(json.loads(sc2.json)) == strip_uid_from_dict(json.loads(sc.json)) + + +def test_setter_getter(complex_software_configuration_node, simple_algorithm_node, complex_citation_node): + sc2 = complex_software_configuration_node + software2 = sc2.software + sc2.software = software2 + assert sc2.software is software2 + + # assert len(sc2.algorithm) == 1 + # al2 = simple_algorithm_node + # print(sc2.get_json(indent=2,sortkeys=False).json) + # print(al2.get_json(indent=2,sortkeys=False).json) + # sc2.algorithm += [al2] + # assert sc2.algorithm[1] is al2 + + sc2.notes = "my new fancy notes" + assert sc2.notes == "my new fancy notes" + + # cit2 = complex_citation_node + # assert len(sc2.citation) == 1 + # sc2.citation += [cit2] + # assert sc2.citation[1] == cit2 + + +def test_integration_software_configuration(cript_api, simple_project_node, simple_collection_node, simple_experiment_node, simple_computation_node, simple_software_configuration): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert JSON sent and JSON received are the same + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_software_configuration_{uuid.uuid4().hex}" + + simple_project_node.collection = [simple_collection_node] + simple_project_node.collection[0].experiment = [simple_experiment_node] + simple_project_node.collection[0].experiment[0].computation = [simple_computation_node] + simple_project_node.collection[0].experiment[0].computation[0].software_configuration = [simple_software_configuration] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.collection[0].experiment[0].computation[0].software_configuration[0].notes = "software configuration integration test UPDATED" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/supporting_nodes/test_file.py b/tests/nodes/supporting_nodes/test_file.py new file mode 100644 index 000000000..aaad885aa --- /dev/null +++ b/tests/nodes/supporting_nodes/test_file.py @@ -0,0 +1,220 @@ +import copy +import json +import os +import uuid + +import pytest +from integration_test_helper import integrate_nodes_helper +from util import strip_uid_from_dict + +import cript + + +def test_create_file() -> None: + """ + tests that a simple file with only required attributes can be created + """ + file_node = cript.File(name="my file name", source="https://google.com", type="calibration") + + assert isinstance(file_node, cript.File) + + +def test_source_is_local(tmp_path, tmp_path_factory) -> None: + """ + tests that the `_is_local_file()` function is working well + and it can correctly tell the difference between local file, URL, cloud storage object_name correctly + + ## test cases + ### web sources + * AWS S3 cloud storage object_name + * web URL file source + example: `https://my-website/my-file-name.pdf` + ## local file sources + * local file path + * absolute file path + * relative file path + """ + from cript.nodes.supporting_nodes.file import _is_local_file + + # URL + assert _is_local_file(file_source="https://my-website/my-uploaded-file.pdf") is False + + # S3 object_name + assert _is_local_file(file_source="s3_directory/s3_uploaded_file.txt") is False + + # create temporary file + temp_file = tmp_path_factory.mktemp("test_source_is_local") / "temp_file.txt" + temp_file.write_text("hello world") # write something to the file to force creation + + # Absolute file path + absolute_file_path: str = str(temp_file.resolve()) + assert _is_local_file(file_source=absolute_file_path) is True + + # Relative file path from cwd + # get relative file path to temp_file from cwd + relative_file_path: str = os.path.relpath(absolute_file_path, os.getcwd()) + assert _is_local_file(file_source=relative_file_path) is True + + +@pytest.mark.skip(reason="test is outdated because files now upload on api.save()") +def test_local_file_source_upload_and_download(tmp_path_factory) -> None: + """ + upload a file and download it and be sure the contents are the same + + 1. create a temporary file and get its file path + 1. create a unique string + 1. write unique string to temporary file + 1. create a file node with the source being the temporary file + 1. the file should then be automatically uploaded to cloud storage + and the source should be replaced with cloud storage source beginning with `https://` + 1. download the file to a temporary path + 1. read that file text and assert that the string written and read are the same + """ + import datetime + import uuid + + file_text: str = ( + f"This is an automated test from the Python SDK within " + f"`tests/nodes/supporting_nodes/test_file.py/test_local_file_source_upload_and_download()` " + f"checking that the file source is automatically and correctly uploaded to AWS S3. " + f"The test is conducted on UTC time of '{datetime.datetime.utcnow()}' " + f"with the unique UUID of '{str(uuid.uuid4())}'" + ) + + # create a temp file and write to it + upload_file_dir = tmp_path_factory.mktemp("file_test_upload_file_dir") + local_file_path = upload_file_dir / "my_upload_file.txt" + local_file_path.write_text(file_text) + + # create a file node with a local file path + my_file = cript.File(name="my local file source node", source=str(local_file_path), type="data") + + # check that the file source has been uploaded to cloud storage and source has changed to reflect that + assert my_file.source.startswith("tests/") + + # Get the temporary directory path and clean up handled by pytest + download_file_dir = tmp_path_factory.mktemp("file_test_download_file_dir") + download_file_name = "my_downloaded_file.txt" + + # download file + my_file.download(destination_directory_path=download_file_dir, file_name=download_file_name) + + # the path the file was downloaded to and can be read from + downloaded_local_file_path = download_file_dir / download_file_name + + # read file contents from where the file was downloaded + downloaded_file_contents = downloaded_local_file_path.read_text() + + # assert file contents for upload and download are the same + assert downloaded_file_contents == file_text + + +def test_create_file_with_local_source(tmp_path) -> None: + """ + tests that a simple file with only required attributes can be created + with source pointing to a local file on storage + + create a temporary directory with temporary file + """ + # create a temporary file in the temporary directory to test with + file_path = tmp_path / "test.txt" + with open(file_path, "w") as temporary_file: + temporary_file.write("hello world!") + + assert cript.File(name="my file node with local source", source=str(file_path), type="calibration") + + +@pytest.mark.skip(reason="validating file type automatically with DB schema and test not currently needed") +def test_file_type_invalid_vocabulary() -> None: + """ + tests that setting the file type to an invalid vocabulary word gives the expected error + """ + pass + + +def test_file_getters_and_setters(complex_file_node) -> None: + """ + tests that all the getters and setters are working fine + + Notes + ----- + indirectly tests setting the file type to correct vocabulary + """ + # ------- new properties ------- + new_source = "https://bing.com" + new_file_type = "computation_config" + new_file_extension = ".csv" + new_data_dictionary = "new data dictionary" + + # ------- set properties ------- + complex_file_node.source = new_source + complex_file_node.type = new_file_type + complex_file_node.extension = new_file_extension + complex_file_node.data_dictionary = new_data_dictionary + + # ------- assert set and get properties are the same ------- + assert complex_file_node.source == new_source + assert complex_file_node.type == new_file_type + assert complex_file_node.extension == new_file_extension + assert complex_file_node.data_dictionary == new_data_dictionary + + +def test_serialize_file_to_json(complex_file_node) -> None: + """ + tests that it can correctly turn the file node into its equivalent JSON + """ + + expected_file_node_dict = { + "node": ["File"], + "name": "my complex file node fixture", + "source": "https://criptapp.org", + "type": "calibration", + "extension": ".csv", + "data_dictionary": "my file's data dictionary", + } + + # compare dicts for more accurate comparison + assert strip_uid_from_dict(json.loads(complex_file_node.json)) == expected_file_node_dict + + +def test_uuid(complex_file_node): + file_node = complex_file_node + + # Deep copies should not share uuid (or uids) or urls + file_node2 = copy.deepcopy(complex_file_node) + assert file_node.uuid != file_node2.uuid + assert file_node.uid != file_node2.uid + assert file_node.url != file_node2.url + + # Loads from json have the same uuid and url + file_node3 = cript.load_nodes_from_json(file_node.json) + assert file_node3.uuid == file_node.uuid + assert file_node3.url == file_node.url + + +def test_integration_file(cript_api, simple_project_node, simple_data_node): + """ + integration test between Python SDK and API Client + + 1. POST to API + 1. GET from API + 1. assert they're both equal + + Notes + ----- + indirectly tests data node as well because every file node must be in a data node + """ + # ========= test create ========= + simple_project_node.name = f"test_integration_file_{uuid.uuid4().hex}" + + simple_project_node.collection[0].experiment[0].data = [simple_data_node] + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) + + # ========= test update ========= + # change simple attribute to trigger update + simple_project_node.collection[0].experiment[0].data[0].file[0].notes = "file notes UPDATED" + # TODO enable later + # simple_project_node.collection[0].experiment[0].data[0].file[0].data_dictionary = "file data_dictionary UPDATED" + + integrate_nodes_helper(cript_api=cript_api, project_node=simple_project_node) diff --git a/tests/nodes/supporting_nodes/test_user.py b/tests/nodes/supporting_nodes/test_user.py new file mode 100644 index 000000000..ef2ee7ef9 --- /dev/null +++ b/tests/nodes/supporting_nodes/test_user.py @@ -0,0 +1,55 @@ +import json + +import pytest +from util import strip_uid_from_dict + +import cript + + +def test_user_serialization_and_deserialization(complex_user_dict, complex_user_node): + """ + tests just to see if a user node can be correctly deserialized from json + and serialized to json + + Notes + ----- + * since a User node cannot be instantiated + * a User node is created from JSON + * then the user node attributes are compared to what they are expected + * to check that the user node is created correctly + """ + + user_node_dict = complex_user_dict + user_node = complex_user_node + assert user_node_dict == strip_uid_from_dict(json.loads(user_node.json)) + + # deserialize node from JSON + user_node = cript.load_nodes_from_json(nodes_json=user_node.json) + + # checks that the user node has been created correctly by checking the properties + assert user_node.username == user_node_dict["username"] + assert user_node.email == user_node_dict["email"] + assert user_node.orcid == user_node_dict["orcid"] + + # check serialize node to JSON is working correctly + # convert dicts for better comparison + assert strip_uid_from_dict(json.loads(user_node.json)) == user_node_dict + + +def test_set_user_properties(complex_user_node): + """ + tests that setting any user property throws an AttributeError + """ + with pytest.raises(AttributeError): + complex_user_node.username = "my new username" + + with pytest.raises(AttributeError): + complex_user_node.email = "my new email" + + with pytest.raises(AttributeError): + complex_user_node.orcid = "my new orcid" + + with pytest.raises(AttributeError): + # TODO try setting it via a group node + # either way it should give the same error + complex_user_node.orcid = ["my new group"] diff --git a/tests/nodes/test_utils.py b/tests/nodes/test_utils.py new file mode 100644 index 000000000..14826bafc --- /dev/null +++ b/tests/nodes/test_utils.py @@ -0,0 +1,18 @@ +from cript.nodes.util import _is_node_field_valid + + +def test_is_node_field_valid() -> None: + """ + test the `_is_node_field_valid()` function to be sure it does the node type check correctly + + checks both in places it should be valid and invalid + """ + assert _is_node_field_valid(node_type_list=["Project"]) is True + + assert _is_node_field_valid(node_type_list=["Project", "Material"]) is False + + assert _is_node_field_valid(node_type_list=[""]) is False + + assert _is_node_field_valid(node_type_list="Project") is False + + assert _is_node_field_valid(node_type_list=[]) is False diff --git a/tests/test_node_util.py b/tests/test_node_util.py new file mode 100644 index 000000000..38c9a0c60 --- /dev/null +++ b/tests/test_node_util.py @@ -0,0 +1,325 @@ +import copy +import json +from dataclasses import replace + +import pytest +from util import strip_uid_from_dict + +import cript +from cript.nodes.core import get_new_uid +from cript.nodes.exceptions import ( + CRIPTJsonNodeError, + CRIPTJsonSerializationError, + CRIPTNodeSchemaError, + CRIPTOrphanedComputationalProcessError, + CRIPTOrphanedComputationError, + CRIPTOrphanedDataError, + CRIPTOrphanedMaterialError, + CRIPTOrphanedProcessError, +) + + +def test_removing_nodes(simple_algorithm_node, complex_parameter_node, simple_algorithm_dict): + a = simple_algorithm_node + p = complex_parameter_node + a.parameter += [p] + assert strip_uid_from_dict(json.loads(a.json)) != simple_algorithm_dict + a.remove_child(p) + assert strip_uid_from_dict(json.loads(a.json)) == simple_algorithm_dict + + +def test_uid_deserialization(simple_algorithm_node, complex_parameter_node, simple_algorithm_dict): + identifiers = [{"bigsmiles": "123456"}] + material = cript.Material(name="my material", identifiers=identifiers) + + computation = cript.Computation(name="my computation name", type="analysis") + property1 = cript.Property("modulus_shear", "value", 5.0, "GPa", computation=[computation]) + property2 = cript.Property("modulus_loss", "value", 5.0, "GPa", computation=[computation]) + material.property = [property1, property2] + + material2 = cript.load_nodes_from_json(material.json) + assert json.loads(material.json) == json.loads(material2.json) + + material3_dict = { + "node": ["Material"], + "uid": "_:f6d56fdc-9df7-49a1-a843-cf92681932ad", + "uuid": "f6d56fdc-9df7-49a1-a843-cf92681932ad", + "name": "my material", + "property": [ + { + "node": ["Property"], + "uid": "_:82e7270e-9f35-4b35-80a2-faa6e7f670be", + "uuid": "82e7270e-9f35-4b35-80a2-faa6e7f670be", + "key": "modulus_shear", + "type": "value", + "value": 5.0, + "unit": "GPa", + "computation": [{"uid": "_:9ddda2c0-ff8c-4ce3-beb0-e0cafb6169ef"}], + }, + { + "node": ["Property"], + "uid": "_:fc4dfa5e-742c-4d0b-bb66-2185461f4582", + "uuid": "fc4dfa5e-742c-4d0b-bb66-2185461f4582", + "key": "modulus_loss", + "type": "value", + "value": 5.0, + "unit": "GPa", + "computation": [ + { + "uid": "_:9ddda2c0-ff8c-4ce3-beb0-e0cafb6169ef", + } + ], + }, + ], + "bigsmiles": "123456", + } + + with pytest.raises(cript.nodes.exceptions.CRIPTDeserializationUIDError): + cript.load_nodes_from_json(json.dumps(material3_dict)) + + # TODO convince beartype to allow _ProxyUID as well + # material4_dict = { + # "node": [ + # "Material" + # ], + # "uid": "_:f6d56fdc-9df7-49a1-a843-cf92681932ad", + # "uuid": "f6d56fdc-9df7-49a1-a843-cf92681932ad", + # "name": "my material", + # "property": [ + # { + # "node": [ + # "Property" + # ], + # "uid": "_:82e7270e-9f35-4b35-80a2-faa6e7f670be", + # "uuid": "82e7270e-9f35-4b35-80a2-faa6e7f670be", + # "key": "modulus_shear", + # "type": "value", + # "value": 5.0, + # "unit": "GPa", + # "computation": [ + # { + # "node": [ + # "Computation" + # ], + # "uid": "_:9ddda2c0-ff8c-4ce3-beb0-e0cafb6169ef" + # } + # ] + # }, + # { + # "node": [ + # "Property" + # ], + # "uid": "_:fc4dfa5e-742c-4d0b-bb66-2185461f4582", + # "uuid": "fc4dfa5e-742c-4d0b-bb66-2185461f4582", + # "key": "modulus_loss", + # "type": "value", + # "value": 5.0, + # "unit": "GPa", + # "computation": [ + # { + # "node": [ + # "Computation" + # ], + # "uid": "_:9ddda2c0-ff8c-4ce3-beb0-e0cafb6169ef", + # "uuid": "9ddda2c0-ff8c-4ce3-beb0-e0cafb6169ef", + # "name": "my computation name", + # "type": "analysis", + # "citation": [] + # } + # ] + # } + # ], + # "bigsmiles": "123456" + # } + + # material4 = cript.load_nodes_from_json(json.dumps(material4_dict)) + # assert json.loads(material.json) == json.loads(material4.json) + + +def test_json_error(complex_parameter_node): + parameter = complex_parameter_node + # Let's break the node by violating the data model + parameter._json_attrs = replace(parameter._json_attrs, value="abc") + with pytest.raises(CRIPTNodeSchemaError): + parameter.validate() + # Let's break it completely + parameter._json_attrs = None + with pytest.raises(CRIPTJsonSerializationError): + parameter.json + + +def test_local_search(simple_algorithm_node, complex_parameter_node): + a = simple_algorithm_node + # Check if we can use search to find the algorithm node, but specifying node and key + find_algorithms = a.find_children({"node": "Algorithm", "key": "mc_barostat"}) + assert find_algorithms == [a] + # Check if it correctly exclude the algorithm if key is specified to non-existent value + find_algorithms = a.find_children({"node": "Algorithm", "key": "mc"}) + assert find_algorithms == [] + + # Adding 2 separate parameters to test deeper search + p1 = complex_parameter_node + p2 = copy.deepcopy(complex_parameter_node) + p2.key = "damping_time" + p2.value = 15.0 + p2.unit = "m" + a.parameter += [p1, p2] + + # Test if we can find a specific one of the parameters + find_parameter = a.find_children({"key": "damping_time"}) + assert find_parameter == [p2] + + # Test to find the other parameter + find_parameter = a.find_children({"key": "update_frequency"}) + assert find_parameter == [p1] + + # Test if correctly find no parameter if we are searching for a non-existent parameter + find_parameter = a.find_children({"key": "update"}) + assert find_parameter == [] + + # Test nested search. Here we are looking for any node that has a child node parameter as specified. + find_algorithms = a.find_children({"parameter": {"key": "damping_time"}}) + assert find_algorithms == [a] + # Same as before, but specifying two children that have to be present (AND condition) + find_algorithms = a.find_children({"parameter": [{"key": "damping_time"}, {"key": "update_frequency"}]}) + assert find_algorithms == [a] + + # Test that the main node is correctly excluded if we specify an additionally non-existent parameter + find_algorithms = a.find_children({"parameter": [{"key": "damping_time"}, {"key": "update_frequency"}, {"foo": "bar"}]}) + assert find_algorithms == [] + + +def test_cycles(complex_data_node, simple_computation_node): + # We create a wrong cycle with parameters here. + # TODO replace this with nodes that actually can form a cycle + d = copy.deepcopy(complex_data_node) + c = copy.deepcopy(simple_computation_node) + d.computation += [c] + # Using input and output data guarantees a cycle here. + c.output_data += [d] + c.input_data += [d] + + # # Test the repetition of a citation. + # # Notice that we do not use a deepcopy here, as we want the citation to be the exact same node. + # citation = d.citation[0] + # # c._json_attrs.citation.append(citation) + # c.citation += [citation] + # # print(c.get_json(indent=2).json) + # # c.validate() + + # Generate json with an implicit cycle + c.json + d.json + + +def test_uid_serial(simple_inventory_node): + simple_inventory_node.material += simple_inventory_node.material + json_dict = json.loads(simple_inventory_node.get_json(condense_to_uuid={}).json) + assert len(json_dict["material"]) == 4 + assert isinstance(json_dict["material"][2]["uid"], str) + assert json_dict["material"][2]["uid"].startswith("_:") + assert len(json_dict["material"][2]["uid"]) == len(get_new_uid()) + assert isinstance(json_dict["material"][3]["uid"], str) + assert json_dict["material"][3]["uid"].startswith("_:") + assert len(json_dict["material"][3]["uid"]) == len(get_new_uid()) + assert json_dict["material"][3]["uid"] != json_dict["material"][2]["uid"] + + +def test_invalid_json_load(): + def raise_node_dict(node_dict): + node_str = json.dumps(node_dict) + with pytest.raises(CRIPTJsonNodeError): + cript.load_nodes_from_json(node_str) + + node_dict = {"node": "Computation"} + raise_node_dict(node_dict) + node_dict = {"node": []} + raise_node_dict(node_dict) + node_dict = {"node": ["asdf", "asdf"]} + raise_node_dict(node_dict) + node_dict = {"node": [None]} + raise_node_dict(node_dict) + + +def test_invalid_project_graphs(simple_project_node, simple_material_node, simple_process_node, simple_property_node, simple_data_node, simple_computation_node, simple_computation_process_node): + project = copy.deepcopy(simple_project_node) + process = copy.deepcopy(simple_process_node) + material = copy.deepcopy(simple_material_node) + + ingredient = cript.Ingredient(material=material, quantity=[cript.Quantity(key="mass", value=1.23, unit="kg")]) + process.ingredient += [ingredient] + + # Add the process to the experiment, but not in inventory or materials + # Invalid graph + project.collection[0].experiment[0].process += [process] + with pytest.raises(CRIPTOrphanedMaterialError): + project.validate() + + # First fix add material to inventory + project.collection[0].inventory += [cript.Inventory("test_inventory", material=[material])] + project.validate() + # Reverse this fix + project.collection[0].inventory = [] + with pytest.raises(CRIPTOrphanedMaterialError): + project.validate() + + # Fix by add to the materials list instead. + # Using the util helper function for this. + cript.add_orphaned_nodes_to_project(project, active_experiment=None, max_iteration=10) + project.validate() + + # Now add an orphan process to the graph + process2 = copy.deepcopy(simple_process_node) + process.prerequisite_process += [process2] + with pytest.raises(CRIPTOrphanedProcessError): + project.validate() + + # Wrong fix it helper node + dummy_experiment = copy.deepcopy(project.collection[0].experiment[0]) + with pytest.raises(RuntimeError): + cript.add_orphaned_nodes_to_project(project, dummy_experiment) + # Problem still persists + with pytest.raises(CRIPTOrphanedProcessError): + project.validate() + # Fix by using the helper function correctly + cript.add_orphaned_nodes_to_project(project, project.collection[0].experiment[0], 10) + project.validate() + + # We add property to the material, because that adds the opportunity for orphaned data and computation + property = copy.deepcopy(simple_property_node) + material.property += [property] + project.validate() + # Now add an orphan data + data = copy.deepcopy(simple_data_node) + property.data = [data] + with pytest.raises(CRIPTOrphanedDataError): + project.validate() + # Fix with the helper function + cript.add_orphaned_nodes_to_project(project, project.collection[0].experiment[0], 10) + project.validate() + + # Add an orphan Computation + computation = copy.deepcopy(simple_computation_node) + property.computation += [computation] + with pytest.raises(CRIPTOrphanedComputationError): + project.validate() + # Fix with the helper function + cript.add_orphaned_nodes_to_project(project, project.collection[0].experiment[0], 10) + project.validate() + + # Add orphan computational process + comp_proc = copy.deepcopy(simple_computation_process_node) + data.computation_process += [comp_proc] + with pytest.raises(CRIPTOrphanedComputationalProcessError): + while True: + try: # Do trigger not orphan materials + project.validate() + except CRIPTOrphanedMaterialError as exc: + project._json_attrs.material.append(exc.orphaned_node) + except CRIPTOrphanedProcessError as exc: + project.collection[0].experiment[0]._json_attrs.process.append(exc.orphaned_node) + else: + break + + cript.add_orphaned_nodes_to_project(project, project.collection[0].experiment[0], 10) + project.validate() diff --git a/tests/util.py b/tests/util.py new file mode 100644 index 000000000..23f056e98 --- /dev/null +++ b/tests/util.py @@ -0,0 +1,21 @@ +import copy + + +def strip_uid_from_dict(node_dict): + """ + Remove "uid" attributes from nested dictionaries. + Helpful for test purposes, since uids are always going to differ. + """ + node_dict_copy = copy.deepcopy(node_dict) + for key in node_dict: + if key in ("uid", "uuid"): + del node_dict_copy[key] + if isinstance(node_dict, str): + continue + if isinstance(node_dict[key], dict): + node_dict_copy[key] = strip_uid_from_dict(node_dict[key]) + elif isinstance(node_dict[key], list): + for i, element in enumerate(node_dict[key]): + if isinstance(element, dict): + node_dict_copy[key][i] = strip_uid_from_dict(element) + return node_dict_copy diff --git a/trunk b/trunk new file mode 100755 index 000000000..7c1bf72af --- /dev/null +++ b/trunk @@ -0,0 +1,442 @@ +#!/bin/bash + +############################################################################### +# # +# Setup # +# # +############################################################################### + +set -euo pipefail + +readonly TRUNK_LAUNCHER_VERSION="1.2.5" # warning: this line is auto-updated + +readonly SUCCESS_MARK="\033[0;32m✔\033[0m" +readonly FAIL_MARK="\033[0;31m✘\033[0m" +readonly PROGRESS_MARKS=("⡿" "⢿" "⣻" "⣽" "⣾" "⣷" "⣯" "⣟") + +# This is how mktemp(1) decides where to create stuff in tmpfs. +readonly TMPDIR="${TMPDIR:-/tmp}" + +KERNEL=$(uname | tr "[:upper:]" "[:lower:]") +if [[ ${KERNEL} == mingw64* || ${KERNEL} == msys* ]]; then + KERNEL="mingw" +fi +readonly KERNEL + +MACHINE=$(uname -m) +readonly MACHINE + +PLATFORM="${KERNEL}-${MACHINE}" +readonly PLATFORM + +PLATFORM_UNDERSCORE="${KERNEL}_${MACHINE}" +readonly PLATFORM_UNDERSCORE + +# https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_(Control_Sequence_Introducer)_sequences +# [nF is "cursor previous line" and moves to the beginning of the nth previous line +# [0K is "erase display" and clears from the cursor to the end of the screen +readonly CLEAR_LAST_MSG="\033[1F\033[0K" + +if [[ ! -z ${CI:-} && "${CI}" = true && -z ${TRUNK_LAUNCHER_QUIET:-} ]]; then + TRUNK_LAUNCHER_QUIET=1 +else + TRUNK_LAUNCHER_QUIET=${TRUNK_LAUNCHER_QUIET:-${TRUNK_QUIET:-false}} +fi + +readonly TRUNK_LAUNCHER_DEBUG + +if [[ ${TRUNK_LAUNCHER_QUIET} != false ]]; then + exec 3>&1 4>&2 &>/dev/null +fi + +TRUNK_CACHE="${TRUNK_CACHE:-}" +if [[ -n ${TRUNK_CACHE} ]]; then + : +elif [[ -n ${XDG_CACHE_HOME:-} ]]; then + TRUNK_CACHE="${XDG_CACHE_HOME}/trunk" +else + TRUNK_CACHE="${HOME}/.cache/trunk" +fi +readonly TRUNK_CACHE +readonly CLI_DIR="${TRUNK_CACHE}/cli" +mkdir -p "${CLI_DIR}" + +# platform check +readonly MINIMUM_MACOS_VERSION="10.15" +check_darwin_version() { + local osx_version + osx_version="$(sw_vers -productVersion)" + + # trunk-ignore-begin(shellcheck/SC2312): the == will fail if anything inside the $() fails + if [[ "$(printf "%s\n%s\n" "${MINIMUM_MACOS_VERSION}" "${osx_version}" | + sort --version-sort | + head -n 1)" == "${MINIMUM_MACOS_VERSION}"* ]]; then + return + fi + # trunk-ignore-end(shellcheck/SC2312) + + echo -e "${FAIL_MARK} Trunk requires at least MacOS ${MINIMUM_MACOS_VERSION}" \ + "(yours is ${osx_version}). See https://docs.trunk.io for more info." + exit 1 +} + +if [[ ${PLATFORM} == "darwin-x86_64" || ${PLATFORM} == "darwin-arm64" ]]; then + check_darwin_version +elif [[ ${PLATFORM} == "linux-x86_64" || ${PLATFORM} == "windows-x86_64" || ${PLATFORM} == "mingw-x86_64" ]]; then + : +else + echo -e "${FAIL_MARK} Trunk is only supported on Linux (x64_64), MacOS (x86_64, arm64), and Windows (x86_64)." \ + "See https://docs.trunk.io for more info." + exit 1 +fi + +TRUNK_TMPDIR="${TMPDIR}/trunk-$( + set -e + id -u +)/launcher_logs" +readonly TRUNK_TMPDIR +mkdir -p "${TRUNK_TMPDIR}" + +# For the `mv $TOOL_TMPDIR/trunk $TOOL_DIR` to be atomic (i.e. just inode renames), the source and destination filesystems need to be the same +TOOL_TMPDIR=$(mktemp -d "${CLI_DIR}/tmp.XXXXXXXXXX") +readonly TOOL_TMPDIR + +cleanup() { + rm -rf "${TOOL_TMPDIR}" + if [[ $1 == "0" ]]; then + rm -rf "${TRUNK_TMPDIR}" + fi +} +trap 'cleanup $?' EXIT + +# e.g. 2022-02-16-20-40-31-0800 +dt_str() { date +"%Y-%m-%d-%H-%M-%S%z"; } + +LAUNCHER_TMPDIR="${TOOL_TMPDIR}/launcher" +readonly LAUNCHER_TMPDIR +mkdir -p "${LAUNCHER_TMPDIR}" + +if [[ -n ${TRUNK_LAUNCHER_DEBUG:-} ]]; then + set -x +fi + +# launcher awk +# +# BEGIN{ORS="";} +# use "" as the output record separator +# ORS defaults to "\n" for bwk, which results in +# $(printf "foo bar" | awk '{print $2}') == "bar\n" +# +# {gsub(/\r/, "", $0)} +# for every input record (i.e. line), the regex "\r" should be replaced with "" +# This is necessary to handle CRLF files in a portable fashion. +# +# Some StackOverflow answers suggest using RS="\r?\n" to handle CRLF files (RS is the record +# separator, i.e. the line delimiter); unfortunately, original-awk only allows single-character +# values for RS (see https://www.gnu.org/software/gawk/manual/gawk.html#awk-split-records). +lawk() { + awk 'BEGIN{ORS="";}{gsub(/\r/, "", $0)}'"${1}" "${@:2}" +} +awk_test() { + # trunk-ignore-begin(shellcheck/SC2310,shellcheck/SC2312) + # SC2310 and SC2312 are about set -e not propagating to the $(); if that happens, the string + # comparison will fail and we'll claim the user's awk doesn't work + if [[ $( + set -e + printf 'k1: v1\n \tk2: v2\r\n' | lawk '/[ \t]+k2:/{print $2}' + ) == 'v2' && + $( + set -e + printf 'k1: v1\r\n\t k2: v2\r\n' | lawk '/[ \t]+k2:/{print $2}' + ) == 'v2' ]]; then + return + fi + # trunk-ignore-end(shellcheck/SC2310,shellcheck/SC2312) + + echo -e "${FAIL_MARK} Trunk does not work with your awk;" \ + "please report this at https://slack.trunk.io." + echo -e "Your version of awk is:" + awk --version || awk -Wversion + exit 1 +} +awk_test + +readonly CURL_FLAGS="${CURL_FLAGS:- -vvv --max-time 120 --retry 3 --fail}" +readonly WGET_FLAGS="${WGET_FLAGS:- --verbose --tries=3 --limit-rate=10M}" +TMP_DOWNLOAD_LOG="${TRUNK_TMPDIR}/download-$( + set -e + dt_str +).log" +readonly TMP_DOWNLOAD_LOG + +# Detect whether we should use wget or curl. +if command -v wget &>/dev/null; then + download_cmd() { + local url="${1}" + local output_to="${2}" + # trunk-ignore-begin(shellcheck/SC2312): we don't care if wget --version errors + cat >>"${TMP_DOWNLOAD_LOG}" <&1) + +EOF + # trunk-ignore-end(shellcheck/SC2312) + + # Support BusyBox wget + if wget --help 2>&1 | grep BusyBox; then + wget "${url}" -O "${output_to}" 2>>"${TMP_DOWNLOAD_LOG}" & + else + # trunk-ignore(shellcheck/SC2086): we deliberately don't quote WGET_FLAGS + wget ${WGET_FLAGS} "${url}" --output-document "${output_to}" 2>>"${TMP_DOWNLOAD_LOG}" & + fi + } +elif command -v curl &>/dev/null; then + download_cmd() { + local url="${1}" + local output_to="${2}" + # trunk-ignore-begin(shellcheck/SC2312): we don't care if curl --version errors + cat >>"${TMP_DOWNLOAD_LOG}" <>"${TMP_DOWNLOAD_LOG}" & + } +else + download_cmd() { + echo -e "${FAIL_MARK} Cannot download '${url}'; please install curl or wget." + exit 1 + } +fi + +download_url() { + local url="${1}" + local output_to="${2}" + local progress_message="${3:-}" + + if [[ -n ${progress_message} ]]; then + echo -e "${PROGRESS_MARKS[0]} ${progress_message}..." + fi + + download_cmd "${url}" "${output_to}" + local download_pid="$!" + + local i_prog=0 + while [[ -d "/proc/${download_pid}" && -n ${progress_message} ]]; do + echo -e "${CLEAR_LAST_MSG}${PROGRESS_MARKS[${i_prog}]} ${progress_message}..." + sleep 0.2 + i_prog=$(((i_prog + 1) % ${#PROGRESS_MARKS[@]})) + done + + local download_log + if ! wait "${download_pid}"; then + download_log="${TRUNK_TMPDIR}/launcher-download-$( + set -e + dt_str + ).log" + mv "${TMP_DOWNLOAD_LOG}" "${download_log}" + echo -e "${CLEAR_LAST_MSG}${FAIL_MARK} ${progress_message}... FAILED (see ${download_log})" + echo -e "Please check your connection and try again." \ + "If you continue to see this error message," \ + "consider reporting it to us at https://slack.trunk.io." + exit 1 + fi + + if [[ -n ${progress_message} ]]; then + echo -e "${CLEAR_LAST_MSG}${SUCCESS_MARK} ${progress_message}... done" + fi + +} + +# sha256sum is in coreutils, so we prefer that over shasum, which is installed with perl +if command -v sha256sum &>/dev/null; then + : +elif command -v shasum &>/dev/null; then + sha256sum() { shasum -a 256 "$@"; } +else + sha256sum() { + echo -e "${FAIL_MARK} Cannot compute sha256; please install sha256sum or shasum" + exit 1 + } +fi + +############################################################################### +# # +# CLI resolution functions # +# # +############################################################################### + +trunk_yaml_abspath() { + local repo_head + local cwd + + if repo_head=$(git rev-parse --show-toplevel 2>/dev/null); then + echo "${repo_head}/.trunk/trunk.yaml" + elif [[ -f .trunk/trunk.yaml ]]; then + cwd="$(pwd)" + echo "${cwd}/.trunk/trunk.yaml" + else + echo "" + fi +} + +read_cli_version_from() { + local config_abspath="${1}" + local cli_version + + cli_version="$( + set -e + lawk '/[ \t]+version:/{print $2; exit;}' "${config_abspath}" + )" + if [[ -z ${cli_version} ]]; then + echo -e "${FAIL_MARK} Invalid .trunk/trunk.yaml, no cli version found." \ + "See https://docs.trunk.io for more info." >&2 + exit 1 + fi + + echo "${cli_version}" +} + +download_cli() { + local dl_version="${1}" + local expected_sha256="${2}" + local actual_sha256 + + readonly TMP_INSTALL_DIR="${LAUNCHER_TMPDIR}/install" + mkdir -p "${TMP_INSTALL_DIR}" + + TRUNK_NEW_URL_VERSION=0.10.2-beta.1 + if sort --help 2>&1 | grep BusyBox; then + readonly URL="https://trunk.io/releases/${dl_version}/trunk-${dl_version}-${PLATFORM}.tar.gz" + else + if [[ "$(printf "%s\n%s\n" "${TRUNK_NEW_URL_VERSION}" "${dl_version}" | + sort --version-sort | + head -n 1 || true)" == "${TRUNK_NEW_URL_VERSION}"* ]]; then + readonly URL="https://trunk.io/releases/${dl_version}/trunk-${dl_version}-${PLATFORM}.tar.gz" + else + readonly URL="https://trunk.io/releases/trunk-${dl_version}.${KERNEL}.tar.gz" + fi + fi + + readonly DOWNLOAD_TAR_GZ="${TMP_INSTALL_DIR}/download-${dl_version}.tar.gz" + + download_url "${URL}" "${DOWNLOAD_TAR_GZ}" "Downloading Trunk ${dl_version}" + + if [[ -n ${expected_sha256:-} ]]; then + local verifying_text="Verifying Trunk sha256..." + echo -e "${PROGRESS_MARKS[0]} ${verifying_text}" + + actual_sha256="$( + set -e + sha256sum "${DOWNLOAD_TAR_GZ}" | lawk '{print $1}' + )" + + if [[ ${actual_sha256} != "${expected_sha256}" ]]; then + echo -e "${CLEAR_LAST_MSG}${FAIL_MARK} ${verifying_text} FAILED" + echo "Expected sha256: ${expected_sha256}" + echo " Actual sha256: ${actual_sha256}" + exit 1 + fi + + echo -e "${CLEAR_LAST_MSG}${SUCCESS_MARK} ${verifying_text} done" + fi + + local unpacking_text="Unpacking Trunk..." + echo -e "${PROGRESS_MARKS[0]} ${unpacking_text}" + tar --strip-components=1 -C "${TMP_INSTALL_DIR}" -xf "${DOWNLOAD_TAR_GZ}" + echo -e "${CLEAR_LAST_MSG}${SUCCESS_MARK} ${unpacking_text} done" + + rm -f "${DOWNLOAD_TAR_GZ}" + mkdir -p "${TOOL_DIR}" + readonly OLD_TOOL_DIR="${CLI_DIR}/${version}" + # Create a backwards compatability link for old versions of trunk that want to write their + # crashpad_handlers to that dir. + if [[ ! -e ${OLD_TOOL_DIR} ]]; then + ln -sf "${TOOL_PART}" "${OLD_TOOL_DIR}" + fi + mv -n "${TMP_INSTALL_DIR}/trunk" "${TOOL_DIR}/" || true + rm -rf "${TMP_INSTALL_DIR}" +} + +############################################################################### +# # +# CLI resolution # +# # +############################################################################### + +CONFIG_ABSPATH="$( + set -e + trunk_yaml_abspath +)" +readonly CONFIG_ABSPATH + +version="${TRUNK_CLI_VERSION:-}" +if [[ -n ${version:-} ]]; then + : +elif [[ -f ${CONFIG_ABSPATH} ]]; then + version="$( + set -e + read_cli_version_from "${CONFIG_ABSPATH}" + )" + version_sha256="$( + set -e + lawk "/${PLATFORM_UNDERSCORE}:/"'{print $2}' "${CONFIG_ABSPATH}" + )" +else + readonly LATEST_FILE="${LAUNCHER_TMPDIR}/latest" + download_url "https://trunk.io/releases/latest" "${LATEST_FILE}" + version=$( + set -e + lawk '/version:/{print $2}' "${LATEST_FILE}" + ) + version_sha256=$( + set -e + lawk "/${PLATFORM_UNDERSCORE}:/"'{print $2}' "${LATEST_FILE}" + ) +fi + +readonly TOOL_PART="${version}-${PLATFORM}" +readonly TOOL_DIR="${CLI_DIR}/${TOOL_PART}" + +if [[ ! -e ${TOOL_DIR}/trunk ]]; then + download_cli "${version}" "${version_sha256:-}" + echo # add newline between launcher and CLI output +fi + +if [[ ${TRUNK_LAUNCHER_QUIET} != false ]]; then + exec 1>&3 3>&- 2>&4 4>&- +fi + +############################################################################### +# # +# CLI invocation # +# # +############################################################################### + +if [[ -n ${LATEST_FILE:-} ]]; then + mv -n "${LATEST_FILE}" "${TOOL_DIR}/version" >/dev/null 2>&1 || true +fi + +# NOTE: exec will overwrite the process image, so trap will not catch the exit signal. +# Therefore, run cleanup manually here. +cleanup 0 + +exec \ + env TRUNK_LAUNCHER_VERSION="${TRUNK_LAUNCHER_VERSION}" \ + env TRUNK_LAUNCHER_PATH="${BASH_SOURCE[0]}" \ + "${TOOL_DIR}/trunk" "$@"