Added a linting tool for Cura Printers and Profiles

printer-linter works of definitions, profiles and meshes;
It has various diagnostic checks. With possible suggestions for fixes.
It should also be able to fix certain diagnostic issues and it can be used
to format the files according to code-style.

It can output the diagnostics in a yaml file, which can then be used to comment
on PR's with suggestions to the author. Future PR.

The settings for the diagnostics and checks are defined in `.printer-linter`
and are very self explanatory.

```
checks:
    diagnostic-mesh-file-extension: true
    diagnostic-mesh-file-size: true
    diagnostic-definition-redundant-override: true
fixes:
    diagnostic-definition-redundant-override: true
format:
    format-definition-bracket-newline: false
    format-definition-paired-coordinate-array: true
    format-definition-sort-keys: true
    format-definition-indent: 4
    format-profile-space-around-delimiters: true
    format-profile-sort-keys: true
diagnostic-mesh-file-size: 1200000
```
This commit is contained in:
jspijker 2022-11-19 19:07:32 +01:00
parent fb69cff511
commit 104bc585f4
8 changed files with 388 additions and 0 deletions

14
.printer-linter Normal file
View File

@ -0,0 +1,14 @@
checks:
diagnostic-mesh-file-extension: true
diagnostic-mesh-file-size: true
diagnostic-definition-redundant-override: true
fixes:
diagnostic-definition-redundant-override: true
format:
format-definition-bracket-newline: false
format-definition-paired-coordinate-array: true
format-definition-sort-keys: true
format-definition-indent: 4
format-profile-space-around-delimiters: true
format-profile-sort-keys: true
diagnostic-mesh-file-size: 800000

View File

@ -0,0 +1,129 @@
import configparser
import json
import re
from argparse import ArgumentParser
from collections import OrderedDict
from os import getcwd
from pathlib import Path
import yaml
from tidy import create
def examineFile(file, settings):
patient = create(file, settings)
if patient is None:
return {}
full_body_check = {f"{file.as_posix()}": []}
for diagnostic in patient.check():
if diagnostic:
full_body_check[f"{file.as_posix()}"].append(diagnostic.toDict())
if len(full_body_check[f"{file.as_posix()}"]) == 0:
del full_body_check[f"{file.as_posix()}"]
return full_body_check
def fixFile(file, settings, full_body_check):
if not file.exists():
return
ext = ".".join(file.name.split(".")[-2:])
if ext == "def.json":
issues = full_body_check[f"{file.as_posix()}"]
for issue in issues:
if issue["diagnostic"] == "diagnostic-definition-redundant-override" and settings["fixes"].get(
"diagnostic-definition-redundant-override", True):
pass
def formatFile(file: Path, settings):
if not file.exists():
return
ext = ".".join(file.name.split(".")[-2:])
if ext == "def.json":
definition = json.loads(file.read_text())
content = json.dumps(definition, indent=settings["format"].get("format-definition-indent", 4),
sort_keys=settings["format"].get("format-definition-sort-keys", True))
if settings["format"].get("format-definition-bracket-newline", True):
newline = re.compile(r"(\B\s+)(\"[\w\"]+)(\:\s\{)")
content = newline.sub(r"\1\2:\1{", content)
if settings["format"].get("format-definition-paired-coordinate-array", True):
paired_coordinates = re.compile(r"(\[)\s+(-?\d*),\s*(-?\d*)\s*(\])")
content = paired_coordinates.sub(r"\1 \2, \3 \4", content)
file.write_text(content)
if ext == "inst.cfg":
config = configparser.ConfigParser()
config.read(file)
if settings["format"].get("format-profile-sort-keys", True):
for section in config._sections:
config._sections[section] = OrderedDict(sorted(config._sections[section].items(), key=lambda t: t[0]))
config._sections = OrderedDict(sorted(config._sections.items(), key=lambda t: t[0]))
with open(file, "w") as f:
config.write(f, space_around_delimiters=settings["format"].get("format-profile-space-around-delimiters", True))
def main(files, setting_path, to_format, to_fix, report):
if not setting_path:
setting_path = Path(getcwd(), ".printer-linter")
if not setting_path.exists():
print(f"Can't find the settings: {setting_path}")
return
with open(setting_path, "r") as f:
settings = yaml.load(f, yaml.FullLoader)
full_body_check = {}
for file in files:
if file.is_dir():
for fp in file.rglob("**/*"):
full_body_check |= examineFile(fp, settings)
else:
full_body_check |= examineFile(file, settings)
results = yaml.dump(full_body_check, default_flow_style=False, indent=4, width=240)
if report:
report.write_text(results)
else:
print(results)
if to_fix:
for file in files:
if file.is_dir():
for fp in file.rglob("**/*"):
if f"{file.as_posix()}" in full_body_check:
fixFile(fp, settings, full_body_check)
else:
if f"{file.as_posix()}" in full_body_check:
fixFile(file, settings, full_body_check)
if to_format:
for file in files:
if file.is_dir():
for fp in file.rglob("**/*"):
formatFile(fp, settings)
else:
formatFile(file, settings)
if __name__ == "__main__":
parser = ArgumentParser(
description="UltiMaker Cura printer linting, static analysis and formatting of Cura printer definitions and other resources")
parser.add_argument("--setting", required=False, type=Path, help="Path to the `.printer-linter` setting file")
parser.add_argument("--report", required=False, type=Path, help="Path where the diagnostic report should be stored")
parser.add_argument("--format", action="store_true", help="Format the files")
parser.add_argument("--fix", action="store_true", help="Attempt to apply the suggested fixes on the files")
parser.add_argument("Files", metavar="F", type=Path, nargs="+", help="Files or directories to format")
args = parser.parse_args()
main(args.Files, args.setting, args.format, args.fix, args.report)

