Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions .claude/hooks/flake8_gate.py

This file was deleted.

23 changes: 23 additions & 0 deletions .claude/hooks/lint_gate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""PostToolUse hook: ruff format + ruff check an edited .py file.

Exit 2 feeds the lint output back to Claude so it fixes the error.
"""

import json
import subprocess
import sys

payload = json.load(sys.stdin)
path = (
(payload.get("tool_input") or {}).get("file_path")
or (payload.get("tool_response") or {}).get("filePath")
or ""
)

if path.endswith(".py"):
ruff = [sys.executable, "-m", "ruff"]
subprocess.run([*ruff, "format", "--quiet", path], capture_output=True)
result = subprocess.run([*ruff, "check", "--quiet", path], capture_output=True, text=True)
if result.returncode != 0:
sys.stderr.write("ruff check failed:\n" + result.stdout + result.stderr)
sys.exit(2)
4 changes: 2 additions & 2 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
"hooks": [
{
"type": "command",
"command": "uv run -q --no-project --with flake8 python \"$CLAUDE_PROJECT_DIR/.claude/hooks/flake8_gate.py\"",
"command": "uv run -q --project \"$CLAUDE_PROJECT_DIR\" python \"$CLAUDE_PROJECT_DIR/.claude/hooks/lint_gate.py\"",
"timeout": 60,
"statusMessage": "flake8 CI gate..."
"statusMessage": "ruff..."
}
]
}
Expand Down
22 changes: 11 additions & 11 deletions .claude/skills/release/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
---
name: release
description: Release a new md-condition version to PyPI — bump setup.py, verify, push, and create the GitHub release that triggers the publish workflow. Usage: /release <version>
description: Release a new md-condition version to PyPI — verify master, then create the GitHub release whose tag sets the version and triggers the publish workflow. Usage: /release <version>
disable-model-invocation: true
---

Release version `$ARGUMENTS` (e.g. `0.1.9`, no `v` prefix). If empty, read the current `version` in `setup.py` and ask which version to release.
Release version `$ARGUMENTS` (e.g. `0.1.9`, no `v` prefix). If empty, show the latest tag (`git describe --tags --abbrev=0`) and ask which version to release.

1. Preconditions: on `master`, working tree clean, `git pull` up to date. `git tag` must not already contain `v$ARGUMENTS`. Stop and report if any fail.
2. Set `version='$ARGUMENTS'` in `setup.py`.
3. Verify — both must pass before continuing:
- `uv run --no-project --with markdown --with pytest pytest -v test.py`
- `uv run --no-project --with flake8 flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics`
4. Commit `setup.py` with message `Bump version to $ARGUMENTS`, then `git push origin master`.
5. Summarize commits since the previous tag (`git log <prev-tag>..HEAD --oneline`) as release notes, and show them to the user for confirmation before creating the release.
6. `gh release create v$ARGUMENTS --target master --title v$ARGUMENTS --notes "<notes>"`. This fires `.github/workflows/pythonpublish.yml`, which uploads to PyPI.
7. Report the release URL and check the publish run with `gh run list --workflow pythonpublish.yml --limit 1`.
The version comes from the git tag via setuptools-scm, so there is no file to bump.

1. Preconditions: on `master`, working tree clean, `git pull` up to date. `git tag` must not already contain `v$ARGUMENTS`. The latest `ci-ok` run on master must be green (`gh run list --branch master --workflow pythonapp.yml --limit 1`). Stop and report if any fail.
2. Verify locally — both must pass:
- `uv run pytest`
- `uv run ruff check .` and `uv run ruff format --check .`
3. Summarize commits since the previous tag (`git log <prev-tag>..HEAD --oneline`) as release notes, and show them to the user for confirmation before creating the release.
4. `gh release create v$ARGUMENTS --target master --title v$ARGUMENTS --notes "<notes>"`. This fires `.github/workflows/pythonpublish.yml`, which builds with version `$ARGUMENTS` and uploads to PyPI.
5. Report the release URL and check the publish run with `gh run list --workflow pythonpublish.yml --limit 1`.
2 changes: 1 addition & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ updates:
directory: /
schedule:
interval: weekly
- package-ecosystem: pip
- package-ecosystem: uv
directory: /
schedule:
interval: weekly
46 changes: 25 additions & 21 deletions .github/workflows/pythonapp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,40 +9,44 @@ permissions:
contents: read

jobs:
build:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v6
- run: uv run ruff format --check .
- run: uv run ruff check .

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']

markdown: ['locked']
include:
# Lowest supported Markdown (pyproject: Markdown>=3.2). Overrides the
# `markdown` axis value, so this becomes an extra job instead of
# being merged into the py3.10 one.
- python-version: '3.10'
markdown: '3.2.*'
name: test (py${{ matrix.python-version }}${{ matrix.markdown != 'locked' && format(', markdown {0}', matrix.markdown) || '' }})
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
- uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
- name: Lint with flake8
run: |
pip install flake8
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Test with pytest
run: |
pip install pytest
pytest -v test.py
- name: Test
if: ${{ matrix.markdown == 'locked' }}
run: uv run pytest -v
- name: Test with lowest Markdown
if: ${{ matrix.markdown != 'locked' }}
run: uv run --isolated --with "markdown==${{ matrix.markdown }}" pytest -v

# Single stable check name for the branch ruleset, independent of the matrix
ci-ok:
if: always()
needs: build
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- run: test "${{ needs.build.result }}" = success
- run: test "${{ needs.lint.result }}" = success && test "${{ needs.test.result }}" = success
9 changes: 3 additions & 6 deletions .github/workflows/pythonpublish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,10 @@ jobs:
id-token: write # PyPI Trusted Publishing
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.x'
fetch-depth: 0 # setuptools-scm derives the version from tags
- uses: astral-sh/setup-uv@v6
- name: Build
run: |
python -m pip install --upgrade pip build
python -m build
run: uv build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
17 changes: 8 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Commands

`python` on this machine is the Windows Store stub, not a real interpreter. Run everything through `uv`:
`python` on this machine is the Windows Store stub, not a real interpreter. Run everything through `uv` (dev deps live in the `dev` dependency group of `pyproject.toml`):

- Tests: `uv run --no-project --with markdown --with pytest pytest -v test.py` (tests live in the root `test.py`, not a `tests/` dir)
- Single test: append `-k test_single_else`
- Lint (the CI gate that fails builds): `uv run --no-project --with flake8 flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics`
- CI also runs flake8 with `--max-line-length=127 --max-complexity=10` as warnings only
- Tests: `uv run pytest` (single test: `-k single_else`)
- Lowest supported Markdown: `uv run --isolated --python 3.10 --with "markdown==3.2.*" pytest`
- Lint/format (CI fails on either): `uv run ruff check .` and `uv run ruff format --check .`
- Build: `uv build`. The version comes from the latest git tag via setuptools-scm; there is no version field to edit.

## Code

- Target modern Python 3 (CI matrix: 3.10–3.14). Py2-isms (`from __future__`, `super(Cls, self)`) may be modernized.
- master is protected by a ruleset: changes go through PRs, and the `ci-ok` check (aggregates the matrix) must pass.
- Python >=3.10, Markdown >=3.2. Markdown 3.0/3.1 can't import on modern Python (they need `pkg_resources`).
- master is protected by a ruleset: changes go through PRs, and the `ci-ok` check (aggregates lint + the test matrix) must pass.
- The whole extension is a single `Preprocessor` in `md_condition/preprocessor.py`, registered at priority 50. `makeExtension` in `__init__.py` is what lets `md_condition` work as a string name in MkDocs `markdown_extensions`.
- Known behaviors to keep in mind (don't change them silently, since they're user-visible):
- The symbol is interpolated into a regex unescaped and matched as a substring (`.*DEBUG.*`), so `DEBUG` also matches `#if DEBUG_X`.
Expand All @@ -23,5 +23,4 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Release

1. Bump `version` in `setup.py` and commit.
2. Create a GitHub release with tag `v<version>`. `.github/workflows/pythonpublish.yml` builds and uploads to PyPI via Trusted Publishing (OIDC, GitHub environment `pypi`; no API token secrets).
Create a GitHub release with tag `v<version>` (use `/release`). `.github/workflows/pythonpublish.yml` builds with the tag as the version and uploads to PyPI via Trusted Publishing (OIDC, GitHub environment `pypi`; no API token secrets).
12 changes: 7 additions & 5 deletions md_condition/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from md_condition.extension import ConditionExtension


def makeExtension(**kwargs):
return ConditionExtension(**kwargs)
from md_condition.extension import ConditionExtension

__all__ = ["ConditionExtension", "makeExtension"]


def makeExtension(**kwargs) -> ConditionExtension:
return ConditionExtension(**kwargs)
32 changes: 15 additions & 17 deletions md_condition/extension.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
from markdown.extensions import Extension
from md_condition.preprocessor import ConditionPreprocessor


class ConditionExtension(Extension):

def __init__(self, **kwargs):
self.config = {
'symbol': ['', 'Condition syombol'],
}
super(ConditionExtension, self).__init__(**kwargs)

def extendMarkdown(self, md):
md.preprocessors.register(
ConditionPreprocessor(md, self),
'condition',
50)
from markdown import Markdown
from markdown.extensions import Extension

from md_condition.preprocessor import ConditionPreprocessor


class ConditionExtension(Extension):
def __init__(self, **kwargs) -> None:
self.config = {
"symbol": ["", "Condition symbol"],
}
super().__init__(**kwargs)

def extendMarkdown(self, md: Markdown) -> None:
md.preprocessors.register(ConditionPreprocessor(md, self), "condition", 50)
119 changes: 62 additions & 57 deletions md_condition/preprocessor.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,62 @@
from __future__ import unicode_literals, absolute_import
import re

from markdown.preprocessors import Preprocessor


class ConditionPreprocessor(Preprocessor):

RE_START = re.compile(r'<!--- #if .* -->')
RE_END = re.compile(r'<!--- #endif -->')
RE_ELSE = re.compile(r'<!--- #else -->')

def __init__(self, md, extension):
super(ConditionPreprocessor, self).__init__(md)
symbol = extension.getConfig('symbol')
self.re_symbol_start = re.compile(r'<!--- #if .*' + symbol + r'.* -->')

def validate(self, lines):
block_counter = 0
target_lines_start = []
target_lines_end = []
for index, line in enumerate(lines):
if self.RE_START.match(line):
target_lines_start.append(str(index) + '. ' + line)
if self.RE_END.match(line):
target_lines_end.append(str(index) + '. ' + line)

assert len(target_lines_start) == len(target_lines_end), "Unbalanced if / endif blocks\n{}\n{}".format(
target_lines_start, target_lines_end)

def run(self, lines):
self.validate(lines)
new_lines = []

matching = False
symbol_matching = False
for line in lines:
start_head = False
if not matching:
if self.RE_START.match(line):
start_head = True
matching = True
symbol_match_start = self.re_symbol_start.match(line)
if symbol_match_start:
symbol_matching = True
else:
if self.RE_ELSE.match(line):
symbol_matching = not symbol_matching
continue
if self.RE_END.match(line):
matching = False
symbol_matching = False
continue
if not matching or symbol_matching and not start_head:
new_lines.append(line)

return new_lines
from __future__ import annotations

import re
from typing import TYPE_CHECKING

from markdown import Markdown
from markdown.preprocessors import Preprocessor

if TYPE_CHECKING:
from md_condition.extension import ConditionExtension


class ConditionPreprocessor(Preprocessor):
RE_START = re.compile(r"<!--- #if .* -->")
RE_END = re.compile(r"<!--- #endif -->")
RE_ELSE = re.compile(r"<!--- #else -->")

def __init__(self, md: Markdown, extension: ConditionExtension) -> None:
super().__init__(md)
symbol = extension.getConfig("symbol")
self.re_symbol_start = re.compile(r"<!--- #if .*" + symbol + r".* -->")

def validate(self, lines: list[str]) -> None:
target_lines_start = []
target_lines_end = []
for index, line in enumerate(lines):
if self.RE_START.match(line):
target_lines_start.append(f"{index}. {line}")
if self.RE_END.match(line):
target_lines_end.append(f"{index}. {line}")

assert len(target_lines_start) == len(target_lines_end), (
f"Unbalanced if / endif blocks\n{target_lines_start}\n{target_lines_end}"
)

def run(self, lines: list[str]) -> list[str]:
self.validate(lines)
new_lines = []

matching = False
symbol_matching = False
for line in lines:
start_head = False
if not matching:
if self.RE_START.match(line):
start_head = True
matching = True
symbol_match_start = self.re_symbol_start.match(line)
if symbol_match_start:
symbol_matching = True
else:
if self.RE_ELSE.match(line):
symbol_matching = not symbol_matching
continue
if self.RE_END.match(line):
matching = False
symbol_matching = False
continue
if not matching or symbol_matching and not start_head:
new_lines.append(line)

return new_lines
Loading
Loading