-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathkicad.py
2942 lines (2338 loc) · 98.7 KB
/
kicad.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright (c) 2023 Axel Voitier
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
# spell-checker:enableCompoundWords
# spell-checker:words kicad altium geda pyparsing uncasted reexport keebs
# spell-checker:ignore sexpr descr alphanums subcls uvia mult tstamp Pcbplotparams
""""""
from __future__ import annotations
# System imports
import collections
import functools
import logging
import math
import re
import types
import typing
import uuid
from abc import abstractmethod
from contextlib import _GeneratorContextManager, contextmanager, suppress
from copy import deepcopy
from enum import Enum
from functools import partial
from itertools import chain
from pathlib import Path
from types import GeneratorType
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Callable,
ClassVar,
Iterator,
Literal,
Optional,
Protocol,
Union,
overload,
runtime_checkable,
)
# Third-party imports
import attrs
import clyo
from attr import field, fields
from attrs import define
from clyo import ProgressContext, StatusContext
from rich import print
from typing_extensions import Self
# Local imports
if TYPE_CHECKING:
from collections.abc import Iterable
from numbers import Number
from typing import TypeAlias
from ergogen import Keyboard, Points
SExpr: TypeAlias = str | float | int | list['SExpr']
_logger = logging.getLogger(__name__)
kicad_cli = clyo.ClyoTyper(help='KiCAD-related commands')
class SParser:
# The following is no longer used as pyparsing is ULTRA slow
# # Inspired by https://gist.github.com/hastern/ac2d7eab7a2a85f588d1
# _Open = pp.Suppress('(')
# _String = pp.QuotedString(quoteChar='"', unquoteResults=True)
# _Attribute = _String ^ pp.common.number ^ pp.Word(pp.alphanums + '_-.')
# _Close = pp.Suppress(')')
# _SExpr = pp.Forward()
# _SubSExpr = pp.Group(pp.ZeroOrMore(_SExpr | _Attribute))
# _SExpr << (_Open + _SubSExpr + _Close)
# @classmethod
# def _parse_pyparsing(cls, content: str, *, unquote: bool = True) -> SExpr:
# if not unquote:
# cls._String.unquote_results = False
# result = cls._SExpr.parse_string(content).as_list()[0]
# # print(f'Done parsing: {len(result)} elements')
# if not unquote:
# cls._String.unquote_results = True
# return result
# The following regexp-based version is inspired by:
# https://rosettacode.org/wiki/S-expressions#Python
# Adapted to fix a few quirks, comply with KiCad variant of SExpr (numbers and strings),
# and optimise the speed of it.
# sq: handles escaped "
# num:
# - accept +number notation
# - accept scientific notation, eg. +1.23e4 (that's for some buggy ergogen
# outputs with almost nul values)
# - match only if followed by space, ) or newline. This allows for:
# - avoid catching SEM.VER.SION style as a number (SEM.VER) and a string (.SION)
# - avoid breaking apart things like 0x1234 (will match as string instead)
_SExpr_RE = r"""(?mx)
\s*(?:
(?P<lparen>\() | # Opening parenthesis
(?P<rparen>\)) | # Closing parenthesis
(?P<number>(?:[+-]?\d+\.\d+(?=[ )\n])) | # Real
(?:[+-]?\d+(?=[ )\n])) | # Integer
(?:[+-]?\d+\.\d+[eE][+-]?\d+(?=[ )\n])) | # Sci-real
(?:[+-]?\d+[eE][+-]?\d+(?=[ )\n])) # Sci-integer
) |
(?P<quoted_string>"((?:[^"]|(?<=\\)")*)") |
(?P<string>[^()\s]+)
)"""
@classmethod
def _parse_regex(cls, content: str, *, unquote: bool = True) -> SExpr:
progress = ProgressContext()
progress.increase('parse', len(content), flush=True)
last_pos = 0
stack: list[list[SExpr]] = []
out: list[SExpr] = []
for match in re.finditer(cls._SExpr_RE, content):
start = match.start()
progress.advance('parse', steps=start - last_pos)
last_pos = start
if match['lparen'] is not None:
stack.append(out)
out = []
elif match['rparen'] is not None:
assert stack, 'Trouble with nesting of brackets'
tmpout, out = out, stack.pop()
out.append(tmpout)
progress.increase('parse', len(tmpout))
elif (value := match['number']) is not None:
v = float(value) if '.' in value else int(value)
out.append(v)
elif (value := match['quoted_string']) is not None:
if unquote:
out.append(value[1:-1].replace(r'\"', '"'))
else:
out.append(value.replace(r'\"', '"'))
elif (value := match['string']) is not None:
out.append(value)
else:
msg = f'Error: "{match.group()}" => {match.groupdict()}'
raise RuntimeError(msg)
progress.advance('parse', steps=len(content) - last_pos, flush=True)
assert not stack, 'Trouble with nesting of brackets'
return out[0]
@classmethod
def parse(cls, content: str, *, unquote: bool = True) -> SExpr:
# result = cls._parse_pyparsing(content, unquote=unquote)
return cls._parse_regex(content, unquote=unquote)
def __init__(self, *args: Any, **kwargs: Any) -> None:
msg = 'SParser is not meant to be instantiated'
raise NotImplementedError(msg)
# Need to have our own fence object for required fields since kicad file format
# has that weird thing of mixing optional fields followed by required ones.
# And that does not express well in Python/attrs world, since optional params/fields
# must always come after the required ones.
# Therefore, after declaring an optional field, attrs does not let us declare fields
# that don't have a default value. In such case, we use the following special
# object as default value
REQUIRED: Any = object
class Token:
"""Base class to model all kicad SExpr tokens.
Provides all the tooling needed for parsing, converting, and serialising tokens.
Note that actual token classes are expected to not only subclass this Token class.
But also be attrs.define() wrapped, with fields to be parsed/serialised declared
the way attrs declares fields.
"""
__slots__ = ()
CURRENT_CONTEXT: ClassVar[list[type[Token]]] = []
@staticmethod
@contextmanager
def with_context(context: type[Token]) -> Iterator[None]:
Token.CURRENT_CONTEXT.append(context)
try:
yield
finally:
Token.CURRENT_CONTEXT.pop()
@classmethod
def as_context(cls) -> _GeneratorContextManager[None]:
return cls.with_context(cls)
#
# Token name <-> class matching, and versioning section
#
__inheritors__: ClassVar[
dict[type, dict[str, dict[str, type[Self]]]]
] = collections.defaultdict(
partial(collections.defaultdict, dict),
)
def __init_subclass__(cls, *args: Any, **kwargs: Any) -> None:
"""We keep track of all inheritors of Token in order to match their
token name with corresponding class when we parse.
"""
super().__init_subclass__(*args, **kwargs)
if hasattr(cls, '__attrs_attrs__'):
attrs.resolve_types(cls, include_extras=True)
token_name = cls.token_name(usage='parse')
if token_name:
class_name = f'{cls.__module__}.{cls.__qualname__}'
for base in cls.mro()[1:-1]:
Token.__inheritors__[base][token_name][class_name] = cls
# This last dict level is here to keep only the last "declared"
# class for a given class name. It is needed because this method
# sees a class more than once: first the original one, then the
# attrs.define() wrapped one (because it uses slots by default).
# They have the same name, but are not the same objects, such
# that a simple set cannot be used.
# We only want to keep the wrapped one.
# Also, filtering on __attrs_attrs__ is not sufficient as in the case
# of a class subclassing another attrs.defined one, this special
# member will be present even in the original one (and that's not
# the one we want).
# So, in practice, we don't really care about the class name here.
# We only need it to base our set (ie. dict keys) on it, while
# being interested only in the value (ie. class object) it represents.
@classmethod
@functools.cache
def token_name_to_classes(cls, token_name: str) -> Iterable[type[Token]]:
"""Returns the token class corresponding to token_name"""
return Token.__inheritors__[Token][token_name].values()
@classmethod
@functools.cache
def token_name(cls, usage: Literal['parse', 'export']) -> str | None:
"""Returns the token name for this class.
usage corresponds to whether this is used for parsing or export.
The difference being that for export we need to remove the version suffix.
Whereas for parsing we need to keep it to not confuse the parser later on.
Subclasses can also reimplement it to implement different behaviours of
token name when parsing or exporting.
"""
name = cls._camel_to_snake(cls.__name__)
if (usage == 'export') and cls._extract_version(name):
name = name.rsplit('_', maxsplit=1)[0]
return name
@classmethod
def accept(cls, sexpr: SExpr) -> bool:
"""Says if this class accept the current sexpr.
During the resolution of token name to class, this method is invoked on a
tentative class. By default, to cover the most common and simple case, if
a token class has a unique token name (ie. no two token classes share the same token name),
then just accepting it regardless of the SExpr content should be fine.
But in case several token classes share the same token name, then they are all asked
to accept or not. In the end, if more than one accepts, an error is raised.
Therefore, token class reimplementing this method should really be certain to
accept only what is for them.
Note that it is fine if none accept, as in that case the SExpr will be kept in raw form.
To determine if they should accept or not, token classes can implement any strategy
they want. The provided SExpr may help by inspecting its structure.
Another helper is the Token.CURRENT_CONTEXT stack. The last element of that stack
is the parent token class. And by walking up the stack, one can retrace the entire
current hierarchy of token classes.
"""
return True
@staticmethod
@functools.cache
def _camel_to_snake(name: str) -> str:
"""Converts a class name in Camel form into a snake form corresponding to
the way token names are made."""
# From https://stackoverflow.com/a/1176023
return re.sub(r'(?<!^)(?=[A-Z])', '_', name).lower()
@staticmethod
@functools.cache
def _extract_version(name: str) -> int | None:
"""Token classes can have a _VERSION suffix used for versioning the
various file formats kicad had."""
with suppress(Exception): # If it fails for any reason, we return None
return int(name.rsplit('_', maxsplit=1)[-1])
return None
@classmethod
@functools.cache # Pretty much one static per (class, version) pair
def _get_versionned_token_class(cls, current_version: int) -> type[Token]:
"""Handles versionned tokens
The logic is that versionned token classes have a date suffix in their name.
We extract that suffix for each subclass of our current class (who should just
be an empty class only used to fullfill token name matching).
We build a list of candidate subclasses, and then pick the one that matches
the current file version.
"""
if not (subclasses := cls.__inheritors__[cls]):
return cls
candidates: dict[int, type[Self]] = {}
for more_subclasses in subclasses.values():
for subcls_name, subcls in more_subclasses.items():
if version := cls._extract_version(subcls_name):
candidates[version] = subcls
if not candidates:
return cls
for class_version in sorted(candidates, reverse=True):
if class_version <= current_version:
# print(f'New class for {current_version=} is {cls.__name__}')
return candidates[class_version]
msg = f'No version suitable for {cls.__name__} with {current_version=}'
raise ValueError(msg)
#
# Fields helpers section
#
@staticmethod
@functools.cache
def _get_field_types(
field_type: Any,
) -> tuple[type[Any], tuple[type[Any], ...] | None]:
"""Determines proper field type.
Takes care of interpreting Unions and Generics.
"""
field_type_args = None
if isinstance(field_type, types.UnionType):
# If it is a Union, keep only non-None type (assuming an optional is declared
# with None at the end of the union list).
field_type = typing.get_args(field_type)
if field_type[-1] is type(None):
field_type = field_type[:-1]
if len(field_type) == 1:
field_type = field_type[0]
if isinstance(field_type, types.GenericAlias):
# We cannot use generic types in isinstance/issubclass.
# Therefore we need to extract their origin type, as well as argument type.
field_type_args = typing.get_args(field_type)
field_type = typing.get_origin(field_type)
return field_type, field_type_args
@staticmethod
def _is_literal_field(field: attrs.Attribute[Any], field_type: type[Any]) -> bool:
"""Recognises a boolean field to be matched with a literal"""
return (
(type(field_type) is not tuple) and (field_type is bool) and field.metadata['literal']
)
@staticmethod
def _match_literal_field(field: attrs.Attribute[Any], arg: Any) -> bool:
"""Matches an argument with a literal boolean field"""
if not isinstance(arg, str):
# Cannot even match if argument is not a string
return False
# Allows for other literal value than field name
literal = field.metadata['literal']
if isinstance(literal, bool):
literal = field.name
return arg == literal
@staticmethod
def _is_named_value(arg: Any, match_name: str) -> bool:
"""Recognises a case of uncasted "(name value)" simple S-Expression"""
return (type(arg) is list) and (len(arg) == 2) and (arg[0] == match_name) # noqa: E721
@staticmethod
@functools.cache
def _is_optional_field(default: Any) -> bool:
"""Recognises a field that is not required"""
return default not in (attrs.NOTHING, REQUIRED)
#
# Token parsing section
#
@classmethod
def from_file(cls, path: Path) -> Self:
ProgressContext().add_task('parse', f'Loading {path}')
sexpr = SParser.parse(path.read_text())
result = cls.from_sexpr(sexpr)
ProgressContext().flush('parse')
ProgressContext().update('parse', visible=False)
return result
@classmethod
def from_text(cls, content: str) -> Self:
ProgressContext().add_task('parse', 'Loading')
sexpr = SParser.parse(content)
result = cls.from_sexpr(sexpr)
ProgressContext().flush('parse')
ProgressContext().update('parse', visible=False)
return result
@classmethod
def from_sexpr(cls, sexpr: SExpr) -> Token | str | Number:
"""Takes the output from SParser and produces object representation of it."""
# print('evaluating', sexpr)
if (type(sexpr) is str) or (type(sexpr) is float) or (type(sexpr) is int): # noqa: E721
return sexpr
token_name = sexpr[0]
token_classes = cls.token_name_to_classes(token_name)
candidates = [
tentative_class for tentative_class in token_classes if tentative_class.accept(sexpr)
]
if not candidates:
token_class = None
elif len(candidates) == 1:
token_class = candidates[0]
else:
msg = f'Got more than one accepting token class for {sexpr=}: {candidates=}'
raise RuntimeError(msg)
if token_class is None:
# If we don't have one, we resort to keeping it in list form.
# Can be more intelligently used later (or just appended as raw data
# in a dedicated token class member).
token_class = list
is_token = False
attributes = sexpr
nargs = len(attributes)
else:
# Otherwise we strip out the token name itself from what needs to be
# deeply/recursively parsed.
attributes = sexpr[1:]
nargs = len(attributes) + 1
is_token = True
# Deep convertion here:
# with cls._with_context(token_class):
# args: list[Self | str | Number] = [
# cls.from_sexpr(attribute) for attribute in attributes
# ]
args: list[Self | str | Number]
if is_token:
Token.CURRENT_CONTEXT.append(token_class)
try:
args = [cls.from_sexpr(attribute) for attribute in attributes]
finally:
Token.CURRENT_CONTEXT.pop()
else:
args = [cls.from_sexpr(attribute) for attribute in attributes]
# Actual object instantiation...
# print('token is', token_name, token_class, args)
if not is_token: # Ie. it's a list
obj = token_class(args)
ProgressContext().advance('parse', steps=nargs)
return obj
else:
# ... except if it is a token class, in which case we deffer to the
# (much more) complex from_sexpr_data().
# This split allows subclasses to customise args before feeding them
# back to Token.from_sexpr_data().
obj = token_class.from_sexpr_data(args)
ProgressContext().advance('parse', steps=nargs)
return obj
@classmethod
def from_sexpr_data(cls, args: list[Self | str | Number]) -> Token:
"""Given a list of args, re-interpret them following kicad ways of expressing
token attributes to transform them into more precise kwargs and instantiate token objects.
While it looks like we could just use that list of attributes as positional arguments
to instantiate token objects, turns out that some peculiarities of kicad token attributes
do not play well with the way Python and attrs match positional args to fields.
This method handles:
- Optional attributes appearing in the middle of required ones
- Optional bool attributes that are just self-named flags
- So-called named values, ie. (name value) sub-SExpr instead of being a plain value attrib
It also handles grouping sequences of similarly typed tokens into list fields of a token
class (eg. graphic items). Or grouping a bunch of (named) sub-SExpr into dict fields
(eg. settings). These two constructs can also be used to lazily not model every possible
token construct, and use them as catch-all fields in certain places.
"""
# print(cls.__name__, f'{args=}')
if Version.CURRENT is not None:
cls = cls._get_versionned_token_class(Version.CURRENT.version)
# To build the kwargs, we take each declared field, and try to match it
# to 0, 1, or more positional args. To match, we rely on type hints.
kwargs = {}
# Optimisation: Instead of popping each arg we use, we rather keep an index
# on the current positional arg. Except in (rare) case of kw_only, where we will pop them.
consummed_args = 0
# Optimisation: Minimize calling len(). In case of kw_only, we have to
# decrement len_args each time we pop an arg.
len_args = len(args)
for field in fields(cls): # noqa: F402
field: attrs.Attribute[Any]
field_type, field_type_args = cls._get_field_types(field.type)
kw_only = field.kw_only
# print(
# f'> {field.name}, {field.type}, {field_type}, {field_type_args}, '
# f'{field.default=}, {field.kw_only=}' # , {args[consummed_args]}'
# )
# Process optionals
if cls._is_optional_field(field.default):
if (
len_args == consummed_args
): # Not even any positional argument left, skip this field
continue
if cls._is_literal_field(field, field_type):
# Case of literals that are just boolean True if present
if not cls._match_literal_field(field, args[consummed_args]):
continue # Not matching => skip this field
args[consummed_args] = True
elif cls._is_named_value(args[consummed_args], field.name): # noqa: SIM114
pass # Handled later
# Optional lists and dicts handling
elif (type(field_type) is not tuple) and issubclass(field_type, (list, dict)):
pass # Handled later
# If type of this optional field does not match next positional arg, skip this field
elif not isinstance(args[consummed_args], field_type):
continue
# TODO: Handle case of kw_only field, which, by luck is no issue for the only cases
# of optional kw_only field (title_block in kicad_pcb)
if type(field_type) is not tuple: # issubclass can't handle Union field type
if issubclass(field_type, list):
# Case of a list[AToken] definition.
# We exhaust all of this AToken type in the positional args to fill the list.
if kw_only:
list_ = []
i = consummed_args
while i < len_args:
if not isinstance(args[i], field_type_args):
i += 1
else:
list_.append(args.pop(i))
len_args -= 1
else:
i = 0
for i, arg in enumerate(args[consummed_args:]):
if not isinstance(arg, field_type_args):
break
else:
i += 1 # Case of a list terminating on a matching item
list_ = args[consummed_args : consummed_args + i]
consummed_args += i
kwargs[field.name] = list_
continue
elif issubclass(field_type, dict):
# Case of a dict[str, Any] definition.
# We exhaust all positional args that are non-empty lists (must have a name)
# to fill the dict.
dict_ = {}
if kw_only:
i = consummed_args
while i < len_args:
if not ((type(args[i]) is list) and args[i]): # noqa: E721 # Opti.
i += 1
else:
name, *value = args.pop(i)
len_args -= 1
if len(value) == 1:
value = value[0]
dict_[name] = value
else:
for arg in args[consummed_args:]:
if not ((type(arg) is list) and arg): # noqa: E721 # Opti.
break
name, *value = args[consummed_args]
consummed_args += 1
if len(value) == 1:
value = value[0]
dict_[name] = value
kwargs[field.name] = dict_
continue
if kw_only:
i = consummed_args
while i < len_args:
if cls._is_named_value(args[i], field.name):
kwargs[field.name] = args.pop(i)[1]
len_args -= 1
break
if isinstance(args[i], field_type):
kwargs[field.name] = args.pop(i)
len_args -= 1
break
i += 1
else:
arg = args[consummed_args]
if cls._is_named_value(arg, field.name):
kwargs[field.name] = arg[1]
consummed_args += 1
else:
# Any other case, the next positional arg is our current field value
kwargs[field.name] = arg
consummed_args += 1
# print(cls.__name__, f'{kwargs=}')
if consummed_args < len_args:
# print(f'{consummed_args=}, {args[:consummed_args]=}, {args[consummed_args:]=}')
args = args[consummed_args:]
msg = f'Unprocessed args in {cls.__name__}: {args}'
raise ValueError(msg)
return cls(**kwargs)
#
# Convertions section
#
def to_version(self, version: int) -> Token:
"""Converts this token to a different version if possible.
If we are not a versionned token, or we already are of that version, returns ourself.
If the requested version is less than our current version, an error is raised
(ie. not supporting downgrade).
Tries to find and call converter methods on ourself, conventionally named "to_XXX",
with XXX being the supported version. If that does not match the requested version but
is still more recent than our own version, we first convert ourself to this
intermediate version before recursively invoking to_version() on it.
"""
our_version = self._extract_version(type(self).__name__)
if (our_version is None) or (our_version == version):
return self
if our_version > version:
msg = f'{type(self).__name__}: Cannot downgrade to version {version}'
raise ValueError(msg)
if to_version_fn := getattr(self, f'to_{version}', None):
target_cls_name = f'{type(self).__name__.rsplit("_", maxsplit=1)[0]}_{version}'
target_cls = globals()[target_cls_name]
return to_version_fn(target_cls)
# Need to search for an intermediate version to convert to
for attr_name in dir(self):
if (not attr_name.startswith('to_')) or (attr_name in ('to_version', 'to_footprint')):
continue
to_version = self._extract_version(attr_name)
if to_version is None:
continue
to_version_fn = getattr(self, attr_name)
if not callable(to_version_fn):
continue
target_cls_name = f'{type(self).__name__.rsplit("_", maxsplit=1)[0]}_{to_version}'
target_cls = globals()[target_cls_name]
if to_version == version: # Would handle 0-padding
return to_version_fn(target_cls)
elif to_version < version:
return to_version_fn(target_cls).to_version(version)
else:
continue
msg = f'{type(self).__name__}: No suitable convertion path found to version {version}'
raise ValueError(msg)
def to_footprint(self) -> Token:
"""Converts ourself to our footprint variant.
Assumes that it can be instanciated with the same fields.
If not, subclasses are free to reimplement.
"""
our_name = type(self).__name__
if not our_name.startswith('Gr'):
return self
target_name = our_name.replace('Gr', 'Fp', 1)
target_cls = globals()[target_name]
return target_cls(**attrs.asdict(self, recurse=False))
#
# Token serialising section
#
def to_file(self, path: Path) -> None:
ProgressContext().add_task('export', f'Exporting to {path}', total=self._count_elements())
path.write_text(self.to_sexpr_text())
ProgressContext().flush('export')
ProgressContext().update('export', visible=False)
if TYPE_CHECKING:
# The iterator to_sexpr_elements() returns.
_ToSExprIterator: TypeAlias = Iterator[
Union[
tuple[
attrs.Attribute[Any],
SExpr | '_ToSExprIterator',
],
tuple[
None,
str | None,
],
]
]
NewlinesFilter: TypeAlias = Callable[
[attrs.Attribute[Any] | None, Any | None, dict[str, bool]], dict[str, bool]
]
def to_sexpr_list(self) -> SExpr:
"""Deeply converts this token to SExpr list-form"""
ProgressContext().add_task('export', 'Exporting', total=self._count_elements())
def list_walker(value: list[SExpr]) -> Iterator[SExpr]:
for item in value:
item_type = type(item)
if item_type is GeneratorType:
yield list(walker(item))
elif item_type is list:
yield list(list_walker(item))
elif item in ('', '\n'):
continue
else:
yield item
def walker(it: Token._ToSExprIterator) -> Iterator[SExpr]:
"""Walk-down a token-sexpr iterator as returned by to_sexpr_elements().
Returns the SExpr elements as an iterator, that just needs to be wrapped
in a list() to be joined."""
for _, value in it:
value_type = type(value)
if value_type is GeneratorType:
yield list(walker(value))
elif value_type is list:
yield list(list_walker(value))
elif value in ('', '\n'):
continue
elif value is not None:
yield value
to_return = list(walker(self.to_sexpr_elements()))
ProgressContext().flush('export')
ProgressContext().update('export', visible=False)
return to_return
def to_sexpr_text(self) -> str:
"""Deeply converts this token to SExpr text-form.
Handles formatting such as indentation, float format and spaces between attributes,
following metadata of token fields provided at their declaration.
"""
indent = 0
INDENT_BY = 2
skip_space = False
def float_to_str(value: float, field: attrs.Attribute[Any]) -> str:
"""Format a float value.
Handles a precision level, as well as if we need to remove trailing zeros or not.
"""
precision = field.metadata['precision']
s = f'{value:.{precision}f}'
if (s[-1] == '0') and field.metadata['strip_0']:
return s.rstrip('.0')
else:
return s
def list_walker(field: attrs.Attribute[Any] | None, value: list[SExpr]) -> Iterator[str]:
"""Walk-down a list. Returns an iterator of strings representing SExpr elements"""
for item in value:
item_type = type(item)
if item_type is list:
yield f'({joiner(list_walker(field, item))})'
elif item_type is float:
yield float_to_str(item, field)
elif item_type is GeneratorType:
yield f'({joiner(walker(item))})'
else:
yield str(item)
def walker(it: Token._ToSExprIterator) -> Iterator[str | None]:
"""Walk-down a token-sexpr iterator as returned by to_sexpr_elements().
Returns an iterator of strings representing SExpr elements."""
nonlocal indent, skip_space
indent += INDENT_BY
for field, value in it:
if not field: # value is str | None
yield value
else:
local_indent = 0
if local_indent := field.metadata['indent']:
if type(local_indent) is bool: # noqa: E721 # Opti.
local_indent = INDENT_BY
indent += local_indent
value_type = type(value)
if value_type is GeneratorType:
str_value = f'({joiner(walker(value))})'
elif value_type is list:
str_value = f'({joiner(list_walker(field, value))})'
elif value_type is float:
str_value = float_to_str(value, field)
elif value_type is str:
str_value = value
else:
str_value = str(value)
skip_space = field.metadata['skip_space']
yield str_value
if local_indent:
indent -= local_indent
indent -= INDENT_BY
yield None
def joiner(it: Iterator[str | None]) -> str:
"""Takes an iterator from one of the walker functions, and handles joining,
spacing, newlines, and indentation."""
nonlocal skip_space
s = ''
had_newline = False
for item in it:
if had_newline:
s += ' ' * indent
if item is not None:
is_newline = item == '\n'
if not is_newline and not had_newline and s and not skip_space:
s += ' '
skip_space = False
had_newline = is_newline
if s and (s[-1] == ' ') and is_newline:
s = s.rstrip(' ')
s += item
else:
had_newline = False
return s
return f'({joiner(walker(self.to_sexpr_elements()))})\n'
def _count_elements(self) -> int:
"""Recurse to walk down our fields in order to know how much elements we have."""
our_fields = fields(type(self))
count = len(our_fields) + 1
for field in our_fields:
field: attrs.Attribute[Any]
field_type, _ = self._get_field_types(field.type)
value = getattr(self, field.name)
if value is None:
continue
if type(field_type) is tuple:
for type_ in field_type:
if isinstance(value, type_):
field_type = type_
break
else:
msg = f'Field {field.name} of {type(self).__name__} is not of correct type: {field_type}'
raise ValueError(msg)
if field_type is list:
for item in value:
if isinstance(item, Token):
count += item._count_elements()
elif issubclass(field_type, Token):
count += value._count_elements()
return count
def to_sexpr_elements(self) -> _ToSExprIterator:
"""Iterates over the fields of this token, and yield tuples of (field, value).
With field being the attrs.Attribute field object from the token attrs class
declaration, meant to provide access to things like field name, type or metadata.
It might also be None in some peculiar cases (eg. when yielding the token name,
or a newline).
And value being the actual element value. That value may be a raw value, or it can
be enclosed in a list (eg. named values). If a string value needs to be quoted,
it will be here.
Additionally, value can also be another iterator, which means it recursed into
another token object. Every time an iterator is encountered, serialisers functions
calling to_sexpr_elements() have to enclose the elements this need iterator yield
into a sub list or sub SExpr.
Finally, due to constraints, some newlines (the ones declared in metadata of list
and dict fields with newline_before_first and newline_after_last) will be yielded
by to_sexpr_elements(). If your serialiser does not need them (eg. like in the case
of to_sexpr_list() serialiser), you may freely ignore them.
You may as well freely ignore an empty string value, as it might be generated by
internal token declarations (such as _LayerDef for instance).
"""
progress = ProgressContext()
Token.CURRENT_CONTEXT.append(type(self))
yield None, self.token_name(usage='export')
progress.advance('export', 1)
newlines_filter: NewlinesFilter = None
if hasattr(self, 'newlines_filter'):
newlines_filter = self.newlines_filter
newlines: dict[str, bool] = dict(at_end=False)
newlines_filter(None, None, newlines)
for field in fields(type(self)):
field: attrs.Attribute[Any]
field_type, field_type_args = self._get_field_types(field.type)
value = getattr(self, field.name)
nl_before_first = field.metadata['newline_before_first']
nl_before = field.metadata['newline_before']
nl_after = field.metadata['newline_after']
nl_after_last = field.metadata['newline_after_last']
nl_force = field.metadata['force_newline_if_none']
if newlines_filter:
newlines['before_first'] = nl_before_first
newlines['before'] = nl_before
newlines['after'] = nl_after
newlines['after_last'] = nl_after_last
newlines['force_if_none'] = nl_force
newlines = newlines_filter(field, value, newlines)
nl_before_first = newlines['before_first']
nl_before = newlines['before']