Skip to content

Commit f9c3c06

Browse files
add DataConsumer bases to simplify data-getting from Data instances
1 parent a704452 commit f9c3c06

12 files changed

+273
-76
lines changed

schemascii/annoline.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
from __future__ import annotations
2+
3+
import itertools
4+
import typing
25
from collections import defaultdict
36
from dataclasses import dataclass
4-
from typing import ClassVar
5-
import schemascii.utils as _utils
7+
8+
import schemascii.data_consumer as _dc
69
import schemascii.grid as _grid
10+
import schemascii.utils as _utils
711

812

913
@dataclass
10-
class AnnotationLine:
14+
class AnnotationLine(_dc.DataConsumer,
15+
namespaces=(":annotation", ":annotation-line")):
1116
"""Class that implements the ability to
1217
draw annotation lines on the drawing
1318
without having to use a disconnected wire.
1419
"""
1520

16-
directions: ClassVar[
21+
css_class = "annotation annotation-line"
22+
23+
directions: typing.ClassVar[
1724
defaultdict[str, defaultdict[complex, list[complex]]]] = defaultdict(
1825
lambda: None, {
1926
# allow jumps over actual wires
@@ -36,7 +43,7 @@ class AnnotationLine:
3643
1: [-1j, -1]
3744
}
3845
})
39-
start_dirs: ClassVar[
46+
start_dirs: typing.ClassVar[
4047
defaultdict[str, list[complex]]] = defaultdict(
4148
lambda: None, {
4249
"~": _utils.LEFT_RIGHT,
@@ -45,6 +52,7 @@ class AnnotationLine:
4552
"'": (-1, 1, 1j),
4653
})
4754

55+
# the sole member
4856
points: list[complex]
4957

5058
@classmethod
@@ -77,6 +85,15 @@ def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]:
7785
seen_points.update(line.points)
7886
return all_lines
7987

88+
def render(self, **options) -> str:
89+
# copy-pasted from wire.py except class changed at bottom
90+
# create lines for all of the neighbor pairs
91+
links = []
92+
for p1, p2 in itertools.combinations(self.points, 2):
93+
if abs(p1 - p2) == 1:
94+
links.append((p1, p2))
95+
return _utils.bunch_o_lines(links, **options)
96+
8097

8198
if __name__ == '__main__':
8299
x = _grid.Grid("", """
@@ -90,6 +107,6 @@ def find_all(cls, grid: _grid.Grid) -> list[AnnotationLine]:
90107
'~~~~~~~~~~~~~~~'
91108
92109
""")
93-
line = AnnotationLine.get_from_grid(x, 30+2j)
110+
line, = AnnotationLine.find_all(x)
94111
print(line)
95112
x.spark(*line.points)

schemascii/annotation.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
import re
22
from dataclasses import dataclass
33

4+
import schemascii.data_consumer as _dc
45
import schemascii.grid as _grid
6+
import schemascii.utils as _utils
57

68
ANNOTATION_RE = re.compile(r"\[([^\]]+)\]")
79

810

911
@dataclass
10-
class Annotation:
12+
class Annotation(_dc.DataConsumer, namespaces=(":annotation",)):
1113
"""A chunk of text that will be rendered verbatim in the output SVG."""
1214

1315
position: complex
1416
content: str
1517

18+
css_class = "annotation"
19+
1620
@classmethod
1721
def find_all(cls, grid: _grid.Grid):
1822
"""Return all of the text annotations present in the grid."""
@@ -23,3 +27,7 @@ def find_all(cls, grid: _grid.Grid):
2327
text = match.group(1)
2428
out.append(cls(complex(x, y), text))
2529
return out
30+
31+
def render(self, **options) -> str:
32+
raise NotImplementedError
33+
return _utils.XML.text()

schemascii/component.py

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
from __future__ import annotations
22

3-
import abc
3+
import typing
44
from collections import defaultdict
55
from dataclasses import dataclass
6-
from typing import ClassVar
76

8-
import schemascii.data as _data
7+
import schemascii.data_consumer as _dc
98
import schemascii.errors as _errors
109
import schemascii.grid as _grid
1110
import schemascii.net as _net
@@ -15,15 +14,19 @@
1514

1615

1716
@dataclass
18-
class Component(abc.ABC):
17+
class Component(_dc.DataConsumer, namespaces=(":component",)):
1918
"""An icon representing a single electronic component."""
20-
all_components: ClassVar[dict[str, type[Component]]] = {}
21-
human_name: ClassVar[str] = ""
19+
all_components: typing.ClassVar[dict[str, type[Component]]] = {}
20+
human_name: typing.ClassVar[str] = ""
2221

2322
rd: _rd.RefDes
2423
blobs: list[list[complex]] # to support multiple parts.
2524
terminals: list[_utils.Terminal]
2625

