swcgeom 0.15.0__py3-none-any.whl → 0.17.0__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.

Potentially problematic release.


This version of swcgeom might be problematic. Click here for more details.

Files changed (42) hide show
  1. swcgeom/_version.py +2 -2
  2. swcgeom/analysis/__init__.py +1 -3
  3. swcgeom/analysis/feature_extractor.py +3 -3
  4. swcgeom/analysis/{node_features.py → features.py} +105 -3
  5. swcgeom/analysis/lmeasure.py +821 -0
  6. swcgeom/analysis/sholl.py +31 -2
  7. swcgeom/core/__init__.py +4 -0
  8. swcgeom/core/branch.py +9 -4
  9. swcgeom/core/{segment.py → compartment.py} +14 -9
  10. swcgeom/core/node.py +0 -8
  11. swcgeom/core/path.py +21 -6
  12. swcgeom/core/population.py +47 -7
  13. swcgeom/core/swc_utils/assembler.py +12 -1
  14. swcgeom/core/swc_utils/base.py +12 -5
  15. swcgeom/core/swc_utils/checker.py +12 -2
  16. swcgeom/core/tree.py +34 -37
  17. swcgeom/core/tree_utils.py +4 -0
  18. swcgeom/images/augmentation.py +6 -1
  19. swcgeom/images/contrast.py +107 -0
  20. swcgeom/images/folder.py +71 -14
  21. swcgeom/images/io.py +74 -88
  22. swcgeom/transforms/__init__.py +2 -0
  23. swcgeom/transforms/image_preprocess.py +100 -0
  24. swcgeom/transforms/image_stack.py +1 -4
  25. swcgeom/transforms/images.py +176 -5
  26. swcgeom/transforms/mst.py +5 -5
  27. swcgeom/transforms/neurolucida_asc.py +495 -0
  28. swcgeom/transforms/tree.py +5 -1
  29. swcgeom/utils/__init__.py +1 -0
  30. swcgeom/utils/neuromorpho.py +425 -300
  31. swcgeom/utils/numpy_helper.py +14 -4
  32. swcgeom/utils/plotter_2d.py +130 -0
  33. swcgeom/utils/renderer.py +28 -139
  34. swcgeom/utils/sdf.py +5 -1
  35. {swcgeom-0.15.0.dist-info → swcgeom-0.17.0.dist-info}/METADATA +3 -3
  36. swcgeom-0.17.0.dist-info/RECORD +65 -0
  37. {swcgeom-0.15.0.dist-info → swcgeom-0.17.0.dist-info}/WHEEL +1 -1
  38. swcgeom/analysis/branch_features.py +0 -67
  39. swcgeom/analysis/path_features.py +0 -37
  40. swcgeom-0.15.0.dist-info/RECORD +0 -62
  41. {swcgeom-0.15.0.dist-info → swcgeom-0.17.0.dist-info}/LICENSE +0 -0
  42. {swcgeom-0.15.0.dist-info → swcgeom-0.17.0.dist-info}/top_level.txt +0 -0
@@ -1,17 +1,31 @@
1
1
  """Image stack related transform."""
2
2
 
3
-
3
+ import warnings
4
4
  from typing import Tuple
5
5
 
6
6
  import numpy as np
7
7
  import numpy.typing as npt
8
8
 
9
- from swcgeom.transforms.base import Transform
9
+ from swcgeom.transforms.base import Identity, Transform
10
+
11
+ __all__ = [
12
+ "ImagesCenterCrop",
13
+ "ImagesScale",
14
+ "ImagesClip",
15
+ "ImagesFlip",
16
+ "ImagesFlipY",
17
+ "ImagesNormalizer",
18
+ "ImagesMeanVarianceAdjustment",
19
+ "ImagesScaleToUnitRange",
20
+ "ImagesHistogramEqualization",
21
+ "Center", # legacy
22
+ ]
10
23
 
11
- __all__ = ["Center"]
12
24
 
25
+ NDArrayf32 = npt.NDArray[np.float32]
13
26
 
14
- class Center(Transform[npt.NDArray[np.float32], npt.NDArray[np.float32]]):
27
+
28
+ class ImagesCenterCrop(Transform[NDArrayf32, NDArrayf32]):
15
29
  """Get image stack center."""
