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 +62 -0
- jpcl/__main__.py +8 -0
- jpcl/cli.py +183 -0
- jpcl/errors.py +73 -0
- jpcl/jp_config.py +446 -0
- jpcl/parser.py +348 -0
- jpcl/py.typed +0 -0
- jpcl/scanner.py +285 -0
- jpcl/writer.py +281 -0
- jpcl-1.1.1.dist-info/METADATA +323 -0
- jpcl-1.1.1.dist-info/RECORD +14 -0
- jpcl-1.1.1.dist-info/WHEEL +4 -0
- jpcl-1.1.1.dist-info/entry_points.txt +3 -0
- jpcl-1.1.1.dist-info/licenses/LICENSE +21 -0
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
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)
|