├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── publish.yml │ └── tests.yml ├── .gitignore ├── CHANGES.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── about.md ├── api.md ├── cli.md ├── config.md ├── examples.md ├── index.md ├── rule-ref.md ├── start.md └── todo.md ├── environment.yml ├── examples ├── __init__.py ├── check_s3_bucket.py ├── plugin_config.py ├── rule_testing.py └── virtual_plugin_config.py ├── mkdocs.yml ├── mkruleref.py ├── notebooks ├── mkdataset.py ├── xrlint-cli.ipynb └── xrlint-linter.ipynb ├── pyproject.toml ├── tests ├── __init__.py ├── _linter │ ├── __init__.py │ └── test_rulectx.py ├── cli │ ├── __init__.py │ ├── configs │ │ ├── recommended.json │ │ ├── recommended.py │ │ └── recommended.yaml │ ├── helpers.py │ ├── test_config.py │ └── test_main.py ├── formatters │ ├── __init__.py │ ├── helpers.py │ ├── test_html.py │ ├── test_json.py │ └── test_simple.py ├── plugins │ ├── __init__.py │ ├── core │ │ ├── __init__.py │ │ ├── rules │ │ │ ├── __init__.py │ │ │ ├── test_access_latency.py │ │ │ ├── test_content_desc.py │ │ │ ├── test_conventions.py │ │ │ ├── test_coords_for_dims.py │ │ │ ├── test_grid_mappings.py │ │ │ ├── test_lat_lon_coordinate.py │ │ │ ├── test_no_empty_attrs.py │ │ │ ├── test_no_empty_chunks.py │ │ │ ├── test_time_coordinate.py │ │ │ ├── test_var_desc.py │ │ │ ├── test_var_flags.py │ │ │ ├── test_var_missing_data.py │ │ │ └── test_var_units.py │ │ └── test_plugin.py │ └── xcube │ │ ├── __init__.py │ │ ├── helpers.py │ │ ├── processors │ │ ├── __init__.py │ │ └── test_mldataset.py │ │ ├── rules │ │ ├── __init__.py │ │ ├── test_any_spatial_data_var.py │ │ ├── test_cube_dims_order.py │ │ ├── test_data_var_colors.py │ │ ├── test_dataset_title.py │ │ ├── test_grid_mapping_naming.py │ │ ├── test_increasing_time.py │ │ ├── test_lat_lon_naming.py │ │ ├── test_ml_dataset_meta.py │ │ ├── test_ml_dataset_time.py │ │ ├── test_ml_dataset_xy.py │ │ ├── test_no_chunked_coords.py │ │ ├── test_single_grid_mapping.py │ │ └── test_time_naming.py │ │ ├── test_plugin.py │ │ └── test_util.py ├── test_all.py ├── test_config.py ├── test_constants.py ├── test_examples.py ├── test_formatter.py ├── test_formatters.py ├── test_linter.py ├── test_node.py ├── test_operation.py ├── test_plugin.py ├── test_processor.py ├── test_result.py ├── test_rule.py ├── test_testing.py └── util │ ├── __init__.py │ ├── test_constructible.py │ ├── test_filefilter.py │ ├── test_filepattern.py │ ├── test_formatting.py │ ├── test_importutil.py │ ├── test_importutil_pkg │ ├── __init__.py │ ├── module1.py │ └── module2 │ │ └── __init__.py │ ├── test_merge.py │ ├── test_naming.py │ ├── test_schema.py │ └── test_serializable.py └── xrlint ├── __init__.py ├── _linter ├── __init__.py ├── apply.py ├── rulectx.py └── validate.py ├── all.py ├── cli ├── __init__.py ├── config.py ├── constants.py ├── engine.py └── main.py ├── config.py ├── constants.py ├── formatter.py ├── formatters ├── __init__.py ├── html.py ├── json.py └── simple.py ├── linter.py ├── node.py ├── operation.py ├── plugin.py ├── plugins ├── __init__.py ├── core │ ├── __init__.py │ ├── plugin.py │ └── rules │ │ ├── __init__.py │ │ ├── access_latency.py │ │ ├── content_desc.py │ │ ├── conventions.py │ │ ├── coords_for_dims.py │ │ ├── grid_mappings.py │ │ ├── lat_lon_coordinate.py │ │ ├── no_empty_attrs.py │ │ ├── no_empty_chunks.py │ │ ├── time_coordinate.py │ │ ├── var_desc.py │ │ ├── var_flags.py │ │ ├── var_missing_data.py │ │ └── var_units.py └── xcube │ ├── __init__.py │ ├── constants.py │ ├── plugin.py │ ├── processors │ ├── __init__.py │ └── mldataset.py │ ├── rules │ ├── __init__.py │ ├── any_spatial_data_var.py │ ├── cube_dims_order.py │ ├── data_var_colors.py │ ├── dataset_title.py │ ├── grid_mapping_naming.py │ ├── increasing_time.py │ ├── lat_lon_naming.py │ ├── ml_dataset_meta.py │ ├── ml_dataset_time.py │ ├── ml_dataset_xy.py │ ├── no_chunked_coords.py │ ├── single_grid_mapping.py │ └── time_naming.py │ └── util.py ├── processor.py ├── result.py ├── rule.py ├── testing.py ├── util ├── __init__.py ├── constructible.py ├── filefilter.py ├── filepattern.py ├── formatting.py ├── importutil.py ├── merge.py ├── naming.py ├── schema.py └── serializable.py └── version.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Describe how to reproduce the behavior. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Python Environment** 20 | - operating system: 21 | - XRLint version, output of `xrlint --version`: 22 | - optional: packages and their versions, output of `pip list` or `conda list`: 23 | 24 | **Additional context** 25 | Add any other context about the problem here. 26 | 27 | **Traceback** 28 | If applicable, add a traceback of your error here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. 12 | Example: _I'm always frustrated when [...]_ 13 | 14 | **Describe the solution you'd like** 15 | A clear and concise description of what you want to happen. 16 | 17 | **Describe alternatives you've considered** 18 | A clear and concise description of any alternative solutions or 19 | features you've considered. 20 | 21 | **Additional context** 22 | Add any other context or screenshots about the feature request here. 23 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | [Description of PR] 2 | 3 | Checklist (strike out non-applicable): 4 | 5 | * [ ] Changes documented in `CHANGES.md` 6 | * [ ] Related issue exists and is referred to in the PR description and `CHANGES.md` 7 | * [ ] Added docstrings and API docs for any new/modified user-facing classes and functions 8 | * [ ] Changes/features documented in `docs/*` 9 | * [ ] Unit-tests adapted/added for changes/features 10 | * [ ] Test coverage remains or increases (target 100%) 11 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Package and Publish to PyPi 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | python-tests: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ["3.10", "3.11", "3.12", "3.13"] 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v3 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | 24 | - name: Install dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install .[dev,doc] 28 | 29 | - name: Lint with ruff 30 | run: | 31 | ruff check 32 | 33 | - name: Run unit tests 34 | shell: bash -l {0} 35 | run: | 36 | pytest --cov=xrlint --cov-branch --cov-report=xml 37 | 38 | - name: Upload coverage reports to Codecov 39 | uses: codecov/codecov-action@v5 40 | with: 41 | fail_ci_if_error: true 42 | verbose: true 43 | token: ${{ secrets.CODECOV_TOKEN }} 44 | 45 | PyPi-Deploy: 46 | name: Publish Python Package to PyPI 47 | runs-on: ubuntu-latest 48 | needs: python-tests 49 | 50 | steps: 51 | - uses: actions/checkout@v4 52 | 53 | - name: Set up Python 54 | uses: actions/setup-python@v5 55 | with: 56 | python-version: '3.x' 57 | 58 | - name: Install dependencies 59 | run: | 60 | python -m pip install --upgrade pip 61 | pip install build 62 | 63 | - name: Build package 64 | run: | 65 | python -m build 66 | 67 | - name: Publish package to PyPI 68 | uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 69 | with: 70 | user: __token__ 71 | password: ${{ secrets.PYPI_API_TOKEN }} 72 | verbose: true 73 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint 2 | # with a variety of Python versions. For more information see: 3 | # https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 4 | 5 | name: tests 6 | 7 | on: 8 | push: 9 | branches: [ "main" ] 10 | pull_request: 11 | branches: [ "main" ] 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | python-version: ["3.10", "3.11", "3.12", "3.13"] 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up Python ${{ matrix.python-version }} 25 | uses: actions/setup-python@v3 26 | with: 27 | python-version: ${{ matrix.python-version }} 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install .[dev,doc] 32 | - name: Lint with ruff 33 | run: | 34 | ruff check 35 | - name: Test with pytest 36 | run: | 37 | pytest --cov=xrlint --cov-branch --cov-report=xml 38 | - name: Upload coverage reports to Codecov 39 | uses: codecov/codecov-action@v5 40 | with: 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | slug: bcdev/xrlint 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # xrlint 2 | /xrlint-config.* 3 | /xrlint_config.* 4 | /notebooks/xrlint-config.* 5 | /notebooks/xrlint_config.* 6 | 7 | # Logs 8 | *.log 9 | 10 | # Artifacts 11 | dist 12 | build 13 | .pytest_cache 14 | __pycache__ 15 | *.egg-info 16 | .coverage 17 | coverage.xml 18 | htmlcov/ 19 | site/ 20 | 21 | # Jupyter 22 | .ipynb_checkpoints 23 | notebooks/*.zarr/ 24 | 25 | # Editor directories and files 26 | .vscode/* 27 | !.vscode/extensions.json 28 | .idea 29 | .DS_Store 30 | *.suo 31 | *.ntvs* 32 | *.njsproj 33 | *.sln 34 | *.sw? 35 | .idea/ 36 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | We are committed to providing a friendly, safe, and welcoming environment 4 | for all, regardless of gender, sexual orientation, ability, ethnicity, 5 | religion, or any other characteristic. 6 | 7 | We expect everyone to treat each other with respect and kindness. We do not 8 | tolerate harassment or discrimination of any kind. 9 | 10 | If you witness or experience any behavior that violates this code of conduct, 11 | please report it to the project maintainers immediately. 12 | 13 | Thank you for helping us create a welcoming community! 14 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute 2 | 3 | The XRLint project welcomes contributions of any form 4 | as long as you respect our [code of conduct](CODE_OF_CONDUCT.md) and stay 5 | in line with the following instructions and guidelines. 6 | 7 | If you have suggestions, ideas, feature requests, or if you have identified 8 | a malfunction or error, then please 9 | [post an issue](https://github.com/bcdev/xrlint/issues). 10 | 11 | If you'd like to submit code or documentation changes, we ask you to provide a 12 | pull request (PR) 13 | [here](https://github.com/bcdev/xrlint/pulls). 14 | For code and configuration changes, your PR must be linked to a 15 | corresponding issue. 16 | 17 | To ensure that your code contributions are consistent with our project’s 18 | coding guidelines, please make sure all applicable items of the following 19 | checklist are addressed in your PR. 20 | 21 | **PR checklist** 22 | 23 | * Format and check code using [ruff](https://docs.astral.sh/ruff/) with 24 | default settings: `ruff format` and `ruff check`. See also section 25 | [code style](#code-style) below. 26 | * Your change shall not break existing unit tests. 27 | `pytest` must run without errors. 28 | * Add unit tests for any new code not yet covered by tests. 29 | * Make sure test coverage stays close to 100% for any change. 30 | Use `pytest --cov=xrlint --cov-report=html` to verify. 31 | * If your change affects the current project documentation, 32 | please adjust it and include the change in the PR. 33 | Run `mkdocs serve` to verify. 34 | 35 | ## Code style 36 | 37 | The code style of XRLint equals the default settings 38 | of [black](https://black.readthedocs.io/). Since black is 39 | un-opinionated regarding the order of imports, we group and 40 | sort imports statements according to the default settings of 41 | [isort](https://pycqa.github.io/isort/) which boils down to 42 | 43 | 0. Future imports 44 | 1. Python standard library imports, e.g., `os`, `typing`, etc 45 | 2. 3rd-party imports, e.g., `xarray`, `zarr`, etc 46 | 3. 1st-party XRLint module imports using absolute paths, 47 | e.g., `from xrlint.a.b.c import d`. 48 | 4. 1st-party XRLint module imports from local modules: 49 | Relative imports such as `from .c import d` are ok 50 | while `..c import d` are not ok. 51 | 52 | Use `typing.TYPE_CHECKING` to resolve forward references 53 | and effectively avoid circular dependencies. 54 | 55 | ## Contributing a XRLint Rule 56 | 57 | ### Rule Naming 58 | 59 | The rule naming conventions for XRLint are based ESLint: 60 | 61 | * Lower-case only. 62 | * Use dashes between words (kebab-case). 63 | * The rule name should be chosen based on what shall be 64 | achieved, of what shall be regulated. It names a contract. 65 | * If your rule only disallows something, 66 | prefix it with `no-` such as `no-empty-attrs` for disallowing 67 | empty attributes in dataset nodes. 68 | * If your rule is enforcing the inclusion of something, 69 | use a short name without a special prefix. 70 | * Plugins should add a prefix before their rule names 71 | separated by a slash, e.g., `xcube/spatial-dims-order`. 72 | 73 | ### Rule Design 74 | 75 | * The reasoning behind a rule should be **easy to grasp**. 76 | 77 | * A rule should serve for a **single purpose only**. Try subdividing 78 | complex rule logic into multiple rules with simpler logic. 79 | 80 | * Each rule should be defined in a dedicated module named after the rule, 81 | i.e., `/rules/`. The module name should be the rule's name 82 | with dashes replaced by underscores. 83 | 84 | * Write a comprehensive test for your rule logic which should be defined 85 | in a dedicated module under `tests`, i.e., `tests/rules/test_`. 86 | Consider using `xrlint.testing.RuleTester` which can save a lot of 87 | time and is used for almost all in-built rules. 88 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Brockmann Consult Development 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CI](https://github.com/bcdev/xrlint/actions/workflows/tests.yml/badge.svg)](https://github.com/bcdev/xrlint/actions/workflows/tests.yml) 2 | [![codecov](https://codecov.io/gh/bcdev/xrlint/graph/badge.svg?token=GVKuJao97t)](https://codecov.io/gh/bcdev/xrlint) 3 | [![PyPI Version](https://img.shields.io/pypi/v/xrlint)](https://pypi.org/project/xrlint/) 4 | [![Conda Version](https://anaconda.org/conda-forge/xrlint/badges/version.svg)](https://anaconda.org/conda-forge/xrlint) 5 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/charliermarsh/ruff/main/assets/badge/v0.json)](https://github.com/charliermarsh/ruff) 6 | [![GitHub License](https://img.shields.io/github/license/bcdev/xrlint)](https://github.com/bcdev/xrlint) 7 | 8 | # XRLint - A linter for xarray datasets 9 | 10 | XRLint is a [linting](https://en.wikipedia.org/wiki/Lint_(software)) 11 | tool and library for [xarray](https://docs.xarray.dev/) datasets. 12 | Its design is heavily inspired by the awesome [ESLint](https://eslint.org/) tool. 13 | 14 | 15 | ## Features 16 | 17 | - Flexible validation for 18 | [`xarray.Dataset`](https://docs.xarray.dev/en/stable/generated/xarray.Dataset.html) and 19 | [`xarray.DataTree`](https://docs.xarray.dev/en/stable/generated/xarray.DataTree.html) objects 20 | by configurable rules. 21 | - Available from CLI and Python API. 22 | - Custom plugins providing custom rule sets allow addressing 23 | different dataset conventions. 24 | - Project-specific configurations including configuration of individual 25 | rules and file-specific settings. 26 | - Works with dataset files in the local filesystem or any of the remote 27 | filesystems supported by xarray. 28 | 29 | ## Inbuilt Rules 30 | 31 | The following plugins provide XRLint's [inbuilt rules](https://bcdev.github.io/xrlint/rule-ref/): 32 | 33 | - `xrlint.plugins.core`: implementing the rules for 34 | [tidy data](https://tutorial.xarray.dev/intermediate/data_cleaning/05.1_intro.html) 35 | and the 36 | [CF-Conventions](https://cfconventions.org/cf-conventions/cf-conventions.html). 37 | - `xrlint.plugins.xcube`: implementing the rules for 38 | [xcube datasets](https://xcube.readthedocs.io/en/latest/cubespec.html). 39 | Note, this plugin is fully optional. You must manually configure 40 | it to apply its rules. It may be moved into a separate GitHub repo later. 41 | 42 | 43 | -------------------------------------------------------------------------------- /docs/about.md: -------------------------------------------------------------------------------- 1 | # About XRLint 2 | 3 | ## Changelog 4 | 5 | You can find the complete XRLint changelog 6 | [here](https://github.com/bcdev/xrlint/blob/main/CHANGES.md). 7 | 8 | ## Reporting 9 | 10 | If you have suggestions, ideas, feature requests, or if you have identified 11 | a malfunction or error, then please 12 | [post an issue](https://github.com/bcdev/xrlint/issues). 13 | 14 | ## Contributions 15 | 16 | The XRLint project welcomes contributions of any form 17 | as long as you respect our 18 | [code of conduct](https://github.com/bcdev/xrlint/blob/main/CODE_OF_CONDUCT.md) 19 | and follow our 20 | [contribution guide](https://github.com/bcdev/xrlint/blob/main/CONTRIBUTING.md). 21 | 22 | If you'd like to submit code or documentation changes, we ask you to provide a 23 | pull request (PR) 24 | [here](https://github.com/bcdev/xrlint/pulls). 25 | For code and configuration changes, your PR must be linked to a 26 | corresponding issue. 27 | 28 | ## Development 29 | 30 | To install the XRLint development environment into an existing Python environment 31 | 32 | ```bash 33 | pip install .[dev,doc] 34 | ``` 35 | 36 | or create a new environment using `conda` or `mamba` 37 | 38 | ```bash 39 | mamba env create 40 | ``` 41 | 42 | ### Testing and Coverage 43 | 44 | XRLint uses [pytest](https://docs.pytest.org/) for unit-level testing 45 | and code coverage analysis. 46 | 47 | ```bash 48 | pytest --cov=xrlint --cov-report html 49 | ``` 50 | 51 | ### Code Style 52 | 53 | XRLint source code is formatted and quality-controlled using 54 | using [ruff](https://docs.astral.sh/ruff/): 55 | 56 | ```bash 57 | ruff format 58 | ruff check 59 | ``` 60 | 61 | ### Documentation 62 | 63 | XRLint documentation is built using the [mkdocs](https://www.mkdocs.org/) tool. 64 | 65 | With repository root as current working directory: 66 | 67 | ```bash 68 | pip install .[doc] 69 | 70 | mkdocs build 71 | mkdocs serve 72 | mkdocs gh-deploy 73 | ``` 74 | 75 | The rule reference page is generated by a script called `mkruleref.py`. 76 | After changing or adding a rule, make sure you recreate the page: 77 | 78 | ```bash 79 | python -m mkruleref 80 | ``` 81 | 82 | ## License 83 | 84 | XRLint is open source made available under the terms and conditions of the 85 | [MIT License](https://github.com/bcdev/xrlint/blob/main/LICENSE). 86 | 87 | Copyright © 2025 Brockmann Consult Development 88 | -------------------------------------------------------------------------------- /docs/cli.md: -------------------------------------------------------------------------------- 1 | # Command Line Interface 2 | 3 | After installation, the `xrlint` command can be used from the terminal. 4 | The following are the command's usage help including a short description 5 | of its options and arguments: 6 | 7 | ``` 8 | Usage: xrlint [OPTIONS] [FILES]... 9 | 10 | Validate the given dataset FILES. 11 | 12 | When executed, XRLint does the following three things: 13 | 14 | (1) Unless options '--no-config-lookup' or '--config' are used it searches 15 | for a default configuration file in the current working directory. Default 16 | configuration files are determined by their filename, namely 17 | 'xrlint_config.py' or 'xrlint-config.', where refers to the 18 | filename extensions 'json', 'yaml', and 'yml'. A Python configuration file 19 | ('*.py'), is expected to provide XRLInt configuration from a function 20 | 'export_config()', which may include custom plugins and rules. 21 | 22 | (2) It then validates each dataset in FILES against the configuration. The 23 | default dataset patters are '**/*.zarr' and '**/.nc'. FILES may comprise 24 | also directories or URLs. The supported URL protocols are the ones supported 25 | by xarray. Using remote protocols may require installing additional packages 26 | such as S3Fs (https://s3fs.readthedocs.io/) for the 's3' protocol. If a 27 | directory is provided that not matched by any file pattern, it will be 28 | traversed recursively. 29 | 30 | (3) The validation result is dumped to standard output if not otherwise 31 | stated by '--output-file'. The output format is 'simple' by default. Other 32 | inbuilt formats are 'json' and 'html' which you can specify using the '-- 33 | format' option. 34 | 35 | Please refer to the documentation (https://bcdev.github.io/xrlint/) for more 36 | information. 37 | 38 | Options: 39 | --no-config-lookup Disable use of default configuration files 40 | -c, --config FILE Use this configuration instead of looking for a 41 | default configuration file 42 | --print-config FILE Print the configuration for the given file 43 | --plugin MODULE Specify plugins. MODULE is the name of Python module 44 | that defines an 'export_plugin()' function. 45 | --rule SPEC Specify rules. SPEC must have format ': 46 | ' (note the space character). 47 | -o, --output-file FILE Specify file to write report to 48 | -f, --format NAME Use a specific output format - default: simple 49 | --color / --no-color Force enabling/disabling of color 50 | --max-warnings COUNT Number of warnings to trigger nonzero exit code - 51 | default: 5 52 | --init Write initial configuration file 'xrlint- 53 | config.yaml' and exit. 54 | --version Show the version and exit. 55 | --help Show this message and exit. 56 | 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | ## Configuration 4 | 5 | ::: examples.plugin_config 6 | 7 | options: 8 | members: false 9 | 10 | Source code: [`examples/plugin_config.py`](https://github.com/bcdev/xrlint/blob/main/examples/plugin_config.py) 11 | 12 | ::: examples.virtual_plugin_config 13 | 14 | options: 15 | members: false 16 | 17 | Source code: [`examples/virtual_plugin_config.py`](https://github.com/bcdev/xrlint/blob/main/examples/virtual_plugin_config.py) 18 | 19 | ## Developing rules 20 | 21 | ::: examples.rule_testing 22 | 23 | options: 24 | members: false 25 | 26 | Source code: [`examples/rule_testing.py`](https://github.com/bcdev/xrlint/blob/main/examples/rule_testing.py) 27 | 28 | ## API usage 29 | 30 | ::: examples.check_s3_bucket 31 | 32 | options: 33 | members: false 34 | 35 | Source code: [`examples/check_s3_bucket.py`](https://github.com/bcdev/xrlint/blob/main/examples/check_s3_bucket.py) 36 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # XRLint - A linter for xarray datasets 2 | 3 | 4 | XRLint is a [linting](https://en.wikipedia.org/wiki/Lint_(software)) 5 | tool and library for [xarray](https://docs.xarray.dev/) datasets. 6 | Its design is heavily inspired by the awesome [ESLint](https://eslint.org/) tool. 7 | 8 | 9 | ## Features 10 | 11 | - Flexible validation for 12 | [`xarray.Dataset`](https://docs.xarray.dev/en/stable/generated/xarray.Dataset.html) and 13 | [`xarray.DataTree`](https://docs.xarray.dev/en/stable/generated/xarray.DataTree.html) objects 14 | by configurable rules. 15 | - Available from CLI and Python API. 16 | - Custom plugins providing custom rule sets allow addressing 17 | different dataset conventions. 18 | - Project-specific configurations including configuration of individual 19 | rules and file-specific settings. 20 | - Works with dataset files in the local filesystem or any of the remote 21 | filesystems supported by xarray. 22 | 23 | 24 | ## Inbuilt Rules 25 | 26 | The following plugins provide XRLint's [inbuilt rules](rule-ref.md): 27 | 28 | - `core`: implementing the rules for 29 | [tidy data](https://tutorial.xarray.dev/intermediate/data_cleaning/05.1_intro.html) 30 | and the 31 | [CF-Conventions](https://cfconventions.org/cf-conventions/cf-conventions.html). 32 | - `xcube`: implementing the rules for 33 | [xcube datasets](https://xcube.readthedocs.io/en/latest/cubespec.html). 34 | Note, this plugin is fully optional. You must manually configure 35 | it to apply its rules. It may be moved into a separate GitHub repo later. 36 | 37 | -------------------------------------------------------------------------------- /docs/start.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Installation 4 | 5 | ```bash 6 | pip install xrlint 7 | ``` 8 | 9 | or 10 | 11 | ```bash 12 | conda install -c conda-forge xrlint 13 | ``` 14 | 15 | 16 | ## Command line interface 17 | 18 | Get basic help: 19 | 20 | ```bash 21 | xrlint --help 22 | ``` 23 | 24 | Initializing a new project with 25 | 26 | ```bash 27 | xrlint --init 28 | ``` 29 | 30 | writes a configuration file `xrlint-config.yaml` 31 | into the current working directory: 32 | 33 | ```yaml 34 | - recommended 35 | ``` 36 | 37 | This configuration file tells XRLint to use the predefined configuration 38 | named `recommended`. 39 | 40 | Create a dataset to test XRLint: 41 | 42 | ```bash 43 | python 44 | >>> import xarray as xr 45 | >>> test_ds = xr.Dataset(attrs={"title": "Test Dataset"}) 46 | >>> test_ds.to_zarr("test.zarr") 47 | >>> exit() 48 | ``` 49 | 50 | And run XRLint: 51 | 52 | ```bash 53 | xrlint test.zarr 54 | ``` 55 | 56 | You can now override the predefined settings by adding your custom 57 | rule configurations: 58 | 59 | ```yaml 60 | - recommended 61 | - rules: 62 | no-empty-attrs: off 63 | var-units-attr: warn 64 | grid-mappings: error 65 | ``` 66 | 67 | You can add rules from plugins as well: 68 | 69 | ```yaml 70 | - recommended 71 | - plugins: 72 | xcube: xrlint.plugins.xcube 73 | - xcube/recommended 74 | ``` 75 | 76 | And customize its rules, if desired: 77 | 78 | ```yaml 79 | - recommended 80 | - plugins: 81 | xcube: xrlint.plugins.xcube 82 | - xcube/recommended 83 | - rules: 84 | xcube/grid-mapping-naming: off 85 | xcube/lat-lon-naming: warn 86 | ``` 87 | 88 | Note the prefix `xcube/` used for the rule names. 89 | 90 | ## Python API 91 | 92 | The easiest approach to use the Python API is to import `xrlint.all`. 93 | It contains all the public definitions from the `xrlint` package. 94 | 95 | ```python 96 | import xrlint.all as xrl 97 | ``` 98 | 99 | Start by creating a linter with recommended settings 100 | using the `new_linter()` function . 101 | 102 | ```python 103 | import xarray as xr 104 | import xrlint.all as xrl 105 | 106 | test_ds = xr.Dataset(attrs={"title": "Test Dataset"}) 107 | 108 | linter = xrl.new_linter("recommended") 109 | linter.validate(test_ds) 110 | ``` 111 | -------------------------------------------------------------------------------- /docs/todo.md: -------------------------------------------------------------------------------- 1 | # To Do 2 | 3 | ## Required 4 | 5 | - enhance docs 6 | - complete configuration page 7 | - provide guide page 8 | - use mkdocstrings ref syntax in docstrings 9 | - provide configuration examples (use as tests?) 10 | - add `docs_url` to all existing rules 11 | - rule ref should cover rule parameters 12 | 13 | ## Desired 14 | 15 | - project logo 16 | - add `xcube` rule that helps to identify chunking issues 17 | - apply rule op args/kwargs validation schema 18 | - allow outputting suggestions, if any, that are emitted by some rules 19 | - add CLI option 20 | - expand/collapse messages with suggestions in Jupyter notebooks 21 | - validate `RuleConfig.args/kwargs` against `RuleMeta.schema` 22 | (see code TODO) 23 | 24 | ## Nice to have 25 | 26 | - support `autofix` feature 27 | - support `md` (markdown) output format 28 | - support formatter op args/kwargs and apply validation schema 29 | 30 | # Ideas 31 | 32 | ## Allow for different dataset openers 33 | 34 | - introduce `dataset_options` config: 35 | - `opener: OpenerOp` 36 | - `opener_options: dict[str, Any]` 37 | 38 | ## Other plugins 39 | 40 | - `sgrid`: https://sgrid.github.io/sgrid/ 41 | - `ugrid`: https://ugrid-conventions.github.io/ugrid-conventions/ 42 | 43 | ## Generalize data linting 44 | 45 | Do not limit validations to `xr.Dataset`. 46 | However, this requires new rule sets. 47 | 48 | To allow for other data models, we need to allow 49 | for a specific validator type for a given data type. 50 | 51 | The validator validates specific node types 52 | that are characteristic for a data type. 53 | 54 | To do so a traverser must traverse the elements of the data 55 | and pass each node to the validator. 56 | 57 | Note, this is the [_Visitor Pattern_](https://en.wikipedia.org/wiki/Visitor_pattern), 58 | where the validator is the _Visitor_ and a node refers to _Element_. 59 | 60 | To support the CLI mode, we need different data opener 61 | types that can read the data from a file path. 62 | 63 | 1. open data, if given data is a file path: 64 | - find opener for file path 65 | - open data 66 | 2. validate data 67 | - find root element type and visitor type for data 68 | - call the root element `accept(validator)` that validates the 69 | root element `validate.root()` and starts traversal of 70 | child elements. 71 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: xrlint 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python >=3.10 6 | # Library Dependencies 7 | - click 8 | - fsspec 9 | - pyyaml 10 | - tabulate 11 | - xarray 12 | # Dev Dependencies 13 | - isort 14 | - mkdocs 15 | - mkdocs-autorefs 16 | - mkdocs-material 17 | - mkdocstrings 18 | - mkdocstrings-python 19 | - pytest 20 | - pytest-cov 21 | - requests-mock 22 | - ruff 23 | # Testing Datasets 24 | - dask 25 | - pandas 26 | - netcdf4 27 | - numpy 28 | - zarr >=2.18,!=3.0.0,!=3.0.1 29 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /examples/check_s3_bucket.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | """ 6 | This code example shows how to use the high-level 7 | Python API to validate the contents of an S3 bucket. 8 | """ 9 | 10 | import xrlint.all as xrl 11 | 12 | URL = "s3://xcube-test/" 13 | 14 | xrlint = xrl.XRLint(no_config_lookup=True) 15 | xrlint.init_config("recommended") 16 | results = xrlint.validate_files([URL]) 17 | print(xrlint.format_results(results)) 18 | -------------------------------------------------------------------------------- /examples/plugin_config.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | """ 6 | This configuration example shows how to define and use a plugin 7 | using the `Plugin` class and its `define_rule()` decorator method. 8 | 9 | You can use this example directly via the Python API by passing it's 10 | exported configuration to an instance of the `Linter` class or use 11 | the XRLint CLI: 12 | 13 | ```bash 14 | xrlint -c examples/plugin_config.py 15 | ``` 16 | """ 17 | 18 | from xrlint.node import DatasetNode 19 | from xrlint.plugin import new_plugin 20 | from xrlint.rule import RuleContext, RuleOp 21 | 22 | plugin = new_plugin(name="hello-plugin", version="1.0.0") 23 | 24 | 25 | @plugin.define_rule("good-title") 26 | class GoodTitle(RuleOp): 27 | """Dataset title should be 'Hello World!'.""" 28 | 29 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 30 | good_title = "Hello World!" 31 | if node.dataset.attrs.get("title") != good_title: 32 | ctx.report( 33 | "Attribute 'title' wrong.", 34 | suggestions=[f"Rename it to {good_title!r}."], 35 | ) 36 | 37 | 38 | # Define more rules here... 39 | 40 | 41 | plugin.define_config( 42 | "recommended", 43 | [ 44 | { 45 | "rules": { 46 | "hello/good-title": "warn", 47 | # Configure more rules here... 48 | }, 49 | } 50 | ], 51 | ) 52 | 53 | # Add more configurations here... 54 | 55 | 56 | def export_config(): 57 | return [ 58 | # Use "hello" plugin 59 | { 60 | "plugins": { 61 | "hello": plugin, 62 | }, 63 | }, 64 | # Use recommended settings from xrlint 65 | "recommended", 66 | # Use recommended settings from "hello" plugin 67 | "hello/recommended", 68 | ] 69 | -------------------------------------------------------------------------------- /examples/rule_testing.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | """ 6 | This example demonstrates how to develop new rules. 7 | """ 8 | 9 | import xarray as xr 10 | 11 | from xrlint.node import DatasetNode 12 | from xrlint.rule import RuleContext, RuleOp, define_rule 13 | from xrlint.testing import RuleTest, RuleTester 14 | 15 | 16 | # ---------------------------------------------------- 17 | # Place the rule implementation code in its own module 18 | # ---------------------------------------------------- 19 | 20 | 21 | @define_rule("good-title") 22 | class GoodTitle(RuleOp): 23 | """Dataset title should be 'Hello World!'.""" 24 | 25 | # We just validate the dataset instance here. You can also implement 26 | # the validation of other nodes in this class, e.g., 27 | # validate_datatree(), validate_variable(), validate_attrs(), 28 | # and validate_attr(). 29 | # 30 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 31 | good_title = "Hello World!" 32 | if node.dataset.attrs.get("title") != good_title: 33 | ctx.report( 34 | "Attribute 'title' wrong.", 35 | suggestions=[f"Rename it to {good_title!r}."], 36 | ) 37 | 38 | 39 | # --------------------------------------------------- 40 | # Place the following rule test code in a test module 41 | # --------------------------------------------------- 42 | 43 | tester = RuleTester() 44 | 45 | valid_dataset = xr.Dataset(attrs=dict(title="Hello World!")) 46 | invalid_dataset = xr.Dataset(attrs=dict(title="Hello Hamburg!")) 47 | 48 | # You can use the tester to run a test directly 49 | # 50 | tester.run( 51 | "good-title", 52 | GoodTitle, 53 | valid=[RuleTest(dataset=valid_dataset)], 54 | # We expect one message to be emitted 55 | invalid=[RuleTest(dataset=invalid_dataset, expected=1)], 56 | ) 57 | 58 | # ... or generate a test class that will be derived from `unitest.TestCase`. 59 | # This will provide you tooling support via your test runner, e.g., pytest, 60 | # as the tests in `valid` and `invalid` will be transformed into 61 | # test methods of the generated class. 62 | # 63 | GoodTitleTest = tester.define_test( 64 | "good-title", 65 | GoodTitle, 66 | valid=[RuleTest(dataset=valid_dataset)], 67 | # Note, here we expect a specific message to be emitted 68 | invalid=[RuleTest(dataset=invalid_dataset, expected=["Attribute 'title' wrong."])], 69 | ) 70 | -------------------------------------------------------------------------------- /examples/virtual_plugin_config.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | """ 6 | This configuration example demonstrates how to 7 | define and use "virtual" plugins. Such plugins 8 | can be defined inside a configuration item. 9 | 10 | You can use this example directly via the Python API by passing it's 11 | exported configuration to an instance of the `Linter` class or use 12 | the XRLint CLI: 13 | 14 | ```bash 15 | xrlint -c examples/virtual_plugin_config.py 16 | ``` 17 | """ 18 | 19 | from xrlint.node import DatasetNode 20 | from xrlint.rule import RuleContext, RuleOp, define_rule 21 | 22 | 23 | @define_rule("good-title", description="Dataset title should be 'Hello World!'.") 24 | class GoodTitle(RuleOp): 25 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 26 | good_title = "Hello World!" 27 | if node.dataset.attrs.get("title") != good_title: 28 | ctx.report( 29 | "Attribute 'title' wrong.", 30 | suggestions=[f"Rename it to {good_title!r}."], 31 | ) 32 | 33 | 34 | # Define more rules here... 35 | 36 | 37 | def export_config(): 38 | return [ 39 | # Define and use "hello" plugin 40 | { 41 | "plugins": { 42 | "hello": { 43 | "meta": { 44 | "name": "hello", 45 | "version": "1.0.0", 46 | }, 47 | "rules": { 48 | "good-title": GoodTitle, 49 | # Add more rules here... 50 | }, 51 | "configs": { 52 | "recommended": [ 53 | { 54 | "rules": { 55 | "hello/good-title": "warn", 56 | # Configure more rules here... 57 | }, 58 | } 59 | ], 60 | # Add more configurations here... 61 | }, 62 | }, 63 | } 64 | }, 65 | # Use recommended settings from xrlint 66 | "recommended", 67 | # Use recommended settings from "hello" plugin 68 | "hello/recommended", 69 | ] 70 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: XRLint 2 | repo_url: https://github.com/bcdev/xrlint 3 | repo_name: bcdev/xrlint 4 | 5 | copyright: Copyright © 2025 Brockmann Consult 6 | 7 | nav: 8 | - Overview: index.md 9 | - Getting Started: start.md 10 | - Configuration: config.md 11 | - Rule Reference: rule-ref.md 12 | - CLI: cli.md 13 | - Python API: api.md 14 | - Examples: examples.md 15 | - About: about.md 16 | 17 | theme: 18 | name: material 19 | # logo: assets/logo.png 20 | # favicon: assets/logo-small.png 21 | palette: 22 | # Palette toggle for light mode 23 | - scheme: default 24 | primary: blue grey 25 | toggle: 26 | icon: material/brightness-7 27 | name: Switch to dark mode 28 | # Palette toggle for dark mode 29 | - scheme: slate 30 | primary: blue grey 31 | toggle: 32 | icon: material/brightness-4 33 | name: Switch to light mode 34 | 35 | markdown_extensions: 36 | - attr_list 37 | - admonition 38 | - pymdownx.details 39 | - pymdownx.superfences 40 | - pymdownx.emoji: 41 | emoji_index: !!python/name:material.extensions.emoji.twemoji 42 | emoji_generator: !!python/name:material.extensions.emoji.to_svg 43 | 44 | extra: 45 | social: 46 | - icon: fontawesome/brands/github 47 | link: https://github.com/bcdev/xrlint 48 | - icon: fontawesome/brands/python 49 | link: https://pypi.org/project/xrlint/ 50 | 51 | plugins: 52 | - search 53 | - autorefs 54 | - mkdocstrings: 55 | handlers: 56 | python: 57 | options: 58 | docstring_style: google 59 | docstring_section_style: list 60 | show_object_full_path: true 61 | show_root_toc_entry: true 62 | show_root_heading: true 63 | show_source: true 64 | show_category_heading: true 65 | show_symbol_type_heading: true 66 | show_symbol_type_toc: true 67 | heading_level: 3 68 | annotations_path: brief 69 | members_order: source 70 | extra: 71 | show_overloads: true 72 | -------------------------------------------------------------------------------- /mkruleref.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.plugin import Plugin 6 | from xrlint.rule import RuleConfig 7 | 8 | # for icons, see 9 | # https://squidfunk.github.io/mkdocs-material/reference/icons-emojis/ 10 | 11 | severity_icons = { 12 | 2: "material-lightning-bolt", 13 | 1: "material-alert", 14 | 0: "material-circle-off-outline", 15 | } 16 | 17 | rule_type_icons = { 18 | "problem": "material-bug", 19 | "suggestion": "material-lightbulb", 20 | "layout": "material-text", 21 | } 22 | 23 | # read_more_icon = "material-book-open-outline" 24 | read_more_icon = "material-information-variant" 25 | 26 | 27 | def write_rule_ref_page(): 28 | import xrlint.plugins.core 29 | import xrlint.plugins.xcube 30 | 31 | core = xrlint.plugins.core.export_plugin() 32 | xcube = xrlint.plugins.xcube.export_plugin() 33 | with open("docs/rule-ref.md", "w") as stream: 34 | stream.write("# Rule Reference\n\n") 35 | stream.write( 36 | "This page is auto-generated from XRLint's builtin" 37 | " rules (`python -m mkruleref`).\n" 38 | "New rules will be added by upcoming XRLint releases.\n\n" 39 | ) 40 | stream.write("## Core Rules\n\n") 41 | write_plugin_rules(stream, core) 42 | stream.write("## xcube Rules\n\n") 43 | write_plugin_rules(stream, xcube) 44 | 45 | 46 | def write_plugin_rules(stream, plugin: Plugin): 47 | config_rules = get_plugin_rule_configs(plugin) 48 | for rule_id in sorted(plugin.rules.keys()): 49 | rule_meta = plugin.rules[rule_id].meta 50 | stream.write( 51 | f"### :{rule_type_icons.get(rule_meta.type)}: `{rule_meta.name}`\n\n" 52 | ) 53 | stream.write(rule_meta.description or "_No description._") 54 | if rule_meta.docs_url: 55 | stream.write(f"\n[More...]({rule_meta.docs_url})") 56 | stream.write("\n\n") 57 | # List the predefined configurations that contain the rule 58 | stream.write("Contained in: ") 59 | for config_id in sorted(config_rules.keys()): 60 | rule_configs = config_rules[config_id] 61 | rule_config = rule_configs.get(rule_id) or rule_configs.get( 62 | f"{plugin.meta.name}/{rule_id}" 63 | ) 64 | if rule_config is not None: 65 | stream.write(f" `{config_id}`-:{severity_icons[rule_config.severity]}:") 66 | stream.write("\n\n") 67 | 68 | 69 | def get_plugin_rule_configs(plugin: Plugin) -> dict[str, dict[str, RuleConfig]]: 70 | configs = plugin.configs 71 | config_rules: dict[str, dict[str, RuleConfig]] = {} 72 | for config_name, config_list in configs.items(): 73 | # note, here we assume most plugins configure their rules 74 | # in one dedicated config object only. However, this is not 75 | # the general case as file patterns may be used to make the 76 | # rules configurations specific. 77 | rule_configs = {} 78 | for config in config_list: 79 | if config.rules: 80 | rule_configs.update(config.rules) 81 | config_rules[config_name] = rule_configs 82 | return config_rules 83 | 84 | 85 | if __name__ == "__main__": 86 | write_rule_ref_page() 87 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 61.2.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "xrlint" 7 | dynamic = ["version", "readme"] 8 | authors = [ 9 | {name = "Norman Fomferra (Brockmann Consult GmbH)"} 10 | ] 11 | description = "A linter for xarray datasets." 12 | keywords = [ 13 | "xarray", "data-science", "cf", "metadata" 14 | ] 15 | license = {text = "MIT"} 16 | requires-python = ">=3.10" 17 | dependencies = [ 18 | "click", 19 | "fsspec", 20 | "pyyaml", 21 | "tabulate", 22 | "xarray", 23 | ] 24 | classifiers = [ 25 | "Development Status :: 5 - Production/Stable", 26 | "Intended Audience :: Science/Research", 27 | "Intended Audience :: Developers", 28 | "License :: OSI Approved :: MIT License", 29 | "Programming Language :: Python :: 3", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12", 33 | "Programming Language :: Python :: 3.13", 34 | "Topic :: Software Development", 35 | "Topic :: Scientific/Engineering", 36 | "Typing :: Typed", 37 | "Operating System :: Microsoft :: Windows", 38 | "Operating System :: POSIX", 39 | "Operating System :: Unix", 40 | "Operating System :: MacOS", 41 | ] 42 | 43 | [tool.setuptools.dynamic] 44 | version = {attr = "xrlint.__version__"} 45 | readme = {file = "README.md", content-type = "text/markdown"} 46 | 47 | [tool.setuptools.packages.find] 48 | exclude = [ 49 | "tests", 50 | "docs" 51 | ] 52 | 53 | [tool.flake8] 54 | max-line-length = 88 55 | 56 | [tool.isort] 57 | profile = "black" 58 | 59 | [tool.ruff] 60 | # There is a problem with ruff when linting imports 61 | exclude = ["**/*.ipynb"] 62 | 63 | [project.scripts] 64 | xrlint = "xrlint.cli.main:main" 65 | 66 | [project.optional-dependencies] 67 | dev = [ 68 | # Development tools 69 | "build", 70 | "hatch", 71 | "isort", 72 | "pytest", 73 | "pytest-cov", 74 | "ruff", 75 | "twine", 76 | # Dataset testing 77 | "dask", 78 | "netcdf4", 79 | "numpy", 80 | "pandas", 81 | "zarr>= 2.18, != 3.0.0, != 3.0.1", 82 | ] 83 | doc = [ 84 | "mkdocs", 85 | "mkdocs-autorefs", 86 | "mkdocs-material", 87 | "mkdocstrings", 88 | "mkdocstrings-python" 89 | ] 90 | 91 | [project.urls] 92 | Documentation = "https://bcdev.github.io/xrlint" 93 | Repository = "https://github.com/bcdev/xrlint" 94 | Changelog = "https://github.com/bcdev/xrlint/blob/main/CHANGES.md" 95 | Issues = "https://github.com/bcdev/xrlint/issues" 96 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/_linter/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/_linter/test_rulectx.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | import xarray as xr 8 | 9 | # noinspection PyProtectedMember 10 | from xrlint._linter.rulectx import RuleContextImpl 11 | from xrlint.config import ConfigObject 12 | from xrlint.constants import DATASET_ROOT_NAME 13 | from xrlint.result import Message, Suggestion 14 | 15 | 16 | class RuleContextImplTest(TestCase): 17 | def test_defaults(self): 18 | config_obj = ConfigObject() 19 | dataset = xr.Dataset() 20 | context = RuleContextImpl(config_obj, dataset, "./ds.zarr", None, None) 21 | self.assertIs(config_obj, context.config) 22 | self.assertIs(dataset, context.dataset) 23 | self.assertEqual({}, context.settings) 24 | self.assertEqual("./ds.zarr", context.file_path) 25 | self.assertEqual(None, context.file_index) 26 | self.assertEqual(None, context.access_latency) 27 | 28 | def test_report(self): 29 | context = RuleContextImpl( 30 | ConfigObject(), xr.Dataset(), "./ds.zarr", None, 1.2345 31 | ) 32 | with context.use_state(rule_id="no-xxx"): 33 | context.report( 34 | "What the heck do you mean?", 35 | suggestions=[Suggestion("Never say XXX again.")], 36 | ) 37 | context.report("You said it.", fatal=True) 38 | self.assertEqual( 39 | [ 40 | Message( 41 | message="What the heck do you mean?", 42 | node_path=DATASET_ROOT_NAME, 43 | rule_id="no-xxx", 44 | severity=2, 45 | suggestions=[ 46 | Suggestion(desc="Never say XXX again.", data=None, fix=None) 47 | ], 48 | ), 49 | Message( 50 | message="You said it.", 51 | node_path=DATASET_ROOT_NAME, 52 | rule_id="no-xxx", 53 | severity=2, 54 | fatal=True, 55 | suggestions=None, 56 | ), 57 | ], 58 | context.messages, 59 | ) 60 | -------------------------------------------------------------------------------- /tests/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/cli/configs/recommended.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "plugins": { 4 | "xcube": "xrlint.plugins.xcube" 5 | } 6 | }, 7 | "recommended", 8 | "xcube/recommended", 9 | { 10 | "rules": { 11 | "xcube/dataset-title": "error", 12 | "xcube/single-grid-mapping": "off" 13 | } 14 | } 15 | ] 16 | -------------------------------------------------------------------------------- /tests/cli/configs/recommended.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | 6 | def export_config(): 7 | import xrlint.plugins.core 8 | import xrlint.plugins.xcube 9 | 10 | core = xrlint.plugins.core.export_plugin() 11 | xcube = xrlint.plugins.xcube.export_plugin() 12 | return [ 13 | { 14 | "plugins": { 15 | "xcube": xcube, 16 | } 17 | }, 18 | *core.configs["recommended"], 19 | *xcube.configs["recommended"], 20 | { 21 | "rules": { 22 | "xcube/dataset-title": "error", 23 | "xcube/single-grid-mapping": "off", 24 | } 25 | }, 26 | ] 27 | -------------------------------------------------------------------------------- /tests/cli/configs/recommended.yaml: -------------------------------------------------------------------------------- 1 | - plugins: 2 | xcube: xrlint.plugins.xcube 3 | - recommended 4 | - xcube/recommended 5 | - rules: 6 | "dataset-title-attr": "error" 7 | "xcube/single-grid-mapping": "off" 8 | -------------------------------------------------------------------------------- /tests/cli/helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import os 6 | from contextlib import contextmanager 7 | 8 | 9 | @contextmanager 10 | def text_file(file_path: str, content: str): 11 | with open(file_path, mode="w") as f: 12 | f.write(content) 13 | try: 14 | yield file_path 15 | finally: 16 | os.remove(file_path) 17 | -------------------------------------------------------------------------------- /tests/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/formatters/helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.config import ConfigObject 6 | from xrlint.formatter import FormatterContext 7 | from xrlint.plugin import new_plugin 8 | from xrlint.result import Message, Result, ResultStats 9 | from xrlint.rule import RuleOp 10 | 11 | 12 | class FormatterContextImpl(FormatterContext): 13 | def __init__(self, max_warnings: int = -1): 14 | self._max_warnings = max_warnings 15 | self._result_stats = ResultStats() 16 | 17 | @property 18 | def max_warnings_exceeded(self) -> bool: 19 | return self._result_stats.warning_count > self._max_warnings 20 | 21 | @property 22 | def result_stats(self) -> ResultStats: 23 | return self._result_stats 24 | 25 | 26 | def get_context(max_warnings: int = -1) -> FormatterContext: 27 | return FormatterContextImpl(max_warnings) 28 | 29 | 30 | def get_test_results(): 31 | plugin = new_plugin(name="test") 32 | 33 | @plugin.define_rule( 34 | "rule-1", description="Haha", docs_url="https://rules.com/haha.html" 35 | ) 36 | class Rule1(RuleOp): 37 | pass 38 | 39 | @plugin.define_rule( 40 | "rule-2", description="Hoho", docs_url="https://rules.com/hoho.html" 41 | ) 42 | class Rule2(RuleOp): 43 | pass 44 | 45 | config_obj = ConfigObject(plugins={"test": plugin}) 46 | 47 | return [ 48 | Result( 49 | file_path="test.nc", 50 | config_object=config_obj, 51 | messages=[ 52 | Message( 53 | message="message-1", 54 | rule_id="test/rule-1", 55 | severity=2, 56 | node_path="dataset", 57 | ), 58 | Message( 59 | message="message-2", 60 | rule_id="test/rule-2", 61 | severity=1, 62 | node_path="dataset", 63 | ), 64 | Message(message="message-3", fatal=True), 65 | ], 66 | ), 67 | Result( 68 | file_path="test.nc", 69 | config_object=config_obj, 70 | messages=[ 71 | Message(message="message-1", rule_id="test/rule-1", severity=1), 72 | Message(message="message-2", rule_id="test/rule-2", severity=2), 73 | Message(message="message-3", fatal=False), 74 | ], 75 | ), 76 | ] 77 | -------------------------------------------------------------------------------- /tests/formatters/test_html.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.formatters.html import Html, HtmlText 8 | 9 | from .helpers import get_context, get_test_results 10 | 11 | 12 | class HtmlTest(TestCase): 13 | def test_html(self): 14 | results = get_test_results() 15 | formatter = Html() 16 | text = formatter.format( 17 | context=get_context(), 18 | results=results, 19 | ) 20 | self.assertIsInstance(text, HtmlText) 21 | self.assertIs(text, text._repr_html_()) 22 | self.assertIn("

