Skip to content

Commit 7e8fdce

Browse files
authored
Merge pull request #202 from Ultimaker/improve-check-material-profiles
Improve check_material_profiles script
2 parents 5f87f40 + 72ae43b commit 7e8fdce

8 files changed

Lines changed: 162 additions & 286 deletions

.github/workflows/cicd.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@ jobs:
1010
- name: Checkout master
1111
uses: actions/checkout@v1.2.0
1212
- name: Install lxml
13-
run: python -m pip install lxml==4.3.0
13+
run: python -m pip install lxml==4.6.5
1414
- name:
15-
run: python scripts/check_material_profiles_new_with_lxml.py
15+
run: python scripts/check_material_profiles.py

Jenkinsfile

Lines changed: 0 additions & 22 deletions
This file was deleted.

build_for_ultimaker.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ run_tests()
4343
{
4444
echo "Testing!"
4545
# These tests should never fail! See .gitlab-ci.yml
46-
./run_check_material_profiles_new_with_lxml.sh || echo "Material Profile Check with lxml Failed!"
46+
./run_check_material_profiles.sh || echo "Material Profile Check Failed!"
4747
}
4848

4949
run_linters()

run_check_material_profiles.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/bin/sh
2+
3+
set -eu
4+
5+
. ./make_docker.sh
6+
7+
run_in_docker python3 scripts/check_material_profiles.py || echo "Failed!"
8+
9+
exit 0

run_check_material_profiles_new_with_lxml.sh

Lines changed: 0 additions & 9 deletions
This file was deleted.

scripts/check_material_profiles.py

Lines changed: 150 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,160 @@
1-
# This script is dedicated to the public domain under the terms of the CC0 license.
1+
#!/usr/bin/env python3
2+
"""
3+
Validation for `*.xml.fdm_material` Material Profile files, based on an XML Schema
4+
Definition.
25
3-
from collections import OrderedDict
6+
This script is dedicated to the public domain under the terms of the CC0 license.
7+
"""
8+
9+
import logging
410
import os
11+
from pathlib import Path
512
import sys
6-
import re
7-
from typing import Optional
13+
from typing import Dict, Iterable, Optional, List
814

15+
from lxml import etree
916

10-
class MaterialProfilesValidator:
1117

12-
def __init__(self, root_dir: str) -> None:
13-
self._repo_dir = os.path.abspath(root_dir)
14-
self._materials_dir = self._repo_dir
15-
16-
self._guid_pattern = re.compile(r"<GUID>.*</GUID>")
17-
18-
def _get_guid(self, content: str) -> Optional[str]:
19-
guid = None
20-
for line in content.splitlines():
21-
line = line.strip()
22-
if self._guid_pattern.match(line):
23-
guid = line.strip("<GUID>").strip("</GUID>")
24-
break
25-
return guid
26-
27-
def get_materials_dir(self, dirpath: str) -> str:
28-
for root_dir, dirnames, filenames in os.walk(dirpath):
29-
has_materials_file = any(fn.endswith(".xml.fdm_material") for fn in filenames)
30-
if not has_materials_file:
31-
for dirname in dirnames:
32-
full_dir_path = os.path.join(root_dir, dirname)
33-
return self.get_materials_dir(full_dir_path)
34-
35-
return dirpath
36-
37-
## Validates the preset settings files and returns ``True`` or ``False``
38-
# indicating whether there are invalid files.
39-
def validate(self) -> bool:
40-
# parse the definition file
41-
guid_dict = OrderedDict()
42-
43-
materials_dir = self.get_materials_dir(self._materials_dir)
44-
45-
# go through all the preset settings files
46-
for _, _, filenames in os.walk(materials_dir):
47-
for filename in filenames:
48-
file_path = os.path.join(materials_dir, filename)
49-
if not filename.endswith(".xml.fdm_material"):
50-
continue
51-
52-
with open(file_path, "r", encoding = "utf-8") as f:
53-
content = f.read()
54-
55-
guid = self._get_guid(content)
56-
if guid not in guid_dict:
57-
guid_dict[guid] = []
58-
59-
item_list = guid_dict[guid]
60-
item_list.append({"file_name": filename,
61-
"file_path": file_path})
62-
break
63-
64-
has_invalid_files = False
65-
for guid, file_item_list in guid_dict.items():
66-
if len(file_item_list) <= 1:
67-
continue
68-
has_invalid_files = True
69-
70-
if guid is not None:
71-
print("-> The following files contain the same GUID [%s]:" % guid)
72-
else:
73-
print("-> The following files DO NOT contain any GUID:")
74-
for file_item in file_item_list:
75-
print(" -- [%s]" % file_item["file_name"])
76-
print("-> PLEASE make sure to generate unique GUIDs for each material.")
77-
78-
return not has_invalid_files
18+
NAMESPACES = {
19+
"um": "http://www.ultimaker.com/material",
20+
"cura": "http://www.ultimaker.com/cura",
21+
}
7922

8023

81-
if __name__ == "__main__":
82-
script_dir = os.path.dirname(os.path.realpath(__file__))
83-
root_dir = os.path.abspath(os.path.join(script_dir, ".."))
24+
class MaterialProfile:
25+
26+
def __init__(self, document: etree.Element, filename: Optional[Path]) -> None:
27+
self.document = document
28+
self.filename = filename
29+
30+
@classmethod
31+
def fromFile(cls, p: Path) -> 'MaterialProfile':
32+
with p.open("rb") as f:
33+
return cls(etree.fromstring(f.read()), p)
34+
35+
@property
36+
def brand(self) -> Optional[str]:
37+
"""Get the material brand name from the given lxml.etree root node.
8438
85-
validator = MaterialProfilesValidator(root_dir)
86-
is_everything_validate = validator.validate()
39+
@returns None if the brand cannot be found, otherwise the brand text.
40+
"""
41+
node = self.document.xpath("./um:metadata/um:name/um:brand", namespaces=NAMESPACES)
42+
return node[0].text if node else None
8743

