jpcl 1.1.1__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.
jpcl/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """jpcl -- a hybrid JSON/TOML configuration language.
2
+
3
+ ``.jp`` files use TOML-style ``[SECTION]`` headers at the top level and
4
+ JSON-style ``{...}`` / ``[...]`` structures inside them, with unquoted keys and
5
+ ``#`` comments::
6
+
7
+ [SERVER_ID]
8
+ config: {
9
+ disabled_channels:,
10
+ disabled_users: [9892, 82082, 8209]
11
+ }
12
+
13
+ Typical use::
14
+
15
+ import jpcl
16
+
17
+ data = jpcl.load("data/servers.jp") # -> dict
18
+ jpcl.dump(data, "data/servers.jp") # formatted, atomic write
19
+
20
+ cfg = jpcl.JPConfig.load("data/servers.jp") # dict-like, dotted paths
21
+ cfg.set_path("SERVER_ID.prefix", "!")
22
+ cfg.save()
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from importlib.metadata import PackageNotFoundError
28
+ from importlib.metadata import version as _installed_version
29
+
30
+ from .errors import JPDecodeError, JPEncodeError, JPError
31
+ from .jp_config import (
32
+ ENCODING,
33
+ SUFFIX,
34
+ JPConfig,
35
+ dump,
36
+ dumps,
37
+ load,
38
+ load_dir,
39
+ loads,
40
+ )
41
+
42
+ try:
43
+ #: Taken from the installed distribution metadata, so ``pyproject.toml`` is
44
+ #: the only place a release version has to be bumped.
45
+ __version__ = _installed_version("jpcl")
46
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
47
+ __version__ = "0.0.0+unknown"
48
+
49
+ __all__ = [
50
+ "JPConfig",
51
+ "JPDecodeError",
52
+ "JPEncodeError",
53
+ "JPError",
54
+ "ENCODING",
55
+ "SUFFIX",
56
+ "dump",
57
+ "dumps",
58
+ "load",
59
+ "load_dir",
60
+ "loads",
61
+ "__version__",
62
+ ]
jpcl/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Allow ``python -m jpcl`` to run the command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
jpcl/cli.py ADDED
@@ -0,0 +1,183 @@
1
+ """Command line interface: ``jpcl <command> [files]``.
2
+
3
+ Commands:
4
+ check Validate ``.jp`` files and report the first error in each.
5
+ fmt Reformat files to canonical style (``-w`` to rewrite in place).
6
+ get Print one dotted-path value as JSON.
7
+ to-json Convert ``.jp`` to JSON.
8
+ from-json Convert JSON to ``.jp``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from pathlib import Path
17
+ from typing import Sequence
18
+
19
+ from . import __version__, jp_config
20
+ from .errors import JPError
21
+
22
+ _MISSING = object()
23
+
24
+
25
+ def _read(path: str) -> dict:
26
+ """Load a ``.jp`` file, or stdin when *path* is ``-``."""
27
+ if path == "-":
28
+ return jp_config.loads(sys.stdin.read(), filename="<stdin>")
29
+ return jp_config.load(path)
30
+
31
+
32
+ def _write_options(args: argparse.Namespace) -> dict:
33
+ return {
34
+ "indent": args.indent,
35
+ "width": args.width,
36
+ "sort_keys": args.sort_keys,
37
+ }
38
+
39
+
40
+ def _cmd_check(args: argparse.Namespace) -> int:
41
+ failures = 0
42
+ for path in args.files:
43
+ try:
44
+ _read(path)
45
+ except (JPError, OSError) as exc:
46
+ failures += 1
47
+ print(exc, file=sys.stderr)
48
+ else:
49
+ if not args.quiet:
50
+ print(f"ok {path}")
51
+ return 1 if failures else 0
52
+
53
+
54
+ def _cmd_fmt(args: argparse.Namespace) -> int:
55
+ failures = 0
56
+ for path in args.files:
57
+ try:
58
+ data = _read(path)
59
+ text = jp_config.dumps(data, **_write_options(args))
60
+ except (JPError, OSError) as exc:
61
+ failures += 1
62
+ print(exc, file=sys.stderr)
63
+ continue
64
+ if args.write and path != "-":
65
+ target = Path(path)
66
+ if target.read_text(encoding=jp_config.ENCODING) == text:
67
+ continue
68
+ jp_config.dump(data, target, **_write_options(args))
69
+ print(f"reformatted {path}", file=sys.stderr)
70
+ else:
71
+ sys.stdout.write(text)
72
+ return 1 if failures else 0
73
+
74
+
75
+ def _cmd_get(args: argparse.Namespace) -> int:
76
+ try:
77
+ config = jp_config.JPConfig(_read(args.file))
78
+ except (JPError, OSError) as exc:
79
+ print(exc, file=sys.stderr)
80
+ return 1
81
+ value = config.get_path(args.path, _MISSING)
82
+ if value is _MISSING:
83
+ print(f"no such path: {args.path}", file=sys.stderr)
84
+ return 1
85
+ if isinstance(value, str) and args.raw:
86
+ print(value)
87
+ else:
88
+ print(json.dumps(value, indent=2, ensure_ascii=False))
89
+ return 0
90
+
91
+
92
+ def _cmd_to_json(args: argparse.Namespace) -> int:
93
+ try:
94
+ data = _read(args.file)
95
+ except (JPError, OSError) as exc:
96
+ print(exc, file=sys.stderr)
97
+ return 1
98
+ text = json.dumps(data, indent=args.indent, ensure_ascii=False) + "\n"
99
+ if args.output:
100
+ Path(args.output).write_text(text, encoding="utf-8", newline="\n")
101
+ else:
102
+ sys.stdout.write(text)
103
+ return 0
104
+
105
+
106
+ def _cmd_from_json(args: argparse.Namespace) -> int:
107
+ try:
108
+ raw = sys.stdin.read() if args.file == "-" else Path(args.file).read_text(
109
+ encoding="utf-8"
110
+ )
111
+ data = json.loads(raw)
112
+ text = jp_config.dumps(data, **_write_options(args))
113
+ except (JPError, OSError, ValueError) as exc:
114
+ print(exc, file=sys.stderr)
115
+ return 1
116
+ if args.output:
117
+ Path(args.output).write_text(text, encoding="utf-8", newline="\n")
118
+ else:
119
+ sys.stdout.write(text)
120
+ return 0
121
+
122
+
123
+ def build_parser() -> argparse.ArgumentParser:
124
+ """Construct the argument parser for the ``jpcl`` command."""
125
+ parser = argparse.ArgumentParser(
126
+ prog="jpcl", description="Work with .jp configuration files."
127
+ )
128
+ parser.add_argument("--version", action="version", version=f"jpcl {__version__}")
129
+ subcommands = parser.add_subparsers(dest="command", required=True)
130
+
131
+ def add_format_flags(sub: argparse.ArgumentParser) -> None:
132
+ sub.add_argument("--indent", type=int, default=2, help="spaces per level")
133
+ sub.add_argument(
134
+ "--width", type=int, default=88, help="column budget for inline arrays"
135
+ )
136
+ sub.add_argument(
137
+ "--sort-keys", action="store_true", help="sort keys alphabetically"
138
+ )
139
+
140
+ check = subcommands.add_parser("check", help="validate .jp files")
141
+ check.add_argument("files", nargs="+")
142
+ check.add_argument("-q", "--quiet", action="store_true", help="only report errors")
143
+ check.set_defaults(func=_cmd_check)
144
+
145
+ fmt = subcommands.add_parser("fmt", help="reformat .jp files")
146
+ fmt.add_argument("files", nargs="+")
147
+ fmt.add_argument(
148
+ "-w", "--write", action="store_true", help="rewrite files in place"
149
+ )
150
+ add_format_flags(fmt)
151
+ fmt.set_defaults(func=_cmd_fmt)
152
+
153
+ get = subcommands.add_parser("get", help="print one value by dotted path")
154
+ get.add_argument("file")
155
+ get.add_argument("path", help="e.g. SERVER_ID.config.disabled_users")
156
+ get.add_argument(
157
+ "-r", "--raw", action="store_true", help="print strings unquoted"
158
+ )
159
+ get.set_defaults(func=_cmd_get)
160
+
161
+ to_json = subcommands.add_parser("to-json", help="convert .jp to JSON")
162
+ to_json.add_argument("file")
163
+ to_json.add_argument("-o", "--output")
164
+ to_json.add_argument("--indent", type=int, default=2)
165
+ to_json.set_defaults(func=_cmd_to_json)
166
+
167
+ from_json = subcommands.add_parser("from-json", help="convert JSON to .jp")
168
+ from_json.add_argument("file")
169
+ from_json.add_argument("-o", "--output")
170
+ add_format_flags(from_json)
171
+ from_json.set_defaults(func=_cmd_from_json)
172
+
173
+ return parser
174
+
175
+
176
+ def main(argv: Sequence[str] | None = None) -> int:
177
+ """Entry point for the ``jpcl`` console script."""
178
+ args = build_parser().parse_args(argv)
179
+ return args.func(args)
180
+
181
+
182
+ if __name__ == "__main__": # pragma: no cover
183
+ raise SystemExit(main())
jpcl/errors.py ADDED
@@ -0,0 +1,73 @@
1
+ """Exception hierarchy for the ``.jp`` (JSOML) configuration format.
2
+
3
+ All errors raised by this package derive from :class:`JPError`, so callers can
4
+ catch everything with a single ``except JPError``. The decode error also
5
+ derives from :class:`ValueError` and the encode error from :class:`TypeError`,
6
+ which keeps the module drop-in compatible with code written against
7
+ :mod:`json`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __all__ = ["JPError", "JPDecodeError", "JPEncodeError"]
13
+
14
+
15
+ def _line_col(doc: str, pos: int) -> tuple[int, int]:
16
+ """Return the 1-based ``(line, column)`` of *pos* inside *doc*."""
17
+ line = doc.count("\n", 0, pos) + 1
18
+ col = pos - doc.rfind("\n", 0, pos)
19
+ return line, col
20
+
21
+
22
+ class JPError(Exception):
23
+ """Base class for every error raised by :mod:`jsoml`."""
24
+
25
+
26
+ class JPEncodeError(JPError, TypeError):
27
+ """Raised when a Python object cannot be serialised to ``.jp``."""
28
+
29
+
30
+ class JPDecodeError(JPError, ValueError):
31
+ """Raised when a ``.jp`` document is malformed.
32
+
33
+ The rendered message points at the exact offending character, for example::
34
+
35
+ servers.jp:4:3: expected ':' after key 'prefix'
36
+ prefix "!"
37
+ ^
38
+
39
+ Attributes:
40
+ raw_message: The message without the location prefix or source excerpt.
41
+ doc: The full source text that was being parsed.
42
+ pos: Zero-based character offset of the error inside ``doc``.
43
+ line: One-based line number of the error.
44
+ col: One-based column number of the error.
45
+ filename: Name of the file the text came from, if known.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ message: str,
51
+ doc: str = "",
52
+ pos: int = 0,
53
+ *,
54
+ filename: str | None = None,
55
+ ) -> None:
56
+ self.raw_message = message
57
+ self.doc = doc
58
+ self.pos = max(0, min(pos, len(doc)))
59
+ self.filename = filename
60
+ self.line, self.col = _line_col(doc, self.pos)
61
+ super().__init__(self._render())
62
+
63
+ def _render(self) -> str:
64
+ location = f"{self.filename or '<string>'}:{self.line}:{self.col}"
65
+ parts = [f"{location}: {self.raw_message}"]
66
+ lines = self.doc.splitlines()
67
+ if 0 < self.line <= len(lines):
68
+ source = lines[self.line - 1].rstrip("\r")
69
+ # Keep tabs in the caret prefix so the marker stays aligned.
70
+ prefix = "".join(c if c == "\t" else " " for c in source[: self.col - 1])
71
+ parts.append(f" {source}")
72
+ parts.append(f" {prefix}^")
73
+ return "\n".join(parts)