scadfmt 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- scadfmt-0.1.0/PKG-INFO +175 -0
- scadfmt-0.1.0/README.md +152 -0
- scadfmt-0.1.0/pyproject.toml +61 -0
- scadfmt-0.1.0/scadfmt/__init__.py +9 -0
- scadfmt-0.1.0/scadfmt/__main__.py +7 -0
- scadfmt-0.1.0/scadfmt/cli.py +147 -0
- scadfmt-0.1.0/scadfmt/fileio.py +31 -0
- scadfmt-0.1.0/scadfmt/formatter.py +527 -0
- scadfmt-0.1.0/scadfmt/py.typed +0 -0
- scadfmt-0.1.0/scadfmt/tokenizer.py +177 -0
- scadfmt-0.1.0/scadfmt/vscode.py +101 -0
- scadfmt-0.1.0/scadfmt.egg-info/PKG-INFO +175 -0
- scadfmt-0.1.0/scadfmt.egg-info/SOURCES.txt +20 -0
- scadfmt-0.1.0/scadfmt.egg-info/dependency_links.txt +1 -0
- scadfmt-0.1.0/scadfmt.egg-info/entry_points.txt +2 -0
- scadfmt-0.1.0/scadfmt.egg-info/top_level.txt +1 -0
- scadfmt-0.1.0/setup.cfg +4 -0
- scadfmt-0.1.0/tests/test_cli.py +180 -0
- scadfmt-0.1.0/tests/test_files.py +38 -0
- scadfmt-0.1.0/tests/test_formatter.py +252 -0
- scadfmt-0.1.0/tests/test_tokenizer.py +102 -0
- scadfmt-0.1.0/tests/test_vscode.py +108 -0
scadfmt-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: scadfmt
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Opinionated formatter for OpenSCAD code
|
|
5
|
+
Author-email: Patrick PΓΆtz <kellervater@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/kellerlabs/homeracker
|
|
8
|
+
Project-URL: Repository, https://github.com/kellerlabs/homeracker
|
|
9
|
+
Project-URL: Documentation, https://github.com/kellerlabs/homeracker/tree/main/cmd/scadfmt
|
|
10
|
+
Project-URL: Issues, https://github.com/kellerlabs/homeracker/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/kellerlabs/homeracker/blob/main/cmd/scadfmt/CHANGELOG.md
|
|
12
|
+
Keywords: openscad,formatter,scad,code-style,pre-commit
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# π¨ scadfmt
|
|
25
|
+
|
|
26
|
+
## π What
|
|
27
|
+
|
|
28
|
+
An opinionated formatter for OpenSCAD code. It fixes indentation and spacing, puts blocks and statements on their own lines, and never joins lines or changes what the code does.
|
|
29
|
+
|
|
30
|
+
## π€ Why
|
|
31
|
+
|
|
32
|
+
Community contributions need one code style without style debates in review. Existing formatters rewrite code they do not understand (dropping operators, breaking `include` paths) or join hand-wrapped lines into very long ones. scadfmt only needs to know OpenSCAD's tokens, so new syntax rarely affects it, and it refuses to write output whose tokens differ from the input. See [build-own-openscad-formatter-scadfmt](../../docs/decisions/build-own-openscad-formatter-scadfmt.md).
|
|
33
|
+
|
|
34
|
+
## π§ How
|
|
35
|
+
|
|
36
|
+
### π¦ Install
|
|
37
|
+
|
|
38
|
+
Requires Python 3.11 or newer, no other dependencies.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install scadfmt # from PyPI
|
|
42
|
+
pip install -e cmd/scadfmt # from this repo
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### βΆοΈ Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
scadfmt format models/ # format files in place, directories recursively
|
|
49
|
+
scadfmt format --check models/ # write nothing, exit 1 if a file would change
|
|
50
|
+
scadfmt format --diff part.scad # write nothing, print a diff
|
|
51
|
+
scadfmt format - < in.scad > out.scad # stdin to stdout
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Exit codes: `0` clean, `1` files would change (`--check`), `2` error. On an error (unknown character, unbalanced brackets) the file stays untouched.
|
|
55
|
+
|
|
56
|
+
### π Before and After
|
|
57
|
+
|
|
58
|
+
```openscad
|
|
59
|
+
include<BOSL2/std.scad>
|
|
60
|
+
wall=2;// wall strength
|
|
61
|
+
height_units=3; // rack units
|
|
62
|
+
/**
|
|
63
|
+
* Bracket holding a device of the given size.
|
|
64
|
+
* center: centers the body on the origin
|
|
65
|
+
*/
|
|
66
|
+
module bracket(width=10,depth=20,center=false){
|
|
67
|
+
size=[width,depth,wall*height_units];
|
|
68
|
+
if(center){translate(-size/2)cube(size);}else{cube(size);}
|
|
69
|
+
for(i=[0:2:width])
|
|
70
|
+
translate([i,0,0])
|
|
71
|
+
rotate([0,0,-90])
|
|
72
|
+
#cylinder(h=wall,r=1);
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
becomes
|
|
77
|
+
|
|
78
|
+
```openscad
|
|
79
|
+
include <BOSL2/std.scad>
|
|
80
|
+
|
|
81
|
+
wall = 2; // wall strength
|
|
82
|
+
height_units = 3; // rack units
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Bracket holding a device of the given size.
|
|
86
|
+
* center: centers the body on the origin
|
|
87
|
+
*/
|
|
88
|
+
module bracket(width = 10, depth = 20, center = false) {
|
|
89
|
+
size = [width, depth, wall * height_units];
|
|
90
|
+
if (center) {
|
|
91
|
+
translate(-size / 2) cube(size);
|
|
92
|
+
} else {
|
|
93
|
+
cube(size);
|
|
94
|
+
}
|
|
95
|
+
for (i = [0:2:width])
|
|
96
|
+
translate([i, 0, 0])
|
|
97
|
+
rotate([0, 0, -90])
|
|
98
|
+
#cylinder(h = wall, r = 1);
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### π Rules
|
|
103
|
+
|
|
104
|
+
| Rule | Example |
|
|
105
|
+
|---|---|
|
|
106
|
+
| 2 spaces per open `{ ( [`, one level per line that opens them | `cube([`β΅` 1,`β΅`]);` |
|
|
107
|
+
| A line continuing a module call nests one level further | `translate(v)`β΅` cube();` |
|
|
108
|
+
| A line continuing an expression is one level in | `x =`β΅` a +`β΅` b;` |
|
|
109
|
+
| Spaces around every binary operator and every `=` | `cube(size = w * 2, center = true);` |
|
|
110
|
+
| Tight unary operators, modifiers, calls, indexing | `-x`, `!a`, `#cube()`, `f(a)[0]` |
|
|
111
|
+
| Tight range colons, spaced ternary colons | `[0:2:10]`, `a ? b : c` |
|
|
112
|
+
| `if`, `for`, `intersection_for`, `function` get a space before `(` | `for (i = [0:2])` |
|
|
113
|
+
| Space after commas, none inside brackets | `f(a, [1, 2])` |
|
|
114
|
+
| Block contents on their own lines, `}` on its own line except `} else`, empty `{}` stays | `if (a) {`β΅` b();`β΅`} else {` |
|
|
115
|
+
| One statement per line (`;` inside `for (...)` excepted) | `a();`β΅`b();` |
|
|
116
|
+
| Exactly one blank line before and after each `module` and `function` definition, none next to a brace | `x = 1;`β΅β΅`module m() {` |
|
|
117
|
+
| Imports form one block without blank lines, followed by exactly one blank line | `include <a.scad>`β΅`use <b.scad>`β΅β΅`x = 1;` |
|
|
118
|
+
| Trailing comments on consecutive lines share one column, a lone one gets 2 spaces | `x = 1; // note` |
|
|
119
|
+
| At most 2 blank lines at top level, 1 inside blocks | |
|
|
120
|
+
| Keeps the file's line endings (LF or CRLF, judged by the first one), no trailing whitespace, one final newline | |
|
|
121
|
+
|
|
122
|
+
Comments (`//` or `/* */`) directly above a line belong to it, so a blank line added before that line goes above its comments. Lines are never joined and line length is never limited.
|
|
123
|
+
|
|
124
|
+
### π Opting Out
|
|
125
|
+
|
|
126
|
+
Lines between `// fmt: off` and `// fmt: on` stay as written, for example a hand-aligned matrix:
|
|
127
|
+
|
|
128
|
+
```openscad
|
|
129
|
+
// fmt: off
|
|
130
|
+
identity = [
|
|
131
|
+
1, 0, 0,
|
|
132
|
+
0, 1, 0,
|
|
133
|
+
];
|
|
134
|
+
// fmt: on
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### πͺ Pre-commit
|
|
138
|
+
|
|
139
|
+
In another repository, install scadfmt from PyPI through a local hook:
|
|
140
|
+
|
|
141
|
+
```yaml
|
|
142
|
+
- repo: local
|
|
143
|
+
hooks:
|
|
144
|
+
- id: scadfmt
|
|
145
|
+
name: scadfmt
|
|
146
|
+
entry: scadfmt format
|
|
147
|
+
language: python
|
|
148
|
+
additional_dependencies: [scadfmt==0.1.0]
|
|
149
|
+
files: \.scad$
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### π₯οΈ VS Code
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
scadfmt vscode
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Installs the [Custom Local Formatters](https://marketplace.visualstudio.com/items?itemName=jkillian.custom-local-formatters) extension and makes scadfmt the default formatter for `.scad` files in `.vscode/settings.json`, so **Format Document** (`Shift+Alt+F`) runs it. It refuses to touch a `settings.json` with comments; add the settings by hand then:
|
|
159
|
+
|
|
160
|
+
```jsonc
|
|
161
|
+
"customLocalFormatters.formatters": [{ "command": "\"/path/to/python\" -m scadfmt format -", "languages": ["scad"] }],
|
|
162
|
+
"[scad]": { "editor.defaultFormatter": "jkillian.custom-local-formatters" }
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
For formatting on save, add `"editor.formatOnSave": true` to the `[scad]` block.
|
|
166
|
+
|
|
167
|
+
### π§ͺ Tests
|
|
168
|
+
|
|
169
|
+
See [TESTING.md](../../TESTING.md#scadfmt-tests). `tests/canary/` holds a file using every OpenSCAD construct and its expected output; `check.sh` checks both against the pinned OpenSCAD.
|
|
170
|
+
|
|
171
|
+
## π References
|
|
172
|
+
|
|
173
|
+
- [#177](https://github.com/kellerlabs/homeracker/issues/177): introduce an OpenSCAD formatter
|
|
174
|
+
- [build-own-openscad-formatter-scadfmt](../../docs/decisions/build-own-openscad-formatter-scadfmt.md): why scadfmt exists and how it works
|
|
175
|
+
- [OpenSCAD language reference](https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/The_OpenSCAD_Language)
|
scadfmt-0.1.0/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# π¨ scadfmt
|
|
2
|
+
|
|
3
|
+
## π What
|
|
4
|
+
|
|
5
|
+
An opinionated formatter for OpenSCAD code. It fixes indentation and spacing, puts blocks and statements on their own lines, and never joins lines or changes what the code does.
|
|
6
|
+
|
|
7
|
+
## π€ Why
|
|
8
|
+
|
|
9
|
+
Community contributions need one code style without style debates in review. Existing formatters rewrite code they do not understand (dropping operators, breaking `include` paths) or join hand-wrapped lines into very long ones. scadfmt only needs to know OpenSCAD's tokens, so new syntax rarely affects it, and it refuses to write output whose tokens differ from the input. See [build-own-openscad-formatter-scadfmt](../../docs/decisions/build-own-openscad-formatter-scadfmt.md).
|
|
10
|
+
|
|
11
|
+
## π§ How
|
|
12
|
+
|
|
13
|
+
### π¦ Install
|
|
14
|
+
|
|
15
|
+
Requires Python 3.11 or newer, no other dependencies.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install scadfmt # from PyPI
|
|
19
|
+
pip install -e cmd/scadfmt # from this repo
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### βΆοΈ Usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
scadfmt format models/ # format files in place, directories recursively
|
|
26
|
+
scadfmt format --check models/ # write nothing, exit 1 if a file would change
|
|
27
|
+
scadfmt format --diff part.scad # write nothing, print a diff
|
|
28
|
+
scadfmt format - < in.scad > out.scad # stdin to stdout
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Exit codes: `0` clean, `1` files would change (`--check`), `2` error. On an error (unknown character, unbalanced brackets) the file stays untouched.
|
|
32
|
+
|
|
33
|
+
### π Before and After
|
|
34
|
+
|
|
35
|
+
```openscad
|
|
36
|
+
include<BOSL2/std.scad>
|
|
37
|
+
wall=2;// wall strength
|
|
38
|
+
height_units=3; // rack units
|
|
39
|
+
/**
|
|
40
|
+
* Bracket holding a device of the given size.
|
|
41
|
+
* center: centers the body on the origin
|
|
42
|
+
*/
|
|
43
|
+
module bracket(width=10,depth=20,center=false){
|
|
44
|
+
size=[width,depth,wall*height_units];
|
|
45
|
+
if(center){translate(-size/2)cube(size);}else{cube(size);}
|
|
46
|
+
for(i=[0:2:width])
|
|
47
|
+
translate([i,0,0])
|
|
48
|
+
rotate([0,0,-90])
|
|
49
|
+
#cylinder(h=wall,r=1);
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
becomes
|
|
54
|
+
|
|
55
|
+
```openscad
|
|
56
|
+
include <BOSL2/std.scad>
|
|
57
|
+
|
|
58
|
+
wall = 2; // wall strength
|
|
59
|
+
height_units = 3; // rack units
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Bracket holding a device of the given size.
|
|
63
|
+
* center: centers the body on the origin
|
|
64
|
+
*/
|
|
65
|
+
module bracket(width = 10, depth = 20, center = false) {
|
|
66
|
+
size = [width, depth, wall * height_units];
|
|
67
|
+
if (center) {
|
|
68
|
+
translate(-size / 2) cube(size);
|
|
69
|
+
} else {
|
|
70
|
+
cube(size);
|
|
71
|
+
}
|
|
72
|
+
for (i = [0:2:width])
|
|
73
|
+
translate([i, 0, 0])
|
|
74
|
+
rotate([0, 0, -90])
|
|
75
|
+
#cylinder(h = wall, r = 1);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### π Rules
|
|
80
|
+
|
|
81
|
+
| Rule | Example |
|
|
82
|
+
|---|---|
|
|
83
|
+
| 2 spaces per open `{ ( [`, one level per line that opens them | `cube([`β΅` 1,`β΅`]);` |
|
|
84
|
+
| A line continuing a module call nests one level further | `translate(v)`β΅` cube();` |
|
|
85
|
+
| A line continuing an expression is one level in | `x =`β΅` a +`β΅` b;` |
|
|
86
|
+
| Spaces around every binary operator and every `=` | `cube(size = w * 2, center = true);` |
|
|
87
|
+
| Tight unary operators, modifiers, calls, indexing | `-x`, `!a`, `#cube()`, `f(a)[0]` |
|
|
88
|
+
| Tight range colons, spaced ternary colons | `[0:2:10]`, `a ? b : c` |
|
|
89
|
+
| `if`, `for`, `intersection_for`, `function` get a space before `(` | `for (i = [0:2])` |
|
|
90
|
+
| Space after commas, none inside brackets | `f(a, [1, 2])` |
|
|
91
|
+
| Block contents on their own lines, `}` on its own line except `} else`, empty `{}` stays | `if (a) {`β΅` b();`β΅`} else {` |
|
|
92
|
+
| One statement per line (`;` inside `for (...)` excepted) | `a();`β΅`b();` |
|
|
93
|
+
| Exactly one blank line before and after each `module` and `function` definition, none next to a brace | `x = 1;`β΅β΅`module m() {` |
|
|
94
|
+
| Imports form one block without blank lines, followed by exactly one blank line | `include <a.scad>`β΅`use <b.scad>`β΅β΅`x = 1;` |
|
|
95
|
+
| Trailing comments on consecutive lines share one column, a lone one gets 2 spaces | `x = 1; // note` |
|
|
96
|
+
| At most 2 blank lines at top level, 1 inside blocks | |
|
|
97
|
+
| Keeps the file's line endings (LF or CRLF, judged by the first one), no trailing whitespace, one final newline | |
|
|
98
|
+
|
|
99
|
+
Comments (`//` or `/* */`) directly above a line belong to it, so a blank line added before that line goes above its comments. Lines are never joined and line length is never limited.
|
|
100
|
+
|
|
101
|
+
### π Opting Out
|
|
102
|
+
|
|
103
|
+
Lines between `// fmt: off` and `// fmt: on` stay as written, for example a hand-aligned matrix:
|
|
104
|
+
|
|
105
|
+
```openscad
|
|
106
|
+
// fmt: off
|
|
107
|
+
identity = [
|
|
108
|
+
1, 0, 0,
|
|
109
|
+
0, 1, 0,
|
|
110
|
+
];
|
|
111
|
+
// fmt: on
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### πͺ Pre-commit
|
|
115
|
+
|
|
116
|
+
In another repository, install scadfmt from PyPI through a local hook:
|
|
117
|
+
|
|
118
|
+
```yaml
|
|
119
|
+
- repo: local
|
|
120
|
+
hooks:
|
|
121
|
+
- id: scadfmt
|
|
122
|
+
name: scadfmt
|
|
123
|
+
entry: scadfmt format
|
|
124
|
+
language: python
|
|
125
|
+
additional_dependencies: [scadfmt==0.1.0]
|
|
126
|
+
files: \.scad$
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### π₯οΈ VS Code
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
scadfmt vscode
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Installs the [Custom Local Formatters](https://marketplace.visualstudio.com/items?itemName=jkillian.custom-local-formatters) extension and makes scadfmt the default formatter for `.scad` files in `.vscode/settings.json`, so **Format Document** (`Shift+Alt+F`) runs it. It refuses to touch a `settings.json` with comments; add the settings by hand then:
|
|
136
|
+
|
|
137
|
+
```jsonc
|
|
138
|
+
"customLocalFormatters.formatters": [{ "command": "\"/path/to/python\" -m scadfmt format -", "languages": ["scad"] }],
|
|
139
|
+
"[scad]": { "editor.defaultFormatter": "jkillian.custom-local-formatters" }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
For formatting on save, add `"editor.formatOnSave": true` to the `[scad]` block.
|
|
143
|
+
|
|
144
|
+
### π§ͺ Tests
|
|
145
|
+
|
|
146
|
+
See [TESTING.md](../../TESTING.md#scadfmt-tests). `tests/canary/` holds a file using every OpenSCAD construct and its expected output; `check.sh` checks both against the pinned OpenSCAD.
|
|
147
|
+
|
|
148
|
+
## π References
|
|
149
|
+
|
|
150
|
+
- [#177](https://github.com/kellerlabs/homeracker/issues/177): introduce an OpenSCAD formatter
|
|
151
|
+
- [build-own-openscad-formatter-scadfmt](../../docs/decisions/build-own-openscad-formatter-scadfmt.md): why scadfmt exists and how it works
|
|
152
|
+
- [OpenSCAD language reference](https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/The_OpenSCAD_Language)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "scadfmt"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Opinionated formatter for OpenSCAD code"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Patrick PΓΆtz", email = "kellervater@gmail.com"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["openscad", "formatter", "scad", "code-style", "pre-commit"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 3 - Alpha",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
24
|
+
"Topic :: Utilities",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/kellerlabs/homeracker"
|
|
29
|
+
Repository = "https://github.com/kellerlabs/homeracker"
|
|
30
|
+
Documentation = "https://github.com/kellerlabs/homeracker/tree/main/cmd/scadfmt"
|
|
31
|
+
Issues = "https://github.com/kellerlabs/homeracker/issues"
|
|
32
|
+
Changelog = "https://github.com/kellerlabs/homeracker/blob/main/cmd/scadfmt/CHANGELOG.md"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
scadfmt = "scadfmt.cli:main"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.packages.find]
|
|
38
|
+
where = ["."]
|
|
39
|
+
include = ["scadfmt*"]
|
|
40
|
+
|
|
41
|
+
[tool.setuptools.package-data]
|
|
42
|
+
scadfmt = ["py.typed"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
pythonpath = ["."]
|
|
46
|
+
# With PYTHONWARNDEFAULTENCODING=1, text I/O without an explicit encoding fails (breaks on non-UTF-8 Windows locales).
|
|
47
|
+
filterwarnings = ["error::EncodingWarning"]
|
|
48
|
+
|
|
49
|
+
[tool.coverage.run]
|
|
50
|
+
source = ["scadfmt"]
|
|
51
|
+
omit = ["scadfmt/__main__.py"]
|
|
52
|
+
branch = true
|
|
53
|
+
|
|
54
|
+
[tool.coverage.report]
|
|
55
|
+
# Raise when coverage grows, never lower.
|
|
56
|
+
fail_under = 98
|
|
57
|
+
show_missing = true
|
|
58
|
+
skip_covered = true
|
|
59
|
+
exclude_also = [
|
|
60
|
+
"if __name__ == .__main__.:",
|
|
61
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Opinionated formatter for OpenSCAD code (scadfmt)."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
# release-please bumps pyproject.toml only, so the installed metadata is the single source of truth.
|
|
6
|
+
try:
|
|
7
|
+
__version__ = version("scadfmt")
|
|
8
|
+
except PackageNotFoundError:
|
|
9
|
+
__version__ = "unknown"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Command-line interface for scadfmt."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import difflib
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from scadfmt import __version__
|
|
10
|
+
from scadfmt.fileio import write_atomically
|
|
11
|
+
from scadfmt.formatter import FormatError, format_source
|
|
12
|
+
from scadfmt.vscode import setup_vscode
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
EXIT_OK = 0
|
|
17
|
+
EXIT_CHANGES = 1
|
|
18
|
+
EXIT_ERROR = 2
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _collect(paths: list[str]) -> list[Path]:
|
|
22
|
+
"""Expand directories to the .scad files below them, sorted."""
|
|
23
|
+
files: list[Path] = []
|
|
24
|
+
for raw in paths:
|
|
25
|
+
path = Path(raw)
|
|
26
|
+
if path.is_dir():
|
|
27
|
+
files.extend(sorted(path.rglob("*.scad")))
|
|
28
|
+
else:
|
|
29
|
+
files.append(path)
|
|
30
|
+
return files
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _diff(before: str, after: str, name: str) -> str:
|
|
34
|
+
"""Unified diff between two versions of a file."""
|
|
35
|
+
return "".join(
|
|
36
|
+
difflib.unified_diff(
|
|
37
|
+
before.splitlines(keepends=True),
|
|
38
|
+
after.splitlines(keepends=True),
|
|
39
|
+
f"{name} (original)",
|
|
40
|
+
f"{name} (formatted)",
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _write(text: str) -> None:
|
|
46
|
+
"""Write UTF-8 text to stdout without newline translation."""
|
|
47
|
+
sys.stdout.buffer.write(text.encode("utf-8"))
|
|
48
|
+
sys.stdout.buffer.flush()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _format_stdin(check: bool, diff: bool) -> int:
|
|
52
|
+
"""Format stdin to stdout, or report on it with --check or --diff."""
|
|
53
|
+
# Bytes, not text: text mode would translate newlines and use the locale codepage on Windows.
|
|
54
|
+
try:
|
|
55
|
+
source = sys.stdin.buffer.read().decode("utf-8")
|
|
56
|
+
formatted = format_source(source)
|
|
57
|
+
except (UnicodeDecodeError, FormatError) as e:
|
|
58
|
+
logger.error("<stdin>:%s", e)
|
|
59
|
+
return EXIT_ERROR
|
|
60
|
+
if diff:
|
|
61
|
+
_write(_diff(source, formatted, "<stdin>"))
|
|
62
|
+
elif not check:
|
|
63
|
+
_write(formatted)
|
|
64
|
+
return EXIT_CHANGES if check and formatted != source else EXIT_OK
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _format_files(files: list[Path], check: bool, diff: bool) -> int:
|
|
68
|
+
"""Format files in place, or report on them with --check or --diff."""
|
|
69
|
+
changed = errors = 0
|
|
70
|
+
for path in files:
|
|
71
|
+
try:
|
|
72
|
+
source = path.read_bytes().decode("utf-8")
|
|
73
|
+
formatted = format_source(source)
|
|
74
|
+
except (OSError, UnicodeDecodeError, FormatError) as e:
|
|
75
|
+
logger.error("%s:%s", path, e)
|
|
76
|
+
errors += 1
|
|
77
|
+
continue
|
|
78
|
+
if formatted == source:
|
|
79
|
+
continue
|
|
80
|
+
changed += 1
|
|
81
|
+
if diff:
|
|
82
|
+
_write(_diff(source, formatted, str(path)))
|
|
83
|
+
if check or diff:
|
|
84
|
+
logger.info("would reformat %s", path)
|
|
85
|
+
else:
|
|
86
|
+
try:
|
|
87
|
+
write_atomically(path, formatted)
|
|
88
|
+
except OSError as e:
|
|
89
|
+
logger.error("%s: cannot write: %s", path, e)
|
|
90
|
+
errors += 1
|
|
91
|
+
continue
|
|
92
|
+
logger.info("reformatted %s", path)
|
|
93
|
+
if errors:
|
|
94
|
+
return EXIT_ERROR
|
|
95
|
+
return EXIT_CHANGES if check and changed else EXIT_OK
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _handle_format(args: argparse.Namespace) -> int:
|
|
99
|
+
"""Run the format subcommand."""
|
|
100
|
+
if args.paths == ["-"]:
|
|
101
|
+
return _format_stdin(args.check, args.diff)
|
|
102
|
+
if "-" in args.paths:
|
|
103
|
+
logger.error("'-' (stdin) cannot be combined with file paths")
|
|
104
|
+
return EXIT_ERROR
|
|
105
|
+
return _format_files(_collect(args.paths), args.check, args.diff)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
109
|
+
"""Build the argument parser.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The configured parser.
|
|
113
|
+
"""
|
|
114
|
+
parser = argparse.ArgumentParser(prog="scadfmt", description="Opinionated formatter for OpenSCAD code.")
|
|
115
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
116
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
117
|
+
|
|
118
|
+
format_parser = subparsers.add_parser("format", help="Format .scad files in place")
|
|
119
|
+
format_parser.add_argument("paths", nargs="+", help="Files or directories to format, or '-' for stdin")
|
|
120
|
+
format_parser.add_argument("--check", action="store_true", help="Write nothing, exit 1 if any file would change")
|
|
121
|
+
format_parser.add_argument("--diff", action="store_true", help="Write nothing, print a diff of the changes")
|
|
122
|
+
|
|
123
|
+
vscode_parser = subparsers.add_parser("vscode", help="Make scadfmt the VS Code formatter for .scad files")
|
|
124
|
+
vscode_parser.add_argument(
|
|
125
|
+
"--workspace", default=".", help="Workspace folder whose .vscode/settings.json is updated (default: .)"
|
|
126
|
+
)
|
|
127
|
+
return parser
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def main(argv: list[str] | None = None) -> int:
|
|
131
|
+
"""Run scadfmt.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
argv: Arguments without the program name. Defaults to sys.argv[1:].
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Exit code: 0 clean, 1 files would change (--check), 2 error.
|
|
138
|
+
"""
|
|
139
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
140
|
+
args = build_parser().parse_args(argv)
|
|
141
|
+
if args.command == "vscode":
|
|
142
|
+
return EXIT_OK if setup_vscode(Path(args.workspace)) else EXIT_ERROR
|
|
143
|
+
return _handle_format(args)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
sys.exit(main())
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Atomic file writes shared by formatting and VS Code setup."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def write_atomically(path: Path, text: str) -> None:
|
|
10
|
+
"""Replace the file at path with text in one step, so a failed write never leaves a truncated file.
|
|
11
|
+
|
|
12
|
+
A symlink is followed and its target updated. An existing file keeps its permissions.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
path: File to write, created if missing.
|
|
16
|
+
text: New content, written as UTF-8 without newline translation.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
OSError: If the temporary file cannot be written or moved into place.
|
|
20
|
+
"""
|
|
21
|
+
target = path.resolve() if path.is_symlink() else path
|
|
22
|
+
handle, temp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp")
|
|
23
|
+
try:
|
|
24
|
+
with os.fdopen(handle, "wb") as temp:
|
|
25
|
+
temp.write(text.encode("utf-8"))
|
|
26
|
+
if target.exists():
|
|
27
|
+
shutil.copymode(target, temp_name)
|
|
28
|
+
os.replace(temp_name, target)
|
|
29
|
+
except OSError:
|
|
30
|
+
Path(temp_name).unlink(missing_ok=True)
|
|
31
|
+
raise
|