View File

@ -0,0 +1,20 @@
from .defintion import Definition
from .diagnostic import Diagnostic
from .meshes import Meshes
from .profile import Profile
__all__ = ["Profile", "Definition", "Meshes", "Diagnostic", "create"]
def create(file, settings):
if not file.exists():
return None
if ".inst" in file.suffixes and ".cfg" in file.suffixes:
return Profile(file, settings)
if ".def" in file.suffixes and ".json" in file.suffixes:
if file.stem in ("fdmprinter.def", "fdmextruder.def"):
return None
return Definition(file, settings)
if file.parent.stem == "meshes":
return Meshes(file, settings)
return None

View File

@ -0,0 +1,96 @@
import json
from pathlib import Path
from .diagnostic import Diagnostic
class Definition:
def __init__(self, file, settings):
self._settings = settings
self._file = file
self._defs = {}
self._getDefs(file)
settings = {}
for k, v in self._defs["fdmprinter"]["settings"].items():
self._getSetting(k, v, settings)
self._defs["fdmprinter"] = {"overrides": settings}
def check(self):
if self._settings["checks"].get("diagnostic-definition-redundant-override", False):
for check in self.checkRedefineOverride():
yield check
# Add other which will yield Diagnostic's
# TODO: A check to determine if the user set value is with the min and max value defined in the parent and doesn't trigger a warning
# TODO: A check if the key exist in the first place
# TODO: Check if the model platform exist
yield
def checkRedefineOverride(self):
definition_name = list(self._defs.keys())[0]
definition = self._defs[definition_name]
if "overrides" in definition and definition_name != "fdmprinter":
keys = list(definition["overrides"].keys())
for key, value_dict in definition["overrides"].items():
is_redefined, value, parent = self._isDefinedInParent(key, value_dict, definition['inherits'])
if is_redefined:
termination_key = keys.index(key) + 1
if termination_key >= len(keys):
# FIXME: find the correct end sequence for now assume it is on the same line
termination_seq = None
else:
termination_seq = keys[termination_key]
yield Diagnostic("diagnostic-definition-redundant-override",
f"Overriding **{key}** with the same value (**{value}**) as defined in parent definition: **{definition['inherits']}**",
self._file,
key,
termination_seq)
def checkValueOutOfBounds(self):
pass
def _getSetting(self, name, setting, settings):
if "children" in setting:
for childname, child in setting["children"].items():
self._getSetting(childname, child, settings)
settings |= {name: setting}
def _getDefs(self, file):
if not file.exists():
return
self._defs[Path(file.stem).stem] = json.loads(file.read_text())
if "inherits" in self._defs[Path(file.stem).stem]:
parent_file = file.parent.joinpath(f"{self._defs[Path(file.stem).stem]['inherits']}.def.json")
self._getDefs(parent_file)
def _isDefinedInParent(self, key, value_dict, inherits_from):
if "overrides" not in self._defs[inherits_from]:
return self._isDefinedInParent(key, value_dict, self._defs[inherits_from]["inherits"])
parent = self._defs[inherits_from]["overrides"]
is_number = self._defs["fdmprinter"]["overrides"][key] in ("float", "int")
for value in value_dict.values():
if key in parent:
check_values = [cv for cv in [parent[key].get("default_value", None), parent[key].get("value", None)] if cv is not None]
for check_value in check_values:
if is_number:
try:
v = str(float(value))
except:
v = value
try:
cv = str(float(check_value))
except:
cv = check_value
else:
v = value
cv = check_value
if v == cv:
return True, value, parent
if "inherits" in parent:
return self._isDefinedInParent(key, value_dict, parent["inherits"])
return False, None, None