16
30
 
17
31
  def __init__(self, shape_out: int | Tuple[int, int, int]):
@@ -22,7 +36,7 @@ class Center(Transform[npt.NDArray[np.float32], npt.NDArray[np.float32]]):
22
36
  else (shape_out, shape_out, shape_out)
23
37
  )
24
38
 
25
- def __call__(self, x: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]:
39
+ def __call__(self, x: NDArrayf32) -> NDArrayf32:
26
40
  diff = np.subtract(x.shape[:3], self.shape_out)
27
41
  s = diff // 2
28
42
  e = np.add(s, self.shape_out)
@@ -30,3 +44,160 @@ class Center(Transform[npt.NDArray[np.float32], npt.NDArray[np.float32]]):
30
44
 
31
45
  def extra_repr(self) -> str:
32
46
  return f"shape_out=({','.join(str(a) for a in self.shape_out)})"
47
+
48
+
49
+ class Center(ImagesCenterCrop):
50
+ """Get image stack center.
51
+
52
+ .. deprecated:: 0.16.0
53
+ Use :class:`ImagesCenterCrop` instead.
54
+ """
55
+
56
+ def __init__(self, shape_out: int | Tuple[int, int, int]):
57
+ warnings.warn(
58
+ "`Center` is deprecated, use `ImagesCenterCrop` instead",
59
+ DeprecationWarning,
60
+ stacklevel=2,
61
+ )
62
+ super().__init__(shape_out)
63
+
64
+
65
+ class ImagesScale(Transform[NDArrayf32, NDArrayf32]):
66
+ def __init__(self, scaler: float) -> None:
67
+ super().__init__()
68
+ self.scaler = scaler
69
+
70
+ def __call__(self, x: NDArrayf32) -> NDArrayf32:
71
+ return self.scaler * x
72
+
73
+ def extra_repr(self) -> str:
74
+ return f"scaler={self.scaler}"
75
+
76
+
77
+ class ImagesClip(Transform[NDArrayf32, NDArrayf32]):
78
+ def __init__(self, vmin: float = 0, vmax: float = 1, /) -> None:
79
+ super().__init__()
80
+ self.vmin, self.vmax = vmin, vmax
81
+
82
+ def __call__(self, x: NDArrayf32) -> NDArrayf32:
83
+ return np.clip(x, self.vmin, self.vmax)
84
+
85
+ def extra_repr(self) -> str:
86
+ return f"vmin={self.vmin}, vmax={self.vmax}"
87
+
88
+
89
+ class ImagesFlip(Transform[NDArrayf32, NDArrayf32]):
90
+ """Flip image stack along axis."""
91
+
92
+ def __init__(self, axis: int, /) -> None:
93
+ super().__init__()
94
+ self.axis = axis
95
+
96
+ def __call__(self, x: NDArrayf32) -> NDArrayf32:
97
+ return np.flip(x, axis=self.axis)
98
+
99
+ def extra_repr(self) -> str:
100
+ return f"axis={self.axis}"
101
+
102
+
103
+ class ImagesFlipY(ImagesFlip):
104
+ """Flip image stack along Y-axis.
105
+
106
+ See Also
107
+ --------
108
+ ~.images.io.TeraflyImageStack:
109
+ Terafly and Vaa3d use a especial right-handed coordinate system
110
+ (with origin point in the left-top and z-axis points front),
111
+ but we flip y-axis to makes it a left-handed coordinate system
112
+ (with orgin point in the left-bottom and z-axis points front).
113
+ If you need to use its coordinate system, remember to FLIP
114
+ Y-AXIS BACK.
115
+ """
116
+
117
+ def __init__(self, axis: int = 1, /) -> None:
118
+ super().__init__(axis) # (X, Y, Z, C)
119
+
120
+
121
+ class ImagesNormalizer(Transform[NDArrayf32, NDArrayf32]):
122
+ """Normalize image stack."""
123
+
124
+ def __call__(self, x: NDArrayf32) -> NDArrayf32:
125
+ mean = np.mean(x)
126
+ variance = np.var(x)
127
+ return (x - mean) / variance
128
+
129
+
130
+ class ImagesMeanVarianceAdjustment(Transform[NDArrayf32, NDArrayf32]):
131
+ """Adjust image stack mean and variance.
132
+
133
+ See Also
134
+ --------
135
+ ~swcgeom.images.ImageStackFolder.stat
136
+ """
137
+
138
+ def __init__(self, mean: float, variance: float) -> None:
139
+ super().__init__()
140
+ self.mean = mean
141
+ self.variance = variance
142
+
143
+ def __call__(self, x: NDArrayf32) -> NDArrayf32:
144
+ return (x - self.mean) / self.variance
145
+
146
+ def extra_repr(self) -> str:
147
+ return f"mean={self.mean}, variance={self.variance}"
148
+
149
+
150
+ class ImagesScaleToUnitRange(Transform[NDArrayf32, NDArrayf32]):
151
+ """Scale image stack to unit range."""
152
+
153
+ def __init__(self, vmin: float, vmax: float, *, clip: bool = True) -> None:
154
+ """Scale image stack to unit range.
155
+
156
+ Parameters
157
+ ----------
158
+ vmin : float
159
+ Minimum value.
160
+ vmax : float
161
+ Maximum value.
162
+ clip : bool, default True
163
+ Clip values to [0, 1] to avoid numerical issues.
164
+ """
165
+
166
+ super().__init__()
167
+ self.vmin = vmin
168
+ self.vmax = vmax
169
+ self.diff = vmax - vmin
170
+ self.clip = clip
171
+ self.post = ImagesClip(0, 1) if self.clip else Identity()
172
+
173
+ def __call__(self, x: NDArrayf32) -> NDArrayf32:
174
+ return self.post((x - self.vmin) / self.diff)
175
+
176
+ def extra_repr(self) -> str:
177
+ return f"vmin={self.vmin}, vmax={self.vmax}, clip={self.clip}"
178
+
179
+
180
+ class ImagesHistogramEqualization(Transform[NDArrayf32, NDArrayf32]):
181
+ """Image histogram equalization.
182
+
183
+ References
184
+ ----------
185
+ http://www.janeriksolem.net/histogram-equalization-with-python-and.html
186
+ """
187
+
188
+ def __init__(self, bins: int = 256) -> None:
189
+ super().__init__()
190
+ self.bins = bins
191
+
192
+ def __call__(self, x: NDArrayf32) -> NDArrayf32:
193
+ # get image histogram
194
+ hist, bin_edges = np.histogram(x.flatten(), self.bins, density=True)
195
+ cdf = hist.cumsum() # cumulative distribution function
196
+ cdf = cdf / cdf[-1] # normalize
197
+
198
+ # use linear interpolation of cdf to find new pixel values
199
+ equalized = np.interp(x.flatten(), bin_edges[:-1], cdf)
200
+ return equalized.reshape(x.shape).astype(np.float32)
201
+
202
+ def extra_repr(self) -> str:
203
+ return f"bins={self.bins}"
swcgeom/transforms/mst.py CHANGED
@@ -23,11 +23,11 @@ class PointsToCuntzMST(Transform[npt.NDArray[np.float32], Tree]):
23
23
 
