metadata-enforcer v2: Django-like class definitions for Obsidian frontmatter.

Pasted image 20260807113959.png

Last week I published omarish/metadata-enforcer, a tool to keep my obsidian metadata organized. I started using it but the intermediate DSL felt superfluous; so I skipped a stable v1.0.0 release and went straight to v2.0.0 (hat-tip to you, Larry Ellison).

v2 is out: github.com/omarish/metadata-enforcer · release notes

What it looks like now

from metadata_enforcer import models

# Base class for pages I publish
class AbstractPage(models.Model):
    latex = models.BooleanField(
        default=False, description="Does this page contain LaTeX?"
    )
    sync_id = models.UUIDField(description="Used for syncing with website.")
    title = models.TextField()
    description = models.TextField(optional=True)

    class Meta:
        abstract = True


# Published essays get tags and a revision id
class Essay(AbstractPage):
    tags = models.TagsField(
        default=list, description="Tags associated with this essay."
    )
    revision_id = models.TextField(default="1")


# Stuff I won't publish doesn't need a sync id
class WontPublishEssay(Essay):
    sync_id = models.UUIDField(
        optional=True,
        description="Used for syncing with website when present.",
    )

Point the CLI at a schema.py that defines those models and a path map:

from pathlib import Path

ROUTES = {
    Path("essays/wont-publish"): WontPublishEssay,
    Path("essays"): Essay,
    Path("projects"): Project,
}

Then:

metadata-enforcer check ~/vault
metadata-enforcer fix ~/vault          # write defaults + field order
metadata-enforcer watch ~/vault --fix

Longest route prefix wins, so essays/wont-publish can be stricter (or looser) than essays.

Why the rewrite

In the spirit of "a man gives two reasons, and then the real reason", let's break convention. The biggest reason is:

  1. This is the version I actually use. The old tool was correct-ish; the new one is the one I actually leave running in the background.

Other reasons:

  1. One less layer of abstraction. Humans write less code now, domain-specific languages might become less necessary. Python classes are already a language I (and my tools) know.
  2. Inheritance is free. Abstract bases, field overrides, nested folder routes, as much complexity as you want.
  3. class Meta is a better home for collection rules. Uniqueness, table-level constraints, indexes: easier to grow here than by inventing more YAML next to JSON Schema. The goal is to get to PostgreSQL-level collection integrity enforcement, so this felt more complex and expressive.
  4. Runtime validation != typehints. Typehints are great for static checking. Using them as a full runtime schema language for “is this frontmatter a valid row?” felt overloaded. Explicit fields (models.TextField(...)) stay readable and do one job.

Install

uv tool install --force git+https://github.com/omarish/metadata-enforcer.git@v2.0.0
# or from a checkout: make install

v2.0.0 is totally incompatible and is guaranteed to have breaking changes from the release that came before it (v0.1.0).

Why does this matter?

Increasingly we're going to be spending more time writing surprising prose, not code. I needed some structure to my thoughts so I made this. Hope you find it useful and enjoy using it, too.