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 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)
@@ -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,7 @@
1
+ """Run scadfmt as `python -m scadfmt`."""
2
+
3
+ import sys
4
+
5
+ from scadfmt.cli import main
6
+
7
+ sys.exit(main())
@@ -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