pepscript 0.1.0__tar.gz → 0.1.2__tar.gz
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.
- {pepscript-0.1.0 → pepscript-0.1.2}/PKG-INFO +15 -5
- {pepscript-0.1.0 → pepscript-0.1.2}/README.md +12 -4
- {pepscript-0.1.0 → pepscript-0.1.2}/pyproject.toml +3 -1
- {pepscript-0.1.0 → pepscript-0.1.2}/src/pepscript/io.py +4 -2
- {pepscript-0.1.0 → pepscript-0.1.2}/src/pepscript/models.py +12 -1
- {pepscript-0.1.0 → pepscript-0.1.2}/src/pepscript/parser.py +20 -6
- {pepscript-0.1.0 → pepscript-0.1.2}/src/pepscript/script.py +24 -5
- {pepscript-0.1.0 → pepscript-0.1.2}/src/pepscript/serialize.py +30 -8
- {pepscript-0.1.0 → pepscript-0.1.2}/src/pepscript/validate.py +170 -17
- {pepscript-0.1.0 → pepscript-0.1.2}/src/pepscript/__init__.py +0 -0
- {pepscript-0.1.0 → pepscript-0.1.2}/src/pepscript/config.py +0 -0
- {pepscript-0.1.0 → pepscript-0.1.2}/src/pepscript/exceptions.py +0 -0
- {pepscript-0.1.0 → pepscript-0.1.2}/src/pepscript/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pepscript
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Typed Python library for working with PEP 723 inline script metadata
|
|
5
5
|
Keywords: pep723,metadata,inline-script,toml
|
|
6
6
|
Author: maximiliancw
|
|
@@ -14,6 +14,8 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
14
14
|
Classifier: Typing :: Typed
|
|
15
15
|
Requires-Python: >=3.12
|
|
16
16
|
Project-URL: Repository, https://github.com/botlot-project/PEPscript
|
|
17
|
+
Project-URL: Documentation, https://botlot-project.github.io/PEPscript/
|
|
18
|
+
Project-URL: Issues, https://github.com/botlot-project/PEPscript/issues
|
|
17
19
|
Description-Content-Type: text/markdown
|
|
18
20
|
|
|
19
21
|
# PEPScript
|
|
@@ -83,8 +85,9 @@ from pepscript import PEPScript
|
|
|
83
85
|
script = PEPScript("my_script.py")
|
|
84
86
|
if script.has_metadata:
|
|
85
87
|
# Attribute access
|
|
86
|
-
|
|
88
|
+
ruff = script.meta.config.tool.ruff
|
|
87
89
|
# Item access (for keys with hyphens)
|
|
90
|
+
line_length = ruff["line-length"]
|
|
88
91
|
setting = script.meta.config.tool["my-tool"]["some-setting"]
|
|
89
92
|
```
|
|
90
93
|
|
|
@@ -120,16 +123,23 @@ uv run ty check . # Type check
|
|
|
120
123
|
### Versioning and releases
|
|
121
124
|
|
|
122
125
|
This project uses [Semantic Versioning](https://semver.org/). The version is set in `pyproject.toml`.
|
|
126
|
+
Release notes are generated automatically from [Conventional Commits](https://www.conventionalcommits.org/) when a version tag is pushed.
|
|
123
127
|
|
|
124
128
|
To release:
|
|
125
129
|
|
|
126
130
|
1. Update `version` in `pyproject.toml`
|
|
127
|
-
2.
|
|
131
|
+
2. Push your changes and merge them into `main`
|
|
132
|
+
3. Wait for the `CI` workflow on `main` to pass
|
|
133
|
+
4. Tag the merged commit on `main` and push the tag:
|
|
128
134
|
```bash
|
|
129
|
-
git
|
|
130
|
-
git
|
|
135
|
+
git checkout main
|
|
136
|
+
git pull origin main
|
|
137
|
+
git tag v0.1.2
|
|
138
|
+
git push origin v0.1.2
|
|
131
139
|
```
|
|
132
140
|
|
|
141
|
+
Pushing the tag triggers the release workflow, which builds the package, publishes it to PyPI, generates release notes with `git-cliff`, and creates the GitHub release.
|
|
142
|
+
|
|
133
143
|
Commit messages should follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix:`, `feat:`, `feat!:` for breaking changes).
|
|
134
144
|
|
|
135
145
|
## License
|
|
@@ -65,8 +65,9 @@ from pepscript import PEPScript
|
|
|
65
65
|
script = PEPScript("my_script.py")
|
|
66
66
|
if script.has_metadata:
|
|
67
67
|
# Attribute access
|
|
68
|
-
|
|
68
|
+
ruff = script.meta.config.tool.ruff
|
|
69
69
|
# Item access (for keys with hyphens)
|
|
70
|
+
line_length = ruff["line-length"]
|
|
70
71
|
setting = script.meta.config.tool["my-tool"]["some-setting"]
|
|
71
72
|
```
|
|
72
73
|
|
|
@@ -102,16 +103,23 @@ uv run ty check . # Type check
|
|
|
102
103
|
### Versioning and releases
|
|
103
104
|
|
|
104
105
|
This project uses [Semantic Versioning](https://semver.org/). The version is set in `pyproject.toml`.
|
|
106
|
+
Release notes are generated automatically from [Conventional Commits](https://www.conventionalcommits.org/) when a version tag is pushed.
|
|
105
107
|
|
|
106
108
|
To release:
|
|
107
109
|
|
|
108
110
|
1. Update `version` in `pyproject.toml`
|
|
109
|
-
2.
|
|
111
|
+
2. Push your changes and merge them into `main`
|
|
112
|
+
3. Wait for the `CI` workflow on `main` to pass
|
|
113
|
+
4. Tag the merged commit on `main` and push the tag:
|
|
110
114
|
```bash
|
|
111
|
-
git
|
|
112
|
-
git
|
|
115
|
+
git checkout main
|
|
116
|
+
git pull origin main
|
|
117
|
+
git tag v0.1.2
|
|
118
|
+
git push origin v0.1.2
|
|
113
119
|
```
|
|
114
120
|
|
|
121
|
+
Pushing the tag triggers the release workflow, which builds the package, publishes it to PyPI, generates release notes with `git-cliff`, and creates the GitHub release.
|
|
122
|
+
|
|
115
123
|
Commit messages should follow [Conventional Commits](https://www.conventionalcommits.org/) (`fix:`, `feat:`, `feat!:` for breaking changes).
|
|
116
124
|
|
|
117
125
|
## License
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pepscript"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Typed Python library for working with PEP 723 inline script metadata"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -21,6 +21,8 @@ classifiers = [
|
|
|
21
21
|
|
|
22
22
|
[project.urls]
|
|
23
23
|
Repository = "https://github.com/botlot-project/PEPscript"
|
|
24
|
+
Documentation = "https://botlot-project.github.io/PEPscript/"
|
|
25
|
+
Issues = "https://github.com/botlot-project/PEPscript/issues"
|
|
24
26
|
|
|
25
27
|
[dependency-groups]
|
|
26
28
|
dev = [
|
|
@@ -25,7 +25,8 @@ def read_source(path: Path, *, encoding: str) -> str:
|
|
|
25
25
|
"""Read source from disk."""
|
|
26
26
|
|
|
27
27
|
try:
|
|
28
|
-
|
|
28
|
+
with path.open("r", encoding=encoding, newline="") as handle:
|
|
29
|
+
return handle.read()
|
|
29
30
|
except OSError as error:
|
|
30
31
|
raise FileLoadError(f"Failed to read file: {path}") from error
|
|
31
32
|
|
|
@@ -34,6 +35,7 @@ def write_source(path: Path, source: str, *, encoding: str) -> None:
|
|
|
34
35
|
"""Write source to disk."""
|
|
35
36
|
|
|
36
37
|
try:
|
|
37
|
-
path.
|
|
38
|
+
with path.open("w", encoding=encoding, newline="") as handle:
|
|
39
|
+
handle.write(source)
|
|
38
40
|
except OSError as error:
|
|
39
41
|
raise SaveError(f"Failed to write file: {path}") from error
|
|
@@ -2,12 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import Mapping
|
|
5
6
|
from dataclasses import dataclass, field
|
|
6
7
|
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
7
9
|
|
|
8
10
|
from .config import ToolConfig
|
|
9
11
|
|
|
10
12
|
|
|
13
|
+
def _mapping_is_deep_empty(value: Mapping[str, Any]) -> bool:
|
|
14
|
+
if not value:
|
|
15
|
+
return True
|
|
16
|
+
return all(
|
|
17
|
+
isinstance(item, Mapping) and _mapping_is_deep_empty(item)
|
|
18
|
+
for item in value.values()
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
11
22
|
@dataclass(slots=True)
|
|
12
23
|
class ScriptFileInfo:
|
|
13
24
|
"""Summary of file metadata exposed by PEPScript."""
|
|
@@ -83,5 +94,5 @@ class Metadata:
|
|
|
83
94
|
return (
|
|
84
95
|
not self.dependencies
|
|
85
96
|
and self.requires_python is None
|
|
86
|
-
and
|
|
97
|
+
and _mapping_is_deep_empty(self.config.tool.to_dict())
|
|
87
98
|
)
|
|
@@ -22,11 +22,18 @@ class ParseResult:
|
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def _is_start_marker(line: str) -> bool:
|
|
25
|
-
return line.
|
|
25
|
+
return line.rstrip("\r\n") == "# /// script"
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def _is_end_marker(line: str) -> bool:
|
|
29
|
-
return line.
|
|
29
|
+
return line.rstrip("\r\n") == "# ///"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_indented_marker(line: str) -> bool:
|
|
33
|
+
return line[:1] in {" ", "\t"} and line.lstrip(" \t").rstrip("\r\n") in {
|
|
34
|
+
"# /// script",
|
|
35
|
+
"# ///",
|
|
36
|
+
}
|
|
30
37
|
|
|
31
38
|
|
|
32
39
|
def _raise_parse_error(message: str, *, path: Path | None = None) -> NoReturn:
|
|
@@ -48,12 +55,20 @@ def _find_blocks(source: str, *, path: Path | None = None) -> list[BlockInfo]:
|
|
|
48
55
|
index = 0
|
|
49
56
|
while index < len(lines):
|
|
50
57
|
line = lines[index]
|
|
58
|
+
if _is_indented_marker(line):
|
|
59
|
+
_raise_parse_error(
|
|
60
|
+
"Metadata markers must start at the first column", path=path
|
|
61
|
+
)
|
|
51
62
|
if not _is_start_marker(line):
|
|
52
63
|
index += 1
|
|
53
64
|
continue
|
|
54
65
|
|
|
55
66
|
end_index = index + 1
|
|
56
67
|
while end_index < len(lines) and not _is_end_marker(lines[end_index]):
|
|
68
|
+
if _is_indented_marker(lines[end_index]):
|
|
69
|
+
_raise_parse_error(
|
|
70
|
+
"Metadata markers must start at the first column", path=path
|
|
71
|
+
)
|
|
57
72
|
end_index += 1
|
|
58
73
|
if end_index >= len(lines):
|
|
59
74
|
_raise_parse_error(
|
|
@@ -86,13 +101,12 @@ def _extract_toml_content(
|
|
|
86
101
|
path=path,
|
|
87
102
|
)
|
|
88
103
|
|
|
89
|
-
|
|
90
|
-
if not trimmed.startswith("#"):
|
|
104
|
+
if not line.startswith("#"):
|
|
91
105
|
_raise_parse_error(
|
|
92
|
-
f"Invalid metadata content line {line_number}: expected '#'",
|
|
106
|
+
f"Invalid metadata content line {line_number}: expected '#' at column 1",
|
|
93
107
|
path=path,
|
|
94
108
|
)
|
|
95
|
-
text =
|
|
109
|
+
text = line[1:]
|
|
96
110
|
if text.startswith(" "):
|
|
97
111
|
text = text[1:]
|
|
98
112
|
content_lines.append(text)
|
|
@@ -109,6 +109,17 @@ class PEPScript:
|
|
|
109
109
|
self.meta = parsed.meta if parsed.meta is not None else Metadata()
|
|
110
110
|
self._block = parsed.block
|
|
111
111
|
|
|
112
|
+
def _meta_to_write(self) -> Metadata | None:
|
|
113
|
+
if self.meta.is_empty:
|
|
114
|
+
return None
|
|
115
|
+
return self.meta
|
|
116
|
+
|
|
117
|
+
def _validated_source_for_write(self) -> str:
|
|
118
|
+
meta_to_write = self._meta_to_write()
|
|
119
|
+
if meta_to_write is not None:
|
|
120
|
+
validate_metadata(meta_to_write, path=self.path)
|
|
121
|
+
return rewrite_source(self.source, meta=meta_to_write, block=self._block)
|
|
122
|
+
|
|
112
123
|
def validate(self) -> None:
|
|
113
124
|
"""Run structural validation against the current metadata.
|
|
114
125
|
|
|
@@ -145,10 +156,9 @@ class PEPScript:
|
|
|
145
156
|
|
|
146
157
|
def to_source(self) -> str:
|
|
147
158
|
"""Serialize current state to source text without writing to disk."""
|
|
148
|
-
|
|
149
|
-
self.meta
|
|
159
|
+
return rewrite_source(
|
|
160
|
+
self.source, meta=self._meta_to_write(), block=self._block
|
|
150
161
|
)
|
|
151
|
-
return rewrite_source(self.source, meta=meta_to_write, block=self._block)
|
|
152
162
|
|
|
153
163
|
def save(self) -> None:
|
|
154
164
|
"""Persist the current state to disk, then reload.
|
|
@@ -165,7 +175,9 @@ class PEPScript:
|
|
|
165
175
|
"""
|
|
166
176
|
if self.path is None:
|
|
167
177
|
raise SaveError("Cannot save in-memory script without a file path")
|
|
168
|
-
write_source(
|
|
178
|
+
write_source(
|
|
179
|
+
self.path, self._validated_source_for_write(), encoding=self.encoding
|
|
180
|
+
)
|
|
169
181
|
self.reload()
|
|
170
182
|
|
|
171
183
|
def save_as(self, path: str | Path) -> None:
|
|
@@ -182,7 +194,14 @@ class PEPScript:
|
|
|
182
194
|
SaveError: If the file cannot be written.
|
|
183
195
|
"""
|
|
184
196
|
target = Path(path)
|
|
185
|
-
|
|
197
|
+
meta_to_write = self._meta_to_write()
|
|
198
|
+
if meta_to_write is not None:
|
|
199
|
+
validate_metadata(meta_to_write, path=target)
|
|
200
|
+
write_source(
|
|
201
|
+
target,
|
|
202
|
+
rewrite_source(self.source, meta=meta_to_write, block=self._block),
|
|
203
|
+
encoding=self.encoding,
|
|
204
|
+
)
|
|
186
205
|
self.path = target
|
|
187
206
|
self.reload()
|
|
188
207
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Mapping
|
|
6
|
+
from datetime import date, datetime, time
|
|
6
7
|
import json
|
|
7
8
|
import re
|
|
8
9
|
from typing import Any, cast
|
|
@@ -14,6 +15,13 @@ _BARE_KEY_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
|
14
15
|
_CODING_RE = re.compile(r"^[ \t]*#.*coding[:=][ \t]*[-_.a-zA-Z0-9]+")
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
def _detect_newline(source: str) -> str:
|
|
19
|
+
match = re.search(r"\r\n|\n|\r", source)
|
|
20
|
+
if match is None:
|
|
21
|
+
return "\n"
|
|
22
|
+
return match.group(0)
|
|
23
|
+
|
|
24
|
+
|
|
17
25
|
def _format_key(key: str) -> str:
|
|
18
26
|
if _BARE_KEY_RE.match(key):
|
|
19
27
|
return key
|
|
@@ -38,8 +46,20 @@ def _format_value(value: Any) -> str:
|
|
|
38
46
|
return str(value)
|
|
39
47
|
if isinstance(value, float):
|
|
40
48
|
return repr(value)
|
|
49
|
+
if isinstance(value, datetime):
|
|
50
|
+
return value.isoformat()
|
|
51
|
+
if isinstance(value, date):
|
|
52
|
+
return value.isoformat()
|
|
53
|
+
if isinstance(value, time):
|
|
54
|
+
return value.isoformat()
|
|
41
55
|
if value is None:
|
|
42
56
|
raise TypeError("None is not a TOML scalar value")
|
|
57
|
+
if isinstance(value, Mapping):
|
|
58
|
+
parts = [
|
|
59
|
+
f"{_format_key(cast(str, key))} = {_format_value(item)}"
|
|
60
|
+
for key, item in sorted(value.items())
|
|
61
|
+
]
|
|
62
|
+
return "{ " + ", ".join(parts) + " }"
|
|
43
63
|
if isinstance(value, list):
|
|
44
64
|
inner = ", ".join(_format_value(item) for item in value)
|
|
45
65
|
return f"[{inner}]"
|
|
@@ -91,17 +111,17 @@ def serialize_metadata_toml(meta: Metadata) -> str:
|
|
|
91
111
|
return "\n".join(lines) + "\n"
|
|
92
112
|
|
|
93
113
|
|
|
94
|
-
def render_metadata_block(meta: Metadata) -> str:
|
|
114
|
+
def render_metadata_block(meta: Metadata, *, newline: str = "\n") -> str:
|
|
95
115
|
"""Render a PEP 723 block from metadata."""
|
|
96
116
|
|
|
97
117
|
toml = serialize_metadata_toml(meta)
|
|
98
|
-
output: list[str] = ["# /// script
|
|
118
|
+
output: list[str] = [f"# /// script{newline}"]
|
|
99
119
|
for line in toml.splitlines():
|
|
100
120
|
if line:
|
|
101
|
-
output.append(f"# {line}
|
|
121
|
+
output.append(f"# {line}{newline}")
|
|
102
122
|
else:
|
|
103
|
-
output.append("
|
|
104
|
-
output.append("#
|
|
123
|
+
output.append(f"#{newline}")
|
|
124
|
+
output.append(f"# ///{newline}")
|
|
105
125
|
return "".join(output)
|
|
106
126
|
|
|
107
127
|
|
|
@@ -127,19 +147,21 @@ def rewrite_source(
|
|
|
127
147
|
) -> str:
|
|
128
148
|
"""Rewrite source with inserted/replaced/removed metadata block."""
|
|
129
149
|
|
|
150
|
+
newline = _detect_newline(source)
|
|
151
|
+
|
|
130
152
|
if block is not None:
|
|
131
153
|
if meta is None:
|
|
132
154
|
return source[: block.start] + source[block.end :]
|
|
133
|
-
rendered = render_metadata_block(meta)
|
|
155
|
+
rendered = render_metadata_block(meta, newline=newline)
|
|
134
156
|
return source[: block.start] + rendered + source[block.end :]
|
|
135
157
|
|
|
136
158
|
if meta is None:
|
|
137
159
|
return source
|
|
138
160
|
|
|
139
|
-
rendered = render_metadata_block(meta)
|
|
161
|
+
rendered = render_metadata_block(meta, newline=newline)
|
|
140
162
|
insert_at = _insertion_offset(source)
|
|
141
163
|
before = source[:insert_at]
|
|
142
164
|
after = source[insert_at:]
|
|
143
165
|
|
|
144
|
-
spacer =
|
|
166
|
+
spacer = newline if after and not after.startswith(("\r\n", "\n", "\r")) else ""
|
|
145
167
|
return before + rendered + spacer + after
|
|
@@ -4,8 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
6
|
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import date, datetime, time
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
from typing import NoReturn
|
|
11
|
+
from urllib.parse import urlsplit
|
|
9
12
|
|
|
10
13
|
from .config import ToolConfig
|
|
11
14
|
from .exceptions import MetadataValidationError
|
|
@@ -43,10 +46,29 @@ _VALID_MARKER_VARS = frozenset(
|
|
|
43
46
|
}
|
|
44
47
|
)
|
|
45
48
|
|
|
46
|
-
|
|
49
|
+
_MARKER_TOKEN_RE = re.compile(
|
|
50
|
+
r"""
|
|
51
|
+
\s*(
|
|
52
|
+
\(
|
|
53
|
+
|\)
|
|
54
|
+
|not\s+in\b
|
|
55
|
+
|and\b
|
|
56
|
+
|or\b
|
|
57
|
+
|~=|===|==|!=|<=|>=|<|>
|
|
58
|
+
|in\b
|
|
59
|
+
|'(?:[^'\\]|\\.)*'
|
|
60
|
+
|"(?:[^"\\]|\\.)*"
|
|
61
|
+
|[a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)*
|
|
62
|
+
)
|
|
63
|
+
""",
|
|
64
|
+
re.VERBOSE | re.IGNORECASE,
|
|
65
|
+
)
|
|
66
|
+
|
|
47
67
|
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class _MarkerToken:
|
|
70
|
+
kind: str
|
|
71
|
+
value: str
|
|
50
72
|
|
|
51
73
|
|
|
52
74
|
def _raise_validation_error(message: str, *, path: Path | None = None) -> NoReturn:
|
|
@@ -56,7 +78,7 @@ def _raise_validation_error(message: str, *, path: Path | None = None) -> NoRetu
|
|
|
56
78
|
|
|
57
79
|
|
|
58
80
|
def _is_scalar(value: object) -> bool:
|
|
59
|
-
return isinstance(value, (str, int, float, bool))
|
|
81
|
+
return isinstance(value, (str, int, float, bool, date, time, datetime))
|
|
60
82
|
|
|
61
83
|
|
|
62
84
|
def _validate_tool_value(
|
|
@@ -83,16 +105,134 @@ def _validate_tool_value(
|
|
|
83
105
|
|
|
84
106
|
|
|
85
107
|
def _validate_marker(marker: str, *, loc: str, path: Path | None = None) -> None:
|
|
86
|
-
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
108
|
+
"""Validate marker syntax and ensure only known marker variables are used."""
|
|
109
|
+
|
|
110
|
+
def tokenize(text: str) -> list[_MarkerToken]:
|
|
111
|
+
tokens: list[_MarkerToken] = []
|
|
112
|
+
index = 0
|
|
113
|
+
length = len(text)
|
|
114
|
+
while index < length:
|
|
115
|
+
if text[index].isspace():
|
|
116
|
+
index += 1
|
|
117
|
+
continue
|
|
118
|
+
match = _MARKER_TOKEN_RE.match(text, index)
|
|
119
|
+
if match is None:
|
|
120
|
+
_raise_validation_error(
|
|
121
|
+
f"{loc} has invalid marker syntax near {text[index:]!r}",
|
|
122
|
+
path=path,
|
|
123
|
+
)
|
|
124
|
+
value = match.group(1)
|
|
125
|
+
kind = "IDENT"
|
|
126
|
+
if value == "(":
|
|
127
|
+
kind = "LPAREN"
|
|
128
|
+
elif value == ")":
|
|
129
|
+
kind = "RPAREN"
|
|
130
|
+
elif value.lower() == "and":
|
|
131
|
+
kind = "AND"
|
|
132
|
+
elif value.lower() == "or":
|
|
133
|
+
kind = "OR"
|
|
134
|
+
elif (
|
|
135
|
+
value.lower() == "in" or re.sub(r"\s+", " ", value.lower()) == "not in"
|
|
136
|
+
):
|
|
137
|
+
kind = "OP"
|
|
138
|
+
elif value.startswith(("'", '"')):
|
|
139
|
+
kind = "STRING"
|
|
140
|
+
elif value in {"~=", "===", "==", "!=", "<=", ">=", "<", ">"}:
|
|
141
|
+
kind = "OP"
|
|
142
|
+
tokens.append(_MarkerToken(kind=kind, value=value))
|
|
143
|
+
index = match.end()
|
|
144
|
+
return tokens
|
|
145
|
+
|
|
146
|
+
class MarkerParser:
|
|
147
|
+
def __init__(self, tokens: list[_MarkerToken]):
|
|
148
|
+
self.tokens = tokens
|
|
149
|
+
self.index = 0
|
|
150
|
+
|
|
151
|
+
def current(self) -> _MarkerToken | None:
|
|
152
|
+
if self.index >= len(self.tokens):
|
|
153
|
+
return None
|
|
154
|
+
return self.tokens[self.index]
|
|
155
|
+
|
|
156
|
+
def consume(self, kind: str) -> _MarkerToken:
|
|
157
|
+
token = self.current()
|
|
158
|
+
if token is None or token.kind != kind:
|
|
159
|
+
_raise_validation_error(
|
|
160
|
+
f"{loc} has invalid marker syntax",
|
|
161
|
+
path=path,
|
|
162
|
+
)
|
|
163
|
+
self.index += 1
|
|
164
|
+
return token
|
|
165
|
+
|
|
166
|
+
def parse(self) -> None:
|
|
167
|
+
self.parse_or_expression()
|
|
168
|
+
if self.current() is not None:
|
|
169
|
+
_raise_validation_error(
|
|
170
|
+
f"{loc} has invalid marker syntax",
|
|
171
|
+
path=path,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def parse_or_expression(self) -> None:
|
|
175
|
+
self.parse_and_expression()
|
|
176
|
+
while (token := self.current()) is not None and token.kind == "OR":
|
|
177
|
+
self.consume("OR")
|
|
178
|
+
self.parse_and_expression()
|
|
179
|
+
|
|
180
|
+
def parse_and_expression(self) -> None:
|
|
181
|
+
self.parse_term()
|
|
182
|
+
while (token := self.current()) is not None and token.kind == "AND":
|
|
183
|
+
self.consume("AND")
|
|
184
|
+
self.parse_term()
|
|
185
|
+
|
|
186
|
+
def parse_term(self) -> None:
|
|
187
|
+
token = self.current()
|
|
188
|
+
if token is None:
|
|
189
|
+
_raise_validation_error(
|
|
190
|
+
f"{loc} has invalid marker syntax",
|
|
191
|
+
path=path,
|
|
192
|
+
)
|
|
193
|
+
if token.kind == "LPAREN":
|
|
194
|
+
self.consume("LPAREN")
|
|
195
|
+
self.parse_or_expression()
|
|
196
|
+
self.consume("RPAREN")
|
|
197
|
+
return
|
|
198
|
+
self.parse_comparison()
|
|
199
|
+
|
|
200
|
+
def parse_comparison(self) -> None:
|
|
201
|
+
self.parse_operand()
|
|
202
|
+
self.consume("OP")
|
|
203
|
+
self.parse_operand()
|
|
204
|
+
|
|
205
|
+
def parse_operand(self) -> None:
|
|
206
|
+
token = self.current()
|
|
207
|
+
if token is None or token.kind not in {"IDENT", "STRING"}:
|
|
208
|
+
_raise_validation_error(
|
|
209
|
+
f"{loc} has invalid marker syntax",
|
|
210
|
+
path=path,
|
|
211
|
+
)
|
|
212
|
+
if token.kind == "IDENT" and token.value not in _VALID_MARKER_VARS:
|
|
213
|
+
_raise_validation_error(
|
|
214
|
+
f"{loc} has unknown marker variable {token.value!r}",
|
|
215
|
+
path=path,
|
|
216
|
+
)
|
|
217
|
+
self.index += 1
|
|
218
|
+
|
|
219
|
+
MarkerParser(tokenize(marker)).parse()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _validate_direct_reference(url: str, *, loc: str, path: Path | None = None) -> None:
|
|
223
|
+
if not url:
|
|
224
|
+
_raise_validation_error(f"{loc} has an empty direct reference URL", path=path)
|
|
225
|
+
if any(character.isspace() for character in url):
|
|
226
|
+
_raise_validation_error(
|
|
227
|
+
f"{loc} has invalid whitespace in direct reference URL {url!r}",
|
|
228
|
+
path=path,
|
|
229
|
+
)
|
|
230
|
+
parsed = urlsplit(url)
|
|
231
|
+
if not parsed.scheme or not (parsed.netloc or parsed.path):
|
|
232
|
+
_raise_validation_error(
|
|
233
|
+
f"{loc} has an invalid direct reference URL {url!r}",
|
|
234
|
+
path=path,
|
|
235
|
+
)
|
|
96
236
|
|
|
97
237
|
|
|
98
238
|
def _validate_pep508_dependency(
|
|
@@ -115,8 +255,12 @@ def _validate_pep508_dependency(
|
|
|
115
255
|
req_part = req_part.strip()
|
|
116
256
|
is_url = "@" in req_part
|
|
117
257
|
|
|
118
|
-
|
|
119
|
-
|
|
258
|
+
if is_url:
|
|
259
|
+
name_scope, url_part = req_part.split("@", 1)
|
|
260
|
+
name_scope = name_scope.strip()
|
|
261
|
+
_validate_direct_reference(url_part.strip(), loc=loc, path=path)
|
|
262
|
+
else:
|
|
263
|
+
name_scope = req_part
|
|
120
264
|
|
|
121
265
|
# Extract package name (stops at [, version operator chars, whitespace, or end)
|
|
122
266
|
name_match = re.match(r"[A-Za-z0-9][A-Za-z0-9._-]*", name_scope)
|
|
@@ -141,7 +285,9 @@ def _validate_pep508_dependency(
|
|
|
141
285
|
for extra in rest[1:close].split(","):
|
|
142
286
|
e = extra.strip()
|
|
143
287
|
if not e:
|
|
144
|
-
|
|
288
|
+
_raise_validation_error(
|
|
289
|
+
f"{loc} has an empty extra in {dep!r}", path=path
|
|
290
|
+
)
|
|
145
291
|
if not _NAME_RE.match(e):
|
|
146
292
|
_raise_validation_error(f"{loc} has invalid extra {e!r}", path=path)
|
|
147
293
|
rest = rest[close + 1 :].strip()
|
|
@@ -154,6 +300,13 @@ def _validate_pep508_dependency(
|
|
|
154
300
|
|
|
155
301
|
# Version specifiers (not applicable for URL requirements)
|
|
156
302
|
if not is_url and rest:
|
|
303
|
+
if rest.startswith("("):
|
|
304
|
+
if not rest.endswith(")"):
|
|
305
|
+
_raise_validation_error(
|
|
306
|
+
f"{loc} has unclosed version specifier parentheses in {dep!r}",
|
|
307
|
+
path=path,
|
|
308
|
+
)
|
|
309
|
+
rest = rest[1:-1].strip()
|
|
157
310
|
for clause in rest.split(","):
|
|
158
311
|
if not _VERSION_CLAUSE_RE.match(clause):
|
|
159
312
|
_raise_validation_error(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|