24
24
  References
25
25
  ----------
26
- [1] Cuntz, H., Forstner, F., Borst, A. & Häusser, M. One Rule to
27
- Grow Them Al: A General Theory of Neuronal Branching and Its
28
- Practical Application. PLOS Comput Biol 6, e1000877 (2010).
29
- [2] Cuntz, H., Borst, A. & Segev, I. Optimization principles of
30
- dendritic structure. Theor Biol Med Model 4, 21 (2007).
26
+ .. [1] Cuntz, H., Forstner, F., Borst, A. & Häusser, M. One Rule to
27
+ Grow Them Al: A General Theory of Neuronal Branching and Its
28
+ Practical Application. PLOS Comput Biol 6, e1000877 (2010).
29
+ .. [2] Cuntz, H., Borst, A. & Segev, I. Optimization principles of
30
+ dendritic structure. Theor Biol Med Model 4, 21 (2007).
31
31
  """
32
32
 
33
33
  def __init__(
@@ -0,0 +1,495 @@
1
+ """Neurolucida related transformation."""
2
+
3
+ import os
4
+ import re
5
+ from enum import Enum, auto
6
+ from io import TextIOBase
7
+ from typing import Any, List, NamedTuple, Optional, cast
8
+
9
+ import numpy as np
10
+
11
+ from swcgeom.core import Tree
12
+ from swcgeom.core.swc_utils import SWCNames, SWCTypes, get_names, get_types
13
+ from swcgeom.transforms.base import Transform
14
+
15
+ __all__ = ["NeurolucidaAscToSwc"]
16
+
17
+
18
+ class NeurolucidaAscToSwc(Transform[str, Tree]):
19
+ """Convert neurolucida asc format to swc format."""
20
+
21
+ def __call__(self, x: str) -> Tree:
22
+ return self.convert(x)
23
+
24
+ @classmethod
25
+ def convert(cls, fname: str) -> Tree:
26
+ with open(fname, "r") as f:
27
+ tree = cls.from_stream(f, source=os.path.abspath(fname))
28
+
29
+ return tree
30
+
31
+ @classmethod
32
+ def from_stream(cls, x: TextIOBase, *, source: str = "") -> Tree:
33
+ parser = Parser(x, source=source)
34
+ ast = parser.parse()
35
+ tree = cls.from_ast(ast)
36
+ return tree
37
+
38
+ @staticmethod
39
+ def from_ast(
40
+ ast: "AST",
41
+ *,
42
+ names: Optional[SWCNames] = None,
43
+ types: Optional[SWCTypes] = None,
44
+ ) -> Tree:
45
+ names = get_names(names)
46
+ types = get_types(types)
47
+ ndata = {n: [] for n in names.cols()}
48
+
49
+ next_id = 0
50
+ typee = [types.undefined]
51
+
52
+ def walk_ast(root: ASTNode, pid: int = -1) -> None:
53
+ nonlocal next_id, typee
54
+ match root.type:
55
+ case ASTType.ROOT:
56
+ for n in root.children:
57
+ walk_ast(n)
58
+
59
+ case ASTType.TREE:
60
+ match root.value:
61
+ case "AXON":
62
+ typee.append(types.axon)
63
+ case "DENDRITE":
64
+ typee.append(types.basal_dendrite)
65
+
66
+ for n in root.children:
67
+ walk_ast(n)
68
+
69
+ typee.pop()
70
+
71
+ case ASTType.NODE:
72
+ x, y, z, r = root.value
73
+ idx = next_id
74
+ next_id += 1
75
+
76
+ ndata[names.id].append(idx)
77
+ ndata[names.type].append(typee[-1])
78
+ ndata[names.x].append(x)
79
+ ndata[names.y].append(y)
80
+ ndata[names.z].append(z)
81
+ ndata[names.r].append(r)
82
+ ndata[names.pid].append(pid)
83
+
84
+ for n in root.children:
85
+ walk_ast(n, pid=idx)
86
+
87
+ walk_ast(ast)
88
+ tree = Tree(
89
+ next_id,
90
+ source=ast.source,
91
+ names=names,
92
+ **ndata, # type: ignore
93
+ )
94
+ return tree
95
+
96
+
97
+ # -----------------
98
+ # ASC format parser
99
+ # -----------------
100
+
101
+ # AST
102
+
103
+
104
+ class ASTType(Enum):
105
+ ROOT = auto()
106
+ TREE = auto()
107
+ NODE = auto()
108
+ COLOR = auto()
109
+ COMMENT = auto()
110
+
111
+
112
+ class ASTNode:
113
+ parent: "ASTNode | None" = None
114
+
115
+ def __init__(
116
+ self,
117
+ type: ASTType,
118
+ value: Any = None,
119
+ tokens: Optional[List["Token"]] = None,
120
+ children: Optional[List["ASTNode"]] = None,
121
+ ):
122
+ self.type = type
123
+ self.value = value
124
+ self.tokens = tokens or []
125
+ self.children = children or []
126
+ for child in self.children:
127
+ child.parent = self
128
+
129
+ def add_child(self, child: "ASTNode") -> None:
130
+ self.children.append(child)
131
+ child.parent = self
132
+ if child.tokens is not None:
133
+ self.tokens.extend(child.tokens)
134
+
135
+ def __eq__(self, __value: object) -> bool:
136
+ """
137
+ Compare two ASTNode objects.
138
+
139
+ Notes
140
+ -----
141
+ The `parent`, `tokens` attribute is not compared.
142
+ """
143
+ return (
144
+ isinstance(__value, ASTNode)
145
+ and self.type == __value.type
146
+ and self.value == __value.value
147
+ and self.children == __value.children
148
+ )
149
+
150
+
151
+ class AST(ASTNode):
152
+ def __init__(self, children: Optional[List[ASTNode]] = None, source: str = ""):
153
+ super().__init__(ASTType.ROOT, children=children)
154
+ self.source = source
155
+
156
+
157
+ # ASC values
158
+
159
+
160
+ class ASCNode(NamedTuple):
161
+ x: float
162
+ y: float
163
+ z: float
164
+ r: float
165
+
166
+
167
+ class ASCColor(NamedTuple):
168
+ color: str
169
+
170
+ def __eq__(self, __value: object) -> bool:
171
+ return (
172
+ isinstance(__value, ASCColor)
173
+ and self.color.upper() == __value.color.upper()
174
+ )
175
+
176
+
177
+ class ASCComment(NamedTuple):
178
+ comment: str
179
+
180
+
181
+ # Error
182
+
183
+
184
+ class TokenTypeError(ValueError):
185
+ def __init__(self, token: "Token", expected: str):
186
+ super().__init__(
187
+ f"Unexpected token {token.type.name} `{token.value}` at {token.lineno}:{token.column}, expected {expected}"
188
+ )
189
+
190
+
191
+ class LiteralTokenError(ValueError):
192
+ def __init__(self, token: "Token", expected: str):
193
+ super().__init__(
194
+ f"Unexpected LITERAL token {token.value} at {token.lineno}:{token.column}, expected {expected}"
195
+ )
196
+
197
+
198
+ class AssertionTokenTypeError(Exception):
199
+ pass
200
+
201
+
202
+ # Parser
203
+
204
+
205
+ class Parser:
206
+ def __init__(self, r: TextIOBase, *, source: str = ""):
207
+ self.lexer = Lexer(r)
208
+ self.next_token = None
209
+ self.source = source
210
+ self._read_token()
211
+
212
+ def parse(self) -> AST:
213
+ try:
214
+ return self._parse()
215
+ except AssertionTokenTypeError as assertion_err:
216
+ msg = (
217
+ f"Error parsing {self.source}" if self.source != "" else "Error parsing"
218
+ )
219
+ original_error = assertion_err.__cause__
220
+ err = ValueError(msg)
221
+ if original_error is None:
222
+ raise err
223
+
224
+ ignores = ["_assert_and_cunsume", "_assert"]
225
+ current = assertion_err.__traceback__
226
+ while current is not None:
227
+ if (
228
+ current.tb_next is not None
229
+ and current.tb_next.tb_frame.f_code.co_name in ignores
230
+ ):
231
+ current.tb_next = None
232
+ else:
233
+ current = current.tb_next
234
+
235
+ original_error.__traceback__ = assertion_err.__traceback__
236
+
237
+ raise err from original_error
238
+ except Exception as original_error:
239
+ msg = f"Error parsing {self.source}" if self.source else "Error parsing"
240
+ raise ValueError(msg) from original_error
241
+
242
+ def _parse(self) -> AST:
243
+ root = AST(source=self.source)
244
+
245
+ token = self._assert_and_cunsume(TokenType.BRACKET_LEFT)
246
+ root.tokens.append(token)
247
+
248
+ while (token := self.next_token) is not None:
249
+ if token.type == TokenType.BRACKET_RIGHT:
250
+ break
251
+
252
+ if token.type != TokenType.BRACKET_LEFT:
253
+ raise TokenTypeError(token, "BRACKET_LEFT, BRACKET_RIGHT")
254
+
255
+ root.tokens.append(token)
256
+ self._consume()
257
+
258
+ token = self._assert(self.next_token, TokenType.LITERAL)
259
+ match str.upper(token.value):
260
+ case "AXON" | "DENDRITE":
261
+ self._parse_tree(root)
262
+
263
+ case "COLOR":
264
+ self._parse_color(root) # TODO: bug
265
+
266
+ case _:
267
+ raise LiteralTokenError(token, "AXON, DENDRITE, COLOR")
268
+
269
+ token = self._assert(self.next_token, TokenType.BRACKET_RIGHT)
270
+ token = self._assert_and_cunsume(TokenType.BRACKET_RIGHT)
271
+ root.tokens.append(token)
272
+ return root
273
+
274
+ def _parse_tree(self, root: ASTNode) -> None:
275
+ t1 = self._assert_and_cunsume(TokenType.LITERAL)
276
+ node = ASTNode(ASTType.TREE, str.upper(t1.value), tokens=[t1])
277
+
278
+ t2 = self._assert_and_cunsume(TokenType.BRACKET_RIGHT)
279
+ node.tokens.append(t2)
280
+
281
+ t3 = self._assert_and_cunsume(TokenType.BRACKET_LEFT)
282
+ node.tokens.append(t3)
283
+
284
+ self._parse_subtree(node)
285
+ root.add_child(node)
286
+
287
+ def _parse_subtree(self, root: ASTNode) -> None:
288
+ flag = True # flag to check if the brachet_left can be consumed
289
+ current = root
290
+ while (token := self.next_token) is not None:
291
+ match token.type:
292
+ case TokenType.BRACKET_LEFT:
293
+ self._read_token()
294
+ if flag:
295
+ flag = False
296
+ else:
297
+ self._parse_subtree(current)
298
+
299
+ case TokenType.BRACKET_RIGHT:
300
+ break
301
+
302
+ case TokenType.FLOAT:
303
+ current = self._parse_node(current)
304
+ flag = True
305
+
306
+ case TokenType.LITERAL:
307
+ match str.upper(token.value):
308
+ case "COLOR":
309
+ self._parse_color(current)
310
+ case _:
311
+ raise LiteralTokenError(token, "COLOR")
312
+
313
+ flag = True
314
+
315
+ case TokenType.OR:
316
+ current = root
317
+ self._read_token()
318
+ flag = True
319
+
320
+ case TokenType.COMMENT:
321
+ self._parse_comment(current)
322
+
323
+ case _:
324
+ excepted = (
325
+ "BRACKET_LEFT, BRACKET_RIGHT, LITERAL, FLOAT, OR, COMMENT"
326
+ )
327
+ raise TokenTypeError(token, excepted)
328
+
329
+ current.tokens.append(token)
330
+
331
+ def _parse_node(self, root: ASTNode) -> ASTNode:
332
+ # FLOAT FLOAT FLOAT FLOAT )
333
+ t1 = self._assert_and_cunsume(TokenType.FLOAT)
334
+ t2 = self._assert(self.next_token, TokenType.FLOAT)
335
+ self._read_token()
336
+ t3 = self._assert(self.next_token, TokenType.FLOAT)
337
+ self._read_token()
338
+ t4 = self._assert(self.next_token, TokenType.FLOAT)
339
+ self._read_token()
340
+ t5 = self._assert_and_cunsume(TokenType.BRACKET_RIGHT)
341
+
342
+ x, y, z, r = t1.value, t2.value, t3.value, t4.value
343
+ node = ASTNode(ASTType.NODE, ASCNode(x, y, z, r), tokens=[t1, t2, t3, t4, t5])
344
+ root.add_child(node)
345
+ return node
346
+
347
+ def _parse_color(self, root: ASTNode) -> ASTNode:
348
+ # COLOR COLOR_VALUE )
349
+ t1 = self._assert_and_cunsume(TokenType.LITERAL)
350
+ t2 = self._assert_and_cunsume(TokenType.LITERAL)
351
+ t3 = self._assert_and_cunsume(TokenType.BRACKET_RIGHT)
352
+
353
+ node = ASTNode(ASTType.COLOR, ASCColor(t2.value), tokens=[t1, t2, t3])
354
+ root.add_child(node)
355
+ return node
356
+
357
+ def _parse_comment(self, root: ASTNode) -> ASTNode:
358
+ # ; COMMENT
359
+ t1 = self._assert_and_cunsume(TokenType.COMMENT)
360
+ node = ASTNode(ASTType.COMMENT, ASCComment(t1.value), tokens=[t1])
361
+ root.add_child(node) # ? where the comment should be added
362
+ return node
363
+
364
+ def _read_token(self) -> None:
365
+ self.next_token = next(self.lexer, None)
366
+
367
+ def _assert_and_cunsume(self, type: "TokenType") -> "Token":
368
+ token = self._consume()
369
+ token = self._assert(token, type)
370
+ return cast(Token, token)
371
+
372
+ def _assert(self, token: "Token | None", type: "TokenType") -> "Token":
373
+ if token is None:
374
+ raise AssertionTokenTypeError() from ValueError("Unexpected EOF")
375
+
376
+ if token.type != type:
377
+ raise AssertionTokenTypeError() from TokenTypeError(token, type.name)
378
+
379
+ return token
380
+
381
+ def _consume(self) -> "Token | None":
382
+ token = self.next_token
383
+ self._read_token()
384
+ return token
385
+
386
+
387
+ # -----------------
388
+ # ASC format lexer
389
+ # -----------------
390
+
391
+
392
+ class TokenType(Enum):
393
+ BRACKET_LEFT = auto()
394
+ BRACKET_RIGHT = auto()
395
+ COMMENT = auto()
396
+ OR = auto()
397
+ FLOAT = auto()
398
+ LITERAL = auto()
399
+
400
+
401
+ class Token:
402
+ def __init__(self, type: TokenType, value: Any, lineno: int, column: int):
403
+ self.type = type
404
+ self.value = value
405
+ self.lineno = lineno
406
+ self.column = column
407
+
408
+ def __repr__(self) -> str:
409
+ return f"Token({self.type.name}, {self.value}, Position={self.lineno}:{self.column})"
410
+
411
+
412
+ RE_FLOAT = re.compile(r"[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?")
413
+
414
+
415
+ class Lexer:
416
+ def __init__(self, r: TextIOBase):
417
+ self.r = r
418
+ self.lineno = 1
419
+ self.column = 1
420
+ self.next_char = self.r.read(1)
421
+
422
+ def __iter__(self):
423
+ return self
424
+
425
+ def __next__(self) -> Token:
426
+ match (word := self._read_word()):
427
+ case "":
428
+ raise StopIteration
429
+
430
+ case "(":
431
+ return self._token(TokenType.BRACKET_LEFT, word)
432
+
433
+ case ")":
434
+ return self._token(TokenType.BRACKET_RIGHT, word)
435
+
436
+ case ";":
437
+ return self._token(TokenType.COMMENT, self._read_line())
438
+
439
+ case "|":
440
+ return self._token(TokenType.OR, word)
441
+
442
+ case _ if RE_FLOAT.match(word) is not None:
443
+ return self._token(TokenType.FLOAT, float(word))
444
+
445
+ case _:
446
+ return self._token(TokenType.LITERAL, word)
447
+
448
+ def _read_char(self) -> bool:
449
+ self.next_char = self.r.read(1)
450
+ if self.next_char == "":
451
+ return False
452
+
453
+ if self.next_char == "\n":
454
+ self.lineno += 1
455
+ self.column = 1
456
+ else:
457
+ self.column += 1
458
+ return True
459
+
460
+ def _read_word(self) -> str:
461
+ # skip leading spaces
462
+ while self.next_char != "" and self.next_char in " \t\n":
463
+ self._read_char()
464
+
465
+ token = ""
466
+ while self.next_char != "" and self.next_char not in " \t\n();|":
467
+ token += self.next_char
468
+ self._read_char()
469
+
470
+ if token != "":
471
+ return token
472
+
473
+ if self.next_char == "":
474
+ return ""
475
+
476
+ ch = self.next_char
477
+ self._read_char()
478
+ return ch
479
+
480
+ def _read_line(self) -> str:
481
+ if self.next_char != "\n":
482
+ line = self.r.readline()
483
+ line = self.next_char + line
484
+ if line.endswith("\n"):
485
+ line = line[:-1]
486
+ else:
487
+ line = ""
488
+
489
+ self.lineno += 1
490
+ self.column = 1
491
+ self.next_char = self.r.read(1)
492
+ return line
493
+
494
+ def _token(self, type: TokenType, value: Any) -> Token:
495
+ return Token(type, value, self.lineno, self.column)