Changelog
July 15, 2026 · View on GitHub
All notable changes to chopdiff are documented here.
This project uses semantic versioning; while pre-1.0, breaking
changes bump the minor version (see docs/publishing.md).
v0.4.0
This is chopdiff’s intended breaking release.
The document model now lives in the separately published
flexdoc package
(PyPI), and chopdiff depends on
flexdoc>=0.3.0,<0.4.0. chopdiff remains focused on diff filtering, windowed
transforms, div chunking, and optional lemmatization, built on FlexDoc.
Breaking Changes
Migration for downstream users, in one pass:
- The document model moved to the
flexdocpackage on PyPI. Imports move:chopdiff.docs.TextDocbecomesflexdoc.FlexDoc(prefer the root importfrom flexdoc import FlexDoc), whilechopdiff.docs.*,chopdiff.html.*, and most document utilities underchopdiff.util.*move to their correspondingflexdoc.*modules.chopdiff.util.lemmatizestays in chopdiff. The chopdiff wheel no longer ships aflexdocimport root; it arrives as a normal dependency. TextDocis renamedFlexDoc(moduleflexdoc.docs.flex_doc, wastext_doc).collect()is keyword-only and thescope/containsparameter aliases are gone: usecollect(subtree_of=, within=).- Editing-view method renames:
block_at_offsetis nowparagraph_at_offset,iter_blocksis nowiter_paragraphs, andSection.own_blocks/subtree_blocksare nowown_paragraphs/subtree_paragraphs. TextDoc.block_type_counts()andSection.block_type_counts()removed, superseded bycollect(). Migrate to standard Python overblocks():Counter(b.type for b in doc.blocks())(per-section:section.blocks()), orCounter(n.kind for n in collect(doc.node_table(), layer={Layer.markdown}, recursive=True)).
See flexdoc’s CHANGELOG for the full list of document-model changes. Document-model features developed in this repo since v0.3.1, including typed block metadata, footnote-reference nodes, read-time formatting, and frontmatter isolation, ship in FlexDoc rather than chopdiff.
Fixes
- Token filters now enforce their contracts. Ignore strings match one exact token; collections and predicate matchers are handled consistently; and word/lemma removal filters preserve token multiplicity instead of collapsing duplicates into sets.
- Sliding windows honor their requested offsets. Word windows now compute each starting offset independently when the shift differs from the window size. Word windows reject non-positive sizes or shifts; paragraph windows reject non-positive sizes and retain the full final paragraph.
- Exact div reassembly preserves original markup.
TextNode.reassemble(padding="")retains the original opening and closing markers, attributes, classes, and whitespace while incorporating edited child content.
Packaging
- Source and wheel artifacts contain only the chopdiff package; FlexDoc is installed as its declared dependency.
- Release validation now inspects both artifacts, installs the wheel in an isolated environment, audits dependencies, and verifies imports before publishing.
Full Changelog
v0.3.1
Versioned as a patch despite the breaking changes below: they touch only the block-type and sentence-splitting surface introduced in v0.3.0, which is very new and not yet relied upon. (The pre-1.0 policy otherwise bumps the minor version for breaking changes.)
Makes TextDoc block-aware end to end and adds the DocGraph node model on top of it.
The block-aware layer gives an exact-span structural block tree, a section hierarchy
with rolled-up stats, inline-link rollups, and link-aware sentence spans.
The DocGraph layer then adds a recursive, layer-tagged node table, the base_blocks()
sequential partition, the collect() query primitive, the SpanRef span-reference
type, and the DocGraph Pydantic projection (schema “DocGraph/v0.1”). The source text
and its offset space are canonical; every view (the structural block tree, sections,
block-type slices, tallies, and the node table) is a projection over that substrate: no
stored counts. Block boundaries and spans now come straight from flowmark’s parser, so
chopdiff carries no Markdown block-detection regex of its own.
Breaking Changes
BlockType.listis now bullet-only; ordered lists areBlockType.ordered_list. Ordered-ness is carried from marko’sList.ordered. Callers that matchedBlockType.listto cover both list kinds now miss ordered lists; match{BlockType.list, BlockType.ordered_list}for either.- Default sentence splitting is now span-aware.
TextDoc.from_text()with the default splitter now routes throughflowmark.atomic_spans.split_sentences_with_spansinstead of callingsplit_sentences_regexdirectly, so sentences never bisect a link, code span, or autolink. Sentence boundaries can therefore differ from v0.3.0 for text containing those constructs.default_sentence_splitteris unchanged and passing an explicit splitter preserves the previous behavior.
New Features
- Opt-in structural block tree with exact spans.
TextDoc.blocks()returns aBlock(type, span, children, tight)tree whose boundaries and[start, end)spans come directly from flowmark’s parser (marko’s own source positions), so a fenced code block stays whole through internal blank lines and a list always decomposes intolist_items with nested sublists. The tree is density-invariant: tight and loose spacing of the same list produce identical block/item counts;Block.tightrecords the CommonMark spacing. - Per-section structure and tallies.
Section.blocks()scopes the structural tree to a section’s own content (document-absolute spans), andSection.block_type_counts()/TextDoc.block_type_counts()give derivedCounter[BlockType]tallies over the live tree (no stored counts).Section.contentholds the section’s own paragraphs (renamed fromSection.blocks, which is now the structural method). - Sections, TOC, and rolled-up size stats.
TextDoc.sections()returns a tree ofSections over the heading hierarchy;TextDoc.toc()returns a flat(level, title, span)list.Section.size(unit, subtree=True|False),Section.size_summary(), andTextDoc.section_size_tree(units=…)roll up sizes per section in anyTextUnit. - Exact
[start, end)spans on paragraphs and sentences. EveryParagraphandSentenceexposes a document-relativespan;TextDoc.source_textis retained so each unit’soriginal_textround-trips into the source.TextDoc.block_at_offset(o)andsentence_at_offset(o)invert spans. - Inline-link rollups and link-aware sentence spans.
Link(text, url, title, span)viaParagraph.links(),Section.links(), andTextDoc.links()—identity from flowmark’sextract_links(reference links resolve across the whole document), spans recovered fromiter_atomic_spans. The default sentence splitter is nowflowmark.atomic_spans.split_sentences_with_spans, so sentence spans are exact for all content and never bisect a link, code span, or autolink. - More block types:
BlockTypegainsordered_list,list_item, andthematic_break. - New public exports:
Block,Link,Section. - Recursive node table with layers. The canonical node table fully populates
container children (blockquotes, list items) and tags each node with its parse
layer(textual, markdown, document, synthetic). Cross-layer relationships are offset-containment queries over a shared id space. base_blocks()sequential partition. A flat, depth-annotated, non-overlapping partition whose spans cover every non-whitespace character exactly once. Lists decompose so each list item is its own base block with increasingdepth(list-item continuation content keeps its own real type, e.g.paragraph, notlist_item); blockquotes stay atomic. Exact source reconstruction is via each block’ssource_span(not by concatenating block text).collect()query primitive. One general query (collect(kinds=, where=, recursive=, inline=, layer=)) at document, section, and block scope, supersedingblock_type_counts()convenience accessors. Thelayer=filter scopes a query to one or more parse layers (default: all layers), since the same span can appear as nodes in several layers. Two relation families select candidates: the tree relationsubtree_of=(a node’s within-layer subtree) and the cross-layer interval relationswithin=/overlaps=(each takes a node id or a span), sowithin=section_idgathers everything inside a section withoutrecursive=True. (scope=/contains=remain as deprecated aliases.)SpanRefspan-reference type. Quote-canonical, offset-hinted span references for durable annotation anchoring (exact-quote resolution with prefix/suffix disambiguation; fuzzy re-anchoring deferred).DocGraphPydantic projection.TextDoc.graph(include=, detail=)builds a serialized, language-neutral JSON contract (schema “DocGraph/v0.1”) with composableLayerandDetailaxes.TextDoc.base_blocks()method. A thin method over thebase_blocksfree function, so the sequential partition has the same ergonomic surface asblocks().- Reusable debug dumper (
chopdiff.docs.debug).doc_report,doc_graph_yaml, anddump_viewsturn any document into clean, deterministic standard-format views (a multi-view report, the DocGraph, the reassembled source) for REPL/script debugging and golden testing. DocGraph.to_yaml(). A clean, deterministic YAML serialization of the projection alongside the existing JSON (block style,|block scalars,None/empty suppressed).
Fixes
base_blocks()complete-cover fix. Content following (or between) a nested sublist inside a list item was dropped from the partition; the partition is now a complete cover again (verified by a cover-invariant test over all non-whitespace source).- Structural-parse memoization.
TextDoc.blocks()is now cached on the immutablesource_text, andSection.blocks()/Section.links()slice that single cached parse instead of re-parsing the whole document per section (a TOC walk was quadratic). - Sections built from structural headings.
sections()/toc()now derive headings from the structural parse (top-levelheadingblocks) instead of the blank-line paragraph view, so a#-prefixed line isolated from inside a fenced code block is no longer mistaken for a section heading. Behavior change: such phantom sections no longer appear (e.g. themalformedgolden loses one). - Linear node-table assembly. Inline-element attribution (containing block, section,
and sentence) now goes through a per-layer
IntervalIndexinstead of scanning the whole table per inline element, sobuild_node_tableis linear rather thanO(inline × nodes)on link-heavy or large documents. - Single shared parse with thread-safe caching.
blocks(),links(), andbase_blocks()now derive from one cached marko parse ofsource_textinstead of each re-parsing the whole document, roughly halvingnode_table()build time (one full parse instead of two). Document-level derivations are memoized under a per-instance reentrant lock, so concurrent reads compute each cache at most once and observe the same value; reads are otherwise side-effect-free and deterministic. See theTextDocread-time-caching contract.
Internal
- Dropped chopdiff’s regex block scanner.
TextDoc.blocks()andParagraph.block_typenow walk flowmark’s annotated parse tree and map marko classes toBlockTypethrough a single table; the per-line regex scanner,classify_block, and the cachedmarkdown_parsersingleton are gone (net negative code). Because chopdiff no longer makes block-boundary decisions, two earlier bugs are fixed by construction: reference links resolve across block boundaries, and adjacent blocks with no blank line between them split correctly.
Documentation
- Grounded design principles.
docs/textdoc-spec.mdnow leads with an explicit, three-tier principle set (P1–P18) and a pitfalls/decisions note; the goals cite the principles they realize. The canonical substrate is stated as the source text + offset space, with the node table as one projection (not a rival store).
Dependencies
- New runtime dependency:
pydantic>=2.13.4(bringsannotated-types,pydantic-core,typing-inspectionas transitive dependencies). Required for theDocGraphschema. - New runtime dependency:
frontmatter-format>=0.3.0(first-party; bringsruamel-yaml). Used for clean deterministic YAML (DocGraph.to_yaml, the debug dumper) and the Markdown-with-frontmatter golden-test corpus. - Requires
flowmark>=0.7.1for the authoritative block spans (jlevy/flowmark#52); recorded as a reviewed first-party cool-off exception inSUPPLY-CHAIN-SECURITY.md. No new transitive dependencies over 0.7.0.
Compatibility
- Additive at the API surface. The DocGraph work adds public names (
base_blocks,collect,SpanRef,DocGraph,NodeModel,Node,NodeTable,NodeKind,Layer,Detail,Views,build_doc_graph, …) without removing or renaming any existing export.Section.block_type_counts()/TextDoc.block_type_counts()are retained;collect()is the preferred general query, not a replacement. - Net release vs. v0.3.0. This release removes no public symbol present in v0.3.0;
every new capability is reached through new methods, types, and exports.
The only behavior changes an existing caller can observe are the two listed under
Breaking Changes above:
BlockType.listis now bullet-only, and default sentence splitting is now span-aware (boundaries may differ).
Full Changelog
https://github.com/jlevy/chopdiff/compare/v0.3.0 … v0.3.1
v0.3.0
This is a cleanup release that hardens the build, makes TextDoc source-referenced and
Markdown-block-aware, and removes the mandatory tiktoken (network) dependency.
It contains several intentional breaking changes for a cleaner API.
Breaking Changes
- Token counting is now a dependency-free estimate. The
tiktokendependency is removed (along withrequests/urllib3at install time), so chopdiff no longer downloads a tokenizer or needs network access to size or summarize a document.TextUnit.tiktokensis renamed toTextUnit.tokensand now returns a heuristic estimate (chopdiff.util.token_estimate.estimate_tokens, ~3.8 characters/token, a blend of current OpenAI/Anthropic/Google rules of thumb).chopdiff.util.tiktoken_utilsandtiktoken_lenare removed. Migratefrom chopdiff.util.tiktoken_utils import tiktoken_lentofrom chopdiff.util.token_estimate import estimate_tokens.size_summary()reports the estimate as~N tok.- Exact, provider-keyed token counts (tiktoken/OpenAI, and network-based counters for other providers) are planned as opt-in extras in a future release.
- Character offsets are now an
Offsetsrecord.Paragraph.char_offsetandSentence.char_offset(plain ints) are replaced by anOffsets(doc_offset, block_offset)record on both:doc_offsetis the absolute offset in the document;block_offsetis relative to the enclosing block (the document for a paragraph, the paragraph for a sentence).TextDoc.char_offset_in_doc(index)is removed; usedoc.get_sent(index).offsets.doc_offset.- Offsets are now exact references into the unmodified input text (the document is no longer stripped during parsing).
- Paragraph splitting recognizes all blank lines. Paragraphs split on two or more
newlines, including blank lines that contain only whitespace; runs of blank lines
collapse into a single break.
Previously only a literal
\n\nsplit. - Removed the unused
chopdiffconsole-script entry point. chopdiff is a library with no CLI; the entry point pointed at a non-existentmain.
New Features
- Markdown block-type classification.
BlockType(heading, paragraph, list, table, code, blockquote, html, footnote) andParagraph.block_type, classified by parsing each block with flowmark’s Markdown (marko) parser.TextDoc.iter_blocks(include=, exclude=)andTextDoc.filtered(include=, exclude=)iterate or sub-select blocks by type (e.g. process only paragraphs and list items, skipping headings and tables), and aggregate counts such as sentences/words across paragraph blocks. - Exact source references. Paragraph and sentence
Offsetsround-trip into the original text;TextDocdocuments a clear contract for offsets and in-place editing.
Infrastructure
- Supply-chain hardening: a 14-day dependency cool-off (
exclude-newer), a committed lockfile, frozenuv sync --lockedinstalls in CI, and apip-auditgate. SeeSUPPLY-CHAIN-SECURITY.md. - Upgraded to the simple-modern-uv template v0.2.26 (Python 3.14 in the CI matrix, uv
0.11.12, basedpyright 1.39.3, docs under
docs/). - The release workflow now installs from the committed lockfile (
--locked) for reproducible release builds.