clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

migrate WireViz through GraphViz Worker

Dami committed 3906040
commit
390604028f3ffe377440d8473b8334719e61a70a
parent
fd1429883bb8ce8b04e004bdfa182af4031c97da
author
Dami <dami.etoile@gmail.com>
39 files changed, +3546 -20
Adiagramzip-python-units/wireviz-translator/.gitignore5+
Adiagramzip-python-units/wireviz-translator/README.md26+
Adiagramzip-python-units/wireviz-translator/THIRD_PARTY_NOTICES.md10+
Adiagramzip-python-units/wireviz-translator/pylock.toml13+
Adiagramzip-python-units/wireviz-translator/pyproject.toml15+
Adiagramzip-python-units/wireviz-translator/src/entry.py158+++++++
Adiagramzip-python-units/wireviz-translator/src/graphviz.py57+++
Adiagramzip-python-units/wireviz-translator/src/protocol.py83++++
Adiagramzip-python-units/wireviz-translator/src/render.py85++++
Adiagramzip-python-units/wireviz-translator/src/wireviz/DataClasses.py353++++++++++++++++
Adiagramzip-python-units/wireviz-translator/src/wireviz/Harness.py451++++++++++++++++++++
Adiagramzip-python-units/wireviz-translator/src/wireviz/__init__.py8+
Adiagramzip-python-units/wireviz-translator/src/wireviz/__main__.py4+
Adiagramzip-python-units/wireviz-translator/src/wireviz/build_examples.py158+++++++
Adiagramzip-python-units/wireviz-translator/src/wireviz/wireviz.py290+++++++++++++
Adiagramzip-python-units/wireviz-translator/src/wireviz/wv_bom.py203+++++++++
Adiagramzip-python-units/wireviz-translator/src/wireviz/wv_colors.py184++++++++
Adiagramzip-python-units/wireviz-translator/src/wireviz/wv_gv_html.py76+++
Adiagramzip-python-units/wireviz-translator/src/wireviz/wv_helper.py118+++++
Adiagramzip-python-units/wireviz-translator/src/wireviz/wv_html.py54++
Adiagramzip-python-units/wireviz-translator/test/test_graphviz.py7+
Adiagramzip-python-units/wireviz-translator/test/test_protocol.py23+
Adiagramzip-python-units/wireviz-translator/test/test_render.py72+++
Adiagramzip-python-units/wireviz-translator/uv.lock327+++++++++++++++
Adiagramzip-python-units/wireviz-translator/vendor-licenses/WireViz-LICENSE674++++++++++++++++++++++++++++++
Adiagramzip-python-units/wireviz-translator/wrangler.jsonc15+
Mdiagramzip-render/README.md16+-
Mdiagramzip-render/package.json1+
Mdiagramzip-render/scripts/deploy-compatibility-units.mjs2+-
Adiagramzip-render/scripts/deploy-python-units.mjs16+
Mdiagramzip-render/scripts/deploy-worker-units.mjs1-
Mdiagramzip-render/scripts/smoke-units.mjs12+
Mdiagramzip-render/src/catalog.ts11+-
Mdiagramzip-render/test/catalog.test.ts6+
Mdiagramzip-render/test/gateway.test.ts2+-
Ddiagramzip-render/wrangler.compatibility/wireviz.jsonc1-
Mdiagramzip/src/renderer-units.js2+-
Mdiagramzip/test/renderer-units.test.js2+-
Mdocs/refactor-engines.md25+-
A diagramzip-python-units/wireviz-translator/.gitignore +5 -0
A diagramzip-python-units/wireviz-translator/README.md +26 -0
A diagramzip-python-units/wireviz-translator/THIRD_PARTY_NOTICES.md +10 -0
A diagramzip-python-units/wireviz-translator/pylock.toml +13 -0
A diagramzip-python-units/wireviz-translator/pyproject.toml +15 -0
A diagramzip-python-units/wireviz-translator/src/entry.py +158 -0
@@ -0,0 +1,158 @@
1+import json2+import hashlib3+from urllib.parse import urlparse4+5+from workers import Request, Response, WorkerEntrypoint6+7+from protocol import MAX_REQUEST_BYTES, ProtocolError, parse_render_input8+from render import WIREVIZ_VERSION, render_dot9+10+UNIT_ID = 'wireviz'11+EXPOSED_HEADERS = ', '.join([12+ 'Cache-Control', 'Content-Type', 'X-Diagram-Cache', 'X-Diagram-Engine',13+ 'X-Diagram-Engine-Version', 'X-Diagram-Pipeline', 'X-Diagram-Renderer',14+ 'X-Diagram-Unit', 'X-Renderer-Build',15+])16+17+18+def _json(value, status=200, cache='no-store'):19+ return Response(json.dumps(value, separators=(',', ':')), status=status, headers={20+ 'Access-Control-Allow-Origin': '*', 'Cache-Control': cache,21+ 'Content-Type': 'application/json; charset=utf-8',22+ })23+24+25+def _error(error):26+ return _json({'error': {'code': error.code, 'message': str(error)}}, error.status)27+28+29+def _cache_url(build, render_input):30+ canonical = json.dumps({31+ 'source': render_input.source, 'options': render_input.options,32+ 'metadata': [render_input.title, render_input.description],33+ 'presentation': [render_input.background, render_input.padding, render_input.frame],34+ }, sort_keys=True, separators=(',', ':'))35+ digest = hashlib.sha256(canonical.encode()).hexdigest()36+ return f'https://diagramzip-wireviz-cache.invalid/1/{build}/{digest}'37+38+39+def _cache_status(response, status):40+ from js import Response as JSResponse41+ updated = JSResponse.new(response.body, response.js_object)42+ updated.headers.set('X-Diagram-Cache', status)43+ return Response(updated)44+45+46+async def _body(request):47+ declared = request.headers.get('Content-Length')48+ if declared is not None:49+ try:50+ length = int(declared)51+ except ValueError as error:52+ raise ProtocolError(400, 'invalid_request', 'Content-Length is invalid.') from error53+ if length < 0:54+ raise ProtocolError(400, 'invalid_request', 'Content-Length is invalid.')55+ if length > MAX_REQUEST_BYTES:56+ raise ProtocolError(413, 'request_too_large', 'Render request is too large.')57+ if request.body is None:58+ raise ProtocolError(400, 'invalid_request', 'A JSON request body is required.')59+ reader = request.body.getReader()60+ chunks, length = [], 061+ while True:62+ result = await reader.read()63+ if result.done:64+ break65+ chunk = result.value.to_bytes()66+ length += len(chunk)67+ if length > MAX_REQUEST_BYTES:68+ await reader.cancel('request too large')69+ raise ProtocolError(413, 'request_too_large', 'Render request is too large.')70+ chunks.append(chunk)71+ try:72+ return b''.join(chunks).decode('utf-8')73+ except UnicodeDecodeError as error:74+ raise ProtocolError(400, 'invalid_json', 'Render request is not valid UTF-8 JSON.') from error75+76+77+class Default(WorkerEntrypoint):78+ async def fetch(self, request):79+ if request.method == 'OPTIONS':80+ return Response(None, status=204, headers={81+ 'Access-Control-Allow-Headers': 'Accept, Content-Type',82+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',83+ 'Access-Control-Allow-Origin': '*',84+ 'Access-Control-Max-Age': '86400',85+ })86+ path = urlparse(request.url).path87+ if request.method == 'GET' and path == '/v1/health':88+ return _json({'ok': True, 'unit': UNIT_ID, 'id': UNIT_ID})89+ if request.method == 'GET' and path == '/v1/capabilities':90+ return _json({91+ 'unit': UNIT_ID, 'id': UNIT_ID, 'kind': 'translate', 'format': 'svg',92+ 'runtime': 'edge-python', 'version': WIREVIZ_VERSION,93+ 'build': self.env.RENDERER_BUILD, 'pipeline': [UNIT_ID, 'graphviz-family', 'graphviz'],94+ 'knownLosses': [95+ 'Only SVG is supported; BOM and HTML sidecars are not exposed.',96+ 'Filesystem and remote images are rejected.',97+ 'tweak.append and tweak.override are rejected.',98+ 'GraphViz layout is provided by the shared GraphViz-Wasm unit; its 15.1.1 output may differ from the compatibility image GraphViz 14.1.3.',99+ ],100+ }, cache='public, max-age=300')101+ if request.method != 'POST' or path != '/v1/svg':102+ return _json({'error': {'code': 'not_found', 'message': 'Route not found.'}}, 404)103+ try:104+ render_input = parse_render_input(await _body(request))105+ from js import Request as JSRequest, caches106+ from pyodide.ffi import create_proxy107+108+ cache_key = JSRequest.new(_cache_url(self.env.RENDERER_BUILD, render_input))109+ try:110+ cached = await caches.default.match(cache_key)111+ if cached is not None:112+ return _cache_status(Response(cached), 'HIT')113+ except Exception as error:114+ print(json.dumps({'message': 'WireViz cache read failed', 'unit': UNIT_ID, 'error': str(error)}))115+116+ dot = render_dot(render_input.source)117+ downstream = Request('https://graphviz.render.diagram.zip/v1/svg', method='POST', headers={118+ 'Accept': 'image/svg+xml', 'Content-Type': 'application/json',119+ }, body=json.dumps({120+ 'source': dot, 'format': 'svg', 'options': {},121+ 'metadata': {'title': render_input.title, 'description': render_input.description},122+ 'presentation': {123+ 'background': render_input.background, 'padding': render_input.padding,124+ 'frame': render_input.frame,125+ },126+ })).js_object127+ try:128+ rendered = await self.env.GRAPHVIZ.fetch(downstream)129+ except Exception as error:130+ print(json.dumps({'message': 'WireViz GraphViz binding failed', 'unit': UNIT_ID, 'error': str(error)}))131+ raise ProtocolError(503, 'renderer_unavailable', 'The GraphViz renderer is unavailable.') from error132+ if rendered.status < 300:133+ headers = {134+ 'Cache-Control': rendered.headers.get('Cache-Control') or 'public, max-age=1800',135+ 'Content-Type': 'image/svg+xml; charset=utf-8',136+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",137+ 'X-Content-Type-Options': 'nosniff',138+ 'Access-Control-Allow-Origin': '*', 'Access-Control-Expose-Headers': EXPOSED_HEADERS,139+ 'X-Diagram-Engine': UNIT_ID, 'X-Diagram-Engine-Version': WIREVIZ_VERSION,140+ 'X-Diagram-Pipeline': 'wireviz,graphviz-family,graphviz', 'X-Diagram-Renderer': 'edge-python',141+ 'X-Diagram-Unit': UNIT_ID, 'X-Renderer-Build': self.env.RENDERER_BUILD,142+ 'X-Diagram-Cache': 'MISS',143+ }144+ response = Response(rendered.body, status=rendered.status, headers=headers)145+ try:146+ self.ctx.waitUntil(create_proxy(caches.default.put(cache_key, response.js_object.clone())))147+ except Exception as error:148+ print(json.dumps({'message': 'WireViz cache write failed', 'unit': UNIT_ID, 'error': str(error)}))149+ return response150+ return Response(rendered.body, status=rendered.status, headers={151+ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store',152+ 'Content-Type': rendered.headers.get('Content-Type') or 'application/json',153+ })154+ except ProtocolError as error:155+ return _error(error)156+ except Exception as error:157+ print(json.dumps({'message': 'WireViz translator failed', 'unit': UNIT_ID, 'error': str(error)}))158+ return _json({'error': {'code': 'render_failed', 'message': 'The WireViz source could not be rendered.'}}, 422)
A diagramzip-python-units/wireviz-translator/src/graphviz.py +57 -0
@@ -0,0 +1,57 @@
1+"""Tiny DOT builder implementing the subset used by WireViz.2+3+WireViz imports the Python graphviz package only to construct DOT. The real4+layout process is deliberately left to the shared GraphViz-Wasm renderer.5+"""6+import re7+8+def _quote(value):9+ value = str(value)10+ return value if value.startswith('<') and value.endswith('>') else '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"'11+12+def _attrs(attributes):13+ return ' '.join(f'{key}={_quote(value)}' for key, value in attributes.items())14+15+def _endpoint(value):16+ """Quote only the node portion of `node:port:compass` endpoints."""17+ match = re.match(r'^(.*?)(?=:[^:]+(?::[a-z]+)?$)', str(value), re.IGNORECASE)18+ if not match:19+ return value if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', str(value)) else _quote(value)20+ node, suffix = str(value)[:match.end()], str(value)[match.end():]21+ return (node if re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', node) else _quote(node)) + suffix22+23+class Graph:24+ def __init__(self, name='G', **_kwargs):25+ self.name = name26+ self.body = []27+ self._graph = {}28+ self._node = {}29+ self._edge = {}30+31+ def attr(self, kw=None, _attributes=None, **attrs):32+ target = kw or 'graph'33+ values = dict(_attributes or {})34+ values.update(attrs)35+ if target == 'graph': self._graph.update(values)36+ elif target == 'node': self._node.update(values)37+ elif target == 'edge': self._edge.update(values)38+39+ def node(self, name, label=None, **attrs):40+ values = dict(attrs)41+ if label is not None: values['label'] = label42+ self.body.append(f'{_quote(name)} [{_attrs(values)}]')43+44+ def edge(self, tail, head, **attrs):45+ values = dict(self._edge)46+ values.update(attrs)47+ self.body.append(f'{_endpoint(tail)} -- {_endpoint(head)}' + (f' [{_attrs(values)}]' if values else ''))48+49+ @property50+ def source(self):51+ lines = ['graph {']52+ if self._graph: lines.append(f'\tgraph [{_attrs(self._graph)}]')53+ if self._node: lines.append(f'\tnode [{_attrs(self._node)}]')54+ if self._edge: lines.append(f'\tedge [{_attrs(self._edge)}]')55+ lines.extend(f'\t{entry}' for entry in self.body)56+ lines.append('}')57+ return '\n'.join(lines)
A diagramzip-python-units/wireviz-translator/src/protocol.py +83 -0
@@ -0,0 +1,83 @@
1+import json2+import re3+from dataclasses import dataclass4+from typing import Any5+6+MAX_REQUEST_BYTES = 1_048_5767+MAX_SOURCE_LENGTH = 524_2888+MAX_OPTION_COUNT = 649+OPTION_NAME = re.compile(r'^[a-z0-9][a-z0-9_-]{0,63}$', re.IGNORECASE)10+11+12+class ProtocolError(Exception):13+ def __init__(self, status, code, message):14+ super().__init__(message)15+ self.status, self.code = status, code16+17+18+@dataclass(frozen=True)19+class RenderInput:20+ source: str21+ options: dict22+ title: str23+ description: str24+ background: str25+ padding: int26+ frame: bool27+28+29+def _object(value: Any, name: str) -> dict:30+ if not isinstance(value, dict):31+ raise ProtocolError(400, 'invalid_request', f'{name} must be an object.')32+ return value33+34+35+def _string(value: Any, name: str, maximum: int) -> str:36+ if not isinstance(value, str):37+ raise ProtocolError(400, 'invalid_request', f'{name} must be a string.')38+ if len(value) > maximum:39+ raise ProtocolError(413, 'request_too_large', f'{name} is too large.')40+ return value41+42+43+def parse_render_input(body: str) -> RenderInput:44+ if len(body.encode()) > MAX_REQUEST_BYTES:45+ raise ProtocolError(413, 'request_too_large', 'Render request is too large.')46+ try:47+ request = _object(json.loads(body), 'request')48+ except (json.JSONDecodeError, UnicodeError) as error:49+ raise ProtocolError(400, 'invalid_json', 'Render request is not valid JSON.') from error50+ if 'engine' in request:51+ raise ProtocolError(400, 'invalid_request', 'Renderer unit requests must not select an engine.')52+ if request.get('format', 'svg') != 'svg':53+ raise ProtocolError(400, 'unsupported_format', 'The rendering plane currently supports SVG only.')54+ source = _string(request.get('source'), 'source', MAX_SOURCE_LENGTH)55+ if not source.strip():56+ raise ProtocolError(400, 'empty_source', 'Diagram source cannot be empty.')57+ raw_options = _object(request.get('options', {}), 'options')58+ if len(raw_options) > MAX_OPTION_COUNT:59+ raise ProtocolError(400, 'invalid_options', f'options cannot contain more than {MAX_OPTION_COUNT} entries.')60+ options = {}61+ for name, value in raw_options.items():62+ if not isinstance(name, str) or not OPTION_NAME.fullmatch(name):63+ raise ProtocolError(400, 'invalid_options', f'Invalid renderer option: {name}.')64+ if not isinstance(value, (str, int, float, bool)) or isinstance(value, (dict, list)):65+ raise ProtocolError(400, 'invalid_options', f'Renderer option {name} must be a string, number, or boolean.')66+ normalized = str(value).lower() if isinstance(value, bool) else str(value)67+ if len(normalized) > 4_096:68+ raise ProtocolError(400, 'invalid_options', f'Renderer option {name} is too large.')69+ options[name.lower()] = normalized70+ metadata = _object(request.get('metadata', {}), 'metadata')71+ title = _string(metadata.get('title', ''), 'metadata.title', 200)72+ description = _string(metadata.get('description', ''), 'metadata.description', 2_000)73+ presentation = _object(request.get('presentation', {}), 'presentation')74+ background = _string(presentation.get('background', ''), 'presentation.background', 7)75+ if background and not (len(background) == 7 and background[0] == '#' and all(c in '0123456789abcdefABCDEF' for c in background[1:])):76+ raise ProtocolError(400, 'invalid_presentation', 'presentation.background must be an RGB hex color.')77+ padding = presentation.get('padding', 0)78+ frame = presentation.get('frame', False)79+ if not isinstance(padding, int) or isinstance(padding, bool) or not 0 <= padding <= 256:80+ raise ProtocolError(400, 'invalid_presentation', 'presentation.padding must be an integer from 0 to 256.')81+ if not isinstance(frame, bool):82+ raise ProtocolError(400, 'invalid_presentation', 'presentation.frame must be a boolean.')83+ return RenderInput(source, options, title, description, background, padding, frame)
A diagramzip-python-units/wireviz-translator/src/render.py +85 -0
@@ -0,0 +1,85 @@
1+import yaml2+import re3+4+from wireviz.wireviz import parse5+6+WIREVIZ_VERSION = '0.3.2'7+MAX_NODES = 20_0008+MAX_DEPTH = 329+MAX_RANGE = 4_09610+MAX_COMPONENT_SIZE = 4_09611+12+13+def _preflight(value, depth=0, seen=None, count=None):14+ seen = set() if seen is None else seen15+ count = [0] if count is None else count16+ if depth > MAX_DEPTH:17+ raise ValueError('WireViz YAML nesting is too deep')18+ count[0] += 119+ if count[0] > MAX_NODES:20+ raise ValueError('WireViz YAML document is too complex')21+ if isinstance(value, (dict, list)):22+ identity = id(value)23+ if identity in seen:24+ raise ValueError('recursive YAML aliases are not supported')25+ seen.add(identity)26+ values = value.values() if isinstance(value, dict) else value27+ for item in values:28+ _preflight(item, depth + 1, seen, count)29+ seen.remove(identity)30+31+32+def _expanded_connection_size(value):33+ if isinstance(value, dict):34+ return sum(_expanded_connection_size(item) for item in value.values())35+ if isinstance(value, list):36+ return sum(_expanded_connection_size(item) for item in value)37+ if isinstance(value, str):38+ match = re.fullmatch(r'\s*(\d+)\s*-\s*(\d+)\s*', value)39+ if match:40+ size = abs(int(match[2]) - int(match[1])) + 141+ if size > MAX_RANGE:42+ raise ValueError('WireViz numeric ranges are too large')43+ return size44+ return 145+46+47+def _bounded_component_size(attributes, section, name):48+ if not isinstance(attributes, dict):49+ raise ValueError(f"WireViz {section} '{name}' must be a mapping")50+ count_fields = ('pincount', 'pins', 'pinlabels', 'pincolors') if section == 'connector' else ('wirecount', 'colors', 'wirelabels')51+ for field in count_fields:52+ value = attributes.get(field)53+ size = len(value) if isinstance(value, list) else value if field.endswith('count') else None54+ if size is not None and (not isinstance(size, int) or isinstance(size, bool) or not 0 <= size <= MAX_COMPONENT_SIZE):55+ raise ValueError(f"WireViz {section} '{name}' {field} is too large")56+57+58+def render_dot(source: str) -> str:59+ """Run the upstream WireViz parser/model, stopping before GraphViz."""60+ document = yaml.safe_load(source)61+ if not isinstance(document, dict):62+ raise ValueError('WireViz document must be a mapping')63+ _preflight(document)64+ connectors = document.get('connectors') or {}65+ cables = document.get('cables') or {}66+ connections = document.get('connections') or []67+ if not isinstance(connectors, dict) or not isinstance(cables, dict) or not isinstance(connections, list):68+ raise ValueError('WireViz connectors and cables must be mappings and connections must be a list')69+ if len(connectors) > 1024 or len(cables) > 1024:70+ raise ValueError('WireViz has too many connectors or cables')71+ if len(connections) > 4096 or _expanded_connection_size(connections) > MAX_NODES:72+ raise ValueError('WireViz has too many connection sets')73+ for name, attributes in connectors.items():74+ _bounded_component_size(attributes, 'connector', name)75+ for name, attributes in cables.items():76+ _bounded_component_size(attributes, 'cable', name)77+ if document.get('tweak'):78+ raise ValueError('WireViz tweak directives are not supported by the edge translator')79+ for section, values in (('connectors', connectors), ('cables', cables)):80+ if isinstance(values, dict) and any(isinstance(value, dict) and value.get('image') for value in values.values()):81+ raise ValueError('WireViz images are not supported by the edge translator')82+ harness = parse(source, return_types='harness')83+ if harness is None:84+ raise ValueError('WireViz source did not produce a harness')85+ return harness.create_graph().source
A diagramzip-python-units/wireviz-translator/src/wireviz/DataClasses.py +353 -0
@@ -0,0 +1,353 @@
1+# -*- coding: utf-8 -*-2+import sys3+from typing import Dict, List, Optional, Tuple, Union4+from dataclasses import dataclass, field, InitVar5+from pathlib import Path6+7+from wireviz.wv_helper import int2tuple, aspect_ratio8+from wireviz.wv_colors import Color, Colors, ColorMode, ColorScheme, COLOR_CODES9+10+11+# Each type alias have their legal values described in comments - validation might be implemented in the future12+PlainText = str # Text not containing HTML tags nor newlines13+Hypertext = str # Text possibly including HTML hyperlinks that are removed in all outputs except HTML output14+MultilineHypertext = str # Hypertext possibly also including newlines to break lines in diagram output15+Designator = PlainText # Case insensitive unique name of connector or cable16+17+# Literal type aliases below are commented to avoid requiring python 3.818+ConnectorMultiplier = PlainText # = Literal['pincount', 'populated']19+CableMultiplier = PlainText # = Literal['wirecount', 'terminations', 'length', 'total_length']20+ImageScale = PlainText # = Literal['false', 'true', 'width', 'height', 'both']21+22+# Type combinations23+Pin = Union[int, PlainText] # Pin identifier24+PinIndex = int # Zero-based pin index25+Wire = Union[int, PlainText] # Wire number or Literal['s'] for shield26+NoneOrMorePinIndices = Union[PinIndex, Tuple[PinIndex, ...], None] # None, one, or a tuple of zero-based pin indices27+OneOrMoreWires = Union[Wire, Tuple[Wire, ...]] # One or a tuple of wires28+29+# Metadata can contain whatever is needed by the HTML generation/template.30+MetadataKeys = PlainText # Literal['title', 'description', 'notes', ...]31+class Metadata(dict):32+ pass33+34+35+@dataclass36+class Options:37+ fontname: PlainText = 'arial'38+ bgcolor: Color = 'WH'39+ bgcolor_node: Optional[Color] = 'WH'40+ bgcolor_connector: Optional[Color] = None41+ bgcolor_cable: Optional[Color] = None42+ bgcolor_bundle: Optional[Color] = None43+ color_mode: ColorMode = 'SHORT'44+ mini_bom_mode: bool = True45+46+ def __post_init__(self):47+ if not self.bgcolor_node:48+ self.bgcolor_node = self.bgcolor49+ if not self.bgcolor_connector:50+ self.bgcolor_connector = self.bgcolor_node51+ if not self.bgcolor_cable:52+ self.bgcolor_cable = self.bgcolor_node53+ if not self.bgcolor_bundle:54+ self.bgcolor_bundle = self.bgcolor_cable55+56+57+@dataclass58+class Tweak:59+ override: Optional[Dict[Designator, Dict[str, Optional[str]]]] = None60+ append: Union[str, List[str], None] = None61+62+63+@dataclass64+class Image:65+ gv_dir: InitVar[Path] # Directory of .gv file injected as context during parsing66+ # Attributes of the image object <img>:67+ src: str68+ scale: Optional[ImageScale] = None69+ # Attributes of the image cell <td> containing the image:70+ width: Optional[int] = None71+ height: Optional[int] = None72+ fixedsize: Optional[bool] = None73+ bgcolor: Optional[Color] = None74+ # Contents of the text cell <td> just below the image cell:75+ caption: Optional[MultilineHypertext] = None76+ # See also HTML doc at https://graphviz.org/doc/info/shapes.html#html77+78+ def __post_init__(self, gv_dir):79+80+ if self.fixedsize is None:81+ # Default True if any dimension specified unless self.scale also is specified.82+ self.fixedsize = (self.width or self.height) and self.scale is None83+84+ if self.scale is None:85+ self.scale = "false" if not self.width and not self.height \86+ else "both" if self.width and self.height \87+ else "true" # When only one dimension is specified.88+89+ if self.fixedsize:90+ # If only one dimension is specified, compute the other91+ # because Graphviz requires both when fixedsize=True.92+ if self.height:93+ if not self.width:94+ self.width = self.height * aspect_ratio(gv_dir.joinpath(self.src))95+ else:96+ if self.width:97+ self.height = self.width / aspect_ratio(gv_dir.joinpath(self.src))98+99+100+@dataclass101+class AdditionalComponent:102+ type: MultilineHypertext103+ subtype: Optional[MultilineHypertext] = None104+ manufacturer: Optional[MultilineHypertext] = None105+ mpn: Optional[MultilineHypertext] = None106+ supplier: Optional[MultilineHypertext] = None107+ spn: Optional[MultilineHypertext] = None108+ pn: Optional[Hypertext] = None109+ qty: float = 1110+ unit: Optional[str] = None111+ qty_multiplier: Union[ConnectorMultiplier, CableMultiplier, None] = None112+ bgcolor: Optional[Color] = None113+114+ @property115+ def description(self) -> str:116+ return self.type.rstrip() + (f', {self.subtype.rstrip()}' if self.subtype else '')117+118+119+@dataclass120+class Connector:121+ name: Designator122+ bgcolor: Optional[Color] = None123+ bgcolor_title: Optional[Color] = None124+ manufacturer: Optional[MultilineHypertext] = None125+ mpn: Optional[MultilineHypertext] = None126+ supplier: Optional[MultilineHypertext] = None127+ spn: Optional[MultilineHypertext] = None128+ pn: Optional[Hypertext] = None129+ style: Optional[str] = None130+ category: Optional[str] = None131+ type: Optional[MultilineHypertext] = None132+ subtype: Optional[MultilineHypertext] = None133+ pincount: Optional[int] = None134+ image: Optional[Image] = None135+ notes: Optional[MultilineHypertext] = None136+ pins: List[Pin] = field(default_factory=list)137+ pinlabels: List[Pin] = field(default_factory=list)138+ pincolors: List[Color] = field(default_factory=list)139+ color: Optional[Color] = None140+ show_name: Optional[bool] = None141+ show_pincount: Optional[bool] = None142+ hide_disconnected_pins: bool = False143+ autogenerate: bool = False144+ loops: List[List[Pin]] = field(default_factory=list)145+ ignore_in_bom: bool = False146+ additional_components: List[AdditionalComponent] = field(default_factory=list)147+148+ def __post_init__(self) -> None:149+150+ if isinstance(self.image, dict):151+ self.image = Image(**self.image)152+153+ self.ports_left = False154+ self.ports_right = False155+ self.visible_pins = {}156+157+ if self.style == 'simple':158+ if self.pincount and self.pincount > 1:159+ raise Exception('Connectors with style set to simple may only have one pin')160+ self.pincount = 1161+162+ if not self.pincount:163+ self.pincount = max(len(self.pins), len(self.pinlabels), len(self.pincolors))164+ if not self.pincount:165+ raise Exception('You need to specify at least one, pincount, pins, pinlabels, or pincolors')166+167+ # create default list for pins (sequential) if not specified168+ if not self.pins:169+ self.pins = list(range(1, self.pincount + 1))170+171+ if len(self.pins) != len(set(self.pins)):172+ raise Exception('Pins are not unique')173+174+ if self.show_name is None:175+ self.show_name = not self.autogenerate # hide auto-generated designators by default176+177+ if self.show_pincount is None:178+ self.show_pincount = self.style != 'simple' # hide pincount for simple (1 pin) connectors by default179+180+ for loop in self.loops:181+ # TODO: check that pins to connect actually exist182+ # TODO: allow using pin labels in addition to pin numbers, just like when defining regular connections183+ # TODO: include properties of wire used to create the loop184+ if len(loop) != 2:185+ raise Exception('Loops must be between exactly two pins!')186+187+ for i, item in enumerate(self.additional_components):188+ if isinstance(item, dict):189+ self.additional_components[i] = AdditionalComponent(**item)190+191+ def activate_pin(self, pin: Pin) -> None:192+ self.visible_pins[pin] = True193+194+ def get_qty_multiplier(self, qty_multiplier: Optional[ConnectorMultiplier]) -> int:195+ if not qty_multiplier:196+ return 1197+ elif qty_multiplier == 'pincount':198+ return self.pincount199+ elif qty_multiplier == 'populated':200+ return sum(self.visible_pins.values())201+ else:202+ raise ValueError(f'invalid qty multiplier parameter for connector {qty_multiplier}')203+204+205+@dataclass206+class Cable:207+ name: Designator208+ bgcolor: Optional[Color] = None209+ bgcolor_title: Optional[Color] = None210+ manufacturer: Union[MultilineHypertext, List[MultilineHypertext], None] = None211+ mpn: Union[MultilineHypertext, List[MultilineHypertext], None] = None212+ supplier: Union[MultilineHypertext, List[MultilineHypertext], None] = None213+ spn: Union[MultilineHypertext, List[MultilineHypertext], None] = None214+ pn: Union[Hypertext, List[Hypertext], None] = None215+ category: Optional[str] = None216+ type: Optional[MultilineHypertext] = None217+ gauge: Optional[float] = None218+ gauge_unit: Optional[str] = None219+ show_equiv: bool = False220+ length: float = 0221+ length_unit: Optional[str] = None222+ color: Optional[Color] = None223+ wirecount: Optional[int] = None224+ shield: Union[bool, Color] = False225+ image: Optional[Image] = None226+ notes: Optional[MultilineHypertext] = None227+ colors: List[Colors] = field(default_factory=list)228+ wirelabels: List[Wire] = field(default_factory=list)229+ color_code: Optional[ColorScheme] = None230+ show_name: bool = True231+ show_wirecount: bool = True232+ show_wirenumbers: Optional[bool] = None233+ ignore_in_bom: bool = False234+ additional_components: List[AdditionalComponent] = field(default_factory=list)235+236+ def __post_init__(self) -> None:237+238+ if isinstance(self.image, dict):239+ self.image = Image(**self.image)240+241+ if isinstance(self.gauge, str): # gauge and unit specified242+ try:243+ g, u = self.gauge.split(' ')244+ except Exception:245+ raise Exception(f'Cable {self.name} gauge={self.gauge} - Gauge must be a number, or number and unit separated by a space')246+ self.gauge = g247+248+ if self.gauge_unit is not None:249+ sys.stderr.write(f'Warning: Cable {self.name} gauge_unit={self.gauge_unit} is ignored because its gauge contains {u}')250+ if u.upper() == 'AWG':251+ self.gauge_unit = u.upper()252+ else:253+ self.gauge_unit = u.replace('mm2', 'mm\u00B2')254+255+ elif self.gauge is not None: # gauge specified, assume mm2256+ if self.gauge_unit is None:257+ self.gauge_unit = 'mm\u00B2'258+ else:259+ pass # gauge not specified260+261+ if isinstance(self.length, str): # length and unit specified262+ try:263+ L, u = self.length.split(' ')264+ L = float(L)265+ except Exception:266+ raise Exception(f'Cable {self.name} length={self.length} - Length must be a number, or number and unit separated by a space')267+ self.length = L268+ if self.length_unit is not None:269+ sys.stderr.write(f'Warning: Cable {self.name} length_unit={self.length_unit} is ignored because its length contains {u}')270+ self.length_unit = u271+ elif not any(isinstance(self.length, t) for t in [int, float]):272+ raise Exception(f'Cable {self.name} length has a non-numeric value')273+ elif self.length_unit is None:274+ self.length_unit = 'm'275+276+ self.connections = []277+278+ if self.wirecount: # number of wires explicitly defined279+ if self.colors: # use custom color palette (partly or looped if needed)280+ pass281+ elif self.color_code: # use standard color palette (partly or looped if needed)282+ if self.color_code not in COLOR_CODES:283+ raise Exception('Unknown color code')284+ self.colors = COLOR_CODES[self.color_code]285+ else: # no colors defined, add dummy colors286+ self.colors = [''] * self.wirecount287+288+ # make color code loop around if more wires than colors289+ if self.wirecount > len(self.colors):290+ m = self.wirecount // len(self.colors) + 1291+ self.colors = self.colors * int(m)292+ # cut off excess after looping293+ self.colors = self.colors[:self.wirecount]294+ else: # wirecount implicit in length of color list295+ if not self.colors:296+ raise Exception('Unknown number of wires. Must specify wirecount or colors (implicit length)')297+ self.wirecount = len(self.colors)298+299+ if self.wirelabels:300+ if self.shield and 's' in self.wirelabels:301+ raise Exception('"s" may not be used as a wire label for a shielded cable.')302+303+ # if lists of part numbers are provided check this is a bundle and that it matches the wirecount.304+ for idfield in [self.manufacturer, self.mpn, self.supplier, self.spn, self.pn]:305+ if isinstance(idfield, list):306+ if self.category == "bundle":307+ # check the length308+ if len(idfield) != self.wirecount:309+ raise Exception('lists of part data must match wirecount')310+ else:311+ raise Exception('lists of part data are only supported for bundles')312+313+ # by default, show wire numbers for cables, hide for bundles314+ if self.show_wirenumbers is None:315+ self.show_wirenumbers = self.category != 'bundle'316+317+ for i, item in enumerate(self.additional_components):318+ if isinstance(item, dict):319+ self.additional_components[i] = AdditionalComponent(**item)320+321+ # The *_pin arguments accept a tuple, but it seems not in use with the current code.322+ def connect(self, from_name: Optional[Designator], from_pin: NoneOrMorePinIndices, via_wire: OneOrMoreWires,323+ to_name: Optional[Designator], to_pin: NoneOrMorePinIndices) -> None:324+ from_pin = int2tuple(from_pin)325+ via_wire = int2tuple(via_wire)326+ to_pin = int2tuple(to_pin)327+ if len(from_pin) != len(to_pin):328+ raise Exception('from_pin must have the same number of elements as to_pin')329+ for i, _ in enumerate(from_pin):330+ self.connections.append(Connection(from_name, from_pin[i], via_wire[i], to_name, to_pin[i]))331+332+ def get_qty_multiplier(self, qty_multiplier: Optional[CableMultiplier]) -> float:333+ if not qty_multiplier:334+ return 1335+ elif qty_multiplier == 'wirecount':336+ return self.wirecount337+ elif qty_multiplier == 'terminations':338+ return len(self.connections)339+ elif qty_multiplier == 'length':340+ return self.length341+ elif qty_multiplier == 'total_length':342+ return self.length * self.wirecount343+ else:344+ raise ValueError(f'invalid qty multiplier parameter for cable {qty_multiplier}')345+346+347+@dataclass348+class Connection:349+ from_name: Optional[Designator]350+ from_port: Optional[PinIndex]351+ via_port: Wire352+ to_name: Optional[Designator]353+ to_port: Optional[PinIndex]
A diagramzip-python-units/wireviz-translator/src/wireviz/Harness.py +451 -0
@@ -0,0 +1,451 @@
1+# -*- coding: utf-8 -*-2+import sys3+4+from graphviz import Graph5+from collections import Counter6+from typing import Any, List, Union7+from dataclasses import dataclass8+from pathlib import Path9+from itertools import zip_longest10+import re11+12+from wireviz import wv_colors, __version__, APP_NAME, APP_URL13+from wireviz.DataClasses import Metadata, Options, Tweak, Connector, Cable14+from wireviz.wv_colors import get_color_hex, translate_color15+from wireviz.wv_gv_html import nested_html_table, \16+ html_bgcolor_attr, html_bgcolor, html_colorbar, \17+ html_image, html_caption, remove_links, html_line_breaks18+from wireviz.wv_bom import pn_info_string, component_table_entry, \19+ get_additional_component_table, bom_list, generate_bom, \20+ HEADER_PN, HEADER_MPN, HEADER_SPN21+from wireviz.wv_html import generate_html_output22+from wireviz.wv_helper import awg_equiv, mm2_equiv, tuplelist2tsv, flatten2d, \23+ open_file_read, open_file_write24+25+26+@dataclass27+class Harness:28+ metadata: Metadata29+ options: Options30+ tweak: Tweak31+32+ def __post_init__(self):33+ self.connectors = {}34+ self.cables = {}35+ self._bom = [] # Internal Cache for generated bom36+ self.additional_bom_items = []37+38+ def add_connector(self, name: str, *args, **kwargs) -> None:39+ self.connectors[name] = Connector(name, *args, **kwargs)40+41+ def add_cable(self, name: str, *args, **kwargs) -> None:42+ self.cables[name] = Cable(name, *args, **kwargs)43+44+ def add_bom_item(self, item: dict) -> None:45+ self.additional_bom_items.append(item)46+47+ def connect(self, from_name: str, from_pin: (int, str), via_name: str, via_wire: (int, str), to_name: str, to_pin: (int, str)) -> None:48+ # check from and to connectors49+ for (name, pin) in zip([from_name, to_name], [from_pin, to_pin]):50+ if name is not None and name in self.connectors:51+ connector = self.connectors[name]52+ # check if provided name is ambiguous53+ if pin in connector.pins and pin in connector.pinlabels:54+ if connector.pins.index(pin) != connector.pinlabels.index(pin):55+ raise Exception(f'{name}:{pin} is defined both in pinlabels and pins, for different pins.')56+ # TODO: Maybe issue a warning if present in both lists but referencing the same pin?57+ if pin in connector.pinlabels:58+ if connector.pinlabels.count(pin) > 1:59+ raise Exception(f'{name}:{pin} is defined more than once.')60+ index = connector.pinlabels.index(pin)61+ pin = connector.pins[index] # map pin name to pin number62+ if name == from_name:63+ from_pin = pin64+ if name == to_name:65+ to_pin = pin66+ if not pin in connector.pins:67+ raise Exception(f'{name}:{pin} not found.')68+69+ # check via cable70+ if via_name in self.cables:71+ cable = self.cables[via_name]72+ # check if provided name is ambiguous73+ if via_wire in cable.colors and via_wire in cable.wirelabels:74+ if cable.colors.index(via_wire) != cable.wirelabels.index(via_wire):75+ raise Exception(f'{via_name}:{via_wire} is defined both in colors and wirelabels, for different wires.')76+ # TODO: Maybe issue a warning if present in both lists but referencing the same wire?77+ if via_wire in cable.colors:78+ if cable.colors.count(via_wire) > 1:79+ raise Exception(f'{via_name}:{via_wire} is used for more than one wire.')80+ via_wire = cable.colors.index(via_wire) + 1 # list index starts at 0, wire IDs start at 181+ elif via_wire in cable.wirelabels:82+ if cable.wirelabels.count(via_wire) > 1:83+ raise Exception(f'{via_name}:{via_wire} is used for more than one wire.')84+ via_wire = cable.wirelabels.index(via_wire) + 1 # list index starts at 0, wire IDs start at 185+86+ from_pin_id = self.connectors[from_name].pins.index(from_pin) if from_pin is not None else None87+ to_pin_id = self.connectors[to_name].pins.index(to_pin) if to_pin is not None else None88+89+ self.cables[via_name].connect(from_name, from_pin_id, via_wire, to_name, to_pin_id)90+ if from_name in self.connectors:91+ self.connectors[from_name].activate_pin(from_pin)92+ if to_name in self.connectors:93+ self.connectors[to_name].activate_pin(to_pin)94+95+ def create_graph(self) -> Graph:96+ dot = Graph()97+ dot.body.append(f'// Graph generated by {APP_NAME} {__version__}\n')98+ dot.body.append(f'// {APP_URL}\n')99+ dot.attr('graph', rankdir='LR',100+ ranksep='2',101+ bgcolor=wv_colors.translate_color(self.options.bgcolor, "HEX"),102+ nodesep='0.33',103+ fontname=self.options.fontname)104+ dot.attr('node',105+ shape='none',106+ width='0', height='0', margin='0', # Actual size of the node is entirely determined by the label.107+ style='filled',108+ fillcolor=wv_colors.translate_color(self.options.bgcolor_node, "HEX"),109+ fontname=self.options.fontname)110+ dot.attr('edge', style='bold',111+ fontname=self.options.fontname)112+113+ # prepare ports on connectors depending on which side they will connect114+ for _, cable in self.cables.items():115+ for connection_color in cable.connections:116+ if connection_color.from_port is not None: # connect to left117+ self.connectors[connection_color.from_name].ports_right = True118+ if connection_color.to_port is not None: # connect to right119+ self.connectors[connection_color.to_name].ports_left = True120+121+ for connector in self.connectors.values():122+123+ # If no wires connected (except maybe loop wires)?124+ if not (connector.ports_left or connector.ports_right):125+ connector.ports_left = True # Use left side pins.126+127+ html = []128+129+ rows = [[f'{html_bgcolor(connector.bgcolor_title)}{remove_links(connector.name)}'130+ if connector.show_name else None],131+ [pn_info_string(HEADER_PN, None, remove_links(connector.pn)),132+ html_line_breaks(pn_info_string(HEADER_MPN, connector.manufacturer, connector.mpn)),133+ html_line_breaks(pn_info_string(HEADER_SPN, connector.supplier, connector.spn))],134+ [html_line_breaks(connector.type),135+ html_line_breaks(connector.subtype),136+ f'{connector.pincount}-pin' if connector.show_pincount else None,137+ translate_color(connector.color, self.options.color_mode) if connector.color else None,138+ html_colorbar(connector.color)],139+ '<!-- connector table -->' if connector.style != 'simple' else None,140+ [html_image(connector.image)],141+ [html_caption(connector.image)]]142+ rows.extend(get_additional_component_table(self, connector))143+ rows.append([html_line_breaks(connector.notes)])144+ html.extend(nested_html_table(rows, html_bgcolor_attr(connector.bgcolor)))145+146+ if connector.style != 'simple':147+ pinhtml = []148+ pinhtml.append('<table border="0" cellspacing="0" cellpadding="3" cellborder="1">')149+150+ for pinindex, (pinname, pinlabel, pincolor) in enumerate(zip_longest(connector.pins, connector.pinlabels, connector.pincolors)):151+ if connector.hide_disconnected_pins and not connector.visible_pins.get(pinname, False):152+ continue153+ pinhtml.append(' <tr>')154+ if connector.ports_left:155+ pinhtml.append(f' <td port="p{pinindex+1}l">{pinname}</td>')156+ if pinlabel:157+ pinhtml.append(f' <td>{pinlabel}</td>')158+ if connector.pincolors:159+ if pincolor in wv_colors._color_hex.keys():160+ pinhtml.append(f' <td sides="tbl">{translate_color(pincolor, self.options.color_mode)}</td>')161+ pinhtml.append( ' <td sides="tbr">')162+ pinhtml.append( ' <table border="0" cellborder="1"><tr>')163+ pinhtml.append(f' <td bgcolor="{wv_colors.translate_color(pincolor, "HEX")}" width="8" height="8" fixedsize="true"></td>')164+ pinhtml.append( ' </tr></table>')165+ pinhtml.append( ' </td>')166+ else:167+ pinhtml.append( ' <td colspan="2"></td>')168+169+ if connector.ports_right:170+ pinhtml.append(f' <td port="p{pinindex+1}r">{pinname}</td>')171+ pinhtml.append(' </tr>')172+173+ pinhtml.append(' </table>')174+175+ html = [row.replace('<!-- connector table -->', '\n'.join(pinhtml)) for row in html]176+177+ html = '\n'.join(html)178+ dot.node(connector.name, label=f'<\n{html}\n>', shape='box', style='filled',179+ fillcolor=translate_color(self.options.bgcolor_connector, "HEX"))180+181+ if len(connector.loops) > 0:182+ dot.attr('edge', color='#000000:#ffffff:#000000')183+ if connector.ports_left:184+ loop_side = 'l'185+ loop_dir = 'w'186+ elif connector.ports_right:187+ loop_side = 'r'188+ loop_dir = 'e'189+ else:190+ raise Exception('No side for loops')191+ for loop in connector.loops:192+ dot.edge(f'{connector.name}:p{loop[0]}{loop_side}:{loop_dir}',193+ f'{connector.name}:p{loop[1]}{loop_side}:{loop_dir}')194+195+196+ # determine if there are double- or triple-colored wires in the harness;197+ # if so, pad single-color wires to make all wires of equal thickness198+ pad = any(len(colorstr) > 2 for cable in self.cables.values() for colorstr in cable.colors)199+200+ for cable in self.cables.values():201+202+ html = []203+204+ awg_fmt = ''205+ if cable.show_equiv:206+ # Only convert units we actually know about, i.e. currently207+ # mm2 and awg --- other units _are_ technically allowed,208+ # and passed through as-is.209+ if cable.gauge_unit =='mm\u00B2':210+ awg_fmt = f' ({awg_equiv(cable.gauge)} AWG)'211+ elif cable.gauge_unit.upper() == 'AWG':212+ awg_fmt = f' ({mm2_equiv(cable.gauge)} mm\u00B2)'213+214+ rows = [[f'{html_bgcolor(cable.bgcolor_title)}{remove_links(cable.name)}'215+ if cable.show_name else None],216+ [pn_info_string(HEADER_PN, None,217+ remove_links(cable.pn)) if not isinstance(cable.pn, list) else None,218+ html_line_breaks(pn_info_string(HEADER_MPN,219+ cable.manufacturer if not isinstance(cable.manufacturer, list) else None,220+ cable.mpn if not isinstance(cable.mpn, list) else None)),221+ html_line_breaks(pn_info_string(HEADER_SPN,222+ cable.supplier if not isinstance(cable.supplier, list) else None,223+ cable.spn if not isinstance(cable.spn, list) else None))],224+ [html_line_breaks(cable.type),225+ f'{cable.wirecount}x' if cable.show_wirecount else None,226+ f'{cable.gauge} {cable.gauge_unit}{awg_fmt}' if cable.gauge else None,227+ '+ S' if cable.shield else None,228+ f'{cable.length} {cable.length_unit}' if cable.length > 0 else None,229+ translate_color(cable.color, self.options.color_mode) if cable.color else None,230+ html_colorbar(cable.color)],231+ '<!-- wire table -->',232+ [html_image(cable.image)],233+ [html_caption(cable.image)]]234+235+ rows.extend(get_additional_component_table(self, cable))236+ rows.append([html_line_breaks(cable.notes)])237+ html.extend(nested_html_table(rows, html_bgcolor_attr(cable.bgcolor)))238+239+ wirehtml = []240+ wirehtml.append('<table border="0" cellspacing="0" cellborder="0">') # conductor table241+ wirehtml.append(' <tr><td>&nbsp;</td></tr>')242+243+ for i, (connection_color, wirelabel) in enumerate(zip_longest(cable.colors, cable.wirelabels), 1):244+ wirehtml.append(' <tr>')245+ wirehtml.append(f' <td><!-- {i}_in --></td>')246+ wirehtml.append(f' <td>')247+248+ wireinfo = []249+ if cable.show_wirenumbers:250+ wireinfo.append(str(i))251+ colorstr = wv_colors.translate_color(connection_color, self.options.color_mode)252+ if colorstr:253+ wireinfo.append(colorstr)254+ if cable.wirelabels:255+ wireinfo.append(wirelabel if wirelabel is not None else '')256+ wirehtml.append(f' {":".join(wireinfo)}')257+258+ wirehtml.append(f' </td>')259+ wirehtml.append(f' <td><!-- {i}_out --></td>')260+ wirehtml.append(' </tr>')261+262+ bgcolors = ['#000000'] + get_color_hex(connection_color, pad=pad) + ['#000000']263+ wirehtml.append(f' <tr>')264+ wirehtml.append(f' <td colspan="3" border="0" cellspacing="0" cellpadding="0" port="w{i}" height="{(2 * len(bgcolors))}">')265+ wirehtml.append(' <table cellspacing="0" cellborder="0" border="0">')266+ for j, bgcolor in enumerate(bgcolors[::-1]): # Reverse to match the curved wires when more than 2 colors267+ wirehtml.append(f' <tr><td colspan="3" cellpadding="0" height="2" bgcolor="{bgcolor if bgcolor != "" else wv_colors.default_color}" border="0"></td></tr>')268+ wirehtml.append(' </table>')269+ wirehtml.append(' </td>')270+ wirehtml.append(' </tr>')271+ if cable.category == 'bundle': # for bundles individual wires can have part information272+ # create a list of wire parameters273+ wireidentification = []274+ if isinstance(cable.pn, list):275+ wireidentification.append(pn_info_string(HEADER_PN, None, remove_links(cable.pn[i - 1])))276+ manufacturer_info = pn_info_string(HEADER_MPN,277+ cable.manufacturer[i - 1] if isinstance(cable.manufacturer, list) else None,278+ cable.mpn[i - 1] if isinstance(cable.mpn, list) else None)279+ supplier_info = pn_info_string(HEADER_SPN,280+ cable.supplier[i - 1] if isinstance(cable.supplier, list) else None,281+ cable.spn[i - 1] if isinstance(cable.spn, list) else None)282+ if manufacturer_info:283+ wireidentification.append(html_line_breaks(manufacturer_info))284+ if supplier_info:285+ wireidentification.append(html_line_breaks(supplier_info))286+ # print parameters into a table row under the wire287+ if len(wireidentification) > 0 :288+ wirehtml.append(' <tr><td colspan="3">')289+ wirehtml.append(' <table border="0" cellspacing="0" cellborder="0"><tr>')290+ for attrib in wireidentification:291+ wirehtml.append(f' <td>{attrib}</td>')292+ wirehtml.append(' </tr></table>')293+ wirehtml.append(' </td></tr>')294+295+ if cable.shield:296+ wirehtml.append(' <tr><td>&nbsp;</td></tr>') # spacer297+ wirehtml.append(' <tr>')298+ wirehtml.append(' <td><!-- s_in --></td>')299+ wirehtml.append(' <td>Shield</td>')300+ wirehtml.append(' <td><!-- s_out --></td>')301+ wirehtml.append(' </tr>')302+ if isinstance(cable.shield, str):303+ # shield is shown with specified color and black borders304+ shield_color_hex = wv_colors.get_color_hex(cable.shield)[0]305+ attributes = f'height="6" bgcolor="{shield_color_hex}" border="2" sides="tb"'306+ else:307+ # shield is shown as a thin black wire308+ attributes = f'height="2" bgcolor="#000000" border="0"'309+ wirehtml.append(f' <tr><td colspan="3" cellpadding="0" {attributes} port="ws"></td></tr>')310+311+ wirehtml.append(' <tr><td>&nbsp;</td></tr>')312+ wirehtml.append(' </table>')313+314+ html = [row.replace('<!-- wire table -->', '\n'.join(wirehtml)) for row in html]315+316+ # connections317+ for connection in cable.connections:318+ if isinstance(connection.via_port, int): # check if it's an actual wire and not a shield319+ dot.attr('edge', color=':'.join(['#000000'] + wv_colors.get_color_hex(cable.colors[connection.via_port - 1], pad=pad) + ['#000000']))320+ else: # it's a shield connection321+ # shield is shown with specified color and black borders, or as a thin black wire otherwise322+ dot.attr('edge', color=':'.join(['#000000', shield_color_hex, '#000000']) if isinstance(cable.shield, str) else '#000000')323+ if connection.from_port is not None: # connect to left324+ from_connector = self.connectors[connection.from_name]325+ from_port = f':p{connection.from_port+1}r' if from_connector.style != 'simple' else ''326+ code_left_1 = f'{connection.from_name}{from_port}:e'327+ code_left_2 = f'{cable.name}:w{connection.via_port}:w'328+ dot.edge(code_left_1, code_left_2)329+ if from_connector.show_name:330+ from_info = [str(connection.from_name), str(self.connectors[connection.from_name].pins[connection.from_port])]331+ if from_connector.pinlabels:332+ pinlabel = from_connector.pinlabels[connection.from_port]333+ if pinlabel != '':334+ from_info.append(pinlabel)335+ from_string = ':'.join(from_info)336+ else:337+ from_string = ''338+ html = [row.replace(f'<!-- {connection.via_port}_in -->', from_string) for row in html]339+ if connection.to_port is not None: # connect to right340+ to_connector = self.connectors[connection.to_name]341+ code_right_1 = f'{cable.name}:w{connection.via_port}:e'342+ to_port = f':p{connection.to_port+1}l' if self.connectors[connection.to_name].style != 'simple' else ''343+ code_right_2 = f'{connection.to_name}{to_port}:w'344+ dot.edge(code_right_1, code_right_2)345+ if to_connector.show_name:346+ to_info = [str(connection.to_name), str(self.connectors[connection.to_name].pins[connection.to_port])]347+ if to_connector.pinlabels:348+ pinlabel = to_connector.pinlabels[connection.to_port]349+ if pinlabel != '':350+ to_info.append(pinlabel)351+ to_string = ':'.join(to_info)352+ else:353+ to_string = ''354+ html = [row.replace(f'<!-- {connection.via_port}_out -->', to_string) for row in html]355+356+ style, bgcolor = ('filled,dashed', self.options.bgcolor_bundle) if cable.category == 'bundle' else \357+ ('filled', self.options.bgcolor_cable)358+ html = '\n'.join(html)359+ dot.node(cable.name, label=f'<\n{html}\n>', shape='box',360+ style=style, fillcolor=translate_color(bgcolor, "HEX"))361+362+ def typecheck(name: str, value: Any, expect: type) -> None:363+ if not isinstance(value, expect):364+ raise Exception(f'Unexpected value type of {name}: Expected {expect}, got {type(value)}\n{value}')365+366+ # TODO?: Differ between override attributes and HTML?367+ if self.tweak.override is not None:368+ typecheck('tweak.override', self.tweak.override, dict)369+ for k, d in self.tweak.override.items():370+ typecheck(f'tweak.override.{k} key', k, str)371+ typecheck(f'tweak.override.{k} value', d, dict)372+ for a, v in d.items():373+ typecheck(f'tweak.override.{k}.{a} key', a, str)374+ typecheck(f'tweak.override.{k}.{a} value', v, (str, type(None)))375+376+ # Override generated attributes of selected entries matching tweak.override.377+ for i, entry in enumerate(dot.body):378+ if isinstance(entry, str):379+ # Find a possibly quoted keyword after leading TAB(s) and followed by [ ].380+ match = re.match(r'^\t*(")?((?(1)[^"]|[^ "])+)(?(1)") \[.*\]$', entry, re.S)381+ keyword = match and match[2]382+ if keyword in self.tweak.override.keys():383+ for attr, value in self.tweak.override[keyword].items():384+ if value is None:385+ entry, n_subs = re.subn(f'( +)?{attr}=("[^"]*"|[^] ]*)(?(1)| *)', '', entry)386+ if n_subs < 1:387+ sys.stderr.write(f'Harness.create_graph() warning: {attr} not found in {keyword}!')388+ elif n_subs > 1:389+ sys.stderr.write(f'Harness.create_graph() warning: {attr} removed {n_subs} times in {keyword}!')390+ continue391+392+ if len(value) == 0 or ' ' in value:393+ value = value.replace('"', r'\"')394+ value = f'"{value}"'395+ entry, n_subs = re.subn(f'{attr}=("[^"]*"|[^] ]*)', f'{attr}={value}', entry)396+ if n_subs < 1:397+ # If attr not found, then append it398+ entry = re.sub(r'\]$', f' {attr}={value}]', entry)399+ elif n_subs > 1:400+ sys.stderr.write(f'Harness.create_graph() warning: {attr} overridden {n_subs} times in {keyword}!')401+402+ dot.body[i] = entry403+404+ if self.tweak.append is not None:405+ if isinstance(self.tweak.append, list):406+ for i, element in enumerate(self.tweak.append, 1):407+ typecheck(f'tweak.append[{i}]', element, str)408+ dot.body.extend(self.tweak.append)409+ else:410+ typecheck('tweak.append', self.tweak.append, str)411+ dot.body.append(self.tweak.append)412+413+ return dot414+415+ @property416+ def png(self):417+ from io import BytesIO418+ graph = self.create_graph()419+ data = BytesIO()420+ data.write(graph.pipe(format='png'))421+ data.seek(0)422+ return data.read()423+424+ @property425+ def svg(self):426+ from io import BytesIO427+ graph = self.create_graph()428+ data = BytesIO()429+ data.write(graph.pipe(format='svg'))430+ data.seek(0)431+ return data.read()432+433+ def output(self, filename: (str, Path), view: bool = False, cleanup: bool = True, fmt: tuple = ('pdf', )) -> None:434+ # graphical output435+ graph = self.create_graph()436+ for f in fmt:437+ graph.format = f438+ graph.render(filename=filename, view=view, cleanup=cleanup)439+440+ graph.save(filename=f'{filename}.gv')441+ # bom output442+ bomlist = bom_list(self.bom())443+ with open_file_write(f'{filename}.bom.tsv') as file:444+ file.write(tuplelist2tsv(bomlist))445+ # HTML output446+ generate_html_output(filename, bomlist, self.metadata, self.options)447+448+ def bom(self):449+ if not self._bom:450+ self._bom = generate_bom(self)451+ return self._bom
A diagramzip-python-units/wireviz-translator/src/wireviz/__init__.py +8 -0
A diagramzip-python-units/wireviz-translator/src/wireviz/__main__.py +4 -0
A diagramzip-python-units/wireviz-translator/src/wireviz/build_examples.py +158 -0
@@ -0,0 +1,158 @@
1+#!/usr/bin/env python32+# -*- coding: utf-8 -*-3+4+import argparse5+import sys6+import os7+from pathlib import Path8+9+script_path = Path(__file__).absolute()10+11+sys.path.insert(0, str(script_path.parent.parent)) # to find wireviz module12+from wireviz import wireviz, __version__, APP_NAME13+from wv_helper import open_file_write, open_file_read, open_file_append14+15+16+dir = script_path.parent.parent.parent17+readme = 'readme.md'18+groups = {19+ 'examples': {20+ 'path': dir / 'examples',21+ 'prefix': 'ex',22+ readme: [], # Include no files23+ 'title': 'Example Gallery',24+ },25+ 'tutorial' : {26+ 'path': dir / 'tutorial',27+ 'prefix': 'tutorial',28+ readme: ['md', 'yml'], # Include .md and .yml files29+ 'title': f'{APP_NAME} Tutorial',30+ },31+ 'demos' : {32+ 'path': dir / 'examples',33+ 'prefix': 'demo',34+ },35+}36+37+input_extensions = ['.yml']38+extensions_not_containing_graphviz_output = ['.gv', '.bom.tsv']39+extensions_containing_graphviz_output = ['.png', '.svg', '.html']40+generated_extensions = extensions_not_containing_graphviz_output + extensions_containing_graphviz_output41+42+43+def collect_filenames(description, groupkey, ext_list):44+ path = groups[groupkey]['path']45+ patterns = [f"{groups[groupkey]['prefix']}*{ext}" for ext in ext_list]46+ if ext_list != input_extensions and readme in groups[groupkey]:47+ patterns.append(readme)48+ sys.stderr.write(f'{description} {groupkey} in "{path}"')49+ return sorted([filename for pattern in patterns for filename in path.glob(pattern)])50+51+52+def build_generated(groupkeys):53+ for key in groupkeys:54+ # preparation55+ path = groups[key]['path']56+ build_readme = readme in groups[key]57+ if build_readme:58+ include_readme = 'md' in groups[key][readme]59+ include_source = 'yml' in groups[key][readme]60+ with open_file_write(path / readme) as out:61+ out.write(f'# {groups[key]["title"]}\n\n')62+ # collect and iterate input YAML files63+ for yaml_file in collect_filenames('Building', key, input_extensions):64+ sys.stderr.write(f' "{yaml_file}"')65+ wireviz.parse_file(yaml_file)66+67+ if build_readme:68+ i = ''.join(filter(str.isdigit, yaml_file.stem))69+70+ with open_file_append(path / readme) as out:71+ if include_readme:72+ with open_file_read(yaml_file.with_suffix('.md')) as info:73+ for line in info:74+ out.write(line.replace('## ', f'## {i} - '))75+ out.write('\n\n')76+ else:77+ out.write(f'## Example {i}\n')78+79+ if include_source:80+ with open_file_read(yaml_file) as src:81+ out.write('```yaml\n')82+ for line in src:83+ out.write(line)84+ out.write('```\n')85+ out.write('\n')86+87+ out.write(f'![]({yaml_file.stem}.png)\n\n')88+ out.write(f'[Source]({yaml_file.name}) - [Bill of Materials]({yaml_file.stem}.bom.tsv)\n\n\n')89+90+91+def clean_generated(groupkeys):92+ for key in groupkeys:93+ # collect and remove files94+ for filename in collect_filenames('Cleaning', key, generated_extensions):95+ if filename.is_file():96+ sys.stderr.write(f' rm "{filename}"')97+ os.remove(filename)98+99+100+def compare_generated(groupkeys, branch = '', include_graphviz_output = False):101+ if branch:102+ branch = f' {branch.strip()}'103+ compare_extensions = generated_extensions if include_graphviz_output else extensions_not_containing_graphviz_output104+ for key in groupkeys:105+ # collect and compare files106+ for filename in collect_filenames('Comparing', key, compare_extensions):107+ cmd = f'git --no-pager diff{branch} -- "{filename}"'108+ sys.stderr.write(f' {cmd}')109+ os.system(cmd)110+111+112+def restore_generated(groupkeys, branch = ''):113+ if branch:114+ branch = f' {branch.strip()}'115+ for key in groupkeys:116+ # collect input YAML files117+ filename_list = collect_filenames('Restoring', key, input_extensions)118+ # collect files to restore119+ filename_list = [fn.with_suffix(ext) for fn in filename_list for ext in generated_extensions]120+ if readme in groups[key]:121+ filename_list.append(groups[key]['path'] / readme)122+ # restore files123+ for filename in filename_list:124+ cmd = f'git checkout{branch} -- "{filename}"'125+ sys.stderr.write(f' {cmd}')126+ os.system(cmd)127+128+129+def parse_args():130+ parser = argparse.ArgumentParser(description=f'{APP_NAME} Example Manager',)131+ parser.add_argument('-V', '--version', action='version', version=f'%(prog)s - {APP_NAME} {__version__}')132+ parser.add_argument('action', nargs='?', action='store',133+ choices=['build','clean','compare','diff','restore'], default='build',134+ help='what to do with the generated files (default: build)')135+ parser.add_argument('-c', '--compare-graphviz-output', action='store_true',136+ help='the Graphviz output is also compared (default: False)')137+ parser.add_argument('-b', '--branch', action='store', default='',138+ help='branch or commit to compare with or restore from')139+ parser.add_argument('-g', '--groups', nargs='+',140+ choices=groups.keys(), default=groups.keys(),141+ help='the groups of generated files (default: all)')142+ return parser.parse_args()143+144+145+def main():146+ args = parse_args()147+ if args.action == 'build':148+ build_generated(args.groups)149+ elif args.action == 'clean':150+ clean_generated(args.groups)151+ elif args.action == 'compare' or args.action == 'diff':152+ compare_generated(args.groups, args.branch, args.compare_graphviz_output)153+ elif args.action == 'restore':154+ restore_generated(args.groups, args.branch)155+156+157+if __name__ == '__main__':158+ main()
A diagramzip-python-units/wireviz-translator/src/wireviz/wireviz.py +290 -0
@@ -0,0 +1,290 @@
1+#!/usr/bin/env python32+# -*- coding: utf-8 -*-3+4+import argparse5+import os6+from pathlib import Path7+import sys8+from typing import Any, Tuple9+10+import yaml11+12+if __name__ == '__main__':13+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))14+15+from wireviz import __version__16+from wireviz.DataClasses import Metadata, Options, Tweak17+from wireviz.Harness import Harness18+from wireviz.wv_helper import expand, open_file_read19+20+21+def parse(yaml_input: str, file_out: (str, Path) = None, return_types: (None, str, Tuple[str]) = None) -> Any:22+ """23+ Parses yaml input string and does the high-level harness conversion24+25+ :param yaml_input: a string containing the yaml input data26+ :param file_out:27+ :param return_types: if None, then returns None; if the value is a string, then a28+ corresponding data format will be returned; if the value is a tuple of strings,29+ then for every valid format in the `return_types` tuple, another return type30+ will be generated and returned in the same order; currently supports:31+ - "png" - will return the PNG data32+ - "svg" - will return the SVG data33+ - "harness" - will return the `Harness` instance34+ """35+36+ yaml_data = yaml.safe_load(yaml_input)37+38+ harness = Harness(39+ metadata = Metadata(**yaml_data.get('metadata', {})),40+ options = Options(**yaml_data.get('options', {})),41+ tweak = Tweak(**yaml_data.get('tweak', {})),42+ )43+44+ # When title is not given, either deduce it from filename, or use default text.45+ if 'title' not in harness.metadata:46+ if file_out is None:47+ harness.metadata['title'] = "WireViz diagram and BOM"48+ else:49+ harness.metadata['title'] = Path(file_out).stem50+51+ # add items52+ sections = ['connectors', 'cables', 'connections']53+ types = [dict, dict, list]54+ for sec, ty in zip(sections, types):55+ if sec in yaml_data and type(yaml_data[sec]) == ty:56+ if len(yaml_data[sec]) > 0:57+ if ty == dict:58+ for key, attribs in yaml_data[sec].items():59+ # The Image dataclass might need to open an image file with a relative path.60+ image = attribs.get('image')61+ if isinstance(image, dict):62+ image['gv_dir'] = Path(file_out if file_out else '').parent # Inject context63+64+ if sec == 'connectors':65+ if not attribs.get('autogenerate', False):66+ harness.add_connector(name=key, **attribs)67+ elif sec == 'cables':68+ harness.add_cable(name=key, **attribs)69+ else:70+ pass # section exists but is empty71+ else: # section does not exist, create empty section72+ if ty == dict:73+ yaml_data[sec] = {}74+ elif ty == list:75+ yaml_data[sec] = []76+77+ # add connections78+79+ def check_designators(what, where): # helper function80+ for i, x in enumerate(what):81+ if x not in yaml_data[where[i]]:82+ return False83+ return True84+85+ autogenerated_ids = {}86+ for connection in yaml_data['connections']:87+ # find first component (potentially nested inside list or dict)88+ first_item = connection[0]89+ if isinstance(first_item, list):90+ first_item = first_item[0]91+ elif isinstance(first_item, dict):92+ first_item = list(first_item.keys())[0]93+ elif isinstance(first_item, str):94+ pass95+96+ # check which section the first item belongs to97+ alternating_sections = ['connectors','cables']98+ for index, section in enumerate(alternating_sections):99+ if first_item in yaml_data[section]:100+ expected_index = index101+ break102+ else:103+ raise Exception('First item not found anywhere.')104+ expected_index = 1 - expected_index # flip once since it is flipped back at the *beginning* of every loop105+106+ # check that all iterable items (lists and dicts) are the same length107+ # and that they are alternating between connectors and cables/bundles, starting with either108+ itemcount = None109+ for item in connection:110+ expected_index = 1 - expected_index # make sure items alternate between connectors and cables111+ expected_section = alternating_sections[expected_index]112+ if isinstance(item, list):113+ itemcount_new = len(item)114+ for subitem in item:115+ if not subitem in yaml_data[expected_section]:116+ raise Exception(f'{subitem} is not in {expected_section}')117+ elif isinstance(item, dict):118+ if len(item.keys()) != 1:119+ raise Exception('Dicts may contain only one key here!')120+ itemcount_new = len(expand(list(item.values())[0]))121+ subitem = list(item.keys())[0]122+ if not subitem in yaml_data[expected_section]:123+ raise Exception(f'{subitem} is not in {expected_section}')124+ elif isinstance(item, str):125+ if not item in yaml_data[expected_section]:126+ raise Exception(f'{item} is not in {expected_section}')127+ continue128+ if itemcount is not None and itemcount_new != itemcount:129+ raise Exception('All lists and dict lists must be the same length!')130+ itemcount = itemcount_new131+ if itemcount is None:132+ raise Exception('No item revealed the number of connections to make!')133+134+ # populate connection list135+ connection_list = []136+ for i, item in enumerate(connection):137+ if isinstance(item, str): # one single-pin component was specified138+ sublist = []139+ for i in range(1, itemcount + 1):140+ if yaml_data['connectors'][item].get('autogenerate'):141+ autogenerated_ids[item] = autogenerated_ids.get(item, 0) + 1142+ new_id = f'_{item}_{autogenerated_ids[item]}'143+ harness.add_connector(new_id, **yaml_data['connectors'][item])144+ sublist.append([new_id, 1])145+ else:146+ sublist.append([item, 1])147+ connection_list.append(sublist)148+ elif isinstance(item, list): # a list of single-pin components were specified149+ sublist = []150+ for subitem in item:151+ if yaml_data['connectors'][subitem].get('autogenerate'):152+ autogenerated_ids[subitem] = autogenerated_ids.get(subitem, 0) + 1153+ new_id = f'_{subitem}_{autogenerated_ids[subitem]}'154+ harness.add_connector(new_id, **yaml_data['connectors'][subitem])155+ sublist.append([new_id, 1])156+ else:157+ sublist.append([subitem, 1])158+ connection_list.append(sublist)159+ elif isinstance(item, dict): # a component with multiple pins was specified160+ sublist = []161+ id = list(item.keys())[0]162+ pins = expand(list(item.values())[0])163+ for pin in pins:164+ sublist.append([id, pin])165+ connection_list.append(sublist)166+ else:167+ raise Exception('Unexpected item in connection list')168+169+ # actually connect components using connection list170+ for i, item in enumerate(connection_list):171+ id = item[0][0] # TODO: make more elegant/robust/pythonic172+ if id in harness.cables:173+ for j, con in enumerate(item):174+ if i == 0: # list started with a cable, no connector to join on left side175+ from_name = None176+ from_pin = None177+ else:178+ from_name = connection_list[i-1][j][0]179+ from_pin = connection_list[i-1][j][1]180+ via_name = item[j][0]181+ via_pin = item[j][1]182+ if i == len(connection_list) - 1: # list ends with a cable, no connector to join on right side183+ to_name = None184+ to_pin = None185+ else:186+ to_name = connection_list[i+1][j][0]187+ to_pin = connection_list[i+1][j][1]188+ harness.connect(from_name, from_pin, via_name, via_pin, to_name, to_pin)189+190+ if "additional_bom_items" in yaml_data:191+ for line in yaml_data["additional_bom_items"]:192+ harness.add_bom_item(line)193+194+ if file_out is not None:195+ harness.output(filename=file_out, fmt=('png', 'svg'), view=False)196+197+ if return_types is not None:198+ returns = []199+ if isinstance(return_types, str): # only one return type specified200+ return_types = [return_types]201+202+ return_types = [t.lower() for t in return_types]203+204+ for rt in return_types:205+ if rt == 'png':206+ returns.append(harness.png)207+ if rt == 'svg':208+ returns.append(harness.svg)209+ if rt == 'harness':210+ returns.append(harness)211+212+ return tuple(returns) if len(returns) != 1 else returns[0]213+214+215+def parse_file(yaml_file: str, file_out: (str, Path) = None) -> None:216+ with open_file_read(yaml_file) as file:217+ yaml_input = file.read()218+219+ if not file_out:220+ fn, fext = os.path.splitext(yaml_file)221+ file_out = fn222+ file_out = os.path.abspath(file_out)223+224+ parse(yaml_input, file_out=file_out)225+226+227+def parse_cmdline():228+ parser = argparse.ArgumentParser(229+ description='Generate cable and wiring harness documentation from YAML descriptions',230+ )231+ parser.add_argument('-V', '--version', action='version', version='%(prog)s ' + __version__)232+ parser.add_argument('-f', '--format', action='store', default='svg', type=str, help="Output formats.")233+ parser.add_argument('input_file', action='store', type=str, metavar='YAML_FILE')234+ parser.add_argument('-o', '--output_file', action='store', type=str, metavar='OUTPUT')235+ # Not implemented: parser.add_argument('--generate-bom', action='store_true', default=True)236+ parser.add_argument('--prepend-file', action='store', type=str, metavar='YAML_FILE')237+ return parser.parse_args()238+239+240+def main():241+ args = parse_cmdline()242+243+ # input file244+ input_file = args.input_file245+ if input_file is None or input_file == '-':246+ yaml_input = sys.stdin.read()247+ else:248+ if not os.path.exists(input_file):249+ sys.stderr.write(f'Error: input file {input_file} inaccessible or does not exist, check path')250+ sys.exit(1)251+ with open_file_read(input_file) as fh:252+ yaml_input = fh.read()253+254+ # prepend file255+ prepend_file = args.prepend_file256+ if prepend_file:257+ if not os.path.exists(prepend_file):258+ sys.stderr.write(f'Error: prepend input file {prepend_file} inaccessible or does not exist, check path')259+ sys.exit(1)260+ with open_file_read(prepend_file) as fh:261+ prepend = fh.read()262+ yaml_input = prepend + yaml_input263+264+ # output file265+ output_file = args.output_file266+ if output_file:267+ if str(output_file) == "-":268+ # write to stdout269+ file_out = None270+ else:271+ file_out = os.path.abspath(output_file)272+ else:273+ if input_file == '-':274+ # write to stdout275+ file_out = None276+ else:277+ file_out = input_file278+ pre, _ = os.path.splitext(file_out)279+ file_out = os.path.abspath(pre) # extension will be added by graphviz output function280+281+ output = parse(yaml_input, file_out=file_out, return_types=args.format)282+ if file_out is None:283+ if isinstance(output, (bytes, bytearray)):284+ sys.stdout.buffer.write(output)285+ else:286+ sys.stdout.write(output)287+288+289+if __name__ == '__main__':290+ main()
A diagramzip-python-units/wireviz-translator/src/wireviz/wv_bom.py +203 -0
@@ -0,0 +1,203 @@
1+# -*- coding: utf-8 -*-2+3+from dataclasses import asdict4+from itertools import groupby5+from typing import Any, Dict, List, Optional, Tuple, Union6+7+from wireviz.DataClasses import AdditionalComponent, Cable, Color, Connector8+from wireviz.wv_colors import translate_color9+from wireviz.wv_gv_html import html_bgcolor_attr, html_line_breaks10+from wireviz.wv_helper import clean_whitespace11+12+BOM_COLUMNS_ALWAYS = ('id', 'description', 'qty', 'unit', 'designators')13+BOM_COLUMNS_OPTIONAL = ('pn', 'manufacturer', 'mpn', 'supplier', 'spn')14+BOM_COLUMNS_IN_KEY = ('description', 'unit') + BOM_COLUMNS_OPTIONAL15+16+HEADER_PN = 'P/N'17+HEADER_MPN = 'MPN'18+HEADER_SPN = 'SPN'19+20+BOMKey = Tuple[str, ...]21+BOMColumn = str # = Literal[*BOM_COLUMNS_ALWAYS, *BOM_COLUMNS_OPTIONAL]22+BOMEntry = Dict[BOMColumn, Union[str, int, float, List[str], None]]23+24+def optional_fields(part: Union[Connector, Cable, AdditionalComponent]) -> BOMEntry:25+ """Return part field values for the optional BOM columns as a dict."""26+ part = asdict(part)27+ return {field: part.get(field) for field in BOM_COLUMNS_OPTIONAL}28+29+def get_additional_component_table(harness: "Harness", component: Union[Connector, Cable]) -> List[str]:30+ """Return a list of diagram node table row strings with additional components."""31+ rows = []32+ if component.additional_components:33+ rows.append(["Additional components"])34+ for part in component.additional_components:35+ common_args = {36+ 'qty': part.qty * component.get_qty_multiplier(part.qty_multiplier),37+ 'unit': part.unit,38+ 'bgcolor': part.bgcolor,39+ }40+ if harness.options.mini_bom_mode:41+ id = get_bom_index(harness.bom(), bom_entry_key({**asdict(part), 'description': part.description}))42+ rows.append(component_table_entry(f'#{id} ({part.type.rstrip()})', **common_args))43+ else:44+ rows.append(component_table_entry(part.description, **common_args, **optional_fields(part)))45+ return rows46+47+def get_additional_component_bom(component: Union[Connector, Cable]) -> List[BOMEntry]:48+ """Return a list of BOM entries with additional components."""49+ bom_entries = []50+ for part in component.additional_components:51+ bom_entries.append({52+ 'description': part.description,53+ 'qty': part.qty * component.get_qty_multiplier(part.qty_multiplier),54+ 'unit': part.unit,55+ 'designators': component.name if component.show_name else None,56+ **optional_fields(part),57+ })58+ return bom_entries59+60+def bom_entry_key(entry: BOMEntry) -> BOMKey:61+ """Return a tuple of string values from the dict that must be equal to join BOM entries."""62+ if 'key' not in entry:63+ entry['key'] = tuple(clean_whitespace(make_str(entry.get(c))) for c in BOM_COLUMNS_IN_KEY)64+ return entry['key']65+66+def generate_bom(harness: "Harness") -> List[BOMEntry]:67+ """Return a list of BOM entries generated from the harness."""68+ from wireviz.Harness import Harness # Local import to avoid circular imports69+ bom_entries = []70+ # connectors71+ for connector in harness.connectors.values():72+ if not connector.ignore_in_bom:73+ description = ('Connector'74+ + (f', {connector.type}' if connector.type else '')75+ + (f', {connector.subtype}' if connector.subtype else '')76+ + (f', {connector.pincount} pins' if connector.show_pincount else '')77+ + (f', {translate_color(connector.color, harness.options.color_mode)}' if connector.color else ''))78+ bom_entries.append({79+ 'description': description, 'designators': connector.name if connector.show_name else None,80+ **optional_fields(connector),81+ })82+83+ # add connectors aditional components to bom84+ bom_entries.extend(get_additional_component_bom(connector))85+86+ # cables87+ # TODO: If category can have other non-empty values than 'bundle', maybe it should be part of description?88+ for cable in harness.cables.values():89+ if not cable.ignore_in_bom:90+ if cable.category != 'bundle':91+ # process cable as a single entity92+ description = ('Cable'93+ + (f', {cable.type}' if cable.type else '')94+ + (f', {cable.wirecount}')95+ + (f' x {cable.gauge} {cable.gauge_unit}' if cable.gauge else ' wires')96+ + ( ' shielded' if cable.shield else '')97+ + (f', {translate_color(cable.color, harness.options.color_mode)}' if cable.color else ''))98+ bom_entries.append({99+ 'description': description, 'qty': cable.length, 'unit': cable.length_unit, 'designators': cable.name if cable.show_name else None,100+ **optional_fields(cable),101+ })102+ else:103+ # add each wire from the bundle to the bom104+ for index, color in enumerate(cable.colors):105+ description = ('Wire'106+ + (f', {cable.type}' if cable.type else '')107+ + (f', {cable.gauge} {cable.gauge_unit}' if cable.gauge else '')108+ + (f', {translate_color(color, harness.options.color_mode)}' if color else ''))109+ bom_entries.append({110+ 'description': description, 'qty': cable.length, 'unit': cable.length_unit, 'designators': cable.name if cable.show_name else None,111+ **{k: index_if_list(v, index) for k, v in optional_fields(cable).items()},112+ })113+114+ # add cable/bundles aditional components to bom115+ bom_entries.extend(get_additional_component_bom(cable))116+117+ # add harness aditional components to bom directly, as they both are List[BOMEntry]118+ bom_entries.extend(harness.additional_bom_items)119+120+ # remove line breaks if present and cleanup any resulting whitespace issues121+ bom_entries = [{k: clean_whitespace(v) for k, v in entry.items()} for entry in bom_entries]122+123+ # deduplicate bom124+ bom = []125+ for _, group in groupby(sorted(bom_entries, key=bom_entry_key), key=bom_entry_key):126+ group_entries = list(group)127+ designators = sum((make_list(entry.get('designators')) for entry in group_entries), [])128+ total_qty = sum(entry.get('qty', 1) for entry in group_entries)129+ bom.append({**group_entries[0], 'qty': round(total_qty, 3), 'designators': sorted(set(designators))})130+131+ # add an incrementing id to each bom entry132+ return [{**entry, 'id': index} for index, entry in enumerate(bom, 1)]133+134+def get_bom_index(bom: List[BOMEntry], target: BOMKey) -> int:135+ """Return id of BOM entry or raise exception if not found."""136+ for entry in bom:137+ if bom_entry_key(entry) == target:138+ return entry['id']139+ raise Exception('Internal error: No BOM entry found matching: ' + '|'.join(target))140+141+def bom_list(bom: List[BOMEntry]) -> List[List[str]]:142+ """Return list of BOM rows as lists of column strings with headings in top row."""143+ keys = list(BOM_COLUMNS_ALWAYS) # Always include this fixed set of BOM columns.144+ for fieldname in BOM_COLUMNS_OPTIONAL: # Include only those optional BOM columns that are in use.145+ if any(entry.get(fieldname) for entry in bom):146+ keys.append(fieldname)147+ # Custom mapping from internal name to BOM column headers.148+ # Headers not specified here are generated by capitilising the internal name.149+ bom_headings = {150+ 'pn': HEADER_PN,151+ 'mpn': HEADER_MPN,152+ 'spn': HEADER_SPN,153+ }154+ return ([[bom_headings.get(k, k.capitalize()) for k in keys]] + # Create header row with key names155+ [[make_str(entry.get(k)) for k in keys] for entry in bom]) # Create string list for each entry row156+157+def component_table_entry(158+ type: str,159+ qty: Union[int, float],160+ unit: Optional[str] = None,161+ bgcolor: Optional[Color] = None,162+ pn: Optional[str] = None,163+ manufacturer: Optional[str] = None,164+ mpn: Optional[str] = None,165+ supplier: Optional[str] = None,166+ spn: Optional[str] = None,167+ ) -> str:168+ """Return a diagram node table row string with an additional component."""169+ part_number_list = [170+ pn_info_string(HEADER_PN, None, pn),171+ pn_info_string(HEADER_MPN, manufacturer, mpn),172+ pn_info_string(HEADER_SPN, supplier, spn),173+ ]174+ output = (f'{qty}'175+ + (f' {unit}' if unit else '')176+ + f' x {type}'177+ + ('<br/>' if any(part_number_list) else '')178+ + (', '.join([pn for pn in part_number_list if pn])))179+ # format the above output as left aligned text in a single visible cell180+ # indent is set to two to match the indent in the generated html table181+ return f'''<table border="0" cellspacing="0" cellpadding="3" cellborder="1"{html_bgcolor_attr(bgcolor)}><tr>182+ <td align="left" balign="left">{html_line_breaks(output)}</td>183+ </tr></table>'''184+185+def pn_info_string(header: str, name: Optional[str], number: Optional[str]) -> Optional[str]:186+ """Return the company name and/or the part number in one single string or None otherwise."""187+ number = str(number).strip() if number is not None else ''188+ if name or number:189+ return f'{name if name else header}{": " + number if number else ""}'190+ else:191+ return None192+193+def index_if_list(value: Any, index: int) -> Any:194+ """Return the value indexed if it is a list, or simply the value otherwise."""195+ return value[index] if isinstance(value, list) else value196+197+def make_list(value: Any) -> list:198+ """Return value if a list, empty list if None, or single element list otherwise."""199+ return value if isinstance(value, list) else [] if value is None else [value]200+201+def make_str(value: Any) -> str:202+ """Return comma separated elements if a list, empty string if None, or value as a string otherwise."""203+ return ', '.join(str(element) for element in make_list(value))
A diagramzip-python-units/wireviz-translator/src/wireviz/wv_colors.py +184 -0
@@ -0,0 +1,184 @@
1+# -*- coding: utf-8 -*-2+import sys3+from typing import Dict, List4+5+COLOR_CODES = {6+ 'DIN': ['WH', 'BN', 'GN', 'YE', 'GY', 'PK', 'BU', 'RD', 'BK', 'VT', 'GYPK', 'RDBU', 'WHGN', 'BNGN', 'WHYE', 'YEBN',7+ 'WHGY', 'GYBN', 'WHPK', 'PKBN', 'WHBU', 'BNBU', 'WHRD', 'BNRD', 'WHBK', 'BNBK', 'GYGN', 'YEGY', 'PKGN',8+ 'YEPK', 'GNBU', 'YEBU', 'GNRD', 'YERD', 'GNBK', 'YEBK', 'GYBU', 'PKBU', 'GYRD', 'PKRD', 'GYBK', 'PKBK',9+ 'BUBK', 'RDBK', 'WHBNBK', 'YEGNBK', 'GYPKBK', 'RDBUBK', 'WHGNBK', 'BNGNBK', 'WHYEBK', 'YEBNBK', 'WHGYBK',10+ 'GYBNBK', 'WHPKBK', 'PKBNBK', 'WHBUBK', 'BNBUBK', 'WHRDBK', 'BNRDBK'],11+ 'IEC': ['BN', 'RD', 'OG', 'YE', 'GN', 'BU', 'VT', 'GY', 'WH', 'BK'],12+ 'BW': ['BK', 'WH'],13+ # 25-pair color code - see also https://en.wikipedia.org/wiki/25-pair_color_code14+ # 5 major colors (WH,RD,BK,YE,VT) combined with 5 minor colors (BU,OG,GN,BN,SL).15+ # Each POTS pair tip (+) had major/minor color, and ring (-) had minor/major color.16+ 'TEL': [ # 25x2: Ring and then tip of each pair17+ 'BUWH', 'WHBU', 'OGWH', 'WHOG', 'GNWH', 'WHGN', 'BNWH', 'WHBN', 'SLWH', 'WHSL',18+ 'BURD', 'RDBU', 'OGRD', 'RDOG', 'GNRD', 'RDGN', 'BNRD', 'RDBN', 'SLRD', 'RDSL',19+ 'BUBK', 'BKBU', 'OGBK', 'BKOG', 'GNBK', 'BKGN', 'BNBK', 'BKBN', 'SLBK', 'BKSL',20+ 'BUYE', 'YEBU', 'OGYE', 'YEOG', 'GNYE', 'YEGN', 'BNYE', 'YEBN', 'SLYE', 'YESL',21+ 'BUVT', 'VTBU', 'OGVT', 'VTOG', 'GNVT', 'VTGN', 'BNVT', 'VTBN', 'SLVT', 'VTSL'],22+ 'TELALT': [ # 25x2: Tip and then ring of each pair23+ 'WHBU', 'BU', 'WHOG', 'OG', 'WHGN', 'GN', 'WHBN', 'BN', 'WHSL', 'SL',24+ 'RDBU', 'BURD', 'RDOG', 'OGRD', 'RDGN', 'GNRD', 'RDBN', 'BNRD', 'RDSL', 'SLRD',25+ 'BKBU', 'BUBK', 'BKOG', 'OGBK', 'BKGN', 'GNBK', 'BKBN', 'BNBK', 'BKSL', 'SLBK',26+ 'YEBU', 'BUYE', 'YEOG', 'OGYE', 'YEGN', 'GNYE', 'YEBN', 'BNYE', 'YESL', 'SLYE',27+ 'VTBU', 'BUVT', 'VTOG', 'OGVT', 'VTGN', 'GNVT', 'VTBN', 'BNVT', 'VTSL', 'SLVT'],28+ 'T568A': ['WHGN', 'GN', 'WHOG', 'BU', 'WHBU', 'OG', 'WHBN', 'BN'],29+ 'T568B': ['WHOG', 'OG', 'WHGN', 'BU', 'WHBU', 'GN', 'WHBN', 'BN'],30+}31+32+# Convention: Color names should be 2 letters long, to allow for multicolored wires33+34+_color_hex = {35+ 'BK': '#000000',36+ 'WH': '#ffffff',37+ 'GY': '#999999',38+ 'PK': '#ff66cc',39+ 'RD': '#ff0000',40+ 'OG': '#ff8000',41+ 'YE': '#ffff00',42+ 'OL': '#708000', # olive green43+ 'GN': '#00ff00',44+ 'TQ': '#00ffff',45+ 'LB': '#a0dfff', # light blue46+ 'BU': '#0066ff',47+ 'VT': '#8000ff',48+ 'BN': '#895956',49+ 'BG': '#ceb673', # beige50+ 'IV': '#f5f0d0', # ivory51+ 'SL': '#708090',52+ 'CU': '#d6775e', # Faux-copper look, for bare CU wire53+ 'SN': '#aaaaaa', # Silvery look for tinned bare wire54+ 'SR': '#84878c', # Darker silver for silvered wire55+ 'GD': '#ffcf80', # Golden color for gold56+}57+58+_color_full = {59+ 'BK': 'black',60+ 'WH': 'white',61+ 'GY': 'grey',62+ 'PK': 'pink',63+ 'RD': 'red',64+ 'OG': 'orange',65+ 'YE': 'yellow',66+ 'OL': 'olive green',67+ 'GN': 'green',68+ 'TQ': 'turquoise',69+ 'LB': 'light blue',70+ 'BU': 'blue',71+ 'VT': 'violet',72+ 'BN': 'brown',73+ 'BG': 'beige',74+ 'IV': 'ivory',75+ 'SL': 'slate',76+ 'CU': 'copper',77+ 'SN': 'tin',78+ 'SR': 'silver',79+ 'GD': 'gold',80+}81+82+_color_ger = {83+ 'BK': 'sw',84+ 'WH': 'ws',85+ 'GY': 'gr',86+ 'PK': 'rs',87+ 'RD': 'rt',88+ 'OG': 'or',89+ 'YE': 'ge',90+ 'OL': 'ol', # olivgrün91+ 'GN': 'gn',92+ 'TQ': 'tk',93+ 'LB': 'hb', # hellblau94+ 'BU': 'bl',95+ 'VT': 'vi',96+ 'BN': 'br',97+ 'BG': 'bg', # beige98+ 'IV': 'eb', # elfenbeinfarben99+ 'SL': 'si', # Schiefer100+ 'CU': 'ku', # Kupfer101+ 'SN': 'vz', # verzinkt102+ 'SR': 'ag', # Silber103+ 'GD': 'au', # Gold104+}105+106+107+color_default = '#ffffff'108+109+_hex_digits = set('0123456789abcdefABCDEF')110+111+112+# Literal type aliases below are commented to avoid requiring python 3.8113+Color = str # Two-letter color name = Literal[_color_hex.keys()]114+Colors = str # One or more two-letter color names (Color) concatenated into one string115+ColorMode = str # = Literal['full', 'FULL', 'hex', 'HEX', 'short', 'SHORT', 'ger', 'GER']116+ColorScheme = str # Color scheme name = Literal[COLOR_CODES.keys()]117+118+119+def get_color_hex(input: Colors, pad: bool = False) -> List[str]:120+ """Return list of hex colors from either a string of color names or :-separated hex colors."""121+ if input is None or input == '':122+ return [color_default]123+ elif input[0] == '#': # Hex color(s)124+ output = input.split(':')125+ for i, c in enumerate(output):126+ if c[0] != '#' or not all(d in _hex_digits for d in c[1:]):127+ if c != input:128+ c += f' in input: {input}'129+ sys.stderr.write(f'Invalid hex color: {c}')130+ output[i] = color_default131+ else: # Color name(s)132+ def lookup(c: str) -> str:133+ try:134+ return _color_hex[c]135+ except KeyError:136+ if c != input:137+ c += f' in input: {input}'138+ sys.stderr.write(f'Unknown color name: {c}')139+ return color_default140+141+ output = [lookup(input[i:i + 2]) for i in range(0, len(input), 2)]142+143+ if len(output) == 2: # Give wires with EXACTLY 2 colors that striped look.144+ output += output[:1]145+ elif pad and len(output) == 1: # Hacky style fix: Give single color wires146+ output *= 3 # a triple-up so that wires are the same size.147+148+ return output149+150+151+def get_color_translation(translate: Dict[Color, str], input: Colors) -> List[str]:152+ """Return list of colors translations from either a string of color names or :-separated hex colors."""153+ def from_hex(hex_input: str) -> str:154+ for color, hex in _color_hex.items():155+ if hex == hex_input:156+ return translate[color]157+ return f'({",".join(str(int(hex_input[i:i+2], 16)) for i in range(1, 6, 2))})'158+159+ return [from_hex(h) for h in input.lower().split(':')] if input[0] == '#' else \160+ [translate.get(input[i:i+2], '??') for i in range(0, len(input), 2)]161+162+163+def translate_color(input: Colors, color_mode: ColorMode) -> str:164+ if input == '' or input is None:165+ return ''166+ upper = color_mode.isupper()167+ if not (color_mode.isupper() or color_mode.islower()):168+ raise Exception('Unknown color mode capitalization')169+170+ color_mode = color_mode.lower()171+ if color_mode == 'full':172+ output = "/".join(get_color_translation(_color_full, input))173+ elif color_mode == 'hex':174+ output = ':'.join(get_color_hex(input, pad=False))175+ elif color_mode == 'ger':176+ output = "".join(get_color_translation(_color_ger, input))177+ elif color_mode == 'short':178+ output = input179+ else:180+ raise Exception('Unknown color mode')181+ if upper:182+ return output.upper()183+ else:184+ return output.lower()
A diagramzip-python-units/wireviz-translator/src/wireviz/wv_gv_html.py +76 -0
@@ -0,0 +1,76 @@
1+# -*- coding: utf-8 -*-2+3+from typing import List, Optional, Union4+import re5+6+from wireviz.DataClasses import Color7+from wireviz.wv_colors import translate_color8+from wireviz.wv_helper import remove_links9+10+def nested_html_table(rows: List[Union[str, List[Optional[str]], None]], table_attrs: str = '') -> str:11+ # input: list, each item may be scalar or list12+ # output: a parent table with one child table per parent item that is list, and one cell per parent item that is scalar13+ # purpose: create the appearance of one table, where cell widths are independent between rows14+ # attributes in any leading <tdX> inside a list are injected into to the preceeding <td> tag15+ html = []16+ html.append(f'<table border="0" cellspacing="0" cellpadding="0"{table_attrs or ""}>')17+ for row in rows:18+ if isinstance(row, List):19+ if len(row) > 0 and any(row):20+ html.append(' <tr><td>')21+ html.append(' <table border="0" cellspacing="0" cellpadding="3" cellborder="1"><tr>')22+ for cell in row:23+ if cell is not None:24+ # Inject attributes to the preceeding <td> tag where needed25+ html.append(f' <td balign="left">{cell}</td>'.replace('><tdX', ''))26+ html.append(' </tr></table>')27+ html.append(' </td></tr>')28+ elif row is not None:29+ html.append(' <tr><td>')30+ html.append(f' {row}')31+ html.append(' </td></tr>')32+ html.append('</table>')33+ return html34+35+def html_bgcolor_attr(color: Color) -> str:36+ """Return attributes for bgcolor or '' if no color."""37+ return f' bgcolor="{translate_color(color, "HEX")}"' if color else ''38+39+def html_bgcolor(color: Color, _extra_attr: str = '') -> str:40+ """Return <td> attributes prefix for bgcolor or '' if no color."""41+ return f'<tdX{html_bgcolor_attr(color)}{_extra_attr}>' if color else ''42+43+def html_colorbar(color: Color) -> str:44+ """Return <tdX> attributes prefix for bgcolor and minimum width or None if no color."""45+ return html_bgcolor(color, ' width="4"') if color else None46+47+def html_image(image):48+ from wireviz.DataClasses import Image49+ if not image:50+ return None51+ # The leading attributes belong to the preceeding tag. See where used below.52+ html = f'{html_size_attr(image)}><img scale="{image.scale}" src="{image.src}"/>'53+ if image.fixedsize:54+ # Close the preceeding tag and enclose the image cell in a table without55+ # borders to avoid narrow borders when the fixed width < the node width.56+ html = f'''>57+ <table border="0" cellspacing="0" cellborder="0"><tr>58+ <td{html}</td>59+ </tr></table>60+ '''61+ return f'''<tdX{' sides="TLR"' if image.caption else ''}{html_bgcolor_attr(image.bgcolor)}{html}'''62+63+def html_caption(image):64+ from wireviz.DataClasses import Image65+ return (f'<tdX sides="BLR"{html_bgcolor_attr(image.bgcolor)}>{html_line_breaks(image.caption)}'66+ if image and image.caption else None)67+68+def html_size_attr(image):69+ from wireviz.DataClasses import Image70+ # Return Graphviz HTML attributes to specify minimum or fixed size of a TABLE or TD object71+ return ((f' width="{image.width}"' if image.width else '')72+ + (f' height="{image.height}"' if image.height else '')73+ + ( ' fixedsize="true"' if image.fixedsize else '')) if image else ''74+75+def html_line_breaks(inp):76+ return remove_links(inp).replace('\n', '<br />') if isinstance(inp, str) else inp
A diagramzip-python-units/wireviz-translator/src/wireviz/wv_helper.py +118 -0
@@ -0,0 +1,118 @@
1+# -*- coding: utf-8 -*-2+import sys3+from typing import List4+import re5+6+awg_equiv_table = {7+ '0.09': '28',8+ '0.14': '26',9+ '0.25': '24',10+ '0.34': '22',11+ '0.5': '21',12+ '0.75': '20',13+ '1': '18',14+ '1.5': '16',15+ '2.5': '14',16+ '4': '12',17+ '6': '10',18+ '10': '8',19+ '16': '6',20+ '25': '4',21+ '35': '2',22+ '50': '1',23+}24+25+mm2_equiv_table = {v:k for k,v in awg_equiv_table.items()}26+27+def awg_equiv(mm2):28+ return awg_equiv_table.get(str(mm2), 'Unknown')29+30+def mm2_equiv(awg):31+ return mm2_equiv_table.get(str(awg), 'Unknown')32+33+34+def expand(yaml_data):35+ # yaml_data can be:36+ # - a singleton (normally str or int)37+ # - a list of str or int38+ # if str is of the format '#-#', it is treated as a range (inclusive) and expanded39+ output = []40+ if not isinstance(yaml_data, list):41+ yaml_data = [yaml_data]42+ for e in yaml_data:43+ e = str(e)44+ if '-' in e:45+ a, b = e.split('-', 1)46+ try:47+ a = int(a)48+ b = int(b)49+ if a < b:50+ for x in range(a, b + 1):51+ output.append(x) # ascending range52+ elif a > b:53+ for x in range(a, b - 1, -1):54+ output.append(x) # descending range55+ else: # a == b56+ output.append(a) # range of length 157+ except:58+ output.append(e) # '-' was not a delimiter between two ints, pass e through unchanged59+ else:60+ try:61+ x = int(e) # single int62+ except Exception:63+ x = e # string64+ output.append(x)65+ return output66+67+68+def int2tuple(inp):69+ if isinstance(inp, tuple):70+ output = inp71+ else:72+ output = (inp,)73+ return output74+75+76+def flatten2d(inp):77+ return [[str(item) if not isinstance(item, List) else ', '.join(item) for item in row] for row in inp]78+79+80+def tuplelist2tsv(inp, header=None):81+ output = ''82+ if header is not None:83+ inp.insert(0, header)84+ inp = flatten2d(inp)85+ for row in inp:86+ output = output + '\t'.join(str(remove_links(item)) for item in row) + '\n'87+ return output88+89+90+def remove_links(inp):91+ return re.sub(r'<[aA] [^>]*>([^<]*)</[aA]>', r'\1', inp) if isinstance(inp, str) else inp92+93+94+def clean_whitespace(inp):95+ return ' '.join(inp.split()).replace(' ,', ',') if isinstance(inp, str) else inp96+97+98+def open_file_read(filename):99+ # TODO: Intelligently determine encoding100+ return open(filename, 'r', encoding='UTF-8')101+102+def open_file_write(filename):103+ return open(filename, 'w', encoding='UTF-8')104+105+def open_file_append(filename):106+ return open(filename, 'a', encoding='UTF-8')107+108+def aspect_ratio(image_src):109+ try:110+ from PIL import Image111+ image = Image.open(image_src)112+ if image.width > 0 and image.height > 0:113+ return image.width / image.height114+ sys.stderr.write(f'aspect_ratio(): Invalid image size {image.width} x {image.height}')115+ # ModuleNotFoundError and FileNotFoundError are the most expected, but all are handled equally.116+ except Exception as error:117+ sys.stderr.write(f'aspect_ratio(): {type(error).__name__}: {error}')118+ return 1 # Assume 1:1 when unable to read actual image size
A diagramzip-python-units/wireviz-translator/src/wireviz/wv_html.py +54 -0
@@ -0,0 +1,54 @@
1+# -*- coding: utf-8 -*-2+3+from pathlib import Path4+from typing import List, Union5+import re6+7+from wireviz import __version__, APP_NAME, APP_URL, wv_colors8+from wireviz.DataClasses import Metadata, Options9+from wireviz.wv_helper import flatten2d, open_file_read, open_file_write10+11+def generate_html_output(filename: Union[str, Path], bom_list: List[List[str]], metadata: Metadata, options: Options):12+ with open_file_write(f'{filename}.html') as file:13+ file.write('<!DOCTYPE html>\n')14+ file.write('<html lang="en"><head>\n')15+ file.write(' <meta charset="UTF-8">\n')16+ file.write(f' <meta name="generator" content="{APP_NAME} {__version__} - {APP_URL}">\n')17+ file.write(f' <title>{metadata["title"]}</title>\n')18+ file.write(f'</head><body style="font-family:{options.fontname};background-color:'19+ f'{wv_colors.translate_color(options.bgcolor, "HEX")}">\n')20+21+ file.write(f'<h1>{metadata["title"]}</h1>\n')22+ description = metadata.get('description')23+ if description:24+ file.write(f'<p>{description}</p>\n')25+ file.write('<h2>Diagram</h2>\n')26+ with open_file_read(f'{filename}.svg') as svg:27+ file.write(re.sub(28+ '^<[?]xml [^?>]*[?]>[^<]*<!DOCTYPE [^>]*>',29+ '<!-- XML and DOCTYPE declarations from SVG file removed -->',30+ svg.read(1024), 1))31+ for svgdata in svg:32+ file.write(svgdata)33+34+ file.write('<h2>Bill of Materials</h2>\n')35+ listy = flatten2d(bom_list)36+ file.write('<table style="border:1px solid #000000; font-size: 14pt; border-spacing: 0px">\n')37+ file.write(' <tr>\n')38+ for item in listy[0]:39+ file.write(f' <th style="text-align:left; border:1px solid #000000; padding: 8px">{item}</th>\n')40+ file.write(' </tr>\n')41+ for row in listy[1:]:42+ file.write(' <tr>\n')43+ for i, item in enumerate(row):44+ item_str = item.replace('\u00b2', '&sup2;')45+ align = '; text-align:right' if listy[0][i] == 'Qty' else ''46+ file.write(f' <td style="border:1px solid #000000; padding: 4px{align}">{item_str}</td>\n')47+ file.write(' </tr>\n')48+ file.write('</table>\n')49+50+ notes = metadata.get('notes')51+ if notes:52+ file.write(f'<h2>Notes</h2>\n<p>{notes}</p>\n')53+54+ file.write('</body></html>\n')
A diagramzip-python-units/wireviz-translator/test/test_graphviz.py +7 -0
A diagramzip-python-units/wireviz-translator/test/test_protocol.py +23 -0
A diagramzip-python-units/wireviz-translator/test/test_render.py +72 -0
@@ -0,0 +1,72 @@
1+from pathlib import Path2+3+import pytest4+5+from render import render_dot6+7+8+FIXTURE = Path(__file__).parents[3] / 'ci/tests/diagrams/wireviz.yaml'9+10+11+def test_upstream_fixture_lowers_to_graphviz_dot():12+ dot = render_dot(FIXTURE.read_text())13+ assert dot.startswith('graph {')14+ assert 'X1' in dot and 'X2' in dot and 'W1' in dot15+ assert 'port="w1"' in dot16+ assert 'port="p1r"' in dot17+ assert '#00ff00' in dot18+19+20+def test_images_are_rejected_before_upstream_graph_generation():21+ with pytest.raises(ValueError, match='images'):22+ render_dot('connectors:\n X:\n pincount: 1\n image:\n src: /etc/passwd\n')23+24+25+def test_raw_tweaks_are_rejected():26+ with pytest.raises(ValueError, match='tweak'):27+ render_dot('tweak:\n append: "evil [image=/etc/passwd]"\nconnectors: {}\n')28+29+30+def test_large_ranges_are_rejected():31+ with pytest.raises(ValueError, match='ranges'):32+ render_dot('connectors:\n X:\n pincount: 1\ncables:\n C:\n wirecount: 1\nconnections:\n - [X: 1-999999, C: 1]\n')33+34+35+def test_yaml_templates_remain_supported_within_complexity_bounds():36+ dot = render_dot('''37+templates:38+ - &connector39+ type: Shared connector40+ pincount: 141+connectors:42+ X1: {<<: *connector}43+ X2: {<<: *connector}44+cables:45+ W: {wirecount: 1}46+connections:47+ - [X1: 1, W: 1, X2: 1]48+''')49+ assert 'Shared connector' in dot50+51+52+def test_recursive_aliases_and_oversized_component_counts_are_rejected():53+ with pytest.raises(ValueError, match='recursive'):54+ render_dot('connectors: &loop\n X: *loop\n')55+ with pytest.raises(ValueError, match='too large'):56+ render_dot('connectors:\n X:\n pincount: 99999999\n')57+58+59+def test_designators_with_spaces_remain_valid_dot_endpoints():60+ dot = render_dot('''61+connectors:62+ Connector A:63+ pincount: 164+cables:65+ Cable 1:66+ wirecount: 167+connections:68+ -69+ - Connector A: 170+ - Cable 1: 171+''')72+ assert '"Connector A":p1r:e -- "Cable 1":w1:w' in dot
A diagramzip-python-units/wireviz-translator/uv.lock +327 -0
@@ -0,0 +1,327 @@
1+version = 12+revision = 33+requires-python = ">=3.13"4+resolution-markers = [5+ "python_full_version >= '3.14'",6+ "python_full_version < '3.14'",7+]8+9+[[package]]10+name = "click"11+version = "8.4.2"12+source = { registry = "https://pypi.org/simple" }13+dependencies = [14+ { name = "colorama", marker = "sys_platform == 'win32'" },15+]16+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }17+wheels = [18+ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },19+]20+21+[[package]]22+name = "colorama"23+version = "0.4.6"24+source = { registry = "https://pypi.org/simple" }25+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }26+wheels = [27+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },28+]29+30+[[package]]31+name = "diagramzip-wireviz-translator"32+version = "0.1.0"33+source = { virtual = "." }34+dependencies = [35+ { name = "pyyaml" },36+]37+38+[package.dev-dependencies]39+dev = [40+ { name = "pytest" },41+ { name = "workers-py" },42+ { name = "workers-runtime-sdk" },43+]44+45+[package.metadata]46+requires-dist = [{ name = "pyyaml", specifier = ">=6.0,<7" }]47+48+[package.metadata.requires-dev]49+dev = [50+ { name = "pytest", specifier = ">=8.4,<9" },51+ { name = "workers-py" },52+ { name = "workers-runtime-sdk" },53+]54+55+[[package]]56+name = "iniconfig"57+version = "2.3.0"58+source = { registry = "https://pypi.org/simple" }59+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }60+wheels = [61+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },62+]63+64+[[package]]65+name = "markdown-it-py"66+version = "4.2.0"67+source = { registry = "https://pypi.org/simple" }68+dependencies = [69+ { name = "mdurl" },70+]71+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }72+wheels = [73+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },74+]75+76+[[package]]77+name = "mdurl"78+version = "0.1.2"79+source = { registry = "https://pypi.org/simple" }80+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }81+wheels = [82+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },83+]84+85+[[package]]86+name = "packaging"87+version = "26.3"88+source = { registry = "https://pypi.org/simple" }89+sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }90+wheels = [91+ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },92+]93+94+[[package]]95+name = "pluggy"96+version = "1.6.0"97+source = { registry = "https://pypi.org/simple" }98+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }99+wheels = [100+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },101+]102+103+[[package]]104+name = "pygments"105+version = "2.21.0"106+source = { registry = "https://pypi.org/simple" }107+sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }108+wheels = [109+ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },110+]111+112+[[package]]113+name = "pyjson5"114+version = "2.0.1"115+source = { registry = "https://pypi.org/simple" }116+sdist = { url = "https://files.pythonhosted.org/packages/f1/9a/3db19560e968d6e85b2a4ddf4b949c6ebf9dd1dcfb5a9f37736f8adeb927/pyjson5-2.0.1.tar.gz", hash = "sha256:a5b0e322e847b198a50d8a1ef16d6b2b19129644dc018d76773e81ef1487ca39", size = 352242, upload-time = "2026-05-15T16:12:54.931Z" }117+wheels = [118+ { url = "https://files.pythonhosted.org/packages/93/2c/0619f89a9576f335ba63eb851e73ba507480a8c7f28a2e7de2501ed3d303/pyjson5-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8a661d292801b38434d5288bf7329f9b50b3a693b1d2941c7595175842692eba", size = 301821, upload-time = "2026-05-15T16:09:28.721Z" },119+ { url = "https://files.pythonhosted.org/packages/91/31/7824913ec71e7421d6a57bc06228f3e2d946d8e8f738f898572dded0dc57/pyjson5-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cb5c1c066038ce6e1922d9d5be23f5cd14d5b5a3c96e6358aa5d0e379014a93", size = 158201, upload-time = "2026-05-15T16:09:30.261Z" },120+ { url = "https://files.pythonhosted.org/packages/bc/7d/4ddb249563a838425242342d2cf67976ccc292a831b543a92fa1a8a83b11/pyjson5-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7417eab751817fa5f070e41975d9df4aec3532d21389073318161f63cd96d636", size = 153244, upload-time = "2026-05-15T16:09:32.04Z" },121+ { url = "https://files.pythonhosted.org/packages/02/39/7622416ac0570d9ce377447bd5b2ec9383c1282e63cc6d0b65779f1336fa/pyjson5-2.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cc4802093b4ab9039367774988486fd04754cf416c32aebf94a94bafd8b8a478", size = 185568, upload-time = "2026-05-15T16:09:33.719Z" },122+ { url = "https://files.pythonhosted.org/packages/d8/90/2f317da231477b77481020d79c73bbe10625e4925ceeae77603726ef1763/pyjson5-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc3181274ed19ecb2315cf85a499bf83002554f42c72453666c616059d665a7b", size = 163169, upload-time = "2026-05-15T16:09:34.865Z" },123+ { url = "https://files.pythonhosted.org/packages/02/b3/20023c3cfe2f2c2523007d7031b5b7ad7ccd8856d2665547683cebaa6f8e/pyjson5-2.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5398306b8aa253e620af9ed8085767de9cd28d6846da45e535e80fcffa9f33d7", size = 166514, upload-time = "2026-05-15T16:09:36.383Z" },124+ { url = "https://files.pythonhosted.org/packages/e2/1c/b863e502153477b2845a54b688b72436457aca5107b300fca95e98184551/pyjson5-2.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b97b592287d52a6ec5af84dca94aab02b185f72f8ddb6fc99c58efb8891be5cd", size = 179907, upload-time = "2026-05-15T16:09:38.166Z" },125+ { url = "https://files.pythonhosted.org/packages/fc/b2/413fcb76632e5f6fa89a2ec83976755b5bb4696c6d9eca9ff3c70cd717b6/pyjson5-2.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4efbf5e910a3aa6f330a92d1ae940dda5ed662976ed5056526baaf2e1821f95d", size = 187171, upload-time = "2026-05-15T16:09:39.624Z" },126+ { url = "https://files.pythonhosted.org/packages/d6/11/66151b819407ab589aef36582038257f1ef42dc065e3423b3d8274f4fcd2/pyjson5-2.0.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e042f9b869e7f21d5f53a2638e5d0cf1daaba2342127c8777c502daf972602e", size = 175564, upload-time = "2026-05-15T16:09:41.356Z" },127+ { url = "https://files.pythonhosted.org/packages/f2/98/deb70690dab994474ea527cba91f43ae55928bcff498c441e812b60fc1e2/pyjson5-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26074370d7a6dd38b6e8c28a2f10c790fc6dec938ec22ec3de6c93c97d195f10", size = 171530, upload-time = "2026-05-15T16:09:42.78Z" },128+ { url = "https://files.pythonhosted.org/packages/a9/be/969752a3a052d00698b6e1dd926c627d7a6475a7c8dd390db148363544f8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3cb163a495096716a7a01d052b60bda0308ecfaeddcb2d50a247ba9d707e3e40", size = 1145510, upload-time = "2026-05-15T16:09:45.005Z" },129+ { url = "https://files.pythonhosted.org/packages/d0/be/4c5c92cdda5a911ee4450a74281782335e7ce6715638308322beb96be639/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5fdd66bdddc2d53421ab7427bedcd2ca9b2f1b4b3b464381aa60d305c94be38c", size = 1010114, upload-time = "2026-05-15T16:09:47.081Z" },130+ { url = "https://files.pythonhosted.org/packages/89/1e/72283bc505d77dcdab7eaeb0020cf0fd79a05d2991e886d1fcfa73e88ae1/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4ad222a3eff1cc93f9b70894c5f3d2ff5e46531e88feaa4818e329d5f3ae4307", size = 1322915, upload-time = "2026-05-15T16:09:48.885Z" },131+ { url = "https://files.pythonhosted.org/packages/06/b4/94a09e744a6bb6e76108b61a28a7cc5ecab1b8105cacde7548359b3b74a3/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:63f5cf25113dd1bf3bfeda211d99413e58ec6b9036ff34f7b69f85f23695e9c4", size = 1240126, upload-time = "2026-05-15T16:09:50.638Z" },132+ { url = "https://files.pythonhosted.org/packages/af/29/4549380cae425ee112f3c154606c35f5211c12ddf415f079c2b23f2d493b/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b0001eecce9080e6c170e978786501e8931e2075a3d15f5260e1bdd6a45e8976", size = 1178022, upload-time = "2026-05-15T16:09:52.639Z" },133+ { url = "https://files.pythonhosted.org/packages/1c/1a/5a8a869e855645858e45dbd077204cec3a85b2f2e669bc3c8cefe9c4a70e/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40371c73cc83ed81914028786a6cc5950bdf300ae82443df83a5dfa80c582fca", size = 1355161, upload-time = "2026-05-15T16:09:55.177Z" },134+ { url = "https://files.pythonhosted.org/packages/82/32/827066cd946447648275a892f494f4572d78e6a79d877fa6b6c14af599d8/pyjson5-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c9dca542eeb6e8fbbc1fe3373bfd18ae6250f1aedc78f39242ac12c8837091b8", size = 1208864, upload-time = "2026-05-15T16:09:56.919Z" },135+ { url = "https://files.pythonhosted.org/packages/2d/cc/a9bc12aff47d8bfd3074a21e3fb056ec0028fac28f8ec7e0fe4449d050f5/pyjson5-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d30c8b2c91d530be475a8fab6dde8c3bcd3e89b999c9e0f05aec2bf2c24b0638", size = 115461, upload-time = "2026-05-15T16:10:30.711Z" },136+ { url = "https://files.pythonhosted.org/packages/c9/c7/fa3fc956fbc3fa6250c8b99ef75a8f52b691780fca1bfb368340283bd898/pyjson5-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:67f8f5b8d3e3b2ca5f618c928729986aa00fb588c476c0d0d3a151633ff41e0d", size = 135836, upload-time = "2026-05-15T16:10:32.062Z" },137+ { url = "https://files.pythonhosted.org/packages/f0/0a/e457b20d1e36a766d3ccd09c418b7ef41acabb679ff2248bb1cd38247ce6/pyjson5-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:bdf30ce5b1242f63254d936b30af75cae237fab0ee71cc2544163c82b6bc9201", size = 116652, upload-time = "2026-05-15T16:10:33.39Z" },138+ { url = "https://files.pythonhosted.org/packages/2b/4c/6266789e615576b62d2db6de26a3b4b8f4cc7a8ff23fe24e48363bca1682/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:954c14d40022fa40e0d36e0ebd337e9e90fd71145592d25a0e2868344e3daca0", size = 320487, upload-time = "2026-05-15T16:09:58.38Z" },139+ { url = "https://files.pythonhosted.org/packages/dc/75/c4a563034805e20b274fe4db963e1d480001931d88fe70b7d082033b7dcb/pyjson5-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f57529b98d21e8b76f8bc51af9d3c3057dd04a92287fd21ef7ec55255ca6f5c5", size = 166879, upload-time = "2026-05-15T16:09:59.849Z" },140+ { url = "https://files.pythonhosted.org/packages/f3/d2/5e6ec3580379794e9a99a402465f74586e93e93814974172202d1254fa89/pyjson5-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:545b655ef0b59f39fc29e2b63b4dadd45e447ad77bad2fdca859ff4db69e21f5", size = 162235, upload-time = "2026-05-15T16:10:01.147Z" },141+ { url = "https://files.pythonhosted.org/packages/5b/48/b2d0e868ef8375eb0696ddc74f71028fc4fcd26cdddaeb34485214f2dc87/pyjson5-2.0.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b519de49dd4cbf254179749e5c6bfe9bd8fd1f97e7755bff2e17cc6f79f85aa5", size = 182162, upload-time = "2026-05-15T16:10:02.523Z" },142+ { url = "https://files.pythonhosted.org/packages/ce/18/fed02a3d68c3badb87394e8420b72d7cb165ade208c1e02561637409aa99/pyjson5-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de48fbd0466c114c17f408be7bcbc5b3aedeb29ff5f73187022915e6d5eb7817", size = 169487, upload-time = "2026-05-15T16:10:03.876Z" },143+ { url = "https://files.pythonhosted.org/packages/8b/90/dc897332dda24e949828616e8e74d7937b587a56789f3aca148fcadddde2/pyjson5-2.0.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fe6516ff9ab94368c18ff8ac0ebdb53fac9e047da093d43e67d5e7cd98cdd0bf", size = 164128, upload-time = "2026-05-15T16:10:05.588Z" },144+ { url = "https://files.pythonhosted.org/packages/39/09/90104baeab58adfe63bda804174ef1806b1cabb1692e047fabc2f4aa1799/pyjson5-2.0.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ea7aadacac7ac6661117b708175f6beff75e132322c08bbf3794d8ec4ba572b4", size = 184306, upload-time = "2026-05-15T16:10:06.986Z" },145+ { url = "https://files.pythonhosted.org/packages/89/73/613efc3cdab5cf9ec399e6c5a0463b672e5f5cbd693a3a4d82cbdda19476/pyjson5-2.0.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e5c339bcac113dfc30a1f88f23af273d67bb6169abee1684c0dd27daa71a32f9", size = 192239, upload-time = "2026-05-15T16:10:08.727Z" },146+ { url = "https://files.pythonhosted.org/packages/47/2a/17b97e02b37ab9353fc9713e56470be3d2539501ed18d6c676cfbcd0a57c/pyjson5-2.0.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:075de0f1e5e0ffee174a8941b5d9d8af632772a6e5eebe92fb0899db7f6ab7fa", size = 177652, upload-time = "2026-05-15T16:10:10.19Z" },147+ { url = "https://files.pythonhosted.org/packages/1c/43/a05331ba88fd1aa09ca418548c6b06692e34b2a64b109bb363e01ecafdeb/pyjson5-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c463c9bf508c1d316191898a3c0bfc03dd3de67bdc02f480b0742ac5c859eaeb", size = 174860, upload-time = "2026-05-15T16:10:11.976Z" },148+ { url = "https://files.pythonhosted.org/packages/2e/00/5d253751f4d27b7e63fdfd765041593fa588395f45b9cea496878d964448/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:54427c8cad0c1a516bad5b4457167cde1e7ef7ee563e28a944f59b3c7413b6a0", size = 1151556, upload-time = "2026-05-15T16:10:13.608Z" },149+ { url = "https://files.pythonhosted.org/packages/9c/f7/a30eed477295e92dfd59553063cf82bf510a7052efc7720c9c50d0082fdb/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:ca04839cecd364cb53de7ebcd81fd5915193707d968730ea1c8d4ff5c9c2f1a8", size = 1012330, upload-time = "2026-05-15T16:10:15.171Z" },150+ { url = "https://files.pythonhosted.org/packages/9c/60/80818fad90a99336f8bfc686d76d0b2d7c8bb51e8de5d031620fa1a2d7a1/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:13a2de8b14aeb576b1623c8af419510bf871b8d9cc2308db41a46bcc94d00f2f", size = 1320970, upload-time = "2026-05-15T16:10:16.863Z" },151+ { url = "https://files.pythonhosted.org/packages/7c/3f/090eb3e0971d067defb58bbfd738f74a09be495c6f4f00d5d76a10755bca/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c8a689f5252e9791bfbc8fa916999202556a1e8c7b24dfd72de1ad84c493e10a", size = 1244775, upload-time = "2026-05-15T16:10:19.416Z" },152+ { url = "https://files.pythonhosted.org/packages/61/38/e7546ad733affe51a5462bad21bceb4fd659baf24930bd141627ce56279b/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc0c2c80fd9d1e4c26f8b6c91a54d38b48648b93ae3f09355856c75fda3e460", size = 1182149, upload-time = "2026-05-15T16:10:21.696Z" },153+ { url = "https://files.pythonhosted.org/packages/90/2c/016647580d8a82ee53b4cb9ddb96deb4b157ccea9624bba61de4aa2cd25c/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:13d78b3e2c60b81bd7e6b00e6ab24a664ba1fd64b5d86baa65c4cdfae1fff7ae", size = 1358579, upload-time = "2026-05-15T16:10:23.649Z" },154+ { url = "https://files.pythonhosted.org/packages/01/45/8b84ff7a0c4d8c12a76e097c67af62abe986a18d1ab2540764dd6eaa0253/pyjson5-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a7b640dbeabbd7d975170f793f90a7500cc5d510aeae9364a1bc558bcc5336ed", size = 1211015, upload-time = "2026-05-15T16:10:25.4Z" },155+ { url = "https://files.pythonhosted.org/packages/cf/48/d8f34de7a7319f5966bdfd10f133e689ba01f138d2040bf792f1ca8b18e1/pyjson5-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:46e7c5525034fde8abf3aaf100ed7660c1b618ccd3205544ed7c99e4c55cf201", size = 131660, upload-time = "2026-05-15T16:10:26.747Z" },156+ { url = "https://files.pythonhosted.org/packages/06/aa/0bd437252134115a846ffc061078c85f8e8c325c86abcaa862c134d3c57e/pyjson5-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:207c00e7f9641e0358bdc848a1c0610f26f2e8bd51d73bd64e5a0ab3b725f1d8", size = 157717, upload-time = "2026-05-15T16:10:28.053Z" },157+ { url = "https://files.pythonhosted.org/packages/24/61/7849b04a0dc78f73905189400ac99c45de8eec2ec451ce9c6990fb3588e5/pyjson5-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4917d5b6186bfa48ec4d85326e2a09769a5f7844963307ab2710429e1f743264", size = 126281, upload-time = "2026-05-15T16:10:29.378Z" },158+ { url = "https://files.pythonhosted.org/packages/89/41/70aa1cb1fb0a3ac4c9b8cd405c0c85ba935c53fdfae6271b8eae364b92d1/pyjson5-2.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:573fecd7cad4e24d232053f9ebf331c80fe1b0ed6e2064f7614797a7f691e7ca", size = 303706, upload-time = "2026-05-15T16:10:35.163Z" },159+ { url = "https://files.pythonhosted.org/packages/0c/d9/caf44bf3d33b9502dc4b8ed5d0c7a8af8fbe33001e82414c0407f39f18bd/pyjson5-2.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a8ade508a046a5407a322c098e3b7e6033216b158a7746eb8962b7a4cdbf9248", size = 158915, upload-time = "2026-05-15T16:10:36.496Z" },160+ { url = "https://files.pythonhosted.org/packages/3f/94/f2bab1ce2eac8f77e16dcd0a1ba39de20733a84b9961c3504af0dd68bceb/pyjson5-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4635e349bb1d1f1e854dc790f14be31b6cb198d6453848e8d86818104074f805", size = 154199, upload-time = "2026-05-15T16:10:37.902Z" },161+ { url = "https://files.pythonhosted.org/packages/82/c4/d94573ca37486af3d6a72f2582844d7d490d8e55639e0cfba371ce91442f/pyjson5-2.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d274697f81f143abac12ce256a06b61635c30d0f8cc11eefae7182655dac9a5", size = 186060, upload-time = "2026-05-15T16:10:39.603Z" },162+ { url = "https://files.pythonhosted.org/packages/03/cc/c42e697def319b286fdcb912939c044cc94bd1cfc7338b6dbb566f817f29/pyjson5-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be4e2242e55a2651fd8696cfd67ec8e04f54f4ed6c089cceea2b63763a7516d8", size = 165796, upload-time = "2026-05-15T16:10:40.967Z" },163+ { url = "https://files.pythonhosted.org/packages/3f/dd/f22a5f0e619ef22b8e32520a91bd92f815d50f9ec2d67cfe5974eab9476c/pyjson5-2.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af855c680feaa39cae4a44914ee2863eeae1549f37ce58069c747b3d4803999a", size = 165262, upload-time = "2026-05-15T16:10:42.391Z" },164+ { url = "https://files.pythonhosted.org/packages/99/8b/90e22ecb12d51ccdc68325b6b051002e6103cc4600a335d9ed13828acdd9/pyjson5-2.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb769d90516da904e6cf0ba58f3d5830f4a5804486b5e85c5ca42ba3146d187a", size = 181608, upload-time = "2026-05-15T16:10:43.914Z" },165+ { url = "https://files.pythonhosted.org/packages/e0/ab/237f9036eed73e08cf8861466d3d1182ef1c88506bd29955740dcadb24ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6484c14e07aa46abeb3cb2ff0204a765260763ad7cf3f87f601f26b20ed607f3", size = 189582, upload-time = "2026-05-15T16:10:45.367Z" },166+ { url = "https://files.pythonhosted.org/packages/c4/3d/3df8d5f003910a9291e5f04fa178f626e3c8553a5986dc62589ca74195ff/pyjson5-2.0.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc03673adb544324500d79b2acfe2ced038998b2aceb564d335de9cc238cbdb0", size = 176321, upload-time = "2026-05-15T16:10:47.134Z" },167+ { url = "https://files.pythonhosted.org/packages/c0/d9/a458a54f780bafec1b44ee3c27cb3007a83fb7bd4f1c17ac6bf519fd6151/pyjson5-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d714daf784bec2e14fc06c0c26e4a373a6157eaeddaed2d5a7b7b6ae2aabc33", size = 171956, upload-time = "2026-05-15T16:10:49.174Z" },168+ { url = "https://files.pythonhosted.org/packages/78/d8/1010e0147c8862dc0881cf3656364d3d87a5e336d290fe4cb0b92223e14c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5cf3ded356730e08b5941a16db6525efc0de26f35468ab09ad573056c4efc6e5", size = 1147679, upload-time = "2026-05-15T16:10:51.005Z" },169+ { url = "https://files.pythonhosted.org/packages/19/2f/91c8d8cdb4a2e4bbe8f14b9b57d717988e14830f2ed4a4f08d503cb5edde/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cc00d4669fb4170c28b2c9ed3cd1c2af9f21727c55d6866030cc154f31f68747", size = 1008216, upload-time = "2026-05-15T16:10:53.252Z" },170+ { url = "https://files.pythonhosted.org/packages/d7/d7/25c3f8693660a35be7bb4eb71b0d10d9a6981e986723a4881d607f0c6462/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a77a91c2e019476345c03cde105da44100daaabc3fa56f82f2bd9eb2ebbcb698", size = 1323856, upload-time = "2026-05-15T16:10:55.517Z" },171+ { url = "https://files.pythonhosted.org/packages/c8/25/82f0f83556ff68ec0ca1129ba9afeb14149cd421dc74823cdf10c209c95c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9ae54e5d717982cb7a082c700ab1f7f965c6aedea6fcb003ec4bdeac4f02bf52", size = 1241860, upload-time = "2026-05-15T16:10:57.635Z" },172+ { url = "https://files.pythonhosted.org/packages/c1/01/2c4695fc06a0f3f29041a875d3bb24f904c9ae7b3d8dbfa1f8db17ce995c/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ce4f5b67b3a6fab623a31d83416b51a5b2c97cb172194f214078484bbb4783a6", size = 1178443, upload-time = "2026-05-15T16:10:59.398Z" },173+ { url = "https://files.pythonhosted.org/packages/9a/ae/c10f534037b096aca21b90017ac9d41f791572dc3ec31fcd92db8ddcd256/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f8d2e61f7bed0b40cfe5f62375581bf56d7b5b9707c9d74747b5931149d7d376", size = 1357739, upload-time = "2026-05-15T16:11:01.189Z" },174+ { url = "https://files.pythonhosted.org/packages/0c/f0/655224f78ede087aa6a295c4745423e0104dd31f67928675b1a4cea72a0a/pyjson5-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2930140edd1a64eb42689993c5ae317bca2e6bc785b5dc5053bd5e9d184c98ad", size = 1209542, upload-time = "2026-05-15T16:11:03.364Z" },175+ { url = "https://files.pythonhosted.org/packages/73/52/a7b2a26625136fcd0f92e17beaaae51e04923cc2e28502db354d4de061b4/pyjson5-2.0.1-cp314-cp314-win32.whl", hash = "sha256:51733f91dda897239ca10928f363ce6f4b5eadaf797e74477f6c1c3222c185a7", size = 118342, upload-time = "2026-05-15T16:11:40.009Z" },176+ { url = "https://files.pythonhosted.org/packages/3a/8e/79db426fff3a41610076989d4060cc18a6508ebd3c7877155f6709eea102/pyjson5-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:698c73aacff49ea35bbbf7f700a97785626201ea7aa2e1f1e0a4fe23788c3be2", size = 138408, upload-time = "2026-05-15T16:11:41.757Z" },177+ { url = "https://files.pythonhosted.org/packages/c7/1b/385da14c05412bca15e86551dde91959686c8bddf7e78722bdc627fa6815/pyjson5-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:d447e2e5756f89abfd0ad82d1438075bcbc701c1cc9279b43d24825aaac67356", size = 120905, upload-time = "2026-05-15T16:11:43.146Z" },178+ { url = "https://files.pythonhosted.org/packages/dd/04/cd69739556d304d3ea48064ea7d47e5ff7321b0032d7ad78864bceaa0cae/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5603be4f0cb9685d7c2cbd408ddec21b33f415252bee00ac7969f20b5789a1d1", size = 321150, upload-time = "2026-05-15T16:11:05.07Z" },179+ { url = "https://files.pythonhosted.org/packages/84/d4/6d98268ed07a2cac1e79634fcd3dc280d2503ade5f3c465f7aa095951444/pyjson5-2.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:87267520a256b3f2dba7f2995b745e8b3f2dd72bbf8436010a5191f7809f8ab6", size = 167540, upload-time = "2026-05-15T16:11:06.822Z" },180+ { url = "https://files.pythonhosted.org/packages/c8/c6/8855a9462bdbc8306cba62793e23cc2952dfcbcbcab8e0413f570c891361/pyjson5-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:77e58307b74019d90aff9f2781bcffbbea3315b15fc814f8fa9b8d334b956ff4", size = 162533, upload-time = "2026-05-15T16:11:08.218Z" },181+ { url = "https://files.pythonhosted.org/packages/a6/a3/1092f68538ee71ed8027393ab5e71aa9c4a114e493e30a55e46e34c2ffb9/pyjson5-2.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd1712038837342dea73ed526883e53c6e85a158242b6f574b9ad9cdd3199c7f", size = 184165, upload-time = "2026-05-15T16:11:09.639Z" },182+ { url = "https://files.pythonhosted.org/packages/46/63/bb9f42a4047284ea59064c134c88dfac3b7f5273e40fe548d6a086cea452/pyjson5-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dba36677c77aed5d680827c65478933705ca156a34838f1250ea191ffc27662", size = 170075, upload-time = "2026-05-15T16:11:11.105Z" },183+ { url = "https://files.pythonhosted.org/packages/55/2a/0379aa5184e986d0f9af2919712c148d81928ce7d2d7950407e271b1df3b/pyjson5-2.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:04356d0c09e58907f60675d33859f269473479b90add5026231ef2fadba5207e", size = 164100, upload-time = "2026-05-15T16:11:12.909Z" },184+ { url = "https://files.pythonhosted.org/packages/15/d5/2087af69695c28c7fe796afc73dec7f0b0b9bc123ec329e45928d58d4705/pyjson5-2.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b331a0fbe2ae26f4ccb88045ebaecc00aeeeab23ff858f9a2223cedd969ec6d6", size = 184674, upload-time = "2026-05-15T16:11:14.448Z" },185+ { url = "https://files.pythonhosted.org/packages/2a/06/cf1b2744e07f1689cb0ac663e162a060b81cd358474754fe9f3bf02d5398/pyjson5-2.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ca55878d923ba5764254ad1c020c22b33e6161b3555d1f457b8060f2b3c6c17", size = 193047, upload-time = "2026-05-15T16:11:15.969Z" },186+ { url = "https://files.pythonhosted.org/packages/54/e6/a6b0deb6a3f393907c6d9115a7ac8e27557109204b56b05d3b57c51fc2ca/pyjson5-2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35e18d11e4c2034b78a664affe18c153a8efbf8872cf6bed7f12dc4fb819d440", size = 178712, upload-time = "2026-05-15T16:11:17.364Z" },187+ { url = "https://files.pythonhosted.org/packages/6e/48/9e5c16daba56dfdb481baab85d1197e14ae3f403902fc23cdca14d14351b/pyjson5-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b710a7489b6e134890eaa1fdb285e9644a1e730c1cfcf1cd90a87859b87a84a9", size = 175642, upload-time = "2026-05-15T16:11:18.781Z" },188+ { url = "https://files.pythonhosted.org/packages/c3/32/f07d4dbd8cf733f9e3f3e9c9abedfbc23da1e5cfa7fed46aebae07ca6f25/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:08920c0dd6aba3fb6bc6c849e6f05731c5fb2716cd651de424ff60cdd12eae65", size = 1152219, upload-time = "2026-05-15T16:11:20.54Z" },189+ { url = "https://files.pythonhosted.org/packages/01/9d/0ddbb89d381bc27f3740850d5d664c1f6f76620ad879ff5eb2506a4dfd6c/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e166dadb3275025cff5ee8602131372a3ea171c6727b3fc6e44697348e3972c5", size = 1013305, upload-time = "2026-05-15T16:11:22.702Z" },190+ { url = "https://files.pythonhosted.org/packages/bb/93/0f44886391dd2249ad9cf98c2deb3f26d1a115dd5a215629af7455765968/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ea3c2b4e7b8e209e7f59bd6a925d79b69cd0a4ffa12f2df0c6af55514c8b2227", size = 1322840, upload-time = "2026-05-15T16:11:24.918Z" },191+ { url = "https://files.pythonhosted.org/packages/d4/29/c478dc24dfcfc07e407ff8669df265ecc51fed5e7d47e51a01f17fd1c53f/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b710fc8ce00c984c8865131f05a8536163e1d3caffb9d081647879eca7424670", size = 1245221, upload-time = "2026-05-15T16:11:26.873Z" },192+ { url = "https://files.pythonhosted.org/packages/e2/f0/5fef8c47c5b7052da79af6425a021040402a9a09b37c5687d91f98696a27/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9784d79275d1b06d95f2920c3f260ce5d3f6671f65a1fae1464f83fbfc834a0", size = 1182991, upload-time = "2026-05-15T16:11:29.258Z" },193+ { url = "https://files.pythonhosted.org/packages/90/33/4d3a9d3159cdff1f1886d39f1991e6c8d5927f9b606cfc9743b5bef98c2a/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0ef86c53d0e14991b5b0fe58f225e3d8faec437aa0a70da4762525b6bc2fca10", size = 1359104, upload-time = "2026-05-15T16:11:31.651Z" },194+ { url = "https://files.pythonhosted.org/packages/0f/04/20eb16c52453aea0af68a697f4058378c9ff871bd2f0bea28eb76ffe12f8/pyjson5-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1d9faccf5a9e86f14104eec31c13428ef8baeb867182ce107e0c68fcc5c62477", size = 1212847, upload-time = "2026-05-15T16:11:33.767Z" },195+ { url = "https://files.pythonhosted.org/packages/b9/a3/69bbe93275eafb7802a5b29230cc5c28f9b924c68d15b3ab2d43cfe19825/pyjson5-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:2df497af7ab03cf82d9278a4707a83d0ae4e6d727f919a883b5ccec3f7d92650", size = 139137, upload-time = "2026-05-15T16:11:35.487Z" },196+ { url = "https://files.pythonhosted.org/packages/48/45/b865ca6e0ae6887bc2b6b17cd123470ae43ac0fa04a2362d77d4b9f5bd43/pyjson5-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3b31cf4f4a4f01800812865af3b02f6700cefb9377e4d9a9c6b78fdcf41fd973", size = 169412, upload-time = "2026-05-15T16:11:36.887Z" },197+ { url = "https://files.pythonhosted.org/packages/68/55/02c734459fef0a955ab3e7e745df415d5f9fc873a4c288765f60f22fbc13/pyjson5-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:15f0d8baea89d35c6c01c60b944c778a79cac10f77f71b727f1b244e2c7a8ccb", size = 129061, upload-time = "2026-05-15T16:11:38.712Z" },198+]199+200+[[package]]201+name = "pyodide-cli"202+version = "0.5.0"203+source = { registry = "https://pypi.org/simple" }204+dependencies = [205+ { name = "click" },206+ { name = "rich" },207+]208+sdist = { url = "https://files.pythonhosted.org/packages/f1/44/fe74049e3e776bd56fae8bd9370900d6d6dc5e541299ae90cb2330a9a8a4/pyodide_cli-0.5.0.tar.gz", hash = "sha256:14df0797e791558b6976d4612f9df9619334f8914bfe3efbdd22e3886f5275b7", size = 12630, upload-time = "2026-01-28T03:29:06.623Z" }209+wheels = [210+ { url = "https://files.pythonhosted.org/packages/4a/07/76977bbd73c686df55fbc3685c6c336158dc11b2ba155abe3e3a802447b2/pyodide_cli-0.5.0-py3-none-any.whl", hash = "sha256:24c02736f1d6c99396f5e9330fd30bd98db88cf49ef059b62b025fa1f5f6e29c", size = 12684, upload-time = "2026-01-28T03:29:05.527Z" },211+]212+213+[[package]]214+name = "pyodide-py"215+version = "0.29.4"216+source = { registry = "https://pypi.org/simple" }217+resolution-markers = [218+ "python_full_version < '3.14'",219+]220+sdist = { url = "https://files.pythonhosted.org/packages/57/85/db25e6a10a2600cb96e4ebe423ada255aa738b991fd8843776ebe4f3173f/pyodide_py-0.29.4.tar.gz", hash = "sha256:2ef83817457364fd1a3dc42bfd0617350244776c8b9f7e37f17efcbc38e3bf0a", size = 58868, upload-time = "2026-05-07T15:14:45.97Z" }221+wheels = [222+ { url = "https://files.pythonhosted.org/packages/b2/03/7805e80a7d9824b03fe40b1c7c4192250f1ea231ee49a57ada62f736c506/pyodide_py-0.29.4-py3-none-any.whl", hash = "sha256:5df0615640e39d3b365da8b8fd653ec0af5361aef28275ded2218af773d766d1", size = 66013, upload-time = "2026-05-07T15:14:44.637Z" },223+]224+225+[[package]]226+name = "pyodide-py"227+version = "314.0.5"228+source = { registry = "https://pypi.org/simple" }229+resolution-markers = [230+ "python_full_version >= '3.14'",231+]232+sdist = { url = "https://files.pythonhosted.org/packages/95/ff/22556c4c96d2ddb5e2d14f5f36182b1cc900bf6f8f277a887b923d13adc9/pyodide_py-314.0.5.tar.gz", hash = "sha256:4e8cb554205e3237eee60ded3665014eef62fac64a8ccb412eba277ec01cc857", size = 62814, upload-time = "2026-08-17T06:54:49.481Z" }233+wheels = [234+ { url = "https://files.pythonhosted.org/packages/30/d3/8cc37a4df9a8214643066b37443dd878d6dc779a5b9d8e1eff5dc7b7f61c/pyodide_py-314.0.5-py3-none-any.whl", hash = "sha256:030b70fb2d370a898f5416119d5329faf720d54393d6b9917fcb43a332084f51", size = 70396, upload-time = "2026-08-17T06:54:48.257Z" },235+]236+237+[[package]]238+name = "pytest"239+version = "8.4.2"240+source = { registry = "https://pypi.org/simple" }241+dependencies = [242+ { name = "colorama", marker = "sys_platform == 'win32'" },243+ { name = "iniconfig" },244+ { name = "packaging" },245+ { name = "pluggy" },246+ { name = "pygments" },247+]248+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }249+wheels = [250+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },251+]252+253+[[package]]254+name = "pyyaml"255+version = "6.0.3"256+source = { registry = "https://pypi.org/simple" }257+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }258+wheels = [259+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },260+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },261+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },262+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },263+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },264+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },265+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },266+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },267+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },268+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },269+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },270+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },271+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },272+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },273+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },274+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },275+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },276+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },277+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },278+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },279+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },280+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },281+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },282+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },283+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },284+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },285+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },286+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },287+]288+289+[[package]]290+name = "rich"291+version = "15.0.0"292+source = { registry = "https://pypi.org/simple" }293+dependencies = [294+ { name = "markdown-it-py" },295+ { name = "pygments" },296+]297+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }298+wheels = [299+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },300+]301+302+[[package]]303+name = "workers-py"304+version = "1.16.5"305+source = { registry = "https://pypi.org/simple" }306+dependencies = [307+ { name = "click" },308+ { name = "pyjson5" },309+ { name = "pyodide-cli" },310+ { name = "pyodide-py", version = "0.29.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },311+ { name = "pyodide-py", version = "314.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },312+ { name = "rich" },313+ { name = "workers-runtime-sdk" },314+]315+sdist = { url = "https://files.pythonhosted.org/packages/4d/5e/382cef4f005ebd19014b6ef5f088fcdb93037e86fd6f8bb0a4656b0a7610/workers_py-1.16.5.tar.gz", hash = "sha256:06496685290cdc4eae4175d0ef968b9d7ec12ef2d3db118ad2716fe154af3af9", size = 69010, upload-time = "2026-08-19T04:41:07.317Z" }316+wheels = [317+ { url = "https://files.pythonhosted.org/packages/7e/55/25b5c5e798f2581be9cd5c87b3c2c588ed93b71ee7c8c53863309621b460/workers_py-1.16.5-py3-none-any.whl", hash = "sha256:5fd6814bb502dc2c42f0bb3fab398e12dc0a8c20e4d42a2a90cd6fbda0e5bca5", size = 16939, upload-time = "2026-08-19T04:41:06.161Z" },318+]319+320+[[package]]321+name = "workers-runtime-sdk"322+version = "1.6.14"323+source = { registry = "https://pypi.org/simple" }324+sdist = { url = "https://files.pythonhosted.org/packages/cd/20/e2ecb82b0c7532437a07fa13369b83340573aa0167c58cca401e3c1ff696/workers_runtime_sdk-1.6.14.tar.gz", hash = "sha256:0905ac15a1d0bd00bfaac4762721f3c46b218aa0f8d6a2aab9b91d4bb76e3d3c", size = 128807, upload-time = "2026-08-19T14:54:25.917Z" }325+wheels = [326+ { url = "https://files.pythonhosted.org/packages/fa/95/960a9a940825c52eae52c316e8f23f91f9d92c5f0e3bb5400097c43c2747/workers_runtime_sdk-1.6.14-py3-none-any.whl", hash = "sha256:c66a7278a93198b36fa3f1d61e5ac071e9a0d5e57a3f4fadcab9cc5107e587b4", size = 34805, upload-time = "2026-08-19T14:54:24.674Z" },327+]
A diagramzip-python-units/wireviz-translator/vendor-licenses/WireViz-LICENSE +674 -0
@@ -0,0 +1,674 @@
1+ GNU GENERAL PUBLIC LICENSE2+ Version 3, 29 June 20073+4+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>5+ Everyone is permitted to copy and distribute verbatim copies6+ of this license document, but changing it is not allowed.7+8+ Preamble9+10+ The GNU General Public License is a free, copyleft license for11+software and other kinds of works.12+13+ The licenses for most software and other practical works are designed14+to take away your freedom to share and change the works. By contrast,15+the GNU General Public License is intended to guarantee your freedom to16+share and change all versions of a program--to make sure it remains free17+software for all its users. We, the Free Software Foundation, use the18+GNU General Public License for most of our software; it applies also to19+any other work released this way by its authors. You can apply it to20+your programs, too.21+22+ When we speak of free software, we are referring to freedom, not23+price. Our General Public Licenses are designed to make sure that you24+have the freedom to distribute copies of free software (and charge for25+them if you wish), that you receive source code or can get it if you26+want it, that you can change the software or use pieces of it in new27+free programs, and that you know you can do these things.28+29+ To protect your rights, we need to prevent others from denying you30+these rights or asking you to surrender the rights. Therefore, you have31+certain responsibilities if you distribute copies of the software, or if32+you modify it: responsibilities to respect the freedom of others.33+34+ For example, if you distribute copies of such a program, whether35+gratis or for a fee, you must pass on to the recipients the same36+freedoms that you received. You must make sure that they, too, receive37+or can get the source code. And you must show them these terms so they38+know their rights.39+40+ Developers that use the GNU GPL protect your rights with two steps:41+(1) assert copyright on the software, and (2) offer you this License42+giving you legal permission to copy, distribute and/or modify it.43+44+ For the developers' and authors' protection, the GPL clearly explains45+that there is no warranty for this free software. For both users' and46+authors' sake, the GPL requires that modified versions be marked as47+changed, so that their problems will not be attributed erroneously to48+authors of previous versions.49+50+ Some devices are designed to deny users access to install or run51+modified versions of the software inside them, although the manufacturer52+can do so. This is fundamentally incompatible with the aim of53+protecting users' freedom to change the software. The systematic54+pattern of such abuse occurs in the area of products for individuals to55+use, which is precisely where it is most unacceptable. Therefore, we56+have designed this version of the GPL to prohibit the practice for those57+products. If such problems arise substantially in other domains, we58+stand ready to extend this provision to those domains in future versions59+of the GPL, as needed to protect the freedom of users.60+61+ Finally, every program is threatened constantly by software patents.62+States should not allow patents to restrict development and use of63+software on general-purpose computers, but in those that do, we wish to64+avoid the special danger that patents applied to a free program could65+make it effectively proprietary. To prevent this, the GPL assures that66+patents cannot be used to render the program non-free.67+68+ The precise terms and conditions for copying, distribution and69+modification follow.70+71+ TERMS AND CONDITIONS72+73+ 0. Definitions.74+75+ "This License" refers to version 3 of the GNU General Public License.76+77+ "Copyright" also means copyright-like laws that apply to other kinds of78+works, such as semiconductor masks.79+80+ "The Program" refers to any copyrightable work licensed under this81+License. Each licensee is addressed as "you". "Licensees" and82+"recipients" may be individuals or organizations.83+84+ To "modify" a work means to copy from or adapt all or part of the work85+in a fashion requiring copyright permission, other than the making of an86+exact copy. The resulting work is called a "modified version" of the87+earlier work or a work "based on" the earlier work.88+89+ A "covered work" means either the unmodified Program or a work based90+on the Program.91+92+ To "propagate" a work means to do anything with it that, without93+permission, would make you directly or secondarily liable for94+infringement under applicable copyright law, except executing it on a95+computer or modifying a private copy. Propagation includes copying,96+distribution (with or without modification), making available to the97+public, and in some countries other activities as well.98+99+ To "convey" a work means any kind of propagation that enables other100+parties to make or receive copies. Mere interaction with a user through101+a computer network, with no transfer of a copy, is not conveying.102+103+ An interactive user interface displays "Appropriate Legal Notices"104+to the extent that it includes a convenient and prominently visible105+feature that (1) displays an appropriate copyright notice, and (2)106+tells the user that there is no warranty for the work (except to the107+extent that warranties are provided), that licensees may convey the108+work under this License, and how to view a copy of this License. If109+the interface presents a list of user commands or options, such as a110+menu, a prominent item in the list meets this criterion.111+112+ 1. Source Code.113+114+ The "source code" for a work means the preferred form of the work115+for making modifications to it. "Object code" means any non-source116+form of a work.117+118+ A "Standard Interface" means an interface that either is an official119+standard defined by a recognized standards body, or, in the case of120+interfaces specified for a particular programming language, one that121+is widely used among developers working in that language.122+123+ The "System Libraries" of an executable work include anything, other124+than the work as a whole, that (a) is included in the normal form of125+packaging a Major Component, but which is not part of that Major126+Component, and (b) serves only to enable use of the work with that127+Major Component, or to implement a Standard Interface for which an128+implementation is available to the public in source code form. A129+"Major Component", in this context, means a major essential component130+(kernel, window system, and so on) of the specific operating system131+(if any) on which the executable work runs, or a compiler used to132+produce the work, or an object code interpreter used to run it.133+134+ The "Corresponding Source" for a work in object code form means all135+the source code needed to generate, install, and (for an executable136+work) run the object code and to modify the work, including scripts to137+control those activities. However, it does not include the work's138+System Libraries, or general-purpose tools or generally available free139+programs which are used unmodified in performing those activities but140+which are not part of the work. For example, Corresponding Source141+includes interface definition files associated with source files for142+the work, and the source code for shared libraries and dynamically143+linked subprograms that the work is specifically designed to require,144+such as by intimate data communication or control flow between those145+subprograms and other parts of the work.146+147+ The Corresponding Source need not include anything that users148+can regenerate automatically from other parts of the Corresponding149+Source.150+151+ The Corresponding Source for a work in source code form is that152+same work.153+154+ 2. Basic Permissions.155+156+ All rights granted under this License are granted for the term of157+copyright on the Program, and are irrevocable provided the stated158+conditions are met. This License explicitly affirms your unlimited159+permission to run the unmodified Program. The output from running a160+covered work is covered by this License only if the output, given its161+content, constitutes a covered work. This License acknowledges your162+rights of fair use or other equivalent, as provided by copyright law.163+164+ You may make, run and propagate covered works that you do not165+convey, without conditions so long as your license otherwise remains166+in force. You may convey covered works to others for the sole purpose167+of having them make modifications exclusively for you, or provide you168+with facilities for running those works, provided that you comply with169+the terms of this License in conveying all material for which you do170+not control copyright. Those thus making or running the covered works171+for you must do so exclusively on your behalf, under your direction172+and control, on terms that prohibit them from making any copies of173+your copyrighted material outside their relationship with you.174+175+ Conveying under any other circumstances is permitted solely under176+the conditions stated below. Sublicensing is not allowed; section 10177+makes it unnecessary.178+179+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.180+181+ No covered work shall be deemed part of an effective technological182+measure under any applicable law fulfilling obligations under article183+11 of the WIPO copyright treaty adopted on 20 December 1996, or184+similar laws prohibiting or restricting circumvention of such185+measures.186+187+ When you convey a covered work, you waive any legal power to forbid188+circumvention of technological measures to the extent such circumvention189+is effected by exercising rights under this License with respect to190+the covered work, and you disclaim any intention to limit operation or191+modification of the work as a means of enforcing, against the work's192+users, your or third parties' legal rights to forbid circumvention of193+technological measures.194+195+ 4. Conveying Verbatim Copies.196+197+ You may convey verbatim copies of the Program's source code as you198+receive it, in any medium, provided that you conspicuously and199+appropriately publish on each copy an appropriate copyright notice;200+keep intact all notices stating that this License and any201+non-permissive terms added in accord with section 7 apply to the code;202+keep intact all notices of the absence of any warranty; and give all203+recipients a copy of this License along with the Program.204+205+ You may charge any price or no price for each copy that you convey,206+and you may offer support or warranty protection for a fee.207+208+ 5. Conveying Modified Source Versions.209+210+ You may convey a work based on the Program, or the modifications to211+produce it from the Program, in the form of source code under the212+terms of section 4, provided that you also meet all of these conditions:213+214+ a) The work must carry prominent notices stating that you modified215+ it, and giving a relevant date.216+217+ b) The work must carry prominent notices stating that it is218+ released under this License and any conditions added under section219+ 7. This requirement modifies the requirement in section 4 to220+ "keep intact all notices".221+222+ c) You must license the entire work, as a whole, under this223+ License to anyone who comes into possession of a copy. This224+ License will therefore apply, along with any applicable section 7225+ additional terms, to the whole of the work, and all its parts,226+ regardless of how they are packaged. This License gives no227+ permission to license the work in any other way, but it does not228+ invalidate such permission if you have separately received it.229+230+ d) If the work has interactive user interfaces, each must display231+ Appropriate Legal Notices; however, if the Program has interactive232+ interfaces that do not display Appropriate Legal Notices, your233+ work need not make them do so.234+235+ A compilation of a covered work with other separate and independent236+works, which are not by their nature extensions of the covered work,237+and which are not combined with it such as to form a larger program,238+in or on a volume of a storage or distribution medium, is called an239+"aggregate" if the compilation and its resulting copyright are not240+used to limit the access or legal rights of the compilation's users241+beyond what the individual works permit. Inclusion of a covered work242+in an aggregate does not cause this License to apply to the other243+parts of the aggregate.244+245+ 6. Conveying Non-Source Forms.246+247+ You may convey a covered work in object code form under the terms248+of sections 4 and 5, provided that you also convey the249+machine-readable Corresponding Source under the terms of this License,250+in one of these ways:251+252+ a) Convey the object code in, or embodied in, a physical product253+ (including a physical distribution medium), accompanied by the254+ Corresponding Source fixed on a durable physical medium255+ customarily used for software interchange.256+257+ b) Convey the object code in, or embodied in, a physical product258+ (including a physical distribution medium), accompanied by a259+ written offer, valid for at least three years and valid for as260+ long as you offer spare parts or customer support for that product261+ model, to give anyone who possesses the object code either (1) a262+ copy of the Corresponding Source for all the software in the263+ product that is covered by this License, on a durable physical264+ medium customarily used for software interchange, for a price no265+ more than your reasonable cost of physically performing this266+ conveying of source, or (2) access to copy the267+ Corresponding Source from a network server at no charge.268+269+ c) Convey individual copies of the object code with a copy of the270+ written offer to provide the Corresponding Source. This271+ alternative is allowed only occasionally and noncommercially, and272+ only if you received the object code with such an offer, in accord273+ with subsection 6b.274+275+ d) Convey the object code by offering access from a designated276+ place (gratis or for a charge), and offer equivalent access to the277+ Corresponding Source in the same way through the same place at no278+ further charge. You need not require recipients to copy the279+ Corresponding Source along with the object code. If the place to280+ copy the object code is a network server, the Corresponding Source281+ may be on a different server (operated by you or a third party)282+ that supports equivalent copying facilities, provided you maintain283+ clear directions next to the object code saying where to find the284+ Corresponding Source. Regardless of what server hosts the285+ Corresponding Source, you remain obligated to ensure that it is286+ available for as long as needed to satisfy these requirements.287+288+ e) Convey the object code using peer-to-peer transmission, provided289+ you inform other peers where the object code and Corresponding290+ Source of the work are being offered to the general public at no291+ charge under subsection 6d.292+293+ A separable portion of the object code, whose source code is excluded294+from the Corresponding Source as a System Library, need not be295+included in conveying the object code work.296+297+ A "User Product" is either (1) a "consumer product", which means any298+tangible personal property which is normally used for personal, family,299+or household purposes, or (2) anything designed or sold for incorporation300+into a dwelling. In determining whether a product is a consumer product,301+doubtful cases shall be resolved in favor of coverage. For a particular302+product received by a particular user, "normally used" refers to a303+typical or common use of that class of product, regardless of the status304+of the particular user or of the way in which the particular user305+actually uses, or expects or is expected to use, the product. A product306+is a consumer product regardless of whether the product has substantial307+commercial, industrial or non-consumer uses, unless such uses represent308+the only significant mode of use of the product.309+310+ "Installation Information" for a User Product means any methods,311+procedures, authorization keys, or other information required to install312+and execute modified versions of a covered work in that User Product from313+a modified version of its Corresponding Source. The information must314+suffice to ensure that the continued functioning of the modified object315+code is in no case prevented or interfered with solely because316+modification has been made.317+318+ If you convey an object code work under this section in, or with, or319+specifically for use in, a User Product, and the conveying occurs as320+part of a transaction in which the right of possession and use of the321+User Product is transferred to the recipient in perpetuity or for a322+fixed term (regardless of how the transaction is characterized), the323+Corresponding Source conveyed under this section must be accompanied324+by the Installation Information. But this requirement does not apply325+if neither you nor any third party retains the ability to install326+modified object code on the User Product (for example, the work has327+been installed in ROM).328+329+ The requirement to provide Installation Information does not include a330+requirement to continue to provide support service, warranty, or updates331+for a work that has been modified or installed by the recipient, or for332+the User Product in which it has been modified or installed. Access to a333+network may be denied when the modification itself materially and334+adversely affects the operation of the network or violates the rules and335+protocols for communication across the network.336+337+ Corresponding Source conveyed, and Installation Information provided,338+in accord with this section must be in a format that is publicly339+documented (and with an implementation available to the public in340+source code form), and must require no special password or key for341+unpacking, reading or copying.342+343+ 7. Additional Terms.344+345+ "Additional permissions" are terms that supplement the terms of this346+License by making exceptions from one or more of its conditions.347+Additional permissions that are applicable to the entire Program shall348+be treated as though they were included in this License, to the extent349+that they are valid under applicable law. If additional permissions350+apply only to part of the Program, that part may be used separately351+under those permissions, but the entire Program remains governed by352+this License without regard to the additional permissions.353+354+ When you convey a copy of a covered work, you may at your option355+remove any additional permissions from that copy, or from any part of356+it. (Additional permissions may be written to require their own357+removal in certain cases when you modify the work.) You may place358+additional permissions on material, added by you to a covered work,359+for which you have or can give appropriate copyright permission.360+361+ Notwithstanding any other provision of this License, for material you362+add to a covered work, you may (if authorized by the copyright holders of363+that material) supplement the terms of this License with terms:364+365+ a) Disclaiming warranty or limiting liability differently from the366+ terms of sections 15 and 16 of this License; or367+368+ b) Requiring preservation of specified reasonable legal notices or369+ author attributions in that material or in the Appropriate Legal370+ Notices displayed by works containing it; or371+372+ c) Prohibiting misrepresentation of the origin of that material, or373+ requiring that modified versions of such material be marked in374+ reasonable ways as different from the original version; or375+376+ d) Limiting the use for publicity purposes of names of licensors or377+ authors of the material; or378+379+ e) Declining to grant rights under trademark law for use of some380+ trade names, trademarks, or service marks; or381+382+ f) Requiring indemnification of licensors and authors of that383+ material by anyone who conveys the material (or modified versions of384+ it) with contractual assumptions of liability to the recipient, for385+ any liability that these contractual assumptions directly impose on386+ those licensors and authors.387+388+ All other non-permissive additional terms are considered "further389+restrictions" within the meaning of section 10. If the Program as you390+received it, or any part of it, contains a notice stating that it is391+governed by this License along with a term that is a further392+restriction, you may remove that term. If a license document contains393+a further restriction but permits relicensing or conveying under this394+License, you may add to a covered work material governed by the terms395+of that license document, provided that the further restriction does396+not survive such relicensing or conveying.397+398+ If you add terms to a covered work in accord with this section, you399+must place, in the relevant source files, a statement of the400+additional terms that apply to those files, or a notice indicating401+where to find the applicable terms.402+403+ Additional terms, permissive or non-permissive, may be stated in the404+form of a separately written license, or stated as exceptions;405+the above requirements apply either way.406+407+ 8. Termination.408+409+ You may not propagate or modify a covered work except as expressly410+provided under this License. Any attempt otherwise to propagate or411+modify it is void, and will automatically terminate your rights under412+this License (including any patent licenses granted under the third413+paragraph of section 11).414+415+ However, if you cease all violation of this License, then your416+license from a particular copyright holder is reinstated (a)417+provisionally, unless and until the copyright holder explicitly and418+finally terminates your license, and (b) permanently, if the copyright419+holder fails to notify you of the violation by some reasonable means420+prior to 60 days after the cessation.421+422+ Moreover, your license from a particular copyright holder is423+reinstated permanently if the copyright holder notifies you of the424+violation by some reasonable means, this is the first time you have425+received notice of violation of this License (for any work) from that426+copyright holder, and you cure the violation prior to 30 days after427+your receipt of the notice.428+429+ Termination of your rights under this section does not terminate the430+licenses of parties who have received copies or rights from you under431+this License. If your rights have been terminated and not permanently432+reinstated, you do not qualify to receive new licenses for the same433+material under section 10.434+435+ 9. Acceptance Not Required for Having Copies.436+437+ You are not required to accept this License in order to receive or438+run a copy of the Program. Ancillary propagation of a covered work439+occurring solely as a consequence of using peer-to-peer transmission440+to receive a copy likewise does not require acceptance. However,441+nothing other than this License grants you permission to propagate or442+modify any covered work. These actions infringe copyright if you do443+not accept this License. Therefore, by modifying or propagating a444+covered work, you indicate your acceptance of this License to do so.445+446+ 10. Automatic Licensing of Downstream Recipients.447+448+ Each time you convey a covered work, the recipient automatically449+receives a license from the original licensors, to run, modify and450+propagate that work, subject to this License. You are not responsible451+for enforcing compliance by third parties with this License.452+453+ An "entity transaction" is a transaction transferring control of an454+organization, or substantially all assets of one, or subdividing an455+organization, or merging organizations. If propagation of a covered456+work results from an entity transaction, each party to that457+transaction who receives a copy of the work also receives whatever458+licenses to the work the party's predecessor in interest had or could459+give under the previous paragraph, plus a right to possession of the460+Corresponding Source of the work from the predecessor in interest, if461+the predecessor has it or can get it with reasonable efforts.462+463+ You may not impose any further restrictions on the exercise of the464+rights granted or affirmed under this License. For example, you may465+not impose a license fee, royalty, or other charge for exercise of466+rights granted under this License, and you may not initiate litigation467+(including a cross-claim or counterclaim in a lawsuit) alleging that468+any patent claim is infringed by making, using, selling, offering for469+sale, or importing the Program or any portion of it.470+471+ 11. Patents.472+473+ A "contributor" is a copyright holder who authorizes use under this474+License of the Program or a work on which the Program is based. The475+work thus licensed is called the contributor's "contributor version".476+477+ A contributor's "essential patent claims" are all patent claims478+owned or controlled by the contributor, whether already acquired or479+hereafter acquired, that would be infringed by some manner, permitted480+by this License, of making, using, or selling its contributor version,481+but do not include claims that would be infringed only as a482+consequence of further modification of the contributor version. For483+purposes of this definition, "control" includes the right to grant484+patent sublicenses in a manner consistent with the requirements of485+this License.486+487+ Each contributor grants you a non-exclusive, worldwide, royalty-free488+patent license under the contributor's essential patent claims, to489+make, use, sell, offer for sale, import and otherwise run, modify and490+propagate the contents of its contributor version.491+492+ In the following three paragraphs, a "patent license" is any express493+agreement or commitment, however denominated, not to enforce a patent494+(such as an express permission to practice a patent or covenant not to495+sue for patent infringement). To "grant" such a patent license to a496+party means to make such an agreement or commitment not to enforce a497+patent against the party.498+499+ If you convey a covered work, knowingly relying on a patent license,500+and the Corresponding Source of the work is not available for anyone501+to copy, free of charge and under the terms of this License, through a502+publicly available network server or other readily accessible means,503+then you must either (1) cause the Corresponding Source to be so504+available, or (2) arrange to deprive yourself of the benefit of the505+patent license for this particular work, or (3) arrange, in a manner506+consistent with the requirements of this License, to extend the patent507+license to downstream recipients. "Knowingly relying" means you have508+actual knowledge that, but for the patent license, your conveying the509+covered work in a country, or your recipient's use of the covered work510+in a country, would infringe one or more identifiable patents in that511+country that you have reason to believe are valid.512+513+ If, pursuant to or in connection with a single transaction or514+arrangement, you convey, or propagate by procuring conveyance of, a515+covered work, and grant a patent license to some of the parties516+receiving the covered work authorizing them to use, propagate, modify517+or convey a specific copy of the covered work, then the patent license518+you grant is automatically extended to all recipients of the covered519+work and works based on it.520+521+ A patent license is "discriminatory" if it does not include within522+the scope of its coverage, prohibits the exercise of, or is523+conditioned on the non-exercise of one or more of the rights that are524+specifically granted under this License. You may not convey a covered525+work if you are a party to an arrangement with a third party that is526+in the business of distributing software, under which you make payment527+to the third party based on the extent of your activity of conveying528+the work, and under which the third party grants, to any of the529+parties who would receive the covered work from you, a discriminatory530+patent license (a) in connection with copies of the covered work531+conveyed by you (or copies made from those copies), or (b) primarily532+for and in connection with specific products or compilations that533+contain the covered work, unless you entered into that arrangement,534+or that patent license was granted, prior to 28 March 2007.535+536+ Nothing in this License shall be construed as excluding or limiting537+any implied license or other defenses to infringement that may538+otherwise be available to you under applicable patent law.539+540+ 12. No Surrender of Others' Freedom.541+542+ If conditions are imposed on you (whether by court order, agreement or543+otherwise) that contradict the conditions of this License, they do not544+excuse you from the conditions of this License. If you cannot convey a545+covered work so as to satisfy simultaneously your obligations under this546+License and any other pertinent obligations, then as a consequence you may547+not convey it at all. For example, if you agree to terms that obligate you548+to collect a royalty for further conveying from those to whom you convey549+the Program, the only way you could satisfy both those terms and this550+License would be to refrain entirely from conveying the Program.551+552+ 13. Use with the GNU Affero General Public License.553+554+ Notwithstanding any other provision of this License, you have555+permission to link or combine any covered work with a work licensed556+under version 3 of the GNU Affero General Public License into a single557+combined work, and to convey the resulting work. The terms of this558+License will continue to apply to the part which is the covered work,559+but the special requirements of the GNU Affero General Public License,560+section 13, concerning interaction through a network will apply to the561+combination as such.562+563+ 14. Revised Versions of this License.564+565+ The Free Software Foundation may publish revised and/or new versions of566+the GNU General Public License from time to time. Such new versions will567+be similar in spirit to the present version, but may differ in detail to568+address new problems or concerns.569+570+ Each version is given a distinguishing version number. If the571+Program specifies that a certain numbered version of the GNU General572+Public License "or any later version" applies to it, you have the573+option of following the terms and conditions either of that numbered574+version or of any later version published by the Free Software575+Foundation. If the Program does not specify a version number of the576+GNU General Public License, you may choose any version ever published577+by the Free Software Foundation.578+579+ If the Program specifies that a proxy can decide which future580+versions of the GNU General Public License can be used, that proxy's581+public statement of acceptance of a version permanently authorizes you582+to choose that version for the Program.583+584+ Later license versions may give you additional or different585+permissions. However, no additional obligations are imposed on any586+author or copyright holder as a result of your choosing to follow a587+later version.588+589+ 15. Disclaimer of Warranty.590+591+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY592+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT593+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY594+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,595+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR596+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM597+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF598+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.599+600+ 16. Limitation of Liability.601+602+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING603+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS604+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY605+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE606+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF607+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD608+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),609+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF610+SUCH DAMAGES.611+612+ 17. Interpretation of Sections 15 and 16.613+614+ If the disclaimer of warranty and limitation of liability provided615+above cannot be given local legal effect according to their terms,616+reviewing courts shall apply local law that most closely approximates617+an absolute waiver of all civil liability in connection with the618+Program, unless a warranty or assumption of liability accompanies a619+copy of the Program in return for a fee.620+621+ END OF TERMS AND CONDITIONS622+623+ How to Apply These Terms to Your New Programs624+625+ If you develop a new program, and you want it to be of the greatest626+possible use to the public, the best way to achieve this is to make it627+free software which everyone can redistribute and change under these terms.628+629+ To do so, attach the following notices to the program. It is safest630+to attach them to the start of each source file to most effectively631+state the exclusion of warranty; and each file should have at least632+the "copyright" line and a pointer to where the full notice is found.633+634+ <one line to give the program's name and a brief idea of what it does.>635+ Copyright (C) <year> <name of author>636+637+ This program is free software: you can redistribute it and/or modify638+ it under the terms of the GNU General Public License as published by639+ the Free Software Foundation, either version 3 of the License, or640+ (at your option) any later version.641+642+ This program is distributed in the hope that it will be useful,643+ but WITHOUT ANY WARRANTY; without even the implied warranty of644+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the645+ GNU General Public License for more details.646+647+ You should have received a copy of the GNU General Public License648+ along with this program. If not, see <https://www.gnu.org/licenses/>.649+650+Also add information on how to contact you by electronic and paper mail.651+652+ If the program does terminal interaction, make it output a short653+notice like this when it starts in an interactive mode:654+655+ <program> Copyright (C) <year> <name of author>656+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.657+ This is free software, and you are welcome to redistribute it658+ under certain conditions; type `show c' for details.659+660+The hypothetical commands `show w' and `show c' should show the appropriate661+parts of the General Public License. Of course, your program's commands662+might be different; for a GUI interface, you would use an "about box".663+664+ You should also get your employer (if you work as a programmer) or school,665+if any, to sign a "copyright disclaimer" for the program, if necessary.666+For more information on this, and how to apply and follow the GNU GPL, see667+<https://www.gnu.org/licenses/>.668+669+ The GNU General Public License does not permit incorporating your program670+into proprietary programs. If your program is a subroutine library, you671+may consider it more useful to permit linking proprietary applications with672+the library. If this is what you want to do, use the GNU Lesser General673+Public License instead of this License. But first, please read674+<https://www.gnu.org/licenses/why-not-lgpl.html>.
A diagramzip-python-units/wireviz-translator/wrangler.jsonc +15 -0
M diagramzip-render/README.md +11 -5
@@ -19,6 +19,7 @@ npm run check
1919 npm test2020 npm run deploy:dry2121 npm run deploy:worker-units22+npm run deploy:python-units2223 npm run dev -- --port 87882324 npm run smoke2425 ```
@@ -48,24 +49,29 @@ hostname, so a caller cannot choose an unrelated engine in the body.
4849 4950 A unit may translate into another renderer. Translation is declared as an5051 ordered pipeline whose first stage is the deployable unit. Vega-Lite therefore51-reports `vega-family,vega`; `X-Diagram-Engine` still reports `vegalite`.52+reports `vega-family,vega`. WireViz preserves its upstream Python parser/model,53+emits DOT, and reports `wireviz,graphviz-family,graphviz`; service binding keeps54+the GraphViz dependency internal. `X-Diagram-Engine` still reports the selected55+catalog engine.5256 5357 ## Current runtime split5458 5559 | Runtime | Engines |5660 | --- | --- |5761 | Worker JavaScript | Bytefield, Nomnoml, Vega family (Vega and Vega-Lite → Vega), WaveDrom |58-| Worker Python | BlockDiag family (BlockDiag, SeqDiag, ActDiag, NwDiag, PacketDiag, RackDiag) |62+| Worker Python | BlockDiag family (BlockDiag, SeqDiag, ActDiag, NwDiag, PacketDiag, RackDiag); WireViz → DOT → GraphViz |5963 | Worker WebAssembly | GraphViz family (GraphViz and ERD → DOT → GraphViz) |6064 | Sandboxed browser unit | Mermaid, BPMN, Excalidraw |61-| Compatibility unit | 13 dependency units covering the remaining 14 engines |65+| Compatibility unit | 12 dependency units covering the remaining 13 engines |6266 63-The current split is 16/30 engines off Fly and 14/30 still dependent on it.67+The current split is 17/30 engines off Fly and 13/30 still dependent on it.6468 Compatibility units are extraction seams, not final runtimes or fallbacks for6569 an engine after cutover. DBML6670 currently proxies because its published package mixes module formats. GraphViz6771 uses a precompiled Worker module; ERD lowers its upstream-compatible source68-language to DOT and reuses the same in-process GraphViz runtime.72+language to DOT and reuses the same in-process GraphViz runtime. WireViz runs73+its vendored upstream parser and DOT composer in a separate Python Worker, then74+calls that same GraphViz deployment through a service binding.6975 7076 All returned SVG passes through the same sanitizer. Scripts, event handlers,7177 external resources, and active embedded HTML are removed. Mermaid's
M diagramzip-render/package.json +1 -0
M diagramzip-render/scripts/deploy-compatibility-units.mjs +1 -1
A diagramzip-render/scripts/deploy-python-units.mjs +16 -0
M diagramzip-render/scripts/deploy-worker-units.mjs +0 -1
M diagramzip-render/scripts/smoke-units.mjs +12 -0
@@ -45,10 +45,12 @@ const sharedUnits = {
4545 erd: 'graphviz-family',4646 vega: 'vega-family',4747 vegalite: 'vega-family',48+ wireviz: 'wireviz',4849 }4950 5051 const pythonEngines = new Set(['blockdiag', 'seqdiag', 'actdiag', 'nwdiag', 'packetdiag', 'rackdiag'])5152 const graphvizEngines = new Set(['graphviz', 'erd'])53+const wirevizEngines = new Set(['wireviz'])5254 5355 async function smoke([engine, filename]) {5456 const source = await readFile(resolve(fixtureDirectory, filename), 'utf8')
@@ -78,6 +80,8 @@ async function smoke([engine, filename]) {
7880 ? `${expectedUnit},graphviz`7981 : engine === 'erd'8082 ? `${expectedUnit},erd,graphviz`83+ : engine === 'wireviz'84+ ? `${expectedUnit},graphviz-family,graphviz`8185 : expectedUnit8286 if (pipeline !== expectedPipeline) {8387 throw new Error(`${engine}: unexpected pipeline ${pipeline}`)
@@ -98,6 +102,14 @@ async function smoke([engine, filename]) {
98102 throw new Error(`${engine}: unexpected GraphViz-family Worker build ${response.headers.get('X-Renderer-Build')}`)99103 }100104 }105+ if (wirevizEngines.has(engine)) {106+ if (response.headers.get('X-Diagram-Renderer') !== 'edge-python') {107+ throw new Error(`${engine}: expected edge-python renderer, received ${response.headers.get('X-Diagram-Renderer')}`)108+ }109+ if (!response.headers.get('X-Renderer-Build')?.startsWith('wireviz-0.3.2-python-dot-')) {110+ throw new Error(`${engine}: unexpected WireViz Worker build ${response.headers.get('X-Renderer-Build')}`)111+ }112+ }101113 return `${engine.padEnd(10)} ${response.headers.get('X-Diagram-Cache')?.padEnd(4)} ${body.length} bytes`102114 }103115
M diagramzip-render/src/catalog.ts +10 -1
@@ -39,7 +39,7 @@ const targetRuntime: Record<EngineId, EngineRuntime> = {
3939 vega: 'edge-js',4040 vegalite: 'edge-js',4141 wavedrom: 'edge-js',42- wireviz: 'origin',42+ wireviz: 'edge-python',4343 }4444 4545 const activeRuntime: Partial<Record<EngineId, EngineRuntime>> = {
@@ -59,6 +59,7 @@ const activeRuntime: Partial<Record<EngineId, EngineRuntime>> = {
5959 rackdiag: 'edge-python',6060 graphviz: 'edge-wasm',6161 erd: 'edge-wasm',62+ wireviz: 'edge-python',6263 }6364 6465 const versions: Partial<Record<EngineId, string>> = {
@@ -78,6 +79,7 @@ const versions: Partial<Record<EngineId, string>> = {
7879 rackdiag: 'rackdiag@3.0.0/python-worker-1',7980 graphviz: 'graphviz@15.1.1/edge-wasm-1',8081 erd: 'erd@0.2.1.0+graphviz@15.1.1/edge-wasm-1',82+ wireviz: 'wireviz@0.3.2/python-translator-1',8183 }8284 8385 const losses: Partial<Record<EngineId, readonly string[]>> = {
@@ -110,6 +112,13 @@ const losses: Partial<Record<EngineId, readonly string[]>> = {
110112 'Only the source language and Kroki rendering mode are supported; ERD CLI and filesystem configuration modes are not exposed.',111113 'The translated graph uses GraphViz 15.1.1 versus the compatibility image GraphViz 14.1.3; output differences are possible.',112114 ],115+ wireviz: [116+ 'Only SVG is supported; BOM and HTML sidecars are not exposed.',117+ 'Filesystem and remote images are rejected.',118+ 'tweak.append and tweak.override are rejected.',119+ 'The upstream source reports WireViz 0.3.2; the Dockerfile release label is 0.3.3.',120+ 'The translated graph uses GraphViz 15.1.1 versus the compatibility image GraphViz 14.1.3; output differences are possible.',121+ ],113122 }114123 115124 export const ENGINE_CATALOG: readonly EngineCatalogEntry[] = ENGINE_IDS.map(id => ({
M diagramzip-render/test/catalog.test.ts +6 -0
M diagramzip-render/test/gateway.test.ts +1 -1
D diagramzip-render/wrangler.compatibility/wireviz.jsonc +0 -1
M diagramzip/src/renderer-units.js +1 -1
M diagramzip/test/renderer-units.test.js +1 -1
M docs/refactor-engines.md +17 -8
@@ -90,9 +90,10 @@ Suggested deployable units:
9090 that accepts only residual origin engines.9191 - dependency-grouped JavaScript units: Bytefield, Nomnoml, WaveDrom, Vega,9292 and Vega-Lite, each reached only through its engine hostname.93-- `graphviz-family`: GraphViz and translators that lower to DOT, currently ERD and potentially WireViz after its own compatibility spike.93+- `graphviz-family`: GraphViz and its in-process ERD-to-DOT translator. WireViz has a separate Python translation unit and calls this family through a service binding.9494 - additional dependency-grouped WebAssembly units for D2, Pikchr, Svgbob, and GoAT only after their separate compatibility spikes.95-- `diagram-zip-render-python`: the upstream BlockDiag suite, isolated because Pyodide, Pillow, fonts, and the Python package graph have a different bundle and startup profile.95+- `blockdiag-family`: the upstream BlockDiag suite, isolated because Pyodide, Pillow, fonts, and its shared renderer registry form one dependency family.96+- `wireviz`: a separate Python YAML-to-DOT translator that depends on PyYAML and WireViz's model, then calls `graphviz-family` through a service binding.9697 - `diagram-zip-render-origin`: the remaining native/JVM/TeX renderers, initially consolidated in one Fly app or equivalent container group.9798 9899 Static assets should not be included in Worker script bundles. Before deploying, validate each bundle against the current Worker compressed-size, uncompressed-size, memory, CPU, and startup limits rather than relying on values copied into this design document.
@@ -378,7 +379,7 @@ This is substantial compatibility work, but it is bounded and removes Structuriz
378379 | Ditaa | Java/native ASCII-art recognition behavior | A compatible TypeScript ASCII renderer after a parity corpus exists |379380 | Symbolator | Python/native dependencies | Keep on origin until traffic justifies a rewrite |380381 | TikZ | Full TeX toolchain | Keep on origin; it is not a reasonable Worker port |381-| WireViz | Python/YAML plus Graphviz composition | TypeScript parser and intermediate representation targeting GraphViz WebAssembly |382+| WireViz | Vendored Python/YAML parser and DOT composition through the GraphViz-family service binding | Keep its dependency-isolated Python translator; replace only if Python Workers fail measured limits |382383 | GoAT | No proven maintained browser/Worker package | Go/WebAssembly spike; otherwise retain origin |383384 384385 The origin is a compatibility bridge, not the target architecture for standardized diagram families. It should be consolidated for organization and operational efficiency. A separate public Fly app per renderer is not a product requirement. One application can contain grouped machines or services, with internal routing and enough isolation for the few engines that need it. Split an engine only when its resource profile, security boundary, release cadence, or failure mode warrants independent deployment.
@@ -416,11 +417,11 @@ The origin is a compatibility bridge, not the target architecture for standardiz
416417 | Vega | Edge JavaScript with interpreter | Origin |417418 | Vega-Lite | Edge JavaScript with interpreter | Origin |418419 | WaveDrom | Edge JavaScript | Origin |419-| WireViz | Origin; rewrite candidate | Origin |420+| WireViz | Upstream Python parser/model/DOT composer → GraphViz service binding | None after cutover |420421 421422 The first coverage milestone puts all 30 catalog types behind one contract and keeps unsupported edge engines on the origin. Its first native cohort is Bytefield, Nomnoml, WaveDrom, Vega, and Vega-Lite. DBML remains on the origin because its published package mixes module formats. GraphViz now imports its WebAssembly as a precompiled Worker module, and ERD lowers into that same runtime. D2, Pikchr, and Svgbob move only after direct precompiled-Wasm adapters are proven. Seven engines can later move interactive preview into the browser: Mermaid, PlantUML, C4-PlantUML, BPMN, Excalidraw, diagrams.net, and UMLet.422423 423-The upstream-reuse milestone then moves all six BlockDiag-family types into one Python Worker, moves ERD to an upstream-guided TypeScript/GraphViz implementation, and compiles Structurizr to PlantUML in TypeScript. At that point the target split is 17 edge-rendered types, eight client/pipeline-rendered types, and five types requiring a compatibility origin: Ditaa, GoAT, Symbolator, TikZ, and WireViz. A successful GoAT WebAssembly adapter reduces the origin set to four.424+The upstream-reuse milestone moves all six BlockDiag-family types into one Python Worker, moves ERD to an upstream-guided TypeScript/GraphViz implementation, and preserves WireViz coverage in a dependency-isolated Python translator that reuses GraphViz through a service binding. Structurizr remains a later PlantUML translation target.424425 425426 ## Rendering contract426427
@@ -558,11 +559,13 @@ Use production traffic and latency data to choose the remaining rewrites. WireVi
558559 559560 ## Implementation status — 2026-08-20560561 561-The coverage plane is implemented across dedicated renderer hostnames. Sixteen562+The coverage plane is implemented across dedicated renderer hostnames. Seventeen562563 of 30 catalog engines no longer depend on Fly: five execute in JavaScript563564 Workers, six share the `blockdiag-family` Python Worker, GraphViz and ERD share564565 the `graphviz-family` WebAssembly Worker, and Mermaid, BPMN, and Excalidraw565-render in sandboxed browser units. The remaining 14 engines use 13566+render in sandboxed browser units. WireViz preserves its upstream Python parser567+and DOT composer, then calls GraphViz through an internal service binding. The568+remaining 13 engines use 12566569 dependency-grouped compatibility units. Repository and production smokes require567570 structurally valid SVG; pixel comparison remains intentionally deferred.568571
@@ -578,7 +581,13 @@ under real workerd, including cache behavior, presentation, HTML labels and
578581 ports, malformed input, and unsafe image rejection. The family dry-run is about579582 1.19 MB uncompressed and 0.47 MB compressed.580583 581-The same hard boundary now applies to all sixteen migrated engines: direct584+The WireViz unit vendors the actual `yuzutech/WireViz` v0.3.3 release source,585+whose own runtime version is 0.3.2, and stops before external `dot` execution.586+It rejects filesystem images and raw GraphViz tweaks, bounds YAML expansion,587+and forwards generated DOT to `graphviz-family`. BOM, HTML, and PNG sidecars588+remain outside the SVG rendering contract.589+590+The same hard boundary now applies to all seventeen migrated engines: direct582591 unit or client-renderer failure is surfaced to the editor, the gateway rejects583592 their engine IDs before consulting its cache or origin adapter, and every584593 catalog fallback is `null`.