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.
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: markstate
3
+ Version: 0.1.0
4
+ Summary: Generic document flow processor for state tracking in markdown front matter
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.3.1
7
+ Requires-Dist: pyyaml>=6.0.3
@@ -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,4 @@
1
+ from markstate.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: markstate
3
+ Version: 0.1.0
4
+ Summary: Generic document flow processor for state tracking in markdown front matter
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.3.1
7
+ Requires-Dist: pyyaml>=6.0.3
@@ -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,2 @@
1
+ [console_scripts]
2
+ markstate = markstate.cli:main
@@ -0,0 +1,2 @@
1
+ click>=8.3.1
2
+ pyyaml>=6.0.3
@@ -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"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+