View File

@ -0,0 +1,87 @@
class Diagnostic:
def __init__(self, illness, msg, file, key=None, termination_seq=None):
self.illness = illness
self.key = key
self.msg = msg
self.file = file
self._lines = None
self._location = None
self._fix = None
self._content_block = None
self._termination_seq = termination_seq
@property
def location(self):
if self._location:
return self._location
if not self._lines:
with open(self.file, "r") as f:
if not self.is_text_file:
self._fix = ""
return self._fix
self._lines = f.readlines()
start_location = {"col": 1, "line": 1}
end_location = {"col": len(self._lines[-1]) + 1, "line": len(self._lines) + 1}
if self.key is not None:
for lino, line in enumerate(self._lines, 1):
if f'"{self.key}":' in line:
col = line.index(f'"{self.key}":') + 1
start_location = {"col": col, "line": lino}
if self._termination_seq is None:
end_location = {"col": len(line) + 1, "line": lino}
break
if f'"{self._termination_seq}":' in line:
col = line.index(f'"{self._termination_seq}":') + 1
end_location = {"col": col, "line": lino}
self._location = {"start": start_location, "end": end_location}
return self._location
@property
def is_text_file(self):
return self.file.name.split(".", maxsplit=1)[-1] in ("def.json", "inst.cfg")
@property
def content_block(self):
if self._content_block:
return self._content_block
if not self._lines:
if not self.is_text_file:
self._fix = ""
return self._fix
with open(self.file, "r") as f:
self._lines = f.readlines()
start_line = self.location["start"]["line"]
start_col = self.location["start"]["col"]
end_line = self.location["end"]["line"]
end_col = len(self._lines[start_line:end_line - 1]) + self.location["start"]["col"]
self._content_block = "".join(self._lines[start_line:end_line])
return self._content_block
@property
def fix(self):
if self._fix:
return self._fix
if not self._lines:
if not self.is_text_file:
self._fix = ""
return self._fix
with open(self.file, "r") as f:
self._lines = f.readlines()
start_line = self.location["start"]["line"]
start_col = self.location["start"]["col"]
end_line = self.location["end"]["line"]
end_col = len(self._lines[start_line:end_line - 1]) + self.location["start"]["col"]
self._fix = self.content_block[start_col:end_col]
return self._fix
def toDict(self):
diagnostic_dict = {"diagnostic": self.illness, "message": self.msg}
if self.is_text_file:
diagnostic_dict |= {"fix": self.fix, "lino": self.location, "content": self.content_block}
return diagnostic_dict

View File

@ -0,0 +1,34 @@
from .diagnostic import Diagnostic
class Meshes:
def __init__(self, file, settings):
self._settings = settings
self._file = file
self._max_file_size = self._settings.get("diagnostic-mesh-file-size", 1e6)
def check(self):
if self._settings["checks"].get("diagnostic-mesh-file-extension", False):
for check in self.checkFileFormat():
yield check
if self._settings["checks"].get("diagnostic-mesh-file-size", False):
for check in self.checkFileSize():
yield check
yield
def checkFileFormat(self):
if self._file.suffix.lower() not in (".3mf", ".obj", ".stl"):
yield Diagnostic("diagnostic-mesh-file-extension",
f"Extension **{self._file.suffix}** not supported, use **3mf**, **obj** or **stl**",
self._file)
yield
def checkFileSize(self):
if self._file.stat().st_size > self._max_file_size:
yield Diagnostic("diagnostic-mesh-file-size",
f"Mesh file with a size **{self._file.stat().st_size}** is bigger then allowed maximum of **{self._max_file_size}**",
self._file)
yield

View File

@ -0,0 +1,7 @@
class Profile:
def __init__(self, file, settings):
self._settings = settings
self._file = file
def check(self):
yield

View File

@ -1,5 +1,6 @@
pytest
pyinstaller
pyinstaller-hooks-contrib
pyyaml
sip==6.5.1
jinja2