26+
@property
27+
def namespaces(self) -> tuple[str, ...]:
28+
return self.rd.name, self.rd.short_name, self.rd.letter, ":component"
29+
2730
@classmethod
2831
def from_rd(cls, rd: _rd.RefDes, grid: _grid.Grid) -> Component:
2932
"""Find the outline of the component and its terminals
@@ -122,27 +125,17 @@ def __init_subclass__(cls, ids: list[str], id_letters: str | None = None):
122125
cls.all_components[id_letters] = cls
123126
cls.human_name = id_letters or cls.__name__
124127

125-
def to_xml_string(self, options: _data.Data) -> str:
126-
"""Render this component to a string of SVG XML."""
127-
return _utils.XML.g(
128-
self.render(options.get_values_for(self.rd.name)),
129-
class_=f"component {self.rd.letter}")
130-
131-
@abc.abstractmethod
132-
def render(self, options: dict) -> str:
133-
"""Render this component to a string of XML using the options.
134-
Component subclasses should implement this method.
135-
136-
This is a private method and should not be called directly. Instead,
137-
use the `to_xml_string` method which performs a few more
138-
transformations and wraps the output in a nicely formatted `<g>`.
139-
"""
140-
raise NotImplementedError
128+
@property
129+
def css_class(self) -> str:
130+
return f"component {self.rd.letter}"
141131

142132
@classmethod
143133
def process_nets(self, nets: list[_net.Net]):
144134
"""Hook method called to do stuff with the nets that this
145135
component type connects to. By itself it does nothing.
136+
137+
If a subclass implements this method to do something, it should
138+
mutate the list in-place and return None.
146139
"""
147140
pass
148141

schemascii/data.py

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22

33
import fnmatch
44
import re
5+
import typing
56
from dataclasses import dataclass
6-
from typing import Any, TypeVar
77

8+
import schemascii.data_consumer as _dc
89
import schemascii.errors as _errors
910

10-
T = TypeVar("T")
11+
T = typing.TypeVar("T")
1112
TOKEN_PAT = re.compile("|".join([
1213
r"[\n{};=]", # special one-character
1314
"%%", # comment marker
@@ -16,7 +17,6 @@
1617
r"""(?:(?!["\s{};=]).)+""", # anything else
1718
]))
1819
SPECIAL = {";", "\n", "%%", "{", "}"}
19-
_NOT_SET = object()
2020

2121

2222
def tokenize(stuff: str) -> list[str]:
@@ -40,10 +40,28 @@ def matches(self, name) -> bool:
4040

4141
@dataclass
4242
class Data:
43-
"""Class that holds the data of a drawing."""
43+
"""Class that manages data defining drawing parameters.
44+
45+
The class object itself manages what data options are allowed for
46+
what namespaces (e.g. to generate a help message) and can parse the data.
47+
48+
Instances of this class represent a collection of data sections that were
49+
found in a drawing.
50+
"""
4451

4552
sections: list[Section]
4653

