home · contact · privacy
Re-organize into modularity handled by plomlib installer.
authorPlom Heller <plom@plomlompom.com>
Mon, 3 Aug 2026 07:37:23 +0000 (09:37 +0200)
committerPlom Heller <plom@plomlompom.com>
Mon, 3 Aug 2026 07:37:23 +0000 (09:37 +0200)
.gitmodules [new file with mode: 0644]
bricksplom.py [deleted file]
install.sh
plomlib [new submodule]
src/bricksplom/__init__.py [new file with mode: 0644]
src/bricksplom/misc.py [new file with mode: 0644]
src/run.py [new file with mode: 0755]

diff --git a/.gitmodules b/.gitmodules
new file mode 100644 (file)
index 0000000..42cf7f3
--- /dev/null
@@ -0,0 +1,3 @@
+[submodule "plomlib"]
+       path = plomlib
+       url = https://plomlompom.com/repos/clone/plomlib
diff --git a/bricksplom.py b/bricksplom.py
deleted file mode 100755 (executable)
index b12064c..0000000
+++ /dev/null
@@ -1,932 +0,0 @@
-#!/usr/bin/env python3
-'Data structures for managing/sorting bricks of a certain kind.'
-from abc import ABC, abstractmethod
-from argparse import (
-        ArgumentError,
-        ArgumentParser,
-        Namespace as ArgumentNamespace,
-        )
-from dataclasses import dataclass
-from os import environ
-from pathlib import Path
-from typing import Callable, Optional, Self
-
-NAME_ENV_DIRNAME = 'BRICKSPLOM_DIR'
-NAME_ENV_LDRAW = 'PATH_LDRAW'
-LDRAW_STUDS = {
-    'stud.dat',
-    'studa.dat',
-    'stud2.dat',
-    'stud2a.dat',
-    'stud6a.dat',
-    'stud10.dat',
-    'stud15.dat',
-    'hipstud.dat',
-    's/973s01.dat'
-}
-
-CHAR_NEWLINE = '\n'
-CHAR_SEP_TOKEN = ' '
-CHAR_COMMENT = '#'
-CHAR_DESIGN_ALT = '='
-CHAR_ATTR_EQ = '='
-CHAR_TEXTCONTAINS = ':'
-SEP_DESIGN_DESC = ' #'
-SEP_DESIGN_ATTR = '|'
-CHAR_COLL_INDENT = ' '
-CHAR_SEP_LDRAW_MODES = ','
-CHAR_COLL_IN = '+'
-CHAR_COLL_OUT = '-'
-CHAR_COLL_INACTIVE = '#'
-CHAR_COLL_SEP_COLUMN = '-'
-CHAR_COLL_SEP_PAGE = '='
-CHAR_COL_SOLID = '+'
-CHAR_COL_TRANSPARENT = '-'
-BOX_PREFIX = 'box:'
-
-ARG_Q_MARK = '?'
-PARAM_Q_ID = f'id{CHAR_ATTR_EQ}'
-PARAM_Q_DELIMITERS = {CHAR_ATTR_EQ, CHAR_TEXTCONTAINS}
-TOK_SORT_BOX = 'box'
-TOK_SORT_ID = 'id'
-
-LDRAW_MODE_FILL_N_STUDS = 'fill_n_studs'
-LDRAW_MODE_FILL_PATHS = 'fill_paths'
-LDRAW_MODE_VERIFY_N_STUDS = 'verify_n_studs'
-LDRAW_MODE_VERIFY_PATHS = 'verify_paths'
-LDRAW_MODES = {LDRAW_MODE_FILL_N_STUDS,
-               LDRAW_MODE_FILL_PATHS,
-               LDRAW_MODE_VERIFY_N_STUDS,
-               LDRAW_MODE_VERIFY_PATHS}
-
-ATTR_DESCS: dict[str, str] = {
-    'n_studs': 'number of studs',
-    'description': 'guesstimate what this be',
-    'ldraw': 'path of associated LDRAW file'
-}
-
-BrickListing = tuple[int, str, str]  # count, ID, comment
-PageColumn = tuple[BrickListing, ...]
-Page = tuple[PageColumn, ...]
-
-
-class Textfiled(ABC):
-    'Table to be read from textfile, and compatible output in .raw().'
-
-    @staticmethod
-    def lines_of(
-            path: tuple[str, str]
-            ) -> tuple[str, ...]:
-        'Non-empty right-stripped lines of file at path.'
-        joined_path = Path(path[0]).joinpath(path[1])
-        return tuple(line.rstrip()
-                     for line in joined_path.read_text(encoding='utf8'
-                                                       ).split(CHAR_NEWLINE)
-                     if line.strip() and not line.startswith(CHAR_COMMENT))
-
-    @staticmethod
-    def tokify(
-            body: str,
-            len_expected: int
-            ) -> tuple[str, ...]:
-        'Body parsed into left-stripped tokens of len_expected count.'
-        collected: list[str] = []
-        while len(collected) < len_expected:
-            body = body.lstrip()
-            if len(collected) == len_expected - 1:
-                tok = body
-            else:
-                assert CHAR_SEP_TOKEN in body, body
-                tok, body = body.split(CHAR_SEP_TOKEN, maxsplit=1)
-            collected += [tok]
-        return tuple(collected)
-
-    @classmethod
-    @abstractmethod
-    def from_textfile(
-            cls,
-            path: tuple[str, str],
-            **kwargs
-            ) -> dict[str, Self]:
-        'Build from file at path.'
-
-    @abstractmethod
-    def raw(self) -> str:
-        'Output in format used for reading in.'
-
-    def __str__(
-            self
-            ) -> str:
-        return self.raw()
-
-
-class WithDb:
-    'Add db:Optional[BricksDB] field to __init__, setting ._db.'
-
-    def __init__(
-            self,
-            db: Optional['BricksDb'] = None,
-            **kwargs
-            ) -> None:
-        self._db = db
-        super().__init__(**kwargs)
-
-
-class Lookupable:
-    'Provides methods for BricksDb.lookup and .id_ padding.'
-    _id_indent: int = 0
-    id_: str
-
-    @classmethod
-    def indent_id(
-            cls,
-            id_: str
-            ) -> str:
-        'id_ indented by value of cls._id_indent.'
-        return (max(0, cls._id_indent - len(id_)) * ' ') + id_
-
-    def id_indented(
-            self
-            ) -> str:
-        'self.id_ indented by value of self._id_indent.'
-        return self.indent_id(self.id_)
-
-    def show(
-            self
-            ) -> str:
-        'Default single-item display of self.'
-        return str(self)
-
-    @classmethod
-    def formatters(
-            cls
-            ) -> dict[str, tuple[str, Callable[[Self], str]]]:
-        'Available per-item string formatters.'
-        formatters = {
-            'default': (
-                'most commonly relevant infos',
-                lambda item: item.show())
-            }
-        if issubclass(cls, Textfiled):
-            formatters['raw'] = (
-                    'in source-file format',
-                    lambda item: item.raw())
-        return formatters
-
-    @classmethod
-    def matchers(
-            cls
-            ) -> dict[str, tuple[str, Callable[[Self, str], bool]]]:
-        'Available lookup matchers.'
-        return {
-            PARAM_Q_ID: (
-                'by ID of individual item',
-                lambda item, q_body: item.id_ == q_body),
-            f'text{CHAR_TEXTCONTAINS}': (
-                'text contained in item description',
-                lambda item, q_body: q_body.upper() in item.show().upper())
-            }
-
-    def match(
-            self,
-            query_char: str,
-            query_body: str
-            ) -> bool:
-        'Return if self matches query.'
-        return self.matchers()[query_char][1](self, query_body)
-
-    @classmethod
-    def _by_box_sorter(
-            cls,
-            to_id_of=''
-            ) -> tuple[str,
-                       Callable[['BricksDb', tuple[Self, ...]],
-                                tuple[Self, ...]]]:
-        def by_box(
-                db: 'BricksDb',
-                pre_sorted: tuple[Self, ...]
-                ) -> tuple[Self, ...]:
-            remains = list(pre_sorted)
-            items: list[Self] = []
-            for box in sorted(db.boxes.values(),
-                              key=lambda box: box.id_indented()):
-                for item_id in [t[1] for t in box.brick_listings_flat()]:
-                    if to_id_of:
-                        item_id = getattr(db.bricks[item_id], f'{to_id_of}_id')
-                    if item_id not in [item.id_ for item in items]:
-                        to_move = [item for item in remains
-                                   if item.id_ == item_id][0]
-                        items += [to_move]
-                        remains.remove(to_move)
-            return tuple(items + remains)
-        return 'by IDs of containing boxes', by_box
-
-    @classmethod
-    def sorters(
-            cls
-            ) -> dict[str, tuple[str, Callable[['BricksDb', tuple[Self, ...]],
-                                               tuple[Self, ...]]]]:
-        'Available sorters.'
-        return {
-            TOK_SORT_ID: (
-                'by individual items\' IDs',
-                lambda _, pre_sorted:
-                tuple(sorted(pre_sorted,
-                             key=lambda item: item.id_indented())))
-            }
-
-
-class BrickColor(Textfiled, Lookupable):
-    'Color incl. solidness/transparency field.'
-    _id_indent = 3
-
-    def __init__(
-            self,
-            id_: str,
-            solid: bool,
-            wavelength: str
-            ) -> None:
-        self.id_ = id_
-        self.solid = solid
-        self.wavelength = wavelength
-
-    @classmethod
-    def from_textfile(
-            cls,
-            path: tuple[str, str],
-            **_
-            ) -> dict[str, Self]:
-        collected = {}
-        for id_, desc in [cls.tokify(line, 2) for line in cls.lines_of(path)]:
-            assert id_ not in collected
-            assert len(desc) > 1
-            assert desc[0] in {CHAR_COL_SOLID, CHAR_COL_TRANSPARENT}
-            collected[id_] = cls(id_, desc[0] == CHAR_COL_SOLID, desc[1:])
-        return collected
-
-    @classmethod
-    def sorters(
-            cls
-            ) -> dict[str, tuple[str,
-                                 Callable[['BricksDb', tuple[Self, ...]],
-                                          tuple[Self, ...]]]]:
-        return super().sorters() | {TOK_SORT_BOX: cls._by_box_sorter('color')}
-
-    def raw(
-            self
-            ) -> str:
-        return (f'{self.id_indented()} '
-                + (CHAR_COL_SOLID if self.solid else CHAR_COL_TRANSPARENT)
-                + self.wavelength)
-
-
-@dataclass
-class BrickDesignData:
-    'Lookupables for BrickDesign besides .id.'
-    n_studs: int = -1
-    description: str = '?'
-    ldraw: str = ''
-
-
-class BrickDesign(Textfiled, Lookupable):
-    'Shape and texture configurations with descriptions and equalities.'
-    _id_indent = 6
-    alternate_to: Optional[Self] = None
-
-    def __init__(
-            self,
-            id_: str,
-            attrs: Optional[BrickDesignData] = None
-            ) -> None:
-        self.id_ = id_
-        self.direct_attrs = attrs
-        self.alternate_ids: set[str] = set()
-
-    def __getattribute__(self, key: str):
-        if key in BrickDesignData.__annotations__:
-            if self.direct_attrs:
-                attrs = self.direct_attrs
-            else:
-                assert self.alternate_to is not None
-                assert self.alternate_to.direct_attrs is not None
-                attrs = self.alternate_to.direct_attrs
-            return getattr(attrs, key)
-        return super().__getattribute__(key)
-
-    @property
-    def all_ids(
-            self
-            ) -> tuple[str, ...]:
-        'Own .id_ plus (sorted) .alternate_ids.'
-        return tuple([self.id_] + sorted(list(self.alternate_ids)))
-
-    @classmethod
-    def matchers(
-            cls
-            ) -> dict[str, tuple[str, Callable[[Self, str], bool]]]:
-        def attr_matcher(
-                attr_name: str
-                ) -> tuple[str, Callable[[Self, str], bool]]:
-            def match(
-                    item: Self,
-                    q_body: str
-                    ) -> bool:
-                assert q_body.isdigit()
-                return getattr(item, attr_name) == int(q_body)
-            return 'by ' + ATTR_DESCS[attr_name], match
-        return super().matchers() | {
-            f'{attr_name}{CHAR_ATTR_EQ}': attr_matcher(attr_name)
-            for attr_name, attr_type in BrickDesignData.__annotations__.items()
-            if attr_type is int}
-
-    @classmethod
-    def sorters(
-            cls
-            ) -> dict[str, tuple[str,
-                                 Callable[['BricksDb', tuple[Self, ...]],
-                                          tuple[Self, ...]]]]:
-        def attr_sorter(
-                attr_name: str,
-                ) -> tuple[str, Callable[[BricksDb, tuple[Self, ...]],
-                                         tuple[Self, ...]]]:
-            def f_sort(
-                    _: BricksDb,
-                    pre_sorted: tuple[Self, ...]
-                    ) -> tuple[Self, ...]:
-                return tuple(sorted(pre_sorted,
-                                    key=lambda item: getattr(item, attr_name)))
-            return 'by ' + ATTR_DESCS[attr_name], f_sort
-        return super().sorters()\
-            | {TOK_SORT_BOX: cls._by_box_sorter('design')}\
-            | {attr_name: attr_sorter(attr_name)
-               for attr_name in BrickDesignData.__annotations__}
-
-    @classmethod
-    def from_textfile(
-            cls,
-            path: tuple[str, str],
-            **_
-            ) -> dict[str, Self]:
-        collected = {}
-        alts: dict[str, set[str]] = {}
-        for design_id, body in [cls.tokify(line, 2)
-                                for line in cls.lines_of(path)]:
-            assert design_id not in collected, design_id
-            assert len(body) > 1
-            if body[0] == CHAR_DESIGN_ALT:
-                alt_id = body[1:]
-                alts[alt_id] = alts.get(alt_id, set())
-                alts[alt_id].add(design_id)
-                collected[design_id] = cls(design_id)
-            else:
-                assert SEP_DESIGN_DESC in body
-                metadata, desc = body.split(SEP_DESIGN_DESC, maxsplit=1)
-                attrs = BrickDesignData(description=desc)
-                annos = BrickDesignData.__annotations__
-                for attr in metadata.split(SEP_DESIGN_ATTR):
-                    assert CHAR_ATTR_EQ in attr
-                    a_key, a_val_str = attr.split(CHAR_ATTR_EQ, maxsplit=1)
-                    assert a_key in annos
-                    if annos[a_key] is int:
-                        assert a_val_str.isdigit()
-                        setattr(attrs, a_key, int(a_val_str))
-                    else:
-                        setattr(attrs, a_key, a_val_str)
-                collected[design_id] = cls(design_id, attrs)
-        for id_, alternate_ids in alts.items():
-            collected[id_].alternate_ids = alternate_ids
-            for alt_id in alternate_ids:
-                collected[alt_id].alternate_to = collected[id_]
-        return collected
-
-    def raw(
-            self
-            ) -> str:
-        raw = f'{self.id_indented()} '
-        if self.alternate_to:
-            return f'{raw}{CHAR_DESIGN_ALT}{self.alternate_to.id_}'
-        attrs = []
-        for attr_key in [k for k in BrickDesignData.__annotations__
-                         if k != 'description']:
-            attr_val = getattr(self, attr_key)
-            if attr_val != getattr(BrickDesignData, attr_key):
-                attrs += [f'{attr_key}{CHAR_ATTR_EQ}{attr_val}']
-        return f'{raw}{"|".join(attrs)}{SEP_DESIGN_DESC}{self.description}'
-
-
-class Brick(Textfiled, WithDb, Lookupable):
-    'Individual configuration of design and color.'
-    _id_indent = 7
-
-    def __init__(
-            self,
-            id_: str,
-            design_id: str,
-            color_id: str,
-            comment: str,
-            **kwargs
-            ) -> None:
-        self.id_ = id_
-        self.design_id = design_id
-        self.color_id = color_id
-        self.comment = comment
-        super().__init__(**kwargs)
-
-    @classmethod
-    def sorters(
-            cls
-            ) -> dict[str, tuple[str, Callable[['BricksDb', tuple[Self, ...]],
-                                               tuple[Self, ...]]]]:
-        return super().sorters() | {TOK_SORT_BOX: cls._by_box_sorter()}
-
-    @classmethod
-    def from_textfile(
-            cls,
-            path: tuple[str, str],
-            db: Optional['BricksDb'] = None,
-            **_
-            ) -> dict[str, Self]:
-        collected = {}
-        for toks in [cls.tokify(line, 3) for line in cls.lines_of(path)]:
-            brick_id, design_id = toks[:2]
-            assert brick_id not in collected
-            color_id, comment = (toks[-1].split(CHAR_SEP_TOKEN, maxsplit=1)
-                                 + [''])[:2]
-            collected[brick_id] = cls(brick_id, design_id, color_id, comment,
-                                      db=db)
-        return collected
-
-    def raw(
-            self
-            ) -> str:
-        return (f'{self.id_indented()} '
-                f'{BrickDesign.indent_id(self.design_id)} '
-                f'{BrickColor.indent_id(self.color_id)} {self.comment}'
-                ).rstrip()
-
-    def __str__(
-            self
-            ) -> str:
-        assert self._db
-        design = self._db.designs[self.design_id]
-        color = str(self._db.colors[self.color_id]).strip()
-        comment = f' # {self.comment}' if self.comment else ''
-        return (f'{self.id_indented()} '
-                f'{BrickDesign.indent_id(self.design_id)} '
-                f'{design.description} ({color}){comment}')
-
-
-class BrickSet(Textfiled, WithDb, Lookupable):
-    'Named collection of bricks in order of pages of columns of counts.'
-
-    def __init__(
-            self,
-            id_: str,
-            is_in: Optional[bool],
-            description: str,
-            brick_listings: tuple[Page, ...],
-            **kwargs
-            ) -> None:
-        self.id_ = id_
-        self.is_in = is_in
-        self.description = description
-        self.brick_listings = brick_listings
-        super().__init__(**kwargs)
-
-    @classmethod
-    def from_textfile(
-            cls,
-            path: tuple[str, str],
-            db: Optional['BricksDb'] = None,
-            **_
-            ) -> dict[str, Self]:
-        collected: dict[str, tuple[Optional[bool],
-                                   str,
-                                   list[list[list[BrickListing]]]]]
-        collected = {}
-        i_listings: list[list[list[BrickListing]]] = [[[]]]
-        for line in cls.lines_of(path):
-            if not line.startswith(CHAR_COLL_INDENT):
-                id_, metadata = cls.tokify(line, 2)
-                assert metadata
-                assert metadata[0] in {CHAR_COLL_IN,
-                                       CHAR_COLL_OUT,
-                                       CHAR_COLL_INACTIVE}
-                is_in = (None if metadata[0] == CHAR_COLL_INACTIVE
-                         else metadata[0] == CHAR_COLL_IN)
-                i_listings = [[[]]]
-                collected[id_] = is_in, metadata[1:], i_listings
-            elif line[1:2] == CHAR_COLL_SEP_COLUMN:
-                i_listings[-1] += [[]]
-            elif line[1:2] == CHAR_COLL_SEP_PAGE:
-                i_listings += [[[]]]
-            else:
-                count, remainder = cls.tokify(line, 2)
-                assert count.isdigit()
-                id_, comment = (remainder.split(CHAR_SEP_TOKEN, maxsplit=1)
-                                + [''])[:2]
-                assert len(id_) > 0
-                i_listings[-1][-1] += [(int(count), id_, comment)]
-        return {
-            k: cls(id_=k,
-                   is_in=v[0],
-                   description=v[1],
-                   brick_listings=tuple(tuple(tuple(column) for column in page)
-                                        for page in v[2]),
-                   db=db)
-            for k, v in collected.items()}
-
-    @property
-    def _is_in_str(
-            self
-            ) -> str:
-        return (CHAR_COLL_INACTIVE if self.is_in is None
-                else (CHAR_COLL_IN if self.is_in else CHAR_COLL_OUT))
-
-    def raw(
-            self
-            ) -> str:
-        return (f'{self.id_indented()} '
-                f'{self._is_in_str}{self.description}{CHAR_NEWLINE}'
-                + self._format_paginated(lambda count, p_id, comment:
-                                         f' {count:2} {Brick.indent_id(p_id)}'
-                                         + (f' {comment}' if comment else '')))
-
-    def __str__(
-            self
-            ) -> str:
-        return f'{self.id_} {self._is_in_str} {self.description}'
-
-    def _format_paginated(
-            self,
-            format_line: Callable[[int, str, str], str]
-            ) -> str:
-        lines = []
-        for idx_pages, page in enumerate(self.brick_listings):
-            if idx_pages != 0:
-                lines += [' =']
-            for idx_columns, column in enumerate(page):
-                if idx_columns != 0:
-                    lines += [' -']
-                for count, brick_id, comment in column:
-                    lines += [format_line(count, brick_id, comment)]
-        return CHAR_NEWLINE.join(lines) + CHAR_NEWLINE
-
-    def brick_listings_flat(self) -> tuple[BrickListing, ...]:
-        'Flattened variant of .brick_listings, no division into pages/cols.'
-        collected: list[BrickListing] = []
-        for page in self.brick_listings:
-            for column in page:
-                collected += list(column)
-        return tuple(collected)
-
-    def show(
-            self
-            ) -> str:
-        def format_line(count, brick_id, comment) -> str:
-            assert self._db
-            brick = self._db.bricks[brick_id]
-            design_id = brick.design_id
-            tail_comment = f' # {comment}' if comment else ''
-            color = str(self._db.colors[self._db.bricks[brick_id].color_id]
-                        ).lstrip().split(CHAR_SEP_TOKEN, maxsplit=1)[1]
-            box: Optional[Box] = None
-            for i_box in self._db.boxes.values():
-                for idx_in_box in [
-                        idx for idx, d_to_ls
-                        in enumerate(i_box.designs_to_listings)
-                        if brick_id in [listing[1] for listing in d_to_ls[1]]]:
-                    box = i_box
-                    break
-                if box:
-                    break
-            if not box:
-                for i_box in self._db.boxes.values():
-                    for idx_in_box in [
-                            idx for idx, t
-                            in enumerate(i_box.designs_to_listings)
-                            if design_id in t[0].all_ids]:
-                        box = i_box
-                        break
-                    if box:
-                        break
-            box_listing = ((box.id_indented() if box else Box.indent_id(''))
-                           + ':' + (f'{idx_in_box:>2}' if box else '__'))
-            return (
-                f'{count:>2}× {Brick.indent_id(brick_id)}:'
-                f'{BrickDesign.indent_id(design_id)} {box_listing} {color} '
-                + self._db.designs[design_id].description + tail_comment)
-
-        return f'{self}{CHAR_NEWLINE}{self._format_paginated(format_line)}'
-
-
-class Box(WithDb, Lookupable):
-    'Order of designs.'
-    _id_indent = 4
-
-    def __init__(
-            self,
-            id_: str,
-            bricks_set: BrickSet,
-            **kwargs
-            ) -> None:
-        super().__init__(**kwargs)
-        assert self._db
-        self.id_ = id_
-        self._set = bricks_set
-        designed_listings: list[tuple[BrickDesign, list[BrickListing]]] = []
-        for listing in self.brick_listings_flat():
-            design = self._db.designs[self._db.bricks[listing[1]].design_id]
-            design = design.alternate_to or design
-            if not (designed_listings and designed_listings[-1][0] == design):
-                designed_listings += [(design, []), ]
-            target = designed_listings[-1][1]
-            target += [listing]
-        self.designs_to_listings = tuple((d, tuple(ls))
-                                         for d, ls in designed_listings)
-
-    def __str__(
-            self
-            ) -> str:
-        return (f'{self.id_indented()} '
-                + ', '.join('/'.join(design.all_ids)
-                            for design, _ in self.designs_to_listings))
-
-    def brick_listings_flat(
-            self
-            ) -> tuple[BrickListing, ...]:
-        'Shortcut to BrickSet method of same name.'
-        return self._set.brick_listings_flat()
-
-    def show(
-            self,
-            ) -> str:
-        assert self._db
-        lines = []
-        for design, listings in self.designs_to_listings:
-            lines += [
-                f'=== {" / ".join(design.all_ids)}: {design.description} ===']
-            for count, brick_id, comment in listings:
-                color = self._db.colors[self._db.bricks[brick_id].color_id]
-                lines += [f'{count:>2}× {Brick.indent_id(brick_id)} / {color} '
-                          f'# {comment}']
-        return CHAR_NEWLINE.join(lines)
-
-
-class BricksDb:
-    'Collection of all the tables enabling their combined processing.'
-    lookupables: dict[str, type[Lookupable]] = {
-            'boxes': Box,
-            'bricks': Brick,
-            'colors': BrickColor,
-            'designs': BrickDesign,
-            'sets': BrickSet
-            }
-    bricks: dict[str, Brick]
-    colors: dict[str, BrickColor]
-    designs: dict[str, BrickDesign]
-    sets: dict[str, BrickSet]
-
-    def __init__(
-            self,
-            path_tables: str,
-            path_ldraw: str,
-            ldraw_modes: str
-            ) -> None:
-        for name, cls in [(name, cls) for name, cls in self.lookupables.items()
-                          if issubclass(cls, Textfiled)]:
-            kwargs = {'db': self} if issubclass(cls, WithDb) else {}
-            setattr(self, name, cls.from_textfile((path_tables, f'{name}.txt'),
-                                                  **kwargs))
-        self._path_ldraw = Path(path_ldraw).joinpath('parts')
-        self._ldraw_modes = ldraw_modes.split(CHAR_SEP_LDRAW_MODES)
-        if LDRAW_MODE_FILL_PATHS in self._ldraw_modes:
-            for design in [design for design in self.designs.values()
-                           if not (design.alternate_to or design.ldraw)]:
-                filename = f'{design.id_}.dat'
-                if self._path_ldraw.joinpath(filename).exists():
-                    assert design.direct_attrs is not None
-                    design.direct_attrs.ldraw = filename
-        self._check_consistencies()
-        if LDRAW_MODE_FILL_N_STUDS in self._ldraw_modes:
-            for design in [d for d in self._designs_ldrawed()
-                           if d.n_studs != 1]:
-                assert design.direct_attrs is not None
-                design.direct_attrs.n_studs\
-                    = self._ldraw_studs_count(design.ldraw)
-
-    @property
-    def boxes(
-            self
-            ) -> dict[str, Box]:
-        'Parsed from BOX_PREFIX-prefixed entries in .sets.'
-        collected = {}
-        for bricks_set in [c for c in self.sets.values()
-                           if c.id_.startswith(BOX_PREFIX)]:
-            box_id = bricks_set.id_[len(BOX_PREFIX):]
-            collected[box_id] = Box(box_id, bricks_set, db=self)
-        return collected
-
-    def _designs_ldrawed(
-            self
-            ) -> tuple[BrickDesign, ...]:
-        return tuple(design for design in self.designs.values()
-                     if (not design.alternate_to)
-                     and design.ldraw and design.ldraw != '!')
-
-    def _ldraw_studs_count(
-            self,
-            filename: str
-            ) -> int:
-        path = self._path_ldraw.joinpath(filename)
-        if not path.exists():
-            path = self._path_ldraw.joinpath('..', 'p', filename)
-            if not path.exists():
-                raise Exception(f'invalid ldraw reference: {filename}')
-        studs_counted = 0
-        for line in path.read_text(encoding='utf8').split(CHAR_NEWLINE):
-            line = line.lstrip()
-            if not line.startswith('1'):
-                continue
-            toks = line.split(maxsplit=14)
-            target = toks[14].replace('\\', '/')
-            matrix = tuple(tuple(toks[n:n+3]) for n in (5, 8, 11))
-            do_ignore = False
-            for row in [[float(x) for x in row] for row in matrix]:
-                if do_ignore:
-                    break
-                for idx in range(len(row)):  # pylint: disable=C0200
-                    if do_ignore:
-                        break
-                    cell = row[idx]
-                    if cell != 0 and [True for jdx in range(len(row))
-                                      if jdx != idx and row[jdx] != 0]:
-                        do_ignore = True
-                        break
-            studs_counted += (int((not do_ignore) and target in LDRAW_STUDS)
-                              or self._ldraw_studs_count(target))
-        return studs_counted
-
-    def _check_consistencies(
-            self
-            ) -> None:
-        fails = []
-
-        # check all items listed in sets recorded in bricks
-        for coll in self.sets.values():
-            for brick_id in [t[1] for t in coll.brick_listings_flat()
-                             if t[1] not in self.bricks]:
-                fails += [f'missing brick of ID: {brick_id}']
-
-        # check all bricks' designs recorded in designs
-        for d_id in [p.design_id for p in self.bricks.values()
-                     if p.design_id not in self.designs]:
-            fails += [f'missing design of ID: {d_id}']
-
-        # check all recorded designs have matching bricks (at least via alts)
-        for design_id, design in self.designs.items():
-            if not [p for p in self.bricks.values()
-                    if p.design_id in design.all_ids]:
-                fails += [f'missing bricks for design of ID: {design_id}']
-
-        # check all bricks' colors are recorded
-        for color_id in [v.color_id for v in self.bricks.values()
-                         if v.color_id not in self.colors]:
-            fails += [f'missing color of ID: {color_id}']
-
-        # check sets' in-out directions even out
-        counts: dict[str, int] = {}
-        for coll in [coll for coll in self.sets.values()
-                     if coll.is_in is not None]:
-            for count, brick_id, _ in coll.brick_listings_flat():
-                counts[brick_id] = (counts.get(brick_id, 0)
-                                    + ((1 if coll.is_in else (-1)) * count))
-        for brick_id, count in [(k, v) for k, v in counts.items()
-                                if v != 0]:
-            fails += [f'invalid count for brick of ID: {brick_id} ({count})']
-
-        for design in self._designs_ldrawed():
-            # hunt for dangling Design.ldraw references
-            if LDRAW_MODE_VERIFY_PATHS in self._ldraw_modes:
-                if not self._path_ldraw.joinpath(design.ldraw).exists():
-                    fails += ['unresolved ldraw reference for design of ID: '
-                              f'{design.id_} ({design.ldraw})']
-                    continue
-            # compare n_studs calculations to explicit records
-            if LDRAW_MODE_VERIFY_N_STUDS in self._ldraw_modes:
-                if design.n_studs <= -1:
-                    continue
-                studs_counted = self._ldraw_studs_count(design.ldraw)
-                if design.n_studs != studs_counted:
-                    fails += ['n_studs count differs between record and ldraw '
-                              f'calculation for design of ID: {design.id_} '
-                              f'({design.n_studs} vs {studs_counted} '
-                              f'as per {design.ldraw})']
-
-        # print, and crash on, collected fails, if any
-        for fail in fails:
-            print(fail)
-        assert not fails
-
-    def lookup(
-            self,
-            table_name: str,
-            formatter: str,
-            match_by: str,
-            sort_by: str
-            ) -> str:
-        'Return formatted result of inquiry on table of table_name.'
-        assert table_name in self.lookupables
-        match_key = match_val = ''
-        for idx, c in enumerate(match_by):
-            match_key += c
-            if c in PARAM_Q_DELIMITERS:
-                match_val = match_by[idx+1:]
-                break
-        cls = self.lookupables[table_name]
-        for arg_key, x_ers in [(arg, m())
-                               for arg, m in ((match_key, cls.matchers),
-                                              (sort_by, cls.sorters),
-                                              (formatter, cls.formatters))]:
-            keys = x_ers.keys()
-            if arg_key == ARG_Q_MARK:
-                return CHAR_NEWLINE.join([f'{key} – {x_ers[key][0]}'
-                                          for key in sorted(keys)])
-            assert (not arg_key) or arg_key in keys, (arg_key, keys)
-        items = tuple(getattr(self, table_name).values())
-        if items and sort_by:
-            items = cls.sorters()[sort_by][1](self, items)
-        if items and match_by:
-            items = tuple(x for x in items if x.match(match_key, match_val))
-        return CHAR_NEWLINE.join([cls.formatters()[formatter][1](item)
-                                  for item in items])
-
-
-def main(
-        ) -> None:
-    'Load tables from BRICKSPLOM_DIR and put out in reproducible listings.'
-    def parse_args(
-            ) -> ArgumentNamespace:
-        def add_abbreviable_table_arg(
-                ) -> None:
-            def complete(
-                    arg: str
-                    ) -> str:
-                candidates = tuple(c for c in choices if c.startswith(arg))
-                if len(candidates) > 1:
-                    raise ArgumentError(
-                        action,
-                        f'{arg} – ambiguous, matches: {", ".join(candidates)}')
-                return (candidates + (arg, ))[0]
-            choices = BricksDb.lookupables.keys()
-            msg = 'one of: ' + ', '.join(sorted(list(choices)))
-            msg += '; NB: abbreviable to first unambiguous character sequence'
-            action = parser.add_argument(
-                   'table',
-                   choices=choices, type=complete, metavar='TABLE', help=msg)
-        parser = ArgumentParser()
-        add_abbreviable_table_arg()
-        hint_qmark = f'use "{ARG_Q_MARK}" to list options for chosen table'
-        hint_ldraw = 'allowed: ' + ', '.join(sorted(list(LDRAW_MODES)))
-        arg_match = '--match'
-        arg_item_id = 'ITEM_ID'
-        parser.add_argument(
-                arg_item_id.lower(),
-                nargs='?', metavar=arg_item_id,
-                help=f'shortcut to "{arg_match} {PARAM_Q_ID}{arg_item_id}"')
-        parser.add_argument(
-                '-f', '--format',
-                action='store', help=hint_qmark, default='default')
-        parser.add_argument(
-                '-m', arg_match,
-                action='store', help=hint_qmark, default='')
-        parser.add_argument(
-                '-s', '--sort',
-                action='store', help=hint_qmark, default=TOK_SORT_ID)
-        action_ldraw = parser.add_argument(
-                '-l', '--ldraw',
-                action='store', default='',
-                help=f'comma-separated; {hint_ldraw}')
-        args = parser.parse_args()
-        for _ in [m for m in args.ldraw.split(CHAR_SEP_LDRAW_MODES)
-                  if m and m not in LDRAW_MODES]:
-            parser.error(str(ArgumentError(action_ldraw, hint_ldraw)))
-        if args.match != ARG_Q_MARK and args.item_id:
-            args.match = PARAM_Q_ID + args.item_id
-        return args
-
-    args = parse_args()
-    db = BricksDb(environ.get(NAME_ENV_DIRNAME, '.'),
-                  environ.get(NAME_ENV_LDRAW, ''),
-                  args.ldraw)
-    print(
-        db.lookup(
-            table_name=args.table,
-            formatter=args.format,
-            match_by=args.match,
-            sort_by=args.sort
-        ).rstrip())
-
-
-if __name__ == '__main__':
-    main()
index 0dc504b3ef6c8119727780ff4b0bfb1a0914eb41..6db08997acfdee769ac52bda048d1f566018a015 100755 (executable)
@@ -1,3 +1,2 @@
 #!/usr/bin/sh
