├── tests ├── data │ └── warning_list.txt ├── __init__.py ├── test_pytest_gee │ ├── test_dictionary_regression.yml │ ├── test_list_regression.yml │ ├── test_dictionary_regression_prescision.yml │ ├── test_list_regression_prescision.yml │ ├── serialized_test_dictionary_regression.yml │ ├── serialized_test_list_regression.yml │ ├── test_image_regression_1_band.png │ ├── test_image_regression_3_bands.png │ ├── test_image_regression_with_viz.png │ ├── test_image_regression_with_region.png │ ├── test_image_regression_with_overlay.png │ ├── serialized_test_dictionary_regression_prescision.yml │ ├── serialized_test_list_regression_prescision.yml │ ├── serialized_test_feature_collection_regression.yml │ ├── serialized_test_feature_collection_regression_prescision.yml │ ├── serialized_test_image_regression_1_band.yml │ ├── serialized_test_image_regression_with_viz.yml │ ├── serialized_test_image_regression_3_bands.yml │ ├── test_feature_collection_regression_prescision.yml │ ├── test_feature_collection_regression.yml │ ├── serialized_test_image_regression_with_region.yml │ ├── serialized_test_image_regression_with_overlay.yml │ ├── test_feature_collection_regression_no_index.yml │ └── serialized_test_feature_collection_regression_no_index.yml ├── conftest.py ├── check_warnings.py └── test_pytest_gee.py ├── codecov.yml ├── pytest_gee ├── py.typed ├── list_regression.py ├── dictionary_regression.py ├── feature_collection_regression.py ├── plugin.py ├── __init__.py ├── image_regression.py └── utils.py ├── docs ├── _static │ ├── logo.png │ ├── long-logo.png │ ├── github_env_var.png │ ├── ee_image_regression.png │ ├── ee_image_regression_viz.png │ ├── custom.css │ ├── custom-icon.js │ └── logo.svg ├── content │ ├── contribute.rst │ ├── filesystem.rst │ ├── installation.rst │ └── regression.rst ├── _template │ └── pypackage-credit.html ├── index.rst └── conf.py ├── .readthedocs.yaml ├── .devcontainer └── devcontainer.json ├── .copier-answers.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── PULL_REQUEST_TEMPLATE │ │ └── pr_template.md │ └── feature_request.md └── workflows │ ├── release.yaml │ ├── pypackage_check.yaml │ └── unit.yaml ├── LICENSE ├── AUTHORS.rst ├── .pre-commit-config.yaml ├── .gitignore ├── noxfile.py ├── README.rst ├── pyproject.toml ├── CONTRIBUTING.rst └── CODE_OF_CONDUCT.rst /tests/data/warning_list.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """make test folder a package for coverage.""" 2 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_dictionary_regression.yml: -------------------------------------------------------------------------------- 1 | a: 1 2 | b: 2 3 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_list_regression.yml: -------------------------------------------------------------------------------- 1 | - 1 2 | - 2 3 | - 3 4 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | # disable the treemap comment and report in PRs 2 | comment: false 3 | -------------------------------------------------------------------------------- /pytest_gee/py.typed: -------------------------------------------------------------------------------- 1 | # Marker file for PEP 561. The mypy package uses inline types. -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_dictionary_regression_prescision.yml: -------------------------------------------------------------------------------- 1 | a: 1.123 2 | b: 2.123 3 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_list_regression_prescision.yml: -------------------------------------------------------------------------------- 1 | - 1.123 2 | - 2.123 3 | - 3.123 4 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gee-community/pytest-gee/main/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/_static/long-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gee-community/pytest-gee/main/docs/_static/long-logo.png -------------------------------------------------------------------------------- /docs/_static/github_env_var.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gee-community/pytest-gee/main/docs/_static/github_env_var.png -------------------------------------------------------------------------------- /docs/content/contribute.rst: -------------------------------------------------------------------------------- 1 | Contribute 2 | ========== 3 | 4 | .. include:: ../../CONTRIBUTING.rst 5 | :start-line: 3 6 | -------------------------------------------------------------------------------- /docs/_static/ee_image_regression.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gee-community/pytest-gee/main/docs/_static/ee_image_regression.png -------------------------------------------------------------------------------- /docs/_static/ee_image_regression_viz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gee-community/pytest-gee/main/docs/_static/ee_image_regression_viz.png -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_dictionary_regression.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | constantValue: 5 | a: 1 6 | b: 2 7 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_list_regression.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | constantValue: 5 | - 1 6 | - 2 7 | - 3 8 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_image_regression_1_band.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gee-community/pytest-gee/main/tests/test_pytest_gee/test_image_regression_1_band.png -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_image_regression_3_bands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gee-community/pytest-gee/main/tests/test_pytest_gee/test_image_regression_3_bands.png -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_image_regression_with_viz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gee-community/pytest-gee/main/tests/test_pytest_gee/test_image_regression_with_viz.png -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_image_regression_with_region.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gee-community/pytest-gee/main/tests/test_pytest_gee/test_image_regression_with_region.png -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_image_regression_with_overlay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gee-community/pytest-gee/main/tests/test_pytest_gee/test_image_regression_with_overlay.png -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_dictionary_regression_prescision.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | constantValue: 5 | a: 1.123456789 6 | b: 2.123456789 7 | -------------------------------------------------------------------------------- /docs/_template/pypackage-credit.html: -------------------------------------------------------------------------------- 1 |

2 | From 3 | @12rambau/pypackage 4 | 0.1.16 Copier project. 5 |

