mirror of
https://git.mirrors.martin98.com/https://github.com/Ultimaker/Cura
synced 2025-05-21 20:19:32 +08:00
Merge pull request #13856 from Ultimaker/printer_linter
Added a linting tool for Cura Printers and Profiles
This commit is contained in:
commit
82f4f9ed16
45
.github/workflows/printer-linter-format.yml
vendored
Normal file
45
.github/workflows/printer-linter-format.yml
vendored
Normal file
@ -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"
|
59
.github/workflows/printer-linter-pr-diagnose.yml
vendored
Normal file
59
.github/workflows/printer-linter-pr-diagnose.yml
vendored
Normal file
@ -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
|
80
.github/workflows/printer-linter-pr-post.yml
vendored
Normal file
80
.github/workflows/printer-linter-pr-post.yml
vendored
Normal file
@ -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 }}
|
1
.github/workflows/requirements-printer-linter.txt
vendored
Normal file
1
.github/workflows/requirements-printer-linter.txt
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
pyyaml
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -99,3 +99,4 @@ conanbuildinfo.txt
|
|||||||
graph_info.json
|
graph_info.json
|
||||||
Ultimaker-Cura.spec
|
Ultimaker-Cura.spec
|
||||||
.run/
|
.run/
|
||||||
|
/printer-linter/src/printerlinter.egg-info/
|
||||||
|
15
.printer-linter
Normal file
15
.printer-linter
Normal file
@ -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
|
33
printer-linter/README.md
Normal file
33
printer-linter/README.md
Normal file
@ -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
|
||||||
|
|
17
printer-linter/pyproject.toml
Normal file
17
printer-linter/pyproject.toml
Normal file
@ -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"
|
10
printer-linter/setup.cfg
Normal file
10
printer-linter/setup.cfg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[metadata]
|
||||||
|
name = printerlinter
|
||||||
|
|
||||||
|
[options]
|
||||||
|
package_dir=
|
||||||
|
=src
|
||||||
|
packages=find:
|
||||||
|
|
||||||
|
[options.packages.find]
|
||||||
|
where=src
|
6
printer-linter/setup.py
Normal file
6
printer-linter/setup.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
from setuptools import setup
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
setup()
|
4
printer-linter/src/printerlinter/__init__.py
Normal file
4
printer-linter/src/printerlinter/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from .diagnostic import Diagnostic
|
||||||
|
from .factory import getLinter
|
||||||
|
|
||||||
|
__all__ = ["Diagnostic", "getLinter"]
|
34
printer-linter/src/printerlinter/diagnostic.py
Normal file
34
printer-linter/src/printerlinter/diagnostic.py
Normal file
@ -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
|
||||||
|
}
|
26
printer-linter/src/printerlinter/factory.py
Normal file
26
printer-linter/src/printerlinter/factory.py
Normal file
@ -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
|
4
printer-linter/src/printerlinter/formatters/__init__.py
Normal file
4
printer-linter/src/printerlinter/formatters/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from .def_json_formatter import DefJsonFormatter
|
||||||
|
from .inst_cfg_formatter import InstCfgFormatter
|
||||||
|
|
||||||
|
__all__ = ["DefJsonFormatter", "InstCfgFormatter"]
|
@ -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])))
|
16
printer-linter/src/printerlinter/formatters/formatter.py
Normal file
16
printer-linter/src/printerlinter/formatters/formatter.py
Normal file
@ -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
|
@ -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))
|
6
printer-linter/src/printerlinter/linters/__init__.py
Normal file
6
printer-linter/src/printerlinter/linters/__init__.py
Normal file
@ -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"]
|
122
printer-linter/src/printerlinter/linters/defintion.py
Normal file
122
printer-linter/src/printerlinter/linters/defintion.py
Normal file
@ -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}
|
20
printer-linter/src/printerlinter/linters/linter.py
Normal file
20
printer-linter/src/printerlinter/linters/linter.py
Normal file
@ -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
|
47
printer-linter/src/printerlinter/linters/meshes.py
Normal file
47
printer-linter/src/printerlinter/linters/meshes.py
Normal file
@ -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
|
9
printer-linter/src/printerlinter/linters/profile.py
Normal file
9
printer-linter/src/printerlinter/linters/profile.py
Normal file
@ -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
|
21
printer-linter/src/printerlinter/replacement.py
Normal file
21
printer-linter/src/printerlinter/replacement.py
Normal file
@ -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}
|
117
printer-linter/src/terminal.py
Normal file
117
printer-linter/src/terminal.py
Normal file
@ -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()
|
@ -1,5 +1,6 @@
|
|||||||
pytest
|
pytest
|
||||||
pyinstaller
|
pyinstaller
|
||||||
pyinstaller-hooks-contrib
|
pyinstaller-hooks-contrib
|
||||||
|
pyyaml
|
||||||
sip==6.5.1
|
sip==6.5.1
|
||||||
jinja2
|
jinja2
|
||||||
|
Loading…
x
Reference in New Issue
Block a user