yini-parser 0.1.0a1__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,14 @@
1
+ # src/yini_parser/__init__.py
2
+ """Public API for the yini_parser package."""
3
+
4
+ from .api import YiniParseError, load, loads
5
+
6
+ """
7
+ So users can write:
8
+ from yini_parser import load, loads, YiniParseError
9
+
10
+ Instead:
11
+ from yini_parser.api import load, loads, YiniParseError
12
+ """
13
+
14
+ __all__ = ["YiniParseError", "load", "loads"]
@@ -0,0 +1,13 @@
1
+ # src/yini_parser/api/__init__.py
2
+ """Public API helpers for parsing YINI documents."""
3
+
4
+ from .errors import YiniParseError
5
+ from .warnings import YiniParseWarning
6
+ from .load import load, loads
7
+
8
+ __all__ = [
9
+ "load",
10
+ "loads",
11
+ "YiniParseError",
12
+ "YiniParseWarning",
13
+ ]
@@ -0,0 +1,15 @@
1
+ # src/yini_parser/api/errors.py
2
+
3
+ class YiniParseError(Exception):
4
+ def __init__(self, message: str, line: int | None = None, column: int | None = None):
5
+ super().__init__(message)
6
+ self.message = message
7
+ self.line = line
8
+ self.column = column
9
+
10
+ def __str__(self) -> str:
11
+ if self.line is not None and self.column is not None:
12
+ return f"{self.message} (line {self.line}, column {self.column})"
13
+ if self.line is not None:
14
+ return f"{self.message} (line {self.line})"
15
+ return self.message
@@ -0,0 +1,55 @@
1
+ # src/yini_parser/api/load.py
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from antlr4 import CommonTokenStream, FileStream, InputStream
8
+
9
+ from yini_parser.api.errors import YiniParseError
10
+
11
+ from ..core.yini_builder_visitor import YiniBuilderVisitor
12
+ from ..grammar.generated.YiniLexer import YiniLexer
13
+ from ..grammar.generated.YiniParser import YiniParser
14
+
15
+
16
+ def loads(text: str, strict: bool=False) -> dict[str, Any]:
17
+ """
18
+ Parse YINI text and return the resulting Python dictionary.
19
+ """
20
+
21
+ input_stream = InputStream(text)
22
+ return _parse_input_stream(input_stream, strict=strict)
23
+
24
+
25
+ def load(path: str, strict: bool=False) -> dict[str, Any]:
26
+ """
27
+ Parse a YINI file from disk and return the resulting Python dictionary.
28
+ """
29
+
30
+ file_path = Path(path)
31
+ input_stream = FileStream(str(file_path), encoding="utf-8")
32
+ return _parse_input_stream(input_stream, strict=strict)
33
+
34
+
35
+ def _parse_input_stream(
36
+ input_stream: InputStream | FileStream,
37
+ strict: bool
38
+ ) -> dict[str, Any]:
39
+ lexer = YiniLexer(input_stream)
40
+ stream = CommonTokenStream(lexer)
41
+ parser = YiniParser(stream)
42
+
43
+ tree = parser.yini()
44
+
45
+ if parser.getNumberOfSyntaxErrors() > 0:
46
+ # raise ValueError(f"Failed to parse YINI input: {parser.getNumberOfSyntaxErrors()} syntax error(s).")
47
+ raise YiniParseError(f"Failed to parse YINI input: {parser.getNumberOfSyntaxErrors()} syntax error(s).")
48
+
49
+ visitor = YiniBuilderVisitor(strict=strict)
50
+ result = visitor.visit(tree)
51
+
52
+ if not isinstance(result, dict):
53
+ raise TypeError(f"Expected parsed result to be a dict, got {type(result).__name__}.")
54
+
55
+ return result
@@ -0,0 +1,53 @@
1
+ # src/yini_parser/api/warnings.py
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class YiniParseWarning(Warning):
7
+ """
8
+ Warning raised for non-fatal YINI parse issues.
9
+
10
+ A YiniParseWarning represents a problem that was detected while parsing,
11
+ but which does not prevent a result from being produced.
12
+
13
+ Typical examples:
14
+ - Duplicate keys ignored in lenient mode.
15
+ - Duplicate sections ignored in lenient mode.
16
+ - Key/section name collisions handled by lenient-mode policy.
17
+ """
18
+
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ line: int | None = None,
23
+ column: int | None = None,
24
+ code: str | None = None,
25
+ ) -> None:
26
+ super().__init__(message)
27
+ self.message = message
28
+ self.line = line
29
+ self.column = column
30
+ self.code = code
31
+
32
+ def __str__(self) -> str:
33
+ location = self._format_location()
34
+
35
+ if self.code is not None and location is not None:
36
+ return f"{self.message} [{self.code}] {location}"
37
+
38
+ if self.code is not None:
39
+ return f"{self.message} [{self.code}]"
40
+
41
+ if location is not None:
42
+ return f"{self.message} {location}"
43
+
44
+ return self.message
45
+
46
+ def _format_location(self) -> str | None:
47
+ if self.line is not None and self.column is not None:
48
+ return f"(line {self.line}, column {self.column})"
49
+
50
+ if self.line is not None:
51
+ return f"(line {self.line})"
52
+
53
+ return None
@@ -0,0 +1 @@
1
+ # This file marks this directory as a Python package so it can be imported (as a package/module).
@@ -0,0 +1,134 @@
1
+ # src/yini_parser/core/section_headers.py
2
+ from ..api.errors import YiniParseError
3
+ from ..utils.text import strip_backticks
4
+
5
+ def parse_section_head(
6
+ raw_text: str,
7
+ *,
8
+ line: int | None = None,
9
+ column: int | None = None,
10
+ ) -> tuple[int, str]:
11
+ """
12
+ Parses a SECTION_HEAD token text like:
13
+ "^ App\\n"
14
+ "^^ Server\\n"
15
+ "^7 DeepSection\\n"
16
+
17
+ Returns:
18
+ (level, name)
19
+ """
20
+
21
+ text = raw_text.strip()
22
+ text = text.splitlines()[0].strip()
23
+ text = _strip_section_tail_comment(text)
24
+
25
+ if not text:
26
+ raise YiniParseError(
27
+ "Invalid section header: the header is empty.",
28
+ line=line,
29
+ column=column,
30
+ )
31
+
32
+ marker = text[0]
33
+
34
+ if marker not in {"^", "<", "§"}:
35
+ raise YiniParseError(
36
+ f"Invalid section header: {marker!r} is not a valid section marker. "
37
+ "Use one of: '^', '<', or '§'.",
38
+ line=line,
39
+ column=column,
40
+ )
41
+
42
+ # Numeric shorthand form, for example: ^7 SectionName.
43
+ if len(text) >= 2 and text[1].isdigit():
44
+ i = 1
45
+ j = i
46
+
47
+ while j < len(text) and text[j].isdigit():
48
+ j += 1
49
+
50
+ level_text = text[i:j]
51
+
52
+ try:
53
+ level = int(level_text)
54
+ except ValueError:
55
+ raise YiniParseError(
56
+ f"Invalid section level: {level_text!r} is not a valid number.",
57
+ line=line,
58
+ column=column,
59
+ ) from None
60
+
61
+ name = text[j:].strip()
62
+
63
+ else:
64
+ # Repeated/basic form, for example:
65
+ # ^^ Section
66
+ # ^_^ Section
67
+ # ^_^_^ Section
68
+ i = 0
69
+ level = 0
70
+ expecting_marker = True
71
+
72
+ while i < len(text):
73
+ ch = text[i]
74
+
75
+ if ch == marker:
76
+ level += 1
77
+ expecting_marker = False
78
+ i += 1
79
+ continue
80
+
81
+ if ch == "_" and not expecting_marker:
82
+ if i + 1 < len(text) and text[i + 1] == marker:
83
+ expecting_marker = True
84
+ i += 1
85
+ continue
86
+
87
+ break
88
+
89
+ name = text[i:].strip()
90
+
91
+ if not name:
92
+ raise YiniParseError(
93
+ f"Missing section name after section marker {marker!r}.",
94
+ line=line,
95
+ column=column,
96
+ )
97
+
98
+ return level, strip_backticks(name)
99
+
100
+
101
+ def _strip_section_tail_comment(text: str) -> str:
102
+ in_single = False
103
+ in_double = False
104
+ in_backtick = False
105
+
106
+ i = 0
107
+ while i < len(text):
108
+ ch = text[i]
109
+
110
+ if ch == "`" and not in_single and not in_double:
111
+ in_backtick = not in_backtick
112
+ i += 1
113
+ continue
114
+
115
+ if ch == "'" and not in_double and not in_backtick:
116
+ in_single = not in_single
117
+ i += 1
118
+ continue
119
+
120
+ if ch == '"' and not in_single and not in_backtick:
121
+ in_double = not in_double
122
+ i += 1
123
+ continue
124
+
125
+ if not in_single and not in_double and not in_backtick:
126
+ if ch == "#":
127
+ return text[:i].rstrip()
128
+
129
+ if ch == "/" and i + 1 < len(text) and text[i + 1] == "/":
130
+ return text[:i].rstrip()
131
+
132
+ i += 1
133
+
134
+ return text.rstrip()
@@ -0,0 +1,157 @@
1
+ # src/yini_parser/core/validator.py
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+
7
+ from ..api.errors import YiniParseError
8
+ from ..api.warnings import YiniParseWarning
9
+
10
+
11
+ class YiniValidator:
12
+ """
13
+ Handles validation policy for strict and lenient parsing.
14
+
15
+ In strict mode, conflicts are errors.
16
+ In lenient mode, conflicts are warnings and the first definition wins.
17
+ """
18
+
19
+ def __init__(self, strict: bool = False) -> None:
20
+ self.strict = strict
21
+
22
+ def handle_duplicate_key(
23
+ self,
24
+ key: str,
25
+ *,
26
+ line: int | None = None,
27
+ column: int | None = None,
28
+ ) -> bool:
29
+ """
30
+ Handles duplicate keys.
31
+
32
+ Returns:
33
+ True -> caller may keep/replace the value
34
+ False -> caller should ignore the new value
35
+ """
36
+ message = (
37
+ f"Duplicate key {key!r} ignored. "
38
+ "The first value is kept."
39
+ )
40
+
41
+ if self.strict:
42
+ raise YiniParseError(
43
+ f"Duplicate key {key!r} is not allowed in strict mode.",
44
+ line=line,
45
+ column=column,
46
+ )
47
+
48
+ self._warn(
49
+ message,
50
+ line=line,
51
+ column=column,
52
+ code="duplicate-key",
53
+ )
54
+
55
+ return False
56
+
57
+ def handle_duplicate_section(
58
+ self,
59
+ name: str,
60
+ *,
61
+ line: int | None = None,
62
+ column: int | None = None,
63
+ ) -> bool:
64
+ """
65
+ Handles duplicate sections.
66
+
67
+ Returns:
68
+ True -> caller may reuse/merge the existing section
69
+ False -> caller should ignore the new section block
70
+ """
71
+ message = (
72
+ f"Duplicate section {name!r} ignored. "
73
+ "The first section is kept."
74
+ )
75
+
76
+ if self.strict:
77
+ raise YiniParseError(
78
+ f"Duplicate section {name!r} is not allowed in strict mode.",
79
+ line=line,
80
+ column=column,
81
+ )
82
+
83
+ self._warn(
84
+ message,
85
+ line=line,
86
+ column=column,
87
+ code="duplicate-section",
88
+ )
89
+
90
+ return False
91
+
92
+ def handle_key_section_collision(
93
+ self,
94
+ name: str,
95
+ existing_kind: str,
96
+ incoming_kind: str,
97
+ *,
98
+ line: int | None = None,
99
+ column: int | None = None,
100
+ ) -> bool:
101
+ """
102
+ Handles name collisions between keys and sections.
103
+
104
+ Example:
105
+ app = "demo"
106
+ ^ app
107
+
108
+ or:
109
+
110
+ ^ app
111
+ app = "demo"
112
+
113
+ Returns:
114
+ True -> caller may accept the incoming definition
115
+ False -> caller should ignore the incoming definition
116
+ """
117
+ message = (
118
+ f"Name collision for {name!r} ignored. "
119
+ f"A {existing_kind} with this name already exists, so the incoming "
120
+ f"{incoming_kind} was ignored."
121
+ )
122
+
123
+ if self.strict:
124
+ raise YiniParseError(
125
+ f"Name collision for {name!r}. "
126
+ f"A {existing_kind} with this name already exists, so it cannot "
127
+ f"also be used as a {incoming_kind} in strict mode.",
128
+ line=line,
129
+ column=column,
130
+ )
131
+
132
+ self._warn(
133
+ message,
134
+ line=line,
135
+ column=column,
136
+ code="key-section-collision",
137
+ )
138
+
139
+ return False
140
+
141
+ def _warn(
142
+ self,
143
+ message: str,
144
+ *,
145
+ line: int | None = None,
146
+ column: int | None = None,
147
+ code: str | None = None,
148
+ ) -> None:
149
+ warnings.warn(
150
+ YiniParseWarning(
151
+ message,
152
+ line=line,
153
+ column=column,
154
+ code=code,
155
+ ),
156
+ stacklevel=3,
157
+ )
@@ -0,0 +1,197 @@
1
+ # src/yini_parser/core/value_decoders.py
2
+ from ..api.errors import YiniParseError
3
+
4
+ """
5
+ - Parsers reads raw/source text and recognizes its structure as tokens.
6
+ - Decoders converts tokens into its runtime value.
7
+ """
8
+
9
+ def decode_string_token(
10
+ token_text: str,
11
+ *,
12
+ line: int | None = None,
13
+ column: int | None = None,
14
+ ) -> str:
15
+ """
16
+ Minimal first-pass string decoding.
17
+
18
+ Handles:
19
+ - Optional prefixes: R/r and C/c.
20
+ - Single/double quoted strings.
21
+ - Triple-quoted strings.
22
+ - Simple quote stripping.
23
+
24
+ Unprefixed strings are treated as raw strings.
25
+ C-prefixed strings decode escape sequences.
26
+ """
27
+ text = token_text
28
+
29
+ if not text:
30
+ return ""
31
+
32
+ prefix = ""
33
+
34
+ if len(text) >= 2 and text[0] in "RrCc" and text[1] in {'"', "'"}:
35
+ prefix = text[0]
36
+ text = text[1:]
37
+ elif len(text) >= 4 and text[0] in "RrCc" and text[1:4] == '"""':
38
+ prefix = text[0]
39
+ text = text[1:]
40
+
41
+ # Triple-quoted string.
42
+ if text.startswith('"""') and text.endswith('"""') and len(text) >= 6:
43
+ inner = text[3:-3]
44
+
45
+ if prefix in {"C", "c"}:
46
+ return _decode_classic_string(
47
+ inner,
48
+ line=line,
49
+ column=column,
50
+ )
51
+
52
+ return inner
53
+
54
+ # Single-quoted or double-quoted string.
55
+ if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
56
+ inner = text[1:-1]
57
+
58
+ # Raw and unprefixed strings: return as-is.
59
+ if prefix in {"", "R", "r"}:
60
+ return inner
61
+
62
+ # Classic strings: decode escapes.
63
+ if prefix in {"C", "c"}:
64
+ return _decode_classic_string(
65
+ inner,
66
+ line=line,
67
+ column=column,
68
+ )
69
+
70
+ return inner
71
+
72
+ raise YiniParseError(
73
+ f"Invalid string literal: {token_text!r}",
74
+ line=line,
75
+ column=column,
76
+ )
77
+
78
+
79
+ def parse_number_literal(text, line=None, column=None):
80
+ # text = ctx.getText().strip()
81
+ # line, column = self._ctx_location(ctx)
82
+
83
+ try:
84
+ sign = 1
85
+ body = text
86
+
87
+ if body.startswith("-"):
88
+ sign = -1
89
+ body = body[1:]
90
+ elif body.startswith("+"):
91
+ body = body[1:]
92
+
93
+ lowered = body.lower()
94
+
95
+ """
96
+ By spec: hex:FF_AA // Shall work.
97
+ hex: FF_AA // INVALID!
98
+ """
99
+ if lowered.startswith("hex:"):
100
+ cleaned = body.split(":", 1)[1].strip().replace("_", "")
101
+ return sign * int(cleaned, 16)
102
+
103
+ if lowered.startswith("0x"):
104
+ return sign * int(body[2:].replace("_", ""), 16)
105
+
106
+ if lowered.startswith("0b"):
107
+ return sign * int(body[2:].replace("_", ""), 2)
108
+
109
+ if lowered.startswith("%"):
110
+ return sign * int(body[1:].replace("_", ""), 2)
111
+
112
+ if lowered.startswith("0o"):
113
+ return sign * int(body[2:].replace("_", ""), 8)
114
+
115
+ if lowered.startswith("0z"):
116
+ return sign * _parse_duodecimal(
117
+ body[2:].replace("_", ""),
118
+ line=line,
119
+ column=column,
120
+ )
121
+
122
+ decimal_text = text.replace("_", "")
123
+
124
+ if any(ch in decimal_text for ch in ".eE"):
125
+ return float(decimal_text)
126
+
127
+ return int(decimal_text, 10)
128
+
129
+ except YiniParseError:
130
+ raise
131
+
132
+ except ValueError:
133
+ raise YiniParseError(
134
+ f"Invalid number literal: {text!r}",
135
+ line=line,
136
+ column=column,
137
+ ) from None
138
+
139
+
140
+ def _decode_classic_string(
141
+ inner: str,
142
+ *,
143
+ line: int | None = None,
144
+ column: int | None = None,
145
+ ) -> str:
146
+ try:
147
+ return bytes(inner, "utf-8").decode("unicode_escape")
148
+ except UnicodeDecodeError as exc:
149
+ raise YiniParseError(
150
+ f"Invalid string escape sequence: {exc.reason}.",
151
+ line=line,
152
+ column=column,
153
+ ) from None
154
+
155
+
156
+ def _parse_duodecimal(
157
+ text: str,
158
+ *,
159
+ line: int | None = None,
160
+ column: int | None = None,
161
+ ) -> int:
162
+ value = 0
163
+
164
+ if not text:
165
+ raise YiniParseError(
166
+ "Invalid duodecimal number: missing digits after '0z'.",
167
+ line=line,
168
+ column=column,
169
+ )
170
+
171
+ for ch in text:
172
+ if ch.isdigit():
173
+ digit = int(ch)
174
+ else:
175
+ lowered = ch.lower()
176
+
177
+ if lowered in {"a", "x"}:
178
+ digit = 10
179
+ elif lowered in {"b", "e"}:
180
+ digit = 11
181
+ else:
182
+ raise YiniParseError(
183
+ f"Invalid duodecimal number: {ch!r} is not a valid base-12 digit.",
184
+ line=line,
185
+ column=column,
186
+ )
187
+
188
+ if digit >= 12:
189
+ raise YiniParseError(
190
+ f"Invalid duodecimal number: {ch!r} is not a valid base-12 digit.",
191
+ line=line,
192
+ column=column,
193
+ )
194
+
195
+ value = value * 12 + digit
196
+
197
+ return value