88-
ret_code = 0 if is_everything_validate else 1
89-
sys.exit(ret_code)
44+
@property
45+
def guid(self) -> Optional[str]:
46+
node = self.document.xpath("./um:metadata/um:GUID", namespaces=NAMESPACES)
47+
return node[0].text if node else None
48+
49+
50+
class MaterialProfilesValidator:
51+
"""Material Profile validator that validates against an XML Schema"""
52+
53+
class ValidationError(Exception):
54+
pass
55+
56+
def __init__(self, xsd_file: Path) -> None:
57+
self._schema = self.loadSchema(xsd_file)
58+
self._guids_seen: Dict[str, Path] = dict()
59+
60+
@staticmethod
61+
def loadSchema(xsd_file: Path) -> etree.XMLSchema:
62+
xmlschema_doc = etree.parse(str(xsd_file))
63+
return etree.XMLSchema(xmlschema_doc)
64+
65+
def validate(self, profile: MaterialProfile) -> None:
66+
"""Validate the given material profile file against the XML schema, plus additional rules
67+
68+
Additional rules:
69+
- Only material profile files whose name starts with "generic_" are allowed to have
70+
their brand set to "Generic".
71+
- The GUID in each profile should be unique; no other profiles can use the same GUID.
72+
73+
@raises ValidationError if any problems are found.
74+
"""
75+
# Validate the file content with the XSD file.
76+
try:
77+
self._schema.assertValid(profile.document)
78+
except etree.DocumentInvalid as e:
79+
raise self.ValidationError(f"{profile.filename} is not a valid FDM Material file:\n{e}")
80+
81+
# Make sure that only the material files such as "generic_<bla>" can have brand "Generic".
82+
brand = profile.brand
83+
if brand is None:
84+
raise self.ValidationError(f"{profile.filename} is missing '<brand>' information")
85+
elif len(brand) == 0:
86+
raise self.ValidationError(f"{profile.filename} contains empty '<brand>' information")
87+
elif brand.lower() == "generic" and not profile.filename.name.lower().startswith("generic_"):
88+
raise self.ValidationError(
89+
f"{profile.filename} contains a 'generic' brand, but only material profiles with a "
90+
f"filename that starts with 'generic_' are allowed to have a generic brand set.")
91+
92+
# Check that we haven't seen this GUID before.
93+
guid = profile.guid
94+
if not guid:
95+
raise self.ValidationError(f"{profile.filename} is missing '<GUID>' information")
96+
if guid in self._guids_seen and self._guids_seen.get(guid) != profile.filename:
97+
raise self.ValidationError(
98+
f"{profile.filename} has duplicate GUID '{guid}', the same GUID is also "
99+
f"used by: {self._guids_seen[guid]}")
100+
self._guids_seen[guid] = profile.filename
101+
102+
103+
def validateFiles(xsd_file: Path, files_to_check: Iterable[Path]) -> bool:
104+
"""Validate a given list of FDM material profile files using the given XSD schema file
105+
106+
@returns Whether all files were validated successfully
107+
"""
108+
validator = MaterialProfilesValidator(xsd_file)
109+
110+
n_success, n_fail = 0, 0
111+
112+
for f in files_to_check:
113+
try:
114+
logging.info(f"Checking {f.name}")
115+
profile = MaterialProfile.fromFile(f)
116+
validator.validate(profile)
117+
except etree.XMLSyntaxError as e:
118+
logging.error(f"{f} is not a valid XML file:\n{e}")
119+
n_fail += 1
120+
except MaterialProfilesValidator.ValidationError as e:
121+
logging.error(str(e))
122+
n_fail += 1
123+
else:
124+
n_success += 1
125+
126+
logging.info(f"Checked {n_success + n_fail} file(s): {n_success} OK, {n_fail} error(s)")
127+
return (n_fail == 0)
128+
129+
130+
def main():
131+
import argparse
132+
133+
SCRIPT_DIR = Path(__file__).parent.resolve()
134+
PROJECT_DIR = SCRIPT_DIR.parent
135+
136+
parser = argparse.ArgumentParser(
137+
description="Validator for Material Profile (*.xml.fdm_material) files")
138+
parser.add_argument("-x", "--xsd", default=(SCRIPT_DIR / "fdmmaterial.xsd"),
139+
help="XML Schema Definition file to use for validation (default: fdmmaterial.xsd)")
140+
parser.add_argument("file", metavar="FILE", nargs="*",
141+
help="One or more *.xml.fdm_material files to check. Default: all files in project root.")
142+
parser.add_argument("-v", "--verbose", action="store_true",
143+
help="Increase output verbosity.")
144+
args = parser.parse_args()
145+
146+
logging.basicConfig(
147+
format="%(levelname)s %(message)s",
148+
level=(logging.INFO if args.verbose else logging.WARNING))
149+
150+
if args.file:
151+
files_to_check = [Path(_) for _ in args.file]
152+
else:
153+
files_to_check = sorted(list(PROJECT_DIR.glob("*.fdm_material")))
154+
155+
success = validateFiles(args.xsd, files_to_check)
156+
sys.exit(0 if success else 1)
157+
158+
159+
if __name__ == "__main__":
160+
main()

0 commit comments

Comments
 (0)