erdscope 0.7.0__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. {erdscope-0.7.0/erdscope.egg-info → erdscope-0.7.1}/PKG-INFO +2 -1
  2. {erdscope-0.7.0 → erdscope-0.7.1}/README.md +1 -0
  3. {erdscope-0.7.0 → erdscope-0.7.1}/erd.py +217 -0
  4. {erdscope-0.7.0 → erdscope-0.7.1/erdscope.egg-info}/PKG-INFO +2 -1
  5. {erdscope-0.7.0 → erdscope-0.7.1}/erdscope.egg-info/SOURCES.txt +1 -0
  6. {erdscope-0.7.0 → erdscope-0.7.1}/pyproject.toml +1 -1
  7. erdscope-0.7.1/tests/test_emit_digest.py +401 -0
  8. {erdscope-0.7.0 → erdscope-0.7.1}/LICENSE +0 -0
  9. {erdscope-0.7.0 → erdscope-0.7.1}/erdscope.egg-info/dependency_links.txt +0 -0
  10. {erdscope-0.7.0 → erdscope-0.7.1}/erdscope.egg-info/entry_points.txt +0 -0
  11. {erdscope-0.7.0 → erdscope-0.7.1}/erdscope.egg-info/requires.txt +0 -0
  12. {erdscope-0.7.0 → erdscope-0.7.1}/erdscope.egg-info/top_level.txt +0 -0
  13. {erdscope-0.7.0 → erdscope-0.7.1}/setup.cfg +0 -0
  14. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_build.py +0 -0
  15. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_characterization.py +0 -0
  16. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_config_validation.py +0 -0
  17. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_db_integration.py +0 -0
  18. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_demo.py +0 -0
  19. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_diff.py +0 -0
  20. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_e2e.py +0 -0
  21. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_emit_config.py +0 -0
  22. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_emit_json.py +0 -0
  23. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_erd.py +0 -0
  24. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_golden_format1.py +0 -0
  25. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_groups.py +0 -0
  26. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_merge_ir.py +0 -0
  27. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_notes.py +0 -0
  28. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_pipeline.py +0 -0
  29. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_provider_contract.py +0 -0
  30. {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_rails_schema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: erdscope
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a MySQL, PostgreSQL, or SQLite database and/or application code (Rails / Prisma / Django) — single file, zero required dependencies
5
5
  Author: tas6
6
6
  License-Expression: MIT
@@ -196,6 +196,7 @@ explicit CLI argument wins.
196
196
  | `--emit-json FILE.json` | Also write a canonical JSON schema snapshot with a content fingerprint (`-` for stdout) |
197
197
  | `--emit-config FILE.yml\|.json` | Also write the schema as a config-authoring file, re-importable via `--config` (`-` for stdout, always JSON) |
198
198
  | `--diff SNAPSHOT.json` | Compare this run against a saved `--emit-json` snapshot and exit 0/1/2 instead of generating output (CI drift gate) |
199
+ | `--emit-digest FILE.md` | Also write a token-efficient Markdown digest of the schema, with design notes, for LLMs/agents (`-` for stdout; `--digest-verbose` adds nullable/default/sql_type) |
199
200
  | `--only 'user*,order*'` | Generate only matching tables |
200
201
  | `--exclude '*_logs'` | Leave matching tables out |
201
202
  | `--infer-fk` | Add clearly marked relation guesses from `*_id` columns |
@@ -162,6 +162,7 @@ explicit CLI argument wins.
162
162
  | `--emit-json FILE.json` | Also write a canonical JSON schema snapshot with a content fingerprint (`-` for stdout) |
163
163
  | `--emit-config FILE.yml\|.json` | Also write the schema as a config-authoring file, re-importable via `--config` (`-` for stdout, always JSON) |
164
164
  | `--diff SNAPSHOT.json` | Compare this run against a saved `--emit-json` snapshot and exit 0/1/2 instead of generating output (CI drift gate) |
165
+ | `--emit-digest FILE.md` | Also write a token-efficient Markdown digest of the schema, with design notes, for LLMs/agents (`-` for stdout; `--digest-verbose` adds nullable/default/sql_type) |
165
166
  | `--only 'user*,order*'` | Generate only matching tables |
166
167
  | `--exclude '*_logs'` | Leave matching tables out |
167
168
  | `--infer-fk` | Add clearly marked relation guesses from `*_id` columns |
@@ -8959,6 +8959,197 @@ def render_text(diff) -> str:
8959
8959
  lines.append('')
8960
8960
 
8961
8961
  return '\n'.join(lines).rstrip('\n') + '\n'
8962
+ # ---------------------------------------------------------------------------
8963
+ # --emit-digest — token-efficient Markdown digest of the schema, WITH design
8964
+ # notes, for LLM/agent consumption (backlog #3). Reuses emit.py's
8965
+ # canonical_schema (same allowlist/pruning/deterministic order --emit-json and
8966
+ # --emit-config already share) — render_digest never re-derives what survives
8967
+ # or how it's ordered, only how it's RENDERED. Pure and non-destructive:
8968
+ # canonical_schema already deep-copies, and nothing here mutates its result.
8969
+ #
8970
+ # Differentiator (the digest's whole reason to exist, per EMIT_DIGEST_SPEC.md
8971
+ # §0): design intent a machine cannot re-derive from the raw schema — notes
8972
+ # (table/relation/global) — survives. provenance/sources, the legacy db_fk/
8973
+ # inferred/manual/schema_fk flags, and (by default) nullable/default/extra are
8974
+ # all dropped to keep the token budget on MEANING, not on every column detail
8975
+ # an LLM can usually infer or doesn't need. `groups` is dropped entirely: it
8976
+ # is a viewer-cosmetic layout aid (which tables get drawn inside a rounded
8977
+ # frame together), not schema semantics — nothing an LLM reading this digest
8978
+ # would need to reason about the data model.
8979
+ # ---------------------------------------------------------------------------
8980
+
8981
+
8982
+ def _oneline(s):
8983
+ """Collapse a free-text field (comment/note text) to one line — a
8984
+ literal newline inside a bullet or heading would break the Markdown
8985
+ structure (turn one bullet into what looks like two, or one table
8986
+ heading into a heading plus stray body text)."""
8987
+ return ' '.join(s.split())
8988
+
8989
+
8990
+ def _notes_by_scope(notes_data):
8991
+ """Split canonical `notes` (already id-sorted by emit.py's
8992
+ _canonical_notes) into (global list, {table -> [note, ...]},
8993
+ {relation identity -> [note, ...]}). A relation identity is
8994
+ `(source_table, type, name, foreign_key, through, polymorphic)` — exactly
8995
+ the fields a resolved relation note carries (providers.py's
8996
+ resolve_and_validate_notes) — so a table's Rel: line can look its own
8997
+ associations up against this dict in O(1) instead of re-scanning every
8998
+ note per association."""
8999
+ global_notes = []
9000
+ table_notes = {}
9001
+ relation_notes = {}
9002
+ for n in notes_data or []:
9003
+ if n['scope'] == 'global':
9004
+ global_notes.append(n)
9005
+ elif n['scope'] == 'table':
9006
+ table_notes.setdefault(n['table'], []).append(n)
9007
+ else: # relation
9008
+ key = (n['source_table'], n['type'], n.get('name'), n.get('foreign_key'),
9009
+ n.get('through'), bool(n.get('polymorphic')))
9010
+ relation_notes.setdefault(key, []).append(n)
9011
+ return global_notes, table_notes, relation_notes
9012
+
9013
+
9014
+ def _note_text(n):
9015
+ """One note -> a short inline rendering: `title: text` when titled, else
9016
+ bare `text`. Links are never rendered here (in verbose mode either) — a
9017
+ URL is low value per token for an LLM reader and the digest already
9018
+ keeps notes themselves regardless of --digest-verbose (G-1's verbosity
9019
+ knob is about column metadata density, not about which notes survive)."""
9020
+ text = _oneline(n['text'])
9021
+ return f'{n["title"]}: {text}' if n.get('title') else text
9022
+
9023
+
9024
+ def _fk_targets(associations):
9025
+ """`{foreign_key_column -> target_table}` for every single-column FK
9026
+ association on a table, so each column line can show `fk→<target>`
9027
+ without re-scanning associations per column. Association `foreign_key`
9028
+ is always single-column (AssociationFragment contract, header.py §4.2);
9029
+ on a rare duplicate the deterministically-last (canonical order) wins —
9030
+ harmless, since this is an informational cross-reference, not identity."""
9031
+ return {a['foreign_key']: a['target'] for a in associations if a.get('foreign_key')}
9032
+
9033
+
9034
+ def _render_column(c, fk_targets, verbose):
9035
+ """One canonical column -> its digest bullet line:
9036
+ `- name: type[, pk][, fk→target][, null][, default=...][, sql_type][, "comment"]`.
9037
+ nullable/default/sql_type only show under --digest-verbose (G-1) —
9038
+ dropped by default to keep the per-column token cost to what's needed to
9039
+ reconstruct the shape of the table, not every DB-level nuance."""
9040
+ bits = [c.get('type', '')]
9041
+ if c.get('primary'):
9042
+ bits.append('pk')
9043
+ target = fk_targets.get(c['name'])
9044
+ if target:
9045
+ bits.append(f'fk→{target}')
9046
+ if verbose:
9047
+ if c.get('nullable'):
9048
+ bits.append('null')
9049
+ if c.get('default'):
9050
+ bits.append(f'default={c["default"]}')
9051
+ if c.get('sql_type'):
9052
+ bits.append(c['sql_type'])
9053
+ comment = c.get('comment')
9054
+ if comment:
9055
+ bits.append(f'"{_oneline(comment)}"')
9056
+ return f'- {c["name"]}: ' + ', '.join(bits)
9057
+
9058
+
9059
+ def _assoc_token(a):
9060
+ """One canonical association -> its compact Rel: token:
9061
+ `type target[ as name][ fk=foreign_key][ through X][ (poly)]`. `as name`
9062
+ is included only when the association name differs from its target
9063
+ (e.g. belongs_to :user on target `users` needs no `as`; a polymorphic
9064
+ belongs_to whose target IS the synthetic association name never adds
9065
+ one either, since target already equals name in that case)."""
9066
+ bits = [a['type'], a['target']]
9067
+ name = a.get('name')
9068
+ if name and name != a['target']:
9069
+ bits.append(f'as {name}')
9070
+ if a.get('foreign_key'):
9071
+ bits.append(f'fk={a["foreign_key"]}')
9072
+ if a.get('through'):
9073
+ bits.append(f'through {a["through"]}')
9074
+ if a.get('polymorphic'):
9075
+ bits.append('(poly)')
9076
+ return ' '.join(bits)
9077
+
9078
+
9079
+ def _relation_note_key(a, table_name):
9080
+ return (table_name, a['type'], a.get('name'), a.get('foreign_key'),
9081
+ a.get('through'), bool(a.get('polymorphic')))
9082
+
9083
+
9084
+ def _render_rel_line(table_name, associations, relation_notes):
9085
+ """The table's one-line association summary (spec §2's `Rel:` line),
9086
+ each association compressed to _assoc_token and any relation note(s)
9087
+ that resolve to it appended as `— "note text"`. Omitted entirely (returns
9088
+ None) when the table has no associations — no reason to spend a line on
9089
+ an empty summary."""
9090
+ if not associations:
9091
+ return None
9092
+ tokens = []
9093
+ for a in associations:
9094
+ token = _assoc_token(a)
9095
+ notes = relation_notes.get(_relation_note_key(a, table_name))
9096
+ if notes:
9097
+ token += ' — ' + '; '.join(f'"{_note_text(n)}"' for n in notes)
9098
+ tokens.append(token)
9099
+ return 'Rel: ' + ', '.join(tokens)
9100
+
9101
+
9102
+ def render_digest(schema, title=None, verbose=False):
9103
+ """Render a canonical `schema` (emit.py's canonical_schema shape:
9104
+ {tables, notes?, groups?}) to the --emit-digest Markdown. Pure and
9105
+ deterministic: the same schema always renders the same text, since every
9106
+ input list is already canonically ordered (emit.py) and this function
9107
+ adds no further data-dependent ordering of its own — only `sorted(tables)`
9108
+ for the one thing canonical_schema does NOT itself sort (its `tables` is
9109
+ a dict keyed by name, in whatever order the caller built it; ordering it
9110
+ here, rather than relying on json.dumps(sort_keys=True) the way
9111
+ --emit-json/--emit-config's file writers do, is what makes THIS text
9112
+ itself byte-deterministic, not just a JSON encoding of it)."""
9113
+ global_notes, table_notes, relation_notes = _notes_by_scope(schema.get('notes'))
9114
+ tables = schema.get('tables', {})
9115
+ names = sorted(tables)
9116
+
9117
+ lines = [f'# {title} — schema digest' if title else '# Schema digest', '']
9118
+ if global_notes:
9119
+ lines.append('\n\n'.join(_note_text(n) for n in global_notes))
9120
+ lines.append('')
9121
+ lines.append(f'## Tables ({len(names)})')
9122
+ lines.append('')
9123
+
9124
+ for name in names:
9125
+ t = tables[name]
9126
+ heading = f'### {name}'
9127
+ comment = t.get('comment')
9128
+ if comment:
9129
+ heading += f' — {_oneline(comment)}'
9130
+ lines.append(heading)
9131
+ for n in table_notes.get(name, []):
9132
+ lines.append(f'_{_note_text(n)}_')
9133
+ fk_targets = _fk_targets(t.get('associations', []))
9134
+ for c in t.get('columns', []):
9135
+ lines.append(_render_column(c, fk_targets, verbose))
9136
+ rel_line = _render_rel_line(name, t.get('associations', []), relation_notes)
9137
+ if rel_line:
9138
+ lines.append(rel_line)
9139
+ lines.append('')
9140
+
9141
+ return '\n'.join(lines).rstrip('\n') + '\n'
9142
+
9143
+
9144
+ def emit_digest_document(tables, notes_data, groups_data, title=None, verbose=False):
9145
+ """Build the --emit-digest document (as a Markdown string): project the
9146
+ final merged IR through the SAME canonical_schema --emit-json/--emit-config
9147
+ already share, then render it. `groups_data` is accepted (mirroring the
9148
+ other two emitters' signatures, and cli.py already has it in hand at the
9149
+ same call site) but intentionally unused here — see the module docstring
9150
+ on why groups carry no schema meaning for a digest."""
9151
+ schema = canonical_schema(tables, notes_data, groups_data)
9152
+ return render_digest(schema, title=title, verbose=verbose)
8962
9153
  def main():
8963
9154
  p = argparse.ArgumentParser(
8964
9155
  description='Generate an interactive ER diagram (and optional Excel table definitions) '
@@ -9004,6 +9195,14 @@ def main():
9004
9195
  'byte-identical) round trip; dispatches on extension — .yml/.yaml '
9005
9196
  'for YAML (needs PyYAML installed) or .json for JSON; use - for '
9006
9197
  'stdout, which is always JSON. The HTML is still generated')
9198
+ p.add_argument('--emit-digest', metavar='FILE.md', default=argparse.SUPPRESS,
9199
+ help='Also write a token-efficient Markdown digest of the schema '
9200
+ '(with design notes) for LLMs/agents, alongside the HTML; use '
9201
+ '- for stdout. Drops provenance and (by default) nullable/'
9202
+ 'default/sql_type — see --digest-verbose')
9203
+ p.add_argument('--digest-verbose', action='store_true',
9204
+ help='With --emit-digest, also include nullable/default/sql_type '
9205
+ 'per column (omitted by default to keep the digest small)')
9007
9206
  p.add_argument('--excel-template', metavar='FILE.xlsx', default=argparse.SUPPRESS,
9008
9207
  help="Override the workbook's colors/fonts/borders from a template "
9009
9208
  '.xlsx — see excel-template.xlsx and its Styles sheet for the '
@@ -9392,6 +9591,7 @@ def _finish(tables, args, title_name, notes=None, notes_label='config',
9392
9591
  if getattr(args, 'diff', None) is not None:
9393
9592
  for _flag, _val in (('--emit-json', getattr(args, 'emit_json', None)),
9394
9593
  ('--emit-config', getattr(args, 'emit_config', None)),
9594
+ ('--emit-digest', getattr(args, 'emit_digest', None)),
9395
9595
  ('--excel', getattr(args, 'excel', None))):
9396
9596
  if _val:
9397
9597
  _diff_fail(f'--diff cannot be combined with {_flag} '
@@ -9445,6 +9645,7 @@ def _finish(tables, args, title_name, notes=None, notes_label='config',
9445
9645
  for _flag, _val in (('-o/--output', getattr(args, 'output', None)),
9446
9646
  ('--emit-json', getattr(args, 'emit_json', None)),
9447
9647
  ('--emit-config', getattr(args, 'emit_config', None)),
9648
+ ('--emit-digest', getattr(args, 'emit_digest', None)),
9448
9649
  ('--excel', getattr(args, 'excel', None))):
9449
9650
  if not _val or _val == '-':
9450
9651
  continue
@@ -9478,6 +9679,15 @@ def _finish(tables, args, title_name, notes=None, notes_label='config',
9478
9679
  emit_config_text = (config_yaml_text(emit_config_doc) if emit_config_fmt == 'yaml'
9479
9680
  else config_json_text(emit_config_doc))
9480
9681
 
9682
+ # --emit-digest (backlog #3): same provenance-preserving-IR timing as
9683
+ # --emit-json/--emit-config above (built before serialize_for_viewer) —
9684
+ # render_digest reads notes/associations straight off canonical_schema,
9685
+ # same as the other two emitters.
9686
+ emit_digest_val = getattr(args, 'emit_digest', None)
9687
+ emit_digest_text = (emit_digest_document(tables, notes_data, groups_data, title=title_name,
9688
+ verbose=getattr(args, 'digest_verbose', False))
9689
+ if emit_digest_val is not None else None)
9690
+
9481
9691
  # §9.3 serialize boundary: convert the internal provenance/sources IR to
9482
9692
  # today's legacy-flag shape (a no-op pass-through for the already-legacy demo
9483
9693
  # IR), so BOTH the HTML DATA_JSON and the Excel export below see exactly the
@@ -9519,6 +9729,13 @@ def _finish(tables, args, title_name, notes=None, notes_label='config',
9519
9729
  Path(emit_config_val).write_text(emit_config_text, encoding='utf-8')
9520
9730
  print(f'Generated: {emit_config_val}', file=sys.stderr)
9521
9731
 
9732
+ if emit_digest_text is not None:
9733
+ if emit_digest_val == '-':
9734
+ sys.stdout.write(emit_digest_text)
9735
+ else:
9736
+ Path(emit_digest_val).write_text(emit_digest_text, encoding='utf-8')
9737
+ print(f'Generated: {emit_digest_val}', file=sys.stderr)
9738
+
9522
9739
  if getattr(args, 'excel', None):
9523
9740
  write_excel(tables, Path(args.excel), title_name,
9524
9741
  template_path=getattr(args, 'excel_template', None), notes=notes_data,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: erdscope
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: Interactive, self-contained ER-diagram HTML and Excel table definitions from a MySQL, PostgreSQL, or SQLite database and/or application code (Rails / Prisma / Django) — single file, zero required dependencies
5
5
  Author: tas6
6
6
  License-Expression: MIT
@@ -196,6 +196,7 @@ explicit CLI argument wins.
196
196
  | `--emit-json FILE.json` | Also write a canonical JSON schema snapshot with a content fingerprint (`-` for stdout) |
197
197
  | `--emit-config FILE.yml\|.json` | Also write the schema as a config-authoring file, re-importable via `--config` (`-` for stdout, always JSON) |
198
198
  | `--diff SNAPSHOT.json` | Compare this run against a saved `--emit-json` snapshot and exit 0/1/2 instead of generating output (CI drift gate) |
199
+ | `--emit-digest FILE.md` | Also write a token-efficient Markdown digest of the schema, with design notes, for LLMs/agents (`-` for stdout; `--digest-verbose` adds nullable/default/sql_type) |
199
200
  | `--only 'user*,order*'` | Generate only matching tables |
200
201
  | `--exclude '*_logs'` | Leave matching tables out |
201
202
  | `--infer-fk` | Add clearly marked relation guesses from `*_id` columns |
@@ -16,6 +16,7 @@ tests/test_demo.py
16
16
  tests/test_diff.py
17
17
  tests/test_e2e.py
18
18
  tests/test_emit_config.py
19
+ tests/test_emit_digest.py
19
20
  tests/test_emit_json.py
20
21
  tests/test_erd.py
21
22
  tests/test_golden_format1.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "erdscope"
7
- version = "0.7.0"
7
+ version = "0.7.1"
8
8
  description = "Interactive, self-contained ER-diagram HTML and Excel table definitions from a MySQL, PostgreSQL, or SQLite database and/or application code (Rails / Prisma / Django) — single file, zero required dependencies"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,401 @@
1
+ """--emit-digest (backlog #3) — token-efficient Markdown digest of the
2
+ schema, with design notes, for LLM/agent consumption.
3
+
4
+ Covers the new src/erdscope/digest.py surface (render_digest,
5
+ emit_digest_document) as direct unit tests against hand-built canonical
6
+ schemas (mirrors tests/test_emit_json.py's style — no need to route through
7
+ merge_ir/canonical_schema for tests that are purely about rendering), plus
8
+ CLI-level wiring tests (--emit-digest alongside --excel/--emit-json/HTML, `-`
9
+ for stdout, output-collision guard, --only/--exclude reflected,
10
+ --digest-verbose, --diff rejecting the combination) driven through main() the
11
+ same way tests/test_emit_json.py's _EmitJsonDriver does.
12
+
13
+ Run from the repository root:
14
+ python3 -m unittest tests.test_emit_digest -v
15
+ """
16
+ import copy
17
+ import importlib.util
18
+ import io
19
+ import json
20
+ import os
21
+ import sys
22
+ import tempfile
23
+ import unittest
24
+ from contextlib import redirect_stderr, redirect_stdout
25
+ from pathlib import Path
26
+
27
+ ROOT = Path(__file__).resolve().parent.parent
28
+
29
+ spec = importlib.util.spec_from_file_location('erd', ROOT / 'erd.py')
30
+ erd = importlib.util.module_from_spec(spec)
31
+ spec.loader.exec_module(erd)
32
+
33
+
34
+ def _col(name, type_='integer', nullable=False, **extra):
35
+ c = {'name': name, 'type': type_, 'nullable': nullable}
36
+ c.update(extra)
37
+ return c
38
+
39
+
40
+ def _schema():
41
+ """A hand-built canonical schema (already the emit.py output shape) —
42
+ users (1) <- posts (N) belongs_to on user_id, plus one note of each
43
+ scope. Mirrors test_emit_json.py's _merged_tables fixture but already
44
+ post-canonical_schema, since render_digest consumes that shape directly."""
45
+ return {
46
+ 'tables': {
47
+ 'users': {
48
+ 'comment': 'App users',
49
+ 'columns': [
50
+ _col('id', primary=True),
51
+ _col('email', type_='string', comment='Unique login'),
52
+ ],
53
+ 'indexes': [],
54
+ 'associations': [
55
+ {'type': 'has_many', 'name': 'posts', 'target': 'posts',
56
+ 'provenance': 'declared'},
57
+ ],
58
+ },
59
+ 'posts': {
60
+ 'columns': [
61
+ _col('id', primary=True),
62
+ _col('user_id', nullable=True, default='0', sql_type='bigint unsigned'),
63
+ ],
64
+ 'indexes': [],
65
+ 'associations': [
66
+ {'type': 'belongs_to', 'name': 'user', 'target': 'users',
67
+ 'foreign_key': 'user_id', 'provenance': 'db_fk'},
68
+ ],
69
+ },
70
+ },
71
+ 'notes': [
72
+ {'id': 'n1', 'scope': 'global', 'text': 'Simple blog schema.'},
73
+ {'id': 'n2', 'scope': 'table', 'table': 'users', 'title': 'PII',
74
+ 'text': 'email is personal data'},
75
+ {'id': 'n3', 'scope': 'relation', 'source_table': 'posts', 'target': 'users',
76
+ 'type': 'belongs_to', 'name': 'user', 'foreign_key': 'user_id',
77
+ 'through': None, 'polymorphic': False, 'text': 'cascade on delete'},
78
+ ],
79
+ 'groups': [{'id': 'g1', 'tables': ['users', 'posts'], 'title': 'Core'}],
80
+ }
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # structure / headings
85
+ # ---------------------------------------------------------------------------
86
+ class TestStructure(unittest.TestCase):
87
+ def test_title_in_heading(self):
88
+ out = erd.render_digest(_schema(), title='blogdb')
89
+ self.assertEqual(out.splitlines()[0], '# blogdb — schema digest')
90
+
91
+ def test_no_title_falls_back_to_generic_heading(self):
92
+ out = erd.render_digest(_schema())
93
+ self.assertEqual(out.splitlines()[0], '# Schema digest')
94
+
95
+ def test_table_count_in_heading(self):
96
+ out = erd.render_digest(_schema())
97
+ self.assertIn('## Tables (2)', out)
98
+
99
+ def test_tables_sorted_alphabetically_regardless_of_dict_order(self):
100
+ schema = _schema()
101
+ reversed_schema = {**schema,
102
+ 'tables': {k: schema['tables'][k]
103
+ for k in reversed(list(schema['tables']))}}
104
+ out1 = erd.render_digest(schema)
105
+ out2 = erd.render_digest(reversed_schema)
106
+ self.assertEqual(out1, out2)
107
+ self.assertLess(out1.index('### posts'), out1.index('### users'))
108
+
109
+ def test_table_comment_on_heading_line(self):
110
+ out = erd.render_digest(_schema())
111
+ self.assertIn('### users — App users', out)
112
+
113
+ def test_table_without_comment_has_bare_heading(self):
114
+ out = erd.render_digest(_schema())
115
+ self.assertIn('### posts\n', out)
116
+
117
+ def test_ends_with_single_trailing_newline(self):
118
+ out = erd.render_digest(_schema())
119
+ self.assertTrue(out.endswith('\n'))
120
+ self.assertFalse(out.endswith('\n\n'))
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # columns
125
+ # ---------------------------------------------------------------------------
126
+ class TestColumns(unittest.TestCase):
127
+ def test_pk_marker(self):
128
+ out = erd.render_digest(_schema())
129
+ self.assertIn('- id: integer, pk', out)
130
+
131
+ def test_fk_arrow_to_target(self):
132
+ out = erd.render_digest(_schema())
133
+ self.assertIn('- user_id: integer, fk→users', out)
134
+
135
+ def test_comment_quoted(self):
136
+ out = erd.render_digest(_schema())
137
+ self.assertIn('- email: string, "Unique login"', out)
138
+
139
+ def test_nullable_default_sql_type_omitted_by_default(self):
140
+ out = erd.render_digest(_schema())
141
+ line = [l for l in out.splitlines() if l.startswith('- user_id:')][0]
142
+ self.assertNotIn('null', line)
143
+ self.assertNotIn('default=', line)
144
+ self.assertNotIn('bigint unsigned', line)
145
+
146
+ def test_verbose_adds_nullable_default_sql_type(self):
147
+ out = erd.render_digest(_schema(), verbose=True)
148
+ line = [l for l in out.splitlines() if l.startswith('- user_id:')][0]
149
+ self.assertIn('null', line)
150
+ self.assertIn('default=0', line)
151
+ self.assertIn('bigint unsigned', line)
152
+
153
+ def test_verbose_does_not_affect_non_nullable_no_default_column(self):
154
+ out_plain = erd.render_digest(_schema())
155
+ out_verbose = erd.render_digest(_schema(), verbose=True)
156
+ id_line_plain = [l for l in out_plain.splitlines() if l.startswith('- id:')][0]
157
+ id_line_verbose = [l for l in out_verbose.splitlines() if l.startswith('- id:')][0]
158
+ self.assertEqual(id_line_plain, id_line_verbose)
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # associations (Rel: line)
163
+ # ---------------------------------------------------------------------------
164
+ class TestAssociations(unittest.TestCase):
165
+ def test_belongs_to_token_with_as_and_fk(self):
166
+ out = erd.render_digest(_schema())
167
+ self.assertIn('Rel: belongs_to users as user fk=user_id', out)
168
+
169
+ def test_has_many_token_without_as_when_name_matches_target(self):
170
+ out = erd.render_digest(_schema())
171
+ self.assertIn('Rel: has_many posts', out)
172
+
173
+ def test_through_and_polymorphic_markers(self):
174
+ schema = _schema()
175
+ schema['tables']['posts']['associations'] = [
176
+ {'type': 'has_many', 'name': 'tags', 'target': 'tags', 'through': 'taggings'},
177
+ {'type': 'belongs_to', 'name': 'commentable', 'target': 'commentable',
178
+ 'polymorphic': True},
179
+ ]
180
+ out = erd.render_digest(schema)
181
+ self.assertIn('Rel: has_many tags through taggings, '
182
+ 'belongs_to commentable (poly)', out)
183
+
184
+ def test_no_associations_omits_rel_line(self):
185
+ schema = _schema()
186
+ schema['tables']['users']['associations'] = []
187
+ out = erd.render_digest(schema)
188
+ users_block = out.split('### users')[1].split('### ')[0]
189
+ self.assertNotIn('Rel:', users_block)
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # notes
194
+ # ---------------------------------------------------------------------------
195
+ class TestNotes(unittest.TestCase):
196
+ def test_global_note_appears_before_tables_section(self):
197
+ out = erd.render_digest(_schema())
198
+ self.assertLess(out.index('Simple blog schema.'), out.index('## Tables'))
199
+
200
+ def test_table_note_rendered_with_title(self):
201
+ out = erd.render_digest(_schema())
202
+ self.assertIn('_PII: email is personal data_', out)
203
+
204
+ def test_table_note_appears_under_its_table(self):
205
+ out = erd.render_digest(_schema())
206
+ users_block = out.split('### users')[1].split('### ')[0]
207
+ self.assertIn('PII: email is personal data', users_block)
208
+
209
+ def test_relation_note_attached_to_matching_rel_token(self):
210
+ out = erd.render_digest(_schema())
211
+ self.assertIn('Rel: belongs_to users as user fk=user_id — "cascade on delete"', out)
212
+
213
+ def test_multiple_relation_notes_joined(self):
214
+ schema = _schema()
215
+ schema['notes'].append(
216
+ {'id': 'n4', 'scope': 'relation', 'source_table': 'posts', 'target': 'users',
217
+ 'type': 'belongs_to', 'name': 'user', 'foreign_key': 'user_id',
218
+ 'through': None, 'polymorphic': False, 'text': 'second note'})
219
+ out = erd.render_digest(schema)
220
+ self.assertIn('"cascade on delete"; "second note"', out)
221
+
222
+ def test_no_notes_key_renders_without_error(self):
223
+ schema = _schema()
224
+ del schema['notes']
225
+ out = erd.render_digest(schema) # must not raise
226
+ self.assertIn('## Tables (2)', out)
227
+
228
+ def test_links_never_rendered(self):
229
+ schema = _schema()
230
+ schema['notes'][0]['links'] = [{'url': 'https://example.com', 'label': 'ADR'}]
231
+ out = erd.render_digest(schema)
232
+ self.assertNotIn('example.com', out)
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # groups — intentionally dropped (viewer-cosmetic, no schema meaning)
237
+ # ---------------------------------------------------------------------------
238
+ class TestGroupsDropped(unittest.TestCase):
239
+ def test_group_title_never_appears(self):
240
+ out = erd.render_digest(_schema())
241
+ self.assertNotIn('Core', out)
242
+
243
+
244
+ # ---------------------------------------------------------------------------
245
+ # determinism / purity
246
+ # ---------------------------------------------------------------------------
247
+ class TestDeterminism(unittest.TestCase):
248
+ def test_same_schema_same_output(self):
249
+ schema = _schema()
250
+ self.assertEqual(erd.render_digest(schema), erd.render_digest(copy.deepcopy(schema)))
251
+
252
+ def test_emit_digest_document_matches_render_digest_of_canonical_schema(self):
253
+ tables = {
254
+ 'users': {'columns': [_col('id', primary=True)], 'indexes': [], 'associations': [],
255
+ 'fk_columns': []},
256
+ }
257
+ expected = erd.render_digest(erd.canonical_schema(tables, None, None), title='t')
258
+ actual = erd.emit_digest_document(tables, None, None, title='t')
259
+ self.assertEqual(expected, actual)
260
+
261
+
262
+ class TestPurity(unittest.TestCase):
263
+ def test_schema_unchanged_after_render(self):
264
+ schema = _schema()
265
+ before = copy.deepcopy(schema)
266
+ erd.render_digest(schema)
267
+ self.assertEqual(schema, before)
268
+
269
+
270
+ # ---------------------------------------------------------------------------
271
+ # CLI wiring — driven through main(), mirroring test_emit_json.py's
272
+ # _EmitJsonDriver technique (stubbed parse_mysql, a temp cwd, sys.argv
273
+ # juggling).
274
+ # ---------------------------------------------------------------------------
275
+ def _dbcol(t, name, dtype, ctype, null='YES', key='', default='', extra='', comment=''):
276
+ return (t, name, dtype, ctype, null, key, default, extra, comment)
277
+
278
+
279
+ TABLE_ROWS = [('users', ''), ('posts', '')]
280
+ COL_ROWS = [
281
+ _dbcol('users', 'id', 'bigint', 'bigint', 'NO', 'PRI', '', 'auto_increment'),
282
+ _dbcol('users', 'email', 'varchar', 'varchar(255)', 'NO', 'UNI'),
283
+ _dbcol('posts', 'id', 'bigint', 'bigint', 'NO', 'PRI', '', 'auto_increment'),
284
+ _dbcol('posts', 'user_id', 'bigint', 'bigint', 'NO', 'MUL'),
285
+ _dbcol('posts', 'title', 'varchar', 'varchar(255)', 'NO'),
286
+ ]
287
+ FK_ROWS = [('posts', 'user_id', 'users')]
288
+ INDEX_ROWS = [('users', 'PRIMARY', 0, 1, 'id')]
289
+
290
+
291
+ class _EmitDigestDriver(unittest.TestCase):
292
+ def setUp(self):
293
+ self._orig_parse = erd.parse_mysql
294
+ self._orig_argv = sys.argv
295
+ self._orig_cwd = os.getcwd()
296
+ self.addCleanup(lambda: setattr(erd, 'parse_mysql', self._orig_parse))
297
+ self.addCleanup(lambda: setattr(sys, 'argv', self._orig_argv))
298
+ self.addCleanup(os.chdir, self._orig_cwd)
299
+ self.tmp = tempfile.TemporaryDirectory()
300
+ self.addCleanup(self.tmp.cleanup)
301
+ os.chdir(self.tmp.name)
302
+ erd.parse_mysql = lambda url: erd.mysql_ir(TABLE_ROWS, COL_ROWS, FK_ROWS, INDEX_ROWS)
303
+
304
+ def _run(self, *cli_args, capture_stdout=False, capture_stderr=False):
305
+ sys.argv = ['erd.py', 'mysql://x@localhost/testdb', *cli_args]
306
+ out_buf, err_buf = io.StringIO(), io.StringIO()
307
+ with redirect_stdout(out_buf), redirect_stderr(err_buf):
308
+ erd.main()
309
+ return out_buf.getvalue(), err_buf.getvalue()
310
+
311
+
312
+ class TestCLIFileOutput(_EmitDigestDriver):
313
+ def test_emit_digest_writes_alongside_html(self):
314
+ self._run('--emit-digest', 'digest.md')
315
+ self.assertTrue((Path(self.tmp.name) / 'erd.html').exists())
316
+ text = (Path(self.tmp.name) / 'digest.md').read_text()
317
+ self.assertTrue(text.startswith('# testdb — schema digest'))
318
+ self.assertIn('### posts', text)
319
+ self.assertIn('### users', text)
320
+
321
+ def test_emit_digest_reports_generated_path_on_stderr(self):
322
+ _, err = self._run('--emit-digest', 'digest.md')
323
+ self.assertIn('Generated: digest.md', err)
324
+
325
+ def test_emit_digest_coexists_with_excel_and_emit_json(self):
326
+ self._run('--emit-digest', 'digest.md', '--excel', 'defs.xlsx', '--emit-json', 'snap.json')
327
+ self.assertTrue((Path(self.tmp.name) / 'erd.html').exists())
328
+ self.assertTrue((Path(self.tmp.name) / 'defs.xlsx').exists())
329
+ self.assertTrue((Path(self.tmp.name) / 'snap.json').exists())
330
+ self.assertTrue((Path(self.tmp.name) / 'digest.md').exists())
331
+
332
+
333
+ class TestCLIOutputCollision(_EmitDigestDriver):
334
+ def test_emit_digest_colliding_with_html_output_errors(self):
335
+ with self.assertRaises(SystemExit):
336
+ self._run('-o', 'same.md', '--emit-digest', 'same.md')
337
+
338
+ def test_emit_digest_colliding_with_emit_json_errors(self):
339
+ with self.assertRaises(SystemExit):
340
+ self._run('--emit-digest', 'same.out', '--emit-json', 'same.out')
341
+
342
+ def test_distinct_paths_and_stdout_do_not_collide(self):
343
+ out, _ = self._run('-o', 'erd.html', '--emit-digest', '-')
344
+ self.assertTrue(out.startswith('# testdb'))
345
+
346
+
347
+ class TestCLIStdout(_EmitDigestDriver):
348
+ def test_emit_digest_dash_writes_to_stdout_and_still_generates_html(self):
349
+ out, _ = self._run('--emit-digest', '-')
350
+ self.assertIn('### users', out)
351
+ self.assertTrue((Path(self.tmp.name) / 'erd.html').exists())
352
+
353
+
354
+ class TestCLIFiltering(_EmitDigestDriver):
355
+ def test_only_reflected_including_dangling_association_pruning(self):
356
+ self._run('--emit-digest', 'digest.md', '--only', 'posts')
357
+ text = (Path(self.tmp.name) / 'digest.md').read_text()
358
+ self.assertIn('### posts', text)
359
+ self.assertNotIn('### users', text)
360
+ # 'users' filtered out -> posts' belongs_to :user must be pruned as
361
+ # dangling, so no Rel: line (and no dangling fk→users column note).
362
+ self.assertNotIn('Rel:', text)
363
+
364
+ def test_exclude_reflected(self):
365
+ self._run('--emit-digest', 'digest.md', '--exclude', 'posts')
366
+ text = (Path(self.tmp.name) / 'digest.md').read_text()
367
+ self.assertIn('### users', text)
368
+ self.assertNotIn('### posts', text)
369
+
370
+
371
+ class TestCLIVerbose(_EmitDigestDriver):
372
+ def test_digest_verbose_has_no_effect_without_emit_digest(self):
373
+ # accepted but inert with no --emit-digest (mirrors --excel-template's
374
+ # own "has no effect" pattern) -- just must not error.
375
+ self._run('--digest-verbose')
376
+
377
+ def test_digest_verbose_changes_output(self):
378
+ plain, _ = self._run('--emit-digest', '-')
379
+ verbose, _ = self._run('--emit-digest', '-', '--digest-verbose')
380
+ self.assertNotEqual(plain, verbose)
381
+
382
+
383
+ class TestCLIDiffRejectsCombination(_EmitDigestDriver):
384
+ def test_diff_with_emit_digest_exits_2(self):
385
+ snap_path = Path(self.tmp.name) / 'snap.json'
386
+ self._run('--emit-json', str(snap_path))
387
+ with self.assertRaises(SystemExit) as cm:
388
+ self._run('--diff', str(snap_path), '--emit-digest', 'digest.md')
389
+ self.assertEqual(cm.exception.code, 2)
390
+
391
+
392
+ class TestByteEquality(_EmitDigestDriver):
393
+ def test_html_byte_identical_with_and_without_emit_digest(self):
394
+ self._run('-o', 'a.html')
395
+ self._run('-o', 'b.html', '--emit-digest', 'digest.md')
396
+ self.assertEqual((Path(self.tmp.name) / 'a.html').read_bytes(),
397
+ (Path(self.tmp.name) / 'b.html').read_bytes())
398
+
399
+
400
+ if __name__ == '__main__':
401
+ unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes