keytidy 0.1.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.
keytidy/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """keytidy — tidy JSON key order, with smart package.json handling. Zero dependencies."""
2
+
3
+ __version__ = "0.1.0"
keytidy/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
keytidy/cli.py ADDED
@@ -0,0 +1,164 @@
1
+ """keytidy command-line interface."""
2
+
3
+ import json
4
+ import os
5
+ import sys
6
+
7
+ from . import core
8
+
9
+ VERSION = "0.1.0"
10
+
11
+ # ---- tiny color helpers (no dep) ----
12
+ _COLOR = sys.stdout.isatty() and not os.environ.get("NO_COLOR")
13
+
14
+
15
+ def _c(code, s):
16
+ return f"\x1b[{code}m{s}\x1b[0m" if _COLOR else s
17
+
18
+
19
+ def red(s): return _c("31", s)
20
+ def green(s): return _c("32", s)
21
+ def yellow(s): return _c("33", s)
22
+ def dim(s): return _c("2", s)
23
+ def bold(s): return _c("1", s)
24
+
25
+
26
+ HELP = f"""{bold('keytidy')} — tidy JSON key order (and package.json the way it's meant to look).
27
+
28
+ {bold('Usage')}
29
+ keytidy [file...] Sort & rewrite the files (defaults to ./package.json)
30
+ keytidy --check Don't write; exit 1 if anything isn't sorted (CI)
31
+ keytidy --stdout Print the sorted result instead of writing
32
+ keytidy *.json Sort every JSON file (your shell expands the glob)
33
+
34
+ {bold('Options')}
35
+ --indent <n|tab> Override indentation (default: detected from the file, else 2)
36
+ --sort-scripts Also sort package.json "scripts" (off — script order matters)
37
+ --all-keys Sort package.json purely alphabetically (ignore the conventional order)
38
+ --version
39
+
40
+ {bold('What it does')}
41
+ • generic JSON — every object's keys sorted A->Z; arrays kept in order
42
+ • package.json — conventional field order (name, version, …, dependencies),
43
+ dependency blocks sorted A->Z, "scripts" left as you wrote it
44
+
45
+ {bold('Exit')} 0 sorted/clean · 1 needs sorting (--check) · 2 bad JSON / IO error
46
+ """
47
+
48
+
49
+ def die(msg):
50
+ sys.stderr.write(red(f"keytidy: {msg}\n"))
51
+ sys.exit(2)
52
+
53
+
54
+ def parse_args(argv):
55
+ opts = {"files": [], "check": False, "stdout": False, "indent": None,
56
+ "sortScripts": False, "allKeys": False}
57
+ i = 0
58
+ while i < len(argv):
59
+ a = argv[i]
60
+ if a == "--check":
61
+ opts["check"] = True
62
+ elif a == "--stdout":
63
+ opts["stdout"] = True
64
+ elif a == "--sort-scripts":
65
+ opts["sortScripts"] = True
66
+ elif a == "--all-keys":
67
+ opts["allKeys"] = True
68
+ elif a == "--indent":
69
+ i += 1
70
+ if i >= len(argv):
71
+ die('--indent needs a value (a number or "tab")')
72
+ v = argv[i]
73
+ if v == "tab":
74
+ opts["indent"] = "\t"
75
+ elif v.isdigit():
76
+ opts["indent"] = int(v)
77
+ else:
78
+ die('--indent must be a number or "tab"')
79
+ elif a.startswith("-"):
80
+ die(f"unknown flag: {a} (try --help)")
81
+ else:
82
+ opts["files"].append(a)
83
+ i += 1
84
+ return opts
85
+
86
+
87
+ def resolve_files(files):
88
+ if files:
89
+ return files
90
+ if os.path.exists("package.json"):
91
+ return ["package.json"]
92
+ die("no files given and no ./package.json found — pass a JSON file")
93
+
94
+
95
+ def main(argv=None):
96
+ argv = list(sys.argv[1:] if argv is None else argv)
97
+ if "-h" in argv or "--help" in argv:
98
+ sys.stdout.write(HELP)
99
+ return 0
100
+ if "-v" in argv or "--version" in argv:
101
+ sys.stdout.write(VERSION + "\n")
102
+ return 0
103
+
104
+ opts = parse_args(argv)
105
+ files = resolve_files(opts["files"])
106
+
107
+ errored = False
108
+ needs_sort = False
109
+
110
+ for file in files:
111
+ try:
112
+ with open(file, encoding="utf-8") as f:
113
+ text = f.read()
114
+ except FileNotFoundError:
115
+ sys.stderr.write(red(f"✗ {file} — no such file\n"))
116
+ errored = True
117
+ continue
118
+ except OSError as e:
119
+ sys.stderr.write(red(f"✗ {file} — {e}\n"))
120
+ errored = True
121
+ continue
122
+
123
+ try:
124
+ result = core.process(text, {
125
+ "packageJson": os.path.basename(file) == "package.json",
126
+ "indent": opts["indent"],
127
+ "sortScripts": opts["sortScripts"],
128
+ "allKeys": opts["allKeys"],
129
+ })
130
+ except (json.JSONDecodeError, ValueError) as e:
131
+ sys.stderr.write(red(f"✗ {file} — invalid JSON: {e}\n"))
132
+ errored = True
133
+ continue
134
+
135
+ if opts["stdout"]:
136
+ sys.stdout.write(result["output"])
137
+ continue
138
+ if opts["check"]:
139
+ if result["changed"]:
140
+ needs_sort = True
141
+ sys.stdout.write("{} {} {}\n".format(yellow("✗"), file, dim("— not sorted")))
142
+ else:
143
+ sys.stdout.write("{} {} {}\n".format(green("✓"), file, dim("— sorted")))
144
+ continue
145
+ # write mode
146
+ if result["changed"]:
147
+ try:
148
+ with open(file, "w", encoding="utf-8") as f:
149
+ f.write(result["output"])
150
+ except OSError as e:
151
+ sys.stderr.write(red(f"✗ {file} — {e}\n"))
152
+ errored = True
153
+ continue
154
+ sys.stdout.write("{} {} {}\n".format(green("✓"), bold(file), dim("— sorted")))
155
+ else:
156
+ sys.stdout.write("{} {} {}\n".format(dim("•"), file, dim("— already sorted")))
157
+
158
+ if errored:
159
+ return 2
160
+ if opts["check"] and needs_sort:
161
+ if not opts["stdout"]:
162
+ sys.stdout.write("\n{} {} {}\n".format(dim("run"), bold("keytidy"), dim("to fix")))
163
+ return 1
164
+ return 0
keytidy/core.py ADDED
@@ -0,0 +1,96 @@
1
+ """keytidy core — pure JSON key-ordering logic. No fs, no process, no clock.
2
+
3
+ Two jobs:
4
+
5
+ 1. Generic JSON — recursively sort every object's keys alphabetically. Arrays
6
+ keep their order (arrays are data, not config); primitives are untouched.
7
+ The point is to kill the diff noise from tools that emit keys in arbitrary
8
+ order.
9
+ 2. package.json — sorting it alphabetically would be *wrong* (``name`` should
10
+ come before ``dependencies``). So package.json gets the conventional field
11
+ order at the top level, its dependency objects sorted alphabetically, and
12
+ its ``scripts`` block left in the author's order.
13
+
14
+ Mirrors the Node port behavior. The fs/CLI plumbing lives in cli.py.
15
+ """
16
+
17
+ import json
18
+ import re
19
+
20
+ # Conventional top-level order for package.json (npm's own ordering plus what
21
+ # the ecosystem settled on). Keys not in this list are appended alphabetically.
22
+ PACKAGE_FIELD_ORDER = [
23
+ "$schema", "name", "displayName", "version", "private", "description",
24
+ "keywords", "license", "licenses", "homepage", "repository", "bugs", "funding",
25
+ "author", "contributors", "maintainers", "type", "imports", "exports", "main",
26
+ "module", "browser", "unpkg", "jsdelivr", "types", "typings", "typesVersions",
27
+ "bin", "man", "files", "directories", "scripts", "config", "sideEffects",
28
+ "workspaces", "engines", "engineStrict", "os", "cpu", "packageManager",
29
+ "publishConfig", "dependencies", "devDependencies", "peerDependencies",
30
+ "peerDependenciesMeta", "optionalDependencies", "bundledDependencies",
31
+ "bundleDependencies", "overrides", "resolutions",
32
+ ]
33
+
34
+ # Within package.json, these object values keep their original key order.
35
+ PRESERVE_ORDER_FIELDS = {"scripts"}
36
+
37
+ _INDENT_RE = re.compile(r'\n([ \t]+)["\]{}]')
38
+
39
+
40
+ def detect_indent(text):
41
+ """Detect the indentation of an existing JSON document. Returns a space
42
+ count, the string "\\t", or 2."""
43
+ m = _INDENT_RE.search(text)
44
+ if not m:
45
+ return 2
46
+ return "\t" if m.group(1)[0] == "\t" else len(m.group(1))
47
+
48
+
49
+ def order_package_keys(keys):
50
+ """Return package.json keys in conventional order: known fields first (in
51
+ canonical sequence), then unknown fields alphabetically."""
52
+ known = set(PACKAGE_FIELD_ORDER)
53
+ present = [k for k in PACKAGE_FIELD_ORDER if k in keys]
54
+ unknown = sorted(k for k in keys if k not in known)
55
+ return present + unknown
56
+
57
+
58
+ def sort_value(value, opts, package_top):
59
+ """Recursively produce a key-sorted copy of ``value``."""
60
+ if isinstance(value, list):
61
+ return [sort_value(v, opts, False) for v in value]
62
+ if not isinstance(value, dict):
63
+ return value
64
+
65
+ keys = list(value.keys())
66
+ if package_top and not opts.get("allKeys"):
67
+ ordered = order_package_keys(keys)
68
+ else:
69
+ ordered = sorted(keys)
70
+
71
+ out = {}
72
+ for k in ordered:
73
+ preserve = package_top and not opts.get("sortScripts") and k in PRESERVE_ORDER_FIELDS
74
+ out[k] = value[k] if preserve else sort_value(value[k], opts, False)
75
+ return out
76
+
77
+
78
+ def serialize(value, indent):
79
+ """Serialize matching the Node port: given indent, ``: `` / ``,`` separators,
80
+ UTF-8 (no \\uXXXX escaping)."""
81
+ return json.dumps(value, indent=indent, ensure_ascii=False, separators=(",", ": "))
82
+
83
+
84
+ def process(text, opts=None):
85
+ """Sort one JSON document. Returns ``{"output": str, "changed": bool}``.
86
+ Raises ValueError (via json) on invalid JSON."""
87
+ opts = opts or {}
88
+ data = json.loads(text)
89
+ indent = opts["indent"] if opts.get("indent") is not None else detect_indent(text)
90
+ package_top = bool(opts.get("packageJson")) and isinstance(data, dict)
91
+ sorted_data = sort_value(data, {"sortScripts": bool(opts.get("sortScripts")),
92
+ "allKeys": bool(opts.get("allKeys"))}, package_top)
93
+ output = serialize(sorted_data, indent)
94
+ if text.endswith("\n"):
95
+ output += "\n"
96
+ return {"output": output, "changed": output != text}
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: keytidy
3
+ Version: 0.1.0
4
+ Summary: Tidy JSON key order — and sort package.json the conventional way (canonical field order, sorted dependencies, untouched scripts). Zero dependencies.
5
+ Author: yyfjj
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/jjdoor/keytidy-py
8
+ Project-URL: Repository, https://github.com/jjdoor/keytidy-py
9
+ Project-URL: Issues, https://github.com/jjdoor/keytidy-py/issues
10
+ Keywords: json,package.json,sort,format,formatter,tidy,keys,cli,pre-commit
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Classifier: Topic :: Utilities
19
+ Requires-Python: >=3.8
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Dynamic: license-file
23
+
24
+ # keytidy
25
+
26
+ **Tidy JSON key order — and sort `package.json` the way it's actually meant to
27
+ look.** Inconsistent key order is pure diff noise: one teammate's editor writes
28
+ `name` first, another's tool writes it last, and every PR churns lines that
29
+ didn't really change. keytidy gives every JSON file one deterministic order.
30
+ **Zero dependencies** (pure standard library).
31
+
32
+ ```bash
33
+ pip install keytidy
34
+
35
+ keytidy # sort ./package.json in place
36
+ keytidy --check # CI gate: exit 1 if anything isn't sorted
37
+ ```
38
+
39
+ ## It's not just "sort all the keys"
40
+
41
+ Sorting `package.json` alphabetically would be *wrong* — `name` belongs at the
42
+ top, not wedged between `module` and `optionalDependencies`. So keytidy treats
43
+ `package.json` specially:
44
+
45
+ - **Top-level fields** follow the conventional order (`name`, `version`,
46
+ `description`, …, `scripts`, `dependencies`, `devDependencies`, …). Unknown
47
+ fields (your `tool.*`, custom keys) sort alphabetically after the known ones.
48
+ - **Dependency blocks** (`dependencies`, `devDependencies`, `peerDependencies`,
49
+ …) are sorted A→Z — the part you actually want sorted.
50
+ - **`scripts` is left in your order**, because script order is frequently
51
+ meaningful (`pretest` → `test` → `posttest`, ordered build steps). Opt in with
52
+ `--sort-scripts` if you disagree.
53
+
54
+ Every other `.json` file just gets a clean recursive alphabetical sort, with
55
+ **arrays left in their original order** (an array is data, not config).
56
+
57
+ ## Example
58
+
59
+ ```jsonc
60
+ // before
61
+ { "version": "1.0.0", "scripts": { "test": "…", "build": "…" },
62
+ "name": "demo", "dependencies": { "zod": "…", "axios": "…" } }
63
+
64
+ // after → keytidy
65
+ {
66
+ "name": "demo",
67
+ "version": "1.0.0",
68
+ "scripts": { "test": "…", "build": "…" }, // order preserved
69
+ "dependencies": { "axios": "…", "zod": "…" } // sorted A→Z
70
+ }
71
+ ```
72
+
73
+ ## Usage
74
+
75
+ ```bash
76
+ keytidy # sort ./package.json in place
77
+ keytidy package.json tsconfig.json
78
+ keytidy *.json # your shell expands the glob
79
+ keytidy --check # don't write; exit 1 if any file isn't sorted
80
+ keytidy --stdout pkg.json # print the sorted result instead of writing
81
+ keytidy --indent 4 # force 4-space indent (default: detected, else 2)
82
+ keytidy --sort-scripts # also sort the package.json scripts block
83
+ keytidy --all-keys # ignore the conventional order, sort A→Z
84
+ ```
85
+
86
+ You can also run it as a module: `python -m keytidy`.
87
+
88
+ Indentation is **detected from the file** and preserved, so keytidy doesn't
89
+ fight your existing 2-space / 4-space / tab style. The trailing newline is kept
90
+ as-is.
91
+
92
+ ### As a CI / pre-commit gate
93
+
94
+ ```yaml
95
+ # .pre-commit-config.yaml
96
+ - repo: local
97
+ hooks:
98
+ - id: keytidy
99
+ name: keytidy
100
+ entry: keytidy --check
101
+ language: system
102
+ files: package\.json$
103
+ ```
104
+
105
+ ## Exit codes
106
+
107
+ | Code | Meaning |
108
+ |------|---------|
109
+ | `0` | sorted (write mode) or everything already sorted (`--check`) |
110
+ | `1` | a file needs sorting (`--check` only) |
111
+ | `2` | invalid JSON, or a file couldn't be read/written |
112
+
113
+ ## Notes
114
+
115
+ - It rewrites by parsing and re-serializing, so it normalizes JSON formatting.
116
+ It does **not** support comments or trailing commas (JSONC / `tsconfig.json`
117
+ with comments will report invalid JSON rather than mangle them).
118
+ - The sort itself is pure and deterministic, shared with the Node port — for
119
+ typical config files the two produce identical output.
120
+
121
+ ## Also available for Node
122
+
123
+ Same behavior, same flags: [`npx keytidy`](https://www.npmjs.com/package/keytidy)
124
+ (source: [keytidy](https://github.com/jjdoor/keytidy)).
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,10 @@
1
+ keytidy/__init__.py,sha256=85EiN65DqHOEbsNMm-jxMRRg2dc10O0N33E1mOIG9dc,115
2
+ keytidy/__main__.py,sha256=4JMK66Wj4uLZTKbF-sT3LAxOsr6buig77PmOkJCRRxw,83
3
+ keytidy/cli.py,sha256=xViDPU4C5O8xxMXuGBMmg2yDMpaSAIxROsLWfbYuoMs,5252
4
+ keytidy/core.py,sha256=bY1Slr4mNs7DoASL_rd4hTMiUtUEDIbeuU1egAeBs8o,3959
5
+ keytidy-0.1.0.dist-info/licenses/LICENSE,sha256=7tATQ4lC-u80SOBmjSE2cdUMyuYdG90m0E1qQLDrAaw,1077
6
+ keytidy-0.1.0.dist-info/METADATA,sha256=1KYzjZfmfbOzeLyguq7KHyQAazKiC3or5fulkhAitCY,4590
7
+ keytidy-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ keytidy-0.1.0.dist-info/entry_points.txt,sha256=JCC7TD_QcqY2angzyaUWZb1WFHLq9T7L_7tUdzcPCx0,45
9
+ keytidy-0.1.0.dist-info/top_level.txt,sha256=LJdwW5-UOt5Dg0-NtRIiUpcYLeqUt-c6yl7PAromCwo,8
10
+ keytidy-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ keytidy = keytidy.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keytidy contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ keytidy