daimyo 1.0.0__py3-none-any.whl
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.
- daimyo/__init__.py +11 -0
- daimyo/__main__.py +205 -0
- daimyo/application/__init__.py +1 -0
- daimyo/application/filtering/__init__.py +5 -0
- daimyo/application/filtering/category_filter.py +81 -0
- daimyo/application/formatters/__init__.py +8 -0
- daimyo/application/formatters/json_formatter.py +53 -0
- daimyo/application/formatters/markdown_formatter.py +149 -0
- daimyo/application/formatters/tree_builder.py +105 -0
- daimyo/application/formatters/yaml_formatter.py +84 -0
- daimyo/application/rule_service.py +140 -0
- daimyo/application/scope_resolution/__init__.py +5 -0
- daimyo/application/scope_resolution/circular_dependency_detector.py +45 -0
- daimyo/application/scope_resolution/multi_parent_resolver.py +80 -0
- daimyo/application/scope_resolution/parent_resolver.py +84 -0
- daimyo/application/scope_resolution/remote_scope_fetcher.py +43 -0
- daimyo/application/scope_resolution/scope_resolver.py +99 -0
- daimyo/application/scope_resolution/shard_merger.py +82 -0
- daimyo/application/scope_service.py +9 -0
- daimyo/config/__init__.py +5 -0
- daimyo/config/settings.py +25 -0
- daimyo/domain/__init__.py +51 -0
- daimyo/domain/exceptions.py +68 -0
- daimyo/domain/models.py +296 -0
- daimyo/domain/protocols.py +59 -0
- daimyo/infrastructure/__init__.py +1 -0
- daimyo/infrastructure/di/__init__.py +5 -0
- daimyo/infrastructure/di/container.py +120 -0
- daimyo/infrastructure/filesystem/__init__.py +12 -0
- daimyo/infrastructure/filesystem/scope_loader.py +112 -0
- daimyo/infrastructure/filesystem/yaml_parser.py +188 -0
- daimyo/infrastructure/logging/__init__.py +5 -0
- daimyo/infrastructure/logging/logger.py +73 -0
- daimyo/infrastructure/remote/__init__.py +5 -0
- daimyo/infrastructure/remote/remote_client.py +178 -0
- daimyo/presentation/__init__.py +1 -0
- daimyo/presentation/mcp/__init__.py +1 -0
- daimyo/presentation/mcp/server.py +128 -0
- daimyo/presentation/rest/__init__.py +1 -0
- daimyo/presentation/rest/app.py +99 -0
- daimyo/presentation/rest/dependencies.py +41 -0
- daimyo/presentation/rest/models.py +68 -0
- daimyo/presentation/rest/routers/__init__.py +1 -0
- daimyo/presentation/rest/routers/scopes.py +279 -0
- daimyo-1.0.0.dist-info/METADATA +301 -0
- daimyo-1.0.0.dist-info/RECORD +49 -0
- daimyo-1.0.0.dist-info/WHEEL +4 -0
- daimyo-1.0.0.dist-info/entry_points.txt +2 -0
- daimyo-1.0.0.dist-info/licenses/LICENSE +21 -0
daimyo/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Daimyo - Rules Server for Agents
|
|
3
|
+
|
|
4
|
+
A Python server providing rules to agents through REST and MCP interfaces.
|
|
5
|
+
Supports scope-based rules with inheritance, categories for filtering,
|
|
6
|
+
and server federation for distributed rule management.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
daimyo/__main__.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""CLI entry point for Daimyo rules server."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
import uvicorn
|
|
7
|
+
|
|
8
|
+
from daimyo import __version__
|
|
9
|
+
from daimyo.config import settings
|
|
10
|
+
from daimyo.infrastructure.logging import setup_logging
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="daimyo",
|
|
14
|
+
help="Daimyo - Rules Server for Agents",
|
|
15
|
+
add_completion=False,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TransportType(str, Enum):
|
|
20
|
+
"""MCP transport types."""
|
|
21
|
+
|
|
22
|
+
STDIO = "stdio"
|
|
23
|
+
SSE = "sse"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def version_callback(value: bool) -> None:
|
|
27
|
+
"""Print version and exit.
|
|
28
|
+
|
|
29
|
+
:param value: Whether version flag was set
|
|
30
|
+
:type value: bool
|
|
31
|
+
:rtype: None
|
|
32
|
+
:raises typer.Exit: Always exits after printing version
|
|
33
|
+
"""
|
|
34
|
+
if value:
|
|
35
|
+
typer.echo(f"daimyo version {__version__}")
|
|
36
|
+
raise typer.Exit()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.callback()
|
|
40
|
+
def main(
|
|
41
|
+
version: bool = typer.Option(
|
|
42
|
+
False,
|
|
43
|
+
"--version",
|
|
44
|
+
"-v",
|
|
45
|
+
callback=version_callback,
|
|
46
|
+
is_eager=True,
|
|
47
|
+
help="Show version and exit",
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Daimyo - Rules Server for Agents.
|
|
51
|
+
|
|
52
|
+
:param version: Show version flag
|
|
53
|
+
:type version: bool
|
|
54
|
+
:rtype: None
|
|
55
|
+
"""
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def serve(
|
|
61
|
+
host: str = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--host",
|
|
64
|
+
help=f"Host to bind to (default: {settings.REST_HOST})",
|
|
65
|
+
),
|
|
66
|
+
port: int = typer.Option(
|
|
67
|
+
None,
|
|
68
|
+
"--port",
|
|
69
|
+
help=f"Port to bind to (default: {settings.REST_PORT})",
|
|
70
|
+
),
|
|
71
|
+
reload: bool = typer.Option(
|
|
72
|
+
False,
|
|
73
|
+
"--reload",
|
|
74
|
+
help="Enable auto-reload for development",
|
|
75
|
+
),
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Start the REST API server.
|
|
78
|
+
|
|
79
|
+
:param host: Host to bind to
|
|
80
|
+
:type host: str
|
|
81
|
+
:param port: Port to bind to
|
|
82
|
+
:type port: int
|
|
83
|
+
:param reload: Enable auto-reload for development
|
|
84
|
+
:type reload: bool
|
|
85
|
+
:rtype: None
|
|
86
|
+
"""
|
|
87
|
+
setup_logging()
|
|
88
|
+
|
|
89
|
+
resolved_host = host or settings.REST_HOST
|
|
90
|
+
resolved_port = port or settings.REST_PORT
|
|
91
|
+
|
|
92
|
+
typer.echo(f"Starting Daimyo REST API server on {resolved_host}:{resolved_port}")
|
|
93
|
+
typer.echo(f"API documentation available at http://{resolved_host}:{resolved_port}/docs")
|
|
94
|
+
|
|
95
|
+
uvicorn.run(
|
|
96
|
+
"daimyo.presentation.rest.app:app",
|
|
97
|
+
host=resolved_host,
|
|
98
|
+
port=resolved_port,
|
|
99
|
+
reload=reload,
|
|
100
|
+
log_config=None,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def mcp(
|
|
106
|
+
transport: TransportType = typer.Option(
|
|
107
|
+
None,
|
|
108
|
+
"--transport",
|
|
109
|
+
help=f"MCP transport type (default: {settings.MCP_TRANSPORT})",
|
|
110
|
+
),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Start the MCP server.
|
|
113
|
+
|
|
114
|
+
:param transport: MCP transport type
|
|
115
|
+
:type transport: TransportType
|
|
116
|
+
:rtype: None
|
|
117
|
+
"""
|
|
118
|
+
setup_logging()
|
|
119
|
+
|
|
120
|
+
resolved_transport = transport.value if transport else settings.MCP_TRANSPORT
|
|
121
|
+
|
|
122
|
+
typer.echo(f"Starting Daimyo MCP server with {resolved_transport} transport")
|
|
123
|
+
|
|
124
|
+
from daimyo.presentation.mcp.server import mcp as mcp_server
|
|
125
|
+
|
|
126
|
+
if resolved_transport == "stdio":
|
|
127
|
+
mcp_server.run(transport="stdio")
|
|
128
|
+
else:
|
|
129
|
+
mcp_server.run(transport="sse")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@app.command()
|
|
133
|
+
def list_scopes() -> None:
|
|
134
|
+
"""List all available scopes.
|
|
135
|
+
|
|
136
|
+
:rtype: None
|
|
137
|
+
"""
|
|
138
|
+
setup_logging()
|
|
139
|
+
|
|
140
|
+
from daimyo.infrastructure.di import get_container
|
|
141
|
+
|
|
142
|
+
container = get_container()
|
|
143
|
+
repo = container.scope_repository()
|
|
144
|
+
scopes = repo.list_scopes()
|
|
145
|
+
|
|
146
|
+
if not scopes:
|
|
147
|
+
typer.echo("No scopes found.")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
typer.echo("Available scopes:")
|
|
151
|
+
for scope_name in scopes:
|
|
152
|
+
typer.echo(f" - {scope_name}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@app.command()
|
|
156
|
+
def show(
|
|
157
|
+
scope_name: str = typer.Argument(
|
|
158
|
+
...,
|
|
159
|
+
help="Name of the scope to show",
|
|
160
|
+
),
|
|
161
|
+
) -> None:
|
|
162
|
+
"""Show details of a specific scope.
|
|
163
|
+
|
|
164
|
+
:param scope_name: Name of the scope to show
|
|
165
|
+
:type scope_name: str
|
|
166
|
+
:rtype: None
|
|
167
|
+
"""
|
|
168
|
+
setup_logging()
|
|
169
|
+
|
|
170
|
+
from daimyo.infrastructure.di import get_container
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
container = get_container()
|
|
174
|
+
repo = container.scope_repository()
|
|
175
|
+
scope = repo.get_scope(scope_name)
|
|
176
|
+
|
|
177
|
+
if scope is None:
|
|
178
|
+
typer.echo(f"Error: Scope '{scope_name}' not found", err=True)
|
|
179
|
+
raise typer.Exit(code=1)
|
|
180
|
+
|
|
181
|
+
typer.echo(f"# Scope: {scope.metadata.name}")
|
|
182
|
+
typer.echo(f"\nDescription: {scope.metadata.description}")
|
|
183
|
+
if scope.metadata.parent:
|
|
184
|
+
typer.echo(f"Parent: {scope.metadata.parent}")
|
|
185
|
+
if scope.metadata.tags:
|
|
186
|
+
typer.echo(f"Tags: {scope.metadata.tags}")
|
|
187
|
+
|
|
188
|
+
typer.echo(f"\nCommandments: {len(scope.commandments.categories)} categories")
|
|
189
|
+
typer.echo(f"Suggestions: {len(scope.suggestions.categories)} categories")
|
|
190
|
+
|
|
191
|
+
except Exception as e:
|
|
192
|
+
typer.echo(f"Error: {str(e)}", err=True)
|
|
193
|
+
raise typer.Exit(code=1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cli() -> None:
|
|
197
|
+
"""Entry point for the CLI.
|
|
198
|
+
|
|
199
|
+
:rtype: None
|
|
200
|
+
"""
|
|
201
|
+
app()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
cli()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Application layer - business logic and use cases."""
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Category filtering service for scopes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from daimyo.application.rule_service import RuleMergingService
|
|
6
|
+
from daimyo.domain import MergedScope
|
|
7
|
+
from daimyo.infrastructure.logging import get_logger
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CategoryFilterService:
|
|
13
|
+
"""Service for filtering categories in scopes."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, rule_service: RuleMergingService):
|
|
16
|
+
"""Initialize category filter service.
|
|
17
|
+
|
|
18
|
+
:param rule_service: Rule merging service for filtering
|
|
19
|
+
:type rule_service: RuleMergingService
|
|
20
|
+
"""
|
|
21
|
+
self.rule_service = rule_service
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def parse_category_string(categories: str | None) -> list[str]:
|
|
25
|
+
"""Parse comma-separated category string into list.
|
|
26
|
+
|
|
27
|
+
:param categories: Comma-separated category string (e.g., "python.web,python.testing")
|
|
28
|
+
:type categories: str | None
|
|
29
|
+
:returns: List of category filters, empty list if None
|
|
30
|
+
:rtype: list[str]
|
|
31
|
+
"""
|
|
32
|
+
if not categories:
|
|
33
|
+
return []
|
|
34
|
+
return [c.strip() for c in categories.split(",")]
|
|
35
|
+
|
|
36
|
+
def apply_filters(
|
|
37
|
+
self, scope: MergedScope, category_filters: list[str] | None
|
|
38
|
+
) -> MergedScope:
|
|
39
|
+
"""Apply category filters to a merged scope.
|
|
40
|
+
|
|
41
|
+
Filters both commandments and suggestions by the given category prefixes.
|
|
42
|
+
If no filters provided, returns scope unchanged.
|
|
43
|
+
|
|
44
|
+
:param scope: Merged scope to filter
|
|
45
|
+
:type scope: MergedScope
|
|
46
|
+
:param category_filters: List of category prefix filters
|
|
47
|
+
:type category_filters: list[str] | None
|
|
48
|
+
:returns: Scope with filtered categories
|
|
49
|
+
:rtype: MergedScope
|
|
50
|
+
"""
|
|
51
|
+
if not category_filters:
|
|
52
|
+
return scope
|
|
53
|
+
|
|
54
|
+
logger.debug(f"Filtering scope by categories: {category_filters}")
|
|
55
|
+
|
|
56
|
+
scope.commandments = self.rule_service.filter_categories(
|
|
57
|
+
scope.commandments, category_filters
|
|
58
|
+
)
|
|
59
|
+
scope.suggestions = self.rule_service.filter_categories(
|
|
60
|
+
scope.suggestions, category_filters
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
return scope
|
|
64
|
+
|
|
65
|
+
def filter_from_string(self, scope: MergedScope, categories: str | None) -> MergedScope:
|
|
66
|
+
"""Parse category string and apply filters to scope.
|
|
67
|
+
|
|
68
|
+
Convenience method combining parse_category_string and apply_filters.
|
|
69
|
+
|
|
70
|
+
:param scope: Merged scope to filter
|
|
71
|
+
:type scope: MergedScope
|
|
72
|
+
:param categories: Comma-separated category string
|
|
73
|
+
:type categories: str | None
|
|
74
|
+
:returns: Scope with filtered categories
|
|
75
|
+
:rtype: MergedScope
|
|
76
|
+
"""
|
|
77
|
+
category_list = self.parse_category_string(categories)
|
|
78
|
+
return self.apply_filters(scope, category_list)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = ["CategoryFilterService"]
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Output formatters for different response formats."""
|
|
2
|
+
|
|
3
|
+
from .json_formatter import JsonFormatter
|
|
4
|
+
from .markdown_formatter import MarkdownFormatter
|
|
5
|
+
from .tree_builder import CategoryTreeBuilder
|
|
6
|
+
from .yaml_formatter import YamlMultiDocFormatter
|
|
7
|
+
|
|
8
|
+
__all__ = ["YamlMultiDocFormatter", "JsonFormatter", "MarkdownFormatter", "CategoryTreeBuilder"]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""JSON formatter for API responses."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from daimyo.domain import MergedScope, RuleSet
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JsonFormatter:
|
|
9
|
+
"""Format merged scope as JSON.
|
|
10
|
+
|
|
11
|
+
Output contains structured JSON with metadata, commandments, and suggestions.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def format(self, scope: MergedScope) -> dict[str, Any]:
|
|
15
|
+
"""Format merged scope as JSON-serializable dictionary.
|
|
16
|
+
|
|
17
|
+
:param scope: The merged scope to format
|
|
18
|
+
:type scope: MergedScope
|
|
19
|
+
:returns: Dictionary ready for JSON serialization
|
|
20
|
+
:rtype: Dict[str, Any]
|
|
21
|
+
"""
|
|
22
|
+
return {
|
|
23
|
+
"metadata": {
|
|
24
|
+
"name": scope.metadata.name,
|
|
25
|
+
"description": scope.metadata.description,
|
|
26
|
+
"parent": scope.metadata.parent,
|
|
27
|
+
"tags": scope.metadata.tags,
|
|
28
|
+
"sources": scope.sources,
|
|
29
|
+
},
|
|
30
|
+
"commandments": self._format_ruleset(scope.commandments),
|
|
31
|
+
"suggestions": self._format_ruleset(scope.suggestions),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def _format_ruleset(self, ruleset: RuleSet) -> dict[str, dict[str, Any]]:
|
|
35
|
+
"""Format a ruleset as flat dictionary with category keys.
|
|
36
|
+
|
|
37
|
+
:param ruleset: The ruleset to format
|
|
38
|
+
:type ruleset: RuleSet
|
|
39
|
+
:returns: Dictionary mapping category keys to their data
|
|
40
|
+
:rtype: Dict[str, Dict[str, Any]]
|
|
41
|
+
"""
|
|
42
|
+
result = {}
|
|
43
|
+
|
|
44
|
+
for category in ruleset.categories.values():
|
|
45
|
+
result[str(category.key)] = {
|
|
46
|
+
"when": category.when,
|
|
47
|
+
"rules": [rule.text for rule in category.rules],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
__all__ = ["JsonFormatter"]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Markdown formatter for MCP API responses."""
|
|
2
|
+
|
|
3
|
+
from daimyo.application.formatters.tree_builder import CategoryTreeBuilder
|
|
4
|
+
from daimyo.domain import Category, MergedScope, RuleSet
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MarkdownFormatter:
|
|
8
|
+
"""Format merged scope as markdown with hierarchy and MUST/SHOULD markers.
|
|
9
|
+
|
|
10
|
+
Features:
|
|
11
|
+
- Nested headings for category hierarchy (## python, ### web, #### testing)
|
|
12
|
+
- MUST markers for commandments, SHOULD markers for suggestions
|
|
13
|
+
- Include 'when' descriptions for each category
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def format(self, scope: MergedScope) -> str:
|
|
17
|
+
"""Format merged scope as markdown.
|
|
18
|
+
|
|
19
|
+
:param scope: The merged scope to format
|
|
20
|
+
:type scope: MergedScope
|
|
21
|
+
:returns: Markdown-formatted string
|
|
22
|
+
:rtype: str
|
|
23
|
+
"""
|
|
24
|
+
lines = []
|
|
25
|
+
|
|
26
|
+
lines.append(f"# Rules for {scope.metadata.name}\n")
|
|
27
|
+
if scope.metadata.description:
|
|
28
|
+
lines.append(f"{scope.metadata.description}\n")
|
|
29
|
+
|
|
30
|
+
merged_tree = CategoryTreeBuilder.merge_trees(
|
|
31
|
+
list(scope.commandments.categories.values()),
|
|
32
|
+
list(scope.suggestions.categories.values())
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
lines.extend(self._format_merged_tree(merged_tree, depth=2))
|
|
36
|
+
|
|
37
|
+
return "\n".join(lines)
|
|
38
|
+
|
|
39
|
+
def _format_merged_tree(
|
|
40
|
+
self, tree: dict[str, dict], depth: int, path: str = ""
|
|
41
|
+
) -> list[str]:
|
|
42
|
+
"""Format merged category tree with both MUST and SHOULD rules.
|
|
43
|
+
|
|
44
|
+
:param tree: Merged category tree
|
|
45
|
+
:type tree: Dict[str, Dict]
|
|
46
|
+
:param depth: Current heading depth
|
|
47
|
+
:type depth: int
|
|
48
|
+
:param path: Current category path
|
|
49
|
+
:type path: str
|
|
50
|
+
:returns: List of markdown lines
|
|
51
|
+
:rtype: List[str]
|
|
52
|
+
"""
|
|
53
|
+
lines = []
|
|
54
|
+
|
|
55
|
+
for key, node in sorted(tree.items()):
|
|
56
|
+
heading = "#" * depth
|
|
57
|
+
current_path = f"{path}.{key}" if path else key
|
|
58
|
+
lines.append(f"{heading} {key}\n")
|
|
59
|
+
|
|
60
|
+
commandments = node.get("_commandments", [])
|
|
61
|
+
suggestions = node.get("_suggestions", [])
|
|
62
|
+
|
|
63
|
+
when_description = None
|
|
64
|
+
for category in commandments:
|
|
65
|
+
if category.when:
|
|
66
|
+
when_description = category.when
|
|
67
|
+
break
|
|
68
|
+
if not when_description:
|
|
69
|
+
for category in suggestions:
|
|
70
|
+
if category.when:
|
|
71
|
+
when_description = category.when
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
if when_description:
|
|
75
|
+
lines.append(f"*{when_description}*\n")
|
|
76
|
+
|
|
77
|
+
for category in commandments:
|
|
78
|
+
for rule in category.rules:
|
|
79
|
+
lines.append(f"- **MUST**: {rule.text}")
|
|
80
|
+
|
|
81
|
+
for category in suggestions:
|
|
82
|
+
for rule in category.rules:
|
|
83
|
+
lines.append(f"- **SHOULD**: {rule.text}")
|
|
84
|
+
|
|
85
|
+
if commandments or suggestions:
|
|
86
|
+
lines.append("")
|
|
87
|
+
|
|
88
|
+
children = node.get("_children", {})
|
|
89
|
+
if children:
|
|
90
|
+
lines.extend(self._format_merged_tree(children, depth + 1, current_path))
|
|
91
|
+
|
|
92
|
+
return lines
|
|
93
|
+
|
|
94
|
+
def _format_ruleset_markdown(self, ruleset: RuleSet, marker: str) -> str:
|
|
95
|
+
"""Format a ruleset as markdown with hierarchical headings.
|
|
96
|
+
|
|
97
|
+
:param ruleset: The ruleset to format
|
|
98
|
+
:type ruleset: RuleSet
|
|
99
|
+
:param marker: The marker to use (MUST or SHOULD)
|
|
100
|
+
:type marker: str
|
|
101
|
+
:returns: Markdown string
|
|
102
|
+
:rtype: str
|
|
103
|
+
"""
|
|
104
|
+
lines = []
|
|
105
|
+
|
|
106
|
+
category_tree = CategoryTreeBuilder.build_tree(list(ruleset.categories.values()))
|
|
107
|
+
|
|
108
|
+
lines.extend(self._format_tree(category_tree, marker, depth=3))
|
|
109
|
+
|
|
110
|
+
return "\n".join(lines)
|
|
111
|
+
|
|
112
|
+
def _format_tree(
|
|
113
|
+
self, tree: dict[str, dict], marker: str, depth: int, path: str = ""
|
|
114
|
+
) -> list[str]:
|
|
115
|
+
"""Recursively format category tree as markdown.
|
|
116
|
+
|
|
117
|
+
:param tree: Category tree
|
|
118
|
+
:type tree: Dict[str, Dict]
|
|
119
|
+
:param marker: MUST or SHOULD
|
|
120
|
+
:type marker: str
|
|
121
|
+
:param depth: Current heading depth
|
|
122
|
+
:type depth: int
|
|
123
|
+
:param path: Current category path
|
|
124
|
+
:type path: str
|
|
125
|
+
:returns: List of markdown lines
|
|
126
|
+
:rtype: List[str]
|
|
127
|
+
"""
|
|
128
|
+
lines = []
|
|
129
|
+
|
|
130
|
+
for key, node in sorted(tree.items()):
|
|
131
|
+
heading = "#" * depth
|
|
132
|
+
current_path = f"{path}.{key}" if path else key
|
|
133
|
+
lines.append(f"{heading} {key}\n")
|
|
134
|
+
|
|
135
|
+
for category in node.get("_categories", []):
|
|
136
|
+
lines.append(f"*{category.when}*\n")
|
|
137
|
+
|
|
138
|
+
for rule in category.rules:
|
|
139
|
+
lines.append(f"- **{marker}**: {rule.text}")
|
|
140
|
+
lines.append("")
|
|
141
|
+
|
|
142
|
+
children = node.get("_children", {})
|
|
143
|
+
if children:
|
|
144
|
+
lines.extend(self._format_tree(children, marker, depth + 1, current_path))
|
|
145
|
+
|
|
146
|
+
return lines
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
__all__ = ["MarkdownFormatter"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Category tree building utilities for formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from daimyo.domain import Category
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CategoryTreeBuilder:
|
|
9
|
+
"""Utility for building hierarchical category trees."""
|
|
10
|
+
|
|
11
|
+
@staticmethod
|
|
12
|
+
def build_tree(categories: list[Category]) -> dict[str, dict]:
|
|
13
|
+
"""Build a hierarchical tree from flat category list.
|
|
14
|
+
|
|
15
|
+
:param categories: List of categories
|
|
16
|
+
:type categories: list[Category]
|
|
17
|
+
:returns: Nested dictionary representing category hierarchy
|
|
18
|
+
:rtype: dict[str, dict]
|
|
19
|
+
"""
|
|
20
|
+
tree: dict[str, dict] = {}
|
|
21
|
+
|
|
22
|
+
for category in categories:
|
|
23
|
+
parts = category.key.parts
|
|
24
|
+
current = tree
|
|
25
|
+
|
|
26
|
+
for i, part in enumerate(parts):
|
|
27
|
+
if part not in current:
|
|
28
|
+
current[part] = {"_categories": [], "_children": {}}
|
|
29
|
+
|
|
30
|
+
if i == len(parts) - 1:
|
|
31
|
+
current[part]["_categories"].append(category)
|
|
32
|
+
else:
|
|
33
|
+
current = current[part]["_children"]
|
|
34
|
+
|
|
35
|
+
return tree
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def merge_trees(commandments: list[Category], suggestions: list[Category]) -> dict[str, dict]:
|
|
39
|
+
"""Merge commandment and suggestion categories into a single tree.
|
|
40
|
+
|
|
41
|
+
:param commandments: List of commandment categories
|
|
42
|
+
:type commandments: list[Category]
|
|
43
|
+
:param suggestions: List of suggestion categories
|
|
44
|
+
:type suggestions: list[Category]
|
|
45
|
+
:returns: Merged tree with both commandments and suggestions
|
|
46
|
+
:rtype: dict[str, dict]
|
|
47
|
+
"""
|
|
48
|
+
tree: dict[str, dict] = {}
|
|
49
|
+
|
|
50
|
+
for category in commandments:
|
|
51
|
+
parts = category.key.parts
|
|
52
|
+
current = tree
|
|
53
|
+
|
|
54
|
+
for i, part in enumerate(parts):
|
|
55
|
+
if part not in current:
|
|
56
|
+
current[part] = {"_commandments": [], "_suggestions": [], "_children": {}}
|
|
57
|
+
|
|
58
|
+
if i == len(parts) - 1:
|
|
59
|
+
current[part]["_commandments"].append(category)
|
|
60
|
+
else:
|
|
61
|
+
current = current[part]["_children"]
|
|
62
|
+
|
|
63
|
+
for category in suggestions:
|
|
64
|
+
parts = category.key.parts
|
|
65
|
+
current = tree
|
|
66
|
+
|
|
67
|
+
for i, part in enumerate(parts):
|
|
68
|
+
if part not in current:
|
|
69
|
+
current[part] = {"_commandments": [], "_suggestions": [], "_children": {}}
|
|
70
|
+
|
|
71
|
+
if i == len(parts) - 1:
|
|
72
|
+
current[part]["_suggestions"].append(category)
|
|
73
|
+
else:
|
|
74
|
+
current = current[part]["_children"]
|
|
75
|
+
|
|
76
|
+
return tree
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def build_index_tree(categories: list[tuple[str, str]]) -> dict[str, dict]:
|
|
80
|
+
"""Build tree for index display from category key-when pairs.
|
|
81
|
+
|
|
82
|
+
:param categories: List of (category_key, when_description) tuples
|
|
83
|
+
:type categories: list[tuple[str, str]]
|
|
84
|
+
:returns: Nested dictionary for rendering category index
|
|
85
|
+
:rtype: dict[str, dict]
|
|
86
|
+
"""
|
|
87
|
+
tree: dict[str, dict] = {}
|
|
88
|
+
|
|
89
|
+
for category_key, when_desc in categories:
|
|
90
|
+
parts = category_key.split(".")
|
|
91
|
+
current = tree
|
|
92
|
+
|
|
93
|
+
for i, part in enumerate(parts):
|
|
94
|
+
if part not in current:
|
|
95
|
+
current[part] = {"_children": {}, "_key": ".".join(parts[: i + 1])}
|
|
96
|
+
|
|
97
|
+
if i == len(parts) - 1:
|
|
98
|
+
current[part]["_when"] = when_desc
|
|
99
|
+
|
|
100
|
+
current = current[part]["_children"]
|
|
101
|
+
|
|
102
|
+
return tree
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
__all__ = ["CategoryTreeBuilder"]
|