├── .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 | [](https://github.com/bcdev/xrlint/actions/workflows/tests.yml)
2 | [](https://codecov.io/gh/bcdev/xrlint)
3 | [](https://pypi.org/project/xrlint/)
4 | [](https://anaconda.org/conda-forge/xrlint)
5 | [](https://github.com/charliermarsh/ruff)
6 | [](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 |
--------------------------------------------------------------------------------