54+
allowed_options: typing.ClassVar[dict[str, list[_dc.Option]]] = {}
55+
56+
@classmethod
57+
def define_option(cls, ns: str, opt: _dc.Option):
58+
if ns in cls.allowed_options:
59+
if any(eo.name == opt.name for eo in cls.allowed_options[ns]):
60+
raise ValueError(f"duplicate option name {opt.name!r}")
61+
cls.allowed_options[ns].append(opt)
62+
else:
63+
cls.allowed_options[ns] = [opt]
64+
4765
@classmethod
4866
def parse_from_string(cls, text: str, startline=1, filename="") -> Data:
4967
"""Parses the data from the text.
@@ -54,7 +72,7 @@ def parse_from_string(cls, text: str, startline=1, filename="") -> Data:
5472
tokens = tokenize(text)
5573
lines = (text + "\n").splitlines()
5674
col = line = index = 0
57-
lastsig = (0, 0, 0)
75+
lastsig: tuple[int, int, int] = (0, 0, 0)
5876

5977
def complain(msg):
6078
raise _errors.DiagramSyntaxError(
@@ -96,7 +114,7 @@ def eat():
96114
def save():
97115
return (index, line, col)
98116

99-
def restore(dat):
117+
def restore(dat: tuple[int, int, int]):
100118
nonlocal index
101119
nonlocal line
102120
nonlocal col
@@ -210,17 +228,7 @@ def get_values_for(self, namespace: str) -> dict:
210228
out |= section.data
211229
return out
212230

213-
def getopt(self, namespace: str, name: str, default: T = _NOT_SET) -> T:
214-
values = self.get_values_for(namespace)
215-
value = values.get(name, _NOT_SET)
216-
if value is _NOT_SET:
217-
if default is _NOT_SET:
218-
raise _errors.NoDataError(
219-
f"value for {namespace}.{name} is required")
220-
return default
221-
return value
222-
223-
def __or__(self, other: Data | dict[str, Any] | Any) -> Data:
231+
def __or__(self, other: Data | dict[str, typing.Any] | typing.Any) -> Data:
224232
if isinstance(other, dict):
225233
other = Data([Section("*", other)])
226234
if not isinstance(other, Data):

schemascii/data_consumer.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from __future__ import annotations
2+
3+
import abc
4+
import typing
5+
from dataclasses import dataclass
6+
7+
import schemascii.data as _data
8+
import schemascii.errors as _errors
9+
import schemascii.utils as _utils
10+
11+
T = typing.TypeVar("T")
12+
_NOT_SET = object()
13+
14+
15+
@dataclass
16+
class Option(typing.Generic[T]):
17+
"""Represents an allowed name used in Schemascii's internals
18+
somewhere. Normal users have no need for this class.
19+
"""
20+
21+
name: str
22+
type: type[T] | list[T]
23+
help: str
24+
default: T = _NOT_SET
25+
26+
27+
class DataConsumer(abc.ABC):
28+
"""Base class for any Schemascii AST node that needs data
29+
to be rendered. This class registers the options that the class
30+
declares with Data so that they can be checked, automatically pulls
31+
the needed options when to_xml_string() is called, and passes the dict of
32+
options to render().
33+
"""
34+
35+
options: typing.ClassVar[list[Option
36+
| typing.Literal["inherit"]
37+
| tuple[str, ...]]] = [
38+
Option("scale", float, "Scale by which to enlarge the "
39+
"entire diagram by", 15),
40+
Option("linewidth", float, "Width of drawn lines", 2),
41+
Option("color", str, "black", "Default color for everything"),
42+
]
43+
namepaces: tuple[str, ...]
44+
css_class: typing.ClassVar[str] = ""
45+
46+
def __init_subclass__(cls, namespaces: tuple[str, ...] = ("*",)):
47+
48+
if not hasattr(cls, "namespaces"):
49+
# don't clobber it if a subclass defines it as a @property!
50+
cls.namespaces = namespaces
51+
52+
for b in cls.mro():
53+
if (b is not cls
54+
and issubclass(b, DataConsumer)
55+
and b.options is cls.options):
56+
# if we literally just inherit the attribute,
57+
# don't bother reprocessing it
58+
return
59+
60+
def coalesce_options(cls: type[DataConsumer]) -> list[Option]:
61+
if DataConsumer not in cls.mro():
62+
return []
63+
seen_inherit = False
64+
opts = []
65+
for opt in cls.options:
66+
if opt == "inherit":
67+
if seen_inherit:
68+
raise ValueError("can't use 'inherit' twice")
69+
70+
seen_inherit = True
71+
elif isinstance(opt, tuple):
72+
for base in cls.__bases__:
73+
opts.extend(o for o in coalesce_options(base)
74+
if o.name in opt)
75+
elif isinstance(opt, Option):
76+
opts.append(opt)
77+
else:
78+
raise TypeError(f"unknown option definition: {opt!r}")
79+
return opts
80+
81+
cls.options = coalesce_options(cls)
82+
for ns in namespaces:
83+
for option in cls.options:
84+
_data.Data.define_option(ns, option)
85+
86+
def to_xml_string(self, data: _data.Data) -> str:
87+
"""Pull options relevant to this node from data, calls
88+
self.render(), and wraps the output in a <g>."""
89+
values = {}
90+
for name in self.namespaces:
91+
values |= data.get_values_for(name)
92+
# validate the options
93+
for opt in self.options:
94+
if opt.name not in values:
95+
if opt.default is _NOT_SET:
96+
raise _errors.NoDataError(
97+
f"value for {self.namespaces[0]}.{name} is required")
98+
values[opt.name] = opt.default
99+
continue
100+
if isinstance(opt.type, list):
101+
if values[opt.name] not in opt.type:
102+
raise _errors.DataTypeError(
103+
f"option {self.namespaces[0]}.{opt.name}: "
104+
f"invalid choice: {values[opt.name]} "
105+
f"(valid options are "
106+
f"{', '.join(map(repr, opt.type))})")
107+
continue
108+
try:
109+
values[opt.name] = opt.type(values[opt.name])
110+
except ValueError as err:
111+
raise _errors.DataTypeError(
112+
f"option {self.namespaces[0]}.{opt.name}: "
113+
f"invalid {opt.type.__name__} value: "
114+
f"{values[opt.name]}") from err
115+
# render
116+
result = self.render(**values, data=data)
117+
if self.css_class:
118+
result = _utils.XML.g(result, class_=self.css_class)
119+
return result
120+
121+
@abc.abstractmethod
122+
def render(self, data: _data.Data, **options) -> str:
123+
"""Render self to a string of XML. This is a private method and should
124+
not be called by non-Schemascii-extending code. External callers should
125+
call to_xml_string() instead.
126+
127+
Subclasses must implement this method.
128+
"""
129+
raise NotImplementedError

0 commit comments

Comments
 (0)