mat-data-handler 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ # Route data PRs to whoever reviews material parameter values;
2
+ # route code PRs to whoever maintains the validation/export API.
3
+ # Replace placeholders with actual GitHub usernames/teams before use.
4
+ /packages/mat-data-handler-data/ @<materials-reviewer>
5
+ /packages/mat-data-handler/ @<core-maintainer>
@@ -0,0 +1,13 @@
1
+ ## What does this PR change?
2
+
3
+ - [ ] New/updated material entry (data)
4
+ - [ ] Schema change
5
+ - [ ] Code change (validation, export, CLI)
6
+ - [ ] Docs/CI only
7
+
8
+ ## Checklist
9
+
10
+ - [ ] `pytest` passes locally for affected package(s)
11
+ - [ ] For data changes: entry validates against `material.schema.json`
12
+ - [ ] For schema changes: existing entries still validate, or migration notes are included
13
+ - [ ] `CHANGELOG.md` updated for the affected package
@@ -0,0 +1,53 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.9", "3.12"]
14
+ steps:
15
+ - name: Check out mat-data-handler
16
+ uses: actions/checkout@v4
17
+
18
+ - name: Check out mat-data (test fixture data)
19
+ uses: actions/checkout@v4
20
+ with:
21
+ repository: ICAMS/mat-data
22
+ # Pin to a released tag for reproducibility once available, e.g. ref: v2026.9.0
23
+ ref: main
24
+ path: mat-data-checkout
25
+
26
+ - uses: actions/setup-python@v5
27
+ with:
28
+ python-version: ${{ matrix.python-version }}
29
+
30
+ - name: Install package
31
+ run: pip install -e ".[test]"
32
+
33
+ - name: Validate mat-data entries
34
+ env:
35
+ MAT_DATA_LOCAL_PATH: ${{ github.workspace }}/mat-data-checkout
36
+ run: mat-build-database --output /tmp/materials.yaml --validate-only
37
+
38
+ - name: Rebuild database and check reproducibility
39
+ env:
40
+ MAT_DATA_LOCAL_PATH: ${{ github.workspace }}/mat-data-checkout
41
+ run: |
42
+ mat-build-database --output /tmp/materials.yaml
43
+ mat-build-database --output /tmp/materials.yaml --check
44
+
45
+ - name: Run tests
46
+ env:
47
+ MAT_DATA_LOCAL_PATH: ${{ github.workspace }}/mat-data-checkout
48
+ run: pytest -v
49
+
50
+ - name: Export a sample material
51
+ env:
52
+ MAT_DATA_LOCAL_PATH: ${{ github.workspace }}/mat-data-checkout
53
+ run: mat-extract-params copper_generic --outdir /tmp/includes
@@ -0,0 +1,24 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ id-token: write # required for PyPI Trusted Publishing
10
+
11
+ jobs:
12
+ build-and-publish:
13
+ runs-on: ubuntu-latest
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+ - run: pip install build
20
+ - run: python -m build --outdir dist
21
+ - name: Publish to PyPI
22
+ uses: pypa/gh-action-pypi-publish@release/v1
23
+ with:
24
+ packages-dir: dist
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .pytest_cache/
7
+ .mypy_cache/
8
+ .ruff_cache/
9
+ .venv/
10
+ venv/
11
+ *.egg
12
+ .DS_Store
@@ -0,0 +1,5 @@
1
+ # mat-data-handler
2
+
3
+ ## [0.1.0]
4
+
5
+ - Initial release with CLI and API support.
@@ -0,0 +1,33 @@
1
+ # Contributing
2
+
3
+ This repository contains only code: validation, database-building, and
4
+ Abaqus `.inc` export logic for material parameter sets. It has no material
5
+ data of its own — that lives in the separate
6
+ [mat-data](https://github.com/ICAMS/mat-data) repository.
7
+
8
+ ## Adding or updating a material (data change)
9
+
10
+ Open a pull request against [mat-data](https://github.com/ICAMS/mat-data),
11
+ not here. See its `CONTRIBUTING.md`.
12
+
13
+ ## Changing the API or export logic (code change)
14
+
15
+ 1. Make changes under `src/mat_data_handler/`.
16
+ 2. Add/update tests under `tests/`.
17
+ 3. Follow SemVer: patch for bug fixes, minor for backward-compatible
18
+ additions, major for breaking changes to the public API
19
+ (`build_database`, `export_entry`, CLI flags).
20
+ 4. Update `CHANGELOG.md`.
21
+
22
+ ## Running tests locally
23
+
24
+ ```bash
25
+ pip install -e ".[test]"
26
+ export MAT_DATA_LOCAL_PATH=../mat-data # point at a sibling mat-data checkout
27
+ pytest
28
+ ```
29
+
30
+ ## Reporting issues
31
+
32
+ Please include the command run and the full error output. If the issue is
33
+ about specific material values, file it against `mat-data` instead.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ICAMS, Ruhr University Bochum
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.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.5
2
+ Name: mat-data-handler
3
+ Version: 0.1.0
4
+ Summary: Validate, combine, and export crystal-plasticity material parameter sets for ICAMS CP-UMAT.
5
+ Project-URL: Homepage, https://github.com/ICAMS/mat-data-handler
6
+ Project-URL: Repository, https://github.com/ICAMS/mat-data-handler
7
+ Author: ICAMS, Ruhr University Bochum
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Requires-Python: >=3.9
14
+ Requires-Dist: jsonschema>=4.18
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: referencing>=0.30
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest; extra == 'test'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # mat-data-handler – Organizing Material Data for Crystal Plasticity
22
+
23
+ Plain Python package to validate, combine, and export crystal-plasticity material parameter sets. The material data itself (YAML
24
+ entries, JSON schemas, PROPS mapping table) is retrieved from the
25
+ [mat-data](https://github.com/ICAMS/mat-data) repository and fetched over
26
+ the network (and cached locally) at runtime.
27
+ Currently only support for the crystal plasticity model [ICAMS CP-UMAT](https://github.com/ICAMS/Crystal_Plasticity_UMAT.git) is provided. Use the `mat_extract_params` command to extract the require .inc files that contains the values for PROPS[9:] read by the ICAMS CP-UMAT.
28
+
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install mat-data-handler
34
+ ```
35
+
36
+ First use of the CLI/API fetches the pinned `mat-data` ref (default: `main`)
37
+ and caches it under `~/.cache/mat-data-handler`. For offline use or local
38
+ development against a sibling checkout, set:
39
+
40
+ ```bash
41
+ export MAT_DATA_LOCAL_PATH=/path/to/mat-data # skips network access entirely
42
+ ```
43
+
44
+ Other environment variables: `MAT_DATA_REPO` (default `ICAMS/mat-data`),
45
+ `MAT_DATA_REF` (default `main`; pin to a release tag for reproducibility),
46
+ `MAT_DATA_FETCH_METHOD` (`http` default, stdlib-only tarball download; or
47
+ `git`, which shells out to the `git` binary), `MAT_DATA_CACHE_DIR`.
48
+
49
+ ## API
50
+
51
+ - `mat_data_handler.data_source.{entries_dir, schemas_dir, mapping_path}()` —
52
+ resolve (fetching/caching as needed) local paths to the `mat-data` content.
53
+ - `mat_data_handler.database.build_database(entries, schemas, output_format="yaml")`
54
+ — validate all entries and return the combined database as text.
55
+ - `mat_data_handler.database.validate_only(entries, schemas)` — validate only,
56
+ returns the number of valid materials.
57
+ - `mat_data_handler.extract.export_entry(entry, mapping, schemas)` — export
58
+ one loaded material entry to Abaqus `PROPS` records.
59
+ - `mat_data_handler.extract.load_document(path)` / `parse_mapping(path)` —
60
+ helpers to load a YAML/JSON entry or the `mapping.csv` table.
61
+
62
+ ## CLI
63
+
64
+ ```bash
65
+ mat-build-database --output build/materials.yaml # generate combined database
66
+ mat-extract-params copper_generic --outdir build/includes
67
+ mat-extract-params all --outdir build/includes
68
+ ```
69
+
70
+ Both commands default to fetching data from `mat-data`; pass
71
+ `--entries`/`--schemas`/`--mapping-file` to point at a different data source
72
+ (e.g. a local checkout) directly.
73
+
74
+ ## Contributing
75
+
76
+ See [CONTRIBUTING.md](CONTRIBUTING.md). For adding/updating material data,
77
+ see [mat-data](https://github.com/ICAMS/mat-data) instead — this repo has no
78
+ data to edit.
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ pip install -e ".[test]"
84
+ export MAT_DATA_LOCAL_PATH=../mat-data # or wherever your mat-data checkout lives
85
+ pytest
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT — see [LICENSE](LICENSE). (Material data in `mat-data` is licensed
91
+ separately, under CC-BY-4.0.)
@@ -0,0 +1,71 @@
1
+ # mat-data-handler – Organizing Material Data for Crystal Plasticity
2
+
3
+ Plain Python package to validate, combine, and export crystal-plasticity material parameter sets. The material data itself (YAML
4
+ entries, JSON schemas, PROPS mapping table) is retrieved from the
5
+ [mat-data](https://github.com/ICAMS/mat-data) repository and fetched over
6
+ the network (and cached locally) at runtime.
7
+ Currently only support for the crystal plasticity model [ICAMS CP-UMAT](https://github.com/ICAMS/Crystal_Plasticity_UMAT.git) is provided. Use the `mat_extract_params` command to extract the require .inc files that contains the values for PROPS[9:] read by the ICAMS CP-UMAT.
8
+
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ pip install mat-data-handler
14
+ ```
15
+
16
+ First use of the CLI/API fetches the pinned `mat-data` ref (default: `main`)
17
+ and caches it under `~/.cache/mat-data-handler`. For offline use or local
18
+ development against a sibling checkout, set:
19
+
20
+ ```bash
21
+ export MAT_DATA_LOCAL_PATH=/path/to/mat-data # skips network access entirely
22
+ ```
23
+
24
+ Other environment variables: `MAT_DATA_REPO` (default `ICAMS/mat-data`),
25
+ `MAT_DATA_REF` (default `main`; pin to a release tag for reproducibility),
26
+ `MAT_DATA_FETCH_METHOD` (`http` default, stdlib-only tarball download; or
27
+ `git`, which shells out to the `git` binary), `MAT_DATA_CACHE_DIR`.
28
+
29
+ ## API
30
+
31
+ - `mat_data_handler.data_source.{entries_dir, schemas_dir, mapping_path}()` —
32
+ resolve (fetching/caching as needed) local paths to the `mat-data` content.
33
+ - `mat_data_handler.database.build_database(entries, schemas, output_format="yaml")`
34
+ — validate all entries and return the combined database as text.
35
+ - `mat_data_handler.database.validate_only(entries, schemas)` — validate only,
36
+ returns the number of valid materials.
37
+ - `mat_data_handler.extract.export_entry(entry, mapping, schemas)` — export
38
+ one loaded material entry to Abaqus `PROPS` records.
39
+ - `mat_data_handler.extract.load_document(path)` / `parse_mapping(path)` —
40
+ helpers to load a YAML/JSON entry or the `mapping.csv` table.
41
+
42
+ ## CLI
43
+
44
+ ```bash
45
+ mat-build-database --output build/materials.yaml # generate combined database
46
+ mat-extract-params copper_generic --outdir build/includes
47
+ mat-extract-params all --outdir build/includes
48
+ ```
49
+
50
+ Both commands default to fetching data from `mat-data`; pass
51
+ `--entries`/`--schemas`/`--mapping-file` to point at a different data source
52
+ (e.g. a local checkout) directly.
53
+
54
+ ## Contributing
55
+
56
+ See [CONTRIBUTING.md](CONTRIBUTING.md). For adding/updating material data,
57
+ see [mat-data](https://github.com/ICAMS/mat-data) instead — this repo has no
58
+ data to edit.
59
+
60
+ ## Development
61
+
62
+ ```bash
63
+ pip install -e ".[test]"
64
+ export MAT_DATA_LOCAL_PATH=../mat-data # or wherever your mat-data checkout lives
65
+ pytest
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT — see [LICENSE](LICENSE). (Material data in `mat-data` is licensed
71
+ separately, under CC-BY-4.0.)
@@ -0,0 +1,49 @@
1
+ {% set name = "mat-data-handler" %}
2
+ {% set version = "0.1.0" %}
3
+
4
+ package:
5
+ name: {{ name|lower }}
6
+ version: {{ version }}
7
+
8
+ source:
9
+ url: https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/{{ name }}-{{ version }}.tar.gz
10
+ # sha256: <fill in after the first PyPI release>
11
+
12
+ build:
13
+ number: 0
14
+ noarch: python
15
+ script: {{ PYTHON }} -m pip install . -vv
16
+ entry_points:
17
+ - mat-build-database = mat_data_handler.database:main
18
+ - mat-extract-params = mat_data_handler.extract:main
19
+
20
+ requirements:
21
+ host:
22
+ - python >=3.9
23
+ - pip
24
+ - hatchling
25
+ run:
26
+ - python >=3.9
27
+ - pyyaml >=6.0
28
+ - jsonschema >=4.18
29
+ - referencing >=0.30
30
+ # Note: no data dependency here. mat-data-handler fetches material data from
31
+ # https://github.com/ICAMS/mat-data over the network at runtime (or from
32
+ # MAT_DATA_LOCAL_PATH), rather than depending on a packaged data distribution.
33
+
34
+ test:
35
+ imports:
36
+ - mat_data_handler
37
+ commands:
38
+ - mat-build-database --help
39
+ - mat-extract-params --help
40
+
41
+ about:
42
+ home: https://github.com/ICAMS/mat-data-handler
43
+ license: MIT
44
+ license_file: LICENSE
45
+ summary: Validate, combine, and export crystal-plasticity material parameter sets for Abaqus UMATs.
46
+
47
+ extra:
48
+ recipe-maintainers:
49
+ - <github-username>
@@ -0,0 +1,13 @@
1
+ name: mat-data-handler
2
+ channels:
3
+ - conda-forge
4
+ - nodefaults
5
+ dependencies:
6
+ - python >=3.9
7
+ - pyyaml >=6.0
8
+ - jsonschema >=4.18
9
+ - referencing >=0.30
10
+ - pytest
11
+ - pip
12
+ - pip:
13
+ - -e .
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mat-data-handler"
7
+ version = "0.1.0"
8
+ description = "Validate, combine, and export crystal-plasticity material parameter sets for ICAMS CP-UMAT."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "ICAMS, Ruhr University Bochum" }]
13
+ dependencies = [
14
+ "PyYAML>=6.0",
15
+ "jsonschema>=4.18",
16
+ "referencing>=0.30",
17
+ ]
18
+ # Note: material data is NOT a pip/conda dependency. It is fetched at runtime
19
+ # from the separate https://github.com/ICAMS/mat-data repository and cached
20
+ # locally; see mat_data_handler.data_source. Requires network access on first
21
+ # use (or MAT_DATA_LOCAL_PATH pointing at a local checkout for offline use).
22
+ classifiers = [
23
+ "License :: OSI Approved :: MIT License",
24
+ "Programming Language :: Python :: 3",
25
+ "Topic :: Scientific/Engineering",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ test = ["pytest"]
30
+
31
+ [project.scripts]
32
+ mat-build-database = "mat_data_handler.database:main"
33
+ mat-extract-params = "mat_data_handler.extract:main"
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/ICAMS/mat-data-handler"
37
+ Repository = "https://github.com/ICAMS/mat-data-handler"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/mat_data_handler"]
41
+
42
+ [tool.pytest.ini_options]
43
+ testpaths = ["tests"]
@@ -0,0 +1,18 @@
1
+ """Public API: validate/combine material YAML entries and export Abaqus .inc files.
2
+
3
+ See :mod:`mat_data_handler.database` for building the combined database and
4
+ :mod:`mat_data_handler.extract` for exporting selected materials to Abaqus
5
+ ``PROPS`` include files.
6
+ """
7
+
8
+ from importlib import metadata
9
+
10
+ try:
11
+ __version__ = metadata.version("mat-data-handler")
12
+ except metadata.PackageNotFoundError: # local checkout, not installed
13
+ __version__ = "0.0.0+dev"
14
+
15
+ from .database import build_database, collect, validate_only
16
+ from .extract import export_material
17
+
18
+ __all__ = ["build_database", "collect", "validate_only", "export_material", "__version__"]
@@ -0,0 +1,89 @@
1
+ """Resolve the mat-data material dataset over the network (no pip/conda dependency).
2
+
3
+ The material entries, JSON schemas, and PROPS mapping table live in the
4
+ separate `mat-data` repository (https://github.com/ICAMS/mat-data), not as an
5
+ installable package. This module fetches (and caches) that repository's
6
+ content, or uses a local checkout directly during development.
7
+
8
+ Resolution order:
9
+ 1. ``MAT_DATA_LOCAL_PATH`` env var, if set: use that directory as-is (no
10
+ network access). Intended for local development against a sibling
11
+ checkout of ``mat-data``, and for CI, which checks out ``mat-data``
12
+ itself and points this at it.
13
+ 2. Cached download for the requested ref under ``MAT_DATA_CACHE_DIR``
14
+ (defaults to ``~/.cache/mat-data-handler``).
15
+ 3. Fresh download via ``MAT_DATA_FETCH_METHOD`` (``http`` tarball download by
16
+ default, stdlib-only; or ``git clone`` if explicitly requested).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import subprocess
23
+ import tarfile
24
+ import urllib.request
25
+ from pathlib import Path
26
+
27
+ DEFAULT_REPO = "ICAMS/mat-data"
28
+ DEFAULT_REF = "main"
29
+
30
+
31
+ def _cache_dir() -> Path:
32
+ return Path(os.environ.get("MAT_DATA_CACHE_DIR", Path.home() / ".cache" / "mat-data-handler"))
33
+
34
+
35
+ def _fetch_http(repo: str, ref: str, dest: Path) -> None:
36
+ """Download and extract the repo's tarball for ``ref`` via GitHub's codeload API."""
37
+ url = f"https://codeload.github.com/{repo}/tar.gz/{ref}"
38
+ dest.parent.mkdir(parents=True, exist_ok=True)
39
+ archive = dest.parent / f"{dest.name}.tar.gz"
40
+ with urllib.request.urlopen(url) as response, open(archive, "wb") as stream:
41
+ stream.write(response.read())
42
+ try:
43
+ with tarfile.open(archive) as tar:
44
+ # GitHub tarballs contain a single top-level "<repo>-<ref>/" directory.
45
+ members = tar.getmembers()
46
+ top = os.path.commonpath([m.name for m in members])
47
+ safe_members = [m for m in members if not (m.name.startswith("/") or ".." in Path(m.name).parts)]
48
+ tar.extractall(dest.parent, members=safe_members) # noqa: S202 -- paths sanitized above
49
+ (dest.parent / top).replace(dest)
50
+ finally:
51
+ archive.unlink(missing_ok=True)
52
+
53
+
54
+ def _fetch_git(repo: str, ref: str, dest: Path) -> None:
55
+ """Shallow-clone the repo at ``ref`` (branch or tag) using the system ``git`` binary."""
56
+ dest.parent.mkdir(parents=True, exist_ok=True)
57
+ subprocess.run(
58
+ ["git", "clone", "--depth", "1", "--branch", ref, f"https://github.com/{repo}.git", str(dest)],
59
+ check=True,
60
+ )
61
+
62
+
63
+ def data_root(repo: str = None, ref: str = None) -> Path:
64
+ """Return a local directory containing entries/, schemas/, mapping_icams_cp.csv."""
65
+ local = os.environ.get("MAT_DATA_LOCAL_PATH")
66
+ if local:
67
+ return Path(local)
68
+
69
+ repo = repo or os.environ.get("MAT_DATA_REPO", DEFAULT_REPO)
70
+ ref = ref or os.environ.get("MAT_DATA_REF", DEFAULT_REF)
71
+ method = os.environ.get("MAT_DATA_FETCH_METHOD", "http")
72
+
73
+ dest = _cache_dir() / repo.replace("/", "__") / ref
74
+ if not dest.is_dir():
75
+ fetch = _fetch_git if method == "git" else _fetch_http
76
+ fetch(repo, ref, dest)
77
+ return dest
78
+
79
+
80
+ def entries_dir(repo: str = None, ref: str = None) -> Path:
81
+ return data_root(repo, ref) / "entries"
82
+
83
+
84
+ def schemas_dir(repo: str = None, ref: str = None) -> Path:
85
+ return data_root(repo, ref) / "schemas"
86
+
87
+
88
+ def mapping_path(repo: str = None, ref: str = None) -> Path:
89
+ return data_root(repo, ref) / "mapping_icams_cp.csv"
@@ -0,0 +1,211 @@
1
+ """Validate individual materials offline and atomically build a collection.
2
+
3
+ Ported from the ``cp-work`` proof of concept (materials/build_database.py),
4
+ adapted to load default data from the installable ``mat-data-handler-data``
5
+ package instead of a fixed repo-relative ``ROOT``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import math
13
+ import os
14
+ from pathlib import Path
15
+ import re
16
+ import sys
17
+ import tempfile
18
+
19
+ import yaml
20
+ from jsonschema import Draft201909Validator
21
+ from referencing import Registry, Resource
22
+
23
+ PLASTIC_MODELS = ("ICAMS CP-UMAT", "DAMASK phenopowerlaw")
24
+
25
+
26
+ class EntryLoader(yaml.SafeLoader):
27
+ def compose_node(self, parent, index):
28
+ if self.check_event(yaml.AliasEvent):
29
+ raise ValueError("YAML aliases are not allowed")
30
+ return super().compose_node(parent, index)
31
+
32
+ def construct_mapping(self, node, deep=False):
33
+ result = {}
34
+ for key_node, value_node in node.value:
35
+ key = self.construct_object(key_node, deep=deep)
36
+ if not isinstance(key, str):
37
+ raise ValueError(f"line {key_node.start_mark.line + 1}: mapping keys must be strings")
38
+ if key in result:
39
+ raise ValueError(f"line {key_node.start_mark.line + 1}: duplicate key {key!r}")
40
+ result[key] = self.construct_object(value_node, deep=deep)
41
+ return result
42
+
43
+
44
+ def json_errors(value, path="$"):
45
+ """Reject YAML-only values and non-finite numbers before schema validation."""
46
+ if isinstance(value, dict):
47
+ for key, child in value.items():
48
+ yield from json_errors(child, f"{path}.{key}")
49
+ elif isinstance(value, list):
50
+ for index, child in enumerate(value):
51
+ yield from json_errors(child, f"{path}[{index}]")
52
+ elif isinstance(value, float) and not math.isfinite(value):
53
+ yield f"{path}: number must be finite"
54
+ elif value is not None and type(value) not in (str, int, float, bool):
55
+ yield f"{path}: unsupported YAML value ({type(value).__name__}); quote text identifiers"
56
+
57
+
58
+ def validator(schema_dir: Path) -> Draft201909Validator:
59
+ resources = []
60
+ for path in sorted(Path(schema_dir).glob("*.json")):
61
+ schema = json.loads(path.read_text(encoding="utf-8"))
62
+ Draft201909Validator.check_schema(schema)
63
+ resources.append((path.resolve().as_uri(), Resource.from_contents(schema)))
64
+ # Registry has no retrieval callback: unresolved references cannot use the network.
65
+ registry = Registry().with_resources(resources)
66
+ uri = (Path(schema_dir) / "material_schema.json").resolve().as_uri()
67
+ return Draft201909Validator({"$ref": uri}, registry=registry)
68
+
69
+
70
+ def schema_messages(error):
71
+ if error.validator == "oneOf" and error.context:
72
+ # Select only diagnostics from the named model's branch. Validation
73
+ # still runs against the complete schema, including shared constraints.
74
+ if isinstance(error.instance, dict):
75
+ model = error.instance.get("plastic_model_name")
76
+ matching = [
77
+ index for index, branch in enumerate(error.validator_value)
78
+ if branch.get("properties", {}).get("plastic_model_name", {}).get("const") == model
79
+ and model in PLASTIC_MODELS
80
+ ]
81
+ if len(matching) == 1:
82
+ for child in error.context:
83
+ if child.schema_path and child.schema_path[0] == matching[0]:
84
+ yield from schema_messages(child)
85
+ return
86
+ yield f"{error.json_path}: does not match an allowed schema alternative"
87
+ for child in error.context:
88
+ yield from schema_messages(child)
89
+ else:
90
+ yield f"{error.json_path}: {error.message}"
91
+
92
+
93
+ def collect(entries: Path, schemas: Path):
94
+ """Validate every YAML entry in ``entries`` against ``schemas``.
95
+
96
+ Returns ``(database, errors)``. ``database`` maps material key (filename
97
+ stem) to the parsed entry; ``errors`` lists human-readable problems.
98
+ """
99
+ entries, schemas = Path(entries), Path(schemas)
100
+ check = validator(schemas)
101
+ files = sorted(p for p in entries.iterdir() if p.is_file() and p.suffix in (".yaml", ".yml"))
102
+ if not files:
103
+ raise ValueError(f"{entries}: no YAML entries found")
104
+ database, errors = {}, []
105
+ for path in files:
106
+ if not re.fullmatch(r"[a-z0-9_]+", path.stem):
107
+ errors.append(f"{path}: filename stem must contain lowercase letters, digits, or underscores")
108
+ continue
109
+ if path.stem in database:
110
+ errors.append(f"{path}: duplicate material key {path.stem!r}")
111
+ continue
112
+ try:
113
+ entry = yaml.load(path.read_text(encoding="utf-8"), Loader=EntryLoader)
114
+ problems = list(json_errors(entry))
115
+ if not problems and isinstance(entry, dict):
116
+ model_object = entry.get("constitutive_model", {})
117
+ if isinstance(model_object, dict):
118
+ model = model_object.get("plastic_model_name")
119
+ if model not in PLASTIC_MODELS:
120
+ problems = [
121
+ "$.constitutive_model.plastic_model_name: "
122
+ f"missing or unsupported plastic model name {model!r}; "
123
+ f"expected one of {PLASTIC_MODELS!r}"
124
+ ]
125
+ if not problems:
126
+ problems = [
127
+ message
128
+ for error in check.iter_errors(entry)
129
+ for message in schema_messages(error)
130
+ ]
131
+ errors.extend(f"{path}: {problem}" for problem in problems)
132
+ database[path.stem] = entry
133
+ except (ValueError, yaml.YAMLError) as error:
134
+ errors.append(f"{path}: {error}")
135
+ return database, errors
136
+
137
+
138
+ def validate_only(entries: Path, schemas: Path) -> int:
139
+ """Validate entries and return the count of valid materials, raising on error."""
140
+ database, errors = collect(entries, schemas)
141
+ if errors:
142
+ raise ValueError("\n".join(errors))
143
+ return len(database)
144
+
145
+
146
+ def build_database(entries: Path, schemas: Path, output_format: str = "yaml") -> str:
147
+ """Validate entries and return the serialized combined database as text."""
148
+ database, errors = collect(entries, schemas)
149
+ if errors:
150
+ raise ValueError("\n".join(errors))
151
+ if output_format == "json":
152
+ return json.dumps(database, indent=2, sort_keys=True, ensure_ascii=False, allow_nan=False) + "\n"
153
+ return yaml.safe_dump(database, sort_keys=True, allow_unicode=True)
154
+
155
+
156
+ def main(argv=None):
157
+ from .data_source import entries_dir as default_entries_dir
158
+ from .data_source import schemas_dir as default_schemas_dir
159
+
160
+ parser = argparse.ArgumentParser(description=__doc__)
161
+ parser.add_argument("--entries", type=Path, help="Defaults to the mat-data repo's entries/ (fetched/cached)")
162
+ parser.add_argument("--schemas", type=Path, help="Defaults to the mat-data repo's schemas/ (fetched/cached)")
163
+ parser.add_argument("--output", "-o", type=Path, required=True, help="Destination (.yaml/.yml or .json)")
164
+ parser.add_argument("--format", choices=("yaml", "json"), help="Defaults to output extension, or YAML")
165
+ mode = parser.add_mutually_exclusive_group()
166
+ mode.add_argument("--check", action="store_true", help="Fail if the output is missing or stale; write nothing")
167
+ mode.add_argument("--validate-only", action="store_true", help="Validate entries without building")
168
+ args = parser.parse_args(argv)
169
+ try:
170
+ entries = args.entries or default_entries_dir()
171
+ schemas = args.schemas or default_schemas_dir()
172
+
173
+ output = args.output
174
+ output_format = args.format or ("json" if output.suffix == ".json" else "yaml")
175
+ if output.resolve().parent in (Path(entries).resolve(), Path(schemas).resolve()):
176
+ raise ValueError("Output must be outside the entries and schemas directories")
177
+
178
+ if args.validate_only:
179
+ count = validate_only(entries, schemas)
180
+ print(f"Validated {count} materials")
181
+ return 0
182
+
183
+ content = build_database(entries, schemas, output_format)
184
+ data = content.encode("utf-8")
185
+ if args.check:
186
+ if not output.is_file() or output.read_bytes() != data:
187
+ print(f"{output}: missing or stale; rebuild without --check", file=sys.stderr)
188
+ return 1
189
+ print(f"{output}: up to date")
190
+ return 0
191
+ output.parent.mkdir(parents=True, exist_ok=True)
192
+ temporary = None
193
+ try:
194
+ with tempfile.NamedTemporaryFile(dir=output.parent, delete=False) as stream:
195
+ temporary = Path(stream.name)
196
+ stream.write(data)
197
+ stream.flush()
198
+ os.fsync(stream.fileno())
199
+ temporary.replace(output)
200
+ finally:
201
+ if temporary is not None:
202
+ temporary.unlink(missing_ok=True)
203
+ print(f"Wrote database to {output}")
204
+ return 0
205
+ except Exception as error:
206
+ print(f"Build failed: {error}", file=sys.stderr)
207
+ return 1
208
+
209
+
210
+ if __name__ == "__main__":
211
+ sys.exit(main())
@@ -0,0 +1,315 @@
1
+ """Export schema-valid material entries to shared Abaqus PROPS(9:) include files.
2
+
3
+ Ported from ``cp-work``'s ``materials/extract_params.py``. Business logic for
4
+ which parameters are active and how they map to Abaqus PROPS positions is
5
+ unchanged; only default data locations moved to the ``mat-data-handler-data``
6
+ package.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import ast
13
+ import csv
14
+ import io
15
+ import json
16
+ import math
17
+ from pathlib import Path
18
+ import re
19
+ import sys
20
+
21
+ import yaml
22
+
23
+ from .database import EntryLoader, json_errors, schema_messages, validator
24
+
25
+ FLAG_LAYOUT = dict(zip((
26
+ "flag_isotropic_hardening", "flag_back_stress", "flag_gradient_plasticity",
27
+ "flag_int", "flag_super_alloy", "flag_trip", "flag_thermal",
28
+ ), range(7)))
29
+ BASE = ["C11", "C12", "C44", "number_slip_systems", "reference_shear_rate",
30
+ "stress_exponent", "initial_critical_resolved_shear_stress"]
31
+ ISO = ["saturated_slip_resistance", "reference_hardening_rate", "self_hardening",
32
+ "cross_hardening", "hardening_exponent"]
33
+ KIN = ["nslip_kinematic_hardening", "kinematic_hardening_a1", "kinematic_hardening_b1",
34
+ "ohno_wang_exponent", "chaboche_parameter_a2", "chaboche_parameter_b2",
35
+ "chaboche_parameter_a3", "chaboche_parameter_b3"]
36
+ GRAD = ["flag_octree", "C_taui", "L_size", "I_gnd_iso", "I_gnd_kin", "C_unit", "B_lattice"]
37
+ THERM = ["activation_energy", "c11_tcoeff", "c12_tcoeff", "c44_tcoeff",
38
+ "kinematic_hardening_a1_tcoeff", "crss0_coef_tcoeff"]
39
+
40
+
41
+ def load_document(path: Path):
42
+ def unique_object(pairs):
43
+ result = {}
44
+ for key, value in pairs:
45
+ if key in result:
46
+ raise ValueError(f"duplicate key: {key}")
47
+ result[key] = value
48
+ return result
49
+
50
+ path = Path(path)
51
+ text = path.read_text(encoding="utf-8")
52
+ # JSON's exponent syntax (e.g. 1e-06) differs from PyYAML's resolver.
53
+ value = (json.loads(text, object_pairs_hook=unique_object) if path.suffix.lower() == ".json"
54
+ else yaml.load(text, Loader=EntryLoader))
55
+ errors = list(json_errors(value))
56
+ if errors:
57
+ raise ValueError("; ".join(errors))
58
+ return value
59
+
60
+
61
+ def parse_mapping(path: Path):
62
+ with Path(path).open(encoding="utf-8-sig", newline="") as stream:
63
+ rows = [{k.strip(): v.strip() for k, v in row.items()} for row in csv.DictReader(stream, delimiter=";")]
64
+ result = {}
65
+ for row in rows:
66
+ key = row[next(k for k in row if k.startswith("json_key"))]
67
+ if key in result:
68
+ raise ValueError(f"duplicate mapping key: {key}")
69
+ result[key] = row
70
+ return result
71
+
72
+
73
+ def condition(expression, env, provided=False):
74
+ expression = expression.strip()
75
+ if expression in ("always", ""):
76
+ return True
77
+ if expression == "never":
78
+ return False
79
+ if expression == "provided":
80
+ return provided
81
+ node = ast.parse(expression, mode="eval")
82
+ allowed = (ast.Expression, ast.Compare, ast.Name, ast.Load, ast.Constant,
83
+ ast.Eq, ast.NotEq, ast.Gt, ast.GtE, ast.Lt, ast.LtE,
84
+ ast.BoolOp, ast.And, ast.Or, ast.UnaryOp, ast.Not)
85
+ if any(not isinstance(n, allowed) for n in ast.walk(node)):
86
+ raise ValueError(f"unsupported mapping condition: {expression}")
87
+ return bool(eval(compile(node, "<mapping condition>", "eval"), {"__builtins__": {}}, env))
88
+
89
+
90
+ def scalar(value, key):
91
+ while isinstance(value, list):
92
+ if len(value) != 1:
93
+ raise ValueError(f"{key}: current Fortran reader supports only one family / coefficient")
94
+ value = value[0]
95
+ if isinstance(value, bool) or not isinstance(value, (float, int)) or not math.isfinite(value):
96
+ raise ValueError(f"{key}: expected a finite number")
97
+ return value
98
+
99
+
100
+ def export_entry(entry, mapping, schemas: Path):
101
+ model = entry.get("constitutive_model", {}) if isinstance(entry, dict) else {}
102
+ if not isinstance(model, dict) or model.get("plastic_model_name") != "ICAMS CP-UMAT":
103
+ raise ValueError("CP-UMAT export requires plastic_model_name: ICAMS CP-UMAT")
104
+ errors = [m for e in validator(schemas).iter_errors(entry) for m in schema_messages(e)]
105
+ if errors:
106
+ raise ValueError("\n".join(errors))
107
+ plastic = model["plastic_parameters"]
108
+ elastic = model["elastic_parameters"]
109
+ units = model["units"]
110
+ records, notes = [], []
111
+
112
+ def raw(key):
113
+ if key == "space_group_number":
114
+ if key in plastic and plastic[key] != entry[key]:
115
+ raise ValueError("conflicting space_group_number values")
116
+ return entry[key]
117
+ return elastic.get(key) if key in elastic else plastic.get(key)
118
+
119
+ flags = {}
120
+ packed = plastic.get("super_flag")
121
+ if packed is not None and not 0 <= packed <= 0x7FFFFFFF:
122
+ raise ValueError("super_flag exceeds signed 32-bit range")
123
+ for key, pos in FLAG_LAYOUT.items():
124
+ if key in plastic:
125
+ flags[key] = plastic[key]
126
+ if packed is not None and ((packed >> (4 * pos)) & 15) != flags[key]:
127
+ raise ValueError(f"super_flag mismatch for {key}")
128
+ elif packed is not None:
129
+ flags[key] = (packed >> (4 * pos)) & 15
130
+ notes.append(f"{key}: decoded from super_flag = {flags[key]}")
131
+ else:
132
+ flags[key] = int(mapping[key]["default_value"])
133
+ notes.append(f"{key}: mapping default = {flags[key]}")
134
+ maximum = 3 if key == "flag_back_stress" else 1
135
+ if not 0 <= flags[key] <= maximum:
136
+ raise ValueError(f"{key}: unsupported mode {flags[key]}")
137
+ super_flag = sum(value << (4 * FLAG_LAYOUT[key]) for key, value in flags.items())
138
+ if packed is not None and packed != super_flag:
139
+ raise ValueError("super_flag contains unsupported spare bits")
140
+ env = dict(zip(("iso_mode", "kin_mode", "grad_mode", "int_mode", "super_mode", "trip_mode", "thermal_mode"), flags.values()))
141
+ if env["super_mode"]:
142
+ raise ValueError("superalloy export unavailable: the schema/mapping do not represent all 12 values read by the UMAT")
143
+ if env["trip_mode"] or env["int_mode"]:
144
+ raise ValueError("TRIP/internal-stress export requires additional constitutive compatibility checks; currently unsupported")
145
+ if scalar(plastic.get("number_slip_families", 1), "number_slip_families") != 1:
146
+ raise ValueError("current Fortran reader supports one slip family only")
147
+ if any(key in elastic for key in ("C33", "C66", "C13")):
148
+ raise ValueError("current Fortran reader consumes only C11, C12, C44; additional elastic constants cannot be exported")
149
+ for key, value in plastic.items():
150
+ if isinstance(value, list):
151
+ scalar(value, key)
152
+ if env["thermal_mode"]:
153
+ if plastic.get("temperature_polynomial_degree", 1) != 1:
154
+ raise ValueError("current Fortran reader supports linear thermal slopes only")
155
+ extra = [k for k in plastic if k.endswith("_tcoeff") and k not in THERM]
156
+ if extra:
157
+ raise ValueError(f"thermal coefficients not read by CP-UMAT: {extra}")
158
+
159
+ def append(key, value, source, unit="-"):
160
+ records.append(dict(props_position=9 + len(records), key=key, value=value, source=source, unit=unit))
161
+
162
+ def mapped(key):
163
+ row = mapping[key]
164
+ value = raw(key)
165
+ provided = value is not None
166
+ if not condition(row["active_if"], env, provided):
167
+ raise ValueError(f"mapping disables {key}, but the Fortran reader requires its slot")
168
+ source = "entry"
169
+ if not provided:
170
+ if condition(row["required_if"], env, provided):
171
+ raise ValueError(f"missing required active parameter: {key}")
172
+ value = float(row["default_value"].replace(",", "."))
173
+ source = "mapping default"
174
+ value = scalar(value, key)
175
+ if row["fortran_type"].startswith("integer"):
176
+ if value != int(value):
177
+ raise ValueError(f"{key}: expected integer")
178
+ value = int(value)
179
+ elif not row["fortran_type"].startswith("real"):
180
+ raise ValueError(f"{key}: unsupported mapping type")
181
+ if key in ("number_slip_systems", "nslip_kinematic_hardening") and not 1 <= value <= 60:
182
+ raise ValueError(f"{key}: must be within current Nslp_mx=60")
183
+ if key in ("I_gnd_iso", "I_gnd_kin") and value not in (0, 1):
184
+ raise ValueError(f"{key}: expected 0 or 1")
185
+ unit = row["unit"]
186
+ if provided and unit.startswith("MPa"):
187
+ category = "Stiffness" if key in elastic or key in ("c11_tcoeff", "c12_tcoeff", "c44_tcoeff") else "Stress"
188
+ if units[category] == "Pa":
189
+ value *= 1e-6
190
+ source += " (Pa converted to MPa)"
191
+ if source == "mapping default":
192
+ notes.append(f"{key}: mapping default = {value} {unit}")
193
+ append(key, value, source, unit)
194
+
195
+ mapped("space_group_number")
196
+ append("super_flag", super_flag, "packed flags")
197
+ for key in BASE:
198
+ mapped(key)
199
+ if env["iso_mode"]:
200
+ for key in ISO:
201
+ mapped(key)
202
+ if env["kin_mode"]:
203
+ for key in KIN:
204
+ mapped(key)
205
+ if env["grad_mode"]:
206
+ for key in GRAD:
207
+ mapped(key)
208
+ # Not yet in mapping/schema; retain the explicit reader initialization.
209
+ append("CD_smooth", 3e-15, "src/mod_material.f default")
210
+ notes.append("CD_smooth: reader default = 3e-15 (not configurable in current schema/mapping)")
211
+ if env["thermal_mode"]:
212
+ for key in THERM:
213
+ mapped(key)
214
+ return records, notes
215
+
216
+
217
+ def render_outputs(name, entry, records, notes, source):
218
+ values = [str(r["value"]) if isinstance(r["value"], int) else format(r["value"], ".17g") for r in records]
219
+ inc_name = f"{name}_inp_{len(values)}p.inc"
220
+ lines = [", ".join(values[i:i + 8]) + ("," if i + 8 < len(values) else "") for i in range(0, len(values), 8)]
221
+ stream = io.StringIO()
222
+ writer = csv.DictWriter(stream, fieldnames=list(records[0]))
223
+ writer.writeheader()
224
+ writer.writerows(records)
225
+ manifest = [f"Source: {source}", f"Include: {inc_name}", f"Include count: {len(values)}",
226
+ f"Total Abaqus constant count: {len(values) + 8}",
227
+ "PROPS(1:8) supplied by caller: material selector, three Euler angles (rad), four spare values.",
228
+ "", "Abaqus usage (replace Euler angles for each grain):",
229
+ f"*User Material, constants={len(values) + 8}",
230
+ f'{entry["material_id"]}, 0., 0., 0., 0., 0., 0., 0.,',
231
+ f"*Include, input={inc_name}", "", "Positions:"]
232
+ manifest += [f'PROPS({r["props_position"]}): {r["key"]} = {r["value"]} [{r["unit"]}] ({r["source"]})' for r in records]
233
+ manifest += ["", "Defaults and notes:", *notes]
234
+ return {inc_name: "\n".join(lines) + "\n", f"{name}_props_flat.csv": stream.getvalue(),
235
+ f"{name}_props_manifest.txt": "\n".join(manifest) + "\n"}
236
+
237
+
238
+ def export_material(name: str, entry, mapping, schemas: Path):
239
+ """Export one already-loaded material entry. Returns ``(records, notes)``."""
240
+ return export_entry(entry, mapping, schemas)
241
+
242
+
243
+ def _default_data_paths():
244
+ from .data_source import entries_dir, mapping_path, schemas_dir
245
+
246
+ return entries_dir(), schemas_dir(), mapping_path()
247
+
248
+
249
+ def main(argv=None):
250
+ parser = argparse.ArgumentParser(description=__doc__)
251
+ parser.add_argument("material", help="Material key or 'all'")
252
+ parser.add_argument("--input", "--yaml_file", "--json_file", dest="input", type=Path,
253
+ help="Entry directory, single entry, or combined YAML/JSON database; "
254
+ "defaults to the mat-data repo's entries/ (fetched/cached)")
255
+ parser.add_argument("--mapping-file", "--mapping_file", dest="mapping_file", type=Path,
256
+ help="Defaults to the mat-data repo's mapping.csv (fetched/cached)")
257
+ parser.add_argument("--schemas", type=Path, help="Defaults to the mat-data repo's schemas/ (fetched/cached)")
258
+ parser.add_argument("--outdir", type=Path, default=Path("."))
259
+ parser.add_argument("--values-per-line", type=int, choices=[8], default=8)
260
+ args = parser.parse_args(argv)
261
+ try:
262
+ if args.input is None or args.mapping_file is None or args.schemas is None:
263
+ default_entries, default_schemas, default_mapping = _default_data_paths()
264
+ input_path = args.input or default_entries
265
+ mapping_file = args.mapping_file or default_mapping
266
+ schemas = args.schemas or default_schemas
267
+ else:
268
+ input_path, mapping_file, schemas = args.input, args.mapping_file, args.schemas
269
+
270
+ mapping = parse_mapping(mapping_file)
271
+ input_path = Path(input_path)
272
+ if input_path.is_dir():
273
+ files = sorted(p for p in input_path.iterdir() if p.suffix in (".yaml", ".yml") and p.is_file())
274
+ selected = files if args.material == "all" else [p for p in files if p.stem == args.material]
275
+ entries = {}
276
+ for path in selected:
277
+ if path.stem in entries:
278
+ raise ValueError(f"duplicate material key: {path.stem}")
279
+ entries[path.stem] = load_document(path)
280
+ else:
281
+ data = load_document(input_path)
282
+ if not isinstance(data, dict):
283
+ raise ValueError("input must be a material object or keyed database")
284
+ entries = {input_path.stem: data} if "constitutive_model" in data else data
285
+ if args.material != "all":
286
+ entries = {k: v for k, v in entries.items() if k == args.material}
287
+ if not entries:
288
+ raise ValueError(f"no matching materials: {args.material}")
289
+
290
+ outputs, errors = {}, []
291
+ for name, entry in sorted(entries.items()):
292
+ try:
293
+ if not isinstance(name, str) or not re.fullmatch("[a-z0-9_]+", name):
294
+ raise ValueError("invalid material key")
295
+ records, notes = export_entry(entry, mapping, schemas)
296
+ outputs.update(render_outputs(name, entry, records, notes, input_path))
297
+ except (ValueError, KeyError) as error:
298
+ errors.append(f"{name}: {error}")
299
+ if errors:
300
+ raise ValueError("\n".join(errors))
301
+
302
+ # Validate the whole selection before touching any output files.
303
+ args.outdir.mkdir(parents=True, exist_ok=True)
304
+ for name, content in outputs.items():
305
+ path = args.outdir / name
306
+ path.write_text(content, encoding="utf-8")
307
+ print(f"Wrote {path}")
308
+ return 0
309
+ except Exception as error:
310
+ print(f"Export failed: {error}", file=sys.stderr)
311
+ return 1
312
+
313
+
314
+ if __name__ == "__main__":
315
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,22 @@
1
+ """Point tests at a local mat-data checkout instead of fetching over the network.
2
+
3
+ Assumes the sibling layout used during development:
4
+ ``~/Codes/mat-data-handler`` and ``~/Codes/mat-data``. CI instead sets
5
+ ``MAT_DATA_LOCAL_PATH`` itself after checking out the ``mat-data`` repo.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ import pytest
12
+
13
+ _SIBLING_MAT_DATA = Path(__file__).resolve().parents[3] / "mat-data"
14
+
15
+
16
+ @pytest.fixture(autouse=True, scope="session")
17
+ def _mat_data_local_path():
18
+ if "MAT_DATA_LOCAL_PATH" not in os.environ and _SIBLING_MAT_DATA.is_dir():
19
+ os.environ["MAT_DATA_LOCAL_PATH"] = str(_SIBLING_MAT_DATA)
20
+ if "MAT_DATA_LOCAL_PATH" not in os.environ:
21
+ pytest.skip("MAT_DATA_LOCAL_PATH not set and no sibling mat-data checkout found")
22
+ yield
@@ -0,0 +1,7 @@
1
+ from mat_data_handler.data_source import entries_dir, schemas_dir
2
+ from mat_data_handler.database import validate_only
3
+
4
+
5
+ def test_bundled_entries_validate():
6
+ count = validate_only(entries_dir(), schemas_dir())
7
+ assert count >= 1
@@ -0,0 +1,10 @@
1
+ from mat_data_handler.data_source import entries_dir, mapping_path, schemas_dir
2
+ from mat_data_handler.extract import export_entry, load_document, parse_mapping
3
+
4
+
5
+ def test_export_copper():
6
+ entry = load_document(entries_dir() / "copper_generic.yaml")
7
+ mapping = parse_mapping(mapping_path())
8
+ records, notes = export_entry(entry, mapping, schemas_dir())
9
+ assert records[0]["props_position"] == 9
10
+ assert any(r["key"] == "C11" for r in records)