-set -e
-cp bricksplom.py ~/.local/bin/bricksplom
+./plomlib/sh/install.sh bricksplom no_deps
diff --git a/plomlib b/plomlib
new file mode 160000 (submodule)
index 0000000..2f85135
--- /dev/null
+++ b/plomlib
@@ -0,0 +1 @@
+Subproject commit 2f851352484cd0f8ad415dd765163b4a94c95ba8
diff --git a/src/bricksplom/__init__.py b/src/bricksplom/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/bricksplom/misc.py b/src/bricksplom/misc.py
new file mode 100644 (file)
index 0000000..ab22a1c
--- /dev/null
@@ -0,0 +1,853 @@
+'Anything not peculiar.'
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable, Optional, Self
+
+LDRAW_STUDS = {
+    'stud.dat',
+    'studa.dat',
+    'stud2.dat',
+    'stud2a.dat',
+    'stud6a.dat',
+    'stud10.dat',
+    'stud15.dat',
+    'hipstud.dat',
+    's/973s01.dat'
+}
+
+CHAR_NEWLINE = '\n'
+CHAR_SEP_TOKEN = ' '
+CHAR_COMMENT = '#'
+CHAR_DESIGN_ALT = '='
+CHAR_ATTR_EQ = '='
+CHAR_TEXTCONTAINS = ':'
+SEP_DESIGN_DESC = ' #'
+SEP_DESIGN_ATTR = '|'
+CHAR_COLL_INDENT = ' '
+CHAR_SEP_LDRAW_MODES = ','
+CHAR_COLL_IN = '+'
+CHAR_COLL_OUT = '-'
+CHAR_COLL_INACTIVE = '#'
+CHAR_COLL_SEP_COLUMN = '-'
+CHAR_COLL_SEP_PAGE = '='
+CHAR_COL_SOLID = '+'
+CHAR_COL_TRANSPARENT = '-'
+BOX_PREFIX = 'box:'
+
+ARG_QMARK = '?'
+PARAM_Q_ID = f'id{CHAR_ATTR_EQ}'
+PARAM_Q_DELIMITERS = {CHAR_ATTR_EQ, CHAR_TEXTCONTAINS}
+TOK_SORT_BOX = 'box'
+TOK_SORT_ID = 'id'
+
+LDRAW_MODE_FILL_N_STUDS = 'fill_n_studs'
+LDRAW_MODE_FILL_PATHS = 'fill_paths'
+LDRAW_MODE_VERIFY_N_STUDS = 'verify_n_studs'
+LDRAW_MODE_VERIFY_PATHS = 'verify_paths'
+LDRAW_MODES = {LDRAW_MODE_FILL_N_STUDS,
+               LDRAW_MODE_FILL_PATHS,
+               LDRAW_MODE_VERIFY_N_STUDS,
+               LDRAW_MODE_VERIFY_PATHS}
+
+ATTR_DESCS: dict[str, str] = {
+    'n_studs': 'number of studs',
+    'description': 'guesstimate what this be',
+    'ldraw': 'path of associated LDRAW file'
+}
+
+BrickListing = tuple[int, str, str]  # count, ID, comment
+PageColumn = tuple[BrickListing, ...]
+Page = tuple[PageColumn, ...]
+
+
+class Textfiled(ABC):
+    'Table to be read from textfile, and compatible output in .raw().'
+
+    @staticmethod
+    def lines_of(
+            path: tuple[str, str]
+            ) -> tuple[str, ...]:
+        'Non-empty right-stripped lines of file at path.'
+        joined_path = Path(path[0]).joinpath(path[1])
+        return tuple(line.rstrip()
+                     for line in joined_path.read_text(encoding='utf8'
+                                                       ).split(CHAR_NEWLINE)
+                     if line.strip() and not line.startswith(CHAR_COMMENT))
+
+    @staticmethod
+    def tokify(
+            body: str,
+            len_expected: int
+            ) -> tuple[str, ...]:
+        'Body parsed into left-stripped tokens of len_expected count.'
+        collected: list[str] = []
+        while len(collected) < len_expected:
+            body = body.lstrip()
+            if len(collected) == len_expected - 1:
+                tok = body
+            else:
+                assert CHAR_SEP_TOKEN in body, body
+                tok, body = body.split(CHAR_SEP_TOKEN, maxsplit=1)
+            collected += [tok]
+        return tuple(collected)
+
+    @classmethod
+    @abstractmethod
+    def from_textfile(
+            cls,
+            path: tuple[str, str],
+            **kwargs
+            ) -> dict[str, Self]:
+        'Build from file at path.'
+
+    @abstractmethod
+    def raw(self) -> str:
+        'Output in format used for reading in.'
+
+    def __str__(
+            self
+            ) -> str:
+        return self.raw()
+
+
+class WithDb:
+    'Add db:Optional[BricksDB] field to __init__, setting ._db.'
+
+    def __init__(
+            self,
+            db: Optional['BricksDb'] = None,
+            **kwargs
+            ) -> None:
+        self._db = db
+        super().__init__(**kwargs)
+
+
+class Lookupable:
+    'Provides methods for BricksDb.lookup and .id_ padding.'
+    _id_indent: int = 0
+    id_: str
+
+    @classmethod
+    def indent_id(
+            cls,
+            id_: str
+            ) -> str:
+        'id_ indented by value of cls._id_indent.'
+        return (max(0, cls._id_indent - len(id_)) * ' ') + id_
+
+    def id_indented(
+            self
+            ) -> str:
+        'self.id_ indented by value of self._id_indent.'
+        return self.indent_id(self.id_)
+
+    def show(
+            self
+            ) -> str:
+        'Default single-item display of self.'
+        return str(self)
+
+    @classmethod
+    def formatters(
+            cls
+            ) -> dict[str, tuple[str, Callable[[Self], str]]]:
+        'Available per-item string formatters.'
+        formatters = {
+            'default': (
+                'most commonly relevant infos',
+                lambda item: item.show())
+            }
+        if issubclass(cls, Textfiled):
+            formatters['raw'] = (
+                    'in source-file format',
+                    lambda item: item.raw())
+        return formatters
+
+    @classmethod
+    def matchers(
+            cls
+            ) -> dict[str, tuple[str, Callable[[Self, str], bool]]]:
+        'Available lookup matchers.'
+        return {
+            PARAM_Q_ID: (
+                'by ID of individual item',
+                lambda item, q_body: item.id_ == q_body),
+            f'text{CHAR_TEXTCONTAINS}': (
+                'text contained in item description',
+                lambda item, q_body: q_body.upper() in item.show().upper())
+            }
+
+    def match(
+            self,
+            query_char: str,
+            query_body: str
+            ) -> bool:
+        'Return if self matches query.'
+        return self.matchers()[query_char][1](self, query_body)
+
+    @classmethod
+    def _by_box_sorter(
+            cls,
+            to_id_of=''
+            ) -> tuple[str,
+                       Callable[['BricksDb', tuple[Self, ...]],
+                                tuple[Self, ...]]]:
+        def by_box(
+                db: 'BricksDb',
+                pre_sorted: tuple[Self, ...]
+                ) -> tuple[Self, ...]:
+            remains = list(pre_sorted)
+            items: list[Self] = []
+            for box in sorted(db.boxes.values(),
+                              key=lambda box: box.id_indented()):
+                for item_id in [t[1] for t in box.brick_listings_flat()]:
+                    if to_id_of:
+                        item_id = getattr(db.bricks[item_id], f'{to_id_of}_id')
+                    if item_id not in [item.id_ for item in items]:
+                        to_move = [item for item in remains
+                                   if item.id_ == item_id][0]
+                        items += [to_move]
+                        remains.remove(to_move)
+            return tuple(items + remains)
+        return 'by IDs of containing boxes', by_box
+
+    @classmethod
+    def sorters(
+            cls
+            ) -> dict[str, tuple[str, Callable[['BricksDb', tuple[Self, ...]],
+                                               tuple[Self, ...]]]]:
+        'Available sorters.'
+        return {
+            TOK_SORT_ID: (
+                'by individual items\' IDs',
+                lambda _, pre_sorted:
+                tuple(sorted(pre_sorted,
+                             key=lambda item: item.id_indented())))
+            }
+
+
+class BrickColor(Textfiled, Lookupable):
+    'Color incl. solidness/transparency field.'
+    _id_indent = 3
+
+    def __init__(
+            self,
+            id_: str,
+            solid: bool,
+            wavelength: str
+            ) -> None:
+        self.id_ = id_
+        self.solid = solid
+        self.wavelength = wavelength
+
+    @classmethod
+    def from_textfile(
+            cls,
+            path: tuple[str, str],
+            **_
+            ) -> dict[str, Self]:
+        collected = {}
+        for id_, desc in [cls.tokify(line, 2) for line in cls.lines_of(path)]:
+            assert id_ not in collected
+            assert len(desc) > 1
+            assert desc[0] in {CHAR_COL_SOLID, CHAR_COL_TRANSPARENT}
+            collected[id_] = cls(id_, desc[0] == CHAR_COL_SOLID, desc[1:])
+        return collected
+
+    @classmethod
+    def sorters(
+            cls
+            ) -> dict[str, tuple[str,
+                                 Callable[['BricksDb', tuple[Self, ...]],
+                                          tuple[Self, ...]]]]:
+        return super().sorters() | {TOK_SORT_BOX: cls._by_box_sorter('color')}
+
+    def raw(
+            self
+            ) -> str:
+        return (f'{self.id_indented()} '
+                + (CHAR_COL_SOLID if self.solid else CHAR_COL_TRANSPARENT)
+                + self.wavelength)
+
+
+@dataclass
+class BrickDesignData:
+    'Lookupables for BrickDesign besides .id.'
+    n_studs: int = -1
+    description: str = '?'
+    ldraw: str = ''
+
+
+class BrickDesign(Textfiled, Lookupable):
+    'Shape and texture configurations with descriptions and equalities.'
+    _id_indent = 6
+    alternate_to: Optional[Self] = None
+
+    def __init__(
+            self,
+            id_: str,
+            attrs: Optional[BrickDesignData] = None
+            ) -> None:
+        self.id_ = id_
+        self.direct_attrs = attrs
+        self.alternate_ids: set[str] = set()
+
+    def __getattribute__(self, key: str):
+        if key in BrickDesignData.__annotations__:
+            if self.direct_attrs:
+                attrs = self.direct_attrs
+            else:
+                assert self.alternate_to is not None
+                assert self.alternate_to.direct_attrs is not None
+                attrs = self.alternate_to.direct_attrs
+            return getattr(attrs, key)
+        return super().__getattribute__(key)
+
+    @property
+    def all_ids(
+            self
+            ) -> tuple[str, ...]:
+        'Own .id_ plus (sorted) .alternate_ids.'
+        return tuple([self.id_] + sorted(list(self.alternate_ids)))
+
+    @classmethod
+    def matchers(
+            cls
+            ) -> dict[str, tuple[str, Callable[[Self, str], bool]]]:
+        def attr_matcher(
+                attr_name: str
+                ) -> tuple[str, Callable[[Self, str], bool]]:
+            def match(
+                    item: Self,
+                    q_body: str
+                    ) -> bool:
+                assert q_body.isdigit()
+                return getattr(item, attr_name) == int(q_body)
+            return 'by ' + ATTR_DESCS[attr_name], match
+        return super().matchers() | {
+            f'{attr_name}{CHAR_ATTR_EQ}': attr_matcher(attr_name)
+            for attr_name, attr_type in BrickDesignData.__annotations__.items()
+            if attr_type is int}
+
+    @classmethod
+    def sorters(
+            cls
+            ) -> dict[str, tuple[str,
+                                 Callable[['BricksDb', tuple[Self, ...]],
+                                          tuple[Self, ...]]]]:
+        def attr_sorter(
+                attr_name: str,
+                ) -> tuple[str, Callable[[BricksDb, tuple[Self, ...]],
+                                         tuple[Self, ...]]]:
+            def f_sort(
+                    _: BricksDb,
+                    pre_sorted: tuple[Self, ...]
+                    ) -> tuple[Self, ...]:
+                return tuple(sorted(pre_sorted,
+                                    key=lambda item: getattr(item, attr_name)))
+            return 'by ' + ATTR_DESCS[attr_name], f_sort
+        return super().sorters()\
+            | {TOK_SORT_BOX: cls._by_box_sorter('design')}\
+            | {attr_name: attr_sorter(attr_name)
+               for attr_name in BrickDesignData.__annotations__}
+
+    @classmethod
+    def from_textfile(
+            cls,
+            path: tuple[str, str],
+            **_
+            ) -> dict[str, Self]:
+        collected = {}
+        alts: dict[str, set[str]] = {}
+        for design_id, body in [cls.tokify(line, 2)
+                                for line in cls.lines_of(path)]:
+            assert design_id not in collected, design_id
+            assert len(body) > 1
+            if body[0] == CHAR_DESIGN_ALT:
+                alt_id = body[1:]
+                alts[alt_id] = alts.get(alt_id, set())
+                alts[alt_id].add(design_id)
+                collected[design_id] = cls(design_id)
+            else:
+                assert SEP_DESIGN_DESC in body
+                metadata, desc = body.split(SEP_DESIGN_DESC, maxsplit=1)
+                attrs = BrickDesignData(description=desc)
+                annos = BrickDesignData.__annotations__
+                for attr in metadata.split(SEP_DESIGN_ATTR):
+                    assert CHAR_ATTR_EQ in attr
+                    a_key, a_val_str = attr.split(CHAR_ATTR_EQ, maxsplit=1)
+                    assert a_key in annos
+                    if annos[a_key] is int:
+                        assert a_val_str.isdigit()
+                        setattr(attrs, a_key, int(a_val_str))
+                    else:
+                        setattr(attrs, a_key, a_val_str)
+                collected[design_id] = cls(design_id, attrs)
+        for id_, alternate_ids in alts.items():
+            collected[id_].alternate_ids = alternate_ids
+            for alt_id in alternate_ids:
+                collected[alt_id].alternate_to = collected[id_]
+        return collected
+
+    def raw(
+            self
+            ) -> str:
+        raw = f'{self.id_indented()} '
+        if self.alternate_to:
+            return f'{raw}{CHAR_DESIGN_ALT}{self.alternate_to.id_}'
+        attrs = []
+        for attr_key in [k for k in BrickDesignData.__annotations__
+                         if k != 'description']:
+            attr_val = getattr(self, attr_key)
+            if attr_val != getattr(BrickDesignData, attr_key):
+                attrs += [f'{attr_key}{CHAR_ATTR_EQ}{attr_val}']
+        return f'{raw}{"|".join(attrs)}{SEP_DESIGN_DESC}{self.description}'
+
+
+class Brick(Textfiled, WithDb, Lookupable):
+    'Individual configuration of design and color.'
+    _id_indent = 7
+
+    def __init__(
+            self,
+            id_: str,
+            design_id: str,
+            color_id: str,
+            comment: str,
+            **kwargs
+            ) -> None:
+        self.id_ = id_
+        self.design_id = design_id
+        self.color_id = color_id
+        self.comment = comment
+        super().__init__(**kwargs)
+
+    @classmethod
+    def sorters(
+            cls
+            ) -> dict[str, tuple[str, Callable[['BricksDb', tuple[Self, ...]],
+                                               tuple[Self, ...]]]]:
+        return super().sorters() | {TOK_SORT_BOX: cls._by_box_sorter()}
+
+    @classmethod
+    def from_textfile(
+            cls,
+            path: tuple[str, str],
+            db: Optional['BricksDb'] = None,
+            **_
+            ) -> dict[str, Self]:
+        collected = {}
+        for toks in [cls.tokify(line, 3) for line in cls.lines_of(path)]:
+            brick_id, design_id = toks[:2]
+            assert brick_id not in collected, brick_id
+            color_id, comment = (toks[-1].split(CHAR_SEP_TOKEN, maxsplit=1)
+                                 + [''])[:2]
+            collected[brick_id] = cls(brick_id, design_id, color_id, comment,
+                                      db=db)
+        return collected
+
+    def raw(
+            self
+            ) -> str:
+        return (f'{self.id_indented()} '
+                f'{BrickDesign.indent_id(self.design_id)} '
+                f'{BrickColor.indent_id(self.color_id)} {self.comment}'
+                ).rstrip()
+
+    def __str__(
+            self
+            ) -> str:
+        assert self._db
+        design = self._db.designs[self.design_id]
+        color = str(self._db.colors[self.color_id]).strip()
+        comment = f' # {self.comment}' if self.comment else ''
+        return (f'{self.id_indented()} '
+                f'{BrickDesign.indent_id(self.design_id)} '
+                f'{design.description} ({color}){comment}')
+
+
+class BrickSet(Textfiled, WithDb, Lookupable):
+    'Named collection of bricks in order of pages of columns of counts.'
+
+    def __init__(
+            self,
+            id_: str,
+            is_in: Optional[bool],
+            description: str,
+            brick_listings: tuple[Page, ...],
+            **kwargs
+            ) -> None:
+        self.id_ = id_
+        self.is_in = is_in
+        self.description = description
+        self.brick_listings = brick_listings
+        super().__init__(**kwargs)
+
+    @classmethod
+    def from_textfile(
+            cls,
+            path: tuple[str, str],
+            db: Optional['BricksDb'] = None,
+            **_
+            ) -> dict[str, Self]:
+        collected: dict[str, tuple[Optional[bool],
+                                   str,
+                                   list[list[list[BrickListing]]]]]
+        collected = {}
+        i_listings: list[list[list[BrickListing]]] = [[[]]]
+        for line in cls.lines_of(path):
+            if not line.startswith(CHAR_COLL_INDENT):
+                id_, metadata = cls.tokify(line, 2)
+                assert metadata
+                assert metadata[0] in {CHAR_COLL_IN,
+                                       CHAR_COLL_OUT,
+                                       CHAR_COLL_INACTIVE}
+                is_in = (None if metadata[0] == CHAR_COLL_INACTIVE
+                         else metadata[0] == CHAR_COLL_IN)
+                i_listings = [[[]]]
+                collected[id_] = is_in, metadata[1:], i_listings
+            elif line[1:2] == CHAR_COLL_SEP_COLUMN:
+                i_listings[-1] += [[]]
+            elif line[1:2] == CHAR_COLL_SEP_PAGE:
+                i_listings += [[[]]]
+            else:
+                count, remainder = cls.tokify(line, 2)
+                assert count.isdigit()
+                id_, comment = (remainder.split(CHAR_SEP_TOKEN, maxsplit=1)
+                                + [''])[:2]
+                assert len(id_) > 0
+                i_listings[-1][-1] += [(int(count), id_, comment)]
+        return {
+            k: cls(id_=k,
+                   is_in=v[0],
+                   description=v[1],
+                   brick_listings=tuple(tuple(tuple(column) for column in page)
+                                        for page in v[2]),
+                   db=db)
+            for k, v in collected.items()}
+
+    @property
+    def _is_in_str(
+            self
+            ) -> str:
+        return (CHAR_COLL_INACTIVE if self.is_in is None
+                else (CHAR_COLL_IN if self.is_in else CHAR_COLL_OUT))
+
+    def raw(
+            self
+            ) -> str:
+        return (f'{self.id_indented()} '
+                f'{self._is_in_str}{self.description}{CHAR_NEWLINE}'
+                + self._format_paginated(lambda count, p_id, comment:
+                                         f' {count:2} {Brick.indent_id(p_id)}'
+                                         + (f' {comment}' if comment else '')))
+
+    def __str__(
+            self
+            ) -> str:
+        return f'{self.id_} {self._is_in_str} {self.description}'
+
+    def _format_paginated(
+            self,
+            format_line: Callable[[int, str, str], str]
+            ) -> str:
+        lines = []
+        for idx_pages, page in enumerate(self.brick_listings):
+            if idx_pages != 0:
+                lines += [' =']
+            for idx_columns, column in enumerate(page):
+                if idx_columns != 0:
+                    lines += [' -']
+                for count, brick_id, comment in column:
+                    lines += [format_line(count, brick_id, comment)]
+        return CHAR_NEWLINE.join(lines) + CHAR_NEWLINE
+
+    def brick_listings_flat(self) -> tuple[BrickListing, ...]:
+        'Flattened variant of .brick_listings, no division into pages/cols.'
+        collected: list[BrickListing] = []
+        for page in self.brick_listings:
+            for column in page:
+                collected += list(column)
+        return tuple(collected)
+
+    def show(
+            self
+            ) -> str:
+        def format_line(count, brick_id, comment) -> str:
+            assert self._db
+            brick = self._db.bricks[brick_id]
+            design_id = brick.design_id
+            tail_comment = f' # {comment}' if comment else ''
+            color = str(self._db.colors[self._db.bricks[brick_id].color_id]
+                        ).lstrip().split(CHAR_SEP_TOKEN, maxsplit=1)[1]
+            box: Optional[Box] = None
+            for i_box in self._db.boxes.values():
+                for idx_in_box in [
+                        idx for idx, d_to_ls
+                        in enumerate(i_box.designs_to_listings)
+                        if brick_id in [listing[1] for listing in d_to_ls[1]]]:
+                    box = i_box
+                    break
+                if box:
+                    break
+            if not box:
+                for i_box in self._db.boxes.values():
+                    for idx_in_box in [
+                            idx for idx, t
+                            in enumerate(i_box.designs_to_listings)
+                            if design_id in t[0].all_ids]:
+                        box = i_box
+                        break
+                    if box:
+                        break
+            box_listing = ((box.id_indented() if box else Box.indent_id(''))
+                           + ':' + (f'{idx_in_box:>2}' if box else '__'))
+            return (
+                f'{count:>2}× {Brick.indent_id(brick_id)}:'
+                f'{BrickDesign.indent_id(design_id)} {box_listing} {color} '
+                + self._db.designs[design_id].description + tail_comment)
+
+        return f'{self}{CHAR_NEWLINE}{self._format_paginated(format_line)}'
+
+
+class Box(WithDb, Lookupable):
+    'Order of designs.'
+    _id_indent = 4
+
+    def __init__(
+            self,
+            id_: str,
+            bricks_set: BrickSet,
+            **kwargs
+            ) -> None:
+        super().__init__(**kwargs)
+        assert self._db
+        self.id_ = id_
+        self._set = bricks_set
+        designed_listings: list[tuple[BrickDesign, list[BrickListing]]] = []
+        for listing in self.brick_listings_flat():
+            design = self._db.designs[self._db.bricks[listing[1]].design_id]
+            design = design.alternate_to or design
+            if not (designed_listings and designed_listings[-1][0] == design):
+                designed_listings += [(design, []), ]
+            target = designed_listings[-1][1]
+            target += [listing]
+        self.designs_to_listings = tuple((d, tuple(ls))
+                                         for d, ls in designed_listings)
+
+    def __str__(
+            self
+            ) -> str:
+        return (f'{self.id_indented()} '
+                + ', '.join('/'.join(design.all_ids)
+                            for design, _ in self.designs_to_listings))
+
+    def brick_listings_flat(
+            self
+            ) -> tuple[BrickListing, ...]:
+        'Shortcut to BrickSet method of same name.'
+        return self._set.brick_listings_flat()
+
+    def show(
+            self,
+            ) -> str:
+        assert self._db
+        lines = []
+        for design, listings in self.designs_to_listings:
+            lines += [
+                f'=== {" / ".join(design.all_ids)}: {design.description} ===']
+            for count, brick_id, comment in listings:
+                color = self._db.colors[self._db.bricks[brick_id].color_id]
+                lines += [f'{count:>2}× {Brick.indent_id(brick_id)} / {color} '
+                          f'# {comment}']
+        return CHAR_NEWLINE.join(lines)
+
+
+class BricksDb:
+    'Collection of all the tables enabling their combined processing.'
+    lookupables: dict[str, type[Lookupable]] = {
+            'boxes': Box,
+            'bricks': Brick,
+            'colors': BrickColor,
+            'designs': BrickDesign,
+            'sets': BrickSet
+            }
+    bricks: dict[str, Brick]
+    colors: dict[str, BrickColor]
+    designs: dict[str, BrickDesign]
+    sets: dict[str, BrickSet]
+
+    def __init__(
+            self,
+            path_tables: str,
+            path_ldraw: str,
+            ldraw_modes: str
+            ) -> None:
+        for name, cls in [(name, cls) for name, cls in self.lookupables.items()
+                          if issubclass(cls, Textfiled)]:
+            kwargs = {'db': self} if issubclass(cls, WithDb) else {}
+            setattr(self, name, cls.from_textfile((path_tables, f'{name}.txt'),
+                                                  **kwargs))
+        self._path_ldraw = Path(path_ldraw).joinpath('parts')
+        self._ldraw_modes = ldraw_modes.split(CHAR_SEP_LDRAW_MODES)
+        if LDRAW_MODE_FILL_PATHS in self._ldraw_modes:
+            for design in [design for design in self.designs.values()
+                           if not (design.alternate_to or design.ldraw)]:
+                filename = f'{design.id_}.dat'
+                if self._path_ldraw.joinpath(filename).exists():
+                    assert design.direct_attrs is not None
+                    design.direct_attrs.ldraw = filename
+        self._check_consistencies()
+        if LDRAW_MODE_FILL_N_STUDS in self._ldraw_modes:
+            for design in [d for d in self._designs_ldrawed()
+                           if d.n_studs != 1]:
+                assert design.direct_attrs is not None
+                design.direct_attrs.n_studs\
+                    = self._ldraw_studs_count(design.ldraw)
+
+    @property
+    def boxes(
+            self
+            ) -> dict[str, Box]:
+        'Parsed from BOX_PREFIX-prefixed entries in .sets.'
+        collected = {}
+        for bricks_set in [c for c in self.sets.values()
+                           if c.id_.startswith(BOX_PREFIX)]:
+            box_id = bricks_set.id_[len(BOX_PREFIX):]
+            collected[box_id] = Box(box_id, bricks_set, db=self)
+        return collected
+
+    def _designs_ldrawed(
+            self
+            ) -> tuple[BrickDesign, ...]:
+        return tuple(design for design in self.designs.values()
+                     if (not design.alternate_to)
+                     and design.ldraw and design.ldraw != '!')
+
+    def _ldraw_studs_count(
+            self,
+            filename: str
+            ) -> int:
+        path = self._path_ldraw.joinpath(filename)
+        if not path.exists():
+            path = self._path_ldraw.joinpath('..', 'p', filename)
+            if not path.exists():
+                raise Exception(f'invalid ldraw reference: {filename}')
+        studs_counted = 0
+        for line in path.read_text(encoding='utf8').split(CHAR_NEWLINE):
+            line = line.lstrip()
+            if not line.startswith('1'):
+                continue
+            toks = line.split(maxsplit=14)
+            target = toks[14].replace('\\', '/')
+            matrix = tuple(tuple(toks[n:n+3]) for n in (5, 8, 11))
+            do_ignore = False
+            for row in [[float(x) for x in row] for row in matrix]:
+                if do_ignore:
+                    break
+                for idx in range(len(row)):  # pylint: disable=C0200
+                    if do_ignore:
+                        break
+                    cell = row[idx]
+                    if cell != 0 and [True for jdx in range(len(row))
+                                      if jdx != idx and row[jdx] != 0]:
+                        do_ignore = True
+                        break
+            studs_counted += (int((not do_ignore) and target in LDRAW_STUDS)
+                              or self._ldraw_studs_count(target))
+        return studs_counted
+
+    def _check_consistencies(
+            self
+            ) -> None:
+        fails = []
+
+        # check all items listed in sets recorded in bricks
+        for coll in self.sets.values():
+            for brick_id in [t[1] for t in coll.brick_listings_flat()
+                             if t[1] not in self.bricks]:
+                fails += [f'missing brick of ID: {brick_id}']
+
+        # check all bricks' designs recorded in designs
+        for d_id in [p.design_id for p in self.bricks.values()
+                     if p.design_id not in self.designs]:
+            fails += [f'missing design of ID: {d_id}']
+
+        # check all recorded designs have matching bricks (at least via alts)
+        for design_id, design in self.designs.items():
+            if not [p for p in self.bricks.values()
+                    if p.design_id in design.all_ids]:
+                fails += [f'missing bricks for design of ID: {design_id}']
+
+        # check all bricks' colors are recorded
+        for color_id in [v.color_id for v in self.bricks.values()
+                         if v.color_id not in self.colors]:
+            fails += [f'missing color of ID: {color_id}']
+
+        # check sets' in-out directions even out
+        counts: dict[str, int] = {}
+        for coll in [coll for coll in self.sets.values()
+                     if coll.is_in is not None]:
+            for count, brick_id, _ in coll.brick_listings_flat():
+                counts[brick_id] = (counts.get(brick_id, 0)
+                                    + ((1 if coll.is_in else (-1)) * count))
+        for brick_id, count in [(k, v) for k, v in counts.items()
+                                if v != 0]:
+            fails += [f'invalid count for brick of ID: {brick_id} ({count})']
+
+        for design in self._designs_ldrawed():
+            # hunt for dangling Design.ldraw references
+            if LDRAW_MODE_VERIFY_PATHS in self._ldraw_modes:
+                if not self._path_ldraw.joinpath(design.ldraw).exists():
+                    fails += ['unresolved ldraw reference for design of ID: '
+                              f'{design.id_} ({design.ldraw})']
+                    continue
+            # compare n_studs calculations to explicit records
+            if LDRAW_MODE_VERIFY_N_STUDS in self._ldraw_modes:
+                if design.n_studs <= -1:
+                    continue
+                studs_counted = self._ldraw_studs_count(design.ldraw)
+                if design.n_studs != studs_counted:
+                    fails += ['n_studs count differs between record and ldraw '
+                              f'calculation for design of ID: {design.id_} '
+                              f'({design.n_studs} vs {studs_counted} '
+                              f'as per {design.ldraw})']
+
+        # print, and crash on, collected fails, if any
+        for fail in fails:
+            print(fail)
+        assert not fails
+
+    def lookup(
+            self,
+            table_name: str,
+            formatter: str,
+            match_by: str,
+            sort_by: str
+            ) -> str:
+        'Return formatted result of inquiry on table of table_name.'
+        assert table_name in self.lookupables
+        match_key = match_val = ''
+        for idx, c in enumerate(match_by):
+            match_key += c
+            if c in PARAM_Q_DELIMITERS:
+                match_val = match_by[idx+1:]
+                break
+        cls = self.lookupables[table_name]
+        for arg_key, x_ers in [(arg, m())
+                               for arg, m in ((match_key, cls.matchers),
+                                              (sort_by, cls.sorters),
+                                              (formatter, cls.formatters))]:
+            keys = x_ers.keys()
+            if arg_key == ARG_QMARK:
+                return CHAR_NEWLINE.join([f'{key} – {x_ers[key][0]}'
+                                          for key in sorted(keys)])
+            assert (not arg_key) or arg_key in keys, (arg_key, keys)
+        items = tuple(getattr(self, table_name).values())
+        if items and sort_by:
+            items = cls.sorters()[sort_by][1](self, items)
+        if items and match_by:
+            items = tuple(x for x in items if x.match(match_key, match_val))
+        return CHAR_NEWLINE.join([cls.formatters()[formatter][1](item)
+                                  for item in items])
diff --git a/src/run.py b/src/run.py
new file mode 100755 (executable)
index 0000000..8c841ae
--- /dev/null
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+'Bricks inventory manager.'
+
+# standard libs
+from argparse import (
+        ArgumentError,
+        ArgumentParser,
+        Namespace as ArgumentNamespace,
+        )
+from os import environ
+# ourselves
+from bricksplom.misc import (
+        BricksDb,
+        ARG_QMARK,
+        CHAR_SEP_LDRAW_MODES,
+        LDRAW_MODES,
+        PARAM_Q_ID,
+        TOK_SORT_ID,
+        )
+
+NAME_ENV_DIRNAME = 'BRICKSPLOM_DIR'
+NAME_ENV_LDRAW = 'PATH_LDRAW'
+
+
+def main(
+        ) -> None:
+    'Load tables from BRICKSPLOM_DIR and put out in reproducible listings.'
+    def parse_args(
+            ) -> ArgumentNamespace:
+        def add_abbreviable_table_arg(
+                ) -> None:
+            def complete(
+                    arg: str
+                    ) -> str:
+                candidates = tuple(c for c in choices if c.startswith(arg))
+                if len(candidates) > 1:
+                    raise ArgumentError(
+                        action,
+                        f'{arg} – ambiguous, matches: {", ".join(candidates)}')
+                return (candidates + (arg, ))[0]
+            choices = BricksDb.lookupables.keys()
+            msg = 'one of: ' + ', '.join(sorted(list(choices)))
+            msg += '; NB: abbreviable to first unambiguous character sequence'
+            action = parser.add_argument(
+                   'table',
+                   choices=choices, type=complete, metavar='TABLE', help=msg)
+        parser = ArgumentParser()
+        add_abbreviable_table_arg()
+        hint_qmark = f'use "{ARG_QMARK}" to list options for chosen table'
+        hint_ldraw = 'allowed: ' + ', '.join(sorted(list(LDRAW_MODES)))
+        arg_match = '--match'
+        arg_item_id = 'ITEM_ID'
+        parser.add_argument(
+                arg_item_id.lower(),
+                nargs='?', metavar=arg_item_id,
+                help=f'shortcut to "{arg_match} {PARAM_Q_ID}{arg_item_id}"')
+        parser.add_argument(
+                '-f', '--format',
+                action='store', help=hint_qmark, default='default')
+        parser.add_argument(
+                '-m', arg_match,
+                action='store', help=hint_qmark, default='')
+        parser.add_argument(
+                '-s', '--sort',
+                action='store', help=hint_qmark, default=TOK_SORT_ID)
+        action_ldraw = parser.add_argument(
+                '-l', '--ldraw',
+                action='store', default='',
+                help=f'comma-separated; {hint_ldraw}')
+        args = parser.parse_args()
+        for _ in [m for m in args.ldraw.split(CHAR_SEP_LDRAW_MODES)
+                  if m and m not in LDRAW_MODES]:
+            parser.error(str(ArgumentError(action_ldraw, hint_ldraw)))
+        if args.match != ARG_QMARK and args.item_id:
+            args.match = PARAM_Q_ID + args.item_id
+        return args
+
+    args = parse_args()
+    db = BricksDb(environ.get(NAME_ENV_DIRNAME, '.'),
+                  environ.get(NAME_ENV_LDRAW, ''),
+                  args.ldraw)
+    print(
+        db.lookup(
+            table_name=args.table,
+            formatter=args.format,
+            match_by=args.match,
+            sort_by=args.sort
+        ).rstrip())
+
+
+if __name__ == '__main__':
+    main()