--- /dev/null
+[submodule "plomlib"]
+ path = plomlib
+ url = https://plomlompom.com/repos/clone/plomlib
+++ /dev/null
-#!/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()
#!/usr/bin/sh
-set -e
-cp bricksplom.py ~/.local/bin/bricksplom
+./plomlib/sh/install.sh bricksplom no_deps
--- /dev/null
+Subproject commit 2f851352484cd0f8ad415dd765163b4a94c95ba8
--- /dev/null
+'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])
--- /dev/null
+#!/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()