markstate 0.1.0__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.
- markstate-0.1.0/PKG-INFO +7 -0
- markstate-0.1.0/README.md +139 -0
- markstate-0.1.0/markstate/__init__.py +0 -0
- markstate-0.1.0/markstate/__main__.py +4 -0
- markstate-0.1.0/markstate/cli.py +111 -0
- markstate-0.1.0/markstate/config.py +105 -0
- markstate-0.1.0/markstate/engine.py +93 -0
- markstate-0.1.0/markstate/frontmatter.py +48 -0
- markstate-0.1.0/markstate.egg-info/PKG-INFO +7 -0
- markstate-0.1.0/markstate.egg-info/SOURCES.txt +14 -0
- markstate-0.1.0/markstate.egg-info/dependency_links.txt +1 -0
- markstate-0.1.0/markstate.egg-info/entry_points.txt +2 -0
- markstate-0.1.0/markstate.egg-info/requires.txt +2 -0
- markstate-0.1.0/markstate.egg-info/top_level.txt +1 -0
- markstate-0.1.0/pyproject.toml +34 -0
- markstate-0.1.0/setup.cfg +4 -0
markstate-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# markstate
|
|
2
|
+
|
|
3
|
+
Generic document flow processor for state tracking in markdown front matter.
|
|
4
|
+
|
|
5
|
+
Define a workflow in `flow.yml` — phases, gate conditions, and moves — then use `markstate` to track and advance documents through the flow.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install markstate
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
Create `flow.yml` in your project root:
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
status_field: status
|
|
19
|
+
|
|
20
|
+
phases:
|
|
21
|
+
- name: drafting
|
|
22
|
+
advance_when:
|
|
23
|
+
- file: spec.md
|
|
24
|
+
status: approved
|
|
25
|
+
|
|
26
|
+
- name: review
|
|
27
|
+
gates:
|
|
28
|
+
- file: spec.md
|
|
29
|
+
status: approved
|
|
30
|
+
advance_when:
|
|
31
|
+
- glob: "docs/*.md"
|
|
32
|
+
all_status: reviewed
|
|
33
|
+
|
|
34
|
+
moves:
|
|
35
|
+
- name: approve-spec
|
|
36
|
+
from: draft
|
|
37
|
+
to: approved
|
|
38
|
+
|
|
39
|
+
- name: mark-reviewed
|
|
40
|
+
from: in-review
|
|
41
|
+
to: reviewed
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Documents are markdown files with YAML front matter:
|
|
45
|
+
|
|
46
|
+
```markdown
|
|
47
|
+
---
|
|
48
|
+
status: draft
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
# My document
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
markstate status [DIRECTORY] Show current phase and completion status
|
|
58
|
+
markstate do MOVE TARGET Apply a named move to a document
|
|
59
|
+
markstate moves List all available moves
|
|
60
|
+
markstate check-gate PHASE [DIR] Check if gate conditions for a phase are met
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### `status`
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
$ markstate status
|
|
67
|
+
current phase: drafting
|
|
68
|
+
|
|
69
|
+
drafting gates=ok in progress
|
|
70
|
+
review gates=blocked in progress
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Add `--json` for machine-readable output.
|
|
74
|
+
|
|
75
|
+
### `do`
|
|
76
|
+
|
|
77
|
+
Apply a move to advance a document's status:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
$ markstate do approve-spec spec.md
|
|
81
|
+
spec.md: draft → approved
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Moves are validated against the current status — applying a move to a document in the wrong state is an error.
|
|
85
|
+
|
|
86
|
+
### `moves`
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
$ markstate moves
|
|
90
|
+
approve-spec draft → approved
|
|
91
|
+
mark-reviewed in-review → reviewed
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### `check-gate`
|
|
95
|
+
|
|
96
|
+
Exits 0 if all gate conditions pass, 1 otherwise:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
$ markstate check-gate review
|
|
100
|
+
gate not satisfied:
|
|
101
|
+
- spec.md must have status 'approved'
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Configuration reference
|
|
105
|
+
|
|
106
|
+
`flow.yml` is discovered by walking up from the current directory.
|
|
107
|
+
|
|
108
|
+
| Field | Description |
|
|
109
|
+
|---|---|
|
|
110
|
+
| `status_field` | Front matter key to track state (default: `status`) |
|
|
111
|
+
| `phases` | Ordered list of phases |
|
|
112
|
+
| `moves` | Named transitions between states |
|
|
113
|
+
|
|
114
|
+
**Phase fields:**
|
|
115
|
+
|
|
116
|
+
| Field | Description |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `name` | Phase name |
|
|
119
|
+
| `gates` | Conditions that must pass to enter this phase |
|
|
120
|
+
| `advance_when` | Conditions that must pass to leave this phase |
|
|
121
|
+
|
|
122
|
+
**Condition fields** (use one pair):
|
|
123
|
+
|
|
124
|
+
| Fields | Description |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `file` + `status` | A specific file must have the given status |
|
|
127
|
+
| `glob` + `all_status` | All files matching the glob must have the given status |
|
|
128
|
+
|
|
129
|
+
**Move fields:**
|
|
130
|
+
|
|
131
|
+
| Field | Description |
|
|
132
|
+
|---|---|
|
|
133
|
+
| `name` | Move name (used with `markstate do`) |
|
|
134
|
+
| `from` | Required current status |
|
|
135
|
+
| `to` | New status after applying the move |
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""CLI entry point for doc-flow."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from markstate import engine
|
|
10
|
+
from markstate.config import FlowConfig, find_and_load
|
|
11
|
+
from markstate.engine import MoveError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_config() -> FlowConfig:
|
|
15
|
+
try:
|
|
16
|
+
return find_and_load()
|
|
17
|
+
except FileNotFoundError as e:
|
|
18
|
+
click.echo(f"error: {e}", err=True)
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@click.group()
|
|
23
|
+
def main() -> None:
|
|
24
|
+
"""Generic document flow processor."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@main.command()
|
|
28
|
+
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
|
29
|
+
@click.argument("directory", default=".", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
30
|
+
def status(as_json: bool, directory: Path) -> None:
|
|
31
|
+
"""Show current phase and phase completion status."""
|
|
32
|
+
config = _load_config()
|
|
33
|
+
result = engine.status(config, directory.resolve())
|
|
34
|
+
if as_json:
|
|
35
|
+
click.echo(json.dumps(result, indent=2))
|
|
36
|
+
else:
|
|
37
|
+
phase = result["current_phase"]
|
|
38
|
+
click.echo(f"current phase: {phase or '(complete)'}")
|
|
39
|
+
click.echo()
|
|
40
|
+
for p in result["phases"]:
|
|
41
|
+
gate = "ok" if p["gates_pass"] else "blocked"
|
|
42
|
+
done = "complete" if p["complete"] else "in progress"
|
|
43
|
+
click.echo(f" {p['name']:20s} gates={gate} {done}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@main.command("check-gate")
|
|
47
|
+
@click.argument("phase_name")
|
|
48
|
+
@click.argument("directory", default=".", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
49
|
+
def check_gate(phase_name: str, directory: Path) -> None:
|
|
50
|
+
"""Check if all gate conditions for a phase are met."""
|
|
51
|
+
config = _load_config()
|
|
52
|
+
phase = config.phase(phase_name)
|
|
53
|
+
if phase is None:
|
|
54
|
+
click.echo(f"error: unknown phase '{phase_name}'", err=True)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
unmet = engine.check_gate(phase, config, directory.resolve())
|
|
58
|
+
if unmet:
|
|
59
|
+
click.echo("gate not satisfied:")
|
|
60
|
+
for condition in unmet:
|
|
61
|
+
click.echo(f" - {condition}")
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
else:
|
|
64
|
+
click.echo("gate satisfied")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class MoveName(click.ParamType):
|
|
68
|
+
"""Dynamic choice type that loads move names from flow.yml."""
|
|
69
|
+
|
|
70
|
+
name = "move"
|
|
71
|
+
|
|
72
|
+
def convert(self, value: str, param: click.Parameter | None, ctx: click.Context | None) -> str:
|
|
73
|
+
try:
|
|
74
|
+
config = find_and_load()
|
|
75
|
+
names = config.move_names()
|
|
76
|
+
if value not in names:
|
|
77
|
+
self.fail(f"'{value}' is not a valid move. Choose from: {', '.join(names)}", param, ctx)
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
pass # let the command handle the missing config
|
|
80
|
+
return value
|
|
81
|
+
|
|
82
|
+
def shell_complete(self, ctx: click.Context, param: click.Parameter, incomplete: str):
|
|
83
|
+
from click.shell_completion import CompletionItem
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
config = find_and_load()
|
|
87
|
+
return [CompletionItem(name) for name in config.move_names() if name.startswith(incomplete)]
|
|
88
|
+
except FileNotFoundError:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@main.command("do")
|
|
93
|
+
@click.argument("move_name", type=MoveName())
|
|
94
|
+
@click.argument("target", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
95
|
+
def do_move(move_name: str, target: Path) -> None:
|
|
96
|
+
"""Apply a named move to a document."""
|
|
97
|
+
config = _load_config()
|
|
98
|
+
try:
|
|
99
|
+
old, new = engine.do_move(move_name, target.resolve(), config)
|
|
100
|
+
click.echo(f"{target}: {old} → {new}")
|
|
101
|
+
except MoveError as e:
|
|
102
|
+
click.echo(f"error: {e}", err=True)
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@main.command("moves")
|
|
107
|
+
def list_moves() -> None:
|
|
108
|
+
"""List all available moves."""
|
|
109
|
+
config = _load_config()
|
|
110
|
+
for move in config.moves:
|
|
111
|
+
click.echo(f" {move.name:20s} {move.from_state} → {move.to_state}")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Load and validate flow.yml, walking up from cwd to find it."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
CONFIG_FILENAME = "flow.yml"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Move:
|
|
14
|
+
name: str
|
|
15
|
+
from_state: str
|
|
16
|
+
to_state: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Condition:
|
|
21
|
+
file: str | None = None
|
|
22
|
+
glob: str | None = None
|
|
23
|
+
status: str | None = None
|
|
24
|
+
all_status: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Phase:
|
|
29
|
+
name: str
|
|
30
|
+
produces: list[str] = field(default_factory=list)
|
|
31
|
+
gates: list[Condition] = field(default_factory=list)
|
|
32
|
+
advance_when: list[Condition] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class FlowConfig:
|
|
37
|
+
root: Path
|
|
38
|
+
status_field: str
|
|
39
|
+
phases: list[Phase]
|
|
40
|
+
moves: list[Move]
|
|
41
|
+
|
|
42
|
+
def move(self, name: str) -> Move | None:
|
|
43
|
+
return next((m for m in self.moves if m.name == name), None)
|
|
44
|
+
|
|
45
|
+
def phase(self, name: str) -> Phase | None:
|
|
46
|
+
return next((p for p in self.phases if p.name == name), None)
|
|
47
|
+
|
|
48
|
+
def move_names(self) -> list[str]:
|
|
49
|
+
return [m.name for m in self.moves]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def find_and_load(start: Path | None = None) -> FlowConfig:
|
|
53
|
+
"""Walk up from start (default: cwd) to find flow.yml and load it."""
|
|
54
|
+
path = _find(start or Path.cwd())
|
|
55
|
+
if path is None:
|
|
56
|
+
raise FileNotFoundError(f"{CONFIG_FILENAME} not found in {start or Path.cwd()} or any parent")
|
|
57
|
+
return _load(path)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _find(start: Path) -> Path | None:
|
|
61
|
+
for directory in [start, *start.parents]:
|
|
62
|
+
candidate = directory / CONFIG_FILENAME
|
|
63
|
+
if candidate.exists():
|
|
64
|
+
return candidate
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load(path: Path) -> FlowConfig:
|
|
69
|
+
raw = yaml.safe_load(path.read_text())
|
|
70
|
+
|
|
71
|
+
phases = [_parse_phase(p) for p in raw.get("phases", [])]
|
|
72
|
+
moves = [_parse_move(m) for m in raw.get("moves", [])]
|
|
73
|
+
|
|
74
|
+
return FlowConfig(
|
|
75
|
+
root=path.parent,
|
|
76
|
+
status_field=raw.get("status_field", "status"),
|
|
77
|
+
phases=phases,
|
|
78
|
+
moves=moves,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _parse_phase(raw: dict) -> Phase:
|
|
83
|
+
return Phase(
|
|
84
|
+
name=raw["name"],
|
|
85
|
+
produces=raw.get("produces", []),
|
|
86
|
+
gates=[_parse_condition(c) for c in raw.get("gates", [])],
|
|
87
|
+
advance_when=[_parse_condition(c) for c in raw.get("advance_when", [])],
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_move(raw: dict) -> Move:
|
|
92
|
+
return Move(
|
|
93
|
+
name=raw["name"],
|
|
94
|
+
from_state=raw["from"],
|
|
95
|
+
to_state=raw["to"],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _parse_condition(raw: dict) -> Condition:
|
|
100
|
+
return Condition(
|
|
101
|
+
file=raw.get("file"),
|
|
102
|
+
glob=raw.get("glob"),
|
|
103
|
+
status=raw.get("status"),
|
|
104
|
+
all_status=raw.get("all_status"),
|
|
105
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""State engine: evaluate conditions and execute moves."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from markstate import frontmatter
|
|
6
|
+
from markstate.config import Condition, FlowConfig, Move, Phase
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MoveError(Exception):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def current_phase(config: FlowConfig, directory: Path) -> Phase | None:
|
|
14
|
+
"""Return the first phase whose gates all pass but advance_when conditions don't all pass."""
|
|
15
|
+
for phase in config.phases:
|
|
16
|
+
if not _all_pass(phase.gates, config, directory):
|
|
17
|
+
continue
|
|
18
|
+
if not _all_pass(phase.advance_when, config, directory):
|
|
19
|
+
return phase
|
|
20
|
+
return None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_gate(phase: Phase, config: FlowConfig, directory: Path) -> list[str]:
|
|
24
|
+
"""Return list of unmet gate conditions, empty if all pass."""
|
|
25
|
+
return [_describe(c) for c in phase.gates if not _evaluate(c, config, directory)]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def do_move(move_name: str, target: Path, config: FlowConfig) -> tuple[str, str]:
|
|
29
|
+
"""Execute a named move on target file. Returns (old_status, new_status)."""
|
|
30
|
+
move = config.move(move_name)
|
|
31
|
+
if move is None:
|
|
32
|
+
raise MoveError(f"unknown move '{move_name}'. Available: {config.move_names()}")
|
|
33
|
+
|
|
34
|
+
doc = frontmatter.load(target)
|
|
35
|
+
current = str(doc.get(config.status_field) or "")
|
|
36
|
+
|
|
37
|
+
if current != move.from_state:
|
|
38
|
+
raise MoveError(
|
|
39
|
+
f"cannot apply move '{move_name}' to '{target.name}': "
|
|
40
|
+
f"expected status '{move.from_state}', got '{current}'"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
doc.set(config.status_field, move.to_state)
|
|
44
|
+
doc.save()
|
|
45
|
+
return current, move.to_state
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def status(config: FlowConfig, directory: Path) -> dict[str, object]:
|
|
49
|
+
"""Return a status summary for the given directory."""
|
|
50
|
+
phase = current_phase(config, directory)
|
|
51
|
+
return {
|
|
52
|
+
"current_phase": phase.name if phase else None,
|
|
53
|
+
"phases": [
|
|
54
|
+
{
|
|
55
|
+
"name": p.name,
|
|
56
|
+
"gates_pass": _all_pass(p.gates, config, directory),
|
|
57
|
+
"complete": _all_pass(p.advance_when, config, directory),
|
|
58
|
+
}
|
|
59
|
+
for p in config.phases
|
|
60
|
+
],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _all_pass(conditions: list[Condition], config: FlowConfig, directory: Path) -> bool:
|
|
65
|
+
return all(_evaluate(c, config, directory) for c in conditions)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _evaluate(condition: Condition, config: FlowConfig, directory: Path) -> bool:
|
|
69
|
+
if condition.file is not None and condition.status is not None:
|
|
70
|
+
path = directory / condition.file
|
|
71
|
+
if not path.exists():
|
|
72
|
+
return False
|
|
73
|
+
doc = frontmatter.load(path)
|
|
74
|
+
return str(doc.get(config.status_field) or "") == condition.status
|
|
75
|
+
|
|
76
|
+
if condition.glob is not None and condition.all_status is not None:
|
|
77
|
+
paths = list(directory.glob(condition.glob))
|
|
78
|
+
if not paths:
|
|
79
|
+
return False
|
|
80
|
+
return all(
|
|
81
|
+
str(frontmatter.load(p).get(config.status_field) or "") == condition.all_status
|
|
82
|
+
for p in paths
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _describe(condition: Condition) -> str:
|
|
89
|
+
if condition.file and condition.status:
|
|
90
|
+
return f"{condition.file} must have status '{condition.status}'"
|
|
91
|
+
if condition.glob and condition.all_status:
|
|
92
|
+
return f"all files matching '{condition.glob}' must have status '{condition.all_status}'"
|
|
93
|
+
return str(condition)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Read and write YAML front matter in markdown files."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
DELIMITER = "---"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Document:
|
|
14
|
+
path: Path
|
|
15
|
+
front_matter: dict[str, object] = field(default_factory=dict)
|
|
16
|
+
body: str = ""
|
|
17
|
+
|
|
18
|
+
def get(self, key: str) -> object | None:
|
|
19
|
+
return self.front_matter.get(key)
|
|
20
|
+
|
|
21
|
+
def set(self, key: str, value: object) -> None:
|
|
22
|
+
self.front_matter[key] = value
|
|
23
|
+
|
|
24
|
+
def save(self) -> None:
|
|
25
|
+
self.path.write_text(_serialize(self.front_matter, self.body))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load(path: Path) -> Document:
|
|
29
|
+
text = path.read_text()
|
|
30
|
+
front_matter, body = _parse(text)
|
|
31
|
+
return Document(path=path, front_matter=front_matter, body=body)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse(text: str) -> tuple[dict[str, object], str]:
|
|
35
|
+
if not text.startswith(DELIMITER + "\n"):
|
|
36
|
+
return {}, text
|
|
37
|
+
|
|
38
|
+
end = text.index("\n" + DELIMITER, len(DELIMITER))
|
|
39
|
+
raw = text[len(DELIMITER) + 1 : end]
|
|
40
|
+
body = text[end + len(DELIMITER) + 2 :] # skip closing --- and newline
|
|
41
|
+
return yaml.safe_load(raw) or {}, body
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _serialize(front_matter: dict[str, object], body: str) -> str:
|
|
45
|
+
if not front_matter:
|
|
46
|
+
return body
|
|
47
|
+
raw = yaml.dump(front_matter, default_flow_style=False, allow_unicode=True)
|
|
48
|
+
return f"{DELIMITER}\n{raw}{DELIMITER}\n{body}"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
markstate/__init__.py
|
|
4
|
+
markstate/__main__.py
|
|
5
|
+
markstate/cli.py
|
|
6
|
+
markstate/config.py
|
|
7
|
+
markstate/engine.py
|
|
8
|
+
markstate/frontmatter.py
|
|
9
|
+
markstate.egg-info/PKG-INFO
|
|
10
|
+
markstate.egg-info/SOURCES.txt
|
|
11
|
+
markstate.egg-info/dependency_links.txt
|
|
12
|
+
markstate.egg-info/entry_points.txt
|
|
13
|
+
markstate.egg-info/requires.txt
|
|
14
|
+
markstate.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
markstate
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "markstate"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Generic document flow processor for state tracking in markdown front matter"
|
|
5
|
+
requires-python = ">=3.11"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"click>=8.3.1",
|
|
8
|
+
"pyyaml>=6.0.3",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[project.scripts]
|
|
12
|
+
markstate = "markstate.cli:main"
|
|
13
|
+
|
|
14
|
+
[tool.uv]
|
|
15
|
+
package = true
|
|
16
|
+
|
|
17
|
+
[[tool.uv.index]]
|
|
18
|
+
url = "https://pypi.org/simple"
|
|
19
|
+
default = true
|
|
20
|
+
|
|
21
|
+
[tool.ruff]
|
|
22
|
+
line-length = 100
|
|
23
|
+
target-version = "py311"
|
|
24
|
+
|
|
25
|
+
[tool.ruff.lint]
|
|
26
|
+
select = [
|
|
27
|
+
"E", # pycodestyle errors
|
|
28
|
+
"F", # pyflakes
|
|
29
|
+
"I", # isort
|
|
30
|
+
"UP", # pyupgrade
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[tool.ruff.format]
|
|
34
|
+
quote-style = "double"
|