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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pepscript
3
- Version: 0.1.0
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
- line_length = script.meta.config.tool.ruff.line_length
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. Tag and push:
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 tag v0.2.0
130
- git push --tags
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
- line_length = script.meta.config.tool.ruff.line_length
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. Tag and push:
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 tag v0.2.0
112
- git push --tags
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.0"
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
- return path.read_text(encoding=encoding)
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.write_text(source, encoding=encoding)
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 not self.config.tool.to_dict()
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.strip() == "# /// script"
25
+ return line.rstrip("\r\n") == "# /// script"
26
26
 
27
27
 
28
28
  def _is_end_marker(line: str) -> bool:
29
- return line.strip() == "# ///"
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
- trimmed = line.lstrip(" \t")
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 = trimmed[1:]
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
- meta_to_write = (
149
- self.meta if (self.has_metadata or not self.meta.is_empty) else None
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(self.path, self.to_source(), encoding=self.encoding)
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
- write_source(target, self.to_source(), encoding=self.encoding)
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\n"]
118
+ output: list[str] = [f"# /// script{newline}"]
99
119
  for line in toml.splitlines():
100
120
  if line:
101
- output.append(f"# {line}\n")
121
+ output.append(f"# {line}{newline}")
102
122
  else:
103
- output.append("#\n")
104
- output.append("# ///\n")
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 = "\n" if after and not after.startswith("\n") else ""
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
- _MARKER_KEYWORDS = frozenset({"and", "or", "not", "in"})
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
- # Matches simple and dotted identifiers (e.g. python_version, os.name)
49
- _MARKER_IDENT_RE = re.compile(r"\b([a-z_][a-z0-9_]*(?:\.[a-z_][a-z0-9_]*)*)\b")
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
- """Check that all unquoted identifiers in a marker expression are valid."""
87
- without_quotes = re.sub(r'"[^"]*"|\'[^\']*\'', "", marker)
88
- for match in _MARKER_IDENT_RE.finditer(without_quotes):
89
- ident = match.group(1)
90
- if ident in _MARKER_KEYWORDS:
91
- continue
92
- if ident not in _VALID_MARKER_VARS:
93
- _raise_validation_error(
94
- f"{loc} has unknown marker variable {ident!r}", path=path
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
- # For URL requirements validate only the name/extras part before the @
119
- name_scope = req_part[: req_part.index("@")].strip() if is_url else req_part
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
- continue
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(