├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── pull_request_template.md └── workflows │ ├── python-app.yml │ └── python-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── build.sh ├── examples ├── multiple_specs.py ├── setup.py └── single_spec.py ├── package.sh ├── pyproject.toml ├── src └── maple │ ├── __init__.py │ ├── base_extensions.py │ ├── models.py │ ├── ops.py │ ├── report.py │ ├── templates │ └── report.html │ └── utils.py └── tests ├── .env.example ├── test_multiple.py ├── test_ops.py ├── test_report.py └── test_run.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[bug]" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. As a user I would like to [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Reference to related issue 2 | 3 | Related Issue # 4 | 5 | ### What was added/changed? 6 | 7 | ### Why was it added/changed? 8 | 9 | ### Technical implementation 10 | 11 | ### Acceptance criteria 12 | 13 | - [ ] ... 14 | 15 | ### Checklist before requesting review 16 | 17 | - [ ] Documentation was expanded 18 | - [ ] Acceptance criteria are met 19 | - [ ] The function was tested by unit tests 20 | 21 | ### Checklist for reviewers 22 | 23 | - [ ] Code checked 24 | - [ ] Acceptance criteria are met 25 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python 3.11 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: "3.11" 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install . 30 | pip install pytest python-dotenv 31 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | - name: Test with pytest 33 | env: 34 | SPECKLE_TOKEN: ${{secrets.SPECKLE_TOKEN}}} 35 | SPECKLE_HOST: https://latest.speckle.systems 36 | run: pytest 37 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 3 | 4 | # This workflow uses actions that are not certified by GitHub. 5 | # They are provided by a third-party and are governed by 6 | # separate terms of service, privacy policy, and support 7 | # documentation. 8 | 9 | name: Upload Python Package 10 | 11 | on: 12 | release: 13 | types: [published] 14 | 15 | permissions: 16 | contents: read 17 | 18 | jobs: 19 | deploy: 20 | 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | - name: Set up Python 26 | uses: actions/setup-python@v3 27 | with: 28 | python-version: '3.x' 29 | - name: Install dependencies 30 | run: | 31 | python -m pip install --upgrade pip 32 | pip install build 33 | - name: Build package 34 | run: python -m build 35 | - name: Publish package 36 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 37 | with: 38 | user: __token__ 39 | password: ${{ secrets.PYPI_API_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | maple_report*.html 2 | /tests/.gitkeep 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # poetry 100 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 101 | # This is especially recommended for binary packages to ensure reproducibility, and is more 102 | # commonly ignored for libraries. 103 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 104 | #poetry.lock 105 | 106 | # pdm 107 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 108 | #pdm.lock 109 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 110 | # in version control. 111 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 112 | .pdm.toml 113 | .pdm-python 114 | .pdm-build/ 115 | 116 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 117 | __pypackages__/ 118 | 119 | # Celery stuff 120 | celerybeat-schedule 121 | celerybeat.pid 122 | 123 | # SageMath parsed files 124 | *.sage.py 125 | 126 | # Environments 127 | .env 128 | .venv 129 | env/ 130 | venv/ 131 | ENV/ 132 | env.bak/ 133 | venv.bak/ 134 | 135 | # Spyder project settings 136 | .spyderproject 137 | .spyproject 138 | 139 | # Rope project settings 140 | .ropeproject 141 | 142 | # mkdocs documentation 143 | /site 144 | 145 | # mypy 146 | .mypy_cache/ 147 | .dmypy.json 148 | dmypy.json 149 | 150 | # Pyre type checker 151 | .pyre/ 152 | 153 | # pytype static type analyzer 154 | .pytype/ 155 | 156 | # Cython debug symbols 157 | cython_debug/ 158 | 159 | # PyCharm 160 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 161 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 162 | # and can be added to the global gitignore or merged into this file. For a more nuclear 163 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 164 | #.idea/ 165 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 Andres Buitrago 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Maple 2 | 3 | ## Automate your model Quality Check with Speckle and Maple 4 | 5 | ### About 6 | 7 | Maple is a library designed to write simple code that can check 8 | different attributes of a Model in Speckle. 9 | 10 | Using Maple you can write `test specs` that check any parameter 11 | or quantity inside the project model. 12 | 13 | Maple can be integrated into [Speckle Automate](https://speckle.systems/blog/automate-with-speckle/) 14 | to run the quality check tests on a continuous integration and ensure project standards. 15 | See [Maple-Automate-CI](https://github.com/Gizemdem/Mapple-CI-Pipeline) to check the full implementation of maple in Speckle Automate. 16 | 17 | ### Get started 18 | 19 | For a more detailed guide check out [Getting started](https://github.com/andrsbtrg/maple/wiki/Getting-Started) 20 | 21 | Install the library from PyPi 22 | 23 | ```sh 24 | pip install maple-spec 25 | ``` 26 | 27 | Then, create your `main.py` to test your specs locally 28 | 29 | ```py 30 | # main.py 31 | import maple as mp 32 | 33 | def spec_a(): 34 | mp.it("checks window height is greater than 2600 mm") 35 | 36 | mp.get('category', 'Windows')\ 37 | .where('speckle_type', 38 | 'Objects.Other.Instance:Objects.Other.Revit.RevitInstance')\ 39 | .its('Height')\ 40 | .should('be.greater', 2600) 41 | 42 | # Use the project and model id of one of your projects 43 | mp.init_model(project_id="24fa0ed1c3", model_id="2696b4a381") 44 | mp.run(spec_a) 45 | ``` 46 | For this to work out of the box, you should have the [Speckle Manager](https://speckle.systems/download/) 47 | installed and your account set-up, so Maple can fetch the data from your `stream`. 48 | 49 | If not, alternatively you can set an environment variable called `SPECKLE_TOKEN` with a Speckle token that can read from streams, for example: 50 | 51 | ```sh 52 | SPECKLE_TOKEN="your-secret-token" 53 | ``` 54 | 55 | Finally run the file with python like so: 56 | 57 | ```sh 58 | python main.py 59 | ``` 60 | 61 | ## Development guide 62 | 63 | Create a development virtual environment: 64 | 65 | ```sh 66 | python -m venv venv 67 | source ./venv/bin/activate 68 | ``` 69 | 70 | Install the dev dependencies 71 | 72 | ```sh 73 | pip install pytest 74 | ``` 75 | 76 | ### Testing 77 | 78 | Run `pytest` 79 | 80 | ### Building 81 | 82 | Ensure `build` is installed: 83 | 84 | ```sh 85 | python -m pip install --upgrade build 86 | ``` 87 | 88 | To build a wheel run: 89 | 90 | ```sh 91 | python -m build 92 | ``` 93 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | # test 2 | source ./tests/.env 3 | echo 'Testing with pytest' 4 | pytest 5 | -------------------------------------------------------------------------------- /examples/multiple_specs.py: -------------------------------------------------------------------------------- 1 | import setup # noqa 2 | import maple as mp 3 | 4 | 5 | def main(): 6 | stream_id = "24fa0ed1c3" 7 | mp.init_model(project_id=stream_id, model_id="2696b4a381") 8 | mp.set_logging(True) 9 | mp.run(spec_a, spec_b, spec_c, spec_d, spec_e, spec_f, spec_g) 10 | mp.generate_report("/tmp/") 11 | 12 | 13 | def spec_a(): 14 | mp.it("checks window height is greater than 2600 mm") 15 | 16 | mp.get("category", "Windows").where( 17 | "speckle_type", "Objects.Other.Instance:Objects.Other.Revit.RevitInstance" 18 | ).its("Height").should("be.greater", 2600) 19 | 20 | 21 | def spec_b(): 22 | mp.it("validates SIP 202mm wall type area is greater than 43 m2") 23 | 24 | mp.get("family", "Basic Wall").where("type", "SIP 202mm Wall - conc clad").its( 25 | "Area" 26 | ).should("be.greater", 43) 27 | 28 | 29 | def spec_c(): 30 | mp.it("checks pipe radius") 31 | 32 | mp.get("category", "Plumbing Fixtures").its("OmniClass Title").should( 33 | "have.value", "Bathtubs" 34 | ) 35 | 36 | 37 | def spec_d(): 38 | mp.it("validates basic roof`s thermal mass") 39 | 40 | mp.get("family", "Basic Roof").where("type", "SG Metal Panels roof").its( 41 | "Thermal Mass" 42 | ).should("be.equal", 20.512) 43 | 44 | 45 | def spec_e(): 46 | mp.it("validates columns assembly type.") 47 | 48 | mp.get("family", "M_Concrete-Round-Column with Drop Caps").its( 49 | "Assembly Code" 50 | ).should("have.value", "B10") 51 | 52 | 53 | def spec_f(): 54 | mp.it("validates ceiling thickness is 50") 55 | 56 | mp.get("category", "Ceilings").where("type", "3000 x 3000mm Grid").its( 57 | "Absorptance" 58 | ).should("be.equal", 0.1) 59 | 60 | 61 | def spec_g(): 62 | mp.it("Checks there are exactly 55 walls") 63 | 64 | mp.get("category", "Walls").should("have.length", 55) 65 | 66 | 67 | if __name__ == "__main__": 68 | main() 69 | -------------------------------------------------------------------------------- /examples/setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Development hack to make the path of the library 3 | available to the examples, usefil while developing 4 | Note: Import this before maple 5 | """ 6 | 7 | import sys 8 | from pathlib import Path 9 | 10 | path_root = Path(__file__).parents[1] 11 | sys.path.append(str(path_root) + "/src") 12 | -------------------------------------------------------------------------------- /examples/single_spec.py: -------------------------------------------------------------------------------- 1 | import setup # noqa 2 | import maple as mp 3 | 4 | 5 | def main(): 6 | mp.init_model(project_id="1471fed2c0", model_id="53db0711db") 7 | mp.set_logging(True) 8 | mp.run(test_check_door_height) 9 | mp.generate_report(output_path="/tmp/") 10 | 11 | 12 | def test_check_door_height(): 13 | mp.it("Checks that the door height its at least 2.0 m") 14 | 15 | mp.get("type", "IFCDOOR").its("OverallHeight").should("be.greater", 2.0) 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /package.sh: -------------------------------------------------------------------------------- 1 | python -m build 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0", "setuptools-scm>=8.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "maple-spec" 7 | version = "0.0.10" 8 | authors = [ 9 | { name="Gizem Demirhan", email="gizemdemirhaan@gmail.com" }, 10 | { name="Andres Buitrago", email="andrsbtrg@gmail.com" } 11 | ] 12 | description = "A testing library for Speckle models" 13 | readme = "README.md" 14 | requires-python = ">=3.8" 15 | dependencies = ["specklepy", "importlib-metadata", "jinja2", "python-dotenv"] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "Programming Language :: Python :: 3", 19 | "License :: OSI Approved :: Apache Software License", 20 | "Operating System :: OS Independent" 21 | ] 22 | 23 | [project.urls] 24 | Homepage = "https://github.com/andrsbtrg/maple" 25 | Issues = "https://github.com/andrsbtrg/maple/issues" 26 | 27 | [tool.pytest.ini_options] 28 | pythonpath = [ 29 | "src" 30 | ] 31 | 32 | [tool.setuptools.packages.find] 33 | where = ["src"] 34 | 35 | [tool.setuptools.package-data] 36 | mypkg = ["*.html"] 37 | -------------------------------------------------------------------------------- /src/maple/__init__.py: -------------------------------------------------------------------------------- 1 | from deprecated import deprecated 2 | from typing_extensions import Self, Callable 3 | from typing import Any 4 | from specklepy.api.client import Account, SpeckleClient 5 | from specklepy.api.credentials import get_default_account 6 | from specklepy.api import operations 7 | from specklepy.transports.server.server import ServerTransport 8 | from specklepy.objects import Base 9 | from specklepy.core.api.models import Branch 10 | from os import getenv 11 | 12 | # maple imports 13 | from .base_extensions import flatten_base 14 | from .utils import print_results 15 | from .ops import ComparisonOps, property_equal, CompOp 16 | from .models import Result, Assertion 17 | from .report import HtmlReport 18 | 19 | 20 | # GLOBALS 21 | # TODO: Refactor to remove globals 22 | _test_cases: list[Result] = [] # Contains the results of the runs 23 | _current_object: Base | None = None 24 | _stream_id: str = "" 25 | _model_id: str = "" 26 | _log_out: bool = True 27 | 28 | 29 | def init(obj: Base) -> None: 30 | """ 31 | Caches the speckle object obj in the global _current_object 32 | so it can be reused in the next tests 33 | 34 | Args: 35 | obj: a speckle Base object 36 | 37 | Returns: None 38 | """ 39 | global _current_object 40 | _current_object = obj 41 | return 42 | 43 | 44 | def set_logging(f: bool) -> None: 45 | """ 46 | Set log to std out. Default is True 47 | 48 | Args: 49 | f: bool 50 | """ 51 | global _log_out 52 | _log_out = f 53 | 54 | 55 | @deprecated( 56 | reason="Starting with maple 0.1 please use mp.init_model to specify a project and model id." 57 | ) 58 | def stream(id: str) -> None: 59 | """ 60 | Sets the current stream_id to be used to query the base object 61 | 62 | Args: 63 | id: a speckle stream (project) id 64 | 65 | Returns: None 66 | """ 67 | global _stream_id 68 | _stream_id = id 69 | global _current_object 70 | _current_object = None 71 | return 72 | 73 | 74 | def init_model(project_id: str, model_id: str) -> None: 75 | """ 76 | Sets the global variables project id and model id for the 77 | current test until reset. 78 | 79 | 80 | Args: 81 | project_id: a project id 82 | model_id: the model id to test 83 | 84 | """ 85 | # set the model and project id 86 | global _stream_id 87 | _stream_id = project_id 88 | global _model_id 89 | _model_id = model_id 90 | # clear the current object 91 | global _current_object 92 | _current_object = None 93 | # clear previous test runs 94 | global _test_cases 95 | _test_cases.clear() 96 | return 97 | 98 | 99 | def get_token() -> str | None: 100 | """ 101 | Get the token to authenticate with Speckle. 102 | The token should be under the env variable 'SPECKLE_TOKEN' 103 | """ 104 | 105 | token = getenv("SPECKLE_TOKEN") 106 | return token 107 | 108 | 109 | def get_stream_id() -> str: 110 | """ 111 | Gets the stream_id provided with mp.stream() 112 | """ 113 | global _stream_id 114 | if _stream_id == "": 115 | raise Exception("Please provide a Stream id using mp.stream()") 116 | return _stream_id 117 | 118 | 119 | def get_model_id() -> str: 120 | """ 121 | Gets the Model id provided with mp.init_model 122 | """ 123 | global _model_id 124 | if _model_id == "": 125 | raise Exception("Please provide a Model Id to test using mp.init_model()") 126 | return _model_id 127 | 128 | 129 | def get_current_obj() -> Base | None: 130 | """ 131 | Get the current object specified with mp.init() 132 | """ 133 | global _current_object 134 | return _current_object 135 | 136 | 137 | def get_current_test_case() -> Result | None: 138 | """ 139 | Get the current test case 140 | """ 141 | global _test_cases 142 | if len(_test_cases) < 1: 143 | return None 144 | current = _test_cases[-1] 145 | return current 146 | 147 | 148 | def get_test_cases() -> list[Result]: 149 | """ 150 | Gets the list of Test Cases 151 | """ 152 | global _test_cases 153 | return _test_cases 154 | 155 | 156 | # endof GLOBALS 157 | 158 | 159 | class Chainable: 160 | def __init__(self, data): 161 | self.content = data 162 | self.selector = "" 163 | self.assertion: Assertion = Assertion() 164 | 165 | def _select_parameters_values(self, parameter_name: str) -> list[Any]: 166 | """ 167 | Gets a list of the values of each object in self.content 168 | where the parameter_name matches 169 | 170 | Args: 171 | parameter_name: 172 | 173 | Returns: a list of the value of the parameter matching 174 | 175 | Raises: 176 | AttributeError: 177 | 178 | """ 179 | parameter_values = [] 180 | objs = self.content 181 | # check on base object 182 | for obj in objs: 183 | prop = getattr(obj, parameter_name, None) 184 | if not prop: 185 | break 186 | parameter_values.append(prop) 187 | 188 | if len(parameter_values) > 0: 189 | return parameter_values 190 | 191 | # check in parameters 192 | for obj in objs: 193 | parameters = getattr(obj, "parameters") 194 | if parameters is None: 195 | raise AttributeError("no parameters") 196 | params = [ 197 | a 198 | for a in dir(parameters) 199 | if not a.startswith("_") and not callable(getattr(parameters, a)) 200 | ] 201 | for p in params: 202 | attr = getattr(parameters, p) 203 | if hasattr(attr, "name"): 204 | if getattr(parameters, p)["name"] == parameter_name: 205 | parameter_values.append(attr.value) 206 | return parameter_values 207 | 208 | def _should_have_length(self, length: int) -> Self: 209 | """ 210 | Use to check wether the content has length equal to length 211 | 212 | Args: 213 | length (int): length to compare 214 | 215 | """ 216 | objs = self.content 217 | self.assertion.selector = "Collection" 218 | if len(objs) == length: 219 | self.assertion.set_passed("have.length") 220 | else: 221 | self.assertion.set_failed("have.length") 222 | current = get_current_test_case() 223 | if current is None: 224 | raise Exception("Expected current test case not to be None") 225 | current.assertions.append(self.assertion) 226 | return self 227 | 228 | def _should_have_param_value(self, comparer: CompOp, assertion_value: Any) -> Self: 229 | """ 230 | Using the comparer will get the parameter given by the self.selector 231 | for each object and compare each one against assertion_value 232 | 233 | Args: 234 | comparer: CompOp 235 | assertion_value: any value to compare 236 | 237 | Returns: Chainable 238 | """ 239 | selected_values = self._select_parameters_values(self.selector) 240 | 241 | objs = self.content 242 | 243 | # store results in the last Results in test_cases 244 | for i, param_value in enumerate(selected_values): 245 | if comparer.evaluate(param_value, assertion_value): 246 | self.assertion.set_passed(objs[i].id) 247 | else: 248 | self.assertion.set_failed(objs[i].id) 249 | 250 | current = get_current_test_case() 251 | if current is None: 252 | raise Exception("Expected current test case not to be None") 253 | current.assertions.append(self.assertion) 254 | 255 | return self 256 | 257 | def should(self, comparer: ComparisonOps, assertion_value) -> Self: 258 | """ 259 | Assert something inside the Chainable 260 | Args: 261 | comparer: one of CompOp possible enum values 262 | assertion_value: value to assert 263 | Raises: ValueError if comparer is not a defined CompOp 264 | Returns: Chainable 265 | """ 266 | log_to_stdout("Asserting - should:", comparer, assertion_value) 267 | comparer_op = CompOp(comparer) 268 | self.assertion.value = assertion_value 269 | self.assertion.comparer = comparer_op 270 | 271 | if comparer_op == CompOp.HAVE_LENGTH: 272 | return self._should_have_length(assertion_value) 273 | else: 274 | return self._should_have_param_value(comparer_op, assertion_value) 275 | 276 | def its(self, property: str) -> Self: 277 | """ 278 | Selector of a parameter inside the Chainable object 279 | 280 | Args: 281 | property: name of parameter to select from content 282 | 283 | Returns: Chainable 284 | 285 | Raises: 286 | AttributeError: if the parameter name does not match in the 287 | inner object selected with get 288 | """ 289 | log_to_stdout("Selecting", property) 290 | self.selector = property 291 | self.assertion.selector = property 292 | 293 | objs = self.content 294 | # check on base object 295 | for obj in objs: 296 | props = getattr(obj, property, None) 297 | if not props: 298 | break 299 | return self 300 | 301 | # check inside parameters 302 | for obj in objs: 303 | parameters = getattr(obj, "parameters") 304 | if parameters is None: 305 | raise AttributeError("no parameters") 306 | params = [ 307 | a 308 | for a in dir(parameters) 309 | if not a.startswith("_") and not callable(getattr(parameters, a)) 310 | ] 311 | found = False 312 | for p in params: 313 | attr = getattr(parameters, p) 314 | if hasattr(attr, "name"): 315 | if getattr(parameters, p)["name"] == self.selector: 316 | found = True 317 | if not found: 318 | self.assertion.set_failed(obj.id) 319 | return self 320 | 321 | def where(self, selector: str, value: str) -> Self: 322 | """ 323 | Filters the current Speckle objects aquired by mp.get() 324 | where the object's own property 'selector' is equal to 'value' 325 | Args: 326 | selector: The name of a property of a Speckle Object to select 327 | e.g: type 328 | value: The name of the value of the property to be filtered 329 | 330 | Returns: Chainable 331 | """ 332 | log_to_stdout("Filtering by:", selector, value) 333 | current = get_current_test_case() 334 | if current is None: 335 | raise Exception("Expected current test case not to be None") 336 | current.selected[selector] = value 337 | 338 | selected = list( 339 | filter(lambda obj: property_equal(selector, value, obj), self.content) 340 | ) 341 | log_to_stdout("Elements after filter:", len(selected)) 342 | self.content = selected 343 | return self 344 | 345 | 346 | def it(spec_name: str): 347 | """ 348 | Declares a new Spec and stores it globally in the test cases. 349 | The next time mp.get() is called, it will be part of this spec name 350 | 351 | Args: 352 | test_name: name of the test case 353 | 354 | Returns: None 355 | """ 356 | log_to_stdout("-------------------------------------------------------") 357 | log_to_stdout("Running test:", spec_name) 358 | get_test_cases().append(Result(spec_name)) 359 | 360 | 361 | def get(selector: str, value: str) -> Chainable: 362 | """ 363 | Does a speckle queries and then filters by 'selector'. 364 | Returns the selected items inside the Chainable object 365 | to start a chain of assertions 366 | 367 | Args: 368 | selector: The name of a property of a Speckle Object to select 369 | e.g: category, family 370 | value: The objects whose selector matches this value will be filtered 371 | 372 | Returns: Chainable 373 | 374 | Raises: 375 | Exception: If it was not possible to query a speckle object 376 | """ 377 | 378 | log_to_stdout("Getting", selector, value) 379 | current_test = get_current_test_case() 380 | if current_test is None: 381 | raise Exception("Expected current test case not to be None") 382 | current_test.selected[selector] = value 383 | 384 | speckle_obj = get_current_obj() 385 | if not speckle_obj: 386 | speckle_obj = get_last_obj() 387 | if speckle_obj is None: 388 | raise Exception("Could not get a Base object to query.") 389 | 390 | objs = list(flatten_base(speckle_obj)) 391 | 392 | selected = list(filter(lambda obj: property_equal(selector, value, obj), objs)) 393 | log_to_stdout("Got", len(selected), value) 394 | 395 | return Chainable(selected) 396 | 397 | 398 | def get_last_obj() -> Base: 399 | """ 400 | Gets the last object for the specified stream_id 401 | """ 402 | log_to_stdout("Getting object from speckle") 403 | host = getenv("SPECKLE_HOST") 404 | if not host: 405 | host = "https://app.speckle.systems" 406 | log_to_stdout("Using Speckle host:", host) 407 | client = SpeckleClient(host) 408 | # authenticate the client with a token 409 | account = get_default_account() 410 | token = get_token() 411 | if token: 412 | log_to_stdout("Auth with token") 413 | client.authenticate_with_token(token) 414 | elif account and account_match_host(account, host): 415 | log_to_stdout("Auth with default account") 416 | client.authenticate_with_account(account) 417 | else: 418 | log_to_stdout("No auth present") 419 | 420 | stream_id = get_stream_id() 421 | model_id = get_model_id() 422 | transport = ServerTransport(client=client, stream_id=stream_id) 423 | 424 | branches: list[Branch] = client.branch.list(stream_id) 425 | if len(branches) == 0: 426 | raise Exception("Project contains no models.") 427 | if type(branches[0]) is not Branch: 428 | raise Exception("Expected list of branches") 429 | branch = list(filter(lambda branch: branch.id == model_id, branches)) 430 | 431 | if len(branch) == 0: 432 | raise Exception("Model id was not found.") 433 | 434 | versions = branch[0].commits 435 | if versions is None: 436 | raise Exception("Current model has no versions.") 437 | 438 | last_obj_id = versions.items[0].referencedObject 439 | 440 | if not last_obj_id: 441 | raise Exception("No object_id") 442 | last_obj = operations.receive(obj_id=last_obj_id, remote_transport=transport) 443 | 444 | # cache the current obj 445 | init(last_obj) 446 | return last_obj 447 | 448 | 449 | def run(*specs: Callable): 450 | """ 451 | Runs any number of spec functions passed by args 452 | Args: 453 | *specs: Callable 454 | """ 455 | print_info(specs) 456 | 457 | for i, spec in enumerate(specs): 458 | if not callable(spec): 459 | print( 460 | "Warning - parameter at position " + f"{i}" + " is not spec function." 461 | ) 462 | continue 463 | spec() 464 | 465 | # print results 466 | print_results(get_test_cases()) 467 | 468 | 469 | def log_to_stdout(*args): 470 | global _log_out 471 | if _log_out: 472 | print("INFO:", *args) 473 | 474 | 475 | def print_info(specs): 476 | from importlib_metadata import version 477 | from .utils import print_title 478 | 479 | print_title("Test session") 480 | 481 | v = version("maple-spec") 482 | print("Maple -", v) 483 | print("collected", len(specs), "specs") 484 | print() 485 | 486 | 487 | def generate_report(output_path: str) -> str: 488 | """ 489 | Generats a report file with the test cases after. 490 | mp.run() 491 | 492 | Returns: 493 | The path of the file created 494 | 495 | Args: 496 | output_path: directory to save reports. It must be an 497 | existing directory. 498 | """ 499 | log_to_stdout("Creating report") 500 | results = get_test_cases() 501 | if len(results) == 0: 502 | raise Exception("mp.run must be called before generating report") 503 | report = HtmlReport(results) 504 | file_created = report.create(output_path) 505 | log_to_stdout("Report created on", file_created) 506 | return file_created 507 | 508 | 509 | def account_match_host(account: Account, host: str) -> bool: 510 | url = account.serverInfo.url 511 | host_url = host.replace("https://", "") 512 | host_url = host_url.replace("/", "") 513 | return url == host_url 514 | pass 515 | -------------------------------------------------------------------------------- /src/maple/base_extensions.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable, Mapping 2 | from specklepy.objects import Base 3 | 4 | 5 | def flatten_base(base: Base) -> Iterable[Base]: 6 | """Flatten a base object into an iterable of bases. 7 | 8 | This function recursively traverses the `elements` or `@elements` attribute of the 9 | base object, yielding each nested base object. 10 | 11 | Args: 12 | base (Base): The base object to flatten. 13 | 14 | Yields: 15 | Base: Each nested base object in the hierarchy. 16 | """ 17 | # Attempt to get the elements attribute, fallback to @elements if necessary 18 | elements = getattr(base, "elements", getattr(base, "@elements", None)) 19 | 20 | if elements is not None: 21 | for element in elements: 22 | yield from flatten_base(element) 23 | 24 | yield base 25 | 26 | 27 | def flatten(obj, visited=None): 28 | 29 | # Avoiding pesky circular references 30 | if visited is None: 31 | visited = set() 32 | 33 | if obj in visited: 34 | return 35 | 36 | visited.add(obj) 37 | 38 | # Define a logic for what objects to include in the diff 39 | should_include = any( 40 | [ 41 | hasattr(obj, "displayValue"), 42 | hasattr(obj, "speckle_type") 43 | and obj.speckle_type == "Objects.Organization.Collection", 44 | hasattr(obj, "displayStyle"), 45 | ] 46 | ) 47 | 48 | if should_include: 49 | yield obj 50 | 51 | props = obj.__dict__ 52 | 53 | # traverse the object's nested properties - 54 | # which may include yieldable objects 55 | for prop in props: 56 | value = getattr(obj, prop) 57 | 58 | if value is None: 59 | continue 60 | 61 | if isinstance(value, Base): 62 | yield from flatten(value, visited) 63 | 64 | elif isinstance(value, Mapping): 65 | for dict_value in value.values(): 66 | if isinstance(dict_value, Base): 67 | yield from flatten(dict_value, visited) 68 | 69 | elif isinstance(value, Iterable): 70 | for list_value in value: 71 | if isinstance(list_value, Base): 72 | yield from flatten(list_value, visited) 73 | -------------------------------------------------------------------------------- /src/maple/models.py: -------------------------------------------------------------------------------- 1 | import maple 2 | 3 | 4 | class Assertion: 5 | """ 6 | Contains parameters and result of performing an assertion on 7 | Speckle objects and its values 8 | 9 | Attributes: 10 | comparer: Comparison Operation used to check assertion 11 | value: Value used to assert 12 | passing: List of Ids that passed assertion 13 | failing: List of Ids that failed assertion 14 | 15 | """ 16 | 17 | def __init__(self) -> None: 18 | self.comparer: maple.CompOp 19 | self.value = None # what will be compared to 20 | self.passing: list[str] = [] 21 | self.failing: list[str] = [] 22 | self.selector = "" 23 | 24 | def set_passed(self, obj_id: str): 25 | self.passing.append(obj_id) 26 | 27 | def set_failed(self, obj_id: str): 28 | self.failing.append(obj_id) 29 | 30 | def passed(self) -> bool: 31 | """ 32 | True if not any failing value 33 | """ 34 | return len(self.failing) == 0 35 | 36 | def failed(self) -> bool: 37 | """ 38 | True if any failing value 39 | """ 40 | return len(self.failing) > 0 41 | 42 | 43 | class Result: 44 | """ 45 | Contains the Results of one Assertion 46 | 47 | Attributes: 48 | spec_name: name of the spec mp.it('name') who expects a result 49 | selected: Dict with selector:value 50 | assertions: List of assertions 51 | 52 | """ 53 | 54 | def __init__(self, spec_name: str) -> None: 55 | self.spec_name = spec_name 56 | self.selected = {} 57 | self.assertions: list[Assertion] = [] 58 | -------------------------------------------------------------------------------- /src/maple/ops.py: -------------------------------------------------------------------------------- 1 | """ 2 | Contains functions to handle operations between Base and comparisons 3 | """ 4 | 5 | from specklepy.objects import Base 6 | from enum import StrEnum 7 | from typing import Any, Literal 8 | 9 | 10 | def property_equal(propName: str, value: str, obj: Base): 11 | """ 12 | Evaluates if an object propName has value equal to value 13 | Args: 14 | propName: the name of the property to find 15 | value: the value to equalize 16 | obj: the object to extract the poperty 17 | 18 | Returns: true if equal or False if not equal or does not exist 19 | """ 20 | try: 21 | return getattr(obj, propName) == value 22 | except Exception: 23 | return False 24 | 25 | 26 | ComparisonOps = Literal[ 27 | "be.greater", "be.smaller", "be.equal", "have.value", "have.length" 28 | ] 29 | 30 | 31 | class CompOp(StrEnum): 32 | """ 33 | Comparison (assertion) operations 34 | """ 35 | 36 | BE_GREATER = "be.greater" 37 | BE_SMALLER = "be.smaller" 38 | BE_EQUAL = "be.equal" 39 | HAVE_VALUE = "have.value" 40 | HAVE_LENGTH = "have.length" 41 | 42 | def evaluate(self, param_value: Any, assertion_value: Any) -> bool: 43 | """ 44 | Checks if the param_value evaluates to the assertion_value 45 | accordinf to the CompOp 46 | 47 | Args: 48 | param_value: 49 | assertion_value: 50 | 51 | Returns: True or False 52 | """ 53 | try: 54 | if self == CompOp.BE_GREATER: 55 | return param_value > assertion_value 56 | elif self == CompOp.BE_SMALLER: 57 | return param_value < assertion_value 58 | elif self == CompOp.HAVE_VALUE: 59 | return str(param_value).lower() == str(assertion_value).lower() 60 | elif self == CompOp.BE_EQUAL: # numbers or floats 61 | return round(float(param_value), 2) == round(float(assertion_value), 2) 62 | except Exception: 63 | # log error 64 | print("Could not assert") 65 | return False 66 | return False 67 | -------------------------------------------------------------------------------- /src/maple/report.py: -------------------------------------------------------------------------------- 1 | from time import strftime 2 | import os 3 | from .models import Result 4 | from jinja2 import Environment, PackageLoader 5 | 6 | 7 | class HtmlReport: 8 | def __init__(self, results: list[Result]) -> None: 9 | self.results = results 10 | self.template_file = "report.html" 11 | return 12 | 13 | def create(self, output_path: str) -> str: 14 | """ 15 | Generates a HTML report at the output_path directory. 16 | 17 | Filename is `maple_report_YYYY-mm-dd_HH-MM-ss` 18 | 19 | Args: 20 | output_path (str): Destination directory 21 | Returns: 22 | int: number of bytes written 23 | """ 24 | 25 | file_loader = PackageLoader("maple") 26 | env = Environment(loader=file_loader) 27 | template = env.get_template(self.template_file) 28 | 29 | output = template.render(results=self.results) 30 | time = strftime("%Y-%m-%d_%H-%M-%S") 31 | filename = f"maple_report_{time}.html" 32 | output_path = os.path.join(output_path, filename) 33 | with open(output_path, "w") as file: 34 | file.write(output) 35 | return output_path 36 | -------------------------------------------------------------------------------- /src/maple/templates/report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 42 | Document 43 | 44 | 45 | 46 |

Summary

47 | 48 | 49 | 50 | 51 | 52 | {% for result in results %} 53 | {% for assertion in result.assertions %} 54 | 55 | 56 | {% if assertion.passed() %} 57 | 60 | {% else %} 61 | 64 | {% endif %} 65 | 66 | {% endfor %} 67 | {% endfor %} 68 |
Spec nameResult
{{ result.spec_name }} 58 |

Passed

59 |
62 |

Failed

63 |
69 |
70 |

Details

71 | {% for result in results %} 72 | {% for assertion in result.assertions %} 73 |

Test case: {{ result.spec_name }}

74 | {% if assertion.passed() %} 75 |

Passed

76 | {% else %} 77 |

Failed

78 | {% endif %} 79 |

Asserting: {{ assertion.selector }} must {{ assertion.comparer }} {{ assertion.value }}

80 |

Selected: {{ result.selected }}

81 |

{{ (assertion.passing | length) }} Elements passing - {{ (assertion.failing | length) }} Elements failing

82 | {% endfor %} 83 | {% endfor %} 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /src/maple/utils.py: -------------------------------------------------------------------------------- 1 | from .models import Result 2 | 3 | 4 | RED = "\033[1;31m" 5 | BLUE = "\033[1;34m" 6 | CYAN = "\033[1;36m" 7 | GREEN = "\033[0;32m" 8 | RESET = "\033[0;0m" 9 | BOLD = "\033[;1m" 10 | REVERSE = "\033[;7m" 11 | ENDC = "\033[0m" 12 | 13 | 14 | def print_title(text: str): 15 | character = "=" 16 | max_length = 73 17 | padded = f" {text} " 18 | n = int((max_length - len(padded)) / 2) 19 | print(f"{character*n}{padded}{character*n}") 20 | 21 | 22 | def print_results(test_cases: list[Result]): 23 | """ 24 | Prints results to std-out 25 | """ 26 | 27 | print_title("Test results") 28 | print() 29 | table = [] 30 | for case in test_cases: 31 | assertions = case.assertions 32 | for assertion in assertions: 33 | row = [case.spec_name] 34 | if len(assertion.failing) > 0: 35 | row.append(RED + "Failed" + ENDC) 36 | else: 37 | row.append(GREEN + "Passed" + ENDC) 38 | table.append(row) 39 | for row in table: 40 | print("| {:<60} | {:<6} |".format(*row)) 41 | -------------------------------------------------------------------------------- /tests/.env.example: -------------------------------------------------------------------------------- 1 | SPECKLE_TOKEN= 2 | SPECKLE_HOST=https://latest.speckle.systems 3 | -------------------------------------------------------------------------------- /tests/test_multiple.py: -------------------------------------------------------------------------------- 1 | import maple as mp 2 | import os 3 | from dotenv import load_dotenv 4 | 5 | load_dotenv() 6 | 7 | 8 | def test_multiple_runs(): 9 | project_id = "24fa0ed1c3" 10 | mp.init_model(project_id=project_id, model_id="2696b4a381") 11 | mp.run(spec) 12 | assert len(mp.get_test_cases()) == 1 13 | assert mp.get_current_test_case() is not None 14 | # Initialize a second model to test should clear results 15 | mp.init_model(project_id=project_id, model_id="2696b4a381") 16 | assert len(mp.get_test_cases()) == 0 17 | assert mp.get_current_test_case() is None 18 | mp.run(spec) 19 | 20 | assert len(mp.get_test_cases()) == 1 21 | 22 | 23 | def spec(): 24 | min_height = 900 25 | mp.it(f"checks window height is greater than {min_height} mm") 26 | 27 | mp.get("category", "Windows").where( 28 | "speckle_type", "Objects.Other.Instance:Objects.Other.Revit.RevitInstance" 29 | ).its("Height").should("be.greater", min_height) 30 | -------------------------------------------------------------------------------- /tests/test_ops.py: -------------------------------------------------------------------------------- 1 | from maple.ops import CompOp, ComparisonOps 2 | from typing import get_args 3 | 4 | 5 | def test_enum(): 6 | comp = "be.equal" 7 | assert comp == CompOp.BE_EQUAL 8 | 9 | 10 | def test_literal(): 11 | """ 12 | Assert that all the enum values in CompOp are covered in 13 | the type ComparisonOps 14 | """ 15 | for opt in CompOp: 16 | assert opt.value in get_args(ComparisonOps) 17 | -------------------------------------------------------------------------------- /tests/test_report.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | import maple as mp 3 | from maple import HtmlReport 4 | 5 | 6 | def test_report(tmp_path): 7 | dir = tmp_path / "sub" 8 | dir.mkdir() 9 | results = mp.get_test_cases() 10 | report = HtmlReport(results) 11 | output_path = report.create(dir) 12 | assert path.exists(output_path) 13 | with open(output_path) as file: 14 | assert file.read() != "" 15 | -------------------------------------------------------------------------------- /tests/test_run.py: -------------------------------------------------------------------------------- 1 | import maple as mp 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | 7 | def test_success_run(): 8 | stream_id = "24fa0ed1c3" 9 | mp.init_model(project_id=stream_id, model_id="2696b4a381") 10 | mp.run(spec) 11 | assert mp._stream_id == stream_id 12 | test_case = mp.get_current_test_case() 13 | 14 | assert test_case is not None 15 | assert len(test_case.assertions) == 1 16 | assert test_case.assertions[0].passed() 17 | assert not test_case.assertions[0].failed() 18 | 19 | return 20 | 21 | 22 | def test_error_run(): 23 | some = "hello" 24 | other = {"name": "i'm a function"} 25 | mp.run(spec, some, other) # type: ignore 26 | 27 | 28 | def test_multiple_streams(): 29 | stream_id = "24fa0ed1c3" 30 | mp.init_model(project_id=stream_id, model_id="2696b4a381") 31 | mp.run(spec) 32 | 33 | # We set the stream id to another, it doesn't 34 | # matter that is not valid since we will not use it to query 35 | mp.init_model("other", "rehto") 36 | 37 | # Setting the stream should reset the current object 38 | assert mp.get_current_obj() is None 39 | 40 | 41 | def spec(): 42 | min_height = 900 43 | mp.it(f"checks window height is greater than {min_height} mm") 44 | 45 | mp.get("category", "Windows").where( 46 | "speckle_type", "Objects.Other.Instance:Objects.Other.Revit.RevitInstance" 47 | ).its("Height").should("be.greater", min_height) 48 | --------------------------------------------------------------------------------