From 07531041fba35816771ec52fa0a79d386f7f9c53 Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 14:22:22 -0500 Subject: [PATCH 01/15] fix(#1532): edge weight encodes cardinality, not master-part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Line weight is binary and encodes cardinality only: thick when the foreign key constitutes the child's entire primary key (1:1), thin when the child has primary-key attributes beyond those the FK contributes (multi-valued) — newly declared or inherited from another FK. penwidth already followed this via multi; remove the misleading master-part conflation in the layout weight and drive it from the same predicate so the two never diverge. Rename-safe: multi compares the child's referencing columns to the child primary key (both child-column space), so a renamed FK that is the child's whole PK is correctly 1:1/thick. Adds a guardrail test (1:1, multi, master-part, renamed-1:1). --- src/datajoint/diagram.py | 17 ++- tests/integration/test_diagram_edge_weight.py | 125 ++++++++++++++++++ 2 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 tests/integration/test_diagram_edge_weight.py diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index 20cd34b58..35ca02d65 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -1540,19 +1540,24 @@ def make_dot(self): # pydot edge — to_pydot stringifies the edge data, so booleans arrive # as "True"/"False". This is parallel-edge-safe: each FK between the # same pair of tables is its own pydot edge. - src = edge.get_source() - dest = edge.get_destination() primary = str(edge.get("primary")) == "True" multi = str(edge.get("multi")) == "True" aliased = str(edge.get("aliased")) == "True" # Renamed FK → distinct color; others → the usual translucent black. edge.set_color("#FF8800" if aliased else "#00000040") edge.set_style("solid" if primary else "dashed") - dest_node_type = graph.nodes[dest].get("node_type") - master_part = dest_node_type is Part and dest.startswith(src + ".") - edge.set_weight(3 if master_part else 1) - edge.set_arrowhead("none") + # Line weight encodes cardinality, and only cardinality. `multi` is + # True when the child has primary-key attributes beyond those this + # foreign key contributes — whether newly declared or inherited from + # another foreign key — i.e. a one-to-many dependency, drawn thin. + # When the foreign key constitutes the child's *entire* primary key + # the dependency is 1:1, drawn thick. Master-part is NOT a weight: a + # part almost always adds a key attribute, so its edge is thin under + # this same rule. penwidth is the visible thickness; the layout + # `weight` hint follows the same predicate so the two never diverge. edge.set_penwidth(0.75 if multi else 2) + edge.set_weight(1 if multi else 3) + edge.set_arrowhead("none") # Group nodes into schema clusters (always on) if schema_map: diff --git a/tests/integration/test_diagram_edge_weight.py b/tests/integration/test_diagram_edge_weight.py new file mode 100644 index 000000000..e787b270e --- /dev/null +++ b/tests/integration/test_diagram_edge_weight.py @@ -0,0 +1,125 @@ +""" +Guards the diagram edge-weight (cardinality) rule (#1532). + +Line weight encodes cardinality only, and it is binary: +- **thick** (penwidth 2): the foreign key constitutes the child's *entire* + primary key -> a 1:1 dependency. +- **thin** (penwidth 0.75): the child has primary-key attributes beyond those + the foreign key contributes (newly declared, or inherited from another foreign + key) -> a one-to-many dependency. + +Master-part is NOT a weight: a part almost always adds a key attribute, so its +edge is thin under this same rule. This test pins that, since the historical +documentation inverted it ("thick = master-part"). +""" + +import time + +import pytest + +import datajoint as dj + +THICK = 2.0 +THIN = 0.75 + + +@pytest.fixture(scope="function") +def schema_by_backend(connection_by_backend, db_creds_by_backend): + backend = db_creds_by_backend["backend"] + test_id = str(int(time.time() * 1000))[-8:] + schema_name = f"djtest_edgewt_{backend}_{test_id}"[:64] + if connection_by_backend.is_connected: + try: + connection_by_backend.query( + f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}" + ) + except Exception: + pass + schema = dj.Schema(schema_name, connection=connection_by_backend) + yield schema + if connection_by_backend.is_connected: + try: + connection_by_backend.query( + f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}" + ) + except Exception: + pass + + +def _penwidth_by_dest(dot): + """Map each edge's destination-node tail -> penwidth (float).""" + out = {} + for edge in dot.get_edges(): + dest = edge.get_destination().strip('"').lower() + try: + pw = float(edge.get_penwidth()) + except (TypeError, ValueError): + pw = None + out.setdefault(dest, []).append((edge.get_source().strip('"').lower(), pw)) + return out + + +def _penwidth_for(edges_by_dest, dest_name): + matches = edges_by_dest.get(dest_name, []) + assert matches, f"no edge found into node {dest_name!r}; nodes: {list(edges_by_dest)}" + return matches + + +def test_edge_weight_encodes_cardinality(schema_by_backend): + if not dj.diagram.diagram_active: + pytest.skip("networkx/pydot not available") + + @schema_by_backend + class Parent(dj.Manual): + definition = """ + parent_id : int32 + """ + + class Part(dj.Part): + definition = """ + -> master + part_id : int32 + """ + + @schema_by_backend + class OneToOne(dj.Manual): + definition = """ + -> Parent + """ + + @schema_by_backend + class OneToMany(dj.Manual): + definition = """ + -> Parent + sub_id : int32 + """ + + @schema_by_backend + class RenamedOneToOne(dj.Manual): + # A renamed foreign key can still be 1:1: the renamed column is + # RenamedOneToOne's entire primary key, so the dependency is 1:1 -> thick. + # The rule must compare child columns to the child PK, not parent-PK + # names to child-PK names (which renaming would break). + definition = """ + -> Parent.proj(alt_parent_id='parent_id') + """ + + dot = dj.Diagram(schema_by_backend).make_dot() + edges = _penwidth_by_dest(dot) + + # 1:1 — the FK is OneToOne's entire primary key -> thick. + assert all( + pw == THICK for _, pw in _penwidth_for(edges, "onetoone") + ), f"1:1 dependency must be thick ({THICK}); edges={edges}" + # multi-valued — OneToMany adds `sub_id` -> thin. + assert all( + pw == THIN for _, pw in _penwidth_for(edges, "onetomany") + ), f"multi-valued dependency must be thin ({THIN}); edges={edges}" + # master -> part — the part adds `part_id` -> thin (NOT thick). + assert all( + pw == THIN for _, pw in _penwidth_for(edges, "parent.part") + ), f"master-part edge must be thin ({THIN}); it is not a 1:1 dependency; edges={edges}" + # renamed FK that is the child's whole primary key — still 1:1 -> thick. + assert all( + pw == THICK for _, pw in _penwidth_for(edges, "renamedonetoone") + ), f"a renamed 1:1 foreign key must be thick ({THICK}); the rule must be rename-safe; edges={edges}" From cafce6da5931cbd06845bb671a5af9e3e53a5534 Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 14:33:02 -0500 Subject: [PATCH 02/15] feat(#1532): modernize dj.Diagram rendering style Replace the alpha-blended Graphviz-default look with a readable, modern palette and typography (parts 2 and 4 of #1532): - Tier palette: each tier gets a fill / stroke / text triple (Manual green, Lookup slate, Imported blue, Computed red, Part near-white) in place of the alpha-blended primaries. Shape stays load-bearing and unchanged. - Rounded corners + generous label margins on box tiers; explicit Helvetica font (no more Times fallback). - Renamed-FK edges use a desaturated amber (#C77D3A) instead of vivid #FF8800; ordinary edges a light translucent slate. - Left-to-right, no arrowheads (already the config default). Entity-group clustering (master+parts) and a visual-regression fixture follow. --- src/datajoint/diagram.py | 64 +++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index 35ca02d65..e41fe0030 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -1411,62 +1411,81 @@ def make_dot(self): schema_map[node] = data["schema_name"] scale = 1.2 # scaling factor for fonts and boxes - label_props = { # http://matplotlib.org/examples/color/named_colors.html + # Modernized tier palette (#1532): each tier gets a readable + # fill / stroke / text triple in place of the old alpha-blended primary + # fills. Shape stays load-bearing and unchanged so an existing diagram + # reads without relearning: Manual = rounded rectangle, Imported and + # Computed = ellipse, Lookup and Part = subtle (white/near-white) box. + label_props = { None: dict( shape="circle", - color="#FFFF0040", - fontcolor="yellow", + fill="#FFFDE7", + stroke="#C9BC5B", + fontcolor="#6B6420", fontsize=round(scale * 8), size=0.4 * scale, fixed=False, + rounded=False, ), Manual: dict( shape="box", - color="#00FF0030", - fontcolor="darkgreen", + fill="#E7F3EC", + stroke="#2F7D5B", + fontcolor="#1B5138", fontsize=round(scale * 10), size=0.4 * scale, fixed=False, + rounded=True, ), Lookup: dict( - shape="plaintext", - color="#00000020", - fontcolor="black", + shape="box", + fill="#F2F4F7", + stroke="#A9B1BD", + fontcolor="#495261", fontsize=round(scale * 8), size=0.4 * scale, fixed=False, + rounded=True, ), Computed: dict( shape="ellipse", - color="#FF000020", - fontcolor="#7F0000A0", + fill="#FBEAEC", + stroke="#B23A48", + fontcolor="#7C2430", fontsize=round(scale * 10), size=0.4 * scale, fixed=False, + rounded=False, ), Imported: dict( shape="ellipse", - color="#00007F40", - fontcolor="#00007FA0", + fill="#E2ECFA", + stroke="#2A5FA5", + fontcolor="#123A6D", fontsize=round(scale * 10), size=0.4 * scale, fixed=False, + rounded=False, ), Part: dict( - shape="plaintext", - color="#00000000", - fontcolor="black", + shape="box", + fill="#FFFFFF", + stroke="#9AA6B8", + fontcolor="#46536B", fontsize=round(scale * 8), size=0.1 * scale, fixed=False, + rounded=True, ), "collapsed": dict( shape="box3d", - color="#80808060", + fill="#EDEEF0", + stroke="#808890", fontcolor="#404040", fontsize=round(scale * 10), size=0.5 * scale, fixed=False, + rounded=False, ), } # Build node_props, handling collapsed nodes specially @@ -1501,10 +1520,11 @@ def make_dot(self): node.set_fontsize(props["fontsize"]) node.set_fontcolor(props["fontcolor"]) node.set_shape(props["shape"]) - node.set_fontname("arial") + node.set_fontname("Helvetica") node.set_fixedsize("shape" if props["fixed"] else False) node.set_width(props["size"]) node.set_height(props["size"]) + node.set_margin("0.11,0.06") # generous label padding (inches) # Handle collapsed nodes specially node_data = graph.nodes.get(f'"{name}"', {}) @@ -1531,8 +1551,9 @@ def make_dot(self): if cluster_label and name.startswith(cluster_label + "."): display_name = name[len(cluster_label) + 1 :] node.set_label("<" + display_name + ">" if node.get("distinguished") == "True" else display_name) - node.set_color(props["color"]) - node.set_style("filled") + node.set_fillcolor(props["fill"]) + node.set_color(props["stroke"]) + node.set_style("rounded,filled" if props.get("rounded") else "filled") for edge in dot.get_edges(): # see https://graphviz.org/doc/info/attrs.html @@ -1543,8 +1564,9 @@ def make_dot(self): primary = str(edge.get("primary")) == "True" multi = str(edge.get("multi")) == "True" aliased = str(edge.get("aliased")) == "True" - # Renamed FK → distinct color; others → the usual translucent black. - edge.set_color("#FF8800" if aliased else "#00000040") + # Renamed FK → a distinct, desaturated amber consistent with the + # modernized palette (#1532); others → a light translucent slate. + edge.set_color("#C77D3A" if aliased else "#3A424F33") edge.set_style("solid" if primary else "dashed") # Line weight encodes cardinality, and only cardinality. `multi` is # True when the child has primary-key attributes beyond those this From dba2f0d0f78879641afee9b63be1301775df775f Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 14:40:28 -0500 Subject: [PATCH 03/15] feat(#1532): master-part entity clustering; drop 'ERD' naming Enclose each master and its parts in a nested, unlabeled entity cluster; part labels drop the master prefix (`C`, not `B.C`) since the enclosure carries membership. Master and its parts share a rank (horizontal = derivation, vertical = containment), except a part that depends on a sibling part, which is left off the rank so the intra-group chain descends. Also rename the diagram test module test_erd.py -> test_diagram.py and its identifiers, and scrub 'ERD' from a code comment: DataJoint diagrams are not ERDs. Adds a test asserting the entity cluster and the prefix-dropped part label. --- tests/integration/{test_erd.py => test_diagram.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/integration/{test_erd.py => test_diagram.py} (100%) diff --git a/tests/integration/test_erd.py b/tests/integration/test_diagram.py similarity index 100% rename from tests/integration/test_erd.py rename to tests/integration/test_diagram.py From 590677694d6b77ca39859fa35911cc39b4325f19 Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 14:41:04 -0500 Subject: [PATCH 04/15] feat(#1532): master-part entity clustering + drop ERD naming (content) --- src/datajoint/diagram.py | 87 ++++++++++++++++++++++++++----- tests/integration/test_diagram.py | 32 ++++++++---- 2 files changed, 96 insertions(+), 23 deletions(-) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index e41fe0030..4cc1948d5 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -558,7 +558,7 @@ def __getitem__(self, key): >>> trace["my_schema.Session"].to_dicts() # string index → FreeTable """ # Non-trace diagrams: defer to networkx adjacency lookup so existing - # `diagram[node_name]` patterns (used in diagram algebra, ERD tests) + # `diagram[node_name]` patterns (used in diagram algebra, diagram tests) # keep working. if getattr(self, "_mode", None) != "trace": return super().__getitem__(key) @@ -1095,15 +1095,17 @@ def _make_graph(self) -> nx.MultiDiGraph: nx.MultiDiGraph Graph with nodes relabeled to class names. """ - # mark "distinguished" tables, i.e. those that introduce new primary key - # attributes + # Mark tables that introduce a new schema dimension, i.e. that add a + # primary-key attribute of their own beyond what they inherit through + # foreign keys. These are drawn with an underlined label. ("Schema + # dimension" / "axis" is the documented term for such a table.) # Filter nodes_to_show to only include nodes that exist in the graph valid_nodes = self.nodes_to_show.intersection(set(self.nodes())) for name in valid_nodes: foreign_attributes = set( attr for p in self.in_edges(name, data=True) for attr in p[2]["attr_map"] if p[2]["primary"] ) - self.nodes[name]["distinguished"] = ( + self.nodes[name]["introduces_dimension"] = ( "primary_key" in self.nodes[name] and foreign_attributes < self.nodes[name]["primary_key"] ) # construct subgraph and rename nodes to class names. A MultiDiGraph is @@ -1513,6 +1515,22 @@ def make_dot(self): self._encapsulate_edge_attributes(graph) dot = nx.drawing.nx_pydot.to_pydot(graph) dot.set_rankdir(direction) + + # Master↔part grouping (#1532): map each part (class name "Master.Part") + # to its master ("Master"), and record which parts depend on a sibling + # part so an intra-group chain can descend rather than share a rank. + part_master = {} + for gname, gdata in graph.nodes(data=True): + if gdata.get("node_type") is Part: + pn = gname.strip('"') + part_master[pn] = pn.rsplit(".", 1)[0] + part_names = set(part_master) + depends_on_sibling = set() + for pn, mn in part_master.items(): + for pred in graph.predecessors(f'"{pn}"'): + if pred.strip('"') in part_names and part_master.get(pred.strip('"')) == mn: + depends_on_sibling.add(pn) + for node in dot.get_nodes(): node.set_shape("circle") name = node.get_name().strip('"') @@ -1545,12 +1563,17 @@ def make_dot(self): node.set_tooltip(" ".join(description)) # Strip module prefix from label if it matches the cluster label display_name = name - schema_name = schema_map.get(name) - if schema_name and "." in name: - cluster_label = cluster_labels.get(schema_name) - if cluster_label and name.startswith(cluster_label + "."): - display_name = name[len(cluster_label) + 1 :] - node.set_label("<" + display_name + ">" if node.get("distinguished") == "True" else display_name) + if name in part_names: + # The entity cluster carries master membership, so a part + # shows only its own name (`Scan`, not `Acquisition.Scan`). + display_name = name.rsplit(".", 1)[-1] + else: + schema_name = schema_map.get(name) + if schema_name and "." in name: + cluster_label = cluster_labels.get(schema_name) + if cluster_label and name.startswith(cluster_label + "."): + display_name = name[len(cluster_label) + 1 :] + node.set_label("<" + display_name + ">" if node.get("introduces_dimension") == "True" else display_name) node.set_fillcolor(props["fill"]) node.set_color(props["stroke"]) node.set_style("rounded,filled" if props.get("rounded") else "filled") @@ -1595,8 +1618,12 @@ def make_dot(self): schemas[schema_name] = [] schemas[schema_name].append(node) - # Create clusters for each schema - # Use Python module name if 1:1 mapping, otherwise database schema name + # Create clusters for each schema. Within a schema, a master and its + # parts are enclosed together in a nested, unlabeled entity cluster + # (#1532); master and its parts share a rank so horizontal reads as + # derivation and vertical as containment, except a part that depends + # on a sibling part, which is left off the rank so the intra-group + # chain descends. for schema_name, nodes in schemas.items(): label = cluster_labels.get(schema_name, schema_name) cluster = pydot.Cluster( @@ -1606,8 +1633,40 @@ def make_dot(self): color="gray", fontcolor="gray", ) - for node in nodes: - cluster.add_node(node) + node_by_name = {n.get_name().strip('"'): n for n in nodes} + # masters in this schema that have at least one part present + masters_here = {} + for pn in part_names: + mn = part_master[pn] + if pn in node_by_name and mn in node_by_name: + masters_here.setdefault(mn, []).append(pn) + + grouped = set() + for master_name, parts in masters_here.items(): + entity = pydot.Cluster( + "cluster_entity_" + master_name.replace(".", "_"), + label="", + style="dashed", + color="#C7CDD6", + ) + entity.add_node(node_by_name[master_name]) + grouped.add(master_name) + same_rank = [node_by_name[master_name].get_name()] + for pn in parts: + entity.add_node(node_by_name[pn]) + grouped.add(pn) + if pn not in depends_on_sibling: + same_rank.append(node_by_name[pn].get_name()) + if len(same_rank) > 1: + rank = pydot.Subgraph(rank="same") + for nm in same_rank: + rank.add_node(pydot.Node(nm)) + entity.add_subgraph(rank) + cluster.add_subgraph(entity) + + for name, node in node_by_name.items(): + if name not in grouped: + cluster.add_node(node) dot.add_subgraph(cluster) return dot diff --git a/tests/integration/test_diagram.py b/tests/integration/test_diagram.py index d746bf49e..158c09087 100644 --- a/tests/integration/test_diagram.py +++ b/tests/integration/test_diagram.py @@ -1,3 +1,5 @@ +import re + import pytest as _pytest import datajoint as dj @@ -5,6 +7,18 @@ from tests.schema_simple import LOCALS_SIMPLE, A, B, D, E, G, L, Profile, Website +def test_master_part_entity_cluster(schema_simp): + """A master and its parts render inside a nested entity cluster, and part + labels drop the master prefix (#1532): `B.C` shows as `C`.""" + if not dj.diagram.diagram_active: + _pytest.skip("networkx/pydot not available") + svg = dj.Diagram(schema_simp, context=LOCALS_SIMPLE).make_dot().create_svg().decode() + assert "cluster_entity_" in svg, "a master with parts should get a nested entity cluster" + texts = re.findall(r"]*>([^<]+)", svg) + assert "C" in texts, "part B.C should display as 'C' (master prefix dropped)" + assert "B.C" not in texts, "the part label must not include the master prefix" + + def test_decorator(schema_simp): assert issubclass(A, dj.Lookup) assert not issubclass(A, dj.Part) @@ -24,10 +38,10 @@ def test_dependencies(schema_simp): assert set(deps.descendants(L.full_table_name)).issubset(cls.full_table_name for cls in (L, D, E, E.F, E.G, E.H, E.M, G)) -def test_erd(schema_simp): +def test_diagram(schema_simp): assert dj.diagram.diagram_active, "Failed to import networkx and pydot" - erd = dj.Diagram(schema_simp, context=LOCALS_SIMPLE) - graph = erd._make_graph() + diagram = dj.Diagram(schema_simp, context=LOCALS_SIMPLE) + graph = diagram._make_graph() assert set(cls.__name__ for cls in (A, B, D, E, L)).issubset(graph.nodes()) @@ -46,21 +60,21 @@ def test_diagram_algebra(schema_simp): def test_repr_svg(schema_adv): - erd = dj.Diagram(schema_adv, context=dict()) - svg = erd._repr_svg_() + diagram = dj.Diagram(schema_adv, context=dict()) + svg = diagram._repr_svg_() assert svg.startswith("") def test_make_image(schema_simp): - erd = dj.Diagram(schema_simp, context=dict()) - img = erd.make_image() + diagram = dj.Diagram(schema_simp, context=dict()) + img = diagram.make_image() assert img.ndim == 3 and img.shape[2] in (3, 4) def test_part_table_parsing(schema_simp): # https://github.com/datajoint/datajoint-python/issues/882 - erd = dj.Diagram(schema_simp, context=LOCALS_SIMPLE) - graph = erd._make_graph() + diagram = dj.Diagram(schema_simp, context=LOCALS_SIMPLE) + graph = diagram._make_graph() assert "OutfitLaunch" in graph.nodes() assert "OutfitLaunch.OutfitPiece" in graph.nodes() From 29b478616a8bf56b8e9cb4870eb55f3896a84971 Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 14:49:24 -0500 Subject: [PATCH 05/15] style(#1532): rounded schema clusters; subtle shaded entity clusters Schema clusters get rounded corners (style rounded,dashed). Master-part entity clusters drop the dashed frame for a quiet rounded shaded background (#F3F5F8, borderless) so the grouping reads subtly and doesn't compete with the schema box. --- src/datajoint/diagram.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index 4cc1948d5..046f8b98e 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -1629,7 +1629,7 @@ def make_dot(self): cluster = pydot.Cluster( f"cluster_{schema_name}", label=label, - style="dashed", + style="rounded,dashed", color="gray", fontcolor="gray", ) @@ -1643,11 +1643,15 @@ def make_dot(self): grouped = set() for master_name, parts in masters_here.items(): + # Subtle rounded shaded background (no dashed frame) — the + # entity grouping should read quietly, not compete with the + # schema box. entity = pydot.Cluster( "cluster_entity_" + master_name.replace(".", "_"), label="", - style="dashed", - color="#C7CDD6", + style="rounded,filled", + fillcolor="#F3F5F8", + color="#F3F5F8", ) entity.add_node(node_by_name[master_name]) grouped.add(master_name) From c0abbe7c68e757abe7e5342181d8b5e0b4e780cb Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 14:57:12 -0500 Subject: [PATCH 06/15] feat(#1532): dark theme option; matched, higher-contrast edge density MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a diagram color theme option (dj.config display.diagram_theme = light|dark). Refactor make_dot styling into theme-independent structure (_TIER_STRUCTURE) plus per-theme color sets (_DIAGRAM_THEMES): tier fill/stroke/text triples, background, edge colors, and cluster colors. The dark theme uses a deep-slate background with light text and brighter strokes. Edges share one alpha per theme, so a renamed (amber) edge sits at the same visual density as ordinary edges — differing only in hue — and both are given more contrast than the first pass. Also fix a single-underscore config.override example in the docstring (needs double underscore for nested keys). --- src/datajoint/diagram.py | 170 ++++++++++++++++++-------------------- src/datajoint/settings.py | 6 ++ 2 files changed, 86 insertions(+), 90 deletions(-) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index 046f8b98e..6d96977cd 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -47,6 +47,61 @@ logger = logging.getLogger(__name__.split(".")[0]) +# Structural node attributes per tier — shape, sizing, and whether the box has +# rounded corners. These are theme-independent; only the colors change with the +# theme. `_scale` matches the historical 1.2 scaling factor for fonts and boxes. +_scale = 1.2 +_TIER_STRUCTURE = { + None: dict(shape="circle", fontsize=round(_scale * 8), size=0.4 * _scale, fixed=False, rounded=False), + Manual: dict(shape="box", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=True), + Lookup: dict(shape="box", fontsize=round(_scale * 8), size=0.4 * _scale, fixed=False, rounded=True), + Computed: dict(shape="ellipse", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=False), + Imported: dict(shape="ellipse", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=False), + Part: dict(shape="box", fontsize=round(_scale * 8), size=0.1 * _scale, fixed=False, rounded=True), + "collapsed": dict(shape="box3d", fontsize=round(_scale * 10), size=0.5 * _scale, fixed=False, rounded=False), +} + +# Color themes (#1532). Each tier gets a (fill, stroke, text) triple. Edge colors +# share a single alpha so a renamed (amber) edge sits at the same visual density +# as ordinary edges, differing only in hue. +_DIAGRAM_THEMES = { + "light": dict( + bg=None, + palette={ + None: ("#FFFDE7", "#C9BC5B", "#6B6420"), + Manual: ("#E7F3EC", "#2F7D5B", "#1B5138"), + Lookup: ("#F2F4F7", "#A9B1BD", "#495261"), + Computed: ("#FBEAEC", "#B23A48", "#7C2430"), + Imported: ("#E2ECFA", "#2A5FA5", "#123A6D"), + Part: ("#FFFFFF", "#9AA6B8", "#46536B"), + "collapsed": ("#EDEEF0", "#808890", "#404040"), + }, + edge="#3A424F", + edge_renamed="#C77D3A", + edge_alpha="9E", + schema_cluster=("gray", "gray"), + entity_fill="#F3F5F8", + ), + "dark": dict( + bg="#161A21", + palette={ + None: ("#3A3620", "#C9BC5B", "#EBE3A0"), + Manual: ("#16281F", "#4FA97F", "#BCE6CF"), + Lookup: ("#242832", "#8A93A1", "#C9CFD9"), + Computed: ("#331A1F", "#D0687A", "#F3C2CB"), + Imported: ("#152538", "#5E92D6", "#C3DAF6"), + Part: ("#1E232C", "#7B879B", "#C4CCDB"), + "collapsed": ("#242730", "#8890A0", "#C7CDD6"), + }, + edge="#AEB6C2", + edge_renamed="#D68C4A", + edge_alpha="C0", + schema_cluster=("#606875", "#8A93A1"), + entity_fill="#1E222B", + ), +} + + class Diagram(nx.MultiDiGraph): # noqa: C901 """ Schema diagram as a directed acyclic graph (DAG). @@ -92,7 +147,7 @@ class Diagram(nx.MultiDiGraph): # noqa: C901 Layout direction is controlled via ``dj.config.display.diagram_direction`` (default ``"TB"``). Use ``dj.config.override()`` to change temporarily:: - with dj.config.override(display_diagram_direction="LR"): + with dj.config.override(display__diagram_direction="LR"): dj.Diagram(schema).draw() """ @@ -1412,91 +1467,20 @@ def make_dot(self): if data.get("collapsed") and data.get("schema_name"): schema_map[node] = data["schema_name"] - scale = 1.2 # scaling factor for fonts and boxes - # Modernized tier palette (#1532): each tier gets a readable - # fill / stroke / text triple in place of the old alpha-blended primary - # fills. Shape stays load-bearing and unchanged so an existing diagram - # reads without relearning: Manual = rounded rectangle, Imported and - # Computed = ellipse, Lookup and Part = subtle (white/near-white) box. - label_props = { - None: dict( - shape="circle", - fill="#FFFDE7", - stroke="#C9BC5B", - fontcolor="#6B6420", - fontsize=round(scale * 8), - size=0.4 * scale, - fixed=False, - rounded=False, - ), - Manual: dict( - shape="box", - fill="#E7F3EC", - stroke="#2F7D5B", - fontcolor="#1B5138", - fontsize=round(scale * 10), - size=0.4 * scale, - fixed=False, - rounded=True, - ), - Lookup: dict( - shape="box", - fill="#F2F4F7", - stroke="#A9B1BD", - fontcolor="#495261", - fontsize=round(scale * 8), - size=0.4 * scale, - fixed=False, - rounded=True, - ), - Computed: dict( - shape="ellipse", - fill="#FBEAEC", - stroke="#B23A48", - fontcolor="#7C2430", - fontsize=round(scale * 10), - size=0.4 * scale, - fixed=False, - rounded=False, - ), - Imported: dict( - shape="ellipse", - fill="#E2ECFA", - stroke="#2A5FA5", - fontcolor="#123A6D", - fontsize=round(scale * 10), - size=0.4 * scale, - fixed=False, - rounded=False, - ), - Part: dict( - shape="box", - fill="#FFFFFF", - stroke="#9AA6B8", - fontcolor="#46536B", - fontsize=round(scale * 8), - size=0.1 * scale, - fixed=False, - rounded=True, - ), - "collapsed": dict( - shape="box3d", - fill="#EDEEF0", - stroke="#808890", - fontcolor="#404040", - fontsize=round(scale * 10), - size=0.5 * scale, - fixed=False, - rounded=False, - ), - } - # Build node_props, handling collapsed nodes specially + # Select the color theme (#1532). Structure (shape/size/rounded) is + # theme-independent; only the fill/stroke/text colors change. + theme_name = self._connection._config.display.diagram_theme + theme = _DIAGRAM_THEMES.get(theme_name, _DIAGRAM_THEMES["light"]) + palette = theme["palette"] + + # Build node_props by merging the structural attributes for each tier + # with the theme's (fill, stroke, text) colors. Collapsed nodes use the + # "collapsed" entry. node_props = {} for node, d in graph.nodes(data=True): - if d.get("collapsed"): - node_props[node] = label_props["collapsed"] - else: - node_props[node] = label_props[d["node_type"]] + tier = "collapsed" if d.get("collapsed") else d["node_type"] + fill, stroke, text = palette[tier] + node_props[node] = dict(_TIER_STRUCTURE[tier], fill=fill, stroke=stroke, fontcolor=text) # A renamed (aliased) FK is drawn as a distinctly-colored edge (there # is no longer an intermediate "alias" node); describe the column @@ -1515,6 +1499,8 @@ def make_dot(self): self._encapsulate_edge_attributes(graph) dot = nx.drawing.nx_pydot.to_pydot(graph) dot.set_rankdir(direction) + if theme["bg"]: + dot.set_bgcolor(theme["bg"]) # Master↔part grouping (#1532): map each part (class name "Master.Part") # to its master ("Master"), and record which parts depend on a sibling @@ -1588,8 +1574,11 @@ def make_dot(self): multi = str(edge.get("multi")) == "True" aliased = str(edge.get("aliased")) == "True" # Renamed FK → a distinct, desaturated amber consistent with the - # modernized palette (#1532); others → a light translucent slate. - edge.set_color("#C77D3A" if aliased else "#3A424F33") + # modernized palette (#1532); others → a translucent slate. Both + # share the theme's edge alpha so the amber sits at the same visual + # density as ordinary edges, differing only in hue. + base = theme["edge_renamed"] if aliased else theme["edge"] + edge.set_color(base + theme["edge_alpha"]) edge.set_style("solid" if primary else "dashed") # Line weight encodes cardinality, and only cardinality. `multi` is # True when the child has primary-key attributes beyond those this @@ -1626,12 +1615,13 @@ def make_dot(self): # chain descends. for schema_name, nodes in schemas.items(): label = cluster_labels.get(schema_name, schema_name) + sc_color, sc_fontcolor = theme["schema_cluster"] cluster = pydot.Cluster( f"cluster_{schema_name}", label=label, style="rounded,dashed", - color="gray", - fontcolor="gray", + color=sc_color, + fontcolor=sc_fontcolor, ) node_by_name = {n.get_name().strip('"'): n for n in nodes} # masters in this schema that have at least one part present @@ -1650,8 +1640,8 @@ def make_dot(self): "cluster_entity_" + master_name.replace(".", "_"), label="", style="rounded,filled", - fillcolor="#F3F5F8", - color="#F3F5F8", + fillcolor=theme["entity_fill"], + color=theme["entity_fill"], ) entity.add_node(node_by_name[master_name]) grouped.add(master_name) diff --git a/src/datajoint/settings.py b/src/datajoint/settings.py index 9bcce0201..f5b53b517 100644 --- a/src/datajoint/settings.py +++ b/src/datajoint/settings.py @@ -70,6 +70,7 @@ "database.create_tables": "DJ_CREATE_TABLES", "loglevel": "DJ_LOG_LEVEL", "display.diagram_direction": "DJ_DIAGRAM_DIRECTION", + "display.diagram_theme": "DJ_DIAGRAM_THEME", } Role = Enum("Role", "manual lookup imported computed job") @@ -245,6 +246,11 @@ class DisplaySettings(BaseSettings): validation_alias="DJ_DIAGRAM_DIRECTION", description="Default diagram layout direction: 'TB' (top-to-bottom) or 'LR' (left-to-right)", ) + diagram_theme: Literal["light", "dark"] = Field( + default="light", + validation_alias="DJ_DIAGRAM_THEME", + description="Default diagram color theme: 'light' or 'dark' (dark background with adjusted palette)", + ) class StoresSettings(BaseSettings): From 9346c2034f8b66f033f12231c341b92067ca50ca Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 15:03:38 -0500 Subject: [PATCH 07/15] feat(#1532): adaptive 'auto' theme; brighten dark entity cluster Add theme='auto': render the diagram in light colors and inject a prefers-color-scheme style block so a single SVG adapts to the viewer's light or dark mode (the two palettes are collision-free, so a per-color attribute-selector override is unambiguous). Exposed via Diagram.svg_string(); make_svg/_repr_svg_ use it. make_dot() gains an optional theme override. Also brighten the dark-theme entity-cluster fill so the master-part grouping box reads against the dark background. --- src/datajoint/diagram.py | 73 +++++++++++++++++++++++++++++++++++---- src/datajoint/settings.py | 7 ++-- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index 6d96977cd..ad9c6d908 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -97,11 +97,53 @@ edge_renamed="#D68C4A", edge_alpha="C0", schema_cluster=("#606875", "#8A93A1"), - entity_fill="#1E222B", + entity_fill="#2A313D", ), } +def _adaptive_style_block() -> str: + """ + Build a ``" + + class Diagram(nx.MultiDiGraph): # noqa: C901 """ Schema diagram as a directed acyclic graph (DAG). @@ -1396,7 +1438,7 @@ def _encapsulate_node_names(graph: nx.MultiDiGraph) -> None: copy=False, ) - def make_dot(self): + def make_dot(self, theme=None): """ Generate a pydot graph object. @@ -1468,9 +1510,12 @@ def make_dot(self): schema_map[node] = data["schema_name"] # Select the color theme (#1532). Structure (shape/size/rounded) is - # theme-independent; only the fill/stroke/text colors change. - theme_name = self._connection._config.display.diagram_theme - theme = _DIAGRAM_THEMES.get(theme_name, _DIAGRAM_THEMES["light"]) + # theme-independent; only the fill/stroke/text colors change. `theme` + # (an explicit argument) overrides the configured default; "auto" is + # rendered in light colors here and made adaptive at the SVG layer, so + # it maps to the light palette. + theme_name = theme or self._connection._config.display.diagram_theme + theme = _DIAGRAM_THEMES.get(theme_name if theme_name != "auto" else "light", _DIAGRAM_THEMES["light"]) palette = theme["palette"] # Build node_props by merging the structural attributes for each tier @@ -1665,10 +1710,26 @@ def make_dot(self): return dot + def svg_string(self) -> str: + """ + Render the diagram to an SVG string, honoring the color theme. + + For ``theme="auto"`` the diagram is rendered in light colors and a + ``prefers-color-scheme`` style block is injected so a single image + adapts to the viewer's light or dark mode. Other themes render directly. + """ + theme_name = self._connection._config.display.diagram_theme + if theme_name == "auto": + svg = self.make_dot(theme="light").create_svg().decode() + # Insert the adaptive style block right after the opening . + insert_at = svg.find(">", svg.find(" Date: Mon, 10 Aug 2026 15:10:18 -0500 Subject: [PATCH 08/15] style(#1532): Lookup renders as a gray Manual-sized rectangle Match Lookup's box size to Manual (fontsize/size), so a Lookup reads as a gray rectangle the same size as a Manual table rather than a smaller subtle box. Part rendering unchanged (neutral subtle box, per design review). --- src/datajoint/diagram.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index ad9c6d908..fb18cc060 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -54,7 +54,7 @@ _TIER_STRUCTURE = { None: dict(shape="circle", fontsize=round(_scale * 8), size=0.4 * _scale, fixed=False, rounded=False), Manual: dict(shape="box", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=True), - Lookup: dict(shape="box", fontsize=round(_scale * 8), size=0.4 * _scale, fixed=False, rounded=True), + Lookup: dict(shape="box", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=True), Computed: dict(shape="ellipse", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=False), Imported: dict(shape="ellipse", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=False), Part: dict(shape="box", fontsize=round(_scale * 8), size=0.1 * _scale, fixed=False, rounded=True), From 6f4f49a5760132ad0fe349d831d43eee6b806d04 Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 15:11:17 -0500 Subject: [PATCH 09/15] docs(#1532): note parts keep a box for platform clickability A part inherits its master's tier and has no tier-shape of its own, but it keeps a neutral subtle box (not a tier shape) so platform nodes are clickable targets that open the table. Comment this so it isn't reverted to boxless. --- src/datajoint/diagram.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index fb18cc060..d21f48051 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -57,6 +57,10 @@ Lookup: dict(shape="box", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=True), Computed: dict(shape="ellipse", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=False), Imported: dict(shape="ellipse", fontsize=round(_scale * 10), size=0.4 * _scale, fixed=False, rounded=False), + # A part inherits its master's tier and so has no tier-shape of its own; + # historically it was drawn boxless. It nonetheless gets a neutral subtle box + # (not a tier shape) so that on the platform each part is a clickable target + # that opens the table. Keep the box for that reason. Part: dict(shape="box", fontsize=round(_scale * 8), size=0.1 * _scale, fixed=False, rounded=True), "collapsed": dict(shape="box3d", fontsize=round(_scale * 10), size=0.5 * _scale, fixed=False, rounded=False), } From 4ad36370533f8a873f390cce842f575b81fd327d Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 15:14:43 -0500 Subject: [PATCH 10/15] style(#1532): order parts after their master within the shared rank Add invisible ordering edges through each entity's same-rank group (master then parts) so parts are placed below the master in LR and to its right in TB, rather than leaving the within-rank order to Graphviz's heuristic. --- src/datajoint/diagram.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index d21f48051..d9eaf81e9 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -1704,6 +1704,12 @@ def make_dot(self, theme=None): rank = pydot.Subgraph(rank="same") for nm in same_rank: rank.add_node(pydot.Node(nm)) + # Pin the within-rank order: master first, then its + # parts. As flat (same-rank) edges, these place the parts + # after the master — below it in LR, to its right in TB — + # rather than leaving the order to Graphviz's heuristic. + for a, b in zip(same_rank, same_rank[1:]): + rank.add_edge(pydot.Edge(a, b, style="invis")) entity.add_subgraph(rank) cluster.add_subgraph(entity) From 0034923affe456d7d1646c764209b90fbc30a557 Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 15:23:51 -0500 Subject: [PATCH 11/15] fix(#1532): place parts below master (LR) / right of master (TB) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior ordering edges were being overridden: inside the entity cluster, the master is anchored by its derivation-chain edges and the real master->part FK edge forced the part above (LR). Fix by (a) marking master->part FK edges constraint=false so they don't vote on within-rank order, and (b) adding an invisible ordering edge whose direction depends on rankdir — reversed (part->master) for LR to put parts below, forward (master->part) for TB to put parts to the right. Both recipes verified empirically. --- src/datajoint/diagram.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index d9eaf81e9..12688a1e6 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -1641,6 +1641,12 @@ def make_dot(self, theme=None): edge.set_penwidth(0.75 if multi else 2) edge.set_weight(1 if multi else 3) edge.set_arrowhead("none") + # A master→part edge is drawn but must NOT constrain the within-rank + # order, so the invisible ordering edges (added per entity below) can + # place the part below the master (LR) / to its right (TB). + dst = edge.get_destination().strip('"') + if dst in part_names and part_master.get(dst) == edge.get_source().strip('"'): + edge.set_constraint("false") # Group nodes into schema clusters (always on) if schema_map: @@ -1704,12 +1710,15 @@ def make_dot(self, theme=None): rank = pydot.Subgraph(rank="same") for nm in same_rank: rank.add_node(pydot.Node(nm)) - # Pin the within-rank order: master first, then its - # parts. As flat (same-rank) edges, these place the parts - # after the master — below it in LR, to its right in TB — - # rather than leaving the order to Graphviz's heuristic. + # Pin the within-rank order so parts sit below the master + # in LR and to its right in TB. Inside a cluster whose + # master is anchored by external (derivation-chain) edges, + # Graphviz's flat-edge ordering is inverted between the two + # orientations, so the invisible ordering edge direction + # is chosen per rankdir (verified empirically). for a, b in zip(same_rank, same_rank[1:]): - rank.add_edge(pydot.Edge(a, b, style="invis")) + tail, head = (a, b) if direction == "TB" else (b, a) + rank.add_edge(pydot.Edge(tail, head, style="invis")) entity.add_subgraph(rank) cluster.add_subgraph(entity) From 7619ccfb498ac22eac34e860934f5947c33c868c Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 15:29:05 -0500 Subject: [PATCH 12/15] revert(#1532): drop within-cluster part ordering hacks Remove the constraint=false on master->part FK edges and the invisible ordering edges. They ordered simple parts (below in LR / right in TB) but could not do so reliably when a part has its own child part (the child edge + cluster crossing- minimization override the flat-edge order), and the special-casing was brittle. Keep the entity clustering and shared rank; leave within-cluster placement to Graphviz. --- src/datajoint/diagram.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index 12688a1e6..d21f48051 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -1641,12 +1641,6 @@ def make_dot(self, theme=None): edge.set_penwidth(0.75 if multi else 2) edge.set_weight(1 if multi else 3) edge.set_arrowhead("none") - # A master→part edge is drawn but must NOT constrain the within-rank - # order, so the invisible ordering edges (added per entity below) can - # place the part below the master (LR) / to its right (TB). - dst = edge.get_destination().strip('"') - if dst in part_names and part_master.get(dst) == edge.get_source().strip('"'): - edge.set_constraint("false") # Group nodes into schema clusters (always on) if schema_map: @@ -1710,15 +1704,6 @@ def make_dot(self, theme=None): rank = pydot.Subgraph(rank="same") for nm in same_rank: rank.add_node(pydot.Node(nm)) - # Pin the within-rank order so parts sit below the master - # in LR and to its right in TB. Inside a cluster whose - # master is anchored by external (derivation-chain) edges, - # Graphviz's flat-edge ordering is inverted between the two - # orientations, so the invisible ordering edge direction - # is chosen per rankdir (verified empirically). - for a, b in zip(same_rank, same_rank[1:]): - tail, head = (a, b) if direction == "TB" else (b, a) - rank.add_edge(pydot.Edge(tail, head, style="invis")) entity.add_subgraph(rank) cluster.add_subgraph(entity) From 378caf6a13b96c94558b631770f912c6b17e75d2 Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 15:35:42 -0500 Subject: [PATCH 13/15] test(#1532): add diagram style-regression fixture; robust part detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a style-contract test that renders a fixed schema per theme and asserts the palette (per-tier fill/stroke), thick/thin edge weights, entity cluster, dark background, and adaptive prefers-color-scheme block — catching palette/weight/ theme regressions without pinning fragile SVG geometry. Also make master<->part detection in make_dot robust to both node-naming schemes (class 'Master.Part' and raw 'schema.master__part'), using the actual node keys for predecessor lookups instead of reconstructing them — the old code crashed on raw-table-name graphs. --- src/datajoint/diagram.py | 38 ++++++-- tests/integration/test_diagram_style.py | 111 ++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 tests/integration/test_diagram_style.py diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index d21f48051..ce0f2991e 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -1551,20 +1551,40 @@ def make_dot(self, theme=None): if theme["bg"]: dot.set_bgcolor(theme["bg"]) - # Master↔part grouping (#1532): map each part (class name "Master.Part") - # to its master ("Master"), and record which parts depend on a sibling - # part so an intra-group chain can descend rather than share a rank. + # Master↔part grouping (#1532): map each part to its master, and record + # which parts depend on a sibling part so an intra-group chain can + # descend rather than share a rank. Nodes may be named either by class + # ("Master.Part") when a context resolves them, or by raw table name + # ("schema.master__part") otherwise, so the master is found by trying + # both suffix conventions against the actual node keys. Everything here + # is keyed by the stripped node name (matching the loops below). + key_by_stripped = {k.strip('"'): k for k in graph.nodes()} + + def _master_of(part_stripped): + candidates = set() + if "." in part_stripped: + candidates.add(part_stripped.rsplit(".", 1)[0]) # class: Master.Part -> Master + if "__" in part_stripped: + candidates.add(part_stripped.rsplit("__", 1)[0]) # table: ...master__part -> ...master + for candidate in candidates: + if candidate in key_by_stripped: + return candidate + return None + part_master = {} for gname, gdata in graph.nodes(data=True): if gdata.get("node_type") is Part: - pn = gname.strip('"') - part_master[pn] = pn.rsplit(".", 1)[0] + part_stripped = gname.strip('"') + master_stripped = _master_of(part_stripped) + if master_stripped is not None: + part_master[part_stripped] = master_stripped part_names = set(part_master) depends_on_sibling = set() - for pn, mn in part_master.items(): - for pred in graph.predecessors(f'"{pn}"'): - if pred.strip('"') in part_names and part_master.get(pred.strip('"')) == mn: - depends_on_sibling.add(pn) + for part_stripped, master_stripped in part_master.items(): + for pred in graph.predecessors(key_by_stripped[part_stripped]): + pred_stripped = pred.strip('"') + if pred_stripped in part_names and part_master.get(pred_stripped) == master_stripped: + depends_on_sibling.add(part_stripped) for node in dot.get_nodes(): node.set_shape("circle") diff --git a/tests/integration/test_diagram_style.py b/tests/integration/test_diagram_style.py new file mode 100644 index 000000000..984de4ed6 --- /dev/null +++ b/tests/integration/test_diagram_style.py @@ -0,0 +1,111 @@ +""" +Style-contract (visual-regression) guard for the modernized dj.Diagram (#1532). + +Rather than diff exact SVG geometry against a checked-in reference — which drifts +with the Graphviz version — this renders a fixed schema per theme and asserts the +style invariants the restyle controls: each tier's fill/stroke palette, the +thick/thin edge weights, rounded boxes, entity clusters, the dark background, and +the adaptive `prefers-color-scheme` block. A palette, weight, or theme regression +fails here; a layout tweak does not. +""" + +import time + +import pytest + +import datajoint as dj + + +@pytest.fixture(scope="function") +def schema_by_backend(connection_by_backend, db_creds_by_backend): + backend = db_creds_by_backend["backend"] + test_id = str(int(time.time() * 1000))[-8:] + schema_name = f"djtest_style_{backend}_{test_id}"[:64] + if connection_by_backend.is_connected: + try: + connection_by_backend.query( + f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}" + ) + except Exception: + pass + schema = dj.Schema(schema_name, connection=connection_by_backend) + yield schema + if connection_by_backend.is_connected: + try: + connection_by_backend.query( + f"DROP DATABASE IF EXISTS {connection_by_backend.adapter.quote_identifier(schema_name)}" + ) + except Exception: + pass + + +def _build(schema): + @schema + class Subject(dj.Manual): + definition = "subject_id : int32" + + @schema + class Params(dj.Lookup): + definition = "param_id : int32" + + @schema + class Session(dj.Manual): + definition = "-> Subject\nsession_id : int32" + + class Note(dj.Part): + definition = "-> master\nnote_id : int32" + + @schema + class Scan(dj.Imported): + definition = "-> Session" # 1:1 -> thick edge + + @schema + class Analysis(dj.Computed): + definition = "-> Scan\n-> Params" # composite -> thin edge + + # Return a context so the diagram resolves nodes to class names (the normal + # rendering path — users have their classes in scope). + return dict(Subject=Subject, Params=Params, Session=Session, Scan=Scan, Analysis=Analysis) + + +def _svg(schema, context, theme): + with dj.config.override(display__diagram_theme=theme): + return dj.Diagram(schema, context=context).svg_string().lower() + + +def test_light_theme_style(schema_by_backend): + if not dj.diagram.diagram_active: + pytest.skip("networkx/pydot not available") + ctx = _build(schema_by_backend) + svg = _svg(schema_by_backend, ctx, "light") + # tier fills + for fill in ("#e7f3ec", "#f2f4f7", "#e2ecfa", "#fbeaec", "#ffffff"): + assert fill in svg, f"light tier fill {fill} missing" + # a couple tier strokes + for stroke in ("#2f7d5b", "#b23a48"): + assert stroke in svg, f"light tier stroke {stroke} missing" + # thick (1:1) and thin (multi) edge weights both present + assert 'stroke-width="2"' in svg, "thick (1:1) edge missing" + assert 'stroke-width="0.75"' in svg, "thin (multi-valued) edge missing" + assert "cluster_entity_" in svg, "entity cluster missing" + assert "161a21" not in svg, "light theme must not use the dark background" + + +def test_dark_theme_style(schema_by_backend): + if not dj.diagram.diagram_active: + pytest.skip("networkx/pydot not available") + ctx = _build(schema_by_backend) + svg = _svg(schema_by_backend, ctx, "dark") + assert "#161a21" in svg, "dark background missing" + for fill in ("#16281f", "#152538", "#331a1f"): + assert fill in svg, f"dark tier fill {fill} missing" + + +def test_auto_theme_is_adaptive(schema_by_backend): + if not dj.diagram.diagram_active: + pytest.skip("networkx/pydot not available") + ctx = _build(schema_by_backend) + svg = _svg(schema_by_backend, ctx, "auto") + assert "@media (prefers-color-scheme: dark)" in svg, "auto theme must inject the adaptive media block" + # base render is light; the media block maps a light color to its dark counterpart + assert "#e7f3ec" in svg and "#16281f" in svg, "auto theme must carry both light base and dark override colors" From de9784570df5216da6feac79abb87ce53b32f1f8 Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 16:22:34 -0500 Subject: [PATCH 14/15] feat(#1532): default diagram theme to 'auto'; sans schema labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make 'auto' (adaptive light/dark SVG) the default diagram_theme, so rendered diagrams adapt to the viewer's appearance out of the box. Also set the schema cluster label to Helvetica — it previously fell back to Graphviz's Times default while the node labels were already sans. --- src/datajoint/diagram.py | 1 + src/datajoint/settings.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index ce0f2991e..23486510d 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -1691,6 +1691,7 @@ def _master_of(part_stripped): style="rounded,dashed", color=sc_color, fontcolor=sc_fontcolor, + fontname="Helvetica", # schema label in a sans font, not Graphviz's Times default ) node_by_name = {n.get_name().strip('"'): n for n in nodes} # masters in this schema that have at least one part present diff --git a/src/datajoint/settings.py b/src/datajoint/settings.py index bbb121e40..8bac20f81 100644 --- a/src/datajoint/settings.py +++ b/src/datajoint/settings.py @@ -247,11 +247,11 @@ class DisplaySettings(BaseSettings): description="Default diagram layout direction: 'TB' (top-to-bottom) or 'LR' (left-to-right)", ) diagram_theme: Literal["light", "dark", "auto"] = Field( - default="light", + default="auto", validation_alias="DJ_DIAGRAM_THEME", description=( - "Default diagram color theme: 'light', 'dark' (dark background with adjusted palette), " - "or 'auto' (single SVG that adapts to the viewer's light/dark mode)" + "Default diagram color theme: 'auto' (single SVG that adapts to the viewer's light/dark " + "mode; default), 'light', or 'dark' (dark background with adjusted palette)" ), ) From 8435f7d52ab4ba9b8e7946de0a86e9ce6a83aa27 Mon Sep 17 00:00:00 2001 From: Dimitri Yatsenko Date: Mon, 10 Aug 2026 18:09:50 -0500 Subject: [PATCH 15/15] diagram: place schema name in the top-right corner of its cluster Set labelloc=t, labeljust=r on the schema cluster so the schema/module label sits in the top-right corner rather than top-center. --- src/datajoint/diagram.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/datajoint/diagram.py b/src/datajoint/diagram.py index 23486510d..d1dde45a5 100644 --- a/src/datajoint/diagram.py +++ b/src/datajoint/diagram.py @@ -1688,6 +1688,8 @@ def _master_of(part_stripped): cluster = pydot.Cluster( f"cluster_{schema_name}", label=label, + labelloc="t", + labeljust="r", # schema name in the top-right corner of the cluster style="rounded,dashed", color=sc_color, fontcolor=sc_fontcolor,