From: Plom Heller Date: Mon, 3 Aug 2026 07:37:23 +0000 (+0200) Subject: Re-organize into modularity handled by plomlib installer. X-Git-Url: https://plomlompom.com/repos/booking/%22https:/validator.w3.org/process?a=commitdiff_plain;h=5a26671dec17bf6c5a6e1852dca4646d95036ed2;p=bricksplom Re-organize into modularity handled by plomlib installer. --- diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..42cf7f3 --- /dev/null +++ b/.gitmodules @@ -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 index b12064c..0000000 --- a/bricksplom.py +++ /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() diff --git a/install.sh b/install.sh index 0dc504b..6db0899 100755 --- a/install.sh +++ b/install.sh @@ -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 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 index 0000000..e69de29 diff --git a/src/bricksplom/misc.py b/src/bricksplom/misc.py new file mode 100644 index 0000000..ab22a1c --- /dev/null +++ b/src/bricksplom/misc.py @@ -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 index 0000000..8c841ae --- /dev/null +++ b/src/run.py @@ -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()