6 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_list_regression_prescision.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | constantValue: 5 | - 1.123456789 6 | - 2.123456789 7 | - 3.123456789 8 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 2 | 3 | version: 2 4 | 5 | build: 6 | os: ubuntu-22.04 7 | tools: 8 | python: "3.10" 9 | 10 | sphinx: 11 | configuration: docs/conf.py 12 | 13 | python: 14 | install: 15 | - method: pip 16 | path: . 17 | extra_requirements: 18 | - doc 19 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Python 3", 3 | "image": "mcr.microsoft.com/devcontainers/python:1-3.11-bullseye", 4 | "features": { 5 | "ghcr.io/devcontainers-contrib/features/nox:2": {}, 6 | "ghcr.io/devcontainers-contrib/features/pre-commit:2": {} 7 | }, 8 | "postCreateCommand": "python -m pip install commitizen uv && pre-commit install" 9 | } 10 | -------------------------------------------------------------------------------- /docs/_static/custom.css: -------------------------------------------------------------------------------- 1 | /* add dollar sign in console code-block */ 2 | div.highlight-console pre span.go::before { 3 | content: "$"; 4 | margin-right: 10px; 5 | margin-left: 5px; 6 | } 7 | 8 | /* custom colors for titles */ 9 | html[data-theme="light"] { 10 | --pst-color-primary: #009fe3; 11 | } 12 | 13 | html[data-theme="dark"] { 14 | --pst-color-primary: #009fe3; 15 | } 16 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier 2 | _commit: 0.1.16 3 | _src_path: gh:12rambau/pypackage 4 | author_email: pierrick.rambaud49@gmail.com 5 | author_first_name: Pierrick 6 | author_last_name: Rambaud 7 | author_orcid: 0000-0000-0000-0000 8 | creation_year: "2023" 9 | github_repo_name: pytest-gee 10 | github_user: gee-community 11 | project_name: pytest-gee 12 | project_slug: pytest_gee 13 | short_description: The Python plugin for your GEE based packages. 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE/pr_template.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Pull request template 3 | about: Create a pull request 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | ## reference the related issue 10 | 11 | PR should answer problem stated in the issue tracker. please open one before starting a PR 12 | 13 | ## description of the changes 14 | 15 | Describe the changes you propose 16 | 17 | ## mention 18 | 19 | @mentions of the person or team responsible for reviewing proposed changes 20 | 21 | ## comments 22 | 23 | any other comments we should pay attention to 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | env: 8 | PIP_ROOT_USER_ACTION: ignore 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: "3.11" 18 | - name: Install dependencies 19 | run: pip install twine build nox[uv] 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: __token__ 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: python -m build && twine upload dist/* 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_feature_collection_regression.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | functionInvocationValue: 5 | arguments: 6 | collection: 7 | functionInvocationValue: 8 | arguments: 9 | tableId: 10 | constantValue: FAO/GAUL/2015/level0 11 | functionName: Collection.loadTable 12 | filter: 13 | functionInvocationValue: 14 | arguments: 15 | leftField: 16 | constantValue: ADM0_NAME 17 | rightValue: 18 | constantValue: Holy See 19 | functionName: Filter.equals 20 | functionName: Collection.filter 21 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_feature_collection_regression_prescision.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | functionInvocationValue: 5 | arguments: 6 | collection: 7 | functionInvocationValue: 8 | arguments: 9 | tableId: 10 | constantValue: FAO/GAUL/2015/level0 11 | functionName: Collection.loadTable 12 | filter: 13 | functionInvocationValue: 14 | arguments: 15 | leftField: 16 | constantValue: ADM0_NAME 17 | rightValue: 18 | constantValue: Holy See 19 | functionName: Filter.equals 20 | functionName: Collection.filter 21 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | :html_theme.sidebar_secondary.remove: 2 | 3 | 4 | pytest-gee 5 | ========== 6 | 7 | .. image:: _static/logo.png 8 | :width: 20% 9 | :align: right 10 | :class: dark-light 11 | 12 | 13 | ``pytest-gee`` provides some fixtures that make it easy to generate independent tests that require Earth Engine asset filesystem. 14 | It also able to help maintaining tests that generate Earth Engine server side data. 15 | 16 | This plugin uses a data directory (courtesy of ``pytest-datadir``) to store expected data files, 17 | which are stored and used as baseline for future test runs. 18 | You can also define your own data directory directly as described in the ``pytest_regression`` documentation. 19 | 20 | .. toctree:: 21 | :hidden: 22 | 23 | content/installation 24 | content/filesystem 25 | content/regression 26 | content/contribute 27 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Pytest session configuration.""" 2 | 3 | import ee 4 | import pytest 5 | 6 | import pytest_gee 7 | 8 | 9 | def pytest_configure(): 10 | """Init GEE in the test environment.""" 11 | pytest_gee.init_ee_from_service_account() 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def gee_folder_structure(): 16 | """Override the default test folder structure.""" 17 | point = ee.Geometry.Point([0, 0]) 18 | return { 19 | "folder::Folder": { 20 | "image": ee.Image(1).clipToBoundsAndScale(point.buffer(100), scale=30), 21 | "fc": ee.FeatureCollection(point), 22 | }, 23 | "ic::ImageCollection": { 24 | "image1": ee.Image(1).clipToBoundsAndScale(point.buffer(100), scale=30), 25 | "image2": ee.Image(1).clipToBoundsAndScale(point.buffer(100), scale=30), 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_image_regression_1_band.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | functionInvocationValue: 5 | arguments: 6 | geometry: 7 | functionInvocationValue: 8 | arguments: 9 | feature: 10 | valueReference: '1' 11 | functionName: Image.geometry 12 | input: 13 | valueReference: '1' 14 | scale: 15 | constantValue: 1000 16 | functionName: Image.clipToBoundsAndScale 17 | '1': 18 | functionInvocationValue: 19 | arguments: 20 | bandNames: 21 | constantValue: 22 | - SR_B5 23 | - SR_B4 24 | input: 25 | functionInvocationValue: 26 | arguments: 27 | id: 28 | constantValue: LANDSAT/LC08/C02/T1_L2/LC08_191031_20240607 29 | functionName: Image.load 30 | functionName: Image.normalizedDifference 31 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_image_regression_with_viz.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | functionInvocationValue: 5 | arguments: 6 | geometry: 7 | functionInvocationValue: 8 | arguments: 9 | feature: 10 | valueReference: '1' 11 | functionName: Image.geometry 12 | input: 13 | valueReference: '1' 14 | scale: 15 | constantValue: 1000 16 | functionName: Image.clipToBoundsAndScale 17 | '1': 18 | functionInvocationValue: 19 | arguments: 20 | bandNames: 21 | constantValue: 22 | - SR_B5 23 | - SR_B4 24 | input: 25 | functionInvocationValue: 26 | arguments: 27 | id: 28 | constantValue: LANDSAT/LC08/C02/T1_L2/LC08_191031_20240607 29 | functionName: Image.load 30 | functionName: Image.normalizedDifference 31 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_image_regression_3_bands.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | functionInvocationValue: 5 | arguments: 6 | geometry: 7 | functionInvocationValue: 8 | arguments: 9 | feature: 10 | valueReference: '1' 11 | functionName: Image.geometry 12 | input: 13 | valueReference: '1' 14 | scale: 15 | constantValue: 1000 16 | functionName: Image.clipToBoundsAndScale 17 | '1': 18 | functionInvocationValue: 19 | arguments: 20 | bandSelectors: 21 | constantValue: 22 | - SR_B4 23 | - SR_B3 24 | - SR_B2 25 | input: 26 | functionInvocationValue: 27 | arguments: 28 | id: 29 | constantValue: LANDSAT/LC08/C02/T1_L2/LC08_191031_20240607 30 | functionName: Image.load 31 | functionName: Image.select 32 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_feature_collection_regression_prescision.yml: -------------------------------------------------------------------------------- 1 | features: 2 | - geometry: 3 | coordinates: 4 | - - - 12.457600000000001 5 | - 41.904 6 | - - 12.457500000000001 7 | - 41.9033 8 | - - 12.4564 9 | - 41.9022 10 | - - 12.454500000000001 11 | - 41.900200000000005 12 | - - 12.449300000000001 13 | - 41.9005 14 | - - 12.448 15 | - 41.900600000000004 16 | - - 12.4458 17 | - 41.901900000000005 18 | - - 12.4481 19 | - 41.9042 20 | - - 12.4505 21 | - 41.9065 22 | - - 12.451500000000001 23 | - 41.906600000000005 24 | - - 12.4532 25 | - 41.9067 26 | - - 12.4577 27 | - 41.9058 28 | - - 12.457600000000001 29 | - 41.904 30 | type: Polygon 31 | id: '0' 32 | properties: 33 | ADM0_CODE: 110 34 | ADM0_NAME: Holy See 35 | DISP_AREA: 'NO' 36 | EXP0_YEAR: 3000 37 | STATUS: The City of Vatican 38 | STR0_YEAR: 1000 39 | Shape_Area: 0.0001 40 | Shape_Leng: 0.0297 41 | type: Feature 42 | type: FeatureCollection 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Pierrick Rambaud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_feature_collection_regression.yml: -------------------------------------------------------------------------------- 1 | features: 2 | - geometry: 3 | coordinates: 4 | - - - 12.44812 5 | - 41.904202 6 | - - 12.450519 7 | - 41.906529 8 | - - 12.451464 9 | - 41.906577999999996 10 | - - 12.453248 11 | - 41.906672 12 | - - 12.457666999999999 13 | - 41.90578 14 | - - 12.457671999999999 15 | - 41.90578 16 | - - 12.457559999999999 17 | - 41.903991999999995 18 | - - 12.457519999999999 19 | - 41.903349999999996 20 | - - 12.456368999999999 21 | - 41.90215 22 | - - 12.454509999999999 23 | - 41.90022 24 | - - 12.449266 25 | - 41.900541 26 | - - 12.447991 27 | - 41.900621 28 | - - 12.44577 29 | - 41.901917999999995 30 | - - 12.44812 31 | - 41.904202 32 | type: Polygon 33 | id: '0' 34 | properties: 35 | ADM0_CODE: 110 36 | ADM0_NAME: Holy See 37 | DISP_AREA: 'NO' 38 | EXP0_YEAR: 3000 39 | STATUS: The City of Vatican 40 | STR0_YEAR: 1000 41 | Shape_Area: 5.4e-05 42 | Shape_Leng: 0.029744 43 | type: Feature 44 | type: FeatureCollection 45 | -------------------------------------------------------------------------------- /AUTHORS.rst: -------------------------------------------------------------------------------- 1 | Thanks goes to these wonderful people (`emoji key `_): 2 | 3 | .. raw:: html 4 | 5 | 6 | 7 | 8 | 22 | 23 | 24 |
9 | 10 | 12rambau
11 | Pierrick Rambaud 12 |
13 |
14 | 💻 15 | 🤔 16 | 💬 17 | 🐛 18 | 🚧 19 | 👀 20 | 💡 21 |
25 | 26 | This project follows the `all-contributors `_ specification. 27 | 28 | Contributions of any kind are welcome! 29 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_image_regression_with_region.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | functionInvocationValue: 5 | arguments: 6 | geometry: 7 | functionInvocationValue: 8 | arguments: 9 | distance: 10 | constantValue: 100 11 | geometry: 12 | functionInvocationValue: 13 | arguments: 14 | coordinates: 15 | constantValue: 16 | - 12.453585 17 | - 41.903115 18 | functionName: GeometryConstructors.Point 19 | functionName: Geometry.buffer 20 | input: 21 | functionInvocationValue: 22 | arguments: 23 | bandNames: 24 | constantValue: 25 | - SR_B5 26 | - SR_B4 27 | input: 28 | functionInvocationValue: 29 | arguments: 30 | id: 31 | constantValue: LANDSAT/LC08/C02/T1_L2/LC08_191031_20240607 32 | functionName: Image.load 33 | functionName: Image.normalizedDifference 34 | scale: 35 | constantValue: 30 36 | functionName: Image.clipToBoundsAndScale 37 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_image_regression_with_overlay.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | functionInvocationValue: 5 | arguments: 6 | geometry: 7 | functionInvocationValue: 8 | arguments: 9 | distance: 10 | constantValue: 20000 11 | geometry: 12 | functionInvocationValue: 13 | arguments: 14 | geometry: 15 | functionInvocationValue: 16 | arguments: 17 | feature: 18 | valueReference: '1' 19 | functionName: Image.geometry 20 | functionName: Geometry.centroid 21 | functionName: Geometry.buffer 22 | input: 23 | valueReference: '1' 24 | scale: 25 | constantValue: 100 26 | functionName: Image.clipToBoundsAndScale 27 | '1': 28 | functionInvocationValue: 29 | arguments: 30 | bandSelectors: 31 | constantValue: 32 | - SR_B4 33 | - SR_B3 34 | - SR_B2 35 | input: 36 | functionInvocationValue: 37 | arguments: 38 | id: 39 | constantValue: LANDSAT/LC08/C02/T1_L2/LC08_191031_20240607 40 | functionName: Image.load 41 | functionName: Image.select 42 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_install_hook_types: [pre-commit, commit-msg] 2 | 3 | repos: 4 | - repo: "https://github.com/commitizen-tools/commitizen" 5 | rev: "v2.18.0" 6 | hooks: 7 | - id: commitizen 8 | stages: [commit-msg] 9 | 10 | - repo: "https://github.com/kynan/nbstripout" 11 | rev: "0.5.0" 12 | hooks: 13 | - id: nbstripout 14 | stages: [pre-commit] 15 | 16 | - repo: "https://github.com/pycontribs/mirrors-prettier" 17 | rev: "v3.4.2" 18 | hooks: 19 | - id: prettier 20 | stages: [pre-commit] 21 | exclude: tests\/test_.+\. 22 | 23 | - repo: https://github.com/charliermarsh/ruff-pre-commit 24 | rev: "v0.7.0" 25 | hooks: 26 | - id: ruff 27 | stages: [pre-commit] 28 | - id: ruff-format 29 | stages: [pre-commit] 30 | 31 | - repo: https://github.com/sphinx-contrib/sphinx-lint 32 | rev: "v1.0.0" 33 | hooks: 34 | - id: sphinx-lint 35 | stages: [pre-commit] 36 | 37 | - repo: https://github.com/codespell-project/codespell 38 | rev: v2.3.0 39 | hooks: 40 | - id: codespell 41 | stages: [pre-commit] 42 | additional_dependencies: 43 | - tomli 44 | 45 | # Prevent committing inline conflict markers 46 | - repo: https://github.com/pre-commit/pre-commit-hooks 47 | rev: v6.0.0 48 | hooks: 49 | - id: check-merge-conflict 50 | stages: [pre-commit] 51 | args: [--assume-in-merge] 52 | -------------------------------------------------------------------------------- /tests/check_warnings.py: -------------------------------------------------------------------------------- 1 | """Check the warnings from doc builds.""" 2 | 3 | import sys 4 | from pathlib import Path 5 | 6 | 7 | def check_warnings(file: Path) -> int: 8 | """Check the list of warnings produced by the CI tests. 9 | 10 | Raises errors if there are unexpected ones and/or if some are missing. 11 | 12 | Args: 13 | file: the path to the generated warning.txt file from 14 | the CI build 15 | 16 | Returns: 17 | 0 if the warnings are all there 18 | 1 if some warning are not registered or unexpected 19 | """ 20 | # print some log 21 | print("\n=== Sphinx Warnings test ===\n") 22 | 23 | # find the file where all the known warnings are stored 24 | warning_file = Path(__file__).parent / "data" / "warning_list.txt" 25 | 26 | test_warnings = file.read_text().strip().split("\n") 27 | ref_warnings = warning_file.read_text().strip().split("\n") 28 | 29 | print( 30 | f'Checking build warnings in file: "{file}" and comparing to expected ' 31 | f'warnings defined in "{warning_file}"\n\n' 32 | ) 33 | 34 | # find all the missing warnings 35 | missing_warnings = [] 36 | for wa in ref_warnings: 37 | index = [i for i, twa in enumerate(test_warnings) if wa in twa] 38 | if len(index) == 0: 39 | missing_warnings += [wa] 40 | print(f"Warning was not raised: {wa}") 41 | else: 42 | test_warnings.pop(index[0]) 43 | 44 | # the remaining one are unexpected 45 | for twa in test_warnings: 46 | print(f"Unexpected warning: {twa}") 47 | 48 | # delete the tmp warnings file 49 | file.unlink() 50 | 51 | return len(missing_warnings) != 0 or len(test_warnings) != 0 52 | 53 | 54 | if __name__ == "__main__": 55 | # cast the file to path and resolve to an absolute one 56 | file = Path.cwd() / "warnings.txt" 57 | 58 | # execute the test 59 | sys.exit(check_warnings(file)) 60 | -------------------------------------------------------------------------------- /.github/workflows/pypackage_check.yaml: -------------------------------------------------------------------------------- 1 | name: template update check 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | PIP_ROOT_USER_ACTION: ignore 8 | 9 | jobs: 10 | check_version: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.10" 17 | - name: install dependencies 18 | run: pip install requests 19 | - name: get latest pypackage release 20 | id: get_latest_release 21 | run: | 22 | RELEASE=$(curl -s https://api.github.com/repos/12rambau/pypackage/releases | jq -r '.[0].tag_name') 23 | echo "latest=$RELEASE" >> $GITHUB_OUTPUT 24 | echo "latest release: $RELEASE" 25 | - name: get current pypackage version 26 | id: get_current_version 27 | run: | 28 | RELEASE=$(yq -r "._commit" .copier-answers.yml) 29 | echo "current=$RELEASE" >> $GITHUB_OUTPUT 30 | echo "current release: $RELEASE" 31 | - name: open issue 32 | if: steps.get_current_version.outputs.current != steps.get_latest_release.outputs.latest 33 | uses: rishabhgupta/git-action-issue@v2 34 | with: 35 | token: ${{ secrets.GITHUB_TOKEN }} 36 | title: "Update template to ${{ steps.get_latest_release.outputs.latest }}" 37 | body: | 38 | The package is based on the ${{ steps.get_current_version.outputs.current }} version of [@12rambau/pypackage](https://github.com/12rambau/pypackage). 39 | 40 | The latest version of the template is ${{ steps.get_latest_release.outputs.latest }}. 41 | 42 | Please consider updating the template to the latest version to include all the latest developments. 43 | 44 | Run the following code in your project directory to update the template: 45 | 46 | ``` 47 | copier update --trust --defaults --vcs-ref ${{ steps.get_latest_release.outputs.latest }} 48 | ``` 49 | 50 | > **Note** 51 | > You may need to reinstall ``copier`` and ``jinja2-time`` if they are not available in your environment. 52 | 53 | After solving the merging issues you can push back the changes to your main branch. 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | .ruff_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | docs/api/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 97 | __pypackages__/ 98 | 99 | # Celery stuff 100 | celerybeat-schedule 101 | celerybeat.pid 102 | 103 | # SageMath parsed files 104 | *.sage.py 105 | 106 | # Environments 107 | .env 108 | .venv 109 | env/ 110 | venv/ 111 | ENV/ 112 | env.bak/ 113 | venv.bak/ 114 | 115 | # Spyder project settings 116 | .spyderproject 117 | .spyproject 118 | 119 | # Rope project settings 120 | .ropeproject 121 | 122 | # mkdocs documentation 123 | /site 124 | 125 | # mypy 126 | .mypy_cache/ 127 | .dmypy.json 128 | dmypy.json 129 | 130 | # Pyre type checker 131 | .pyre/ 132 | 133 | # system IDE 134 | .vscode/ 135 | 136 | # image tmp file 137 | *Zone.Identifier 138 | 139 | # debugging notebooks 140 | test.ipynb 141 | -------------------------------------------------------------------------------- /docs/_static/custom-icon.js: -------------------------------------------------------------------------------- 1 | /******************************************************************************* 2 | * Set a custom icon for pypi as it's not available in the fa built-in brands 3 | */ 4 | FontAwesome.library.add( 5 | (faListOldStyle = { 6 | prefix: "fa-custom", 7 | iconName: "conda", 8 | icon: [ 9 | 24, // viewBox width 10 | 24, // viewBox height 11 | [], // ligature 12 | "e001", // unicode codepoint - private use area 13 | "M12.045.033a12.181 12.182 0 00-1.361.078 17.512 17.513 0 011.813 1.433l.48.438-.465.45a15.047 15.048 0 00-1.126 1.205l-.178.215a8.527 8.527 0 01.86-.05 8.154 8.155 0 11-4.286 15.149 15.764 15.765 0 01-1.841.106h-.86a21.847 21.848 0 00.264 2.866 11.966 11.967 0 106.7-21.89zM8.17.678a12.181 12.182 0 00-2.624 1.275 15.506 15.507 0 011.813.43A18.551 18.552 0 018.17.678zM9.423.75a16.237 16.238 0 00-.995 1.998 16.15 16.152 0 011.605.66 6.98 6.98 0 01.43-.509c.234-.286.472-.559.716-.817A15.047 15.048 0 009.423.75zM4.68 2.949a14.969 14.97 0 000 2.336c.587-.065 1.196-.1 1.812-.107a16.617 16.617 0 01.48-1.748 16.48 16.481 0 00-2.292-.481zM3.62 3.5A11.938 11.938 0 001.762 5.88a17.004 17.004 0 011.877-.444A17.39 17.391 0 013.62 3.5zm4.406.287c-.143.437-.265.888-.38 1.347a8.255 8.255 0 011.67-.803c-.423-.2-.845-.38-1.29-.544zM6.3 6.216a14.051 14.052 0 00-1.555.108c.064.523.157 1.038.272 1.554a8.39 8.391 0 011.283-1.662zm-2.55.137a15.313 15.313 0 00-2.602.716h-.078v.079a17.104 17.105 0 001.267 2.544l.043.071.072-.049a16.309 16.31 0 011.734-1.083l.057-.035V8.54a16.867 16.868 0 01-.408-2.094v-.092zM.644 8.095l-.063.2A11.844 11.845 0 000 11.655v.209l.143-.152a17.706 17.707 0 011.584-1.447l.057-.043-.043-.064a16.18 16.18 0 01-1.025-1.87zm3.77 1.253l-.18.1c-.465.273-.93.573-1.375.889l-.065.05.05.064c.309.437.645.867.996 1.276l.137.165v-.208a8.176 8.176 0 01.364-2.15zM2.2 10.853l-.072.05a16.574 16.574 0 00-1.813 1.734l-.058.058.066.057a15.449 15.45 0 001.991 1.483l.072.05.043-.08a16.738 16.74 0 011.053-1.64v-.05l-.043-.05a16.99 16.99 0 01-1.19-1.54zm1.855 2.071l-.121.172a15.363 15.363 0 00-.917 1.433l-.043.072.071.043a16.61 16.61 0 001.562.766l.193.086-.086-.193a8.04 8.04 0 01-.66-2.172zm-3.976.48v.2a11.758 11.759 0 00.946 3.326l.078.186.072-.194a16.215 16.216 0 01.845-2l.057-.063-.064-.043a17.197 17.198 0 01-1.776-1.284zm2.543 1.805l-.035.08a15.764 15.765 0 00-.983 2.479v.08h.086a16.15 16.152 0 002.688.5l.072.007v-.086a17.562 17.563 0 01.164-2.056v-.065H4.55a16.266 16.266 0 01-1.849-.896zm2.544 1.169v.114a17.254 17.255 0 00-.151 1.828v.078h.931c.287 0 .624.014.946 0h.209l-.166-.129a8.011 8.011 0 01-1.64-1.834zm-3.29 2.1l.115.172a11.988 11.988 0 002.502 2.737l.157.129v-.201a22.578 22.58 0 01-.2-2.336v-.071h-.072a16.23 16.23 0 01-2.3-.387z", // svg path (https://simpleicons.org/icons/anaconda.svg) 14 | ], 15 | }), 16 | ); 17 | -------------------------------------------------------------------------------- /.github/workflows/unit.yaml: -------------------------------------------------------------------------------- 1 | name: Unit tests 2 | 3 | on: 4 | workflow_dispatch: 5 | workflow_call: 6 | push: 7 | branches: 8 | - main 9 | pull_request: 10 | 11 | env: 12 | EARTHENGINE_SERVICE_ACCOUNT: ${{ secrets.EARTHENGINE_SERVICE_ACCOUNT }} 13 | EARTHENGINE_PROJECT: ${{ secrets.EARTHENGINE_PROJECT }} 14 | FORCE_COLOR: 1 15 | PIP_ROOT_USER_ACTION: ignore 16 | 17 | jobs: 18 | lint: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.11" 25 | - uses: pre-commit/action@v3.0.0 26 | 27 | mypy: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: actions/setup-python@v5 32 | with: 33 | python-version: "3.11" 34 | - name: Install nox 35 | run: pip install nox[uv] 36 | - name: run mypy checks 37 | run: nox -s mypy 38 | 39 | docs: 40 | needs: [lint, mypy] 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions/setup-python@v5 45 | with: 46 | python-version: "3.11" 47 | - name: Install nox 48 | run: pip install nox[uv] 49 | - name: build static docs 50 | run: nox -s docs 51 | 52 | build: 53 | needs: [lint, mypy] 54 | strategy: 55 | fail-fast: false 56 | matrix: 57 | os: [ubuntu-latest] 58 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] 59 | include: 60 | - os: macos-latest # macos test 61 | python-version: "3.13" 62 | - os: windows-latest # windows test 63 | python-version: "3.13" 64 | runs-on: ${{ matrix.os }} 65 | steps: 66 | - uses: actions/checkout@v4 67 | - name: Set up Python ${{ matrix.python-version }} 68 | uses: actions/setup-python@v5 69 | with: 70 | python-version: ${{ matrix.python-version }} 71 | - name: Install nox 72 | run: pip install nox[uv] 73 | - name: test with pytest 74 | run: nox -s ci-test 75 | - name: assess dead fixtures 76 | if: ${{ matrix.python-version == '3.11' }} 77 | shell: bash 78 | run: nox -s dead-fixtures 79 | - uses: actions/upload-artifact@v4 80 | if: ${{ matrix.python-version == '3.11' }} 81 | with: 82 | name: coverage 83 | path: coverage.xml 84 | 85 | coverage: 86 | needs: [build] 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: actions/checkout@v4 90 | - uses: actions/download-artifact@v4 91 | with: 92 | name: coverage 93 | - name: codecov 94 | uses: codecov/codecov-action@v4 95 | with: 96 | token: ${{ secrets.CODECOV_TOKEN }} 97 | verbose: true 98 | fail_ci_if_error: true 99 | -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | """All the process that can be run using nox. 2 | 3 | The nox run are build in isolated environment that will be stored in .nox. to force the venv update, remove the .nox/xxx folder. 4 | """ 5 | 6 | import datetime 7 | import fileinput 8 | 9 | import nox 10 | 11 | nox.options.sessions = ["lint", "test", "docs", "mypy"] 12 | 13 | 14 | @nox.session(reuse_venv=True, venv_backend="uv") 15 | def lint(session): 16 | """Apply the pre-commits.""" 17 | session.install("pre-commit") 18 | session.run("pre-commit", "run", "--all-files", *session.posargs) 19 | 20 | 21 | @nox.session(reuse_venv=True) 22 | def test(session): 23 | """Run the selected tests and report coverage in html.""" 24 | session.install(".[test]") 25 | test_files = session.posargs or ["tests"] 26 | session.run("pytest", "--cov", "--cov-report=html", *test_files) 27 | 28 | 29 | @nox.session(reuse_venv=True, name="ci-test", venv_backend="uv") 30 | def ci_test(session): 31 | """Run all the test and report coverage in xml.""" 32 | session.install(".[test]") 33 | session.run("pytest", "--cov", "--cov-report=xml") 34 | 35 | 36 | @nox.session(reuse_venv=True, name="dead-fixtures", venv_backend="uv") 37 | def dead_fixtures(session): 38 | """Check for dead fixtures within the tests.""" 39 | session.install(".[test]") 40 | session.run("pytest", "--dead-fixtures") 41 | 42 | 43 | @nox.session(reuse_venv=True, venv_backend="uv") 44 | def docs(session): 45 | """Build the documentation.""" 46 | build = session.posargs.pop() if session.posargs else "html" 47 | session.install(".[doc]") 48 | dst, warn = f"docs/_build/{build}", "warnings.txt" 49 | session.run("sphinx-build", "-v", "-b", build, "docs", dst, "-w", warn) 50 | session.run("python", "tests/check_warnings.py") 51 | 52 | 53 | @nox.session(name="mypy", reuse_venv=True, venv_backend="uv") 54 | def mypy(session): 55 | """Run a mypy check of the lib.""" 56 | session.install("mypy", "types-requests", "types-Deprecated", "types-PyYAML") 57 | test_files = session.posargs or ["pytest_gee"] 58 | session.run("mypy", *test_files) 59 | 60 | 61 | @nox.session(reuse_venv=True, venv_backend="uv") 62 | def stubgen(session): 63 | """Generate stub files for the lib but requires human attention before merge.""" 64 | session.install("mypy") 65 | package = session.posargs or ["pytest_gee"] 66 | session.run("stubgen", "-p", package[0], "-o", "stubs", "--include-private") 67 | 68 | 69 | @nox.session(name="release-date", reuse_venv=True, venv_backend="uv") 70 | def release_date(session): 71 | """Update the release date of the citation file.""" 72 | current_date = datetime.datetime.now().strftime("%Y-%m-%d") 73 | 74 | with fileinput.FileInput("CITATION.cff", inplace=True) as file: 75 | for line in file: 76 | if line.startswith("date-released:"): 77 | print(f'date-released: "{current_date}"') 78 | else: 79 | print(line, end="") 80 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | """Configuration file for the Sphinx documentation builder. 2 | 3 | This file only contains a selection of the most common options. For a full 4 | list see the documentation: 5 | https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | """ 7 | 8 | # -- Path setup ---------------------------------------------------------------- 9 | from datetime import datetime 10 | 11 | # -- Project information ------------------------------------------------------- 12 | project = "pytest-gee" 13 | author = "Pierrick Rambaud" 14 | copyright = f"2020-{datetime.now().year}, {author}" 15 | release = "0.8.1" 16 | 17 | # -- General configuration ----------------------------------------------------- 18 | extensions = [ 19 | "sphinx_copybutton", 20 | "sphinx.ext.napoleon", 21 | "sphinx.ext.viewcode", 22 | "sphinx.ext.intersphinx", 23 | "sphinx_design", 24 | "autoapi.extension", 25 | ] 26 | exclude_patterns = ["**.ipynb_checkpoints"] 27 | templates_path = ["_template"] 28 | 29 | # -- Options for HTML output --------------------------------------------------- 30 | html_theme = "pydata_sphinx_theme" 31 | html_static_path = ["_static"] 32 | html_logo = "_static/long-logo.png" 33 | html_favicon = "_static/logo.png" 34 | html_sidebars = {"content/*": []} 35 | html_theme_options = { 36 | "use_edit_page_button": True, 37 | "footer_end": ["theme-version", "pypackage-credit"], 38 | "icon_links": [ 39 | { 40 | "name": "GitHub", 41 | "url": "https://github.com/gee-community/pytest-gee", 42 | "icon": "fa-brands fa-github", 43 | }, 44 | { 45 | "name": "Pypi", 46 | "url": "https://pypi.org/project/pytest-gee/", 47 | "icon": "fa-brands fa-python", 48 | }, 49 | { 50 | "name": "Conda", 51 | "url": "https://anaconda.org/conda-forge/pytest-gee", 52 | "icon": "fa-custom fa-conda", 53 | "type": "fontawesome", 54 | }, 55 | ], 56 | } 57 | html_context = { 58 | "github_user": "gee-community", 59 | "github_repo": "pytest-gee", 60 | "github_version": "main", 61 | "doc_path": "docs", 62 | } 63 | html_css_files = ["custom.css"] 64 | 65 | # -- Options for autosummary/autodoc output ------------------------------------ 66 | autodoc_typehints = "description" 67 | autoapi_dirs = ["../pytest_gee"] 68 | autoapi_python_class_content = "init" 69 | autoapi_member_order = "groupwise" 70 | 71 | # -- Options for intersphinx output -------------------------------------------- 72 | # fmt: off 73 | intersphinx_mapping = { 74 | "python": ("https://docs.python.org/3", None), 75 | "pytest_regressions": ("https://pytest-regressions.readthedocs.io/en/latest/", None), 76 | "pytest": ("https://docs.pytest.org/en/stable/", None), 77 | "ee": ("https://developers.google.com/earth-engine/apidocs", "https://raw.githubusercontent.com/gee-community/sphinx-inventory/refs/heads/main/inventory/earthengine-api.inv"), 78 | } 79 | # fmt: on 80 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | 2 | pytest-gee 3 | ========== 4 | 5 | .. |license| image:: https://img.shields.io/badge/License-MIT-yellow.svg?logo=opensourceinitiative&logoColor=white 6 | :target: LICENSE 7 | :alt: License: MIT 8 | 9 | .. |commit| image:: https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg?logo=git&logoColor=white 10 | :target: https://conventionalcommits.org 11 | :alt: conventional commit 12 | 13 | .. |ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json 14 | :target: https://github.com/astral-sh/ruff 15 | :alt: ruff badge 16 | 17 | .. |prettier| image:: https://img.shields.io/badge/code_style-prettier-ff69b4.svg?logo=prettier&logoColor=white 18 | :target: https://github.com/prettier/prettier 19 | :alt: prettier badge 20 | 21 | .. |pre-commmit| image:: https://img.shields.io/badge/pre--commit-active-yellow?logo=pre-commit&logoColor=white 22 | :target: https://pre-commit.com/ 23 | :alt: pre-commit 24 | 25 | .. |pypi| image:: https://img.shields.io/pypi/v/pytest-gee?color=blue&logo=pypi&logoColor=white 26 | :target: https://pypi.org/project/pytest-gee/ 27 | :alt: PyPI version 28 | 29 | .. |conda| image:: https://img.shields.io/conda/vn/conda-forge/pytest-gee?logo=anaconda&logoColor=white&color=blue 30 | :target: https://anaconda.org/conda-forge/pytest-gee 31 | :alt: conda-forge version badge 32 | 33 | .. |build| image:: https://img.shields.io/github/actions/workflow/status/gee-community/pytest-gee/unit.yaml?logo=github&logoColor=white 34 | :target: https://github.com/gee-community/pytest-gee/actions/workflows/unit.yaml 35 | :alt: build 36 | 37 | .. |coverage| image:: https://img.shields.io/codecov/c/github/gee-community/pytest-gee?logo=codecov&logoColor=white 38 | :target: https://codecov.io/gh/gee-community/pytest-gee 39 | :alt: Test Coverage 40 | 41 | .. |docs| image:: https://img.shields.io/readthedocs/pytest-gee?logo=readthedocs&logoColor=white 42 | :target: https://pytest-gee.readthedocs.io/en/latest/ 43 | :alt: Documentation Status 44 | 45 | |license| |commit| |ruff| |prettier| |pre-commmit| |pypi| |conda| |build| |coverage| |docs| 46 | 47 | Overview 48 | -------- 49 | 50 | .. image:: https://raw.githubusercontent.com/gee-community/pytest-gee/main/docs/_static/logo.svg 51 | :width: 20% 52 | :align: right 53 | 54 | ``pytest-gee`` provides some fixtures that make it easy to generate independent tests that require Earth Engine asset filesystem. 55 | It also able to help maintaining tests that generate Earth Engine server side data. 56 | 57 | This plugin uses a data directory (courtesy of ``pytest-datadir``) to store expected data files, 58 | which are stored and used as baseline for future test runs. 59 | You can also define your own data directory directly as described in the ``pytest_regression`` documentation. 60 | 61 | Credits 62 | ------- 63 | 64 | This package was created with `Copier `__ and the `@12rambau/pypackage `__ 0.1.16 project template. 65 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "pytest-gee" 7 | version = "0.8.1" 8 | description = "The Python plugin for your GEE based packages." 9 | keywords = [ 10 | "skeleton", 11 | "Python" 12 | ] 13 | classifiers = [ 14 | "Development Status :: 3 - Alpha", 15 | "Intended Audience :: Developers", 16 | "License :: OSI Approved :: MIT License", 17 | "Programming Language :: Python :: 3.9", 18 | "Programming Language :: Python :: 3.10", 19 | "Programming Language :: Python :: 3.11", 20 | "Programming Language :: Python :: 3.12", 21 | "Programming Language :: Python :: 3.13", 22 | ] 23 | requires-python = ">=3.9" 24 | dependencies = [ 25 | "deprecated>=1.2.14", 26 | "earthengine-api>=0.1.397", # new ee.data.createFolder method 27 | "pytest", 28 | "pytest-regressions>=2.7.0", # get the fullpath parameter in the Imageregression 29 | "geopandas", 30 | "pillow", 31 | ] 32 | 33 | [[project.authors]] 34 | name = "Pierrick Rambaud" 35 | email = "pierrick.rambaud49@gmail.com" 36 | 37 | [project.license] 38 | text = "MIT" 39 | 40 | [project.readme] 41 | file = "README.rst" 42 | content-type = "text/x-rst" 43 | 44 | [project.urls] 45 | Homepage = "https://github.com/gee-community/pytest-gee" 46 | 47 | [project.entry-points."pytest11"] 48 | gee = "pytest_gee.plugin" 49 | 50 | [project.optional-dependencies] 51 | test = [ 52 | "pytest", 53 | "pytest-cov", 54 | "pytest-deadfixtures" 55 | ] 56 | doc = [ 57 | "sphinx>=6.2.1,<8", 58 | "pydata-sphinx-theme", 59 | "sphinx-copybutton", 60 | "sphinx-design", 61 | "sphinx-autoapi", 62 | "sphinxemoji", 63 | ] 64 | 65 | [tool.hatch.build.targets.wheel] 66 | only-include = ["pytest_gee"] 67 | 68 | [tool.hatch.envs.default] 69 | dependencies = [ 70 | "pre-commit", 71 | "commitizen", 72 | "nox[uv]" 73 | ] 74 | post-install-commands = ["pre-commit install"] 75 | 76 | [tool.commitizen] 77 | tag_format = "v$major.$minor.$patch$prerelease" 78 | update_changelog_on_bump = false 79 | version = "0.8.1" 80 | version_files = [ 81 | "pyproject.toml:version", 82 | "pytest_gee/__init__.py:__version__", 83 | "docs/conf.py:release", 84 | ] 85 | 86 | [tool.pytest.ini_options] 87 | testpaths = "tests" 88 | 89 | [tool.ruff] 90 | line-length = 100 91 | fix = true 92 | 93 | [tool.ruff.lint] 94 | select = ["E", "F", "W", "I", "D", "RUF"] 95 | ignore = [ 96 | "E501", # line too long | Black take care of it 97 | "D212", # Multi-line docstring | We use D213 98 | "D101", # Missing docstring in public class | We use D106 99 | ] 100 | 101 | [tool.ruff.lint.flake8-quotes] 102 | docstring-quotes = "double" 103 | 104 | [tool.ruff.lint.pydocstyle] 105 | convention = "google" 106 | 107 | [tool.coverage.run] 108 | source = ["pytest_gee"] 109 | 110 | [tool.mypy] 111 | scripts_are_modules = true 112 | ignore_missing_imports = true 113 | install_types = true 114 | non_interactive = true 115 | warn_redundant_casts = true 116 | 117 | [tool.codespell] 118 | ignore-words-list = "nd" 119 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/test_feature_collection_regression_no_index.yml: -------------------------------------------------------------------------------- 1 | features: 2 | - geometry: 3 | coordinates: 4 | - - - 0.00030000000000000003 5 | - 0.00030000000000000003 6 | - - 0.0004 7 | - -0.0001 8 | - - 0.0002 9 | - -0.0004 10 | - - -0.0002 11 | - -0.0004 12 | - - -0.0004 13 | - -0.0001 14 | - - -0.0004 15 | - 0.00030000000000000003 16 | - - 0.0 17 | - 0.0004 18 | - - 0.00030000000000000003 19 | - 0.00030000000000000003 20 | type: Polygon 21 | id: '0' 22 | properties: {} 23 | type: Feature 24 | - geometry: 25 | coordinates: 26 | - - - 0.0004 27 | - 0.00030000000000000003 28 | - - 0.0005 29 | - -0.0001 30 | - - 0.0002 31 | - -0.0005 32 | - - -0.0002 33 | - -0.0005 34 | - - -0.0005 35 | - -0.0001 36 | - - -0.0004 37 | - 0.00030000000000000003 38 | - - 0.0 39 | - 0.0005 40 | - - 0.0004 41 | - 0.00030000000000000003 42 | type: Polygon 43 | id: '1' 44 | properties: {} 45 | type: Feature 46 | - geometry: 47 | coordinates: 48 | - - - 0.0005 49 | - 0.0004 50 | - - 0.0006000000000000001 51 | - -0.0001 52 | - - 0.00030000000000000003 53 | - -0.0006000000000000001 54 | - - -0.00030000000000000003 55 | - -0.0006000000000000001 56 | - - -0.0006000000000000001 57 | - -0.0001 58 | - - -0.0005 59 | - 0.0004 60 | - - 0.0 61 | - 0.0006000000000000001 62 | - - 0.0005 63 | - 0.0004 64 | type: Polygon 65 | id: '2' 66 | properties: {} 67 | type: Feature 68 | - geometry: 69 | coordinates: 70 | - - - 0.0006000000000000001 71 | - 0.0005 72 | - - 0.0007 73 | - -0.0001 74 | - - 0.00030000000000000003 75 | - -0.0006000000000000001 76 | - - -0.00030000000000000003 77 | - -0.0007 78 | - - -0.0007 79 | - -0.0002 80 | - - -0.0006000000000000001 81 | - 0.0004 82 | - - 0.0 83 | - 0.0007 84 | - - 0.0006000000000000001 85 | - 0.0005 86 | type: Polygon 87 | id: '3' 88 | properties: {} 89 | type: Feature 90 | - geometry: 91 | coordinates: 92 | - - - 0.0006000000000000001 93 | - 0.0005 94 | - - 0.0008 95 | - -0.0002 96 | - - 0.0004 97 | - -0.0007 98 | - - -0.00030000000000000003 99 | - -0.0007 100 | - - -0.0008 101 | - -0.0002 102 | - - -0.0006000000000000001 103 | - 0.0005 104 | - - 0.0 105 | - 0.0008 106 | - - 0.0006000000000000001 107 | - 0.0005 108 | type: Polygon 109 | id: '4' 110 | properties: {} 111 | type: Feature 112 | - geometry: 113 | coordinates: 114 | - - - 0.0007 115 | - 0.0006000000000000001 116 | - - 0.0009000000000000001 117 | - -0.0002 118 | - - 0.0004 119 | - -0.0008 120 | - - -0.0004 121 | - -0.0008 122 | - - -0.0009000000000000001 123 | - -0.0002 124 | - - -0.0007 125 | - 0.0006000000000000001 126 | - - 0.0 127 | - 0.0009000000000000001 128 | - - 0.0007 129 | - 0.0006000000000000001 130 | type: Polygon 131 | id: '5' 132 | properties: {} 133 | type: Feature 134 | type: FeatureCollection 135 | -------------------------------------------------------------------------------- /pytest_gee/list_regression.py: -------------------------------------------------------------------------------- 1 | """Implementation of the ``list_regression`` fixture.""" 2 | 3 | import os 4 | from contextlib import suppress 5 | from typing import Optional 6 | 7 | import ee 8 | from pytest import fail 9 | from pytest_regressions.data_regression import DataRegressionFixture 10 | 11 | from .utils import build_fullpath, check_serialized, round_data 12 | 13 | 14 | class ListFixture(DataRegressionFixture): 15 | """Fixture for regression testing of :py:class:`ee.List`.""" 16 | 17 | def check( 18 | self, 19 | data_list: ee.List, 20 | basename: Optional[str] = None, 21 | fullpath: Optional[os.PathLike] = None, 22 | prescision: int = 6, 23 | ): 24 | """Check the given list against a previously recorded version, or generate a new file. 25 | 26 | Parameters: 27 | data_list: The list to check. 28 | basename: The basename of the file to test/record. If not given the name of the test is used. 29 | fullpath: complete path to use as a reference file. This option will ignore ``datadir`` fixture when reading *expected* files but will still use it to write *obtained* files. Useful if a reference file is located in the session data dir for example. 30 | precision: The number of decimal places to round to when comparing floats. 31 | """ 32 | # build the different filename to be consistent between our 3 checks 33 | data_name = build_fullpath( 34 | datadir=self.original_datadir, 35 | request=self.request, 36 | extension=".yml", 37 | basename=basename, 38 | fullpath=fullpath, 39 | with_test_class_names=self.with_test_class_names, 40 | ) 41 | serialized_name = data_name.with_stem(f"serialized_{data_name.stem}").with_suffix(".yml") 42 | 43 | # check the previously registered serialized call from GEE. If it matches the current call, 44 | # we don't need to check the data 45 | with suppress(AssertionError, fail.Exception): 46 | check_serialized( 47 | object=data_list, 48 | path=serialized_name, 49 | datadir=self.datadir, 50 | original_datadir=self.original_datadir, 51 | request=self.request, 52 | with_test_class_names=self.with_test_class_names, 53 | ) 54 | return 55 | 56 | # delete the previously created file if wasn't successful 57 | serialized_name.unlink(missing_ok=True) 58 | 59 | # if it needs to be checked, we need to round the float values to the same precision as the 60 | # reference file 61 | data = round_data(data_list.getInfo(), prescision) 62 | try: 63 | super().check(data, fullpath=data_name) 64 | 65 | # IF we are here it means the data has been modified so we edit the API call accordingly 66 | # to make sure next run will not be forced to call the API for a response. 67 | with suppress(AssertionError, fail.Exception): 68 | check_serialized( 69 | object=data_list, 70 | path=serialized_name, 71 | datadir=self.datadir, 72 | original_datadir=self.original_datadir, 73 | request=self.request, 74 | with_test_class_names=self.with_test_class_names, 75 | force_regen=True, 76 | ) 77 | 78 | except (AssertionError, fail.Exception) as e: 79 | raise e 80 | -------------------------------------------------------------------------------- /pytest_gee/dictionary_regression.py: -------------------------------------------------------------------------------- 1 | """Implementation of the ``dictionary_regression`` fixture.""" 2 | 3 | import os 4 | from contextlib import suppress 5 | from typing import Optional 6 | 7 | import ee 8 | from pytest import fail 9 | from pytest_regressions.data_regression import DataRegressionFixture 10 | 11 | from .utils import build_fullpath, check_serialized, round_data 12 | 13 | 14 | class DictionaryFixture(DataRegressionFixture): 15 | """Fixture for regression testing of :py:class:`ee.Dictionary`.""" 16 | 17 | def check( 18 | self, 19 | data_dict: ee.Dictionary, 20 | basename: Optional[str] = None, 21 | fullpath: Optional[os.PathLike] = None, 22 | prescision: int = 6, 23 | ): 24 | """Check the given list against a previously recorded version, or generate a new file. 25 | 26 | Parameters: 27 | data_dict: The dictionary to check. 28 | basename: The basename of the file to test/record. If not given the name of the test is used. 29 | fullpath: complete path to use as a reference file. This option will ignore ``datadir`` fixture when reading *expected* files but will still use it to write *obtained* files. Useful if a reference file is located in the session data dir for example. 30 | precision: The number of decimal places to round to when comparing floats. 31 | """ 32 | # build the different filename to be consistent between our 3 checks 33 | data_name = build_fullpath( 34 | datadir=self.original_datadir, 35 | request=self.request, 36 | extension=".yml", 37 | basename=basename, 38 | fullpath=fullpath, 39 | with_test_class_names=self.with_test_class_names, 40 | ) 41 | serialized_name = data_name.with_stem(f"serialized_{data_name.stem}").with_suffix(".yml") 42 | 43 | # check the previously registered serialized call from GEE. If it matches the current call, 44 | # we don't need to check the data 45 | with suppress(AssertionError, fail.Exception): 46 | check_serialized( 47 | object=ee.Dictionary(data_dict), 48 | path=serialized_name, 49 | datadir=self.datadir, 50 | original_datadir=self.original_datadir, 51 | request=self.request, 52 | with_test_class_names=self.with_test_class_names, 53 | ) 54 | return 55 | 56 | # delete the previously created file if wasn't successful 57 | serialized_name.unlink(missing_ok=True) 58 | 59 | # if it needs to be checked, we need to round the float values to the same precision as the 60 | # reference file 61 | data = round_data(data_dict.getInfo(), prescision) 62 | try: 63 | super().check(data, fullpath=data_name) 64 | 65 | # IF we are here it means the data has been modified so we edit the API call accordingly 66 | # to make sure next run will not be forced to call the API for a response. 67 | with suppress(AssertionError, fail.Exception): 68 | check_serialized( 69 | object=data_dict, 70 | path=serialized_name, 71 | datadir=self.datadir, 72 | original_datadir=self.original_datadir, 73 | request=self.request, 74 | with_test_class_names=self.with_test_class_names, 75 | force_regen=True, 76 | ) 77 | 78 | except (AssertionError, fail.Exception) as e: 79 | raise e 80 | -------------------------------------------------------------------------------- /tests/test_pytest_gee/serialized_test_feature_collection_regression_no_index.yml: -------------------------------------------------------------------------------- 1 | result: '0' 2 | values: 3 | '0': 4 | functionInvocationValue: 5 | arguments: 6 | baseAlgorithm: 7 | functionDefinitionValue: 8 | argumentNames: 9 | - _MAPPING_VAR_0_0 10 | body: '1' 11 | collection: 12 | functionInvocationValue: 13 | arguments: 14 | features: 15 | functionInvocationValue: 16 | arguments: 17 | baseAlgorithm: 18 | functionDefinitionValue: 19 | argumentNames: 20 | - _MAPPING_VAR_0_0 21 | body: '2' 22 | dropNulls: 23 | constantValue: false 24 | list: 25 | functionInvocationValue: 26 | arguments: 27 | baseAlgorithm: 28 | functionDefinitionValue: 29 | argumentNames: 30 | - _MAPPING_VAR_0_0 31 | body: '3' 32 | dropNulls: 33 | constantValue: false 34 | list: 35 | functionInvocationValue: 36 | arguments: 37 | end: 38 | constantValue: 100 39 | start: 40 | constantValue: 50 41 | step: 42 | constantValue: 10 43 | functionName: List.sequence 44 | functionName: List.map 45 | functionName: List.map 46 | functionName: Collection 47 | functionName: Collection.map 48 | '1': 49 | functionInvocationValue: 50 | arguments: 51 | input: 52 | argumentReference: _MAPPING_VAR_0_0 53 | propertySelectors: 54 | functionInvocationValue: 55 | arguments: 56 | element: 57 | constantValue: system:index 58 | list: 59 | functionInvocationValue: 60 | arguments: 61 | element: 62 | argumentReference: _MAPPING_VAR_0_0 63 | functionName: Element.propertyNames 64 | functionName: List.remove 65 | functionName: Feature.select 66 | '2': 67 | functionInvocationValue: 68 | arguments: 69 | geometry: 70 | argumentReference: _MAPPING_VAR_0_0 71 | functionName: Feature 72 | '3': 73 | functionInvocationValue: 74 | arguments: 75 | distance: 76 | argumentReference: _MAPPING_VAR_0_0 77 | geometry: 78 | functionInvocationValue: 79 | arguments: 80 | coordinates: 81 | constantValue: 82 | - 0 83 | - 0 84 | functionName: GeometryConstructors.Point 85 | maxError: 86 | functionInvocationValue: 87 | arguments: 88 | value: 89 | functionInvocationValue: 90 | arguments: 91 | left: 92 | argumentReference: _MAPPING_VAR_0_0 93 | right: 94 | constantValue: 5 95 | functionName: Number.divide 96 | functionName: ErrorMargin 97 | functionName: Geometry.buffer 98 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contribute 2 | ========== 3 | 4 | Thank you for your help improving **pytest-gee**! 5 | 6 | **pytest-gee** uses `nox `__ to automate several development-related tasks. 7 | Currently, the project uses four automation processes (called sessions) in ``noxfile.py``: 8 | 9 | - ``mypy``: to perform a mypy check on the lib; 10 | - ``test``: to run the test with pytest; 11 | - ``docs``: to build the documentation in the ``build`` folder; 12 | - ``lint``: to run the pre-commits in an isolated environment 13 | 14 | Every nox session is run in its own virtual environment, and the dependencies are installed automatically. 15 | 16 | To run a specific nox automation process, use the following command: 17 | 18 | .. code-block:: console 19 | 20 | nox -s 21 | 22 | For example: ``nox -s test`` or ``nox -s docs``. 23 | 24 | Workflow for contributing changes 25 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 26 | 27 | We follow a typical GitHub workflow of: 28 | 29 | - Create a personal fork of this repo 30 | - Create a branch 31 | - Open a pull request 32 | - Fix findings of various linters and checks 33 | - Work through code review 34 | 35 | See the following sections for more details. 36 | 37 | Clone the repository 38 | ^^^^^^^^^^^^^^^^^^^^ 39 | 40 | First off, you'll need your own copy of **pytest-gee** codebase. You can clone it for local development like so: 41 | 42 | Fork the repository so you have your own copy on GitHub. See the `GitHub forking guide for more information `__. 43 | 44 | Then, clone the repository locally so that you have a local copy to work on: 45 | 46 | .. code-block:: console 47 | 48 | git clone https://github.com//pytest-gee 49 | cd pytest-gee 50 | 51 | Then install the development version of the extension: 52 | 53 | .. code-block:: console 54 | 55 | pip install -e .[dev] 56 | 57 | This will install the **pytest-gee** library, together with two additional tools: 58 | - `pre-commit `__ for automatically enforcing code standards and quality checks before commits. 59 | - `nox `__, for automating common development tasks. 60 | 61 | Lastly, activate the pre-commit hooks by running: 62 | 63 | .. code-block:: console 64 | 65 | pre-commit install 66 | 67 | This will install the necessary dependencies to run pre-commit every time you make a commit with Git. 68 | 69 | Contribute to the codebase 70 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 71 | 72 | Any larger updates to the codebase should include tests and documentation. The tests are located in the ``tests`` folder, and the documentation is located in the ``docs`` folder. 73 | 74 | To run the tests locally, use the following command: 75 | 76 | .. code-block:: console 77 | 78 | nox -s test 79 | 80 | See :ref:`below ` for more information on how to update the documentation. 81 | 82 | .. _contributing-docs: 83 | 84 | Contribute to the docs 85 | ^^^^^^^^^^^^^^^^^^^^^^ 86 | 87 | The documentation is built using `Sphinx `__ and deployed to `Read the Docs `__. 88 | 89 | To build the documentation locally, use the following command: 90 | 91 | .. code-block:: console 92 | 93 | nox -s docs 94 | 95 | For each pull request, the documentation is built and deployed to make it easier to review the changes in the PR. To access the docs build from a PR, click on the "Read the Docs" preview in the CI/CD jobs. 96 | 97 | Release new version 98 | ^^^^^^^^^^^^^^^^^^^ 99 | 100 | To release a new version, start by pushing a new bump from the local directory: 101 | 102 | .. code-block:: 103 | 104 | cz bump 105 | 106 | The commitizen-tool will detect the semantic version name based on the existing commits messages. 107 | 108 | Then push to Github. In Github design a new release using the same tag name nad the ``release.yaml`` job will send it to pipy. 109 | -------------------------------------------------------------------------------- /pytest_gee/feature_collection_regression.py: -------------------------------------------------------------------------------- 1 | """Implementation of the ``feature_collection_regression`` fixture.""" 2 | 3 | import os 4 | from contextlib import suppress 5 | from typing import Optional 6 | 7 | import ee 8 | import geopandas as gpd 9 | from pytest import fail 10 | from pytest_regressions.data_regression import DataRegressionFixture 11 | 12 | from .utils import build_fullpath, check_serialized, round_data 13 | 14 | 15 | class FeatureCollectionFixture(DataRegressionFixture): 16 | """Fixture for regression testing of :py:class:`ee.FeatureCollection`.""" 17 | 18 | def check( 19 | self, 20 | data_fc: ee.FeatureCollection, 21 | basename: Optional[str] = None, 22 | fullpath: Optional[os.PathLike] = None, 23 | prescision: int = 6, 24 | drop_index=False, 25 | ): 26 | """Check the given list against a previously recorded version, or generate a new file. 27 | 28 | Parameters: 29 | data_fc: The feature collection to check. 30 | basename: The basename of the file to test/record. If not given the name of the test is used. 31 | fullpath: complete path to use as a reference file. This option will ignore ``datadir`` fixture when reading *expected* files but will still use it to write *obtained* files. Useful if a reference file is located in the session data dir for example. 32 | precision: The number of decimal places to round to when comparing floats. 33 | drop_index: If True, the ``system:index`` property will be removed from the feature collection before checking. 34 | """ 35 | if drop_index is True: 36 | data_fc = data_fc.map(lambda f: f.select(f.propertyNames().remove("system:index"))) 37 | 38 | # build the different filename to be consistent between our 3 checks 39 | data_name = build_fullpath( 40 | datadir=self.original_datadir, 41 | request=self.request, 42 | extension=".yml", 43 | basename=basename, 44 | fullpath=fullpath, 45 | with_test_class_names=self.with_test_class_names, 46 | ) 47 | serialized_name = data_name.with_stem(f"serialized_{data_name.stem}").with_suffix(".yml") 48 | 49 | # check the previously registered serialized call from GEE. If it matches the current call, 50 | # we don't need to check the data 51 | with suppress(AssertionError, fail.Exception): 52 | check_serialized( 53 | object=data_fc, 54 | path=serialized_name, 55 | datadir=self.datadir, 56 | original_datadir=self.original_datadir, 57 | request=self.request, 58 | with_test_class_names=self.with_test_class_names, 59 | ) 60 | return 61 | 62 | # delete the previously created file if wasn't successful 63 | serialized_name.unlink(missing_ok=True) 64 | 65 | # round the geometry using geopandas to make sre with use the specific number of decimal places 66 | gdf = gpd.GeoDataFrame.from_features(data_fc.getInfo()) 67 | gdf.geometry = gdf.set_precision(grid_size=10 ** (-prescision)).remove_repeated_points() 68 | 69 | # round any float value before serving the data to the check function 70 | data = gdf.to_geo_dict() 71 | data = round_data(data, prescision) 72 | 73 | # if it needs to be checked, we need to round the float values to the same precision as the 74 | # reference file 75 | try: 76 | super().check(data, fullpath=data_name) 77 | 78 | # IF we are here it means the data has been modified so we edit the API call accordingly 79 | # to make sure next run will not be forced to call the API for a response. 80 | with suppress(AssertionError, fail.Exception): 81 | check_serialized( 82 | object=data_fc, 83 | path=serialized_name, 84 | datadir=self.datadir, 85 | original_datadir=self.original_datadir, 86 | request=self.request, 87 | with_test_class_names=self.with_test_class_names, 88 | force_regen=True, 89 | ) 90 | 91 | except (AssertionError, fail.Exception) as e: 92 | raise e 93 | -------------------------------------------------------------------------------- /pytest_gee/plugin.py: -------------------------------------------------------------------------------- 1 | """A pytest plugin to build a GEE environment for a test session.""" 2 | 3 | from __future__ import annotations 4 | 5 | import uuid 6 | from pathlib import Path 7 | 8 | import pytest 9 | from ee._state import get_state 10 | 11 | from . import utils 12 | from .dictionary_regression import DictionaryFixture 13 | from .feature_collection_regression import FeatureCollectionFixture 14 | from .image_regression import ImageFixture 15 | from .list_regression import ListFixture 16 | 17 | 18 | @pytest.fixture(scope="session") 19 | def gee_hash(): 20 | """Generate a unique hash for the test session.""" 21 | return uuid.uuid4().hex 22 | 23 | 24 | @pytest.fixture(scope="session") 25 | def gee_folder_root(): 26 | """Link to the root folder of the connected account.""" 27 | project_id = get_state().cloud_api_user_project 28 | return Path(f"projects/{project_id}/assets") 29 | 30 | 31 | @pytest.fixture(scope="session") 32 | def gee_folder_structure(): 33 | """The structure of the generated test folder.""" 34 | return {} 35 | 36 | 37 | @pytest.fixture(scope="session") 38 | def gee_test_folder(gee_hash, gee_folder_root, gee_folder_structure): 39 | """Create a test folder for the duration of the test session.""" 40 | folder = utils.init_tree(gee_folder_structure, gee_hash, gee_folder_root) 41 | 42 | yield folder 43 | 44 | utils.delete_assets(folder, False) 45 | 46 | 47 | @pytest.fixture 48 | def ee_list_regression( 49 | datadir: Path, original_datadir: Path, request: pytest.FixtureRequest 50 | ) -> ListFixture: 51 | """Fixture to test :py:class:`ee.List` objects. 52 | 53 | Args: 54 | datadir: The directory where the data files are stored. 55 | original_datadir: The original data directory. 56 | request: The pytest request object. 57 | 58 | Returns: 59 | The ListFixture object. 60 | 61 | Example: 62 | .. code-block:: python 63 | 64 | def test_list_regression(list_regression): 65 | data = ee.List([1, 2, 3]) 66 | list_regression.check(data) 67 | """ 68 | return ListFixture(datadir, original_datadir, request) 69 | 70 | 71 | @pytest.fixture 72 | def ee_feature_collection_regression( 73 | datadir: Path, original_datadir: Path, request: pytest.FixtureRequest 74 | ) -> FeatureCollectionFixture: 75 | """Fixture to test :py:class:`ee.FeatureCollection` objects. 76 | 77 | Args: 78 | datadir: The directory where the data files are stored. 79 | original_datadir: The original data directory. 80 | request: The pytest request object. 81 | 82 | Returns: 83 | The FeatureCollectionFixture object. 84 | 85 | Example: 86 | .. code-block:: python 87 | 88 | def test_feature_collection_regression(feature_collection_regression): 89 | data = ee.FeatureCollection("FAO/GAUL/2015/level0").filter(ee.Filter.eq("ADM0_NAME", "Holy See")) 90 | feature_collection_regression.check(data) 91 | """ 92 | return FeatureCollectionFixture(datadir, original_datadir, request) 93 | 94 | 95 | @pytest.fixture 96 | def ee_dictionary_regression( 97 | datadir: Path, original_datadir: Path, request: pytest.FixtureRequest 98 | ) -> DictionaryFixture: 99 | """Fixture to test `ee.Dictionary` objects. 100 | 101 | Args: 102 | datadir: The directory where the data files are stored. 103 | original_datadir: The original data directory. 104 | request: The pytest request object. 105 | 106 | Returns: 107 | The DictionaryFixture object. 108 | 109 | Example: 110 | .. code-block:: python 111 | 112 | def test_dictionary_regression(dictionary_regression): 113 | data = ee.Dictionary({"a": 1, "b": 2}) 114 | dictionary_regression.check(data) 115 | """ 116 | return DictionaryFixture(datadir, original_datadir, request) 117 | 118 | 119 | @pytest.fixture 120 | def ee_image_regression( 121 | datadir: Path, original_datadir: Path, request: pytest.FixtureRequest 122 | ) -> ImageFixture: 123 | """Fixture to test :py:class:`ee.Image` objects. 124 | 125 | Args: 126 | datadir: The directory where the data files are stored. 127 | original_datadir: The original data directory. 128 | request: The pytest request object. 129 | 130 | Returns: 131 | The ImageFixture object. 132 | 133 | Example: 134 | .. code-block:: python 135 | 136 | def test_image_regression(image_regression): 137 | data = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_191031_20210514") 138 | image_regression.check(data, scale=1000) 139 | """ 140 | return ImageFixture(datadir, original_datadir, request) 141 | -------------------------------------------------------------------------------- /docs/content/filesystem.rst: -------------------------------------------------------------------------------- 1 | GEE Filesystem 2 | ============== 3 | 4 | Generate a test file tree in GEE 5 | -------------------------------- 6 | 7 | Using the ``pytest_gee`` plugin, you can easily generate a test file tree in GEE that will be used to run your tests. 8 | This tree will start in a folder named with the ``gee_hash`` fixture and will be deleted at the end of the test session. 9 | 10 | By using this method you will ensure that the folder you are using for your test is unique and that it will not interfere with other tests (e.g. parallel tests). 11 | 12 | .. code-block:: python 13 | 14 | # test_something.py 15 | 16 | def test_something(gee_hash, gee_folder_root, gee_test_folder): 17 | # this folder is existing within your GEE account and will be deleted at the end of the test session 18 | print(gee_folder_root) 19 | 20 | .. warning:: 21 | 22 | To avoid piling up fake folder in your GEE account, make sure to let the test reach the end of the session. 23 | It means that you should **never** cancel a session with ``ctrl+c`` or by killing the process. 24 | 25 | 26 | Customize the test folder tree 27 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 28 | 29 | By default the test folder tree is empty and will be deleted at the end of the test session. 30 | You can decide to populate it with some assets that will be used in your tests. 31 | 32 | To do so customize the ``gee_folder_structure`` fixture in your ``conftest.py`` file. 33 | This fixture is a ``dict`` that will be used to create the folder tree in GEE. 34 | First you can create containers assets (namely folders or image collections) to store your assets. 35 | These container are simply marked as keys in the dict and specify their types after a "::" symbol as shown in the following example. 36 | assets need to be ``ee.Image`` or ``ee.FeatureCollection`` objects and remaining small as the creation operation is taken care of by the plugin. 37 | Specifically for ``ee.Image`` objects, please use the ``clipToBoundsAndScale`` method to make sure the asset has a geometry and a scale. 38 | 39 | .. code-block:: python 40 | 41 | # conftest.py 42 | 43 | import pytest 44 | 45 | @pytest.fixture(scope="session") 46 | def gee_folder_structure(): 47 | """Override the default test folder structure.""" 48 | point = ee.Geometry.Point([0, 0]) 49 | return { 50 | "folder::Folder": { 51 | "image": ee.Image(1).clipToBoundsAndScale(point.buffer(100), scale=30), 52 | "fc": ee.FeatureCollection(point), 53 | }, 54 | "image_collection::ImageCollection": { 55 | "image1": ee.Image(1).clipToBoundsAndScale(point.buffer(100), scale=30), 56 | "image2": ee.Image(1).clipToBoundsAndScale(point.buffer(100), scale=30), 57 | } 58 | } 59 | 60 | Which will render in your GEE account as: 61 | 62 | .. code-block:: 63 | 64 | 8d98a5be574041a6a54d6def9d915c67/ 65 | └── folder/ 66 | ├── fc (FeatureCollection) 67 | └── image (Image) 68 | └── image_collection/ (ImageCollection) 69 | ├── image1 (Image) 70 | └── image2 (Image) 71 | 72 | Customize the root folder 73 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 74 | 75 | By default the test folder will be created at the root of the user account. There are situation where one could prefer to store it in a specific folder. 76 | 77 | To do so customize the ``gee_folder_root`` fixture in your ``conftest.py`` file, simply return the asset id of the folder you want to use as root. 78 | 79 | .. code-block:: python 80 | 81 | # conftest.py 82 | 83 | import pytest 84 | 85 | @pytest.fixture(scope="session") 86 | def gee_folder_root(): 87 | """Override the default test folder root.""" 88 | return "project/username/assets/my_root_folder" 89 | 90 | .. note:: 91 | 92 | This is compulsory if you use a service account to connect to GEE as the service account has no associated root folder. 93 | 94 | Create assets 95 | ------------- 96 | 97 | Most of tests pipelines are checking different python versions in parallel which can create multiple issues from a GEE perspective: 98 | 99 | - The assets names need to be unique 100 | - The tasks names also need to be unique 101 | 102 | To avoid this issue, the plugin is shipped with a session wise unique hex fixture ``gee_hash`` that can be used to suffix or prefix your assets and tasks names. 103 | To make sure the asset exist when you run your tests, you can use the ``pytest_gee.wait`` method to wait until the asset is effectively generated. 104 | 105 | .. code-block:: python 106 | 107 | # test.py 108 | 109 | import pytest 110 | import pytest_gee 111 | 112 | 113 | def test_create_asset(gee_hash): 114 | # create an asset name 115 | asset_name = f"asset_{gee_hash}" 116 | 117 | # export the an object to this asset 118 | task = ee.batch.Export.image.toAsset( 119 | image=ee.Image(1), 120 | description=asset_name, 121 | assetId=asset_name, 122 | scale=1, 123 | maxPixels=1e9, 124 | ) 125 | task.start() 126 | 127 | # wait for the asset to be created 128 | pytest_gee.wait(task) 129 | 130 | # Do something with the asset name 131 | -------------------------------------------------------------------------------- /pytest_gee/__init__.py: -------------------------------------------------------------------------------- 1 | """The init file of the package.""" 2 | 3 | from __future__ import annotations 4 | 5 | import json 6 | import os 7 | import re 8 | from pathlib import Path 9 | from typing import Union 10 | 11 | import ee 12 | import httplib2 13 | from deprecated.sphinx import deprecated 14 | from ee.cli.utils import wait_for_task 15 | 16 | __version__ = "0.8.1" 17 | __author__ = "Pierrick Rambaud" 18 | __email__ = "pierrick.rambaud49@gmail.com" 19 | 20 | 21 | def init_ee_from_token(): 22 | r"""Initialize earth engine according using a token. 23 | 24 | THe environment used to run the tests need to have a EARTHENGINE_TOKEN variable. 25 | The content of this variable must be the copy of a personal credential file that you can find on your local computer if you already run the earth engine command line tool. See the usage question for a github action example. 26 | 27 | - Windows: ``C:\Users\USERNAME\\.config\\earthengine\\credentials`` 28 | - Linux: ``/home/USERNAME/.config/earthengine/credentials`` 29 | - MacOS: ``/Users/USERNAME/.config/earthengine/credentials`` 30 | 31 | Note: 32 | As all init method of pytest-gee, this method will fallback to a regular ``ee.Initialize()`` if the environment variable is not found e.g. on your local computer. 33 | """ 34 | if "EARTHENGINE_TOKEN" in os.environ: 35 | # read the ee_token from the environment variable 36 | ee_token = os.environ["EARTHENGINE_TOKEN"] 37 | 38 | # small workaround to remove the quotes around the token 39 | # related to a very specific issue with readthedocs interface 40 | # https://github.com/readthedocs/readthedocs.org/issues/10553 41 | pattern = re.compile(r"^'[^']*'$") 42 | ee_token = ee_token[1:-1] if pattern.match(ee_token) else ee_token 43 | 44 | # write the token to the appropriate folder 45 | credential_folder_path = Path.home() / ".config" / "earthengine" 46 | credential_folder_path.mkdir(parents=True, exist_ok=True) 47 | credential_file_path = credential_folder_path / "credentials" 48 | credential_file_path.write_text(ee_token) 49 | 50 | project_id = os.environ.get("EARTHENGINE_PROJECT") 51 | if project_id is None: 52 | raise ValueError( 53 | "The project name cannot be detected." 54 | "Please set the EARTHENGINE_PROJECT environment variable." 55 | ) 56 | 57 | # if the user is in local development the authentication should 58 | # already be available 59 | ee.Initialize(project=project_id, http_transport=httplib2.Http()) 60 | 61 | 62 | def init_ee_from_service_account(): 63 | """Initialize earth engine according using a service account. 64 | 65 | The environment used to run the tests need to have a EARTHENGINE_SERVICE_ACCOUNT variable. 66 | The content of this variable must be the copy of a personal credential file that you can generate from the google cloud console. 67 | 68 | Note: 69 | As all init method of ``pytest-gee``, this method will fallback to a regular ``ee.Initialize`` using the ``EARTHENGINE_PROJECT`` environment variable. 70 | """ 71 | if "EARTHENGINE_SERVICE_ACCOUNT" in os.environ: 72 | # extract the environment variables data 73 | private_key = os.environ["EARTHENGINE_SERVICE_ACCOUNT"] 74 | 75 | # small workaround to remove the quotes around the token 76 | # related to a very specific issue with readthedocs interface 77 | # https://github.com/readthedocs/readthedocs.org/issues/10553 78 | pattern = re.compile(r"^'[^']*'$") 79 | private_key = private_key[1:-1] if pattern.match(private_key) else private_key 80 | 81 | # connect to GEE using a a ServiceAccountCredential object based on the 82 | # private key data 83 | ee_user = json.loads(private_key)["client_email"] 84 | credentials = ee.ServiceAccountCredentials(ee_user, key_data=private_key) 85 | ee.Initialize( 86 | credentials=credentials, project=credentials.project_id, http_transport=httplib2.Http() 87 | ) 88 | 89 | elif "EARTHENGINE_PROJECT" in os.environ: 90 | # if the user is in local development the authentication should already be available 91 | # we simply need to use the provided project name 92 | ee.Initialize(project=os.environ["EARTHENGINE_PROJECT"], http_transport=httplib2.Http()) 93 | 94 | else: 95 | msg = "EARTHENGINE_SERVICE_ACCOUNT or EARTHENGINE_PROJECT environment variable is missing" 96 | raise ValueError(msg) 97 | 98 | 99 | @deprecated(version="0.3.5", reason="Use the vanilla GEE ``wait_for_task`` function instead.") 100 | def wait(task: Union[ee.batch.Task, str], timeout: int = 5 * 60) -> str: 101 | """Wait until the selected process is finished or we reached timeout value. 102 | 103 | Args: 104 | task: name of the running task or the Task object itself. 105 | timeout: timeout in seconds. if set to 0 the parameter is ignored. default to 5 minutes. 106 | 107 | Returns: 108 | the final state of the task 109 | """ 110 | # just expose the utils function 111 | # this is compulsory as wait is also needed in the utils module 112 | task_id = task.id if isinstance(task, ee.batch.Task) else task 113 | return wait_for_task(task_id, timeout, log_progress=False) 114 | -------------------------------------------------------------------------------- /tests/test_pytest_gee.py: -------------------------------------------------------------------------------- 1 | """Test the pytest_gee package.""" 2 | 3 | import ee 4 | 5 | import pytest_gee 6 | 7 | landsat_image = "LANDSAT/LC08/C02/T1_L2/LC08_191031_20240607" 8 | "landsat image from 2024-06-07 on top of Rome" 9 | 10 | 11 | def test_hash_fixture(gee_hash): 12 | """Test the hash fixture.""" 13 | assert isinstance(gee_hash, str) 14 | assert len(gee_hash) == 32 15 | 16 | 17 | def test_gee_init(): 18 | """Test the init_ee_from_token function.""" 19 | assert ee.Number(1).getInfo() == 1 20 | 21 | 22 | def test_structure(gee_folder_structure): 23 | """Test the structure fixture.""" 24 | assert isinstance(gee_folder_structure, dict) 25 | assert "folder::Folder" in gee_folder_structure 26 | assert "image" in gee_folder_structure["folder::Folder"] 27 | assert "fc" in gee_folder_structure["folder::Folder"] 28 | assert "ic::ImageCollection" in gee_folder_structure 29 | assert "image1" in gee_folder_structure["ic::ImageCollection"] 30 | assert "image2" in gee_folder_structure["ic::ImageCollection"] 31 | 32 | 33 | def test_init_tree(gee_folder_root, gee_test_folder): 34 | """Test the init_tree function.""" 35 | # search all the assets contained in the test_folder 36 | asset_list = pytest_gee.utils.get_assets(gee_folder_root) 37 | asset_list = [i["name"] for i in asset_list] 38 | 39 | # identify specific files and folders 40 | folder = gee_test_folder / "folder" 41 | image = folder / "image" 42 | feature_collection = folder / "fc" 43 | 44 | # check that they exist 45 | assert str(gee_test_folder) in asset_list 46 | assert str(folder) in asset_list 47 | assert str(image) in asset_list 48 | assert str(feature_collection) in asset_list 49 | 50 | 51 | def test_list_regression(ee_list_regression): 52 | """Test the ee_list_regression fixture.""" 53 | data = ee.List([1, 2, 3]) 54 | ee_list_regression.check(data) 55 | 56 | 57 | def test_list_regression_prescision(ee_list_regression): 58 | """Test the ee_list_regression fixture with a different precision.""" 59 | data = ee.List([1.123456789, 2.123456789, 3.123456789]) 60 | ee_list_regression.check(data, prescision=3) 61 | 62 | 63 | def test_feature_collection_regression(ee_feature_collection_regression): 64 | """Test the ee_feature_collection_regression fixture.""" 65 | fc = ee.FeatureCollection("FAO/GAUL/2015/level0").filter(ee.Filter.eq("ADM0_NAME", "Holy See")) 66 | ee_feature_collection_regression.check(fc) 67 | 68 | 69 | def test_feature_collection_regression_prescision(ee_feature_collection_regression): 70 | """Test the ee_feature_collection_regression fixture.""" 71 | fc = ee.FeatureCollection("FAO/GAUL/2015/level0").filter(ee.Filter.eq("ADM0_NAME", "Holy See")) 72 | ee_feature_collection_regression.check(fc, prescision=4) 73 | 74 | 75 | def test_feature_collection_regression_no_index(ee_feature_collection_regression): 76 | """Test the ee_feature_collection_regression fixture.""" 77 | point = ee.Geometry.Point([0, 0]) 78 | size = ee.List.sequence(50, 100, 10) 79 | geometries = size.map(lambda s: point.buffer(s, ee.Number(s).divide(5))) 80 | fc = ee.FeatureCollection(geometries.map(lambda g: ee.Feature(ee.Geometry(g)))) 81 | ee_feature_collection_regression.check(fc, drop_index=True, prescision=4) 82 | 83 | 84 | def test_dictionary_regression(ee_dictionary_regression): 85 | """Test the ee_dictionary_regression fixture.""" 86 | data = ee.Dictionary({"a": 1, "b": 2}) 87 | ee_dictionary_regression.check(data) 88 | 89 | 90 | def test_dictionary_regression_prescision(ee_dictionary_regression): 91 | """Test the ee_dictionary_regression fixture with a different precision.""" 92 | data = ee.Dictionary({"a": 1.123456789, "b": 2.123456789}) 93 | ee_dictionary_regression.check(data, prescision=3) 94 | 95 | 96 | def test_image_regression_3_bands(ee_image_regression): 97 | """Test the image_regression fixture.""" 98 | image = ee.Image(landsat_image).select(["SR_B4", "SR_B3", "SR_B2"]) 99 | ee_image_regression.check(image, scale=1000) 100 | 101 | 102 | def test_image_regression_1_band(ee_image_regression): 103 | """Test the image_regression fixture.""" 104 | image = ee.Image(landsat_image).normalizedDifference(["SR_B5", "SR_B4"]) 105 | ee_image_regression.check(image, scale=1000) 106 | 107 | 108 | def test_image_regression_with_viz(ee_image_regression): 109 | """Test the image_regression fixture.""" 110 | image = ee.Image(landsat_image).normalizedDifference(["SR_B5", "SR_B4"]) 111 | # use magma palette and stretched to 2 sigma 112 | palette = ["#000004", "#2C105C", "#711F81", "#B63679", "#EE605E", "#FDAE78", "#FCFDBF"] 113 | viz = {"bands": ["nd"], "min": 0.0122, "max": 1.237, "palette": palette} 114 | ee_image_regression.check(image, scale=1000, viz_params=viz) 115 | 116 | 117 | def test_image_regression_with_region(ee_image_regression): 118 | """Test the image_regression fixture.""" 119 | image = ee.Image(landsat_image).normalizedDifference(["SR_B5", "SR_B4"]) 120 | vatican = ee.Geometry.Point([12.453585, 41.903115]).buffer(100) 121 | ee_image_regression.check(image, scale=30, region=vatican) 122 | 123 | 124 | def test_image_regression_with_overlay(ee_image_regression): 125 | """Test the image_regression fixture with overlay param.""" 126 | image = ee.Image(landsat_image).select(["SR_B4", "SR_B3", "SR_B2"]) 127 | centroid = image.geometry().centroid() 128 | overlay = ee.FeatureCollection( 129 | [ee.Feature(centroid, {"style": {"color": "red", "pointShape": "plus", "pointSize": 10}})] 130 | ) 131 | ee_image_regression.check(image, scale=100, region=centroid.buffer(20000), overlay=overlay) 132 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.rst: -------------------------------------------------------------------------------- 1 | Contributor Covenant Code of Conduct 2 | ==================================== 3 | 4 | Our Pledge 5 | ---------- 6 | 7 | We as members, contributors, and leaders pledge to make participation in our 8 | community a harassment-free experience for everyone, regardless of age, body 9 | size, visible or invisible disability, ethnicity, sex characteristics, gender 10 | identity and expression, level of experience, education, socioeconomic status, 11 | nationality, personal appearance, race, religion, or sexual identity 12 | and orientation. 13 | 14 | We pledge to act and interact in ways that contribute to an open, welcoming, 15 | diverse, inclusive, and healthy community. 16 | 17 | Our Standards 18 | ------------- 19 | 20 | Examples of behavior that contributes to a positive environment for our 21 | community include: 22 | 23 | * Demonstrating empathy and kindness toward other people 24 | * Being respectful of differing opinions, viewpoints, and experiences 25 | * Giving and gracefully accepting constructive feedback 26 | * Accepting responsibility and apologizing to those affected by our mistakes, 27 | and learning from the experience 28 | * Focusing on what is best not just for us as individuals, but for the 29 | overall community 30 | 31 | Examples of unacceptable behavior include: 32 | 33 | * The use of sexualized language or imagery, and sexual attention or 34 | advances of any kind 35 | * Trolling, insulting or derogatory comments, and personal or political attacks 36 | * Public or private harassment 37 | * Publishing others' private information, such as a physical or email 38 | address, without their explicit permission 39 | * Other conduct which could reasonably be considered inappropriate in a 40 | professional setting 41 | 42 | Enforcement Responsibilities 43 | ---------------------------- 44 | 45 | Community leaders are responsible for clarifying and enforcing our standards of 46 | acceptable behavior and will take appropriate and fair corrective action in 47 | response to any behavior that they deem inappropriate, threatening, offensive, 48 | or harmful. 49 | 50 | Community leaders have the right and responsibility to remove, edit, or reject 51 | comments, commits, code, wiki edits, issues, and other contributions that are 52 | not aligned to this Code of Conduct, and will communicate reasons for moderation 53 | decisions when appropriate. 54 | 55 | Scope 56 | ----- 57 | 58 | This Code of Conduct applies within all community spaces, and also applies when 59 | an individual is officially representing the community in public spaces. 60 | Examples of representing our community include using an official e-mail address, 61 | posting via an official social media account, or acting as an appointed 62 | representative at an online or offline event. 63 | 64 | Enforcement 65 | ----------- 66 | 67 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 68 | reported to the FAO team responsible for enforcement at 69 | pierrick.rambaud49@gmail.com. 70 | All complaints will be reviewed and investigated promptly and fairly. 71 | 72 | All community leaders are obligated to respect the privacy and security of the 73 | reporter of any incident. 74 | 75 | Enforcement Guidelines 76 | ---------------------- 77 | 78 | Community leaders will follow these Community Impact Guidelines in determining 79 | the consequences for any action they deem in violation of this Code of Conduct: 80 | 81 | Correction 82 | ^^^^^^^^^^ 83 | 84 | **Community Impact**: Use of inappropriate language or other behavior deemed 85 | unprofessional or unwelcome in the community. 86 | 87 | **Consequence**: A private, written warning from community leaders, providing 88 | clarity around the nature of the violation and an explanation of why the 89 | behavior was inappropriate. A public apology may be requested. 90 | 91 | Warning 92 | ^^^^^^^ 93 | 94 | **Community Impact**: A violation through a single incident or series 95 | of actions. 96 | 97 | **Consequence**: A warning with consequences for continued behavior. No 98 | interaction with the people involved, including unsolicited interaction with 99 | those enforcing the Code of Conduct, for a specified period of time. This 100 | includes avoiding interactions in community spaces as well as external channels 101 | like social media. Violating these terms may lead to a temporary or 102 | permanent ban. 103 | 104 | Temporary Ban 105 | ^^^^^^^^^^^^^ 106 | 107 | **Community Impact**: A serious violation of community standards, including 108 | sustained inappropriate behavior. 109 | 110 | **Consequence**: A temporary ban from any sort of interaction or public 111 | communication with the community for a specified period of time. No public or 112 | private interaction with the people involved, including unsolicited interaction 113 | with those enforcing the Code of Conduct, is allowed during this period. 114 | Violating these terms may lead to a permanent ban. 115 | 116 | Permanent Ban 117 | ^^^^^^^^^^^^^ 118 | 119 | **Community Impact**: Demonstrating a pattern of violation of community 120 | standards, including sustained inappropriate behavior, harassment of an 121 | individual, or aggression toward or disparagement of classes of individuals. 122 | 123 | **Consequence**: A permanent ban from any sort of public interaction within 124 | the community. 125 | 126 | Attribution 127 | ----------- 128 | 129 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 130 | version 2.0, available at 131 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 132 | 133 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 134 | enforcement ladder](https://github.com/mozilla/diversity). 135 | 136 | [homepage]: https://www.contributor-covenant.org 137 | 138 | For answers to common questions about this code of conduct, see the FAQ at 139 | https://www.contributor-covenant.org/faq. Translations are available at 140 | https://www.contributor-covenant.org/translations. 141 | -------------------------------------------------------------------------------- /pytest_gee/image_regression.py: -------------------------------------------------------------------------------- 1 | """implementation of the ``image_regression`` fixture.""" 2 | 3 | import os 4 | from contextlib import suppress 5 | from typing import Optional, Union 6 | 7 | import ee 8 | import requests 9 | from pytest import fail 10 | from pytest_regressions.image_regression import ImageRegressionFixture 11 | 12 | from .utils import build_fullpath, check_serialized 13 | 14 | 15 | class ImageFixture(ImageRegressionFixture): 16 | """Fixture for regression testing of :py:class:`ee.Image`.""" 17 | 18 | def check( 19 | self, 20 | data_image: ee.Image, 21 | diff_threshold: float = 0.1, 22 | expect_equal: bool = True, 23 | basename: Optional[str] = None, 24 | fullpath: Optional[os.PathLike] = None, 25 | scale: Optional[int] = 30, 26 | viz_params: Optional[dict] = None, 27 | region: Optional[Union[ee.FeatureCollection, ee.Feature, ee.Geometry]] = None, 28 | overlay: Optional[ee.FeatureCollection] = None, 29 | ): 30 | """Check the given image against a previously recorded version, or generate a new file. 31 | 32 | This method will create a thumbnail version of the requested image. It is made to allow a human user to check the result of the 33 | Computation. The thumbnail will be computed on the fly using earthengine. This mean that the test must be reasonable in size and scale. 34 | We will perform no feasibility checks and your computation might crash if you are too greedy. 35 | The input image will be either a single band image (displayed using black&white colormap) or a 3 band image (displayed using as fake RGB bands). 36 | If the ``viz_params`` parameter is omitted then it will detect the available ands, and use default viz params. 37 | 38 | Parameters: 39 | data_image: The image to check. The image needs to be clipped to a geometry or have an existing footprint. 40 | diff_threshold: The threshold for the difference between the expected and obtained images. 41 | expect_equal: If ``True`` the images are expected to be equal, otherwise they are expected to be different. 42 | basename: The basename of the file to test/record. If not given the name of the test is used. 43 | fullpath: complete path to use as a reference file. This option will ignore ``datadir`` fixture when reading *expected* files but will still use it to write *obtained* files. Useful if a reference file is located in the session data dir for example. 44 | scale: The scale to use for the thumbnail. 45 | viz_params: The visualization parameters to use for the thumbnail. If not given, the min and max values of the image will be used. 46 | region: The region to use for clipping the image. If not given, the image's region will be used. 47 | overlay: A FeatureCollection to draw on top of the image. The style will be taken from each Feature's "style" property. 48 | """ 49 | # rescale the original image 50 | region = data_image if region is None else region 51 | geometry = region if isinstance(region, ee.Geometry) else region.geometry() 52 | data_image = data_image.clipToBoundsAndScale(geometry, scale=scale) 53 | 54 | # build the different filename to be consistent between our 3 checks 55 | data_name = build_fullpath( 56 | datadir=self.original_datadir, 57 | request=self.request, 58 | extension=".png", 59 | basename=basename, 60 | fullpath=fullpath, 61 | with_test_class_names=self.with_test_class_names, 62 | ) 63 | serialized_name = data_name.with_stem(f"serialized_{data_name.stem}").with_suffix(".yml") 64 | 65 | # check the previously registered serialized call from GEE. If it matches the current call, 66 | # we don't need to check the data 67 | with suppress(AssertionError, fail.Exception): 68 | check_serialized( 69 | object=data_image, 70 | path=serialized_name, 71 | datadir=self.datadir, 72 | original_datadir=self.original_datadir, 73 | request=self.request, 74 | with_test_class_names=self.with_test_class_names, 75 | ) 76 | return 77 | 78 | # delete the previously created file if wasn't successful 79 | serialized_name.unlink(missing_ok=True) 80 | 81 | # extract min and max for visualization 82 | minMax = data_image.reduceRegion(ee.Reducer.minMax(), geometry, scale) 83 | 84 | # create visualization parameters based on the computed minMax values 85 | if viz_params is None: 86 | nbBands = ee.Algorithms.If(data_image.bandNames().size().gte(3), 3, 1) 87 | bands = data_image.bandNames().slice(0, ee.Number(nbBands)) 88 | min = bands.map(lambda b: minMax.get(ee.String(b).cat("_min"))) 89 | max = bands.map(lambda b: minMax.get(ee.String(b).cat("_max"))) 90 | viz_params = ee.Dictionary({"bands": bands, "min": min, "max": max}).getInfo() 91 | 92 | rgb = data_image.visualize(**viz_params) 93 | if overlay: 94 | rgb = rgb.blend(overlay.style(styleProperty="style")) 95 | 96 | # get the thumbnail image 97 | thumb_url = rgb.getThumbURL() 98 | byte_data = requests.get(thumb_url).content 99 | 100 | # if it needs to be checked, we need to round the float values to the same precision as the 101 | # reference file 102 | try: 103 | super().check(byte_data, diff_threshold, expect_equal, fullpath=data_name) 104 | 105 | # IF we are here it means the data has been modified so we edit the API call accordingly 106 | # to make sure next run will not be forced to call the API for a response. 107 | with suppress(AssertionError, fail.Exception): 108 | check_serialized( 109 | object=data_image, 110 | path=serialized_name, 111 | datadir=self.datadir, 112 | original_datadir=self.original_datadir, 113 | request=self.request, 114 | with_test_class_names=self.with_test_class_names, 115 | force_regen=True, 116 | ) 117 | 118 | except (AssertionError, fail.Exception) as e: 119 | raise e 120 | -------------------------------------------------------------------------------- /docs/content/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | Getting started 5 | --------------- 6 | 7 | Use pip/conda to install **pytest-gee** in your environment: 8 | 9 | .. tab-set:: 10 | 11 | .. tab-item:: pip 12 | 13 | .. code-block:: console 14 | 15 | pip install pytest-gee 16 | 17 | .. tab-item:: conda 18 | 19 | .. code-block:: console 20 | 21 | conda install -c conda-forge pytest-gee 22 | 23 | It will then be automatically detected by ``pytest`` when you run your test suit. 24 | 25 | Connect To Google Earth Engine 26 | ------------------------------ 27 | 28 | The main purpose of this plugin is to facilitate the connection to Google Earth Engine API in both CI/CD tests and local tests. 29 | To do so, the lib will provide a number of connection methods that will hopefully cover your favorite way of connecting to GEE. 30 | 31 | .. note:: 32 | 33 | If you would like us to code an extra connection method please `open an issue `__ on the github repo and never forget that contribution are very welcome! 34 | 35 | .. note:: 36 | 37 | All the methods presented in this section will fallback to a regular ``ee.Initialize()`` if the environment parameter are not found. 38 | This means that you can use this plugin in your local environment without having to change anything as long as the ``ee`` module is installed and that you already run once ``ee.Authenticate()``. 39 | 40 | .. danger:: 41 | 42 | Never forget that this method can potentially expose your personal credential to GEE so take some safety precautions before starting: 43 | 44 | - make sure the CI/CD platform support private variable (that are not exposed in the build logs) 45 | - make sure to review PR from new users before starting the build to make sure nobody steal your credentials 46 | - make sure the account you are using will have access to all the assets you need to run your tests 47 | - create small tests that will run quickly to make sure you don't overload your own GEE account with concurrent tasks 48 | 49 | Private Token 50 | ^^^^^^^^^^^^^ 51 | 52 | The first method is to use a private token. This is the easiest way to connect to GEE in a CI/CD environment. 53 | 54 | First authenticate to GEE API in your local computer using ``ee.Authenticate()``. 55 | 56 | Then copy the ``credentials`` file content. This file is located in a different folder depending on the platform you use: 57 | 58 | - Windows: ``C:\Users\USERNAME\\.config\\earthengine\\credentials`` 59 | - Linux: ``/home/USERNAME/.config/earthengine/credentials`` 60 | - MacOS: ``/Users/USERNAME/.config/earthengine/credentials`` 61 | 62 | Paste this content in your CI/CD environment in a ``EARTHENGINE_TOKEN`` variable. 63 | 64 | Here is a github action example: 65 | 66 | .. image:: ../_static/github_env_var.png 67 | :alt: Github action environment variable setup 68 | :align: center 69 | 70 | #. First go to the :guilabel:`settings`` of your Github repository 71 | #. Then to :guilabel:`secretes and variables` -> :guilabel:`Actions` 72 | #. In this page, set a :guilabel:`new repository secret` with the name ``EARTHENGINE_TOKEN`` and paste the content of your ``credentials`` file in the value field. 73 | 74 | Since earthengine-api v0.1.370, it's not possible to use EE without providing a GCS project bucket. Save this value in a `EARTHENGINE_PROJECT` variable, it will be used in the method. 75 | 76 | To make the variable available in your CI environment, you will need to add the following line in your action `.yaml` file: 77 | 78 | .. code-block:: yaml 79 | 80 | # .github/action.yaml 81 | 82 | env: 83 | EARTHENGINE_TOKEN: ${{ secrets.EARTHENGINE_TOKEN }} 84 | EARTHENGINE_PROJECT: ${{ secrets.EARTHENGINE_PROJECT }} 85 | 86 | # The rest of your tests configuration 87 | 88 | When working in your local environment export a ``EARTHENGINE_PROJECT`` variable as well: 89 | 90 | .. code-block:: console 91 | 92 | export EARTHENGINE_PROJECT=ee-community 93 | 94 | Finally you need to configure the ``pytest`` execution environment itself. Add the following line in your ``conftest.py`` file: 95 | 96 | .. code-block:: python 97 | 98 | # conftest.py 99 | 100 | import pytest_gee 101 | 102 | 103 | def pytest_configure(): 104 | pytest_gee.init_ee_from_token() 105 | 106 | You are now ready to make API calls within your tests! 107 | 108 | Service account 109 | ^^^^^^^^^^^^^^^ 110 | 111 | .. warning:: 112 | 113 | This documentation assumes that you already have a Google cloud service account and that you have generated an API key for it. If not, please refer to Google own `documentation `__ to proceed. 114 | 115 | Paste this content of the `private-key.json` in your CI/CD environment in a ``EARTHENGINE_SERVICE_ACCOUNT`` variable. 116 | 117 | Here is a github action example: 118 | 119 | .. image:: ../_static/github_env_var.png 120 | :alt: Github action environment variable setup 121 | :align: center 122 | 123 | #. First go to the :guilabel:`settings` of your Github repository 124 | #. Then to :guilabel:`secretes and variables` -> :guilabel:`Actions` 125 | #. In this page, set a :guilabel:`new repository secret` with the name ``EARTHENGINE_SERVICE_ACCOUNT`` and paste the content of your ``credentials`` file in the value field. 126 | 127 | Currently when the earthengine-api is Initialized using a service account, the name of the associated cloud project is not detectable. It will prevent the initialization of the test folder generated from `pytest-gee`. To avoid this issue the method rely also on a ``EARTHENGINE_PROJECT`` env variable where you can set the name of your project. 128 | 129 | To make the variable available in your CI environment, you will need to add the following line in your action `.yaml` file: 130 | 131 | .. code-block:: yaml 132 | 133 | # .github/action.yaml 134 | 135 | env: 136 | EARTHENGINE_SERVICE_ACCOUNT: ${{ secrets.EARTHENGINE_SERVICE_ACCOUNT }} 137 | EARTHENGINE_PROJECT: ${{ secrets.EARTHENGINE_PROJECT }} 138 | 139 | # The rest of your tests configuration 140 | 141 | When working in your local environment export a ``EARTHENGINE_PROJECT`` variable as well: 142 | 143 | .. code-block:: console 144 | 145 | export EARTHENGINE_PROJECT=ee-community 146 | 147 | Finally you need to configure the ``pytest`` execution environment itself. Add the following line in your ``conftest.py`` file: 148 | 149 | .. code-block:: python 150 | 151 | # conftest.py 152 | 153 | import pytest_gee 154 | 155 | 156 | def pytest_configure(): 157 | pytest_gee.init_ee_from_service_account() 158 | 159 | You are now ready to make API calls within your tests! 160 | -------------------------------------------------------------------------------- /docs/content/regression.rst: -------------------------------------------------------------------------------- 1 | Regression fixtures 2 | =================== 3 | 4 | **pytest-gee** fixtures that will make it easy to generate test data for your test suits using Earth 5 | Engine server side objects as sources. It is based on the ``pytest-regressions`` plugin and add GEE 6 | data management backend. We highly recommend users to read their `documentation `__ 7 | to understand how to use some parameter of the fixtures like the saving directory and the management 8 | of parametrized tests. 9 | 10 | .. note:: 11 | 12 | The fixtures provided by this plugin are based on the ``pytest-regressions`` plugin and are fully 13 | compatible with it. This means that you can use the ``pytest-regressions`` fixtures and the ``pytest-gee`` 14 | fixtures in the same test suit. 15 | 16 | ee_list_regression 17 | ------------------ 18 | 19 | Suppose the output of our test is a :py:class:`ee.List` object containing informmation. We could 20 | test the result as follow: 21 | 22 | .. code-block:: python 23 | 24 | def test_list(): 25 | list = ee.List([ee.Number(0.11111111), ee.String("test")]) 26 | assert list.getInfo()[0] == 0.11111111 27 | assert list.getInfo()[1] == "test" 28 | 29 | But this presents a number of problems: 30 | 31 | - Gets old quickly. 32 | - Error-prone. 33 | - If a check fails, we don’t know what else might be wrong with the obtained data. 34 | - Does not scale for large data. 35 | 36 | Instead, we can use the :py:func:`ee_list_regression ` fixture to save 37 | the expected output and compare it with the obtained output in a humanly readable format: 38 | 39 | .. code-block:: python 40 | 41 | def test_list(ee_list_regression): 42 | list = ee.List([ee.Number(0.11111111), ee.String("test")]) 43 | ee_list_regression.check(list) 44 | 45 | That will be saved in a yaml file: 46 | 47 | .. code-block:: yaml 48 | 49 | # //.yaml 50 | - 0.11111111 51 | - test 52 | 53 | ee_dictionary_regression 54 | ------------------------ 55 | 56 | Suppose the output of our test is a :py:class:`ee.Dictionary` object containing informmation. We could 57 | test the result as follow: 58 | 59 | .. code-block:: python 60 | 61 | def test_dict(): 62 | dict = ee.Dictionary({"key1": ee.Number(0.11111111), "key2": ee.String("test")}) 63 | assert dict.getInfo()["key1"] == 0.11111111 64 | assert dict.getInfo()["key2"] == "test" 65 | 66 | But this presents a number of problems: 67 | 68 | - Gets old quickly. 69 | - Error-prone. 70 | - If a check fails, we don’t know what else might be wrong with the obtained data. 71 | - Does not scale for large data. 72 | 73 | Instead, we can use the :py:func:`ee_dictionary_regression ` fixture 74 | to save the expected output and compare it with the obtained output in a humanly readable format: 75 | 76 | .. code-block:: python 77 | 78 | def test_dict(ee_dictionary_regression): 79 | dict = ee.Dictionary({"key1": ee.Number(0.11111111), "key2": ee.String("test")}) 80 | ee_dictionary_regression.check(dict) 81 | 82 | That will be saved in a yaml file: 83 | 84 | .. code-block:: yaml 85 | 86 | # //.yaml 87 | key1: 0.11111111 88 | key2: test 89 | 90 | 91 | ee_feature_collection_regression 92 | -------------------------------- 93 | 94 | Suppose the output of our test is a :py:class:`ee.FeatureCollection` object containing informmation. 95 | We could test the result as follow: 96 | 97 | .. code-block:: python 98 | 99 | def test_fc(): 100 | fc = ee.FeatureCollection([ee.Feature(ee.Geometry.Point([0, 0]), {"key1": 0.11111111, "key2": "test"})]) 101 | assert fc.getInfo()["features"][0]["properties"]["key1"] == 0.11111111 102 | assert fc.getInfo()["features"][0]["properties"]["key2"] == "test" 103 | assert fc.getInfo()["features"][0]["geometry"]["type"] == "Point" 104 | 105 | Instead we can use the :py:func:`ee_feature_collection_regression ` 106 | fixture to save the expected output and compare it with the obtained output in a humanly readable format 107 | compatible with the ``geo_interface`` standard: 108 | 109 | .. code-block:: python 110 | 111 | def test_fc(ee_feature_collection_regression): 112 | fc = ee.FeatureCollection([ee.Feature(ee.Geometry.Point([0, 0]), {"key1": 0.11111111, "key2": "test"})]) 113 | ee_feature_collection_regression.check(fc) 114 | 115 | That will be saved in a yaml file: 116 | 117 | .. code-block:: yaml 118 | 119 | # //.yaml 120 | type: FeatureCollection 121 | features: 122 | - type: Feature 123 | geometry: 124 | type: Point 125 | coordinates: 126 | - 0 127 | - 0 128 | properties: 129 | key1: 0.11111111 130 | key2: test 131 | 132 | ee_image_regression 133 | ------------------- 134 | 135 | Suppose the output of our test is a :py:class:`ee.Image` object containing informmation. We could test the result as follow: 136 | 137 | .. code-block:: python 138 | 139 | import ee 140 | import pytest 141 | 142 | def test_image(): 143 | image = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_191031_20240607") 144 | image = image.normalizedDifference(["SR_B5", "SR_B4"]) 145 | assert image.bandNames().size().getInfo() == 1 146 | 147 | Instead we can use the :py:func:`ee_image_regression ` fixture to save the expected output and compare 148 | it with the obtained output as a processed `.png` image: 149 | 150 | .. code-block:: python 151 | 152 | import ee 153 | import pytest 154 | 155 | def test_image(ee_image_regression): 156 | image = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_191031_20240607") 157 | image = image.normalizedDifference(["SR_B5", "SR_B4"]) 158 | ee_image_regression.check(image, scale=1000) 159 | 160 | That will be saved in a png file: 161 | 162 | .. image:: ../_static/ee_image_regression.png 163 | :alt: ee.Image regression 164 | 165 | :py:func:`ee_image_regression ` comes with more extra options than the other regressions as you need to give information 166 | about the scale, region and bands you want to see on the final output. First the image must be **small** as the 167 | image is generated via the :py:meth:`ee.Image.getThumbURL` method hat has a maximum limit of pixels. 168 | By default the fixture will autodetect the number of bands and display either a black&white single band or a 169 | RGB representation of the 3 first bands. You can customize the output by passing the ``viz_params`` argument to the 170 | :py:func:`check() ` method. The ``viz_params`` argument is a dictionary that 171 | is the same as the one used in the :py:meth:`ee.Image.getThumbURL` method. For example to display the image in 172 | magma colormap with the value stretched to 2 standard deviation (instead of the default min-max) you can do: 173 | 174 | .. code-block:: python 175 | 176 | import ee 177 | import pytest 178 | 179 | def test_image(ee_image_regression): 180 | image = ee.Image("LANDSAT/LC08/C02/T1_L2/LC08_191031_20240607") 181 | image = image.normalizedDifference(["SR_B5", "SR_B4"]) 182 | palette = ["#000004", "#2C105C", "#711F81", "#B63679", "#EE605E", "#FDAE78", "#FCFDBF"] 183 | viz = {"bands": ["nd"], "min": 0.0122, "max": 1.237, "palette": palette} 184 | ee_image_regression.check(image, viz_params=viz, scale=1000) 185 | 186 | .. image:: ../_static/ee_image_regression_viz.png 187 | :alt: ee.Image regression with custom viz_params 188 | -------------------------------------------------------------------------------- /docs/_static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /pytest_gee/utils.py: -------------------------------------------------------------------------------- 1 | """functions used to build the API that we don't want to expose to end users. 2 | 3 | .. danger:: 4 | 5 | This module is for internal use only and should not be used directly. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import json 11 | import os 12 | import re 13 | from functools import partial 14 | from pathlib import Path, PurePosixPath 15 | from typing import List, Optional, Union 16 | from warnings import warn 17 | 18 | import ee 19 | import pytest 20 | import yaml 21 | from deprecated.sphinx import deprecated 22 | from ee.cli.utils import wait_for_task 23 | from pytest_regressions.common import check_text_files, perform_regression_check 24 | from pytest_regressions.data_regression import RegressionYamlDumper 25 | 26 | TASK_FINISHED_STATES: tuple[str, str, str] = ( 27 | ee.batch.Task.State.COMPLETED, 28 | ee.batch.Task.State.FAILED, 29 | ee.batch.Task.State.CANCELLED, 30 | ) 31 | 32 | 33 | @deprecated(version="0.3.5", reason="Use the vanilla GEE ``wait_for_task`` function instead.") 34 | def wait(task: Union[ee.batch.Task, str], timeout: int = 10 * 60) -> str: 35 | """Wait until the selected process is finished or we reached timeout value. 36 | 37 | Args: 38 | task: name of the running task or the Task object itself. 39 | timeout: timeout in seconds. if set to 0 the parameter is ignored. default to 1 minutes. 40 | 41 | Returns: 42 | the final state of the task 43 | """ 44 | task_id = task.id if isinstance(task, ee.batch.Task) else task 45 | return wait_for_task(task_id, timeout, log_progress=False) 46 | 47 | 48 | def get_task(task_descripsion: str) -> Optional[ee.batch.Task]: 49 | """Search for the described task in the user Task list return None if nothing is found. 50 | 51 | Args: 52 | task_descripsion: the task description 53 | 54 | Returns: 55 | return the found task else None 56 | """ 57 | task = None 58 | for t in ee.batch.Task.list(): 59 | if t.config["description"] == task_descripsion: 60 | task = t 61 | break 62 | 63 | return task 64 | 65 | 66 | def get_assets(folder: Union[str, Path]) -> List[dict]: 67 | """Get all the assets from the parameter folder. every nested asset will be displayed. 68 | 69 | Args: 70 | folder: the initial GEE folder 71 | 72 | Returns: 73 | the asset list. each asset is a dict with 3 keys: 'type', 'name' and 'id' 74 | """ 75 | # set the folder and init the list 76 | asset_list: list = [] 77 | folder = folder if isinstance(folder, str) else folder.as_posix() 78 | 79 | # recursive function to get all the assets 80 | def _recursive_get(folder, asset_list): 81 | for asset in ee.data.listAssets({"parent": folder})["assets"]: 82 | asset_list.append(asset) 83 | if asset["type"] in ["FOLDER", "IMAGE_COLLECTION"]: 84 | asset_list = _recursive_get(asset["name"], asset_list) 85 | return asset_list 86 | 87 | return _recursive_get(folder, asset_list) 88 | 89 | 90 | def export_asset( 91 | object: ee.ComputedObject, asset_id: Union[str, Path], description: str 92 | ) -> PurePosixPath: 93 | """Export assets to the GEE platform, only working for very simple objects. 94 | 95 | Args: 96 | object: the object to export 97 | asset_id: the name of the asset to create 98 | description: the description of the task 99 | 100 | Returns: 101 | the path of the created asset 102 | """ 103 | # convert the asset_id to a string note that GEE only supports unix style separator 104 | asset_id = asset_id if isinstance(asset_id, str) else asset_id.as_posix() 105 | 106 | if isinstance(object, ee.FeatureCollection): 107 | task = ee.batch.Export.table.toAsset( 108 | collection=object, 109 | description=description, 110 | assetId=asset_id, 111 | ) 112 | elif isinstance(object, ee.Image): 113 | task = ee.batch.Export.image.toAsset( 114 | region=object.geometry(), 115 | image=object, 116 | description=description, 117 | assetId=asset_id, 118 | ) 119 | else: 120 | raise ValueError("Only ee.Image and ee.FeatureCollection are supported") 121 | 122 | # launch the task and wait for the end of exportation 123 | task.start() 124 | wait_for_task(task.id, 10 * 60, False) 125 | 126 | return PurePosixPath(asset_id) 127 | 128 | 129 | def _create_container(asset_request: str) -> str: 130 | """Create a container for the asset request depending on the requested type. 131 | 132 | Args: 133 | asset_request: the asset request specifying the type of asset to create. Convention is :: 134 | 135 | Returns: 136 | the asset_id of the container 137 | """ 138 | # deprecation management for older version of the lib 139 | parts = asset_request.split("::") 140 | if len(parts) == 1: 141 | parts.append("Folder") 142 | warn(f"Asset {asset_request} is not specifying asset Type, it will be created as a FOLDER.") 143 | 144 | # extract the asset_id and the asset_type from the different parts 145 | # if more than 2 splits are identified they will be ignored 146 | asset_id, asset_type = parts[:2] 147 | 148 | # create the container 149 | if asset_type in ["Folder", "ImageCollection"]: 150 | ee.data.createAsset({"type": asset_type}, asset_id) 151 | else: 152 | raise ValueError(f"Asset type {asset_type} is not supported.") 153 | 154 | return asset_id 155 | 156 | 157 | def init_tree(structure: dict, prefix: str, root: Union[str, PurePosixPath]) -> PurePosixPath: 158 | """Create an EarthEngine folder tree from a dictionary. 159 | 160 | The input ditionary should described the structure of the folder you want to create. 161 | The keys are the folder names and the values are the subfolders. 162 | Once you reach an ``ee.FeatureCollection`` and/or an ``ee.Image`` set it in the dictionary and the function will export the object. 163 | 164 | Args: 165 | structure: the structure of the folder to create 166 | prefix: the prefix to use on every item (folder, tasks, asset_id, etc.) 167 | root: the root folder of the test where to create the test folder. 168 | 169 | Returns: 170 | the path of the created folder 171 | 172 | Examples: 173 | >>> structure = { 174 | ... "folder_1": { 175 | ... "image": ee.image(1), 176 | ... "fc": ee.FeatureCollection(ee.Geometry.Point([0, 0])), 177 | ... }, 178 | ... } 179 | ... init_tree(structure, "toto") 180 | """ 181 | 182 | # recursive function to create the folder tree 183 | def _recursive_create(structure, prefix, folder): 184 | for name, content in structure.items(): 185 | asset_id = PurePosixPath(folder) / name 186 | description = f"{prefix}_{name}" 187 | if isinstance(content, dict): 188 | asset_id = _create_container(str(asset_id)) 189 | _recursive_create(content, prefix, asset_id) 190 | else: 191 | export_asset(content, asset_id, description) 192 | 193 | # create the root folder 194 | root = PurePosixPath(root) if isinstance(root, str) else root 195 | root_folder = f"{root.as_posix()}/{prefix}" 196 | ee.data.createFolder(root_folder) 197 | 198 | # start the recursive function 199 | _recursive_create(structure, prefix, root_folder) 200 | 201 | return PurePosixPath(root_folder) 202 | 203 | 204 | def delete_assets(asset_id: Union[str, Path], dry_run: bool = True) -> list: 205 | """Delete the selected asset and all its content. 206 | 207 | This method will delete all the files and folders existing in an asset folder. 208 | By default a dry run will be launched and if you are satisfyed with the displayed names, change the ``dry_run`` variable to ``False``. 209 | No other warnng will be displayed. 210 | 211 | .. warning:: 212 | 213 | If this method is used on the root directory you will loose all your data, it's highly recommended to use a dry run first and carefully review the destroyed files. 214 | 215 | Args: 216 | asset_id: the Id of the asset or a folder 217 | dry_run: whether or not a dry run should be launched. dry run will only display the files name without deleting them. 218 | 219 | Returns: 220 | a list of all the files deleted or to be deleted 221 | """ 222 | # convert the asset_id to a string 223 | asset_id = asset_id if isinstance(asset_id, str) else asset_id.as_posix() 224 | 225 | # define a delete function to change the behaviour of the method depending of the mode 226 | # in dry mode, the function only store the assets to be destroyed as a dictionary. 227 | # in non dry mode, the function store the asset names in a dictionary AND delete them. 228 | output = [] 229 | 230 | def delete(id: str): 231 | output.append(id) 232 | dry_run is True or ee.data.deleteAsset(id) 233 | 234 | # identify the type of asset 235 | asset_info = ee.data.getAsset(asset_id) 236 | 237 | if asset_info["type"] in ["FOLDER", "IMAGE_COLLECTION"]: 238 | # get all the assets 239 | asset_list = get_assets(folder=asset_id) 240 | 241 | # split the files by nesting levels 242 | # we will need to delete the more nested files first 243 | assets_ordered: dict = {} 244 | for asset in asset_list: 245 | lvl = len(asset["id"].split("/")) 246 | assets_ordered.setdefault(lvl, []) 247 | assets_ordered[lvl].append(asset) 248 | 249 | # delete all items starting from the more nested ones 250 | assets_ordered = dict(sorted(assets_ordered.items(), reverse=True)) 251 | print(assets_ordered) 252 | for lvl in assets_ordered: 253 | for i in assets_ordered[lvl]: 254 | delete(i["name"]) 255 | 256 | # delete the initial folder/asset 257 | delete(asset_id) 258 | 259 | return output 260 | 261 | 262 | def round_data(data: Union[list, dict], prescision: int = 6) -> Union[list, dict]: 263 | """Recusrsively Round the values of a list to the given prescision.""" 264 | # change the generator depending on the collection type 265 | generator = enumerate(data) if isinstance(data, list) else data.items() 266 | for k, v in generator: 267 | if isinstance(v, (list, dict)): 268 | data[k] = round_data(v, prescision) 269 | elif isinstance(v, float): 270 | data[k] = round(v, prescision) 271 | else: 272 | data[k] = v 273 | return data 274 | 275 | 276 | def build_fullpath( 277 | datadir: Path, 278 | request: pytest.FixtureRequest, 279 | extension: str, 280 | basename: Optional[str] = None, 281 | fullpath: Optional["os.PathLike[str]"] = None, 282 | with_test_class_names: bool = False, 283 | ) -> Path: 284 | """Generate a fullpath from parameters of the test. 285 | 286 | Args: 287 | datadir: Fixture embed_data. 288 | request: Pytest request object. 289 | extension: Extension of files compared by this check. 290 | basename: basename of the file to test/record. If not given the name of the test is used. Use either `basename` or `fullpath`. 291 | fullpath: complete path to use as a reference file. This option will ignore ``datadir`` fixture when reading *expected* files but will still use it to write *obtained* files. Useful if a reference file is located in the session data dir for example. 292 | with_test_class_names: if true it will use the test class name (if any) to compose the basename. 293 | """ 294 | assert not (basename and fullpath), "pass either basename or fullpath, but not both" 295 | 296 | __tracebackhide__ = True 297 | 298 | with_test_class_names = with_test_class_names or request.config.getoption( 299 | "with_test_class_names" 300 | ) 301 | 302 | if basename is None: 303 | if (request.node.cls is not None) and (with_test_class_names): 304 | basename = re.sub(r"[\W]", "_", request.node.cls.__name__) + "_" 305 | else: 306 | basename = "" 307 | basename += re.sub(r"[\W]", "_", request.node.name) 308 | 309 | if fullpath: 310 | filename = Path(fullpath) 311 | else: 312 | filename = (datadir / basename).with_suffix(extension) 313 | 314 | return filename 315 | 316 | 317 | def check_serialized( 318 | object: ee.ComputedObject, 319 | path: Path, 320 | datadir: Path, 321 | original_datadir: Path, 322 | request: pytest.FixtureRequest, 323 | force_regen: bool = False, 324 | with_test_class_names: bool = False, 325 | ): 326 | """Check if the serialized GEE object is the same as the saved one. 327 | 328 | Args: 329 | object: the earthnegine object to check 330 | path: the full path to the file to check against. 331 | datadir: Fixture embed_data. 332 | original_datadir: Fixture embed_data. 333 | request: Pytest request object. 334 | force_regen: if True, the file will be regenerated even if it exists. 335 | with_test_class_names: if true it will use the test class name (if any) to compose the basename. 336 | 337 | Raise: 338 | AssertionError if the serialized object is different from the saved one. 339 | """ 340 | # serialize the object# extract the data from the computed object 341 | data_dict = json.loads(object.serialize()) 342 | 343 | # delete the file upstream if force_regen is set 344 | if force_regen is True: 345 | path.unlink(missing_ok=True) 346 | 347 | def dump(filename: Path) -> None: 348 | """Dump dict contents to the given filename.""" 349 | dumped_str = yaml.dump_all( 350 | [data_dict], 351 | Dumper=RegressionYamlDumper, 352 | default_flow_style=False, 353 | allow_unicode=True, 354 | indent=2, 355 | encoding="utf-8", 356 | ) 357 | filename.write_bytes(dumped_str) 358 | 359 | # check the previously registered serialized call from GEE. If it matches the current call, 360 | # we don't need to check the data 361 | perform_regression_check( 362 | datadir=datadir, 363 | original_datadir=original_datadir, 364 | request=request, 365 | check_fn=partial(check_text_files, encoding="UTF-8"), 366 | dump_fn=dump, 367 | extension=".yml", 368 | fullpath=path, 369 | with_test_class_names=with_test_class_names, 370 | ) 371 | --------------------------------------------------------------------------------