diff --git a/.github/workflows/printer-linter-format.yml b/.github/workflows/printer-linter-format.yml new file mode 100644 index 0000000000..9fb5a1c584 --- /dev/null +++ b/.github/workflows/printer-linter-format.yml @@ -0,0 +1,45 @@ +name: printer-linter-format + +on: + push: + branches: + - main + - '[1-9].[0-9]' + - '[1-9].[0-9][0-9]' + path: + - 'resources/**' + +jobs: + printer-linter-format: + name: Printer linter auto format + + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Python and pip + uses: actions/setup-python@v4 + with: + python-version: 3.11.x + cache: 'pip' + cache-dependency-path: .github/workflows/requirements-printer-linter.txt + + - uses: technote-space/get-diff-action@v6 + with: + PATTERNS: | + resources/+(definitions|extruders)/*.def.json + resources/+(intent|quality|variants)/**/*.inst.cfg + + - name: Install Python requirements for runner + if: env.GIT_DIFF && !env.MATCHED_FILES + run: pip install -r .github/workflows/requirements-printer-linter.txt + + - name: Format file + if: env.GIT_DIFF && !env.MATCHED_FILES + run: python printer-linter/src/terminal.py --format ${{ env.GIT_DIFF_FILTERED }} + + - uses: stefanzweifel/git-auto-commit-action@v4 + if: env.GIT_DIFF && !env.MATCHED_FILES + with: + commit_message: "Applied printer-linter format" diff --git a/.github/workflows/printer-linter-pr-diagnose.yml b/.github/workflows/printer-linter-pr-diagnose.yml new file mode 100644 index 0000000000..b218ebe623 --- /dev/null +++ b/.github/workflows/printer-linter-pr-diagnose.yml @@ -0,0 +1,59 @@ +name: printer-linter-pr-diagnose + +on: + pull_request: + path: + - 'resources/**' + +jobs: + printer-linter-diagnose: + name: Printer linter PR diagnose + + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Setup Python and pip + uses: actions/setup-python@v4 + with: + python-version: 3.11.x + cache: 'pip' + cache-dependency-path: .github/workflows/requirements-printer-linter.txt + + - uses: technote-space/get-diff-action@v6 + with: + PATTERNS: | + resources/+(extruders|definitions)/*.def.json + resources/+(intent|quality|variants)/**/*.inst.cfg + + - name: Install Python requirements for runner + if: env.GIT_DIFF && !env.MATCHED_FILES + run: pip install -r .github/workflows/requirements-printer-linter.txt + + - name: Create results directory + run: mkdir printer-linter-result + + - name: Diagnose file(s) + if: env.GIT_DIFF && !env.MATCHED_FILES + run: python printer-linter/src/terminal.py --diagnose --report printer-linter-result/fixes.yml ${{ env.GIT_DIFF_FILTERED }} + + - name: Save PR metadata + run: | + echo ${{ github.event.number }} > printer-linter-result/pr-id.txt + echo ${{ github.event.pull_request.head.repo.full_name }} > printer-linter-result/pr-head-repo.txt + echo ${{ github.event.pull_request.head.ref }} > printer-linter-result/pr-head-ref.txt + + - uses: actions/upload-artifact@v2 + with: + name: printer-linter-result + path: printer-linter-result/ + + - name: Run clang-tidy-pr-comments action + uses: platisd/clang-tidy-pr-comments@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + clang_tidy_fixes: result.yml + request_changes: true diff --git a/.github/workflows/printer-linter-pr-post.yml b/.github/workflows/printer-linter-pr-post.yml new file mode 100644 index 0000000000..f47e22c5c5 --- /dev/null +++ b/.github/workflows/printer-linter-pr-post.yml @@ -0,0 +1,80 @@ +name: printer-linter-pr-post + +on: + workflow_run: + workflows: [ "printer-linter-pr-diagnose" ] + types: [ completed ] + +jobs: + clang-tidy-results: + # Trigger the job only if the previous (insecure) workflow completed successfully + if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + steps: + - name: Download analysis results + uses: actions/github-script@v3.1.0 + with: + script: | + let artifacts = await github.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{github.event.workflow_run.id }}, + }); + let matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "printer-linter-result" + })[0]; + let download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: "zip", + }); + let fs = require("fs"); + fs.writeFileSync("${{github.workspace}}/printer-linter-result.zip", Buffer.from(download.data)); + + - name: Set environment variables + run: | + mkdir printer-linter-result + unzip printer-linter-result.zip -d printer-linter-result + echo "pr_id=$(cat printer-linter-result/pr-id.txt)" >> $GITHUB_ENV + echo "pr_head_repo=$(cat printer-linter-result/pr-head-repo.txt)" >> $GITHUB_ENV + echo "pr_head_ref=$(cat printer-linter-result/pr-head-ref.txt)" >> $GITHUB_ENV + + - uses: actions/checkout@v2 + with: + repository: ${{ env.pr_head_repo }} + ref: ${{ env.pr_head_ref }} + persist-credentials: false + + - name: Redownload analysis results + uses: actions/github-script@v3.1.0 + with: + script: | + let artifacts = await github.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{github.event.workflow_run.id }}, + }); + let matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "printer-linter-result" + })[0]; + let download = await github.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: "zip", + }); + let fs = require("fs"); + fs.writeFileSync("${{github.workspace}}/printer-linter-result.zip", Buffer.from(download.data)); + + - name: Extract analysis results + run: | + mkdir printer-linter-result + unzip printer-linter-result.zip -d printer-linter-result + + - name: Run clang-tidy-pr-comments action + uses: platisd/clang-tidy-pr-comments@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + clang_tidy_fixes: printer-linter-result/fixes.yml + pull_request_id: ${{ env.pr_id }} diff --git a/.github/workflows/requirements-printer-linter.txt b/.github/workflows/requirements-printer-linter.txt new file mode 100644 index 0000000000..4818cc5419 --- /dev/null +++ b/.github/workflows/requirements-printer-linter.txt @@ -0,0 +1 @@ +pyyaml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 26fe1ccf4a..1e8fd47664 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,4 @@ conanbuildinfo.txt graph_info.json Ultimaker-Cura.spec .run/ +/printer-linter/src/printerlinter.egg-info/ diff --git a/.printer-linter b/.printer-linter new file mode 100644 index 0000000000..f9f105e1f7 --- /dev/null +++ b/.printer-linter @@ -0,0 +1,15 @@ +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: true + format-definition-paired-coordinate-array: true + format-definition-sort-keys: true + format-definition-indent: 4 + format-definition-single-value-single-line: true # Format dicts and lists with a single item on one line "dict": { "value": 10 } + format-profile-space-around-delimiters: true + format-profile-sort-keys: true +diagnostic-mesh-file-size: 1200000 \ No newline at end of file diff --git a/printer-linter/README.md b/printer-linter/README.md new file mode 100644 index 0000000000..fc6a9a8e29 --- /dev/null +++ b/printer-linter/README.md @@ -0,0 +1,33 @@ +# Printer Linter +Printer linter is a python package that does linting on Cura definitions files. +Running this on your definition files will get them ready for a pull request. + +## Running Locally +From the Cura root folder. + +```python3 printer-linter/src/terminal.py "flashforge_dreamer_nx.def.json" "flashforge_base.def.json" --fix --format``` + +## Developing +### Printer Linter Rules +Inside ```.printer-linter``` you can find a list of rules. These are seperated into roughly three categories. + +1. Checks + 1. These rules are about checking if a file meets some requirements that can't be fixed by replacing its content. + 2. An example of a check is ```diagnostic-mesh-file-extension``` this checks if a mesh file extension is acceptable. +2. Format + 1. These rules are purely about how a file is structured, not content. + 2. An example of a format rule is ```format-definition-bracket-newline``` This rule says that when assigning a dict value the bracket should go on a new line. +3. Fixes + 1. These are about the content of the file. + 2. An example of a fix is ```diagnostic-definition-redundant-override``` This removes settings that have already been defined by a parent definition + +### Linters +Linters find issues within a file. There are separate linters for each type of file. The linter that is used is decided by the create function in factory.py. All linters implement the abstract class Linter. + +A Linter class returns an iterator of Diagnostics, each diagnostic is an issue with the file. The diagnostics can also contain suggested fixes. + +### Formatters +Formatters load a file reformat it and write it to disk. There are separate formatters for each file type. All formatters implement the abstract class Formatter. + +Formatters should format based on the Format rules in .printer-linter + diff --git a/printer-linter/pyproject.toml b/printer-linter/pyproject.toml new file mode 100644 index 0000000000..74c6531c87 --- /dev/null +++ b/printer-linter/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "printerlinter" +description = "Cura UltiMaker printer linting tool" +version = "0.1.0" +authors = [ + { name = "UltiMaker", email = "cura@ultimaker.com" } +] +dependencies = [ + "pyyaml" +] + +[project.scripts] +printer-linter = "terminal:main" + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" \ No newline at end of file diff --git a/printer-linter/setup.cfg b/printer-linter/setup.cfg new file mode 100644 index 0000000000..68b0484162 --- /dev/null +++ b/printer-linter/setup.cfg @@ -0,0 +1,10 @@ +[metadata] +name = printerlinter + +[options] +package_dir= + =src +packages=find: + +[options.packages.find] +where=src \ No newline at end of file diff --git a/printer-linter/setup.py b/printer-linter/setup.py new file mode 100644 index 0000000000..25536050b2 --- /dev/null +++ b/printer-linter/setup.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python + +from setuptools import setup + +if __name__ == "__main__": + setup() diff --git a/printer-linter/src/printerlinter/__init__.py b/printer-linter/src/printerlinter/__init__.py new file mode 100644 index 0000000000..3ec571c3c6 --- /dev/null +++ b/printer-linter/src/printerlinter/__init__.py @@ -0,0 +1,4 @@ +from .diagnostic import Diagnostic +from .factory import getLinter + +__all__ = ["Diagnostic", "getLinter"] diff --git a/printer-linter/src/printerlinter/diagnostic.py b/printer-linter/src/printerlinter/diagnostic.py new file mode 100644 index 0000000000..27f4fdd14a --- /dev/null +++ b/printer-linter/src/printerlinter/diagnostic.py @@ -0,0 +1,34 @@ +from pathlib import Path +from typing import Optional, List, Dict, Any + +from .replacement import Replacement + + +class Diagnostic: + def __init__(self, file: Path, diagnostic_name: str, message: str, level: str, offset: int, replacements: Optional[List[Replacement]] = None) -> None: + """ A diagnosis of an issue in "file" at "offset" in that file. May include suggested replacements. + + @param file: The path to the file this diagnostic is for. + @param diagnostic_name: The name of the diagnostic rule that spawned this result. A list can be found in .printer-linter. + @param message: A message explaining the issue with this file. + @param level: How important this diagnostic is, ranges from Warning -> Error. + @param offset: The offset in file where the issue is. + @param replacements: A list of Replacement that contain replacement text. + """ + self.file = file + self.diagnostic_name = diagnostic_name + self.message = message + self.offset = offset + self.level = level + self.replacements = replacements + + def toDict(self) -> Dict[str, Any]: + return {"DiagnosticName": self.diagnostic_name, + "DiagnosticMessage": { + "Message": self.message, + "FilePath": self.file.as_posix(), + "FileOffset": self.offset, + "Replacements": [] if self.replacements is None else [r.toDict() for r in self.replacements], + }, + "Level": self.level + } diff --git a/printer-linter/src/printerlinter/factory.py b/printer-linter/src/printerlinter/factory.py new file mode 100644 index 0000000000..d27f82244b --- /dev/null +++ b/printer-linter/src/printerlinter/factory.py @@ -0,0 +1,26 @@ +from pathlib import Path +from typing import Optional + +from .linters.profile import Profile +from .linters.defintion import Definition +from .linters.linter import Linter +from .linters.meshes import Meshes + + +def getLinter(file: Path, settings: dict) -> Optional[Linter]: + """ Returns a Linter depending on the file format """ + 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 diff --git a/printer-linter/src/printerlinter/formatters/__init__.py b/printer-linter/src/printerlinter/formatters/__init__.py new file mode 100644 index 0000000000..00d9c7eef3 --- /dev/null +++ b/printer-linter/src/printerlinter/formatters/__init__.py @@ -0,0 +1,4 @@ +from .def_json_formatter import DefJsonFormatter +from .inst_cfg_formatter import InstCfgFormatter + +__all__ = ["DefJsonFormatter", "InstCfgFormatter"] \ No newline at end of file diff --git a/printer-linter/src/printerlinter/formatters/def_json_formatter.py b/printer-linter/src/printerlinter/formatters/def_json_formatter.py new file mode 100644 index 0000000000..f99fe5bfb4 --- /dev/null +++ b/printer-linter/src/printerlinter/formatters/def_json_formatter.py @@ -0,0 +1,84 @@ +import json +import re +from collections import OrderedDict +from pathlib import Path +from typing import Dict + +from .formatter import FileFormatter + + +# Dictionary items with matching keys will be sorted as if they were the value +# Example: "version" will be sorted as if it was "0" +TOP_LEVEL_SORT_PRIORITY = { + "version": "0", + "name": "1", + "inherits": "3", +} + +METADATA_SORT_PRIORITY = { + "visible": "0", + "author": "1", + "manufacturer": "2", + "file_formats": "3", + "platform": "4", +} + + +class DefJsonFormatter(FileFormatter): + def formatFile(self, file: Path): + """ Format .def.json files according to the rules in settings. + + You can assume that you will be running regex on standard formatted json files, because we load the json first and then + dump it to a string. This means you only have to write regex that works on the output of json.dump() + """ + + definition = json.loads(file.read_text(), object_pairs_hook=OrderedDict) + + if self._settings["format"].get("format-definition-sort-keys", True): + definition = self.order_keys(definition) + + content = json.dumps(definition, indent=self._settings["format"].get("format-definition-indent", 4)) + + if self._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 self._settings["format"].get("format-definition-single-value-single-line", True): + single_value_dict = re.compile(r"(:)(\s*\n?.*\{\s+)(\".*)(\d*\s*\})(.*\n,?)") + content = single_value_dict.sub(r"\1 { \3 }\5", content) + + single_value_list = re.compile(r"(:)(\s*\n?.*\[\s+)(\".*)(\d*\s*\])(.*\n,?)") + content = single_value_list.sub(r"\1 [ \3 ]\5", content) + + if self._settings["format"].get("format-definition-paired-coordinate-array", True): + paired_coordinates = re.compile(r"(\s*\[)\s*([-\d\.]+),\s*([-\d\.]+)[\s]*(\])") + content = paired_coordinates.sub(r"\1\2, \3\4", content) + + file.write_text(content) + + def order_keys(self, json_content: OrderedDict) -> OrderedDict: + """ Orders json keys lexicographically """ + # First order all keys (Recursive) lexicographically + json_content_text = json.dumps(json_content, sort_keys=True) + json_content = json.loads(json_content_text, object_pairs_hook=OrderedDict) + + # Do a custom ordered sort on the top level items in the json. This is so that keys like "version" appear at the top. + json_content = self.custom_sort_keys(json_content, TOP_LEVEL_SORT_PRIORITY) + + # Do a custom ordered sort on collections that are one level deep into the json + if "metadata" in json_content.keys(): + json_content["metadata"] = self.custom_sort_keys(json_content["metadata"], METADATA_SORT_PRIORITY) + + return json_content + + + def custom_sort_keys(self, ordered_dictionary: OrderedDict, sort_priority: Dict[str, str]) -> OrderedDict: + """ Orders keys in dictionary lexicographically, except for keys with matching strings in sort_priority. + + Keys in ordered_dictionary that match keys in sort_priority will sort based on the value in sort_priority. + + @param ordered_dictionary: A dictionary that will have it's top level keys sorted + @param sort_priority: A mapping from string keys to alternative strings to be used instead when sorting. + @return: A dictionary sorted by it's top level keys + """ + return OrderedDict(sorted(ordered_dictionary.items(), key=lambda x: sort_priority[x[0]] if str(x[0]) in sort_priority.keys() else str(x[0]))) diff --git a/printer-linter/src/printerlinter/formatters/formatter.py b/printer-linter/src/printerlinter/formatters/formatter.py new file mode 100644 index 0000000000..c668744f42 --- /dev/null +++ b/printer-linter/src/printerlinter/formatters/formatter.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod +from pathlib import Path + + +class FileFormatter(ABC): + def __init__(self, settings: dict) -> None: + """ Yields Diagnostics for file, these are issues with the file such as bad text format or too large file size. + + @param file: A file to generate diagnostics for + @param settings: A list of settings containing rules for creating diagnostics + """ + self._settings = settings + + @abstractmethod + def formatFile(self, file: Path) -> None: + pass \ No newline at end of file diff --git a/printer-linter/src/printerlinter/formatters/inst_cfg_formatter.py b/printer-linter/src/printerlinter/formatters/inst_cfg_formatter.py new file mode 100644 index 0000000000..c4113bcb48 --- /dev/null +++ b/printer-linter/src/printerlinter/formatters/inst_cfg_formatter.py @@ -0,0 +1,21 @@ +import configparser +import json +import re +from collections import OrderedDict +from pathlib import Path + +from .formatter import FileFormatter + +class InstCfgFormatter(FileFormatter): + def formatFile(self, file: Path): + """ Format .inst.cfg files according to the rules in settings """ + config = configparser.ConfigParser() + config.read(file) + + if self._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=self._settings["format"].get("format-profile-space-around-delimiters", True)) \ No newline at end of file diff --git a/printer-linter/src/printerlinter/linters/__init__.py b/printer-linter/src/printerlinter/linters/__init__.py new file mode 100644 index 0000000000..a4a48acb3d --- /dev/null +++ b/printer-linter/src/printerlinter/linters/__init__.py @@ -0,0 +1,6 @@ +from .profile import Profile +from .meshes import Meshes +from .linter import Linter +from .defintion import Definition + +__all__ = ["Profile", "Meshes", "Linter", "Definition"] \ No newline at end of file diff --git a/printer-linter/src/printerlinter/linters/defintion.py b/printer-linter/src/printerlinter/linters/defintion.py new file mode 100644 index 0000000000..dc272ccd9c --- /dev/null +++ b/printer-linter/src/printerlinter/linters/defintion.py @@ -0,0 +1,122 @@ +import json +import re +from pathlib import Path +from typing import Iterator + +from ..diagnostic import Diagnostic +from .linter import Linter +from ..replacement import Replacement + + +class Definition(Linter): + """ Finds issues in definition files, such as overriding default parameters """ + def __init__(self, file: Path, settings: dict) -> None: + super().__init__(file, settings) + self._definitions = {} + self._loadDefinitionFiles(file) + self._content = self._file.read_text() + self._loadBasePrinterSettings() + + @property + def base_def(self): + if "fdmextruder" in self._definitions: + return "fdmextruder" + return "fdmprinter" + + def check(self) -> Iterator[Diagnostic]: + 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) -> Iterator[Diagnostic]: + """ Checks if definition file overrides its parents settings with the same value. """ + definition_name = list(self._definitions.keys())[0] + definition = self._definitions[definition_name] + if "overrides" in definition and definition_name not in ("fdmprinter", "fdmextruder"): + for key, value_dict in definition["overrides"].items(): + is_redefined, value, parent = self._isDefinedInParent(key, value_dict, definition['inherits']) + if is_redefined: + redefined = re.compile(r'.*(\"' + key + r'\"[\s\S]*?\{)[\s\S]*?(\}[,\"]?)') + found = redefined.search(self._content) + yield Diagnostic( + file = self._file, + diagnostic_name = "diagnostic-definition-redundant-override", + message = f"Overriding {key} with the same value ({value}) as defined in parent definition: {definition['inherits']}", + level = "Warning", + offset = found.span(0)[0], + replacements = [Replacement( + file = self._file, + offset = found.span(1)[0], + length = found.span(2)[1] - found.span(1)[0], + replacement_text = "")] + ) + + def _loadDefinitionFiles(self, definition_file) -> None: + """ Loads definition file contents into self._definitions. Also load parent definition if it exists. """ + definition_name = Path(definition_file.stem).stem + + if not definition_file.exists() or definition_name in self._definitions: + return + + # Load definition file into dictionary + self._definitions[definition_name] = json.loads(definition_file.read_text()) + + # Load parent definition if it exists + if "inherits" in self._definitions[definition_name]: + if self._definitions[definition_name]['inherits'] in ("fdmextruder", "fdmprinter"): + parent_file = definition_file.parent.parent.joinpath("definitions", f"{self._definitions[definition_name]['inherits']}.def.json") + else: + parent_file = definition_file.parent.joinpath(f"{self._definitions[definition_name]['inherits']}.def.json") + self._loadDefinitionFiles(parent_file) + + def _isDefinedInParent(self, key, value_dict, inherits_from): + if "overrides" not in self._definitions[inherits_from]: + return self._isDefinedInParent(key, value_dict, self._definitions[inherits_from]["inherits"]) + + parent = self._definitions[inherits_from]["overrides"] + if key not in self._definitions[self.base_def]["overrides"]: + is_number = False + else: + is_number = self._definitions[self.base_def]["overrides"][key]["type"] 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 + + def _loadBasePrinterSettings(self): + """ TODO @Jelle please explain why this """ + settings = {} + for k, v in self._definitions[self.base_def]["settings"].items(): + self._getSetting(k, v, settings) + self._definitions[self.base_def] = {"overrides": settings} + + def _getSetting(self, name, setting, settings) -> None: + if "children" in setting: + for childname, child in setting["children"].items(): + self._getSetting(childname, child, settings) + settings |= {name: setting} diff --git a/printer-linter/src/printerlinter/linters/linter.py b/printer-linter/src/printerlinter/linters/linter.py new file mode 100644 index 0000000000..fbcd91196d --- /dev/null +++ b/printer-linter/src/printerlinter/linters/linter.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Iterator + +from ..diagnostic import Diagnostic + + +class Linter(ABC): + def __init__(self, file: Path, settings: dict) -> None: + """ Yields Diagnostics for file, these are issues with the file such as bad text format or too large file size. + + @param file: A file to generate diagnostics for + @param settings: A list of settings containing rules for creating diagnostics + """ + self._settings = settings + self._file = file + + @abstractmethod + def check(self) -> Iterator[Diagnostic]: + pass \ No newline at end of file diff --git a/printer-linter/src/printerlinter/linters/meshes.py b/printer-linter/src/printerlinter/linters/meshes.py new file mode 100644 index 0000000000..d49caf7dc9 --- /dev/null +++ b/printer-linter/src/printerlinter/linters/meshes.py @@ -0,0 +1,47 @@ +from pathlib import Path +from typing import Iterator + +from ..diagnostic import Diagnostic +from .linter import Linter + + +class Meshes(Linter): + def __init__(self, file: Path, settings: dict) -> None: + """ Finds issues in model files, such as incorrect file format or too large size """ + super().__init__(file, settings) + self._max_file_size = self._settings.get("diagnostic-mesh-file-size", 1e6) + + def check(self) -> Iterator[Diagnostic]: + 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) -> Iterator[Diagnostic]: + """ Check if mesh is in supported format """ + if self._file.suffix.lower() not in (".3mf", ".obj", ".stl"): + yield Diagnostic( + file = self._file, + diagnostic_name = "diagnostic-mesh-file-extension", + message = f"Extension {self._file.suffix} not supported, use 3mf, obj or stl", + level = "Error", + offset = 1 + ) + yield + + def checkFileSize(self) -> Iterator[Diagnostic]: + """ Check if file is within size limits for Cura """ + if self._file.stat().st_size > self._max_file_size: + yield Diagnostic( + file = self._file, + diagnostic_name = "diagnostic-mesh-file-size", + message = f"Mesh file with a size {self._file.stat().st_size} is bigger then allowed maximum of {self._max_file_size}", + level = "Error", + offset = 1 + ) + yield diff --git a/printer-linter/src/printerlinter/linters/profile.py b/printer-linter/src/printerlinter/linters/profile.py new file mode 100644 index 0000000000..85cc2d9f0b --- /dev/null +++ b/printer-linter/src/printerlinter/linters/profile.py @@ -0,0 +1,9 @@ +from typing import Iterator + +from ..diagnostic import Diagnostic +from .linter import Linter + + +class Profile(Linter): + def check(self) -> Iterator[Diagnostic]: + yield diff --git a/printer-linter/src/printerlinter/replacement.py b/printer-linter/src/printerlinter/replacement.py new file mode 100644 index 0000000000..b9f390107d --- /dev/null +++ b/printer-linter/src/printerlinter/replacement.py @@ -0,0 +1,21 @@ +from pathlib import Path + +class Replacement: + def __init__(self, file: Path, offset: int, length: int, replacement_text: str): + """ Replacement text for file between offset and offset+length. + + @param file: File to replace text in + @param offset: Offset in file to start text replace + @param length: Length of text that will be replaced. offset -> offset+length is the section of text to replace. + @param replacement_text: Text to insert of offset in file. + """ + self.file = file + self.offset = offset + self.length = length + self.replacement_text = replacement_text + + def toDict(self) -> dict: + return {"FilePath": self.file.as_posix(), + "Offset": self.offset, + "Length": self.length, + "ReplacementText": self.replacement_text} diff --git a/printer-linter/src/terminal.py b/printer-linter/src/terminal.py new file mode 100644 index 0000000000..6e6d1af4e5 --- /dev/null +++ b/printer-linter/src/terminal.py @@ -0,0 +1,117 @@ +from argparse import ArgumentParser +from os import getcwd +from pathlib import Path +from typing import List + +import yaml + +from printerlinter import factory +from printerlinter.diagnostic import Diagnostic +from printerlinter.formatters.def_json_formatter import DefJsonFormatter +from printerlinter.formatters.inst_cfg_formatter import InstCfgFormatter + + +def main() -> None: + 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("--diagnose", action="store_true", help="Diagnose 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() + files = extractFilePaths(args.Files) + setting_path = args.setting + to_format = args.format + to_fix = args.fix + to_diagnose = args.diagnose + report = args.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 = {"Diagnostics": []} + + if to_fix or to_diagnose: + for file in files: + diagnostics = diagnoseIssuesWithFile(file, settings) + full_body_check["Diagnostics"].extend([d.toDict() for d in diagnostics]) + + 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 f"{file.as_posix()}" in full_body_check: + applyFixesToFile(file, settings, full_body_check) + + if to_format: + for file in files: + applyFormattingToFile(file, settings) + + +def diagnoseIssuesWithFile(file: Path, settings: dict) -> List[Diagnostic]: + """ For file, runs all diagnostic checks in settings and returns a list of diagnostics """ + linter = factory.getLinter(file, settings) + + if not linter: + return [] + + return list(filter(lambda d: d is not None, linter.check())) + + +def applyFixesToFile(file, settings, full_body_check) -> None: + 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 applyFormattingToFile(file: Path, settings) -> None: + if not file.exists(): + return + + ext = ".".join(file.name.split(".")[-2:]) + + if ext == "def.json": + formatter = DefJsonFormatter(settings) + formatter.formatFile(file) + + if ext == "inst.cfg": + formatter = InstCfgFormatter(settings) + formatter.formatFile(file) + + +def extractFilePaths(paths: List[Path]) -> List[Path]: + """ Takes list of files and directories, returns the files as well as all files within directories as a List """ + file_paths = [] + for path in paths: + if path.is_dir(): + file_paths.extend(path.rglob("**/*")) + else: + file_paths.append(path) + + return file_paths + + +if __name__ == "__main__": + main() diff --git a/requirements-dev.txt b/requirements-dev.txt index 819943e8b7..b1e52571ca 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ pytest pyinstaller pyinstaller-hooks-contrib +pyyaml sip==6.5.1 jinja2