#!/usr/bin/env python3 """Edit the material definitions in `materials.py` without opening it. The materials are Python, not data: an entry is an `M(...)` call, its ingredients are usually `ingots(...)` rather than a list, and its effects are `speed(-0.12)` rather than a dictionary. A generated JSON file is downstream of all that and gets overwritten by the next `generate_materials.py` run, so this edits the source instead. It does that by rewriting one argument at a time, in place. Each field shows the *source text* of the argument it stands for rather than a rendering of its value, and saving replaces exactly that span of the file - so `ingots("mekanism", "ingot_tin", ...)` stays a call, the comments under every material stay where they were, and a diff shows the number that changed and nothing else. tools/MATERIAL_EDITOR.py Materials built by a loop rather than written out - the dragon scales, the gem families - have no literal `M(...)` to edit and are shown read-only, because the thing to change for those is the loop. """ from __future__ import annotations import argparse import ast import os import subprocess import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import guiplatform guiplatform.configure(prefer=os.environ.get('ARMOUR_GUI_PLATFORM'), quiet=True) from PySide6 import QtCore, QtGui, QtWidgets # noqa: E402 TOOLS = os.path.dirname(os.path.abspath(__file__)) REPO = os.path.dirname(TOOLS) SOURCE = os.path.join(TOOLS, 'materials.py') # The signature of M(), in order, with the type each argument carries. The type # is shown beside the field because these are Python literals being edited as # text: "3" and "3.0" are not the same thing to a stat that is declared a float, # and a tuple of one string needs its trailing comma. POSITIONAL = ['name', 'pack', 'group', 'translation', 'palette_from', 'items', 'tier', 'hardness', 'density', 'flexibility', 'durability', 'enchantability', 'mining_speed'] FIELDS = [ ('name', 'str'), ('pack', 'str'), ('group', 'str'), ('translation', 'str'), ('palette_from', 'str'), ('icon', 'str | None'), ('items', 'list[dict]'), ('tier', 'int'), ('hardness', 'float'), ('density', 'float'), ('flexibility', 'float'), ('durability', 'int'), ('enchantability', 'int'), ('mining_speed', 'int'), ('mining_level', 'str | None'), ('toughness', 'int'), ('armor_durability', 'int | None'), ('armor_toughness', 'float | None'), ('knockback_resistance', 'float | None'), ('groups', 'tuple[str] | None'), ('hidden_groups', 'tuple[str] | None'), ('textures', 'tuple[str]'), ('properties', 'dict | None'), ] # The ones worth more than a single line to look at. TALL = {'items', 'properties', 'groups', 'hidden_groups', 'textures'} DEFAULTS = {'toughness': '0', 'textures': '("metallic",)', 'properties': '{}', 'groups': 'None', 'hidden_groups': 'None', 'icon': 'None', 'mining_level': 'None', 'armor_durability': 'None', 'armor_toughness': 'None', 'knockback_resistance': 'None'} # ------------------------------------------------------------------ the source class MaterialSource: """`materials.py`, parsed so that a single argument can be replaced. Everything is done in bytes. `ast` reports column offsets as byte offsets into the encoded line, so working in characters would put every span one place out the first time somebody writes a material with an accent in its name. """ def __init__(self, path=SOURCE): self.path = path self.reload() def reload(self): with open(self.path, 'rb') as fh: self.data = fh.read() self.lines = self.data.split(b'\n') self.starts, at = [], 0 for line in self.lines: self.starts.append(at) at += len(line) + 1 tree = ast.parse(self.data.decode('utf-8'), self.path) self.calls = {} for node in ast.walk(tree): if (isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == 'M' and node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str)): self.calls[node.args[0].value] = node # Where a new material can be appended: just before the `]` that closes # the MATERIALS list. self.list_end = None for node in ast.walk(tree): if (isinstance(node, ast.Assign) and node.targets and isinstance(node.targets[0], ast.Name) and node.targets[0].id == 'MATERIALS' and isinstance(node.value, ast.List)): self.list_end = self._offset(node.value.end_lineno, node.value.end_col_offset) - 1 def _offset(self, lineno, col): return self.starts[lineno - 1] + col def span(self, node): return (self._offset(node.lineno, node.col_offset), self._offset(node.end_lineno, node.end_col_offset)) def names(self): return set(self.calls) def argument(self, name, field): """The node for one argument of one material, or None if not passed.""" call = self.calls.get(name) if call is None: return None for kw in call.keywords: if kw.arg == field: return kw.value if field in POSITIONAL: index = POSITIONAL.index(field) if index < len(call.args): return call.args[index] return None def text(self, name, field): node = self.argument(name, field) if node is None: return None start, end = self.span(node) return self.data[start:end].decode('utf-8') # ------------------------------------------------------------- writing def apply(self, edits): """Replace argument spans. `edits` is [(material, field, source)]. Applied back to front so that an earlier edit never moves a later one's offsets, and re-parsed afterwards so the next edit is measured against what is now on disk. """ patches = [] for name, field, new in edits: node = self.argument(name, field) if node is not None: start, end = self.span(node) patches.append((start, end, new.encode('utf-8'))) else: patches.append(self._insert_keyword(name, field, new)) data = self.data for start, end, blob in sorted(patches, key=lambda p: -p[0]): data = data[:start] + blob + data[end:] with open(self.path, 'wb') as fh: fh.write(data) self.reload() def _insert_keyword(self, name, field, new): """A keyword the call does not pass yet, added before its closing paren.""" call = self.calls[name] last = call.keywords[-1].value if call.keywords else call.args[-1] _, end = self.span(last) return (end, end, f', {field}={new}'.encode('utf-8')) def add_material(self, source): """Append a whole `M(...)` call to the end of MATERIALS.""" if self.list_end is None: raise RuntimeError('could not find the end of MATERIALS') blob = ('\n ' + source.strip().rstrip(',') + ',\n').encode('utf-8') data = self.data[:self.list_end] + blob + self.data[self.list_end:] with open(self.path, 'wb') as fh: fh.write(data) self.reload() TEMPLATE = '''M("{name}", "{pack}", "metal", "{title}", "{pack}:ingot_{name}", ingots("{pack}", "ingot_{name}"), tier=3, hardness=5.0, density=4.0, flexibility=1, durability=300, enchantability=12, mining_speed=6)''' # ----------------------------------------------------------------------- window class MaterialEditor(QtWidgets.QMainWindow): def __init__(self, source, materials): super().__init__() self.source = source self.materials = materials self.pending = {} self.current = None self.setWindowTitle('materials') splitter = QtWidgets.QSplitter() splitter.addWidget(self._left()) splitter.addWidget(self._right()) splitter.setSizes([320, 780]) self.setCentralWidget(splitter) self._fill_list() self._status() # -------------------------------------------------------------- panes def _left(self): box = QtWidgets.QWidget() lay = QtWidgets.QVBoxLayout(box) self.filter = QtWidgets.QLineEdit() self.filter.setPlaceholderText('filter by name, pack or group') self.filter.textChanged.connect(self._fill_list) lay.addWidget(self.filter) self.list = QtWidgets.QListWidget() self.list.currentItemChanged.connect(self._select) lay.addWidget(self.list, 1) row = QtWidgets.QHBoxLayout() for label, slot in (('Add', self.on_add), ('Save', self.on_save), ('Revert', self.on_revert)): button = QtWidgets.QPushButton(label) button.clicked.connect(slot) row.addWidget(button) lay.addLayout(row) self.jars = QtWidgets.QLineEdit(os.path.expanduser('~/.cache/abdelpak-jars')) lay.addWidget(QtWidgets.QLabel('mod jars, for regenerating the JSON')) lay.addWidget(self.jars) regen = QtWidgets.QPushButton('Regenerate material JSON') regen.clicked.connect(self.on_regenerate) lay.addWidget(regen) return box def _right(self): outer = QtWidgets.QWidget() lay = QtWidgets.QVBoxLayout(outer) self.heading = QtWidgets.QLabel('-') font = self.heading.font() font.setBold(True) self.heading.setFont(font) lay.addWidget(self.heading) self.note = QtWidgets.QLabel('') self.note.setWordWrap(True) self.note.setStyleSheet('color: #b08;') lay.addWidget(self.note) scroll = QtWidgets.QScrollArea() scroll.setWidgetResizable(True) inner = QtWidgets.QWidget() form = QtWidgets.QGridLayout(inner) form.setColumnStretch(1, 1) self.editors = {} for row, (field, kind) in enumerate(FIELDS): form.addWidget(QtWidgets.QLabel(field), row, 0) if field in TALL: widget = QtWidgets.QPlainTextEdit() widget.setFixedHeight(58) widget.textChanged.connect(lambda f=field: self._changed(f)) else: widget = QtWidgets.QLineEdit() widget.textChanged.connect(lambda _=None, f=field: self._changed(f)) widget.setFont(QtGui.QFont('monospace')) form.addWidget(widget, row, 1) # The type sits at the end of the field, because these are literals # typed by hand and "3" against "3.0" is a real difference. kind_label = QtWidgets.QLabel(kind) kind_label.setStyleSheet('color: #888;') form.addWidget(kind_label, row, 2) self.editors[field] = widget scroll.setWidget(inner) lay.addWidget(scroll, 1) return outer # ------------------------------------------------------------ contents def _fill_list(self): want = self.filter.text().strip().lower() self.list.blockSignals(True) self.list.clear() for mat in self.materials: label = f"{mat['name']} [{mat['pack']} / {mat['group']}]" if want and want not in label.lower(): continue item = QtWidgets.QListWidgetItem(label) item.setData(QtCore.Qt.UserRole, mat['name']) if mat['name'] not in self.source.names(): item.setForeground(QtGui.QColor('#888')) self.list.addItem(item) self.list.blockSignals(False) if self.list.count(): self.list.setCurrentRow(0) def _select(self, item, _previous=None): if item is None: return self.current = item.data(QtCore.Qt.UserRole) editable = self.current in self.source.names() self.heading.setText(self.current) self.note.setText('' if editable else 'Built by a loop rather than written out - read only ' 'here; edit the loop that makes it.') self._loading = True for field, _kind in FIELDS: text = self.source.text(self.current, field) if editable else None key = (self.current, field) if key in self.pending: text = self.pending[key] shown = text if text is not None else DEFAULTS.get(field, '') widget = self.editors[field] widget.setReadOnly(not editable) if isinstance(widget, QtWidgets.QPlainTextEdit): widget.setPlainText(shown) else: widget.setText(shown) self._loading = False self._status() def _value(self, field): widget = self.editors[field] if isinstance(widget, QtWidgets.QPlainTextEdit): return widget.toPlainText().strip() return widget.text().strip() def _changed(self, field): if getattr(self, '_loading', False) or self.current is None: return if self.current not in self.source.names(): return new = self._value(field) old = self.source.text(self.current, field) key = (self.current, field) # An untouched optional argument stays untouched: writing `toughness=0` # into every material that never mentioned it would be a diff of noise. if new == (old if old is not None else DEFAULTS.get(field, '')): self.pending.pop(key, None) else: self.pending[key] = new self._mark(field, ok=self._parses(new)) self._status() @staticmethod def _parses(text): if not text: return False try: ast.parse(text, mode='eval') return True except SyntaxError: return False def _mark(self, field, ok): widget = self.editors[field] widget.setStyleSheet('' if ok else 'background: #5a2230;') def _status(self): bad = [f'{n}.{f}' for (n, f), v in self.pending.items() if not self._parses(v)] msg = f'{len(self.pending)} unsaved change(s)' if self.pending else 'no changes' if bad: msg += f' - will not parse: {", ".join(bad)}' self.statusBar().showMessage(msg) # ------------------------------------------------------------- actions def on_save(self): bad = [k for k, v in self.pending.items() if not self._parses(v)] if bad: QtWidgets.QMessageBox.warning( self, 'materials', 'These are not valid Python and were not saved:\n\n' + '\n'.join(f'{n}.{f}' for n, f in bad)) return if not self.pending: return edits = [(n, f, v) for (n, f), v in self.pending.items()] self.source.apply(edits) self.pending.clear() self._select(self.list.currentItem()) self.statusBar().showMessage( f'wrote {len(edits)} change(s) to {_short(self.source.path)}') def on_revert(self): self.pending.clear() self._select(self.list.currentItem()) def on_add(self): name, ok = QtWidgets.QInputDialog.getText( self, 'new material', 'id, lowercase with underscores:') if not ok or not name.strip(): return name = name.strip() if name in self.source.names(): QtWidgets.QMessageBox.warning(self, 'materials', f'{name} already exists.') return pack, ok = QtWidgets.QInputDialog.getText(self, 'new material', 'pack:', text='mekanism') if not ok: return self.source.add_material(TEMPLATE.format( name=name, pack=pack.strip() or 'mekanism', title=name.replace('_', ' ').title())) self.materials = load_materials(self.source.path) self._fill_list() for row in range(self.list.count()): if self.list.item(row).data(QtCore.Qt.UserRole) == name: self.list.setCurrentRow(row) break self.statusBar().showMessage(f'added {name} - it still needs real items ' f'and stats') def on_regenerate(self): folder = self.jars.text().strip() if not os.path.isdir(folder): QtWidgets.QMessageBox.warning(self, 'materials', f'not a folder: {folder}') return self.statusBar().showMessage('regenerating...') QtWidgets.QApplication.processEvents() run = subprocess.run([sys.executable, os.path.join(TOOLS, 'generate_materials.py'), '--jars', folder], capture_output=True, text=True, cwd=REPO) tail = (run.stdout + run.stderr).strip().splitlines() self.statusBar().showMessage(tail[-1] if tail else 'done') if run.returncode: QtWidgets.QMessageBox.warning(self, 'generate_materials.py', '\n'.join(tail[-25:])) def _short(path): """Repo-relative when it is in the repo, absolute when it is not.""" rel = os.path.relpath(path, REPO) return path if rel.startswith(os.pardir) else rel def load_materials(path=SOURCE): """The evaluated list, which is the only thing that knows the full set. Loaded from the same file that is being edited, and freshly each time - a material added during the session has to appear in the list, and a listing taken from a different file than the edits go to would be a quiet lie. """ import importlib.util spec = importlib.util.spec_from_file_location('_materials_under_edit', path) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return list(module.MATERIALS) def main(argv=None): ap = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument('--source', default=SOURCE, help='materials.py to edit') args = ap.parse_args(argv) app = QtWidgets.QApplication(sys.argv[:1]) window = MaterialEditor(MaterialSource(args.source), load_materials(args.source)) window.resize(1180, 760) window.show() return app.exec() if __name__ == '__main__': sys.exit(main())