", text) 23 | 24 | def test_html_with_meta(self): 25 | results = get_test_results() 26 | formatter = Html(with_meta=True) 27 | text = formatter.format( 28 | context=get_context(), 29 | results=results, 30 | ) 31 | self.assertIsInstance(text, HtmlText) 32 | self.assertIs(text, text._repr_html_()) 33 | self.assertIn("

", text) 34 | -------------------------------------------------------------------------------- /tests/formatters/test_json.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.formatters.json import Json 8 | 9 | from .helpers import get_context, get_test_results 10 | 11 | 12 | class JsonTest(TestCase): 13 | def test_json(self): 14 | results = get_test_results() 15 | formatter = Json() 16 | text = formatter.format( 17 | context=get_context(), 18 | results=results, 19 | ) 20 | self.assertIn('"results": [', text) 21 | 22 | def test_json_with_meta(self): 23 | results = get_test_results() 24 | formatter = Json(with_meta=True) 25 | text = formatter.format( 26 | context=get_context(), 27 | results=results, 28 | ) 29 | self.assertIn('"results": [', text) 30 | -------------------------------------------------------------------------------- /tests/formatters/test_simple.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from tests.formatters.helpers import get_context 8 | from xrlint.config import ConfigObject 9 | from xrlint.formatters.simple import Simple 10 | from xrlint.result import Message, Result 11 | 12 | 13 | class SimpleTest(TestCase): 14 | errors_and_warnings = [ 15 | Result( 16 | file_path="test1.nc", 17 | config_object=ConfigObject(), 18 | messages=[ 19 | Message(message="what", rule_id="rule-1", severity=2), 20 | Message(message="is", fatal=True), 21 | Message(message="happening?", rule_id="rule-2", severity=1), 22 | ], 23 | ) 24 | ] 25 | 26 | warnings_only = [ 27 | Result( 28 | file_path="test2.nc", 29 | config_object=ConfigObject(), 30 | messages=[ 31 | Message(message="what", rule_id="rule-1", severity=1), 32 | Message(message="happened?", rule_id="rule-2", severity=1), 33 | ], 34 | ) 35 | ] 36 | 37 | def test_no_color(self): 38 | formatter = Simple(styled=False) 39 | text = formatter.format( 40 | context=get_context(), 41 | results=self.errors_and_warnings, 42 | ) 43 | self.assert_output_1_ok(text) 44 | self.assertNotIn("\033]", text) 45 | 46 | formatter = Simple(styled=False) 47 | text = formatter.format( 48 | context=get_context(), 49 | results=self.warnings_only, 50 | ) 51 | self.assert_output_2_ok(text) 52 | self.assertNotIn("\033]", text) 53 | 54 | def test_color(self): 55 | formatter = Simple(styled=True) 56 | text = formatter.format( 57 | context=get_context(), 58 | results=self.errors_and_warnings, 59 | ) 60 | self.assert_output_1_ok(text) 61 | self.assertIn("\033]", text) 62 | 63 | formatter = Simple(styled=True) 64 | text = formatter.format( 65 | context=get_context(), 66 | results=self.warnings_only, 67 | ) 68 | self.assert_output_2_ok(text) 69 | self.assertIn("\033]", text) 70 | 71 | def assert_output_1_ok(self, text): 72 | self.assertIsInstance(text, str) 73 | self.assertIn("test1.nc", text) 74 | self.assertIn("happening?", text) 75 | self.assertIn("error", text) 76 | self.assertIn("warn", text) 77 | self.assertIn("rule-1", text) 78 | self.assertIn("rule-2", text) 79 | 80 | def assert_output_2_ok(self, text): 81 | self.assertIsInstance(text, str) 82 | self.assertIn("test2.nc", text) 83 | self.assertIn("happened?", text) 84 | self.assertNotIn("error", text) 85 | self.assertIn("warn", text) 86 | self.assertIn("rule-1", text) 87 | self.assertIn("rule-2", text) 88 | -------------------------------------------------------------------------------- /tests/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/plugins/core/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/test_access_latency.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | import pytest 8 | import xarray as xr 9 | 10 | # noinspection PyProtectedMember 11 | from xrlint._linter.rulectx import RuleContextImpl 12 | from xrlint.config import ConfigObject 13 | from xrlint.constants import DATASET_ROOT_NAME 14 | from xrlint.node import DatasetNode 15 | from xrlint.plugins.core.rules.access_latency import AccessLatency 16 | from xrlint.result import Message 17 | from xrlint.rule import RuleExit 18 | 19 | valid_dataset_0 = xr.Dataset() 20 | 21 | invalid_dataset_0 = xr.Dataset() 22 | 23 | 24 | class OpeningTimeTest(TestCase): 25 | @classmethod 26 | def invoke_op( 27 | cls, dataset: xr.Dataset, access_latency: float, threshold: float | None = None 28 | ): 29 | ctx = RuleContextImpl( 30 | config=ConfigObject(), 31 | dataset=dataset, 32 | file_path="test.zarr", 33 | file_index=None, 34 | access_latency=access_latency, 35 | ) 36 | node = DatasetNode( 37 | parent=None, 38 | path=DATASET_ROOT_NAME, 39 | name=DATASET_ROOT_NAME, 40 | dataset=ctx.dataset, 41 | ) 42 | rule_op = ( 43 | AccessLatency(threshold=threshold) 44 | if threshold is not None 45 | else AccessLatency() 46 | ) 47 | with pytest.raises(RuleExit): 48 | rule_op.validate_dataset(ctx, node) 49 | return ctx 50 | 51 | def test_valid(self): 52 | ctx = self.invoke_op(xr.Dataset(), 1.0, threshold=None) 53 | self.assertEqual([], ctx.messages) 54 | 55 | ctx = self.invoke_op(xr.Dataset(), 1.0, threshold=1.0) 56 | self.assertEqual([], ctx.messages) 57 | 58 | def test_invalid(self): 59 | ctx = self.invoke_op(xr.Dataset(), 3.16, threshold=None) 60 | self.assertEqual( 61 | [ 62 | Message( 63 | message="Access latency exceeds threshold: 3.2 > 2.5 seconds.", 64 | node_path=DATASET_ROOT_NAME, 65 | severity=2, 66 | ) 67 | ], 68 | ctx.messages, 69 | ) 70 | 71 | ctx = self.invoke_op(xr.Dataset(), 0.2032, threshold=0.1) 72 | self.assertEqual( 73 | [ 74 | Message( 75 | message="Access latency exceeds threshold: 0.2 > 0.1 seconds.", 76 | node_path=DATASET_ROOT_NAME, 77 | severity=2, 78 | ) 79 | ], 80 | ctx.messages, 81 | ) 82 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/test_content_desc.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from xrlint.plugins.core.rules.content_desc import ContentDesc 8 | from xrlint.testing import RuleTest, RuleTester 9 | 10 | global_attrs = dict( 11 | title="OC-Climatology", 12 | history="2025-01-26: created", 13 | ) 14 | 15 | common_attrs = dict( 16 | institution="ESA", 17 | source="a.nc; b.nc", 18 | references="!", 19 | comment="?", 20 | ) 21 | 22 | all_attrs = global_attrs | common_attrs 23 | 24 | time_coord = xr.DataArray( 25 | [1, 2, 3], dims="time", attrs=dict(units="days since 2025-01-01") 26 | ) 27 | 28 | valid_dataset_0 = xr.Dataset( 29 | attrs=all_attrs, 30 | data_vars=dict(chl=xr.DataArray([1, 2, 3], dims="time", attrs=dict())), 31 | coords=dict(time=time_coord), 32 | ) 33 | valid_dataset_1 = xr.Dataset( 34 | attrs=global_attrs, 35 | data_vars=dict(chl=xr.DataArray([1, 2, 3], dims="time", attrs=common_attrs)), 36 | coords=dict(time=time_coord), 37 | ) 38 | valid_dataset_1a = xr.Dataset( 39 | attrs=global_attrs, 40 | data_vars=dict( 41 | chl=xr.DataArray([1, 2, 3], dims="time", attrs=common_attrs), 42 | crs=xr.DataArray(0, attrs=dict(grid_mapping_name="...")), 43 | ), 44 | coords=dict(time=time_coord), 45 | ) 46 | valid_dataset_1b = xr.Dataset( 47 | attrs=global_attrs, 48 | data_vars=dict( 49 | chl=xr.DataArray([1, 2, 3], dims="time", attrs=common_attrs), 50 | chl_unc=xr.DataArray(0, attrs=dict(units="...")), 51 | ), 52 | coords=dict(time=time_coord), 53 | ) 54 | valid_dataset_2 = xr.Dataset( 55 | attrs=global_attrs, 56 | data_vars=dict(chl=xr.DataArray([1, 2, 3], dims="time", attrs=dict())), 57 | coords=dict(time=time_coord), 58 | ) 59 | valid_dataset_3 = xr.Dataset( 60 | attrs=global_attrs, 61 | data_vars=dict( 62 | chl=xr.DataArray([1, 2, 3], dims="time", attrs=dict(description="Bla!")) 63 | ), 64 | coords=dict(time=time_coord), 65 | ) 66 | 67 | invalid_dataset_0 = xr.Dataset() 68 | invalid_dataset_1 = xr.Dataset( 69 | attrs=dict(), 70 | data_vars=dict(chl=xr.DataArray([1, 2, 3], dims="time", attrs=dict())), 71 | coords=dict(time=time_coord), 72 | ) 73 | invalid_dataset_2 = xr.Dataset( 74 | attrs=global_attrs, 75 | data_vars=dict(chl=xr.DataArray([1, 2, 3], dims="time", attrs=dict())), 76 | coords=dict(time=time_coord), 77 | ) 78 | 79 | ContentDescTest = RuleTester.define_test( 80 | "content-desc", 81 | ContentDesc, 82 | valid=[ 83 | RuleTest(dataset=valid_dataset_0, name="0"), 84 | RuleTest(dataset=valid_dataset_1, name="1"), 85 | RuleTest(dataset=valid_dataset_1a, name="1a"), 86 | RuleTest( 87 | dataset=valid_dataset_1b, name="1b", kwargs={"ignored_vars": ["chl_unc"]} 88 | ), 89 | RuleTest(dataset=valid_dataset_2, name="2", kwargs={"commons": []}), 90 | RuleTest( 91 | dataset=valid_dataset_2, name="2", kwargs={"commons": [], "skip_vars": True} 92 | ), 93 | RuleTest( 94 | dataset=valid_dataset_3, name="3", kwargs={"commons": ["description"]} 95 | ), 96 | ], 97 | invalid=[ 98 | RuleTest(dataset=invalid_dataset_0, expected=2), 99 | RuleTest(dataset=invalid_dataset_1, expected=6), 100 | RuleTest(dataset=invalid_dataset_2, kwargs={"skip_vars": True}, expected=4), 101 | ], 102 | ) 103 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/test_conventions.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from xrlint.plugins.core.rules.conventions import Conventions 8 | from xrlint.testing import RuleTest, RuleTester 9 | 10 | valid_dataset_0 = xr.Dataset(attrs=dict(Conventions="CF-1.10")) 11 | 12 | invalid_dataset_0 = xr.Dataset() 13 | invalid_dataset_1 = xr.Dataset(attrs=dict(Conventions=1.12)) 14 | invalid_dataset_2 = xr.Dataset(attrs=dict(Conventions="CF 1.10")) 15 | 16 | 17 | ConventionsTest = RuleTester.define_test( 18 | "conventions", 19 | Conventions, 20 | valid=[ 21 | RuleTest(dataset=valid_dataset_0), 22 | RuleTest(dataset=valid_dataset_0, kwargs={"match": r"CF-.*"}), 23 | ], 24 | invalid=[ 25 | RuleTest( 26 | dataset=invalid_dataset_0, 27 | expected=["Missing attribute 'Conventions'."], 28 | ), 29 | RuleTest( 30 | dataset=invalid_dataset_1, 31 | expected=["Invalid attribute 'Conventions': 1.12."], 32 | ), 33 | RuleTest( 34 | dataset=invalid_dataset_2, 35 | kwargs={"match": r"CF-.*"}, 36 | expected=[ 37 | "Invalid attribute 'Conventions': 'CF 1.10' doesn't match 'CF-.*'." 38 | ], 39 | ), 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/test_coords_for_dims.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from xrlint.plugins.core.rules.coords_for_dims import CoordsForDims 8 | from xrlint.testing import RuleTest, RuleTester 9 | 10 | valid_dataset_1 = xr.Dataset(attrs=dict(title="empty")) 11 | valid_dataset_2 = xr.Dataset( 12 | attrs=dict(title="v-data"), 13 | coords={"x": xr.DataArray([0, 0.1, 0.2], dims="x", attrs={"units": "s"})}, 14 | data_vars={"v": xr.DataArray([10, 20, 30], dims="x", attrs={"units": "m/s"})}, 15 | ) 16 | invalid_dataset_2 = valid_dataset_2.drop_vars("x") 17 | 18 | 19 | CoordsForDimsTest = RuleTester.define_test( 20 | "coords-for-dims", 21 | CoordsForDims, 22 | valid=[ 23 | RuleTest(dataset=valid_dataset_1), 24 | RuleTest(dataset=valid_dataset_2), 25 | ], 26 | invalid=[ 27 | RuleTest(dataset=invalid_dataset_2, expected=1), 28 | ], 29 | ) 30 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/test_grid_mappings.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import numpy as np 6 | import xarray as xr 7 | 8 | from xrlint.plugins.core.rules.grid_mappings import GridMappings 9 | from xrlint.testing import RuleTest, RuleTester 10 | 11 | 12 | def make_dataset(): 13 | return xr.Dataset( 14 | attrs=dict(title="OC Data"), 15 | coords={ 16 | "x": xr.DataArray(np.linspace(0, 1, 4), dims="x", attrs={"units": "m"}), 17 | "y": xr.DataArray(np.linspace(0, 1, 3), dims="y", attrs={"units": "m"}), 18 | "time": xr.DataArray([2022, 2021], dims="time", attrs={"units": "years"}), 19 | "crs": xr.DataArray( 20 | 0, 21 | attrs={ 22 | "grid_mapping_name": "latitude_longitude", 23 | "semi_major_axis": 6371000.0, 24 | "inverse_flattening": 0, 25 | }, 26 | ), 27 | }, 28 | data_vars={ 29 | "chl": xr.DataArray( 30 | np.random.random((2, 3, 4)), 31 | dims=["time", "y", "x"], 32 | attrs={"units": "mg/m^-3", "grid_mapping": "crs"}, 33 | ), 34 | "tsm": xr.DataArray( 35 | np.random.random((2, 3, 4)), 36 | dims=["time", "y", "x"], 37 | attrs={"units": "mg/m^-3", "grid_mapping": "crs"}, 38 | ), 39 | }, 40 | ) 41 | 42 | 43 | valid_dataset_1 = xr.Dataset(attrs=dict(title="Empty")) 44 | valid_dataset_2 = make_dataset() 45 | 46 | invalid_dataset_1 = make_dataset().drop_vars("crs") 47 | invalid_dataset_2 = make_dataset() 48 | crs_var = invalid_dataset_2.coords["crs"] 49 | del invalid_dataset_2.coords["crs"] 50 | invalid_dataset_2["crs"] = crs_var 51 | invalid_dataset_3 = make_dataset() 52 | crs_var = invalid_dataset_3.coords["crs"] 53 | del invalid_dataset_3.coords["crs"] 54 | invalid_dataset_3 = invalid_dataset_3.assign_coords(crs=crs_var.expand_dims("m")) 55 | invalid_dataset_4 = make_dataset() 56 | del invalid_dataset_4["crs"].attrs["grid_mapping_name"] 57 | 58 | 59 | GridMappingsTest = RuleTester.define_test( 60 | "grid-mappings", 61 | GridMappings, 62 | valid=[ 63 | RuleTest(dataset=valid_dataset_1), 64 | RuleTest(dataset=valid_dataset_2), 65 | ], 66 | invalid=[ 67 | RuleTest(dataset=invalid_dataset_1, expected=1), 68 | RuleTest(dataset=invalid_dataset_2, expected=1), 69 | RuleTest(dataset=invalid_dataset_3, expected=1), 70 | RuleTest(dataset=invalid_dataset_4, expected=1), 71 | ], 72 | ) 73 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/test_no_empty_attrs.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from xrlint.plugins.core.rules.no_empty_attrs import NoEmptyAttrs 8 | from xrlint.testing import RuleTest, RuleTester 9 | 10 | valid_dataset_1 = xr.Dataset(attrs=dict(title="empty")) 11 | valid_dataset_2 = xr.Dataset( 12 | attrs=dict(title="v-data"), 13 | coords={"x": xr.DataArray([0, 0.1, 0.2], dims="x", attrs={"units": "s"})}, 14 | data_vars={"v": xr.DataArray([10, 20, 30], dims="x", attrs={"units": "m/s"})}, 15 | ) 16 | invalid_dataset_1 = valid_dataset_1.copy() 17 | invalid_dataset_2 = valid_dataset_2.copy() 18 | invalid_dataset_3 = valid_dataset_2.copy() 19 | invalid_dataset_1.attrs = {} 20 | invalid_dataset_2.x.attrs = {} 21 | invalid_dataset_3.v.attrs = {} 22 | 23 | 24 | NoEmptyAttrsTest = RuleTester.define_test( 25 | "no-empty-attrs", 26 | NoEmptyAttrs, 27 | valid=[ 28 | RuleTest(dataset=valid_dataset_1), 29 | RuleTest(dataset=valid_dataset_2), 30 | ], 31 | invalid=[ 32 | RuleTest(dataset=invalid_dataset_1, expected=1), 33 | RuleTest(dataset=invalid_dataset_2, expected=1), 34 | RuleTest(dataset=invalid_dataset_3, expected=1), 35 | ], 36 | ) 37 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/test_no_empty_chunks.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from xrlint.plugins.core.rules.no_empty_chunks import NoEmptyChunks 8 | from xrlint.testing import RuleTest, RuleTester 9 | 10 | # valid, because it is not chunked 11 | valid_dataset_0 = xr.Dataset(attrs=dict(title="OC-Climatology")) 12 | valid_dataset_0.encoding["source"] = "test.zarr" 13 | valid_dataset_0["sst"] = xr.DataArray([273, 274, 272], dims="time") 14 | valid_dataset_0["sst"].encoding["_FillValue"] = 0 15 | valid_dataset_0["sst"].encoding["chunks"] = [3] 16 | # valid, because it does not apply 17 | valid_dataset_1 = valid_dataset_0.copy() 18 | del valid_dataset_1.encoding["source"] 19 | # valid, because it does not apply 20 | valid_dataset_2 = valid_dataset_0.copy() 21 | valid_dataset_2.encoding["source"] = "test.nc" 22 | 23 | # valid, because it does not apply 24 | invalid_dataset_0 = valid_dataset_0.copy() 25 | invalid_dataset_0["sst"].encoding["chunks"] = [1] 26 | 27 | NoEmptyChunksTest = RuleTester.define_test( 28 | "no-empty-chunks", 29 | NoEmptyChunks, 30 | valid=[ 31 | RuleTest(dataset=valid_dataset_0), 32 | RuleTest(dataset=valid_dataset_1), 33 | RuleTest(dataset=valid_dataset_2), 34 | ], 35 | invalid=[ 36 | RuleTest(dataset=invalid_dataset_0, expected=1), 37 | ], 38 | ) 39 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/test_var_desc.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from xrlint.plugins.core.rules.var_desc import VarDesc 8 | from xrlint.testing import RuleTest, RuleTester 9 | 10 | pressure_attrs = dict( 11 | long_name="mean sea level pressure", 12 | units="hPa", 13 | standard_name="air_pressure_at_sea_level", 14 | ) 15 | 16 | time_coord = xr.DataArray( 17 | [1, 2, 3], dims="time", attrs=dict(units="days since 2025-01-01") 18 | ) 19 | 20 | valid_dataset_0 = xr.Dataset( 21 | coords=dict(time=time_coord), 22 | ) 23 | valid_dataset_1 = xr.Dataset( 24 | data_vars=dict(pressure=xr.DataArray([1, 2, 3], dims="time", attrs=pressure_attrs)), 25 | coords=dict(time=time_coord), 26 | ) 27 | valid_dataset_2 = xr.Dataset( 28 | data_vars=dict( 29 | chl=xr.DataArray( 30 | [1, 2, 3], dims="time", attrs=dict(description="It is air pressure") 31 | ) 32 | ), 33 | coords=dict(time=time_coord), 34 | ) 35 | 36 | invalid_dataset_0 = xr.Dataset( 37 | attrs=dict(), 38 | data_vars=dict(chl=xr.DataArray([1, 2, 3], dims="time", attrs=dict())), 39 | coords=dict(time=time_coord), 40 | ) 41 | 42 | invalid_dataset_1 = xr.Dataset( 43 | attrs=dict(), 44 | data_vars=dict( 45 | chl=xr.DataArray( 46 | [1, 2, 3], 47 | dims="time", 48 | attrs=dict(standard_name="air_pressure_at_sea_level"), 49 | ) 50 | ), 51 | coords=dict(time=time_coord), 52 | ) 53 | invalid_dataset_2 = xr.Dataset( 54 | attrs=dict(), 55 | data_vars=dict( 56 | chl=xr.DataArray( 57 | [1, 2, 3], dims="time", attrs=dict(long_name="mean sea level pressure") 58 | ) 59 | ), 60 | coords=dict(time=time_coord), 61 | ) 62 | invalid_dataset_3 = xr.Dataset( 63 | attrs=dict(), 64 | data_vars=dict(chl=xr.DataArray([1, 2, 3], dims="time", attrs=pressure_attrs)), 65 | coords=dict(time=time_coord), 66 | ) 67 | 68 | VarDescTest = RuleTester.define_test( 69 | "var-desc", 70 | VarDesc, 71 | valid=[ 72 | RuleTest(dataset=valid_dataset_0), 73 | RuleTest(dataset=valid_dataset_1), 74 | RuleTest(dataset=valid_dataset_2, kwargs={"attrs": ["description"]}), 75 | ], 76 | invalid=[ 77 | RuleTest( 78 | dataset=invalid_dataset_0, 79 | expected=[ 80 | "Missing attribute 'standard_name'.", 81 | "Missing attribute 'long_name'.", 82 | ], 83 | ), 84 | RuleTest( 85 | dataset=invalid_dataset_1, expected=["Missing attribute 'long_name'."] 86 | ), 87 | RuleTest( 88 | dataset=invalid_dataset_2, expected=["Missing attribute 'standard_name'."] 89 | ), 90 | RuleTest( 91 | dataset=invalid_dataset_3, 92 | kwargs={"attrs": ["description"]}, 93 | expected=["Missing attribute 'description'."], 94 | ), 95 | ], 96 | ) 97 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/test_var_missing_data.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | import numpy as np 5 | import xarray as xr 6 | 7 | from xrlint.plugins.core.rules.var_missing_data import VarMissingData 8 | from xrlint.testing import RuleTest, RuleTester 9 | 10 | valid_dataset_0 = xr.Dataset() 11 | valid_dataset_1 = xr.Dataset( 12 | attrs=dict(title="v-data"), 13 | coords={"t": xr.DataArray([0, 1, 2], dims="t", attrs={"units": "seconds"})}, 14 | data_vars={"v": xr.DataArray([10, 20, 30], dims="t", attrs={"units": "m/s"})}, 15 | ) 16 | 17 | invalid_dataset_0 = valid_dataset_1.copy(deep=True) 18 | invalid_dataset_0.t.attrs["_FillValue"] = -999 19 | 20 | invalid_dataset_1 = valid_dataset_1.copy(deep=True) 21 | invalid_dataset_1.t.encoding["_FillValue"] = -999 22 | 23 | invalid_dataset_2 = valid_dataset_1.copy(deep=True) 24 | invalid_dataset_2.v.attrs["scaling_factor"] = 0.01 25 | 26 | invalid_dataset_3 = valid_dataset_1.copy(deep=True) 27 | invalid_dataset_3.v.encoding["dtype"] = np.dtype(np.float64) 28 | 29 | invalid_dataset_4 = valid_dataset_1.copy(deep=True) 30 | invalid_dataset_4.v.attrs["valid_range"] = [0, 1] 31 | 32 | VarMissingDataTest = RuleTester.define_test( 33 | "var-missing-data", 34 | VarMissingData, 35 | valid=[ 36 | RuleTest(dataset=valid_dataset_0), 37 | RuleTest(dataset=valid_dataset_1), 38 | ], 39 | invalid=[ 40 | RuleTest( 41 | dataset=invalid_dataset_0, 42 | expected=[ 43 | "Unexpected attribute '_FillValue', coordinates must not have missing data." 44 | ], 45 | ), 46 | RuleTest( 47 | dataset=invalid_dataset_1, 48 | expected=[ 49 | "Unexpected encoding '_FillValue', coordinates must not have missing data." 50 | ], 51 | ), 52 | RuleTest( 53 | dataset=invalid_dataset_2, 54 | expected=["Missing attribute '_FillValue' since data packing is used."], 55 | ), 56 | RuleTest( 57 | dataset=invalid_dataset_3, 58 | expected=["Missing attribute '_FillValue', which should be NaN."], 59 | ), 60 | RuleTest( 61 | dataset=invalid_dataset_4, 62 | expected=["Valid ranges are not recognized by xarray (as of Feb 2025)."], 63 | ), 64 | ], 65 | ) 66 | -------------------------------------------------------------------------------- /tests/plugins/core/rules/test_var_units.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from xrlint.plugins.core.rules.var_units import VarUnits 8 | from xrlint.testing import RuleTest, RuleTester 9 | 10 | valid_dataset_0 = xr.Dataset() 11 | valid_dataset_1 = xr.Dataset( 12 | attrs=dict(title="v-data"), 13 | coords={"t": xr.DataArray([0, 1, 2], dims="t", attrs={"units": "seconds"})}, 14 | data_vars={"v": xr.DataArray([10, 20, 30], dims="t", attrs={"units": "m/s"})}, 15 | ) 16 | valid_dataset_2 = valid_dataset_1.copy() 17 | valid_dataset_2.t.encoding["units"] = "seconds since 2025-02-01 12:15:00" 18 | del valid_dataset_2.t.attrs["units"] 19 | 20 | valid_dataset_3 = valid_dataset_1.copy() 21 | valid_dataset_3.t.attrs["grid_mapping_name"] = "latitude_longitude" 22 | 23 | invalid_dataset_0 = valid_dataset_1.copy() 24 | invalid_dataset_0.t.attrs = {} 25 | 26 | invalid_dataset_1 = valid_dataset_1.copy() 27 | invalid_dataset_1.t.attrs = {"units": 1} 28 | 29 | invalid_dataset_2 = valid_dataset_1.copy() 30 | invalid_dataset_2.t.attrs = {"units": ""} 31 | 32 | 33 | VarUnitsTest = RuleTester.define_test( 34 | "var-units", 35 | VarUnits, 36 | valid=[ 37 | RuleTest(dataset=valid_dataset_0), 38 | RuleTest(dataset=valid_dataset_1), 39 | RuleTest(dataset=valid_dataset_2), 40 | RuleTest(dataset=valid_dataset_3), 41 | ], 42 | invalid=[ 43 | RuleTest(dataset=invalid_dataset_0, expected=["Missing attribute 'units'."]), 44 | RuleTest(dataset=invalid_dataset_1, expected=["Invalid attribute 'units': 1"]), 45 | RuleTest(dataset=invalid_dataset_2, expected=["Empty attribute 'units'."]), 46 | ], 47 | ) 48 | -------------------------------------------------------------------------------- /tests/plugins/core/test_plugin.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.plugins.core import export_plugin 8 | 9 | 10 | class ExportPluginTest(TestCase): 11 | def test_rules_complete(self): 12 | plugin = export_plugin() 13 | self.assertEqual( 14 | { 15 | "access-latency", 16 | "content-desc", 17 | "conventions", 18 | "coords-for-dims", 19 | "grid-mappings", 20 | "lat-coordinate", 21 | "lon-coordinate", 22 | "no-empty-attrs", 23 | "no-empty-chunks", 24 | "time-coordinate", 25 | "var-desc", 26 | "var-flags", 27 | "var-missing-data", 28 | "var-units", 29 | }, 30 | set(plugin.rules.keys()), 31 | ) 32 | 33 | def test_configs_complete(self): 34 | plugin = export_plugin() 35 | self.assertEqual( 36 | { 37 | "all", 38 | "recommended", 39 | }, 40 | set(plugin.configs.keys()), 41 | ) 42 | all_rule_names = set(plugin.rules.keys()) 43 | self.assertEqual( 44 | all_rule_names, 45 | set(plugin.configs["all"][-1].rules.keys()), 46 | ) 47 | self.assertEqual( 48 | all_rule_names, 49 | set(plugin.configs["recommended"][-1].rules.keys()), 50 | ) 51 | -------------------------------------------------------------------------------- /tests/plugins/xcube/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/plugins/xcube/helpers.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import math 6 | 7 | import numpy as np 8 | import xarray as xr 9 | 10 | from xrlint.plugins.xcube.util import LevelsMeta, attach_dataset_level_infos 11 | 12 | 13 | def make_cube_levels( 14 | nl: int, 15 | nx: int, 16 | ny: int, 17 | nt: int | None = None, 18 | meta: LevelsMeta | None = None, 19 | force_infos: bool = False, 20 | ) -> list[xr.Dataset]: 21 | levels = [ 22 | make_cube(math.ceil(nx >> level), math.ceil(ny >> level), nt) 23 | for level in range(nl) 24 | ] 25 | if meta is not None or force_infos: 26 | attach_dataset_level_infos( 27 | [(ds, f"{i}.zarr") for i, ds in enumerate(levels)], meta=meta 28 | ) 29 | return levels 30 | 31 | 32 | def make_cube(nx: int, ny: int, nt: int | None = None) -> xr.Dataset: 33 | """Make an in-memory dataset that should pass all xcube rules. 34 | 35 | Args: 36 | nx: length of the lon-dimension 37 | ny: length of the lat-dimension 38 | nt: length of the time-dimension, optional 39 | 40 | Returns: 41 | an in-memory dataset with one 3-d data variable "chl" 42 | with dimensions ["time",] "lat", "lon". 43 | """ 44 | x_attrs = dict( 45 | long_name="longitude", 46 | standard_name="longitude", 47 | units="degrees_east", 48 | ) 49 | y_attrs = dict( 50 | long_name="latitude", 51 | standard_name="latitude", 52 | units="degrees_north", 53 | ) 54 | 55 | dx = 180.0 / nx 56 | dy = 90.0 / ny 57 | x_data = np.linspace(-180 + dx, 180 - dx, nx) 58 | y_data = np.linspace(-90 + dy, 90 - dy, ny) 59 | 60 | chl_attrs = dict( 61 | long_name="chlorophyll concentration", 62 | standard_name="chlorophyll_concentration", 63 | units="mg/m^3", 64 | _FillValue=0, 65 | ) 66 | chl_chunks = dict(lat=min(ny, 90), lon=min(nx, 90)) 67 | 68 | ds_attrs = dict(title="Chlorophyll") 69 | 70 | coords = dict( 71 | lon=xr.DataArray(x_data, dims="lon", attrs=x_attrs), 72 | lat=xr.DataArray(y_data, dims="lat", attrs=y_attrs), 73 | ) 74 | 75 | if nt is None: 76 | return xr.Dataset( 77 | data_vars=dict( 78 | chl=xr.DataArray( 79 | np.zeros((ny, nx)), dims=["lat", "lon"], attrs=chl_attrs 80 | ).chunk(**chl_chunks), 81 | ), 82 | coords=coords, 83 | attrs=ds_attrs, 84 | ) 85 | else: 86 | time_attrs = dict( 87 | long_name="time", 88 | standard_name="time", 89 | units="days since 2024-06-10:12:00:00 utc", 90 | calendar="gregorian", 91 | ) 92 | coords.update(time=xr.DataArray(range(nt), dims="time", attrs=time_attrs)) 93 | return xr.Dataset( 94 | data_vars=dict( 95 | chl=xr.DataArray( 96 | np.zeros((nt, ny, nx)), dims=["time", "lat", "lon"], attrs=chl_attrs 97 | ).chunk(time=1, **chl_chunks), 98 | ), 99 | coords=coords, 100 | attrs=ds_attrs, 101 | ) 102 | -------------------------------------------------------------------------------- /tests/plugins/xcube/processors/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_any_spatial_data_var.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.plugins.xcube.rules.any_spatial_data_var import AnySpatialDataVar 6 | from xrlint.testing import RuleTest, RuleTester 7 | 8 | from .test_grid_mapping_naming import make_dataset 9 | 10 | valid_dataset = make_dataset() 11 | invalid_dataset = valid_dataset.drop_vars(["chl", "tsm"]) 12 | 13 | 14 | AnySpatialDataVarTest = RuleTester.define_test( 15 | "any-spatial-data-var", 16 | AnySpatialDataVar, 17 | valid=[ 18 | RuleTest(dataset=valid_dataset), 19 | ], 20 | invalid=[ 21 | RuleTest(dataset=invalid_dataset, expected=1), 22 | ], 23 | ) 24 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_cube_dims_order.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import numpy as np 6 | import xarray as xr 7 | 8 | from xrlint.plugins.xcube.rules.cube_dims_order import CubeDimsOrder 9 | from xrlint.testing import RuleTest, RuleTester 10 | 11 | 12 | def make_dataset(dims: tuple[str, str, str]): 13 | n = 3 14 | return xr.Dataset( 15 | attrs=dict(title="v-data"), 16 | coords={ 17 | "x": xr.DataArray(np.linspace(0, 1, n), dims="x", attrs={"units": "m"}), 18 | "y": xr.DataArray(np.linspace(0, 1, n), dims="y", attrs={"units": "m"}), 19 | "time": xr.DataArray( 20 | list(range(2010, 2010 + n)), dims="time", attrs={"units": "years"} 21 | ), 22 | }, 23 | data_vars={ 24 | "chl": xr.DataArray( 25 | np.random.random((n, n, n)), dims=dims, attrs={"units": "mg/m^-3"} 26 | ), 27 | "tsm": xr.DataArray( 28 | np.random.random((n, n, n)), dims=dims, attrs={"units": "mg/m^-3"} 29 | ), 30 | "avg_temp": xr.DataArray( 31 | np.random.random(n), dims=dims[0], attrs={"units": "kelvin"} 32 | ), 33 | }, 34 | ) 35 | 36 | 37 | valid_dataset_0 = make_dataset(("time", "y", "x")) 38 | valid_dataset_1 = make_dataset(("time", "lat", "lon")) 39 | valid_dataset_2 = make_dataset(("level", "y", "x")) 40 | 41 | invalid_dataset_0 = make_dataset(("time", "x", "y")) 42 | invalid_dataset_1 = make_dataset(("x", "y", "time")) 43 | invalid_dataset_2 = make_dataset(("time", "lon", "lat")) 44 | invalid_dataset_3 = make_dataset(("lon", "lat", "level")) 45 | invalid_dataset_4 = make_dataset(("x", "y", "level")) 46 | 47 | 48 | CubeDimsOrderTest = RuleTester.define_test( 49 | "cube-dims-order", 50 | CubeDimsOrder, 51 | valid=[ 52 | RuleTest(dataset=valid_dataset_0), 53 | RuleTest(dataset=valid_dataset_1), 54 | RuleTest(dataset=valid_dataset_2), 55 | ], 56 | invalid=[ 57 | RuleTest(dataset=invalid_dataset_0, expected=2), 58 | RuleTest(dataset=invalid_dataset_1, expected=2), 59 | RuleTest(dataset=invalid_dataset_2, expected=2), 60 | RuleTest(dataset=invalid_dataset_3, expected=2), 61 | RuleTest(dataset=invalid_dataset_4, expected=2), 62 | ], 63 | ) 64 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_data_var_colors.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import numpy as np 6 | import xarray as xr 7 | 8 | from xrlint.plugins.xcube.rules.data_var_colors import DataVarColors 9 | from xrlint.testing import RuleTest, RuleTester 10 | 11 | 12 | def make_dataset(): 13 | dims = ["time", "y", "x"] 14 | n = 5 15 | return xr.Dataset( 16 | attrs=dict(title="v-data"), 17 | coords={ 18 | dims[2]: xr.DataArray( 19 | np.linspace(0, 1, n), dims=dims[2], attrs={"units": "m"} 20 | ), 21 | dims[1]: xr.DataArray( 22 | np.linspace(0, 1, n), dims=dims[1], attrs={"units": "m"} 23 | ), 24 | dims[0]: xr.DataArray( 25 | [2010, 2011, 2012, 2013, 2014], 26 | dims=dims[0], 27 | attrs={"units": "years"}, 28 | ), 29 | }, 30 | data_vars={ 31 | "chl": xr.DataArray( 32 | np.random.random((n, n, n)), 33 | dims=dims, 34 | attrs={ 35 | "units": "mg/m^-3", 36 | "color_bar_name": "plasma", 37 | "color_value_min": 0, 38 | "color_value_max": 100, 39 | "color_norm": "log", 40 | }, 41 | ), 42 | }, 43 | ) 44 | 45 | 46 | valid_dataset_1 = make_dataset() 47 | 48 | invalid_dataset_1 = make_dataset() 49 | invalid_dataset_1.chl.attrs = { 50 | "units": "mg/m^-3", 51 | # Missing: 52 | # "color_bar_name": "plasma", 53 | # "color_value_min": 0, 54 | # "color_value_max": 100, 55 | # "color_norm": "log", 56 | } 57 | invalid_dataset_2 = make_dataset() 58 | invalid_dataset_2.chl.attrs = { 59 | "units": "mg/m^-3", 60 | "color_bar_name": "plasma", 61 | # Missing: 62 | # "color_value_min": 0, 63 | # "color_value_max": 100, 64 | # "color_norm": "log", 65 | } 66 | invalid_dataset_3 = make_dataset() 67 | invalid_dataset_3.chl.attrs = { 68 | "units": "mg/m^-3", 69 | "color_bar_name": "plasma", 70 | "color_value_min": 0, 71 | "color_value_max": 100, 72 | "color_norm": "ln", # wrong 73 | } 74 | 75 | LatLonNamingTest = RuleTester.define_test( 76 | "data-var-colors", 77 | DataVarColors, 78 | valid=[ 79 | RuleTest(dataset=valid_dataset_1), 80 | ], 81 | invalid=[ 82 | RuleTest(dataset=invalid_dataset_1, expected=1), 83 | RuleTest(dataset=invalid_dataset_2, expected=1), 84 | RuleTest(dataset=invalid_dataset_3, expected=1), 85 | ], 86 | ) 87 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_dataset_title.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from xrlint.plugins.xcube.rules.dataset_title import DatasetTitle 8 | from xrlint.testing import RuleTest, RuleTester 9 | 10 | valid_dataset_0 = xr.Dataset(attrs=dict(title="OC-Climatology")) 11 | valid_dataset_1 = xr.Dataset(attrs=dict(title="SST-Climatology")) 12 | 13 | invalid_dataset_0 = xr.Dataset() 14 | invalid_dataset_1 = xr.Dataset(attrs=dict(title="")) 15 | 16 | 17 | DatasetTitleTest = RuleTester.define_test( 18 | "dataset-title", 19 | DatasetTitle, 20 | valid=[ 21 | RuleTest(dataset=valid_dataset_0), 22 | RuleTest(dataset=valid_dataset_1), 23 | ], 24 | invalid=[ 25 | RuleTest(dataset=invalid_dataset_0, expected=1), 26 | RuleTest(dataset=invalid_dataset_1, expected=1), 27 | ], 28 | ) 29 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_grid_mapping_naming.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import numpy as np 6 | import xarray as xr 7 | 8 | from xrlint.plugins.xcube.rules.grid_mapping_naming import GridMappingNaming 9 | from xrlint.testing import RuleTest, RuleTester 10 | 11 | 12 | def make_dataset(): 13 | return xr.Dataset( 14 | attrs=dict(title="OC Data"), 15 | coords={ 16 | "x": xr.DataArray(np.linspace(0, 1, 4), dims="x", attrs={"units": "m"}), 17 | "y": xr.DataArray(np.linspace(0, 1, 3), dims="y", attrs={"units": "m"}), 18 | "time": xr.DataArray([2022, 2021], dims="time", attrs={"units": "years"}), 19 | "crs": xr.DataArray( 20 | 0, 21 | attrs={ 22 | "grid_mapping_name": "latitude_longitude", 23 | "semi_major_axis": 6371000.0, 24 | "inverse_flattening": 0, 25 | }, 26 | ), 27 | }, 28 | data_vars={ 29 | "chl": xr.DataArray( 30 | np.random.random((2, 3, 4)), 31 | dims=["time", "y", "x"], 32 | attrs={"units": "mg/m^-3", "grid_mapping": "crs"}, 33 | ), 34 | "tsm": xr.DataArray( 35 | np.random.random((2, 3, 4)), 36 | dims=["time", "y", "x"], 37 | attrs={"units": "mg/m^-3", "grid_mapping": "crs"}, 38 | ), 39 | }, 40 | ) 41 | 42 | 43 | valid_dataset_1 = make_dataset() 44 | valid_dataset_2 = valid_dataset_1.rename({"crs": "spatial_ref"}) 45 | valid_dataset_3 = valid_dataset_1.drop_vars("crs") 46 | 47 | invalid_dataset_1 = valid_dataset_1.rename({"crs": "gm"}) 48 | 49 | 50 | GridMappingNamingTest = RuleTester.define_test( 51 | "grid-mapping-naming", 52 | GridMappingNaming, 53 | valid=[ 54 | RuleTest(dataset=valid_dataset_1), 55 | RuleTest(dataset=valid_dataset_2), 56 | RuleTest(dataset=valid_dataset_3), 57 | ], 58 | invalid=[ 59 | RuleTest(dataset=invalid_dataset_1, expected=1), 60 | ], 61 | ) 62 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_increasing_time.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import numpy as np 6 | import xarray as xr 7 | 8 | from xrlint.plugins.xcube.rules.increasing_time import IncreasingTime 9 | from xrlint.testing import RuleTest, RuleTester 10 | 11 | 12 | def make_dataset(): 13 | dims = ["time", "y", "x"] 14 | n = 5 15 | return xr.Dataset( 16 | attrs=dict(title="v-data"), 17 | coords={ 18 | dims[2]: xr.DataArray( 19 | np.linspace(0, 1, n), dims=dims[2], attrs={"units": "m"} 20 | ), 21 | dims[1]: xr.DataArray( 22 | np.linspace(0, 1, n), dims=dims[1], attrs={"units": "m"} 23 | ), 24 | dims[0]: xr.DataArray( 25 | [2010, 2011, 2012, 2013, 2014], 26 | dims=dims[0], 27 | attrs={"units": "years"}, 28 | ), 29 | }, 30 | data_vars={ 31 | "chl": xr.DataArray( 32 | np.random.random((n, n, n)), dims=dims, attrs={"units": "mg/m^-3"} 33 | ), 34 | "tsm": xr.DataArray( 35 | np.random.random((n, n, n)), dims=dims, attrs={"units": "mg/m^-3"} 36 | ), 37 | "mask": xr.DataArray(np.random.random((n, n)), dims=dims[-2:]), 38 | }, 39 | ) 40 | 41 | 42 | valid_dataset_1 = make_dataset() 43 | 44 | invalid_dataset_1 = make_dataset() 45 | invalid_dataset_1 = invalid_dataset_1.assign_coords( 46 | time=xr.DataArray( 47 | [2010, 2011, 2012, 2013, 2013], dims="time", attrs={"units": "years"} 48 | ) 49 | ) 50 | 51 | invalid_dataset_2 = make_dataset() 52 | invalid_dataset_2 = invalid_dataset_2.assign_coords( 53 | time=xr.DataArray( 54 | [2010, 2011, 2012, 2014, 2013], dims="time", attrs={"units": "years"} 55 | ) 56 | ) 57 | 58 | LatLonNamingTest = RuleTester.define_test( 59 | "increasing-time", 60 | IncreasingTime, 61 | valid=[ 62 | RuleTest(dataset=valid_dataset_1), 63 | ], 64 | invalid=[ 65 | RuleTest(dataset=invalid_dataset_1, expected=1), 66 | RuleTest(dataset=invalid_dataset_2, expected=1), 67 | ], 68 | ) 69 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_lat_lon_naming.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import numpy as np 6 | import xarray as xr 7 | 8 | from xrlint.plugins.xcube.rules.lat_lon_naming import LatLonNaming 9 | from xrlint.testing import RuleTest, RuleTester 10 | 11 | 12 | def make_dataset(lat_dim: str, lon_dim: str): 13 | dims = ["time", lat_dim, lon_dim] 14 | n = 3 15 | return xr.Dataset( 16 | attrs=dict(title="v-data"), 17 | coords={ 18 | lon_dim: xr.DataArray( 19 | np.linspace(0, 1, n), dims=lon_dim, attrs={"units": "m"} 20 | ), 21 | lat_dim: xr.DataArray( 22 | np.linspace(0, 1, n), dims=lat_dim, attrs={"units": "m"} 23 | ), 24 | "time": xr.DataArray( 25 | list(range(2010, 2010 + n)), dims="time", attrs={"units": "years"} 26 | ), 27 | }, 28 | data_vars={ 29 | "chl": xr.DataArray( 30 | np.random.random((n, n, n)), dims=dims, attrs={"units": "mg/m^-3"} 31 | ), 32 | "tsm": xr.DataArray( 33 | np.random.random((n, n, n)), dims=dims, attrs={"units": "mg/m^-3"} 34 | ), 35 | "avg_temp": xr.DataArray( 36 | np.random.random(n), dims=dims[0], attrs={"units": "kelvin"} 37 | ), 38 | "mask": xr.DataArray(np.random.random((n, n)), dims=dims[-2:]), 39 | }, 40 | ) 41 | 42 | 43 | valid_dataset_1 = make_dataset("lat", "lon") 44 | 45 | invalid_dataset_1 = make_dataset("lat", "long") 46 | invalid_dataset_2 = make_dataset("lat", "longitude") 47 | invalid_dataset_3 = make_dataset("lat", "Lon") 48 | 49 | invalid_dataset_4 = make_dataset("ltd", "lon") 50 | invalid_dataset_5 = make_dataset("latitude", "lon") 51 | invalid_dataset_6 = make_dataset("Lat", "lon") 52 | 53 | LatLonNamingTest = RuleTester.define_test( 54 | "lat-lon-naming", 55 | LatLonNaming, 56 | valid=[ 57 | RuleTest(dataset=valid_dataset_1), 58 | ], 59 | invalid=[ 60 | RuleTest(dataset=invalid_dataset_1, expected=1), 61 | RuleTest(dataset=invalid_dataset_2, expected=1), 62 | RuleTest(dataset=invalid_dataset_3, expected=1), 63 | RuleTest(dataset=invalid_dataset_4, expected=1), 64 | RuleTest(dataset=invalid_dataset_5, expected=1), 65 | RuleTest(dataset=invalid_dataset_6, expected=1), 66 | ], 67 | ) 68 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_ml_dataset_meta.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from tests.plugins.xcube.helpers import make_cube_levels 8 | from xrlint.plugins.xcube.rules.ml_dataset_meta import MLDatasetMeta 9 | from xrlint.plugins.xcube.util import ( 10 | LevelInfo, 11 | LevelsMeta, 12 | get_dataset_level_info, 13 | set_dataset_level_info, 14 | ) 15 | from xrlint.testing import RuleTest, RuleTester 16 | 17 | 18 | def _replace_meta(dataset: xr.Dataset, meta: LevelsMeta) -> xr.Dataset: 19 | dataset = dataset.copy() 20 | old_level_info = get_dataset_level_info(dataset) 21 | new_level_info = LevelInfo( 22 | level=old_level_info.level, 23 | num_levels=old_level_info.num_levels, 24 | datasets=old_level_info.datasets, 25 | meta=meta, 26 | ) 27 | set_dataset_level_info(dataset, new_level_info) 28 | return dataset 29 | 30 | 31 | levels_with_meta = make_cube_levels( 32 | 4, 33 | 720, 34 | 360, 35 | meta=LevelsMeta( 36 | version="1.0", 37 | num_levels=4, 38 | use_saved_levels=True, 39 | agg_methods={"chl": "mean"}, 40 | ), 41 | ) 42 | 43 | valid_dataset_0 = levels_with_meta[0] 44 | valid_dataset_1 = levels_with_meta[1] 45 | valid_dataset_2 = levels_with_meta[2] 46 | valid_dataset_3 = xr.Dataset() 47 | 48 | levels_wo_meta = make_cube_levels(4, 720, 360, force_infos=True) 49 | invalid_dataset_0 = levels_wo_meta[0] 50 | invalid_dataset_1 = _replace_meta( 51 | levels_wo_meta[0].copy(), 52 | meta=LevelsMeta( 53 | version="2.0", # error: != "1.x" 54 | num_levels=0, # error: < 1 55 | # error: missing use_saved_levels=False 56 | # error: missing agg_methods={"chl": "mean"} 57 | ), 58 | ) 59 | invalid_dataset_2 = _replace_meta( 60 | levels_wo_meta[0], 61 | meta=LevelsMeta( 62 | version="1.0", # ok 63 | num_levels=3, # error: != level_info.num_levels 64 | ), 65 | ) 66 | 67 | invalid_dataset_3 = _replace_meta( 68 | levels_wo_meta[0], 69 | meta=LevelsMeta( 70 | version="1.0", # ok 71 | num_levels=4, # ok 72 | use_saved_levels=False, # ok 73 | agg_methods={"tsm": "median"}, # error: where is "chl"? 74 | ), 75 | ) 76 | 77 | MLDatasetMetaTest = RuleTester.define_test( 78 | "ml-dataset-meta", 79 | MLDatasetMeta, 80 | valid=[ 81 | RuleTest(dataset=valid_dataset_0), 82 | RuleTest(dataset=valid_dataset_1), 83 | RuleTest(dataset=valid_dataset_2), 84 | RuleTest(dataset=valid_dataset_3), 85 | ], 86 | invalid=[ 87 | RuleTest(dataset=invalid_dataset_0, expected=1), 88 | RuleTest(dataset=invalid_dataset_1, expected=4), 89 | RuleTest(dataset=invalid_dataset_2, expected=3), 90 | RuleTest(dataset=invalid_dataset_3, expected=2), 91 | ], 92 | ) 93 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_ml_dataset_time.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from tests.plugins.xcube.helpers import make_cube_levels 8 | from xrlint.plugins.xcube.rules.ml_dataset_time import MLDatasetTime 9 | from xrlint.plugins.xcube.util import LevelsMeta 10 | from xrlint.testing import RuleTest, RuleTester 11 | 12 | meta = LevelsMeta( 13 | version="1.0", 14 | num_levels=4, 15 | use_saved_levels=True, 16 | agg_methods={"chl": "mean"}, 17 | ) 18 | levels_with_time = make_cube_levels(4, 720, 360, nt=6, meta=meta) 19 | levels_wo_time = make_cube_levels(4, 720, 360, meta=meta) 20 | 21 | valid_dataset_0 = levels_with_time[0] 22 | valid_dataset_1 = levels_with_time[1] 23 | valid_dataset_2 = levels_wo_time[0] 24 | valid_dataset_3 = xr.Dataset() 25 | 26 | invalid_dataset_0 = levels_with_time[0].copy() 27 | invalid_dataset_0["chl"] = invalid_dataset_0["chl"].chunk(time=3) 28 | 29 | MLDatasetTimeTest = RuleTester.define_test( 30 | "ml-dataset-time", 31 | MLDatasetTime, 32 | valid=[ 33 | RuleTest(dataset=valid_dataset_0), 34 | RuleTest(dataset=valid_dataset_1), 35 | RuleTest(dataset=valid_dataset_2), 36 | RuleTest(dataset=valid_dataset_3), 37 | ], 38 | invalid=[ 39 | RuleTest(dataset=invalid_dataset_0, expected=1), 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_ml_dataset_xy.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from tests.plugins.xcube.helpers import make_cube_levels 8 | from xrlint.plugins.xcube.rules.ml_dataset_xy import MLDatasetXY 9 | from xrlint.plugins.xcube.util import LevelsMeta, get_dataset_level_info 10 | from xrlint.testing import RuleTest, RuleTester 11 | 12 | meta = LevelsMeta( 13 | version="1.0", 14 | num_levels=4, 15 | use_saved_levels=True, 16 | agg_methods={"chl": "mean"}, 17 | ) 18 | levels = make_cube_levels(4, 720, 360, nt=3, meta=meta) 19 | 20 | valid_dataset_0 = levels[0] 21 | valid_dataset_1 = levels[1] 22 | valid_dataset_2 = levels[2] 23 | valid_dataset_3 = levels[3] 24 | valid_dataset_4 = xr.Dataset() 25 | 26 | levels = make_cube_levels(4, 720, 360, meta=meta) 27 | valid_dataset_5 = levels[2] 28 | level_info = get_dataset_level_info(valid_dataset_5) 29 | for ds, _ in level_info.datasets: 30 | # remove spatial vars 31 | del ds["chl"] 32 | 33 | levels = make_cube_levels(4, 720, 360, meta=meta) 34 | invalid_dataset_0 = levels[2].copy() 35 | # simulate resolution mismatch by exchanging 2 levels 36 | level_info = get_dataset_level_info(invalid_dataset_0) 37 | level_datasets = level_info.datasets 38 | level_0_dataset = level_datasets[0] 39 | level_1_dataset = level_datasets[1] 40 | level_info.datasets[0] = level_1_dataset 41 | level_info.datasets[1] = level_0_dataset 42 | 43 | 44 | MLDatasetXYTest = RuleTester.define_test( 45 | "ml-dataset-xy", 46 | MLDatasetXY, 47 | valid=[ 48 | RuleTest(dataset=valid_dataset_0), 49 | RuleTest(dataset=valid_dataset_1), 50 | RuleTest(dataset=valid_dataset_2), 51 | RuleTest(dataset=valid_dataset_3), 52 | RuleTest(dataset=valid_dataset_4), 53 | RuleTest(dataset=valid_dataset_5), 54 | ], 55 | invalid=[ 56 | RuleTest(dataset=invalid_dataset_0, expected=2), 57 | ], 58 | ) 59 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_no_chunked_coords.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import xarray as xr 6 | 7 | from tests.plugins.xcube.helpers import make_cube 8 | from xrlint.plugins.xcube.rules.no_chunked_coords import NoChunkedCoords 9 | from xrlint.testing import RuleTest, RuleTester 10 | 11 | valid_dataset_0 = xr.Dataset(attrs=dict(title="Empty")) 12 | valid_dataset_1 = make_cube(360, 180, 3) 13 | valid_dataset_2 = make_cube(90, 45, 20) 14 | # ok, below default limit 5: ceil(20 / 5) = 4 15 | valid_dataset_2.time.encoding["chunks"] = [4] 16 | 17 | invalid_dataset_0 = make_cube(90, 45, 10) 18 | # exceed default limit 5: ceil(10 / 1) = 10 19 | invalid_dataset_0.time.encoding["chunks"] = [1] 20 | 21 | NoChunkedCoordsTest = RuleTester.define_test( 22 | "no-chunked-coords", 23 | NoChunkedCoords, 24 | valid=[ 25 | RuleTest(dataset=valid_dataset_0), 26 | RuleTest(dataset=valid_dataset_1), 27 | RuleTest(dataset=valid_dataset_2), 28 | ], 29 | invalid=[ 30 | RuleTest(dataset=invalid_dataset_0, expected=1), 31 | ], 32 | ) 33 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_single_grid_mapping.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import numpy as np 6 | import xarray as xr 7 | 8 | from xrlint.plugins.xcube.rules.single_grid_mapping import SingleGridMapping 9 | from xrlint.testing import RuleTest, RuleTester 10 | 11 | 12 | def make_dataset(): 13 | return xr.Dataset( 14 | attrs=dict(title="OC Data"), 15 | coords={ 16 | "x": xr.DataArray(np.linspace(0, 1, 4), dims="x", attrs={"units": "m"}), 17 | "y": xr.DataArray(np.linspace(0, 1, 3), dims="y", attrs={"units": "m"}), 18 | "time": xr.DataArray([2022, 2021], dims="time", attrs={"units": "years"}), 19 | "crs": xr.DataArray( 20 | 0, 21 | attrs={ 22 | "grid_mapping_name": "latitude_longitude", 23 | "semi_major_axis": 6371000.0, 24 | "inverse_flattening": 0, 25 | }, 26 | ), 27 | }, 28 | data_vars={ 29 | "chl": xr.DataArray( 30 | np.random.random((2, 3, 4)), 31 | dims=["time", "y", "x"], 32 | attrs={"units": "mg/m^-3", "grid_mapping": "crs"}, 33 | ), 34 | "tsm": xr.DataArray( 35 | np.random.random((2, 3, 4)), 36 | dims=["time", "y", "x"], 37 | attrs={"units": "mg/m^-3", "grid_mapping": "crs"}, 38 | ), 39 | }, 40 | ) 41 | 42 | 43 | valid_dataset_1 = xr.Dataset(attrs=dict(title="Empty")) 44 | valid_dataset_2 = make_dataset() 45 | valid_dataset_3 = make_dataset().rename({"crs": "spatial_ref"}) 46 | valid_dataset_4 = make_dataset().drop_vars("crs") 47 | valid_dataset_5 = ( 48 | make_dataset() 49 | .rename_dims({"x": "lon", "y": "lat"}) 50 | .rename_vars({"x": "lon", "y": "lat"}) 51 | .drop_vars("crs") 52 | ) 53 | del valid_dataset_5.chl.attrs["grid_mapping"] 54 | del valid_dataset_5.tsm.attrs["grid_mapping"] 55 | 56 | invalid_dataset_1 = make_dataset().copy(deep=True) 57 | invalid_dataset_1.tsm.attrs["grid_mapping"] = "crs2" 58 | invalid_dataset_2 = make_dataset().copy(deep=True) 59 | del invalid_dataset_2.chl.attrs["grid_mapping"] 60 | del invalid_dataset_2.tsm.attrs["grid_mapping"] 61 | 62 | SingleGridMappingTest = RuleTester.define_test( 63 | "single-grid-mapping", 64 | SingleGridMapping, 65 | valid=[ 66 | RuleTest(dataset=valid_dataset_1), 67 | RuleTest(dataset=valid_dataset_2), 68 | RuleTest(dataset=valid_dataset_3), 69 | RuleTest(dataset=valid_dataset_4), 70 | RuleTest(dataset=valid_dataset_5), 71 | ], 72 | invalid=[ 73 | RuleTest(dataset=invalid_dataset_1, expected=1), 74 | RuleTest(dataset=invalid_dataset_2, expected=1), 75 | ], 76 | ) 77 | -------------------------------------------------------------------------------- /tests/plugins/xcube/rules/test_time_naming.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import numpy as np 6 | import xarray as xr 7 | 8 | from xrlint.plugins.xcube.rules.time_naming import TimeNaming 9 | from xrlint.testing import RuleTest, RuleTester 10 | 11 | 12 | def make_dataset(time_var: str, time_dim: str | None = None): 13 | time_dim = time_dim or time_var 14 | dims = [time_dim, "y", "x"] 15 | n = 3 16 | return xr.Dataset( 17 | attrs=dict(title="v-data"), 18 | coords={ 19 | "x": xr.DataArray(np.linspace(0, 1, n), dims="x", attrs={"units": "m"}), 20 | "y": xr.DataArray(np.linspace(0, 1, n), dims="y", attrs={"units": "m"}), 21 | time_var: xr.DataArray( 22 | list(range(n)), 23 | dims=time_dim, 24 | attrs={"units": "days since 2010-05-01 UTC", "calendar": "gregorian"}, 25 | ), 26 | }, 27 | data_vars={ 28 | "chl": xr.DataArray( 29 | np.random.random((n, n, n)), dims=dims, attrs={"units": "mg/m^-3"} 30 | ), 31 | "tsm": xr.DataArray( 32 | np.random.random((n, n, n)), dims=dims, attrs={"units": "mg/m^-3"} 33 | ), 34 | }, 35 | ) 36 | 37 | 38 | valid_dataset_0 = xr.Dataset() 39 | valid_dataset_1 = make_dataset("time") 40 | 41 | # Not ok, because time coord not called time 42 | invalid_dataset_0 = make_dataset("t") 43 | 44 | # Not ok, because no units 45 | invalid_dataset_1 = make_dataset("time") 46 | del invalid_dataset_1.time.attrs["units"] 47 | 48 | # Not ok, because no calendar 49 | invalid_dataset_2 = make_dataset("time") 50 | del invalid_dataset_2.time.attrs["calendar"] 51 | 52 | # Not ok, because invalid unit 53 | invalid_dataset_3 = make_dataset("time") 54 | invalid_dataset_3.time.attrs["units"] = "meters" 55 | 56 | # Not ok, because coordinate 'time' should have dim 'time' 57 | invalid_dataset_4 = make_dataset("time", "t0") 58 | 59 | # Not ok, because coordinate 't0' should be named 'time' 60 | invalid_dataset_5 = make_dataset("t0", "time") 61 | 62 | TimeNamingTest = RuleTester.define_test( 63 | "time-naming", 64 | TimeNaming, 65 | valid=[ 66 | RuleTest(dataset=valid_dataset_0), 67 | RuleTest(dataset=valid_dataset_1), 68 | ], 69 | invalid=[ 70 | RuleTest(dataset=invalid_dataset_0, expected=1), 71 | RuleTest(dataset=invalid_dataset_1, expected=1), 72 | RuleTest(dataset=invalid_dataset_2, expected=1), 73 | RuleTest(dataset=invalid_dataset_3, expected=1), 74 | RuleTest(dataset=invalid_dataset_4, expected=1), 75 | RuleTest(dataset=invalid_dataset_5, expected=2), 76 | ], 77 | ) 78 | -------------------------------------------------------------------------------- /tests/plugins/xcube/test_plugin.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.plugins.xcube import export_plugin 8 | 9 | 10 | class ExportPluginTest(TestCase): 11 | def test_rules_complete(self): 12 | plugin = export_plugin() 13 | self.assertEqual( 14 | { 15 | "any-spatial-data-var", 16 | "cube-dims-order", 17 | "data-var-colors", 18 | "dataset-title", 19 | "grid-mapping-naming", 20 | "increasing-time", 21 | "lat-lon-naming", 22 | "ml-dataset-meta", 23 | "ml-dataset-time", 24 | "ml-dataset-xy", 25 | "no-chunked-coords", 26 | "single-grid-mapping", 27 | "time-naming", 28 | }, 29 | set(plugin.rules.keys()), 30 | ) 31 | 32 | def test_configs_complete(self): 33 | plugin = export_plugin() 34 | self.assertEqual( 35 | { 36 | "all", 37 | "recommended", 38 | }, 39 | set(plugin.configs.keys()), 40 | ) 41 | all_rule_names = set(f"xcube/{k}" for k in plugin.rules.keys()) 42 | self.assertEqual( 43 | all_rule_names, 44 | set(plugin.configs["all"][-1].rules.keys()), 45 | ) 46 | self.assertEqual( 47 | all_rule_names, 48 | set(plugin.configs["recommended"][-1].rules.keys()), 49 | ) 50 | -------------------------------------------------------------------------------- /tests/plugins/xcube/test_util.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.plugins.xcube.util import is_absolute_path 8 | from xrlint.plugins.xcube.util import resolve_path 9 | 10 | 11 | class UtilTest(TestCase): 12 | def test_is_absolute_path(self): 13 | self.assertTrue(is_absolute_path("/home/forman")) 14 | self.assertTrue(is_absolute_path("//bcserver2/fs1")) 15 | self.assertTrue(is_absolute_path("file://home/forman")) 16 | self.assertTrue(is_absolute_path("s3://xcube-data")) 17 | self.assertTrue(is_absolute_path(r"C:\Users\Norman")) 18 | self.assertTrue(is_absolute_path(r"C:/Users/Norman")) 19 | self.assertTrue(is_absolute_path(r"C:/Users/Norman")) 20 | self.assertTrue(is_absolute_path(r"\\bcserver2\fs1")) 21 | 22 | self.assertFalse(is_absolute_path(r"data")) 23 | self.assertFalse(is_absolute_path(r"./data")) 24 | self.assertFalse(is_absolute_path(r"../data")) 25 | 26 | def test_resolve_path(self): 27 | self.assertEqual( 28 | "/home/forman/data", resolve_path("data", root_path="/home/forman") 29 | ) 30 | self.assertEqual( 31 | "/home/forman/data", resolve_path("./data", root_path="/home/forman") 32 | ) 33 | self.assertEqual( 34 | "/home/data", resolve_path("../data", root_path="/home/forman") 35 | ) 36 | self.assertEqual("s3://opensr/test.zarr", resolve_path("s3://opensr/test.zarr")) 37 | -------------------------------------------------------------------------------- /tests/test_all.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | 8 | class AllTest(TestCase): 9 | def test_api_is_complete(self): 10 | import xrlint.all as xrl 11 | from xrlint.all import __all__ 12 | 13 | # noinspection PyUnresolvedReferences 14 | keys = set( 15 | k 16 | for k, v in xrl.__dict__.items() 17 | if isinstance(k, str) and not k.startswith("_") 18 | ) 19 | self.assertEqual(set(__all__), keys) 20 | -------------------------------------------------------------------------------- /tests/test_constants.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.constants import SEVERITY_CODE_TO_NAME, SEVERITY_ENUM, SEVERITY_ENUM_TEXT 8 | 9 | 10 | class ConstantsTest(TestCase): 11 | def test_computed_values(self): 12 | self.assertEqual({0: "off", 1: "warn", 2: "error"}, SEVERITY_CODE_TO_NAME) 13 | self.assertEqual( 14 | {"off": 0, "warn": 1, "error": 2, 0: 0, 1: 1, 2: 2}, 15 | SEVERITY_ENUM, 16 | ) 17 | self.assertEqual("'error', 'warn', 'off', 2, 1, 0", SEVERITY_ENUM_TEXT) 18 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import unittest 6 | from unittest import TestCase 7 | 8 | from xrlint.config import Config 9 | from xrlint.rule import RuleOp 10 | from xrlint.util.importutil import import_value 11 | 12 | 13 | class ExamplesTest(TestCase): 14 | def test_plugin_config(self): 15 | config, _ = import_value( 16 | "examples.plugin_config", "export_config", factory=Config.from_value 17 | ) 18 | self.assertIsInstance(config, Config) 19 | self.assertEqual(3, len(config.objects)) 20 | 21 | def test_virtual_plugin_config(self): 22 | config, _ = import_value( 23 | "examples.virtual_plugin_config", 24 | "export_config", 25 | factory=Config.from_value, 26 | ) 27 | self.assertIsInstance(config, Config) 28 | self.assertEqual(3, len(config.objects)) 29 | 30 | def test_rule_testing(self): 31 | from examples.rule_testing import GoodTitle, GoodTitleTest 32 | 33 | self.assertTrue(issubclass(GoodTitle, RuleOp)) 34 | self.assertTrue(issubclass(GoodTitleTest, unittest.TestCase)) 35 | -------------------------------------------------------------------------------- /tests/test_formatter.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.formatter import Formatter, FormatterOp, FormatterRegistry 8 | 9 | 10 | class FormatterRegistryTest(TestCase): 11 | def test_decorator_sets_meta(self): 12 | registry = FormatterRegistry() 13 | 14 | @registry.define_formatter() 15 | class MyFormat(FormatterOp): 16 | def format(self, *args, **kwargs) -> str: 17 | """Dummy""" 18 | 19 | my_rule = registry.get("my-format") 20 | self.assertIsInstance(my_rule, Formatter) 21 | self.assertEqual("my-format", my_rule.meta.name) 22 | self.assertEqual(None, my_rule.meta.version) 23 | self.assertEqual(None, my_rule.meta.schema) 24 | 25 | def test_decorator_registrations(self): 26 | registry = FormatterRegistry() 27 | 28 | @registry.define_formatter("my-fmt-a") 29 | class MyFormat1(FormatterOp): 30 | def format(self, *args, **kwargs) -> str: 31 | """Dummy""" 32 | 33 | @registry.define_formatter("my-fmt-b") 34 | class MyFormat2(FormatterOp): 35 | def format(self, *args, **kwargs) -> str: 36 | """Dummy""" 37 | 38 | @registry.define_formatter("my-fmt-c") 39 | class MyFormat3(FormatterOp): 40 | def format(self, *args, **kwargs) -> str: 41 | """Dummy""" 42 | 43 | fmt_names = list(registry.keys()) 44 | fmt1, fmt2, fmt3 = list(registry.values()) 45 | self.assertEqual(["my-fmt-a", "my-fmt-b", "my-fmt-c"], fmt_names) 46 | self.assertIsInstance(fmt1, Formatter) 47 | self.assertIsInstance(fmt2, Formatter) 48 | self.assertIsInstance(fmt3, Formatter) 49 | self.assertIsNot(fmt2, fmt1) 50 | self.assertIsNot(fmt3, fmt1) 51 | self.assertIsNot(fmt3, fmt2) 52 | -------------------------------------------------------------------------------- /tests/test_formatters.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.formatters import export_formatters 8 | 9 | 10 | class ImportFormattersTest(TestCase): 11 | def test_import_formatters(self): 12 | registry = export_formatters() 13 | self.assertEqual( 14 | { 15 | "html", 16 | "json", 17 | "simple", 18 | }, 19 | set(registry.keys()), 20 | ) 21 | -------------------------------------------------------------------------------- /tests/test_node.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.node import XarrayNode 8 | 9 | 10 | class XarrayNodeTest(TestCase): 11 | def node(self, path: str): 12 | return XarrayNode(path=path, parent=None) 13 | 14 | def attrs_node(self): 15 | return self.node("dataset.attrs") 16 | 17 | def coords_node(self): 18 | return self.node("dataset.coords['x']") 19 | 20 | def data_var_node(self): 21 | return self.node("dataset.data_vars['v']") 22 | 23 | def test_in_coords(self): 24 | self.assertEqual(False, self.attrs_node().in_coords()) 25 | self.assertEqual(True, self.coords_node().in_coords()) 26 | self.assertEqual(False, self.data_var_node().in_coords()) 27 | 28 | def test_in_data_vars(self): 29 | self.assertEqual(False, self.attrs_node().in_data_vars()) 30 | self.assertEqual(False, self.coords_node().in_data_vars()) 31 | self.assertEqual(True, self.data_var_node().in_data_vars()) 32 | 33 | def test_in_root(self): 34 | self.assertEqual(True, self.attrs_node().in_root()) 35 | self.assertEqual(False, self.coords_node().in_root()) 36 | self.assertEqual(False, self.data_var_node().in_root()) 37 | -------------------------------------------------------------------------------- /tests/util/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/util/test_formatting.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.util.formatting import ( 8 | format_case, 9 | format_count, 10 | format_problems, 11 | format_seq, 12 | format_styled, 13 | ) 14 | 15 | 16 | class FormattingTest(TestCase): 17 | def test_format_count(self): 18 | self.assertEqual("-3 eggs", format_count(-3, "egg")) 19 | self.assertEqual("-2 eggs", format_count(-2, "egg")) 20 | self.assertEqual("-1 eggs", format_count(-1, "egg")) 21 | self.assertEqual("no eggs", format_count(0, "egg")) 22 | self.assertEqual("one egg", format_count(1, "egg")) 23 | self.assertEqual("2 eggs", format_count(2, "egg")) 24 | self.assertEqual("3 eggs", format_count(3, "egg")) 25 | 26 | def test_format_problems(self): 27 | self.assertEqual("no problems", format_problems(0, 0)) 28 | self.assertEqual("one error", format_problems(1, 0)) 29 | self.assertEqual("2 errors", format_problems(2, 0)) 30 | self.assertEqual("one warning", format_problems(0, 1)) 31 | self.assertEqual( 32 | "2 problems (one error and one warning)", format_problems(1, 1) 33 | ) 34 | self.assertEqual("3 problems (2 errors and one warning)", format_problems(2, 1)) 35 | self.assertEqual("2 warnings", format_problems(0, 2)) 36 | self.assertEqual("3 problems (one error and 2 warnings)", format_problems(1, 2)) 37 | self.assertEqual("4 problems (2 errors and 2 warnings)", format_problems(2, 2)) 38 | 39 | def test_format_case(self): 40 | self.assertEqual("hello", format_case("hello")) 41 | self.assertEqual("Hello", format_case("Hello")) 42 | self.assertEqual("hello", format_case("hello", upper=False)) 43 | self.assertEqual("Hello", format_case("hello", upper=True)) 44 | self.assertEqual("hello", format_case("Hello", upper=False)) 45 | self.assertEqual("Hello", format_case("Hello", upper=True)) 46 | 47 | def test_format_seq(self): 48 | self.assertEqual("", format_seq([])) 49 | self.assertEqual("1, 2, 3", format_seq([1, 2, 3])) 50 | self.assertEqual( 51 | "1, 2, 3, ..., 8, 9, 10", format_seq([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) 52 | ) 53 | self.assertEqual( 54 | "1, 2, ..., 9, 10", format_seq([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], max_count=4) 55 | ) 56 | 57 | def test_format_styled(self): 58 | self.assertEqual("Hello", format_styled("Hello")) 59 | self.assertEqual("\x1b[2mHello\x1b[0m", format_styled("Hello", s="dim")) 60 | self.assertEqual("\x1b[;31mHello\x1b[0m", format_styled("Hello", fg="red")) 61 | self.assertEqual("\x1b[;;42mHello\x1b[0m", format_styled("Hello", bg="green")) 62 | self.assertEqual( 63 | "\x1b]8;;file://x.nc\x1b\\File\x1b]8;;\x1b\\", 64 | format_styled("File", href="x.nc"), 65 | ) 66 | self.assertEqual( 67 | "\x1b]8;;https://data.com/x.nc\x1b\\File\x1b]8;;\x1b\\", 68 | format_styled("File", href="https://data.com/x.nc"), 69 | ) 70 | self.assertEqual( 71 | "\x1b]8;;file://x.nc\x1b\\x.nc\x1b]8;;\x1b\\", 72 | format_styled("", href="x.nc"), 73 | ) 74 | self.assertEqual( 75 | "\x1b]8;;file://x.nc\x1b\\\x1b[4mFile\x1b[0m\x1b]8;;\x1b\\", 76 | format_styled("File", href="x.nc", s="underline"), 77 | ) 78 | 79 | self.assertEqual("", format_styled("")) 80 | self.assertEqual("", format_styled("", s="dim")) 81 | -------------------------------------------------------------------------------- /tests/util/test_importutil.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | import pytest 8 | 9 | from xrlint.plugin import Plugin 10 | from xrlint.util.importutil import ValueImportError, import_submodules, import_value 11 | 12 | 13 | def get_foo(): 14 | return 42 15 | 16 | 17 | class ImportSubmodulesTest(TestCase): 18 | def test_import_submodules(self): 19 | modules = import_submodules("tests.util.test_importutil_pkg", dry_run=True) 20 | self.assertEqual( 21 | { 22 | "tests.util.test_importutil_pkg.module1", 23 | "tests.util.test_importutil_pkg.module2", 24 | }, 25 | set(modules), 26 | ) 27 | 28 | modules = import_submodules("tests.util.test_importutil_pkg") 29 | self.assertEqual( 30 | { 31 | "tests.util.test_importutil_pkg.module1", 32 | "tests.util.test_importutil_pkg.module2", 33 | }, 34 | set(modules), 35 | ) 36 | 37 | 38 | class ImportValueTest(TestCase): 39 | def test_import_exported_value_ok(self): 40 | plugin, plugin_ref = import_value( 41 | "xrlint.plugins.core", "export_plugin", factory=Plugin.from_value 42 | ) 43 | self.assertIsInstance(plugin, Plugin) 44 | self.assertEqual("xrlint.plugins.core:export_plugin", plugin_ref) 45 | 46 | def test_import_exported_value_ok_no_factory(self): 47 | value, value_ref = import_value( 48 | "tests.util.test_importutil:get_foo", 49 | ) 50 | self.assertEqual(value, 42) 51 | self.assertEqual("tests.util.test_importutil:get_foo", value_ref) 52 | 53 | # noinspection PyMethodMayBeStatic 54 | def test_import_exported_value_fail(self): 55 | with pytest.raises( 56 | ValueImportError, 57 | match=( 58 | r"value of tests.util.test_importutil:get_foo\(\)" 59 | r" must be of type float, but got int" 60 | ), 61 | ): 62 | import_value("tests.util.test_importutil:get_foo", expected_type=float) 63 | 64 | # noinspection PyMethodMayBeStatic 65 | def test_import_exported_value_import_error(self): 66 | with pytest.raises( 67 | ValueImportError, 68 | match=( 69 | "failed to import value from 'tests.util.test_baz:get_foo':" 70 | " No module named 'tests.util.test_baz'" 71 | ), 72 | ): 73 | import_value("tests.util.test_baz:get_foo") 74 | -------------------------------------------------------------------------------- /tests/util/test_importutil_pkg/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/util/test_importutil_pkg/module1.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/util/test_importutil_pkg/module2/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /tests/util/test_merge.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.util.merge import merge_arrays, merge_dicts, merge_set_lists, merge_values 8 | 9 | 10 | class NamingTest(TestCase): 11 | def test_merge_arrays(self): 12 | self.assertEqual(None, merge_arrays(None, None)) 13 | self.assertEqual(["x"], merge_arrays(["x"], None)) 14 | self.assertEqual(["y"], merge_arrays(None, ["y"])) 15 | self.assertEqual(["y"], merge_arrays(["x"], ["y"])) 16 | self.assertEqual(["z", "y"], merge_arrays(["x", "y"], ["z"])) 17 | self.assertEqual(["y", "z"], merge_arrays(["x"], ["y", "z"])) 18 | 19 | def test_merge_dicts(self): 20 | self.assertEqual(None, merge_dicts(None, None)) 21 | self.assertEqual({"x": 1}, merge_dicts({"x": 1}, None)) 22 | self.assertEqual({"y": 2}, merge_dicts(None, {"y": 2})) 23 | self.assertEqual({"x": 1, "y": 2}, merge_dicts({"x": 1}, {"y": 2})) 24 | self.assertEqual({"x": 2}, merge_dicts({"x": 1}, {"x": 2})) 25 | self.assertEqual( 26 | {"x": 1, "y": 2, "z": 3}, merge_dicts({"x": 1, "y": 2}, {"z": 3}) 27 | ) 28 | 29 | def test_merge_set_lists(self): 30 | self.assertEqual(None, merge_set_lists(None, None)) 31 | self.assertEqual(["y"], merge_set_lists(None, ["y"])) 32 | self.assertEqual(["x"], merge_set_lists(["x"], None)) 33 | self.assertEqual(["x", "y"], merge_set_lists(["x"], ["y"])) 34 | self.assertEqual(["y", "x"], merge_set_lists(["y", "x"], ["x", "y"])) 35 | 36 | def test_merge_values(self): 37 | self.assertEqual(None, merge_values(None, None)) 38 | self.assertEqual(["z", "y"], merge_values(["x", "y"], ["z"])) 39 | self.assertEqual({"x": 1, "y": 2}, merge_values({"x": 1}, {"y": 2})) 40 | self.assertEqual(1, merge_values("x", 1)) 41 | self.assertEqual(2, merge_values(["x"], 2)) 42 | self.assertEqual(3, merge_values({"x": 1}, 3)) 43 | -------------------------------------------------------------------------------- /tests/util/test_naming.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | from xrlint.util.naming import to_kebab_case, to_snake_case 8 | 9 | 10 | class NamingTest(TestCase): 11 | def test_to_kebab_case(self): 12 | self.assertEqual("", to_kebab_case("")) 13 | self.assertEqual("rule", to_kebab_case("rule")) 14 | self.assertEqual("rule", to_kebab_case("Rule")) 15 | self.assertEqual("my-rule", to_kebab_case("MyRule")) 16 | self.assertEqual("my-rule-a", to_kebab_case("MyRuleA")) 17 | self.assertEqual("my-rule-3", to_kebab_case("MyRule3")) 18 | self.assertEqual("my-rule-3", to_kebab_case("MyRule_3")) 19 | self.assertEqual("my-rule-3", to_kebab_case("My_Rule_3")) 20 | self.assertEqual("my-rule-3", to_kebab_case("my-rule-3")) 21 | self.assertEqual("my-rule-3", to_kebab_case("My-Rule-3")) 22 | self.assertEqual("my-rule-3", to_kebab_case("My-Rule 3")) 23 | self.assertEqual("abc-rule-123", to_kebab_case("ABCRule123")) 24 | self.assertEqual("abc-rule-xyz", to_kebab_case("ABCRuleXYZ")) 25 | 26 | def test_to_snake_case(self): 27 | self.assertEqual("", to_snake_case("")) 28 | self.assertEqual("rule", to_snake_case("rule")) 29 | self.assertEqual("rule", to_snake_case("Rule")) 30 | self.assertEqual("my_rule", to_snake_case("MyRule")) 31 | self.assertEqual("my_rule_a", to_snake_case("MyRuleA")) 32 | self.assertEqual("my_rule_3", to_snake_case("MyRule3")) 33 | self.assertEqual("my_rule_3", to_snake_case("MyRule_3")) 34 | self.assertEqual("my_rule_3", to_snake_case("My_Rule_3")) 35 | self.assertEqual("my_rule_3", to_snake_case("my-rule-3")) 36 | self.assertEqual("my_rule_3", to_snake_case("My-Rule-3")) 37 | self.assertEqual("my_rule_3", to_snake_case("My-Rule 3")) 38 | self.assertEqual("abc_rule_123", to_snake_case("ABCRule123")) 39 | self.assertEqual("abc_rule_xyz", to_snake_case("ABCRuleXYZ")) 40 | -------------------------------------------------------------------------------- /tests/util/test_schema.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from unittest import TestCase 6 | 7 | import pytest 8 | 9 | from xrlint.util.schema import schema 10 | 11 | 12 | class SchemaTest(TestCase): 13 | def test_type_name(self): 14 | self.assertEqual({}, schema()) 15 | self.assertEqual({}, schema(type=None)) 16 | self.assertEqual({"type": "null"}, schema("null")) 17 | self.assertEqual({"type": "boolean"}, schema("boolean")) 18 | self.assertEqual({"type": "integer"}, schema("integer")) 19 | self.assertEqual({"type": "number"}, schema("number")) 20 | self.assertEqual({"type": "string"}, schema("string")) 21 | self.assertEqual({"type": "object"}, schema("object")) 22 | self.assertEqual({"type": "array"}, schema("array")) 23 | 24 | def test_type_name_list(self): 25 | self.assertEqual({}, schema()) 26 | self.assertEqual({}, schema([])) 27 | self.assertEqual({"type": ["array", "string"]}, schema(["array", "string"])) 28 | 29 | # noinspection PyTypeChecker,PyMethodMayBeStatic 30 | def test_type_name_invalid(self): 31 | with pytest.raises( 32 | TypeError, match="type must be of type str|list[str], but got str" 33 | ): 34 | schema(type=str) 35 | with pytest.raises( 36 | ValueError, 37 | match=( 38 | "type name must be one of " 39 | "'null', 'boolean', 'integer', 'number'," 40 | " 'string', 'array', 'object'," 41 | " but got 'float'" 42 | ), 43 | ): 44 | schema(type="float") 45 | 46 | # noinspection PyTypeChecker,PyMethodMayBeStatic 47 | def test_type_name_list_invalid(self): 48 | with pytest.raises( 49 | TypeError, match="type must be of type str|list[str], but got int" 50 | ): 51 | schema(type=["string", 2]) 52 | with pytest.raises( 53 | ValueError, 54 | match=( 55 | "type name must be one of" 56 | " 'null', 'boolean', 'integer', 'number'," 57 | " 'string', 'array', 'object'," 58 | " but got 'list'" 59 | ), 60 | ): 61 | schema(type=["string", "list"]) 62 | 63 | def test_properties(self): 64 | self.assertEqual({}, schema(properties=None)) 65 | self.assertEqual({"properties": {}}, schema(properties={})) 66 | self.assertEqual( 67 | {"properties": {"a": {"type": "string"}, "b": {}}}, 68 | schema(properties={"a": schema("string"), "b": schema()}), 69 | ) 70 | -------------------------------------------------------------------------------- /xrlint/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | # No other imports here! 6 | from .version import version 7 | 8 | __version__ = version 9 | -------------------------------------------------------------------------------- /xrlint/_linter/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | # Empty by intention. 6 | -------------------------------------------------------------------------------- /xrlint/_linter/rulectx.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import contextlib 6 | from typing import Any, Literal 7 | 8 | import xarray as xr 9 | 10 | from xrlint.config import ConfigObject 11 | from xrlint.constants import DATASET_ROOT_NAME, SEVERITY_ERROR 12 | from xrlint.node import Node 13 | from xrlint.result import Message, Suggestion 14 | from xrlint.rule import RuleContext 15 | 16 | 17 | class RuleContextImpl(RuleContext): 18 | def __init__( 19 | self, 20 | config: ConfigObject, 21 | dataset: xr.Dataset | xr.DataTree, 22 | file_path: str, 23 | file_index: int | None, 24 | access_latency: float | None, 25 | ): 26 | assert isinstance(config, ConfigObject) 27 | assert isinstance(dataset, (xr.Dataset | xr.DataTree)) 28 | assert isinstance(file_path, str) 29 | assert file_index is None or isinstance(file_index, int) 30 | assert access_latency is None or isinstance(access_latency, float) 31 | if isinstance(dataset, xr.DataTree): 32 | datatree = dataset 33 | dataset = None 34 | if datatree.is_leaf: 35 | dataset = datatree.dataset 36 | datatree = None 37 | else: 38 | datatree = None 39 | self._config = config 40 | self._datatree = datatree 41 | self._dataset = dataset 42 | self._file_path = file_path 43 | self._file_index = file_index 44 | self._access_latency = access_latency 45 | self.messages: list[Message] = [] 46 | self.rule_id: str | None = None 47 | self.severity: Literal[1, 2] = SEVERITY_ERROR 48 | self.node: Node | None = None 49 | 50 | @property 51 | def config(self) -> ConfigObject: 52 | return self._config 53 | 54 | @property 55 | def settings(self) -> dict[str, Any]: 56 | return self._config.settings or {} 57 | 58 | @property 59 | def datatree(self) -> xr.DataTree | None: 60 | return self._datatree 61 | 62 | @property 63 | def dataset(self) -> xr.Dataset | None: 64 | return self._dataset 65 | 66 | @dataset.setter 67 | def dataset(self, value: xr.Dataset) -> None: 68 | self._dataset = value 69 | 70 | @property 71 | def file_path(self) -> str: 72 | return self._file_path 73 | 74 | @property 75 | def file_index(self) -> int | None: 76 | return self._file_index 77 | 78 | @property 79 | def access_latency(self) -> float | None: 80 | return self._access_latency 81 | 82 | def report( 83 | self, 84 | message: str, 85 | *, 86 | fatal: bool | None = None, 87 | suggestions: list[Suggestion | str] | None = None, 88 | ): 89 | suggestions = ( 90 | [Suggestion.from_value(s) for s in suggestions] if suggestions else None 91 | ) 92 | m = Message( 93 | message=message, 94 | fatal=fatal, 95 | suggestions=suggestions, 96 | rule_id=self.rule_id, 97 | node_path=self.node.path if self.node is not None else DATASET_ROOT_NAME, 98 | severity=self.severity, 99 | ) 100 | self.messages.append(m) 101 | 102 | @contextlib.contextmanager 103 | def use_state(self, **new_state): 104 | old_state = {k: getattr(self, k) for k in new_state.keys()} 105 | try: 106 | for k, v in new_state.items(): 107 | setattr(self, k, v) 108 | yield 109 | finally: 110 | for k, v in old_state.items(): 111 | setattr(self, k, v) 112 | -------------------------------------------------------------------------------- /xrlint/all.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.cli.engine import XRLint 6 | from xrlint.config import Config, ConfigLike, ConfigObject, ConfigObjectLike 7 | from xrlint.formatter import ( 8 | Formatter, 9 | FormatterContext, 10 | FormatterMeta, 11 | FormatterOp, 12 | FormatterRegistry, 13 | ) 14 | from xrlint.linter import Linter, new_linter 15 | from xrlint.node import AttrNode, AttrsNode, DatasetNode, Node, VariableNode 16 | from xrlint.plugin import Plugin, PluginMeta, new_plugin 17 | from xrlint.processor import Processor, ProcessorMeta, ProcessorOp, define_processor 18 | from xrlint.result import ( 19 | EditInfo, 20 | Message, 21 | Result, 22 | Suggestion, 23 | get_rules_meta_for_results, 24 | ) 25 | from xrlint.rule import ( 26 | Rule, 27 | RuleConfig, 28 | RuleContext, 29 | RuleExit, 30 | RuleMeta, 31 | RuleOp, 32 | define_rule, 33 | ) 34 | from xrlint.testing import RuleTest, RuleTester 35 | from xrlint.version import version 36 | 37 | __all__ = [ 38 | "XRLint", 39 | "Config", 40 | "ConfigLike", 41 | "ConfigObject", 42 | "ConfigObjectLike", 43 | "Linter", 44 | "new_linter", 45 | "EditInfo", 46 | "Message", 47 | "Result", 48 | "Suggestion", 49 | "get_rules_meta_for_results", 50 | "Formatter", 51 | "FormatterContext", 52 | "FormatterMeta", 53 | "FormatterOp", 54 | "FormatterRegistry", 55 | "AttrNode", 56 | "AttrsNode", 57 | "VariableNode", 58 | "DatasetNode", 59 | "Node", 60 | "Plugin", 61 | "PluginMeta", 62 | "new_plugin", 63 | "Processor", 64 | "ProcessorMeta", 65 | "ProcessorOp", 66 | "define_processor", 67 | "Rule", 68 | "RuleConfig", 69 | "RuleContext", 70 | "RuleExit", 71 | "RuleMeta", 72 | "RuleOp", 73 | "define_rule", 74 | "RuleTest", 75 | "RuleTester", 76 | "version", 77 | ] 78 | -------------------------------------------------------------------------------- /xrlint/cli/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | # Empty by intention. 6 | -------------------------------------------------------------------------------- /xrlint/cli/config.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import sys 6 | from os import PathLike 7 | from pathlib import Path 8 | from typing import Any 9 | 10 | import fsspec 11 | 12 | from xrlint.config import Config 13 | from xrlint.util.formatting import format_message_type_of 14 | from xrlint.util.importutil import ValueImportError, import_value 15 | 16 | 17 | def read_config(config_path: str | Path | PathLike[str]) -> Config: 18 | """Read configuration from configuration file. 19 | 20 | Args: 21 | config_path: configuration file path. 22 | 23 | Returns: 24 | A `Config` instance. 25 | 26 | Raises: 27 | TypeError: if `config_path` is not a path-like object 28 | FileNotFoundError: if configuration file could not be found 29 | ConfigError: if the configuration could not be read or is 30 | otherwise invalid. 31 | """ 32 | if not isinstance(config_path, (str, Path, PathLike)): 33 | raise TypeError( 34 | format_message_type_of("config_path", config_path, "str|Path|PathLike") 35 | ) 36 | 37 | try: 38 | config_like = _read_config_like(str(config_path)) 39 | except FileNotFoundError: 40 | raise 41 | except OSError as e: 42 | raise ConfigError(config_path, e) from e 43 | 44 | try: 45 | return Config.from_value(config_like) 46 | except (ValueError, TypeError) as e: 47 | raise ConfigError(config_path, e) from e 48 | 49 | 50 | def _read_config_like(config_path: str) -> Any: 51 | if config_path.endswith(".yml") or config_path.endswith(".yaml"): 52 | return _read_config_yaml(config_path) 53 | if config_path.endswith(".json"): 54 | return _read_config_json(config_path) 55 | if config_path.endswith(".py"): 56 | return _read_config_python(config_path) 57 | raise ConfigError(config_path, "unsupported configuration file format") 58 | 59 | 60 | def _read_config_yaml(config_path) -> Any: 61 | import yaml 62 | 63 | with fsspec.open(config_path, mode="r") as f: 64 | try: 65 | return yaml.load(f, Loader=yaml.SafeLoader) 66 | except yaml.YAMLError as e: 67 | raise ConfigError(config_path, e) from e 68 | 69 | 70 | def _read_config_json(config_path) -> Any: 71 | import json 72 | 73 | with fsspec.open(config_path, mode="r") as f: 74 | try: 75 | return json.load(f) 76 | except json.JSONDecodeError as e: 77 | raise ConfigError(config_path, e) from e 78 | 79 | 80 | def _read_config_python(config_path: str) -> Any: 81 | module_path = Path(config_path) 82 | 83 | if not module_path.exists(): 84 | raise FileNotFoundError(f"file not found: {config_path}") 85 | 86 | module_parent = module_path.parent 87 | module_name = module_path.stem 88 | 89 | old_sys_path = sys.path 90 | sys.path = [module_parent.as_posix()] + sys.path 91 | try: 92 | return import_value( 93 | module_name, 94 | "export_config", 95 | factory=Config.from_value, 96 | )[0] 97 | except ValueImportError as e: 98 | raise ConfigError(config_path, e) from e 99 | finally: 100 | sys.path = old_sys_path 101 | 102 | 103 | class ConfigError(Exception): 104 | """An error raised if loading of configuration fails.""" 105 | 106 | def __init__(self, config_path: str, e: Exception | str | None = None): 107 | super().__init__(config_path if e is None else f"{config_path}: {e}") 108 | -------------------------------------------------------------------------------- /xrlint/cli/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from typing import Final 6 | 7 | 8 | _MODULE_BASENAME: Final = "xrlint_config" 9 | _REGULAR_BASENAME: Final = "xrlint-config" 10 | 11 | 12 | DEFAULT_CONFIG_FILES: Final = [ 13 | # Added in 0.5.1: 14 | f"{_REGULAR_BASENAME}.yaml", 15 | f"{_REGULAR_BASENAME}.yml", 16 | f"{_REGULAR_BASENAME}.json", 17 | # Until 0.5.0: 18 | f"{_MODULE_BASENAME}.yaml", 19 | f"{_MODULE_BASENAME}.yml", 20 | f"{_MODULE_BASENAME}.json", 21 | f"{_MODULE_BASENAME}.py", 22 | ] 23 | 24 | DEFAULT_CONFIG_FILE_YAML: Final = f"{_REGULAR_BASENAME}.yaml" 25 | DEFAULT_OUTPUT_FORMAT: Final = "simple" 26 | DEFAULT_MAX_WARNINGS: Final = 5 27 | 28 | INIT_CONFIG_YAML: Final = ( 29 | "# XRLint configuration file\n" 30 | "# See https://bcdev.github.io/xrlint/config/\n" 31 | "\n" 32 | "- recommended\n" 33 | ) 34 | 35 | DEFAULT_GLOBAL_FILES: Final = ["**/*.zarr", "**/*.nc"] 36 | DEFAULT_GLOBAL_IGNORES: Final = [".git", "node_modules"] 37 | -------------------------------------------------------------------------------- /xrlint/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from typing import Final 6 | 7 | CORE_PLUGIN_NAME: Final = "__core__" 8 | CORE_DOCS_URL = "https://bcdev.github.io/xrlint/rule-ref" 9 | 10 | DATATREE_ROOT_NAME: Final = "dt" 11 | DATASET_ROOT_NAME: Final = "ds" 12 | MISSING_DATATREE_FILE_PATH: Final = "" 13 | MISSING_DATASET_FILE_PATH: Final = "" 14 | 15 | SEVERITY_ERROR: Final = 2 16 | SEVERITY_WARN: Final = 1 17 | SEVERITY_OFF: Final = 0 18 | 19 | SEVERITY_NAME_TO_CODE: Final = { 20 | "error": SEVERITY_ERROR, 21 | "warn": SEVERITY_WARN, 22 | "off": SEVERITY_OFF, 23 | } 24 | SEVERITY_CODE_TO_NAME: Final = {v: k for k, v in SEVERITY_NAME_TO_CODE.items()} 25 | SEVERITY_CODE_TO_CODE: Final = {v: v for v in SEVERITY_NAME_TO_CODE.values()} 26 | SEVERITY_CODE_TO_COLOR = {2: "red", 1: "blue", 0: "green", None: ""} 27 | 28 | SEVERITY_ENUM: Final[dict[int | str, int]] = ( 29 | SEVERITY_NAME_TO_CODE | SEVERITY_CODE_TO_CODE 30 | ) 31 | SEVERITY_ENUM_TEXT: Final = ", ".join(f"{k!r}" for k in SEVERITY_ENUM.keys()) 32 | -------------------------------------------------------------------------------- /xrlint/formatter.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from abc import ABC, abstractmethod 6 | from collections.abc import Iterable, Mapping 7 | from dataclasses import dataclass 8 | from typing import Any, Callable, Type 9 | 10 | from xrlint.operation import Operation, OperationMeta 11 | from xrlint.result import Result, ResultStats 12 | 13 | 14 | class FormatterContext(ABC): 15 | """A formatter context is passed to `FormatOp`.""" 16 | 17 | @property 18 | @abstractmethod 19 | def max_warnings_exceeded(self) -> bool: 20 | """`True` if the maximum number of warnings has been exceeded.""" 21 | 22 | @property 23 | @abstractmethod 24 | def result_stats(self) -> ResultStats: 25 | """Get current result statistics.""" 26 | 27 | 28 | class FormatterOp(ABC): 29 | """Define the specific format operation.""" 30 | 31 | @abstractmethod 32 | def format( 33 | self, 34 | context: FormatterContext, 35 | results: Iterable[Result], 36 | ) -> str: 37 | """Format the given results. 38 | 39 | Args: 40 | context: formatting context 41 | results: an iterable of results to format 42 | Returns: 43 | A text representing the results in a given format 44 | """ 45 | 46 | 47 | @dataclass(kw_only=True) 48 | class FormatterMeta(OperationMeta): 49 | """Formatter metadata.""" 50 | 51 | name: str 52 | """Formatter name.""" 53 | 54 | version: str = "0.0.0" 55 | """Formatter version.""" 56 | 57 | ref: str | None = None 58 | """Formatter reference. 59 | Specifies the location from where the formatter can be 60 | dynamically imported. 61 | Must have the form ":", if given. 62 | """ 63 | 64 | schema: dict[str, Any] | list[dict[str, Any]] | bool | None = None 65 | """Formatter options schema.""" 66 | 67 | 68 | @dataclass(frozen=True, kw_only=True) 69 | class Formatter(Operation): 70 | """A formatter for linting results.""" 71 | 72 | meta: FormatterMeta 73 | """The formatter metadata.""" 74 | 75 | op_class: Type[FormatterOp] 76 | """The class that implements the format operation.""" 77 | 78 | @classmethod 79 | def meta_class(cls) -> Type: 80 | return FormatterMeta 81 | 82 | @classmethod 83 | def op_base_class(cls) -> Type: 84 | return FormatterOp 85 | 86 | @classmethod 87 | def value_name(cls) -> str: 88 | return "formatter" 89 | 90 | 91 | class FormatterRegistry(Mapping[str, Formatter]): 92 | def __init__(self): 93 | self._registrations = {} 94 | 95 | def define_formatter( 96 | self, 97 | name: str | None = None, 98 | version: str | None = None, 99 | schema: dict[str, Any] | list[dict[str, Any]] | bool | None = None, 100 | ) -> Callable[[FormatterOp], Type[FormatterOp]] | Formatter: 101 | """Decorator function.""" 102 | return Formatter.define_operation( 103 | None, 104 | registry=self._registrations, 105 | meta_kwargs=dict(name=name, version=version, schema=schema), 106 | ) 107 | 108 | def __getitem__(self, key: str) -> Formatter: 109 | return self._registrations[key] 110 | 111 | def __len__(self) -> int: 112 | return len(self._registrations) 113 | 114 | def __iter__(self): 115 | return iter(self._registrations) 116 | -------------------------------------------------------------------------------- /xrlint/formatters/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.formatter import FormatterRegistry 6 | from xrlint.util.importutil import import_submodules 7 | 8 | registry = FormatterRegistry() 9 | 10 | 11 | def export_formatters() -> FormatterRegistry: 12 | import_submodules("xrlint.formatters") 13 | return registry 14 | -------------------------------------------------------------------------------- /xrlint/formatters/json.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import json 6 | from collections.abc import Iterable 7 | 8 | from xrlint.formatter import FormatterContext, FormatterOp 9 | from xrlint.formatters import registry 10 | from xrlint.result import Result, get_rules_meta_for_results 11 | from xrlint.util.schema import schema 12 | 13 | 14 | @registry.define_formatter( 15 | "json", 16 | version="1.0.0", 17 | schema=schema( 18 | "object", 19 | properties=dict( 20 | indent=schema("integer", minimum=0, maximum=8, default=2), 21 | with_meta=schema("boolean", default=False), 22 | ), 23 | ), 24 | ) 25 | class Json(FormatterOp): 26 | def __init__(self, indent: int = 2, with_meta: bool = False): 27 | super().__init__() 28 | self.indent = indent 29 | self.with_meta = with_meta 30 | 31 | def format( 32 | self, 33 | context: FormatterContext, 34 | results: Iterable[Result], 35 | ) -> str: 36 | results = list(results) # get them all 37 | 38 | omitted_props = {"config"} 39 | results_json = { 40 | "results": [ 41 | {k: v for k, v in r.to_json().items() if k not in omitted_props} 42 | for r in results 43 | ], 44 | } 45 | if self.with_meta: 46 | rules_meta = get_rules_meta_for_results(results) 47 | results_json.update( 48 | { 49 | "rules_meta": [rm.to_json() for rm in rules_meta.values()], 50 | } 51 | ) 52 | return json.dumps(results_json, indent=self.indent) 53 | -------------------------------------------------------------------------------- /xrlint/node.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from abc import ABC 6 | from dataclasses import dataclass 7 | from typing import Any, Hashable, Union 8 | 9 | import xarray as xr 10 | 11 | 12 | @dataclass(frozen=True, kw_only=True) 13 | class Node(ABC): 14 | """Abstract base class for nodes passed to the methods of a 15 | rule operation [xrlint.rule.RuleOp][].""" 16 | 17 | path: str 18 | """Node path. So users find where in the tree the issue occurred.""" 19 | 20 | parent: Union["Node", None] 21 | """Node parent. `None` for root nodes.""" 22 | 23 | 24 | @dataclass(frozen=True, kw_only=True) 25 | class XarrayNode(Node): 26 | """Base class for `xr.Dataset` nodes.""" 27 | 28 | def in_coords(self) -> bool: 29 | """Return `True` if this node is in `xr.Dataset.coords`.""" 30 | return ".coords[" in self.path 31 | 32 | def in_data_vars(self) -> bool: 33 | """Return `True` if this node is a `xr.Dataset.data_vars`.""" 34 | return ".data_vars[" in self.path 35 | 36 | def in_root(self) -> bool: 37 | """Return `True` if this node is a direct child of the dataset.""" 38 | return not self.in_coords() and not self.in_data_vars() 39 | 40 | 41 | @dataclass(frozen=True, kw_only=True) 42 | class DataTreeNode(XarrayNode): 43 | """DataTree node.""" 44 | 45 | name: Hashable 46 | """The name of the datatree.""" 47 | 48 | datatree: xr.DataTree 49 | """The `xarray.DataTree` instance.""" 50 | 51 | 52 | @dataclass(frozen=True, kw_only=True) 53 | class DatasetNode(XarrayNode): 54 | """Dataset node.""" 55 | 56 | name: Hashable 57 | """The name of the dataset.""" 58 | 59 | dataset: xr.Dataset 60 | """The `xarray.Dataset` instance.""" 61 | 62 | 63 | @dataclass(frozen=True, kw_only=True) 64 | class VariableNode(XarrayNode): 65 | """Variable node. 66 | Could be a coordinate or data variable. 67 | If you need to distinguish, you can use expression 68 | `node.name in ctx.dataset.coords`. 69 | """ 70 | 71 | name: Hashable 72 | """The name of the variable.""" 73 | 74 | array: xr.DataArray 75 | """The `xarray.DataArray` instance.""" 76 | 77 | 78 | @dataclass(frozen=True, kw_only=True) 79 | class AttrsNode(XarrayNode): 80 | """Attributes node.""" 81 | 82 | attrs: dict[str, Any] 83 | """Attributes dictionary.""" 84 | 85 | 86 | @dataclass(frozen=True, kw_only=True) 87 | class AttrNode(XarrayNode): 88 | """Attribute node.""" 89 | 90 | name: str 91 | """Attribute name.""" 92 | 93 | value: Any 94 | """Attribute value.""" 95 | -------------------------------------------------------------------------------- /xrlint/plugins/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | # Empty by intention. 6 | -------------------------------------------------------------------------------- /xrlint/plugins/core/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.plugin import Plugin 6 | from xrlint.util.importutil import import_submodules 7 | 8 | 9 | def export_plugin() -> Plugin: 10 | from .plugin import plugin 11 | 12 | import_submodules("xrlint.plugins.core.rules") 13 | 14 | plugin.define_config( 15 | "recommended", 16 | { 17 | "name": "recommended", 18 | "rules": { 19 | "access-latency": "warn", 20 | "content-desc": "warn", 21 | "conventions": "warn", 22 | "coords-for-dims": "error", 23 | "grid-mappings": "error", 24 | "lat-coordinate": "error", 25 | "lon-coordinate": "error", 26 | "no-empty-attrs": "warn", 27 | "no-empty-chunks": "off", 28 | "time-coordinate": "error", 29 | "var-desc": "warn", 30 | "var-flags": "error", 31 | "var-missing-data": "warn", 32 | "var-units": "warn", 33 | }, 34 | }, 35 | ) 36 | 37 | plugin.define_config( 38 | "all", 39 | { 40 | "name": "all", 41 | "rules": {rule_id: "error" for rule_id in plugin.rules.keys()}, 42 | }, 43 | ) 44 | 45 | return plugin 46 | -------------------------------------------------------------------------------- /xrlint/plugins/core/plugin.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.constants import CORE_DOCS_URL, CORE_PLUGIN_NAME 6 | from xrlint.plugin import new_plugin 7 | from xrlint.version import version 8 | 9 | plugin = new_plugin( 10 | name=CORE_PLUGIN_NAME, 11 | version=version, 12 | ref="xrlint.plugins.core:export_plugin", 13 | docs_url=CORE_DOCS_URL, 14 | ) 15 | -------------------------------------------------------------------------------- /xrlint/plugins/core/rules/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /xrlint/plugins/core/rules/access_latency.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from typing import Final 6 | 7 | from xrlint.node import DatasetNode 8 | from xrlint.plugins.core.plugin import plugin 9 | from xrlint.rule import RuleContext, RuleExit, RuleOp 10 | from xrlint.util.formatting import format_count 11 | from xrlint.util.schema import schema 12 | 13 | DEFAULT_THRESHOLD: Final = 2.5 # seconds 14 | 15 | 16 | @plugin.define_rule( 17 | "access-latency", 18 | version="1.0.0", 19 | description=( 20 | "Ensure that the time it takes to open a dataset from its source" 21 | " does a exceed a given `threshold` in seconds." 22 | f" The default threshold is `{DEFAULT_THRESHOLD}`." 23 | ), 24 | schema=schema( 25 | "object", 26 | properties={ 27 | "threshold": schema( 28 | "number", 29 | exclusiveMinimum=0, 30 | default=DEFAULT_THRESHOLD, 31 | title="Threshold time in seconds", 32 | ) 33 | }, 34 | ), 35 | ) 36 | class AccessLatency(RuleOp): 37 | def __init__(self, threshold: float = DEFAULT_THRESHOLD): 38 | self.threshold = threshold 39 | 40 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode) -> None: 41 | if ctx.access_latency is not None and ctx.access_latency > self.threshold: 42 | ctx.report( 43 | f"Access latency exceeds threshold: {ctx.access_latency:.1f}" 44 | f" > {format_count(self.threshold, 'second')}." 45 | ) 46 | raise RuleExit 47 | -------------------------------------------------------------------------------- /xrlint/plugins/core/rules/conventions.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import re 6 | 7 | from xrlint.node import DatasetNode 8 | from xrlint.plugins.core.plugin import plugin 9 | from xrlint.rule import RuleContext, RuleExit, RuleOp 10 | from xrlint.util.schema import schema 11 | 12 | 13 | @plugin.define_rule( 14 | "conventions", 15 | version="1.0.0", 16 | type="suggestion", 17 | description=( 18 | "Datasets should identify the applicable conventions" 19 | " using the `Conventions` attribute.\n" 20 | " The rule has an optional configuration parameter `match` which" 21 | " is a regex pattern that the value of the `Conventions` attribute" 22 | " must match, if any. If not provided, the rule just verifies" 23 | " that the attribute exists and whether it is a character string." 24 | ), 25 | docs_url=( 26 | "https://cfconventions.org/cf-conventions/cf-conventions.html" 27 | "#identification-of-conventions" 28 | ), 29 | schema=schema( 30 | "object", 31 | properties={ 32 | "match": schema("string", title="Regex pattern"), 33 | }, 34 | ), 35 | ) 36 | class Conventions(RuleOp): 37 | def __init__(self, match: str | None = None): 38 | self.match = re.compile(match) if match else None 39 | 40 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 41 | if "Conventions" not in node.dataset.attrs: 42 | ctx.report("Missing attribute 'Conventions'.") 43 | else: 44 | value = node.dataset.attrs.get("Conventions") 45 | if not isinstance(value, str) and value: 46 | ctx.report(f"Invalid attribute 'Conventions': {value!r}.") 47 | elif self.match is not None and not self.match.match(value): 48 | ctx.report( 49 | f"Invalid attribute 'Conventions':" 50 | f" {value!r} doesn't match {self.match.pattern!r}." 51 | ) 52 | raise RuleExit 53 | -------------------------------------------------------------------------------- /xrlint/plugins/core/rules/coords_for_dims.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import DatasetNode 6 | from xrlint.plugins.core.plugin import plugin 7 | from xrlint.rule import RuleContext, RuleOp 8 | from xrlint.util.formatting import format_item 9 | 10 | 11 | @plugin.define_rule( 12 | "coords-for-dims", 13 | version="1.0.0", 14 | type="problem", 15 | description="Dimensions of data variables should have corresponding coordinates.", 16 | ) 17 | class CoordsForDims(RuleOp): 18 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 19 | dataset = node.dataset 20 | 21 | # Get data variable dimensions 22 | data_var_dims = set() 23 | for v in dataset.data_vars.values(): 24 | data_var_dims.update(v.dims) 25 | if not data_var_dims: 26 | return 27 | 28 | # Get dimensions with coordinate variables 29 | no_coord_dims = [] 30 | for d in sorted(str(d) for d in data_var_dims): 31 | if d not in dataset.coords: 32 | no_coord_dims.append(d) 33 | 34 | if no_coord_dims: 35 | n = len(no_coord_dims) 36 | ctx.report( 37 | f"{format_item(n, 'Data variable dimension')} without" 38 | f" coordinates: {', '.join(no_coord_dims)}.", 39 | suggestions=[ 40 | f"Add corresponding {format_item(n, 'coordinate variable')}" 41 | f" to dataset:" 42 | f" {', '.join(f'{d}[{dataset.sizes[d]}]' for d in no_coord_dims)}." 43 | ], 44 | ) 45 | -------------------------------------------------------------------------------- /xrlint/plugins/core/rules/grid_mappings.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import DatasetNode 6 | from xrlint.plugins.core.plugin import plugin 7 | from xrlint.rule import RuleContext, RuleOp 8 | 9 | 10 | @plugin.define_rule( 11 | "grid-mappings", 12 | version="1.0.0", 13 | type="problem", 14 | description=( 15 | "Grid mappings, if any, shall have valid grid mapping coordinate variables." 16 | ), 17 | docs_url=( 18 | "https://cfconventions.org/cf-conventions/cf-conventions.html" 19 | "#grid-mappings-and-projections" 20 | ), 21 | ) 22 | class GridMappings(RuleOp): 23 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 24 | dataset = node.dataset 25 | 26 | # Get the mapping of grid mapping names to grid-mapped variables 27 | grid_mapped_vars = { 28 | str(v.attrs.get("grid_mapping")): str(k) 29 | for k, v in dataset.data_vars.items() 30 | if "grid_mapping" in v.attrs 31 | } 32 | 33 | if len(grid_mapped_vars) == 0: 34 | return 35 | 36 | # Check validity of grid mappings 37 | for gm_name, var_name in grid_mapped_vars.items(): 38 | gm_var = dataset.variables.get(gm_name) 39 | if gm_var is None: 40 | ctx.report( 41 | f"Missing grid mapping variable {gm_name!r}" 42 | f" referred to by variable {var_name!r}." 43 | ) 44 | else: 45 | if gm_name not in dataset.coords: 46 | ctx.report( 47 | f"Grid mapping variable {gm_name!r} should" 48 | f" be a coordinate variable, not data variable." 49 | ) 50 | if gm_var.dims: 51 | dims_text = ",".join(str(d) for d in gm_var.dims) 52 | ctx.report( 53 | f"Grid mapping variable {gm_name!r} should be a scalar," 54 | f" but has dimension(s) {dims_text}." 55 | ) 56 | # Note: we could check if creating a CRS from 57 | # gm_var.attrs is possible using pyproj.CRS.from_cf(). 58 | # Report otherwise. 59 | grid_mapping_name = gm_var.attrs.get("grid_mapping_name") 60 | if not grid_mapping_name: 61 | ctx.report( 62 | f"Grid mapping variable {gm_name!r} is missing" 63 | f" a valid attribute 'grid_mapping_name'." 64 | ) 65 | -------------------------------------------------------------------------------- /xrlint/plugins/core/rules/no_empty_attrs.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import AttrsNode 6 | from xrlint.plugins.core.plugin import plugin 7 | from xrlint.result import Suggestion 8 | from xrlint.rule import RuleContext, RuleOp 9 | 10 | 11 | @plugin.define_rule( 12 | "no-empty-attrs", 13 | version="1.0.0", 14 | type="suggestion", 15 | description="Every dataset element should have metadata that describes it.", 16 | ) 17 | class NoEmptyAttrs(RuleOp): 18 | def validate_attrs(self, ctx: RuleContext, node: AttrsNode): 19 | if not node.attrs: 20 | ctx.report( 21 | "Missing metadata, attributes are empty.", 22 | suggestions=[ 23 | Suggestion( 24 | desc=( 25 | "Make sure to add appropriate metadata" 26 | " attributes to dataset elements." 27 | ) 28 | ) 29 | ], 30 | ) 31 | -------------------------------------------------------------------------------- /xrlint/plugins/core/rules/no_empty_chunks.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import DatasetNode 6 | from xrlint.plugins.core.plugin import plugin 7 | from xrlint.rule import RuleContext, RuleExit, RuleOp 8 | 9 | 10 | @plugin.define_rule( 11 | "no-empty-chunks", 12 | version="1.0.0", 13 | type="suggestion", 14 | description=( 15 | "Empty chunks should not be encoded and written." 16 | " The rule currently applies to Zarr format only." 17 | ), 18 | docs_url=( 19 | "https://docs.xarray.dev/en/stable/generated/xarray.Dataset.to_zarr.html" 20 | "#xarray-dataset-to-zarr" 21 | ), 22 | ) 23 | class NoEmptyChunks(RuleOp): 24 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 25 | source = node.dataset.encoding.get("source") 26 | is_zarr = isinstance(source, str) and source.endswith(".zarr") 27 | if is_zarr: 28 | for var in node.dataset.data_vars.values(): 29 | is_chunked_in_storage = ( 30 | "_FillValue" in var.encoding 31 | and "chunks" in var.encoding 32 | and tuple(var.encoding.get("chunks")) != tuple(var.shape) 33 | ) 34 | if is_chunked_in_storage: 35 | ctx.report("Consider writing with `write_empty_chunks=False`.") 36 | break 37 | # no need to traverse further 38 | raise RuleExit 39 | -------------------------------------------------------------------------------- /xrlint/plugins/core/rules/var_desc.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import VariableNode 6 | from xrlint.plugins.core.plugin import plugin 7 | from xrlint.rule import RuleContext, RuleOp 8 | from xrlint.util.schema import schema 9 | 10 | DEFAULT_ATTRS = ["standard_name", "long_name"] 11 | 12 | 13 | @plugin.define_rule( 14 | "var-desc", 15 | version="1.0.0", 16 | type="suggestion", 17 | description=( 18 | "Check that each data variable provides an" 19 | " identification and description of the content." 20 | " The rule can be configured by parameter `attrs` which is a list" 21 | " of names of attributes that provides descriptive information." 22 | f" It defaults to `{DEFAULT_ATTRS}`." 23 | "" 24 | ), 25 | docs_url=( 26 | "https://cfconventions.org/cf-conventions/cf-conventions.html#standard-name" 27 | ), 28 | schema=schema( 29 | "object", 30 | properties={ 31 | "attrs": schema( 32 | "array", 33 | items=schema("string"), 34 | default=DEFAULT_ATTRS, 35 | title="Attribute names to check", 36 | ), 37 | }, 38 | ), 39 | ) 40 | class VarDesc(RuleOp): 41 | def __init__(self, attrs: list[str] | None = None): 42 | self._attrs = attrs if attrs is not None else DEFAULT_ATTRS 43 | 44 | def validate_variable(self, ctx: RuleContext, node: VariableNode): 45 | if node.name not in ctx.dataset.data_vars: 46 | # This rule applies to data variables only 47 | return 48 | 49 | var_attrs = node.array.attrs 50 | for attr_name in self._attrs: 51 | if attr_name not in var_attrs: 52 | ctx.report(f"Missing attribute {attr_name!r}.") 53 | -------------------------------------------------------------------------------- /xrlint/plugins/core/rules/var_missing_data.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import numpy as np 6 | 7 | from xrlint.node import VariableNode 8 | from xrlint.plugins.core.plugin import plugin 9 | from xrlint.rule import RuleContext, RuleOp 10 | 11 | 12 | @plugin.define_rule( 13 | "var-missing-data", 14 | version="1.0.0", 15 | type="suggestion", 16 | description=( 17 | "Checks the recommended use of missing data, i.e., coordinate variables" 18 | " should not define missing data, but packed data should." 19 | " Notifies about the use of valid ranges to indicate missing data, which" 20 | " is currently not supported by xarray." 21 | ), 22 | docs_url="https://cfconventions.org/cf-conventions/cf-conventions.html#units", 23 | ) 24 | class VarMissingData(RuleOp): 25 | def validate_variable(self, ctx: RuleContext, node: VariableNode): 26 | array = node.array 27 | encoding = array.encoding 28 | attrs = array.attrs 29 | 30 | fill_value_source = None 31 | if "_FillValue" in encoding: 32 | fill_value_source = "encoding" 33 | elif "_FillValue" in attrs: 34 | fill_value_source = "attribute" 35 | 36 | if fill_value_source is not None and node.name in ctx.dataset.coords: 37 | ctx.report( 38 | f"Unexpected {fill_value_source} '_FillValue'," 39 | f" coordinates must not have missing data." 40 | ) 41 | elif fill_value_source is None and node.name in ctx.dataset.data_vars: 42 | scaling_factor = encoding.get("scaling_factor", attrs.get("scaling_factor")) 43 | add_offset = encoding.get("add_offset", attrs.get("add_offset")) 44 | raw_dtype = encoding.get("dtype") 45 | if add_offset is not None or scaling_factor is not None: 46 | ctx.report("Missing attribute '_FillValue' since data packing is used.") 47 | elif isinstance(raw_dtype, np.dtype) and np.issubdtype( 48 | raw_dtype, np.floating 49 | ): 50 | ctx.report("Missing attribute '_FillValue', which should be NaN.") 51 | 52 | if any((name in attrs) for name in ("valid_min", "valid_max", "valid_range")): 53 | ctx.report("Valid ranges are not recognized by xarray (as of Feb 2025).") 54 | -------------------------------------------------------------------------------- /xrlint/plugins/core/rules/var_units.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import VariableNode 6 | from xrlint.plugins.core.plugin import plugin 7 | from xrlint.rule import RuleContext, RuleOp 8 | 9 | 10 | @plugin.define_rule( 11 | "var-units", 12 | version="1.0.0", 13 | type="suggestion", 14 | description="Every variable should provide a description of its units.", 15 | docs_url="https://cfconventions.org/cf-conventions/cf-conventions.html#units", 16 | ) 17 | class VarUnits(RuleOp): 18 | def validate_variable(self, ctx: RuleContext, node: VariableNode): 19 | array = node.array 20 | attrs = array.attrs 21 | 22 | if "grid_mapping_name" in attrs: 23 | # likely grid mapping variable --> rule "gid-mappings" 24 | return 25 | if "units" in array.encoding: 26 | # likely time coordinate --> rule "time-coordinate" 27 | return 28 | 29 | units = attrs.get("units") 30 | if "units" not in attrs: 31 | ctx.report("Missing attribute 'units'.") 32 | elif not isinstance(units, str): 33 | ctx.report(f"Invalid attribute 'units': {units!r}") 34 | elif not units: 35 | ctx.report("Empty attribute 'units'.") 36 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.plugin import Plugin 6 | from xrlint.plugins.xcube.constants import ML_FILE_PATTERN 7 | from xrlint.util.importutil import import_submodules 8 | 9 | 10 | def export_plugin() -> Plugin: 11 | from .plugin import plugin 12 | 13 | import_submodules("xrlint.plugins.xcube.rules") 14 | import_submodules("xrlint.plugins.xcube.processors") 15 | 16 | common_configs = [ 17 | { 18 | "plugins": { 19 | "xcube": plugin, 20 | }, 21 | }, 22 | { 23 | # Add *.levels to globally included list of file types 24 | "files": [ML_FILE_PATTERN], 25 | }, 26 | { 27 | # Specify a processor for *.levels files 28 | "files": [ML_FILE_PATTERN], 29 | "processor": "xcube/multi-level-dataset", 30 | }, 31 | ] 32 | 33 | plugin.define_config( 34 | "recommended", 35 | [ 36 | *common_configs, 37 | { 38 | "rules": { 39 | "xcube/any-spatial-data-var": "error", 40 | "xcube/cube-dims-order": "error", 41 | "xcube/data-var-colors": "warn", 42 | "xcube/dataset-title": "error", 43 | "xcube/grid-mapping-naming": "warn", 44 | "xcube/increasing-time": "error", 45 | "xcube/lat-lon-naming": "error", 46 | "xcube/ml-dataset-meta": "error", 47 | "xcube/ml-dataset-time": "warn", 48 | "xcube/ml-dataset-xy": "error", 49 | "xcube/no-chunked-coords": "warn", 50 | "xcube/single-grid-mapping": "error", 51 | "xcube/time-naming": "error", 52 | }, 53 | }, 54 | ], 55 | ) 56 | 57 | plugin.define_config( 58 | "all", 59 | [ 60 | *common_configs, 61 | { 62 | "rules": { 63 | f"xcube/{rule_id}": "error" for rule_id in plugin.rules.keys() 64 | }, 65 | }, 66 | ], 67 | ) 68 | 69 | return plugin 70 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from typing import Final 6 | 7 | LON_NAME: Final = "lon" 8 | LAT_NAME: Final = "lat" 9 | X_NAME: Final = "x" 10 | Y_NAME: Final = "y" 11 | TIME_NAME: Final = "time" 12 | 13 | GM_NAMES: Final = "spatial_ref", "crs" 14 | GM_NAMES_TEXT: Final = " or ".join(repr(gm_name) for gm_name in GM_NAMES) 15 | 16 | ML_FILE_PATTERN: Final = "**/*.levels" 17 | ML_META_FILENAME: Final = ".zlevels" 18 | ML_INFO_ATTR: Final = "_LEVEL_INFO" 19 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/plugin.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.plugin import new_plugin 6 | from xrlint.version import version 7 | 8 | plugin = new_plugin( 9 | name="xcube", 10 | version=version, 11 | ref="xrlint.plugins.xcube:export_plugin", 12 | docs_url="https://xcube.readthedocs.io/en/latest/cubespec.html", 13 | ) 14 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/processors/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/any_spatial_data_var.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import DatasetNode 6 | from xrlint.plugins.xcube.plugin import plugin 7 | from xrlint.plugins.xcube.util import is_spatial_var 8 | from xrlint.rule import RuleContext, RuleOp 9 | 10 | 11 | @plugin.define_rule( 12 | "any-spatial-data-var", 13 | version="1.0.0", 14 | type="problem", 15 | description="A datacube should have spatial data variables.", 16 | docs_url=( 17 | "https://xcube.readthedocs.io/en/latest/cubespec.html#data-model-and-format" 18 | ), 19 | ) 20 | class AnySpatialDataVar(RuleOp): 21 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 22 | if not any(map(is_spatial_var, node.dataset.data_vars.values())): 23 | ctx.report("No spatial data variables found.") 24 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/cube_dims_order.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import VariableNode 6 | from xrlint.plugins.xcube.constants import LAT_NAME, LON_NAME, TIME_NAME, X_NAME, Y_NAME 7 | from xrlint.plugins.xcube.plugin import plugin 8 | from xrlint.rule import RuleContext, RuleOp 9 | 10 | 11 | @plugin.define_rule( 12 | "cube-dims-order", 13 | version="1.0.0", 14 | type="problem", 15 | description=( 16 | f"Order of dimensions in spatio-temporal datacube variables" 17 | f" should be [{TIME_NAME}, ..., {Y_NAME}, {X_NAME}]." 18 | ), 19 | docs_url=( 20 | "https://xcube.readthedocs.io/en/latest/cubespec.html#data-model-and-format" 21 | ), 22 | ) 23 | class CubeDimsOrder(RuleOp): 24 | def validate_variable(self, ctx: RuleContext, node: VariableNode): 25 | if node.in_data_vars(): 26 | dims = list(node.array.dims) 27 | indexes = {d: i for i, d in enumerate(node.array.dims)} 28 | 29 | yx_names = None 30 | if X_NAME in indexes and Y_NAME in indexes: 31 | yx_names = [Y_NAME, X_NAME] 32 | elif LON_NAME in indexes and LAT_NAME in indexes: 33 | yx_names = [LAT_NAME, LON_NAME] 34 | else: 35 | # Note, we could get yx_names also from grid-mapping, 36 | # which would be more generic. 37 | pass 38 | if yx_names is None: 39 | # This rule only applies to spatial dimensions 40 | return 41 | 42 | t_name = None 43 | if TIME_NAME in indexes: 44 | t_name = TIME_NAME 45 | 46 | n = len(dims) 47 | t_index = indexes[t_name] if t_name else None 48 | y_index = indexes[yx_names[0]] 49 | x_index = indexes[yx_names[1]] 50 | 51 | yx_ok = y_index == n - 2 and x_index == n - 1 52 | t_ok = t_index is None or t_index == 0 53 | if not yx_ok or not t_ok: 54 | if t_index is None: 55 | expected_dims = [d for d in dims if d not in yx_names] + yx_names 56 | else: 57 | expected_dims = ( 58 | [t_name] 59 | + [d for d in dims if d != t_name and d not in yx_names] 60 | + yx_names 61 | ) 62 | # noinspection PyTypeChecker 63 | ctx.report( 64 | f"Order of dimensions should be" 65 | f" {','.join(expected_dims)}, but found {','.join(dims)}.", 66 | suggestions=["Use xarray.transpose(...) to reorder dimensions."], 67 | ) 68 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/data_var_colors.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import VariableNode 6 | from xrlint.plugins.xcube.plugin import plugin 7 | from xrlint.plugins.xcube.util import is_spatial_var 8 | from xrlint.rule import RuleContext, RuleOp 9 | 10 | 11 | @plugin.define_rule( 12 | "data-var-colors", 13 | version="1.0.0", 14 | type="suggestion", 15 | description=( 16 | "Spatial data variables should encode xcube color mappings in their metadata." 17 | ), 18 | docs_url=( 19 | "https://xcube.readthedocs.io/en/latest/cubespec.html#encoding-of-colors" 20 | ), 21 | ) 22 | class DataVarColors(RuleOp): 23 | def validate_variable(self, ctx: RuleContext, node: VariableNode): 24 | array = node.array 25 | if not node.in_data_vars() or not is_spatial_var(array): 26 | return 27 | attrs = array.attrs 28 | color_bar_name = attrs.get("color_bar_name") 29 | if not color_bar_name: 30 | ctx.report("Missing attribute 'color_bar_name'.") 31 | else: 32 | color_value_min = attrs.get("color_value_min") 33 | color_value_max = attrs.get("color_value_max") 34 | if color_value_min is None or color_value_max is None: 35 | ctx.report( 36 | "Missing both or one of 'color_value_min' and 'color_value_max'." 37 | ) 38 | 39 | color_norm = attrs.get("color_norm") 40 | if color_norm and color_norm not in ("lin", "log"): 41 | ctx.report( 42 | "Invalid value of attribute 'color_norm', should be 'lin' or 'log'." 43 | ) 44 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/dataset_title.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import DatasetNode 6 | from xrlint.plugins.xcube.plugin import plugin 7 | from xrlint.rule import RuleContext, RuleOp 8 | 9 | 10 | @plugin.define_rule( 11 | "dataset-title", 12 | version="1.0.0", 13 | type="problem", 14 | description="Datasets should be given a non-empty title.", 15 | docs_url="https://xcube.readthedocs.io/en/latest/cubespec.html#metadata", 16 | ) 17 | class DatasetTitle(RuleOp): 18 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 19 | attrs = node.dataset.attrs 20 | if "title" not in attrs: 21 | ctx.report("Missing attribute 'title'.") 22 | elif not attrs["title"]: 23 | ctx.report(f"Invalid attribute 'title': {attrs['title']!r}") 24 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/grid_mapping_naming.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import DatasetNode 6 | from xrlint.plugins.xcube.constants import GM_NAMES, GM_NAMES_TEXT 7 | from xrlint.plugins.xcube.plugin import plugin 8 | from xrlint.rule import RuleContext, RuleOp 9 | 10 | 11 | @plugin.define_rule( 12 | "grid-mapping-naming", 13 | version="1.0.0", 14 | type="suggestion", 15 | description=( 16 | f"Grid mapping variables should be called {GM_NAMES_TEXT}" 17 | f" for compatibility with rioxarray and other packages." 18 | ), 19 | docs_url="https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference", 20 | ) 21 | class GridMappingNaming(RuleOp): 22 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 23 | for var_name, var in node.dataset.variables.items(): 24 | if "grid_mapping_name" in var.attrs and var_name not in GM_NAMES: 25 | ctx.report( 26 | f"Grid mapping variables should be named" 27 | f" {GM_NAMES_TEXT}," 28 | f" but name is {var_name!r}." 29 | ) 30 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/increasing_time.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import numpy as np 6 | 7 | from xrlint.node import VariableNode 8 | from xrlint.plugins.xcube.plugin import plugin 9 | from xrlint.rule import RuleContext, RuleExit, RuleOp 10 | from xrlint.util.formatting import format_count, format_seq 11 | 12 | 13 | @plugin.define_rule( 14 | "increasing-time", 15 | version="1.0.0", 16 | type="problem", 17 | description="Time coordinate labels should be monotonically increasing.", 18 | docs_url=( 19 | "https://xcube.readthedocs.io/en/latest/cubespec.html#temporal-reference" 20 | ), 21 | ) 22 | class IncreasingTime(RuleOp): 23 | def validate_variable(self, ctx: RuleContext, node: VariableNode): 24 | array = node.array 25 | if node.in_coords() and node.name == "time" and array.dims == ("time",): 26 | diff_array: np.ndarray = array.diff("time").values 27 | if not np.count_nonzero(diff_array > 0) == diff_array.size: 28 | check_indexes(ctx, diff_array == 0, "Duplicate") 29 | check_indexes(ctx, diff_array < 0, "Backsliding") 30 | raise RuleExit # No need to apply rule any further 31 | 32 | 33 | def check_indexes(ctx, cond: np.ndarray, issue_name: str): 34 | (indexes,) = np.nonzero(cond) 35 | if indexes.size > 0: 36 | index_text = format_count(indexes.size, singular="index", plural="indexes") 37 | ctx.report( 38 | f"{issue_name} 'time' coordinate label at {index_text}" 39 | f" {format_seq(indexes)}." 40 | ) 41 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/lat_lon_naming.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import DatasetNode 6 | from xrlint.plugins.xcube.constants import LAT_NAME, LON_NAME 7 | from xrlint.plugins.xcube.plugin import plugin 8 | from xrlint.rule import RuleContext, RuleOp 9 | 10 | INVALID_LAT_NAMES = {"ltd", "latitude"} 11 | INVALID_LON_NAMES = {"lng", "long", "longitude"} 12 | 13 | 14 | @plugin.define_rule( 15 | "lat-lon-naming", 16 | version="1.0.0", 17 | type="problem", 18 | description=( 19 | f"Latitude and longitude coordinates and dimensions" 20 | f" should be called {LAT_NAME!r} and {LON_NAME!r}." 21 | ), 22 | docs_url="https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference", 23 | ) 24 | class LatLonNaming(RuleOp): 25 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 26 | lon_ok = _check( 27 | ctx, "variable", node.dataset.variables.keys(), INVALID_LON_NAMES, LON_NAME 28 | ) 29 | lat_ok = _check( 30 | ctx, "variable", node.dataset.variables.keys(), INVALID_LAT_NAMES, LAT_NAME 31 | ) 32 | if lon_ok and lat_ok: 33 | # If variables have been reported, 34 | # we should not need to report (their) coordinates 35 | _check( 36 | ctx, 37 | "dimension", 38 | node.dataset.sizes.keys(), 39 | INVALID_LON_NAMES, 40 | LON_NAME, 41 | ) 42 | _check( 43 | ctx, 44 | "dimension", 45 | node.dataset.sizes.keys(), 46 | INVALID_LAT_NAMES, 47 | LAT_NAME, 48 | ) 49 | 50 | 51 | def _check(ctx, names_name, names, invalid_names, valid_name): 52 | names = [str(n) for n in names] # xarray keys are Hashable, not str 53 | found_names = [ 54 | n 55 | for n in names 56 | if (n.lower() in invalid_names) or (n.lower() == valid_name and n != valid_name) 57 | ] 58 | if found_names: 59 | ctx.report( 60 | f"The {names_name} {found_names[0]!r} should be named {valid_name!r}.", 61 | suggestions=[f"Rename {names_name} to {valid_name!r}."], 62 | ) 63 | return False 64 | else: 65 | return True 66 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/ml_dataset_meta.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import DatasetNode 6 | from xrlint.plugins.xcube.constants import ML_META_FILENAME 7 | from xrlint.plugins.xcube.plugin import plugin 8 | from xrlint.plugins.xcube.util import get_dataset_level_info, is_spatial_var 9 | from xrlint.rule import RuleContext, RuleOp 10 | from xrlint.util.formatting import format_item 11 | 12 | 13 | @plugin.define_rule( 14 | "ml-dataset-meta", 15 | version="1.0.0", 16 | type="suggestion", 17 | description=( 18 | f"Multi-level datasets should provide a {ML_META_FILENAME!r}" 19 | f" meta-info file, and if so, it should be consistent." 20 | f" Without the meta-info file the multi-level dataset cannot be" 21 | f" reliably extended by new time slices as the aggregation method" 22 | f" used for each variable must be specified." 23 | ), 24 | docs_url=( 25 | "https://xcube.readthedocs.io/en/latest/mldatasets.html#the-xcube-levels-format" 26 | ), 27 | ) 28 | class MLDatasetMeta(RuleOp): 29 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 30 | level_info = get_dataset_level_info(node.dataset) 31 | if level_info is None: 32 | # ok, this rules applies only to level datasets opened 33 | # by the xcube multi-level processor 34 | return 35 | 36 | level = level_info.level 37 | if level > 0: 38 | # ok, this rule does only apply to level 0 39 | return 40 | 41 | meta = level_info.meta 42 | if meta is None: 43 | ctx.report( 44 | f"Missing {ML_META_FILENAME!r} meta-info file.", 45 | suggestions=[ 46 | f"Add {ML_META_FILENAME!r} meta-info file." 47 | f" Without the meta-info the dataset cannot be reliably extended" 48 | f" as the aggregation method used for each variable must be" 49 | f" specified." 50 | ], 51 | ) 52 | return 53 | 54 | if not meta.version.startswith("1."): 55 | ctx.report(f"Unsupported {ML_META_FILENAME!r} meta-info version.") 56 | 57 | if meta.num_levels <= 0: 58 | ctx.report( 59 | f"Invalid 'num_levels' in {ML_META_FILENAME!r} meta-info:" 60 | f" {meta.num_levels}." 61 | ) 62 | elif meta.num_levels != level_info.num_levels: 63 | ctx.report( 64 | f"Expected {format_item(meta.num_levels, 'level')}," 65 | f" but found {level_info.num_levels}." 66 | ) 67 | 68 | if meta.use_saved_levels is None: 69 | ctx.report( 70 | f"Missing value for 'use_saved_levels'" 71 | f" in {ML_META_FILENAME!r} meta-info." 72 | ) 73 | 74 | if not meta.agg_methods: 75 | ctx.report( 76 | f"Missing value for 'agg_methods' in {ML_META_FILENAME!r} meta-info." 77 | ) 78 | else: 79 | for var_name, var in node.dataset.data_vars.items(): 80 | if is_spatial_var(var) and not meta.agg_methods.get(var_name): 81 | ctx.report( 82 | f"Missing value for variable {var_name!r}" 83 | f" in 'agg_methods' of {ML_META_FILENAME!r} meta-info." 84 | ) 85 | for var_name in meta.agg_methods.keys(): 86 | if var_name not in node.dataset: 87 | ctx.report( 88 | f"Variable {var_name!r} not found in dataset, but specified" 89 | f" in 'agg_methods' of {ML_META_FILENAME!r} meta-info." 90 | ) 91 | 92 | # Later: check meta.tile_size as well... 93 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/ml_dataset_time.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import DatasetNode 6 | from xrlint.plugins.xcube.constants import TIME_NAME 7 | from xrlint.plugins.xcube.plugin import plugin 8 | from xrlint.plugins.xcube.util import get_dataset_level_info, is_spatial_var 9 | from xrlint.rule import RuleContext, RuleOp 10 | from xrlint.util.formatting import format_seq 11 | 12 | 13 | @plugin.define_rule( 14 | "ml-dataset-time", 15 | version="1.0.0", 16 | type="problem", 17 | description=( 18 | "The `time` dimension of multi-level datasets should use a chunk size of 1." 19 | " This allows for faster image tile generation for visualisation." 20 | ), 21 | docs_url="https://xcube.readthedocs.io/en/latest/mldatasets.html#definition", 22 | ) 23 | class MLDatasetTime(RuleOp): 24 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 25 | level_info = get_dataset_level_info(node.dataset) 26 | if level_info is None: 27 | # ok, this rules applies only to level datasets opened 28 | # by the xcube multi-level processor 29 | return 30 | 31 | if TIME_NAME not in node.dataset.sizes or node.dataset.sizes[TIME_NAME] <= 1: 32 | # ok, no time dimension used or no time extent 33 | return 34 | 35 | for var_name, var in node.dataset.data_vars.items(): 36 | if is_spatial_var(var) and TIME_NAME in var.dims and var.chunks is not None: 37 | time_index = var.dims.index(TIME_NAME) 38 | time_chunks = var.chunks[time_index] 39 | if not all(c == 1 for c in time_chunks): 40 | ctx.report( 41 | f"Variable {var_name!r} uses chunking for {TIME_NAME!r}" 42 | f" that differs from from one: {format_seq(time_chunks)}." 43 | ) 44 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/ml_dataset_xy.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import math 6 | 7 | from xrlint.node import DatasetNode 8 | from xrlint.plugins.xcube.plugin import plugin 9 | from xrlint.plugins.xcube.util import get_dataset_level_info, get_spatial_size 10 | from xrlint.rule import RuleContext, RuleOp 11 | 12 | 13 | @plugin.define_rule( 14 | "ml-dataset-xy", 15 | version="1.0.0", 16 | type="problem", 17 | description=( 18 | "Multi-level dataset levels should provide spatial resolutions" 19 | " decreasing by powers of two." 20 | ), 21 | docs_url="https://xcube.readthedocs.io/en/latest/mldatasets.html#definition", 22 | ) 23 | class MLDatasetXY(RuleOp): 24 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 25 | level_info = get_dataset_level_info(node.dataset) 26 | if level_info is None: 27 | # ok, this rules applies only to level datasets opened 28 | # by the xcube multi-level processor 29 | return 30 | 31 | level = level_info.level 32 | if level == 0: 33 | # ok, this rule does only apply to level > 0 34 | return 35 | 36 | datasets = level_info.datasets 37 | level_0_dataset, _ = datasets[0] 38 | l0_size = get_spatial_size(level_0_dataset) 39 | if l0_size is None: 40 | # ok, maybe no spatial data vars? 41 | return 42 | 43 | (x_name, level_0_width), (y_name, level_0_height) = l0_size 44 | level_width = node.dataset.sizes.get(x_name) 45 | level_height = node.dataset.sizes.get(y_name) 46 | expected_level_width = math.ceil(level_0_width >> level) 47 | expected_level_height = math.ceil(level_0_height >> level) 48 | 49 | if level_width != expected_level_width: 50 | ctx.report( 51 | f"Expected size of dimension {x_name!r} in level {level}" 52 | f" to be {expected_level_width}, but was {level_width}." 53 | ) 54 | 55 | if level_height != expected_level_height: 56 | ctx.report( 57 | f"Expected size of dimension {y_name!r} in level {level}" 58 | f" to be {expected_level_height}, but was {level_height}." 59 | ) 60 | 61 | # Here: check spatial coordinates... 62 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/no_chunked_coords.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | import math 6 | 7 | from xrlint.node import VariableNode 8 | from xrlint.plugins.xcube.plugin import plugin 9 | from xrlint.rule import RuleContext, RuleOp 10 | from xrlint.util.schema import schema 11 | 12 | DEFAULT_LIMIT = 5 13 | 14 | 15 | @plugin.define_rule( 16 | "no-chunked-coords", 17 | version="1.0.0", 18 | type="problem", 19 | description=( 20 | "Coordinate variables should not be chunked." 21 | " Can be used to identify performance issues, where chunked coordinates" 22 | " can cause slow opening if datasets due to the many chunk-fetching" 23 | " requests made to (remote) filesystems with low bandwidth." 24 | " You can use the `limit` parameter to specify an acceptable number " 25 | f" of chunks. Its default is {DEFAULT_LIMIT}." 26 | ), 27 | schema=schema( 28 | "object", 29 | properties=dict( 30 | limit=schema( 31 | "integer", 32 | minimum=0, 33 | default=DEFAULT_LIMIT, 34 | title="Acceptable number of chunks", 35 | ) 36 | ), 37 | ), 38 | ) 39 | class NoChunkedCoords(RuleOp): 40 | def __init__(self, limit: int = DEFAULT_LIMIT): 41 | self.limit = limit 42 | 43 | def validate_variable(self, ctx: RuleContext, node: VariableNode): 44 | if node.name not in ctx.dataset.coords or node.array.ndim != 1: 45 | return 46 | 47 | chunks = node.array.encoding.get("chunks") 48 | if isinstance(chunks, (list, tuple)) and len(chunks) == 1: 49 | num_chunks = math.ceil(node.array.size / chunks[0]) 50 | if num_chunks > self.limit: 51 | ctx.report( 52 | f"Number of chunks exceeds limit: {num_chunks} > {self.limit}.", 53 | suggestions=["Combine chunks into a one or more larger ones."], 54 | ) 55 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/single_grid_mapping.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from xrlint.node import DatasetNode 6 | from xrlint.plugins.xcube.constants import GM_NAMES_TEXT, LAT_NAME, LON_NAME 7 | from xrlint.plugins.xcube.plugin import plugin 8 | from xrlint.plugins.xcube.util import is_spatial_var 9 | from xrlint.rule import RuleContext, RuleOp 10 | 11 | 12 | @plugin.define_rule( 13 | "single-grid-mapping", 14 | version="1.0.0", 15 | type="problem", 16 | description=( 17 | "A single grid mapping shall be used for all" 18 | " spatial data variables of a datacube." 19 | ), 20 | docs_url="https://xcube.readthedocs.io/en/latest/cubespec.html#spatial-reference", 21 | ) 22 | class SingleGridMapping(RuleOp): 23 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 24 | dataset = node.dataset 25 | 26 | if not dataset.data_vars: 27 | # Rule applies to dataset with data variables only 28 | return 29 | 30 | # Get the mapping of grid mapping names to grid-mapped variables 31 | grid_mapped_vars = { 32 | str(v.attrs.get("grid_mapping")): str(k) 33 | for k, v in dataset.data_vars.items() 34 | if (is_spatial_var(v)) and "grid_mapping" in v.attrs 35 | } 36 | 37 | # datacubes with geographic CRS do not need an explicit grid mapping 38 | geo_crs = LON_NAME in dataset.coords and LAT_NAME in dataset.coords 39 | if geo_crs and not grid_mapped_vars: 40 | return 41 | 42 | # if there is not a single grid mapping then report it 43 | if len(grid_mapped_vars) > 1: 44 | gm_names = "".join( 45 | [ 46 | f"var {var_name!r} -> {gm_name!r}" 47 | for gm_name, var_name in grid_mapped_vars.items() 48 | ] 49 | ) 50 | ctx.report( 51 | f"Spatial variables refer to multiple grid mappings: {gm_names}.", 52 | suggestions=[ 53 | ( 54 | "Split datacube into multiple datacubes" 55 | " each with a single grid mapping." 56 | ), 57 | ], 58 | ) 59 | elif len(grid_mapped_vars) == 0: 60 | ctx.report( 61 | "None of the spatial variables provides a grid mapping.", 62 | suggestions=[ 63 | ( 64 | f"Add a grid mapping coordinate variable named" 65 | f" {GM_NAMES_TEXT} to the dataset." 66 | ), 67 | ( 68 | "Set attribute 'grid_mapping' of spatial data variables" 69 | " to the name of the grid mapping coordinate variable." 70 | ), 71 | ], 72 | ) 73 | 74 | # Note the validity of grid mappings should be covered by 75 | # core rule "grid-mappings". 76 | -------------------------------------------------------------------------------- /xrlint/plugins/xcube/rules/time_naming.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from collections.abc import Hashable 6 | from typing import Any 7 | 8 | import xarray as xr 9 | 10 | from xrlint.node import DatasetNode 11 | from xrlint.plugins.xcube.constants import TIME_NAME 12 | from xrlint.plugins.xcube.plugin import plugin 13 | from xrlint.rule import RuleContext, RuleOp 14 | 15 | 16 | @plugin.define_rule( 17 | "time-naming", 18 | version="1.0.0", 19 | type="problem", 20 | description=f"Time coordinate and dimension should be called {TIME_NAME!r}.", 21 | docs_url="https://xcube.readthedocs.io/en/latest/cubespec.html#temporal-reference", 22 | ) 23 | class TimeNaming(RuleOp): 24 | def validate_dataset(self, ctx: RuleContext, node: DatasetNode): 25 | time_vars = { 26 | var_name: var 27 | for var_name, var in node.dataset.coords.items() 28 | if var_name != TIME_NAME and _is_time_coord(var_name, var) 29 | } 30 | for var_name, var in time_vars.items(): 31 | ctx.report( 32 | f"The coordinate {var_name!r} should be named {TIME_NAME!r}.", 33 | suggestions=[f"Rename {var_name!r} to {TIME_NAME!r}."], 34 | ) 35 | 36 | time_var = node.dataset.coords.get(TIME_NAME) 37 | if time_var is not None: 38 | if not _is_time_coord(TIME_NAME, time_var): 39 | ctx.report(f"Missing time units for coordinate {TIME_NAME!r}.") 40 | if not _get_time_encoding_attr(time_var, "calendar"): 41 | ctx.report(f"Missing calendar for coordinate {TIME_NAME!r}.") 42 | 43 | 44 | def _is_time_coord(var_name: Hashable, var: xr.DataArray) -> bool: 45 | if var.dims == (var_name,): 46 | units = _get_time_encoding_attr(var, "units") 47 | return units and " since " in units 48 | return False 49 | 50 | 51 | def _get_time_encoding_attr(var: xr.DataArray, name: str) -> Any: 52 | # decode_cf=True / decode_cf=False 53 | return var.encoding.get(name, var.attrs.get(name)) 54 | -------------------------------------------------------------------------------- /xrlint/util/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | -------------------------------------------------------------------------------- /xrlint/util/filefilter.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from dataclasses import dataclass 6 | 7 | from xrlint.util.filepattern import FilePattern 8 | 9 | 10 | @dataclass(frozen=True) 11 | class FileFilter: 12 | """Encapsulates the file filtering mechanism using `files` and `ignores`. 13 | 14 | Args: 15 | files: File path patterns for files to be included. 16 | ignores: File path patterns for files to be excluded. 17 | """ 18 | 19 | files: tuple[FilePattern, ...] = () 20 | ignores: tuple[FilePattern, ...] = () 21 | 22 | @classmethod 23 | def from_patterns( 24 | cls, files: list[str] | None, ignores: list[str] | None 25 | ) -> "FileFilter": 26 | return FileFilter( 27 | cls.patterns_to_matchers(files), 28 | cls.patterns_to_matchers(ignores, flip_negate=True), 29 | ) 30 | 31 | @classmethod 32 | def patterns_to_matchers( 33 | cls, patterns: list[str] | None, flip_negate: bool = False 34 | ) -> tuple[FilePattern, ...]: 35 | matchers = (FilePattern(p, flip_negate=flip_negate) for p in patterns or ()) 36 | return tuple(m for m in matchers if not (m.empty or m.comment)) 37 | 38 | @property 39 | def empty(self) -> bool: 40 | return not (self.files or self.ignores) 41 | 42 | def merge(self, file_filter: "FileFilter") -> "FileFilter": 43 | return FileFilter( 44 | self.files + file_filter.files, # note, we should exclude duplicates 45 | self.ignores + file_filter.ignores, 46 | ) 47 | 48 | def accept(self, file_path) -> bool: 49 | if self.files: 50 | included = False 51 | for p in self.files: 52 | if p.match(file_path): 53 | included = True 54 | break 55 | if not included: 56 | return False 57 | 58 | excluded = False 59 | for p in self.ignores: 60 | if not p.negate: 61 | if excluded: 62 | # Already excluded, no need to check further 63 | return False 64 | excluded = p.match(file_path) 65 | else: 66 | if excluded and p.match(file_path): 67 | # Negate the old excluded status on match 68 | excluded = False 69 | 70 | return not excluded 71 | -------------------------------------------------------------------------------- /xrlint/util/merge.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from typing import Any, Callable 6 | 7 | 8 | def merge_values( 9 | value1: Any, 10 | value2: Any, 11 | merge_items: Callable[[Any, Any], Any] | None = None, 12 | ) -> Any: 13 | if isinstance(value1, (list, tuple)) and isinstance(value2, (list, tuple)): 14 | return merge_arrays(value1, value2, merge_items=merge_items) 15 | if isinstance(value1, dict) and isinstance(value2, dict): 16 | return merge_dicts(value1, value2, merge_items=merge_items) 17 | return value2 if value2 is not None else value1 18 | 19 | 20 | def merge_dicts( 21 | dct1: dict[str, Any] | None, 22 | dct2: dict[str, Any] | None, 23 | merge_items: Callable[[Any, Any], Any] | None = None, 24 | ) -> dict[str, Any] | None: 25 | if dct1 is None: 26 | return dct2 27 | if dct2 is None: 28 | return dct1 29 | result = {} 30 | for k1, v1 in dct1.items(): 31 | if k1 in dct2: 32 | v2 = dct2[k1] 33 | result[k1] = merge_items(v1, v2) if merge_items is not None else v2 34 | else: 35 | result[k1] = v1 36 | for k2, v2 in dct2.items(): 37 | if k2 not in dct1: 38 | result[k2] = v2 39 | return result 40 | 41 | 42 | def merge_arrays( 43 | arr1: list | tuple | None, 44 | arr2: list | tuple | None, 45 | merge_items: Callable[[Any, Any], Any] | None = None, 46 | ) -> list | tuple | None: 47 | if arr1 is None: 48 | return arr2 49 | if arr2 is None: 50 | return arr1 51 | n1 = len(arr1) 52 | n2 = len(arr2) 53 | result = list(arr1) 54 | for i, v1 in enumerate(arr1): 55 | if i < n2: 56 | v2 = arr2[i] 57 | result[i] = merge_items(v1, v2) if merge_items is not None else v2 58 | if n1 < n2: 59 | result.extend(arr2[n1:]) 60 | return result 61 | 62 | 63 | def merge_set_lists(set1: list | None, set2: list | None) -> list | None: 64 | if set1 is None: 65 | return set2 66 | if set2 is None: 67 | return set1 68 | result = [] 69 | for v in set1 + set2: 70 | if v not in result: 71 | result.append(v) 72 | return result 73 | -------------------------------------------------------------------------------- /xrlint/util/naming.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | 6 | def to_kebab_case(name: str) -> str: 7 | return _to_lc_case(name, "-") 8 | 9 | 10 | def to_snake_case(name: str) -> str: 11 | return _to_lc_case(name, "_") 12 | 13 | 14 | # noinspection SpellCheckingInspection 15 | def _to_lc_case(name: str, sep: str) -> str: 16 | lc_case = [] 17 | pl = False # (p)revious character is (l)owercase 18 | pu = False # (p)revious character is (u)ppercase 19 | ppu = False # (p)pre-(p)revious character is (u)ppercase 20 | for cc in name: 21 | cl = cc.islower() # (c)urrent character is (l)owercase 22 | cu = cc.isupper() # (c)urrent character is (u)ppercase 23 | if not cc.isalnum(): 24 | _append_sep(lc_case, sep) 25 | else: 26 | if pl and not cl: 27 | _append_sep(lc_case, sep) 28 | elif ppu and pu and cl: 29 | _insert_sep(lc_case, sep) 30 | lc_case.append(cc.lower() if not cl else cc) 31 | ppu = pu 32 | pu = cu 33 | pl = cl 34 | return "".join(lc_case) 35 | 36 | 37 | def _append_sep(sc_name, sep): 38 | n = len(sc_name) 39 | if n > 0 and sc_name[-1] != sep: 40 | sc_name.append(sep) 41 | 42 | 43 | def _insert_sep(sc_name, sep): 44 | n = len(sc_name) 45 | if n > 1 and sc_name[-2] != sep: 46 | sc_name.append(sc_name[-1]) 47 | sc_name[-2] = sep 48 | -------------------------------------------------------------------------------- /xrlint/util/schema.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | from typing import Any, Literal 6 | 7 | from .formatting import format_message_one_of, format_message_type_of 8 | 9 | TYPE_NAMES = ( 10 | "null", 11 | "boolean", 12 | "integer", 13 | "number", 14 | "string", 15 | "array", 16 | "object", 17 | ) 18 | 19 | JsonTypeName = Literal[ 20 | "null", 21 | "boolean", 22 | "integer", 23 | "number", 24 | "string", 25 | "array", 26 | "object", 27 | ] 28 | 29 | JsonSchema = dict[str, Any] | bool 30 | 31 | 32 | # noinspection PyPep8Naming 33 | def schema( 34 | type: JsonTypeName | list[JsonTypeName] | None = None, 35 | *, 36 | # common 37 | default: Any | None = None, 38 | const: Any | None = None, 39 | enum: list[Any] | None = None, 40 | title: str | None = None, 41 | description: str | None = None, 42 | # "integer", "number" 43 | minimum: int | float | None = None, 44 | maximum: int | float | None = None, 45 | exclusiveMinimum: int | float | None = None, 46 | exclusiveMaximum: int | float | None = None, 47 | # "array" 48 | items: list[JsonSchema] | JsonSchema | None = None, 49 | # "object" 50 | properties: dict[str, JsonSchema] | None = None, 51 | additionalProperties: bool | None = None, 52 | required: list[str] | None = None, 53 | ) -> JsonSchema: 54 | """Helper function so you have keyword-arguments for creating schemas.""" 55 | return { 56 | k: v 57 | for k, v in dict( 58 | type=_parse_type(type), 59 | default=default, 60 | const=const, 61 | enum=enum, 62 | minimum=minimum, 63 | maximum=maximum, 64 | exclusiveMinimum=exclusiveMinimum, 65 | exclusiveMaximum=exclusiveMaximum, 66 | items=items, 67 | properties=properties, 68 | additionalProperties=False if additionalProperties is False else None, 69 | required=required, 70 | title=title, 71 | description=description, 72 | ).items() 73 | if v is not None 74 | } 75 | 76 | 77 | def _parse_type(type: Any) -> JsonTypeName | list[JsonTypeName] | None: 78 | if isinstance(type, (list, tuple)): 79 | if not type: 80 | return None 81 | return [_validate_type_name(t) for t in type] 82 | else: 83 | if type is None: 84 | return None 85 | return _validate_type_name(type) 86 | 87 | 88 | def _validate_type_name(type_name: Any) -> JsonTypeName: 89 | if not isinstance(type_name, str): 90 | raise TypeError(format_message_type_of("type", type_name, "str|list[str]")) 91 | if type_name not in TYPE_NAMES: 92 | raise ValueError(format_message_one_of("type name", type_name, TYPE_NAMES)) 93 | # noinspection PyTypeChecker 94 | return type_name 95 | -------------------------------------------------------------------------------- /xrlint/version.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025 Brockmann Consult GmbH. 2 | # This software is distributed under the terms and conditions of the 3 | # MIT license (https://mit-license.org/). 4 | 5 | version = "0.5.1" 6 | --------------------------------------------------------------------------------