prettycsharp 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,9 @@
1
+ """An opinionated formatter for C#, in the spirit of Prettier."""
2
+
3
+ from prettycsharp.formatter import FormattingError, format_source
4
+ from prettycsharp.parsing import CSharpSyntaxError
5
+ from prettycsharp.settings import Settings, SettingsError
6
+
7
+ __version__ = "1.0.0"
8
+
9
+ __all__ = ["CSharpSyntaxError", "FormattingError", "Settings", "SettingsError", "__version__", "format_source"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from prettycsharp.cli import main
4
+
5
+ sys.exit(main())
prettycsharp/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ """The prettycsharp command line.
2
+
3
+ prettycsharp format PATH... rewrite files in place (alias --write)
4
+ prettycsharp check PATH... exit 1 if any would change (alias --check)
5
+ prettycsharp diff PATH... print a unified diff (alias --diff)
6
+ prettycsharp PATH print the formatted file to stdout
7
+ prettycsharp - format stdin to stdout
8
+
9
+ A PATH is a .cs file, a folder, a .csproj, or a .sln or .slnx. Exit status:
10
+ 0 when nothing needs to change, 1 when check or diff found a file that
11
+ would change, 2 when a file could not be read, parsed or formatted, or the
12
+ settings are wrong.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import difflib
19
+ import os
20
+ import shutil
21
+ import sys
22
+ import tempfile
23
+ from collections.abc import Iterator, Sequence
24
+ from pathlib import Path
25
+
26
+ from prettycsharp import __version__, config, ignore, project
27
+ from prettycsharp.config import settings_for
28
+ from prettycsharp.formatter import FormattingError, format_source
29
+ from prettycsharp.ignore import Ignorer
30
+ from prettycsharp.parsing import CSharpSyntaxError
31
+ from prettycsharp.project import compiled_files, lang_version_for, solution_projects
32
+ from prettycsharp.settings import Settings, SettingsError
33
+
34
+ _BOM = chr(0xFEFF)
35
+ _MODES = {"format": "write", "check": "check", "diff": "diff"}
36
+
37
+
38
+ class _Options:
39
+ def __init__(self, namespace: argparse.Namespace) -> None:
40
+ self.overrides: dict[str, object] = {}
41
+ for item in namespace.option or []:
42
+ key, sep, value = item.partition("=")
43
+ if not sep or not key.strip():
44
+ raise SettingsError(f"--option {item!r}: use KEY=VALUE")
45
+ self.overrides[key.strip()] = value.strip()
46
+ self.config: Path | None = namespace.config
47
+ self.no_ignore: bool = namespace.no_ignore
48
+ self.ignorer = Ignorer(respect_gitignore=not self.no_ignore, use_ignore_files=not self.no_ignore)
49
+ self.stdin_filepath: Path | None = namespace.stdin_filepath
50
+
51
+ def ignorer_for(self, target: Path) -> Ignorer:
52
+ """The skipping rules for a target, honouring its respect_gitignore setting."""
53
+ if self.no_ignore:
54
+ return self.ignorer
55
+ probe = target / "probe.cs" if target.is_dir() else target
56
+ settings = settings_for(probe, self.overrides, self.config)
57
+ return Ignorer(respect_gitignore=settings.respect_gitignore, use_ignore_files=True)
58
+
59
+
60
+ def main(argv: Sequence[str] | None = None) -> int:
61
+ args = list(sys.argv[1:] if argv is None else argv)
62
+ # Settings, project and ignore files are cached for one run, not across runs.
63
+ config.clear_caches()
64
+ project.clear_caches()
65
+ ignore.clear_caches()
66
+ mode = "stdout"
67
+ if args and args[0] in _MODES:
68
+ mode = _MODES[args.pop(0)]
69
+
70
+ parser = argparse.ArgumentParser(prog="prettycsharp", description="Format C# source files.")
71
+ parser.add_argument("paths", nargs="+", help=".cs files, folders, .csproj or .sln files, or - for stdin")
72
+ parser.add_argument("--version", action="version", version=f"prettycsharp {__version__}")
73
+ flags = parser.add_mutually_exclusive_group()
74
+ flags.add_argument("--write", action="store_const", const="write", dest="mode", help="same as format")
75
+ flags.add_argument("--check", action="store_const", const="check", dest="mode", help="same as check")
76
+ flags.add_argument("--diff", action="store_const", const="diff", dest="mode", help="same as diff")
77
+ parser.add_argument("--config", type=Path, help="use this settings file instead of searching for one")
78
+ parser.add_argument(
79
+ "-o", "--option", action="append", metavar="KEY=VALUE", help="set one setting, over every settings file"
80
+ )
81
+ parser.add_argument("--no-ignore", action="store_true", help="do not skip files .gitignore or .prettycsharpignore lists")
82
+ parser.add_argument("--stdin-filepath", type=Path, help="the path stdin stands for, to find its settings")
83
+ namespace = parser.parse_args(args)
84
+ if namespace.mode is not None:
85
+ if mode != "stdout" and namespace.mode != mode:
86
+ parser.error("give a subcommand or a flag, not two different ones")
87
+ mode = namespace.mode
88
+
89
+ try:
90
+ options = _Options(namespace)
91
+ if namespace.config is not None and not namespace.config.is_file():
92
+ raise SettingsError(f"--config {namespace.config}: no such file")
93
+ except SettingsError as error:
94
+ print(f"prettycsharp: {error}", file=sys.stderr)
95
+ return 2
96
+
97
+ if namespace.paths == ["-"]:
98
+ return _run_stdin(options)
99
+ if "-" in namespace.paths:
100
+ parser.error("- (stdin) cannot be combined with other paths")
101
+
102
+ files, status = _collect(namespace.paths, options)
103
+ if mode == "stdout" and len(files) != 1:
104
+ if status:
105
+ return status
106
+ parser.error("printing to stdout takes exactly one file; use format, check or diff")
107
+ for path in files:
108
+ status = max(status, _run(path, mode, options))
109
+ return status
110
+
111
+
112
+ def _collect(targets: Sequence[str], options: _Options) -> tuple[list[Path], int]:
113
+ files: list[Path] = []
114
+ seen: set[Path] = set()
115
+ status = 0
116
+ for target in targets:
117
+ path = Path(target)
118
+ if not path.exists():
119
+ print(f"{path}: no such file or folder", file=sys.stderr)
120
+ status = 2
121
+ continue
122
+ try:
123
+ ignorer = options.ignorer_for(path)
124
+ except SettingsError as error:
125
+ print(f"prettycsharp: {error}", file=sys.stderr)
126
+ status = 2
127
+ continue
128
+ for found in _expand(path, ignorer):
129
+ key = found.resolve()
130
+ if key not in seen:
131
+ seen.add(key)
132
+ files.append(found)
133
+ return files, status
134
+
135
+
136
+ def _expand(path: Path, ignorer: Ignorer) -> Iterator[Path]:
137
+ suffix = path.suffix.lower()
138
+ if path.is_dir():
139
+ for folder, subfolders, names in os.walk(path):
140
+ here = Path(folder)
141
+ subfolders[:] = sorted(s for s in subfolders if not ignorer.skip_folder(here / s))
142
+ for name in sorted(names):
143
+ candidate = here / name
144
+ if name.lower().endswith(".cs") and not ignorer.skip_file(candidate):
145
+ yield candidate
146
+ elif suffix == ".csproj":
147
+ for found in compiled_files(path):
148
+ if not ignorer.skip_file(found):
149
+ yield found
150
+ elif suffix in (".sln", ".slnx"):
151
+ for project in solution_projects(path):
152
+ if project.is_file():
153
+ yield from _expand(project, ignorer)
154
+ elif not ignorer.skip_file(path):
155
+ yield path
156
+
157
+
158
+ def _settings(path: Path, options: _Options) -> Settings:
159
+ return settings_for(path, options.overrides, options.config)
160
+
161
+
162
+ def _format_text(text: str, path: Path, settings: Settings) -> str:
163
+ """Format text read from ``path``, keeping or changing its byte order mark."""
164
+ bom = _BOM if text.startswith(_BOM) else ""
165
+ lang = lang_version_for(path) if settings.lang_version == "auto" else None
166
+ formatted = format_source(text[len(bom) :], settings, lang_version=lang)
167
+ if settings.charset == "utf-8-bom":
168
+ bom = _BOM
169
+ elif settings.charset == "utf-8":
170
+ bom = ""
171
+ return bom + formatted
172
+
173
+
174
+ def _run_stdin(options: _Options) -> int:
175
+ path = options.stdin_filepath or Path("stdin.cs")
176
+ text = sys.stdin.read()
177
+ if options.stdin_filepath is not None and options.ignorer.skip_file(path):
178
+ sys.stdout.write(text)
179
+ return 0
180
+ try:
181
+ formatted = _format_text(text, path, _settings(path, options))
182
+ except (CSharpSyntaxError, FormattingError, SettingsError) as error:
183
+ print(f"{path}: {error}; not formatted", file=sys.stderr)
184
+ return 2
185
+ sys.stdout.write(formatted.lstrip(_BOM))
186
+ return 0
187
+
188
+
189
+ def _run(path: Path, mode: str, options: _Options) -> int:
190
+ try:
191
+ settings = _settings(path, options)
192
+ except SettingsError as error:
193
+ print(f"prettycsharp: {error}", file=sys.stderr)
194
+ return 2
195
+ try:
196
+ # newline="" keeps the file's own line endings.
197
+ with path.open(encoding="utf-8", newline="") as handle:
198
+ original = handle.read()
199
+ except (OSError, UnicodeDecodeError) as error:
200
+ print(f"{path}: cannot read: {error}", file=sys.stderr)
201
+ return 2
202
+
203
+ try:
204
+ formatted = _format_text(original, path, settings)
205
+ except CSharpSyntaxError as error:
206
+ print(f"{path}: {error}; not formatted", file=sys.stderr)
207
+ return 2
208
+ except FormattingError as error:
209
+ print(f"{path}: not formatted: {error}", file=sys.stderr)
210
+ return 2
211
+
212
+ if mode == "stdout":
213
+ sys.stdout.write(formatted.lstrip(_BOM))
214
+ return 0
215
+ if formatted == original:
216
+ return 0
217
+ if mode == "write":
218
+ try:
219
+ _replace(path, formatted)
220
+ except OSError as error:
221
+ print(f"{path}: cannot write: {error}", file=sys.stderr)
222
+ return 2
223
+ print(f"formatted {path}")
224
+ return 0
225
+ if mode == "diff":
226
+ sys.stdout.writelines(
227
+ difflib.unified_diff(
228
+ original.splitlines(keepends=True),
229
+ formatted.splitlines(keepends=True),
230
+ fromfile=str(path),
231
+ tofile=str(path),
232
+ )
233
+ )
234
+ else:
235
+ print(f"would format {path}")
236
+ return 1
237
+
238
+
239
+ def _replace(path: Path, text: str) -> None:
240
+ # Write beside the file, then swap it in, so a failed write never leaves
241
+ # the original half-written. The new file keeps the original's mode.
242
+ handle, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
243
+ try:
244
+ with os.fdopen(handle, "w", encoding="utf-8", newline="") as stream:
245
+ stream.write(text)
246
+ shutil.copymode(path, temporary)
247
+ os.replace(temporary, path)
248
+ except BaseException:
249
+ Path(temporary).unlink(missing_ok=True)
250
+ raise
prettycsharp/config.py ADDED
@@ -0,0 +1,263 @@
1
+ """Finding the settings for a source file.
2
+
3
+ Settings come from, highest precedence first: the command line, the
4
+ nearest ``.prettycsharp.toml`` or ``pyproject.toml`` with a
5
+ ``[tool.prettycsharp]`` table, the standard keys of ``.editorconfig``, and
6
+ the defaults in ``Settings``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import sys
13
+ from collections.abc import Mapping
14
+ from functools import cache
15
+ from pathlib import Path
16
+
17
+ from prettycsharp.settings import Settings, SettingsError
18
+
19
+ if sys.version_info >= (3, 11):
20
+ import tomllib
21
+ else: # pragma: no cover - exercised on Python 3.10 only
22
+ import tomli as tomllib
23
+
24
+ CONFIG_NAME = ".prettycsharp.toml"
25
+
26
+
27
+ def settings_for(path: Path, overrides: Mapping[str, object] | None = None, config_file: Path | None = None) -> Settings:
28
+ """The settings for the source file at ``path``."""
29
+ directory = path.resolve().parent
30
+ settings = Settings()
31
+ settings = settings.with_values(editorconfig_values(path.resolve()), ".editorconfig")
32
+ if config_file is not None:
33
+ values, source = read_config(config_file.resolve())
34
+ else:
35
+ found = find_config(directory)
36
+ values, source = ({}, "") if found is None else read_config(found)
37
+ if values:
38
+ settings = settings.with_values(values, source)
39
+ if overrides:
40
+ settings = settings.with_values(overrides, "command line")
41
+ return settings
42
+
43
+
44
+ @cache
45
+ def find_config(directory: Path) -> Path | None:
46
+ """The nearest .prettycsharp.toml, or pyproject.toml with our table, at or above ``directory``."""
47
+ for folder in (directory, *directory.parents):
48
+ candidate = folder / CONFIG_NAME
49
+ if candidate.is_file():
50
+ return candidate
51
+ pyproject = folder / "pyproject.toml"
52
+ if pyproject.is_file() and "prettycsharp" in _tool_tables(_load_toml(pyproject)):
53
+ return pyproject
54
+ return None
55
+
56
+
57
+ def _tool_tables(data: dict[str, object]) -> dict[str, object]:
58
+ tool = data.get("tool", {})
59
+ return tool if isinstance(tool, dict) else {}
60
+
61
+
62
+ @cache
63
+ def read_config(path: Path) -> tuple[dict[str, object], str]:
64
+ data = _load_toml(path)
65
+ table: object = _tool_tables(data).get("prettycsharp", {}) if path.name == "pyproject.toml" else data
66
+ if not isinstance(table, dict):
67
+ raise SettingsError(f"{path}: [tool.prettycsharp] must be a table")
68
+ return dict(table), str(path)
69
+
70
+
71
+ def _load_toml(path: Path) -> dict[str, object]:
72
+ try:
73
+ with path.open("rb") as handle:
74
+ return tomllib.load(handle)
75
+ except tomllib.TOMLDecodeError as error:
76
+ raise SettingsError(f"{path}: {error}") from None
77
+ except OSError as error:
78
+ raise SettingsError(f"{path}: cannot read: {error}") from None
79
+
80
+
81
+ # .editorconfig ------------------------------------------------------------
82
+
83
+
84
+ def editorconfig_values(path: Path) -> dict[str, object]:
85
+ """Our settings from the .editorconfig files that apply to ``path``."""
86
+ files: list[Path] = []
87
+ for folder in path.parents:
88
+ candidate = folder / ".editorconfig"
89
+ if candidate.is_file():
90
+ files.append(candidate)
91
+ if _parse_editorconfig(candidate)[0]:
92
+ break
93
+ raw: dict[str, str] = {}
94
+ # The file nearest the source wins, so apply the farthest first.
95
+ for config in reversed(files):
96
+ _, sections = _parse_editorconfig(config)
97
+ relative = path.relative_to(config.parent).as_posix()
98
+ for pattern, values in sections:
99
+ if _section_matches(pattern, relative):
100
+ raw.update(values)
101
+ return _map_editorconfig(raw)
102
+
103
+
104
+ @cache
105
+ def _parse_editorconfig(path: Path) -> tuple[bool, tuple[tuple[str, dict[str, str]], ...]]:
106
+ root = False
107
+ sections: list[tuple[str, dict[str, str]]] = []
108
+ try:
109
+ lines = path.read_text(encoding="utf-8-sig").splitlines()
110
+ except (OSError, UnicodeDecodeError):
111
+ return False, ()
112
+ for line in lines:
113
+ text = line.strip()
114
+ if not text or text[0] in "#;":
115
+ continue
116
+ if text.startswith("[") and text.endswith("]"):
117
+ sections.append((text[1:-1], {}))
118
+ continue
119
+ if "=" not in text:
120
+ continue
121
+ key, value = (part.strip() for part in text.split("=", 1))
122
+ key = key.lower()
123
+ if not sections:
124
+ if key == "root":
125
+ root = value.lower() == "true"
126
+ continue
127
+ sections[-1][1][key] = value
128
+ return root, tuple(sections)
129
+
130
+
131
+ def _section_matches(pattern: str, relative: str) -> bool:
132
+ if "/" not in pattern:
133
+ pattern = "**/" + pattern
134
+ elif pattern.startswith("/"):
135
+ pattern = pattern[1:]
136
+ return re.fullmatch(_editorconfig_regex(pattern), relative) is not None
137
+
138
+
139
+ @cache
140
+ def _editorconfig_regex(pattern: str) -> str:
141
+ out: list[str] = []
142
+ i = 0
143
+ depth = 0
144
+ while i < len(pattern):
145
+ c = pattern[i]
146
+ if c == "\\" and i + 1 < len(pattern):
147
+ out.append(re.escape(pattern[i + 1]))
148
+ i += 2
149
+ continue
150
+ if c == "*":
151
+ if pattern[i : i + 3] == "**/":
152
+ out.append("(?:.*/)?")
153
+ i += 3
154
+ continue
155
+ if pattern[i : i + 2] == "**":
156
+ out.append(".*")
157
+ i += 2
158
+ continue
159
+ out.append("[^/]*")
160
+ elif c == "?":
161
+ out.append("[^/]")
162
+ elif c == "[":
163
+ end = pattern.find("]", i + 1)
164
+ if end < 0:
165
+ out.append(re.escape(c))
166
+ else:
167
+ body = pattern[i + 1 : end]
168
+ negate = body.startswith("!")
169
+ body = body[1:] if negate else body
170
+ out.append("[" + ("^" if negate else "") + body.replace("\\", "\\\\") + "]")
171
+ i = end + 1
172
+ continue
173
+ elif c == "{":
174
+ end = pattern.find("}", i)
175
+ body = pattern[i + 1 : end] if end > 0 else ""
176
+ numbers = re.fullmatch(r"(-?\d+)\.\.(-?\d+)", body)
177
+ if end > 0 and numbers:
178
+ low, high = sorted(int(n) for n in numbers.groups())
179
+ out.append("(?:" + "|".join(str(n) for n in range(low, high + 1)) + ")")
180
+ i = end + 1
181
+ continue
182
+ if end > 0 and "," in body:
183
+ out.append("(?:")
184
+ depth += 1
185
+ else:
186
+ out.append(re.escape(c))
187
+ elif c == "," and depth:
188
+ out.append("|")
189
+ elif c == "}" and depth:
190
+ out.append(")")
191
+ depth -= 1
192
+ else:
193
+ out.append(re.escape(c))
194
+ i += 1
195
+ return "".join(out)
196
+
197
+
198
+ def _map_editorconfig(raw: Mapping[str, str]) -> dict[str, object]:
199
+ values: dict[str, object] = {}
200
+
201
+ def text(key: str) -> str | None:
202
+ value = raw.get(key)
203
+ if value is None:
204
+ return None
205
+ # Roslyn options can carry a severity: true:warning.
206
+ return value.split(":")[0].strip().lower()
207
+
208
+ style = text("indent_style")
209
+ if style in ("space", "tab"):
210
+ values["indent_style"] = style
211
+ size = text("indent_size")
212
+ if size is not None and size.isdigit() and int(size) > 0:
213
+ values["indent_size"] = int(size)
214
+ elif size == "tab":
215
+ width = text("tab_width")
216
+ if width is not None and width.isdigit() and int(width) > 0:
217
+ values["indent_size"] = int(width)
218
+ length = text("max_line_length")
219
+ if length is not None and length.isdigit():
220
+ values["max_line_length"] = int(length)
221
+ elif length == "off":
222
+ values["max_line_length"] = 0
223
+ eol = text("end_of_line")
224
+ if eol in ("lf", "crlf", "cr"):
225
+ values["end_of_line"] = eol
226
+ charset = text("charset")
227
+ if charset in ("utf-8", "utf-8-bom"):
228
+ values["charset"] = charset
229
+ for key in ("insert_final_newline", "trim_trailing_whitespace"):
230
+ flag = text(key)
231
+ if flag in ("true", "false"):
232
+ values[key] = flag == "true"
233
+ brace = text("csharp_new_line_before_open_brace")
234
+ if brace == "all":
235
+ values["brace_style"] = "allman"
236
+ elif brace == "none":
237
+ values["brace_style"] = "kr"
238
+ if any(text(f"csharp_new_line_before_{k}") == "false" for k in ("else", "catch", "finally")):
239
+ values["continuation_keywords"] = "same_line"
240
+ if text("dotnet_sort_system_directives_first") == "true":
241
+ values["using_order"] = "system_first"
242
+ namespaces = text("csharp_style_namespace_declarations")
243
+ if namespaces == "file_scoped":
244
+ values["namespace_style"] = "file_scoped"
245
+ elif namespaces == "block_scoped":
246
+ values["namespace_style"] = "block"
247
+ braces = text("csharp_prefer_braces")
248
+ if braces == "true":
249
+ values["braces"] = "always"
250
+ elif braces == "when_multiline":
251
+ values["braces"] = "single_line_only"
252
+ elif braces == "false":
253
+ values["braces"] = "preserve"
254
+ order = raw.get("csharp_preferred_modifier_order")
255
+ if order:
256
+ values["modifier_order"] = [m.strip() for m in order.split(":")[0].split(",") if m.strip()]
257
+ return values
258
+
259
+
260
+ def clear_caches() -> None:
261
+ find_config.cache_clear()
262
+ read_config.cache_clear()
263
+ _parse_editorconfig.cache_clear()