Kiwari -- the numbers a frame is built from, and what they are allowed to be.
Kiwari (木割) is the traditional system that sets every member's dimension
from a small set of base numbers. That is what this is: a frame declares the
handful of numbers it is proportioned from, and kigumi puts a control on each.
A builder declares its kiwari in its own body and hands it back on the frame::
def build_frame(k: Kiwari | None = None) -> Frame:
k = kiwari(
legs=kiwari.count(4, minimum=3, about="Number of legs"),
seat_height=kiwari.length(mm(450)),
butt_end=kiwari.choice(TimberEnd, TimberEnd.TOP),
).resolve(k)
for i in range(k.count("legs")):
...
return Frame.from_joints(joints, kiwari=k)
Nothing here is global and nothing is mutated: kiwari(...) makes a
declaration whose values are its defaults, resolve returns a new one with
incoming values laid over the top, and Frame carries the result. Calling
build_frame() bare gets the defaults, which is what a script or a test wants.
Declaring and binding are one type on purpose. Values are never useful without
the declarations -- you cannot validate, coerce, or fall back to a default
without them -- so splitting them would mean every caller carried both and
something had to check they agreed. Here there is only ever one schema in the
room, and an accessor asking for the wrong thing says so at the call site::
k.length("legs") # TypeError: 'legs' is a count, not a length
Reading a value back out is typed, because a length and a count are both
floats once they are numbers, and the declaration is the only thing that knows
which one a key is.
kiwari
module-attribute
kiwari = _KiwariFactory()
Declaration
dataclass
Declaration(kind: str, default: Any, about: str = '', optional: bool = False, minimum: Optional[float] = None, maximum: Optional[float] = None, choices: Optional[Type[Enum]] = None)
One parameter: what it is, what it defaults to, and what it may be.
default
instance-attribute
about
class-attribute
instance-attribute
optional
class-attribute
instance-attribute
minimum
class-attribute
instance-attribute
minimum: Optional[float] = None
maximum
class-attribute
instance-attribute
maximum: Optional[float] = None
choices
class-attribute
instance-attribute
choices: Optional[Type[Enum]] = None
__post_init__
Source code in kumiki/kiwari.py
| def __post_init__(self) -> None:
if self.kind not in _KINDS:
raise ValueError(f"{self.kind!r} is not a kind of parameter. Expected one of {_KINDS}")
if self.kind == CHOICE:
if self.choices is None or not (isinstance(self.choices, type) and issubclass(self.choices, Enum)):
raise ValueError("a choice must name the Enum class its options come from")
if self.default is None and not self.optional:
raise ValueError(
"a parameter with no default has nothing to build with. "
"Pass optional=True if it is genuinely allowed to be nothing."
)
|
Kiwari
dataclass
Kiwari(declarations: Mapping[str, Declaration], values: Mapping[str, Any] = dict(), texts: Mapping[str, str] = dict(), _origin: Optional['Kiwari'] = None)
What a frame is proportioned from: the declarations and their values.
Made by :func:kiwari. A bare one's values are its defaults; resolve
returns a new one with incoming values laid over the top. Both halves are
one type so an accessor always has the declaration to check itself against.
declarations
instance-attribute
values
class-attribute
instance-attribute
values: Mapping[str, Any] = field(default_factory=dict)
texts
class-attribute
instance-attribute
texts: Mapping[str, str] = field(default_factory=dict)
origin
property
The kiwari as declared, before any values were laid over it.
Resolving makes a new object, so two frames built from one declared
kiwari never hold the same one. They do share its origin, which is how
kigumi tells they share their parameters.
__post_init__
Source code in kumiki/kiwari.py
| def __post_init__(self) -> None:
# Frozen stops the attributes being rebound, not the dicts behind them
# being written to. A kiwari that quietly changed under a frame that
# had already been built from it would be the worst kind of bug here.
for name in ("declarations", "values", "texts"):
object.__setattr__(self, name, MappingProxyType(dict(getattr(self, name))))
|
length
length(key: str) -> float
A distance, in metres.
Source code in kumiki/kiwari.py
| def length(self, key: str) -> float:
"""A distance, in metres."""
return self._look_up(key, LENGTH)
|
angle
An angle, in radians.
Source code in kumiki/kiwari.py
| def angle(self, key: str) -> float:
"""An angle, in radians."""
return self._look_up(key, ANGLE)
|
count
A whole number of things.
Source code in kumiki/kiwari.py
| def count(self, key: str) -> int:
"""A whole number of things."""
return self._look_up(key, COUNT)
|
number
number(key: str) -> float
A plain dimensionless number.
Source code in kumiki/kiwari.py
| def number(self, key: str) -> float:
"""A plain dimensionless number."""
return self._look_up(key, NUMBER)
|
flag
On or off.
Source code in kumiki/kiwari.py
| def flag(self, key: str) -> bool:
"""On or off."""
return self._look_up(key, FLAG)
|
text
Free text.
Source code in kumiki/kiwari.py
| def text(self, key: str) -> str:
"""Free text."""
return self._look_up(key, TEXT)
|
choice
choice(key: str, choices: Optional[Type[Enum]] = None) -> Any
One of an Enum's members.
Pass the Enum class to be told at the call site if the declaration has
changed under you.
Source code in kumiki/kiwari.py
| def choice(self, key: str, choices: Optional[Type[Enum]] = None) -> Any:
"""One of an Enum's members.
Pass the Enum class to be told at the call site if the declaration has
changed under you.
"""
value = self._look_up(key, CHOICE)
declared = self.declarations[key].choices
if choices is not None and declared is not choices:
raise TypeError(
f"{key!r} chooses from {declared.__name__ if declared else 'nothing'}, "
f"not {choices.__name__}"
)
return value
|
v2
Two lengths.
Source code in kumiki/kiwari.py
| def v2(self, key: str) -> V2:
"""Two lengths."""
return self._look_up(key, POINT2)
|
v3
Three lengths.
Source code in kumiki/kiwari.py
| def v3(self, key: str) -> V3:
"""Three lengths."""
return self._look_up(key, POINT3)
|
resolve
resolve(incoming: Union['Kiwari', Mapping[str, Any], None]) -> 'Kiwari'
This kiwari with incoming's values laid over its own.
Takes another kiwari (whose values are used, not its declarations), a
plain mapping of key to value, or None for "just the defaults". Every
value is checked against this kiwari's declarations -- which is what
makes editing the file while the viewer is open come out right: the
declarations made on this run win, and values for keys that survived
carry over.
A key that is no longer declared is dropped with a warning rather than
raising: it means the file changed under a saved value, which the
author cannot fix from inside the build.
Source code in kumiki/kiwari.py
| def resolve(self, incoming: Union["Kiwari", Mapping[str, Any], None]) -> "Kiwari":
"""This kiwari with *incoming*'s values laid over its own.
Takes another kiwari (whose values are used, not its declarations), a
plain mapping of key to value, or None for "just the defaults". Every
value is checked against *this* kiwari's declarations -- which is what
makes editing the file while the viewer is open come out right: the
declarations made on this run win, and values for keys that survived
carry over.
A key that is no longer declared is dropped with a warning rather than
raising: it means the file changed under a saved value, which the
author cannot fix from inside the build.
"""
if incoming is None:
return self
# Recognised by shape, not by isinstance: the runner purges and
# re-imports kumiki on every reload so source edits take effect, which
# makes the Kiwari held from the previous build a different class
# object from this one. It is still a kiwari.
if hasattr(incoming, "declarations"):
# `declarations` alone, because a plain dict has `.values` too --
# as a method, which would read as an empty set of values rather
# than failing loudly.
arriving = dict(getattr(incoming, "values"))
arriving_texts = dict(getattr(incoming, "texts", {}))
elif isinstance(incoming, Mapping):
arriving, arriving_texts = _split_values_and_texts(incoming)
else:
raise TypeError(
f"a kiwari resolves against another kiwari or a mapping, not {type(incoming).__name__}"
)
stale = [key for key in arriving if key not in self.declarations]
if stale:
warnings.warn(
f"{', '.join(sorted(stale))} {'is' if len(stale) == 1 else 'are'} no longer "
f"declared by this kiwari, so {'it was' if len(stale) == 1 else 'they were'} "
f"ignored. Declares: {', '.join(self.declarations) or 'nothing'}."
)
values = dict(self.values)
texts = dict(self.texts)
for key, declaration in self.declarations.items():
if key not in arriving:
continue
values[key] = _coerce(key, declaration, arriving[key])
written = arriving_texts.get(key)
if written is not None:
texts[key] = written
else:
texts.pop(key, None)
return replace(self, values=values, texts=texts, _origin=self.origin)
|
default
What key is when nobody has said otherwise.
Source code in kumiki/kiwari.py
| def default(self, key: str) -> Any:
"""What *key* is when nobody has said otherwise."""
return self.declarations[key].default
|
changed_from_defaults
changed_from_defaults() -> Tuple[str, ...]
The keys whose value is not what the code declares.
What the viewer highlights, and the only thing a companion file saves:
writing every value out would mean changing a default in the source
silently did nothing.
Source code in kumiki/kiwari.py
| def changed_from_defaults(self) -> Tuple[str, ...]:
"""The keys whose value is not what the code declares.
What the viewer highlights, and the only thing a companion file saves:
writing every value out would mean changing a default in the source
silently did nothing.
"""
return tuple(
key for key, declaration in self.declarations.items()
if key in self.values and not _same_value(self.values[key], declaration.default)
)
|
written
key's value as it would be typed.
What somebody actually typed, where they did. Writing a length back out
is lossy in inches, so the text beats anything we could regenerate.
Source code in kumiki/kiwari.py
| def written(self, key: str) -> str:
"""*key*'s value as it would be typed.
What somebody actually typed, where they did. Writing a length back out
is lossy in inches, so the text beats anything we could regenerate.
"""
if key in self.texts:
return self.texts[key]
declaration = self.declarations[key]
return _write(declaration, self.values.get(key, declaration.default))
|
to_payload
to_payload() -> Dict[str, Any]
The whole kiwari as JSON-able data, for the viewer.
Source code in kumiki/kiwari.py
| def to_payload(self) -> Dict[str, Any]:
"""The whole kiwari as JSON-able data, for the viewer."""
return {
"schema": [
_declaration_payload(key, declaration)
for key, declaration in self.declarations.items()
],
"applied": {
key: self.value_payload(key)
for key in self.declarations
},
"changed": list(self.changed_from_defaults()),
}
|
value_payload
value_payload(key: str) -> Dict[str, Any]
One value, as the viewer holds it: the number, and how it was typed if anyone typed it.
Only typed text: anything written out here would be in mm, and the viewer writes
an untyped value in the units it is set to.
Source code in kumiki/kiwari.py
| def value_payload(self, key: str) -> Dict[str, Any]:
"""One value, as the viewer holds it: the number, and how it was typed if anyone typed it.
Only typed text: anything written out here would be in mm, and the viewer writes
an untyped value in the units it is set to.
"""
declaration = self.declarations[key]
value = self.values.get(key, declaration.default)
payload: Dict[str, Any] = {"value": _plain(declaration, value)}
written = self.texts.get(key)
if written is not None:
payload["text"] = written
return payload
|