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.
- {erdscope-0.7.0/erdscope.egg-info → erdscope-0.7.1}/PKG-INFO +2 -1
- {erdscope-0.7.0 → erdscope-0.7.1}/README.md +1 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/erd.py +217 -0
- {erdscope-0.7.0 → erdscope-0.7.1/erdscope.egg-info}/PKG-INFO +2 -1
- {erdscope-0.7.0 → erdscope-0.7.1}/erdscope.egg-info/SOURCES.txt +1 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/pyproject.toml +1 -1
- erdscope-0.7.1/tests/test_emit_digest.py +401 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/LICENSE +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/erdscope.egg-info/dependency_links.txt +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/erdscope.egg-info/entry_points.txt +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/erdscope.egg-info/requires.txt +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/erdscope.egg-info/top_level.txt +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/setup.cfg +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_build.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_characterization.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_config_validation.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_db_integration.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_demo.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_diff.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_e2e.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_emit_config.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_emit_json.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_erd.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_golden_format1.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_groups.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_merge_ir.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_notes.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_pipeline.py +0 -0
- {erdscope-0.7.0 → erdscope-0.7.1}/tests/test_provider_contract.py +0 -0
- {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.
|
|
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.
|
|
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 |
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "erdscope"
|
|
7
|
-
version = "0.7.
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|