mcp-stata 1.0.1__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.
Potentially problematic release.
This version of mcp-stata might be problematic. Click here for more details.
- mcp_stata/__init__.py +4 -0
- mcp_stata/discovery.py +124 -0
- mcp_stata/models.py +57 -0
- mcp_stata/server.py +202 -0
- mcp_stata/smcl/smcl2html.py +80 -0
- mcp_stata/stata_client.py +524 -0
- mcp_stata-1.0.1.dist-info/METADATA +240 -0
- mcp_stata-1.0.1.dist-info/RECORD +11 -0
- mcp_stata-1.0.1.dist-info/WHEEL +4 -0
- mcp_stata-1.0.1.dist-info/entry_points.txt +2 -0
- mcp_stata-1.0.1.dist-info/licenses/LICENSE +661 -0
mcp_stata/__init__.py
ADDED
mcp_stata/discovery.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import glob
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from typing import Tuple, Optional, List
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger("mcp_stata.discovery")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _dedupe_preserve(items: List[tuple]) -> List[tuple]:
|
|
12
|
+
seen = set()
|
|
13
|
+
unique = []
|
|
14
|
+
for path, edition in items:
|
|
15
|
+
if path in seen:
|
|
16
|
+
continue
|
|
17
|
+
seen.add(path)
|
|
18
|
+
unique.append((path, edition))
|
|
19
|
+
return unique
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find_stata_path() -> Tuple[str, str]:
|
|
23
|
+
"""
|
|
24
|
+
Attempts to automatically locate the Stata installation path.
|
|
25
|
+
Returns (path_to_executable, edition_string).
|
|
26
|
+
"""
|
|
27
|
+
system = platform.system()
|
|
28
|
+
|
|
29
|
+
# 1. Check Environment Variable
|
|
30
|
+
if os.environ.get("STATA_PATH"):
|
|
31
|
+
path = os.environ["STATA_PATH"]
|
|
32
|
+
edition = "be"
|
|
33
|
+
lower_path = path.lower()
|
|
34
|
+
if "mp" in lower_path:
|
|
35
|
+
edition = "mp"
|
|
36
|
+
elif "se" in lower_path:
|
|
37
|
+
edition = "se"
|
|
38
|
+
elif "be" in lower_path:
|
|
39
|
+
edition = "be"
|
|
40
|
+
if not os.path.exists(path):
|
|
41
|
+
raise FileNotFoundError(
|
|
42
|
+
f"STATA_PATH points to '{path}', but that file does not exist. "
|
|
43
|
+
"Update STATA_PATH to your Stata binary (e.g., "
|
|
44
|
+
"/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp)."
|
|
45
|
+
)
|
|
46
|
+
if not os.access(path, os.X_OK):
|
|
47
|
+
raise PermissionError(
|
|
48
|
+
f"STATA_PATH points to '{path}', but it is not executable. "
|
|
49
|
+
"Ensure this is the Stata binary, not the .app directory."
|
|
50
|
+
)
|
|
51
|
+
logger.info("Using STATA_PATH override: %s (%s)", path, edition)
|
|
52
|
+
return path, edition
|
|
53
|
+
|
|
54
|
+
# 2. Platform-specific search
|
|
55
|
+
candidates = [] # List of (path, edition)
|
|
56
|
+
|
|
57
|
+
if system == "Darwin": # macOS
|
|
58
|
+
app_globs = [
|
|
59
|
+
"/Applications/StataNow/StataMP.app",
|
|
60
|
+
"/Applications/StataNow/StataSE.app",
|
|
61
|
+
"/Applications/StataNow/Stata.app",
|
|
62
|
+
"/Applications/Stata/StataMP.app",
|
|
63
|
+
"/Applications/Stata/StataSE.app",
|
|
64
|
+
"/Applications/Stata/Stata.app",
|
|
65
|
+
"/Applications/Stata*/Stata*.app",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
for pattern in app_globs:
|
|
69
|
+
for app_dir in glob.glob(pattern):
|
|
70
|
+
binary_dir = os.path.join(app_dir, "Contents", "MacOS")
|
|
71
|
+
if not os.path.exists(binary_dir):
|
|
72
|
+
continue
|
|
73
|
+
for binary, edition in [("stata-mp", "mp"), ("stata-se", "se"), ("stata", "be")]:
|
|
74
|
+
full_path = os.path.join(binary_dir, binary)
|
|
75
|
+
if os.path.exists(full_path):
|
|
76
|
+
candidates.append((full_path, edition))
|
|
77
|
+
|
|
78
|
+
elif system == "Windows":
|
|
79
|
+
base_dirs = [
|
|
80
|
+
os.environ.get("ProgramFiles", "C:\\Program Files"),
|
|
81
|
+
os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
for base_dir in base_dirs:
|
|
85
|
+
for stata_dir in glob.glob(os.path.join(base_dir, "Stata*")):
|
|
86
|
+
for exe, edition in [
|
|
87
|
+
("StataMP-64.exe", "mp"),
|
|
88
|
+
("StataMP.exe", "mp"),
|
|
89
|
+
("StataSE-64.exe", "se"),
|
|
90
|
+
("StataSE.exe", "se"),
|
|
91
|
+
("Stata-64.exe", "be"),
|
|
92
|
+
("Stata.exe", "be"),
|
|
93
|
+
]:
|
|
94
|
+
full_path = os.path.join(stata_dir, exe)
|
|
95
|
+
if os.path.exists(full_path):
|
|
96
|
+
candidates.append((full_path, edition))
|
|
97
|
+
|
|
98
|
+
elif system == "Linux":
|
|
99
|
+
for binary, edition in [
|
|
100
|
+
("/usr/local/stata/stata-mp", "mp"),
|
|
101
|
+
("/usr/local/stata/stata-se", "se"),
|
|
102
|
+
("/usr/local/stata/stata", "be"),
|
|
103
|
+
("/usr/bin/stata", "be"),
|
|
104
|
+
]:
|
|
105
|
+
if os.path.exists(binary):
|
|
106
|
+
candidates.append((binary, edition))
|
|
107
|
+
|
|
108
|
+
candidates = _dedupe_preserve(candidates)
|
|
109
|
+
|
|
110
|
+
for path, edition in candidates:
|
|
111
|
+
if not os.path.exists(path):
|
|
112
|
+
logger.warning("Discovered candidate missing on disk: %s", path)
|
|
113
|
+
continue
|
|
114
|
+
if not os.access(path, os.X_OK):
|
|
115
|
+
logger.warning("Discovered candidate is not executable: %s", path)
|
|
116
|
+
continue
|
|
117
|
+
logger.info("Auto-discovered Stata at %s (%s)", path, edition)
|
|
118
|
+
return path, edition
|
|
119
|
+
|
|
120
|
+
raise FileNotFoundError(
|
|
121
|
+
"Could not automatically locate Stata. "
|
|
122
|
+
"Set STATA_PATH to your Stata executable (e.g., "
|
|
123
|
+
"/Applications/StataNow/StataMP.app/Contents/MacOS/stata-mp or C:\\Program Files\\Stata18\\StataMP-64.exe)."
|
|
124
|
+
)
|
mcp_stata/models.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from typing import List, Optional, Dict, Any
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ErrorEnvelope(BaseModel):
|
|
6
|
+
message: str
|
|
7
|
+
rc: Optional[int] = None
|
|
8
|
+
line: Optional[int] = None
|
|
9
|
+
command: Optional[str] = None
|
|
10
|
+
stdout: Optional[str] = None
|
|
11
|
+
stderr: Optional[str] = None
|
|
12
|
+
snippet: Optional[str] = None
|
|
13
|
+
trace: Optional[bool] = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CommandResponse(BaseModel):
|
|
17
|
+
command: str
|
|
18
|
+
rc: int
|
|
19
|
+
stdout: str
|
|
20
|
+
stderr: Optional[str] = None
|
|
21
|
+
success: bool
|
|
22
|
+
error: Optional[ErrorEnvelope] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class DataResponse(BaseModel):
|
|
26
|
+
start: int
|
|
27
|
+
count: int
|
|
28
|
+
data: List[Dict[str, Any]]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class VariableInfo(BaseModel):
|
|
32
|
+
name: str
|
|
33
|
+
label: Optional[str] = None
|
|
34
|
+
type: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VariablesResponse(BaseModel):
|
|
38
|
+
variables: List[VariableInfo]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GraphInfo(BaseModel):
|
|
42
|
+
name: str
|
|
43
|
+
active: bool = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class GraphListResponse(BaseModel):
|
|
47
|
+
graphs: List[GraphInfo]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GraphExport(BaseModel):
|
|
51
|
+
name: str
|
|
52
|
+
image_base64: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GraphExportResponse(BaseModel):
|
|
56
|
+
graphs: List[GraphExport]
|
|
57
|
+
|
mcp_stata/server.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from mcp.server.fastmcp import FastMCP
|
|
2
|
+
from mcp.server.fastmcp import Image
|
|
3
|
+
import mcp.types as types
|
|
4
|
+
from .stata_client import StataClient
|
|
5
|
+
from .models import (
|
|
6
|
+
DataResponse,
|
|
7
|
+
GraphListResponse,
|
|
8
|
+
VariablesResponse,
|
|
9
|
+
GraphExportResponse,
|
|
10
|
+
)
|
|
11
|
+
import logging
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
LOG_LEVEL = os.getenv("STATA_MCP_LOGLEVEL", "INFO").upper()
|
|
16
|
+
logging.basicConfig(level=LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s - %(message)s")
|
|
17
|
+
|
|
18
|
+
# Initialize FastMCP
|
|
19
|
+
mcp = FastMCP("stata")
|
|
20
|
+
client = StataClient()
|
|
21
|
+
|
|
22
|
+
@mcp.tool()
|
|
23
|
+
def run_command(code: str, echo: bool = True, as_json: bool = True, trace: bool = False, raw: bool = False) -> str:
|
|
24
|
+
"""
|
|
25
|
+
Executes a specific Stata command.
|
|
26
|
+
|
|
27
|
+
This is the primary tool for interacting with Stata. You can run any valid Stata syntax.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
code: The detailed Stata command(s) to execute (e.g., "sysuse auto", "regress price mpg", "summarize").
|
|
31
|
+
echo: If True, the command itself is included in the output. Default is True.
|
|
32
|
+
as_json: If True, returns a JSON envelope with rc/stdout/stderr/error.
|
|
33
|
+
trace: If True, enables `set trace on` for deeper error diagnostics (automatically disabled after).
|
|
34
|
+
"""
|
|
35
|
+
result = client.run_command_structured(code, echo=echo, trace=trace)
|
|
36
|
+
if raw:
|
|
37
|
+
if result.success:
|
|
38
|
+
return result.stdout
|
|
39
|
+
if result.error:
|
|
40
|
+
msg = result.error.message
|
|
41
|
+
if result.error.rc is not None:
|
|
42
|
+
msg = f"{msg}\nrc={result.error.rc}"
|
|
43
|
+
return msg
|
|
44
|
+
return result.stdout
|
|
45
|
+
if as_json:
|
|
46
|
+
return result.model_dump_json(indent=2)
|
|
47
|
+
# Default structured string for compatibility when as_json is False but raw is also False
|
|
48
|
+
return result.model_dump_json(indent=2)
|
|
49
|
+
|
|
50
|
+
@mcp.tool()
|
|
51
|
+
def get_data(start: int = 0, count: int = 50) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Returns a slice of the active dataset as a JSON-formatted list of dictionaries.
|
|
54
|
+
|
|
55
|
+
Use this to inspect the actual data values in memory. Useful for checking data quality or content.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
start: The zero-based index of the first observation to retrieve.
|
|
59
|
+
count: The number of observations to retrieve. Defaults to 50.
|
|
60
|
+
"""
|
|
61
|
+
data = client.get_data(start, count)
|
|
62
|
+
resp = DataResponse(start=start, count=count, data=data)
|
|
63
|
+
return resp.model_dump_json(indent=2)
|
|
64
|
+
|
|
65
|
+
@mcp.tool()
|
|
66
|
+
def describe() -> str:
|
|
67
|
+
"""
|
|
68
|
+
Returns variable descriptions, storage types, and labels (equivalent to Stata's `describe` command).
|
|
69
|
+
|
|
70
|
+
Use this to understand the structure of the dataset, variable names, and their formats before running analysis.
|
|
71
|
+
"""
|
|
72
|
+
return client.run_command("describe")
|
|
73
|
+
|
|
74
|
+
@mcp.tool()
|
|
75
|
+
def list_graphs() -> str:
|
|
76
|
+
"""
|
|
77
|
+
Lists the names of all graphs currently stored in Stata's memory.
|
|
78
|
+
|
|
79
|
+
Use this to see which graphs are available for export via `export_graph`.
|
|
80
|
+
"""
|
|
81
|
+
graphs = client.list_graphs_structured()
|
|
82
|
+
return graphs.model_dump_json(indent=2)
|
|
83
|
+
|
|
84
|
+
@mcp.tool()
|
|
85
|
+
def export_graph(graph_name: str = None) -> Image:
|
|
86
|
+
"""
|
|
87
|
+
Exports a stored Stata graph to an image format (PNG) and returns it.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
graph_name: The name of the graph to export (as seen in `list_graphs`).
|
|
91
|
+
If None, exports the currently active graph.
|
|
92
|
+
"""
|
|
93
|
+
try:
|
|
94
|
+
path = client.export_graph(graph_name)
|
|
95
|
+
with open(path, "rb") as f:
|
|
96
|
+
data = f.read()
|
|
97
|
+
return Image(data=data, format="png")
|
|
98
|
+
except Exception as e:
|
|
99
|
+
# Return error as text if image fails?
|
|
100
|
+
# FastMCP expects Image or error.
|
|
101
|
+
raise RuntimeError(f"Failed to export graph: {e}")
|
|
102
|
+
|
|
103
|
+
@mcp.tool()
|
|
104
|
+
def get_help(topic: str, plain_text: bool = False) -> str:
|
|
105
|
+
"""
|
|
106
|
+
Returns the official Stata help text for a given command or topic.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
topic: The command name or help topic (e.g., "regress", "graph", "options").
|
|
110
|
+
Returns Markdown by default, or plain text when plain_text=True.
|
|
111
|
+
"""
|
|
112
|
+
return client.get_help(topic, plain_text=plain_text)
|
|
113
|
+
|
|
114
|
+
@mcp.tool()
|
|
115
|
+
def get_stored_results() -> str:
|
|
116
|
+
"""
|
|
117
|
+
Returns the current stored results (r-class and e-class scalars/macros) as a JSON-formatted string.
|
|
118
|
+
|
|
119
|
+
Use this after running a command (like `summarize` or `regress`) to programmatically retrieve
|
|
120
|
+
specific values (e.g., means, coefficients, sample sizes) for validation or further calculation.
|
|
121
|
+
"""
|
|
122
|
+
import json
|
|
123
|
+
return json.dumps(client.get_stored_results(), indent=2)
|
|
124
|
+
|
|
125
|
+
@mcp.tool()
|
|
126
|
+
def load_data(source: str, clear: bool = True, as_json: bool = True, raw: bool = False) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Loads data using sysuse/webuse/use heuristics based on the source string.
|
|
129
|
+
Automatically appends , clear unless clear=False.
|
|
130
|
+
"""
|
|
131
|
+
result = client.load_data(source, clear=clear)
|
|
132
|
+
if raw:
|
|
133
|
+
return result.stdout if result.success else (result.error.message if result.error else result.stdout)
|
|
134
|
+
return result.model_dump_json(indent=2) if as_json else result.model_dump_json(indent=2)
|
|
135
|
+
|
|
136
|
+
@mcp.tool()
|
|
137
|
+
def codebook(variable: str, as_json: bool = True, trace: bool = False, raw: bool = False) -> str:
|
|
138
|
+
"""
|
|
139
|
+
Returns codebook/summary for a specific variable.
|
|
140
|
+
"""
|
|
141
|
+
result = client.codebook(variable, trace=trace)
|
|
142
|
+
if raw:
|
|
143
|
+
return result.stdout if result.success else (result.error.message if result.error else result.stdout)
|
|
144
|
+
return result.model_dump_json(indent=2) if as_json else result.model_dump_json(indent=2)
|
|
145
|
+
|
|
146
|
+
@mcp.tool()
|
|
147
|
+
def run_do_file(path: str, echo: bool = True, as_json: bool = True, trace: bool = False, raw: bool = False) -> str:
|
|
148
|
+
"""
|
|
149
|
+
Executes a .do file with optional trace output and JSON envelope.
|
|
150
|
+
"""
|
|
151
|
+
result = client.run_do_file(path, echo=echo, trace=trace)
|
|
152
|
+
if raw:
|
|
153
|
+
return result.stdout if result.success else (result.error.message if result.error else result.stdout)
|
|
154
|
+
return result.model_dump_json(indent=2) if as_json else result.model_dump_json(indent=2)
|
|
155
|
+
|
|
156
|
+
@mcp.resource("stata://data/summary")
|
|
157
|
+
def get_summary() -> str:
|
|
158
|
+
"""
|
|
159
|
+
Returns the output of the `summarize` command for the dataset in memory.
|
|
160
|
+
Provides descriptive statistics (obs, mean, std. dev, min, max) for all variables.
|
|
161
|
+
"""
|
|
162
|
+
return client.run_command("summarize")
|
|
163
|
+
|
|
164
|
+
@mcp.resource("stata://data/metadata")
|
|
165
|
+
def get_metadata() -> str:
|
|
166
|
+
"""
|
|
167
|
+
Returns the output of the `describe` command.
|
|
168
|
+
Provides metadata about the dataset, including variable names, storage types, display formats, and labels.
|
|
169
|
+
"""
|
|
170
|
+
return client.run_command("describe")
|
|
171
|
+
|
|
172
|
+
@mcp.resource("stata://graphs/list")
|
|
173
|
+
def get_graph_list() -> str:
|
|
174
|
+
"""Returns list of active graphs."""
|
|
175
|
+
return client.list_graphs_structured().model_dump_json(indent=2)
|
|
176
|
+
|
|
177
|
+
@mcp.resource("stata://variables/list")
|
|
178
|
+
def get_variable_list() -> str:
|
|
179
|
+
"""Returns JSON list of all variables."""
|
|
180
|
+
variables = client.list_variables_structured()
|
|
181
|
+
return variables.model_dump_json(indent=2)
|
|
182
|
+
|
|
183
|
+
@mcp.resource("stata://results/stored")
|
|
184
|
+
def get_stored_results_resource() -> str:
|
|
185
|
+
"""Returns stored r() and e() results."""
|
|
186
|
+
import json
|
|
187
|
+
return json.dumps(client.get_stored_results(), indent=2)
|
|
188
|
+
|
|
189
|
+
@mcp.tool()
|
|
190
|
+
def export_graphs_all() -> str:
|
|
191
|
+
"""
|
|
192
|
+
Exports all graphs in memory to base64-encoded PNGs.
|
|
193
|
+
Returns a JSON envelope listing graph names and images.
|
|
194
|
+
"""
|
|
195
|
+
exports = client.export_graphs_all()
|
|
196
|
+
return exports.model_dump_json(indent=2)
|
|
197
|
+
|
|
198
|
+
def main():
|
|
199
|
+
mcp.run()
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Convert a SMCL file into Markdown.
|
|
2
|
+
|
|
3
|
+
Adapted from https://github.com/sergiocorreia/parse-smcl (MIT). Simplified into
|
|
4
|
+
a single module geared toward MCP, emitting Markdown by default.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def expand_includes(lines, adopath):
|
|
12
|
+
"""Expand INCLUDE directives if ado path is available."""
|
|
13
|
+
if not adopath:
|
|
14
|
+
return lines
|
|
15
|
+
includes = [(i, line[13:].strip()) for (i, line) in enumerate(lines) if line.startswith("INCLUDE help ")]
|
|
16
|
+
if os.path.exists(adopath):
|
|
17
|
+
for i, cmd in reversed(includes):
|
|
18
|
+
fn = os.path.join(adopath, cmd[0], cmd if cmd.endswith(".ihlp") else cmd + ".ihlp")
|
|
19
|
+
try:
|
|
20
|
+
with open(fn, "r", encoding="utf-8") as f:
|
|
21
|
+
content = f.readlines()
|
|
22
|
+
except FileNotFoundError:
|
|
23
|
+
continue
|
|
24
|
+
if content and content[0].startswith("{* *! version"):
|
|
25
|
+
content.pop(0)
|
|
26
|
+
lines[i:i+1] = content
|
|
27
|
+
return lines
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _inline_to_markdown(text: str) -> str:
|
|
31
|
+
"""Convert common inline SMCL directives to Markdown."""
|
|
32
|
+
|
|
33
|
+
def repl(match: re.Match) -> str:
|
|
34
|
+
tag = match.group(1).lower()
|
|
35
|
+
content = match.group(2) or ""
|
|
36
|
+
if tag in ("bf", "strong"):
|
|
37
|
+
return f"**{content}**"
|
|
38
|
+
if tag in ("it", "em"):
|
|
39
|
+
return f"*{content}*"
|
|
40
|
+
if tag in ("cmd", "cmdab", "code", "inp", "input", "res", "err", "txt"):
|
|
41
|
+
return f"`{content}`"
|
|
42
|
+
return content
|
|
43
|
+
|
|
44
|
+
text = re.sub(r"\{([a-zA-Z0-9_]+):([^}]*)\}", repl, text)
|
|
45
|
+
text = re.sub(r"\{[^}]*\}", "", text)
|
|
46
|
+
return text
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def smcl_to_markdown(smcl_text: str, adopath: str = None, current_file: str = "help") -> str:
|
|
50
|
+
"""Convert SMCL text to lightweight Markdown suitable for LLM consumption."""
|
|
51
|
+
if not smcl_text:
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
lines = smcl_text.splitlines()
|
|
55
|
+
if lines and lines[0].strip() == "{smcl}":
|
|
56
|
+
lines = lines[1:]
|
|
57
|
+
|
|
58
|
+
lines = expand_includes(lines, adopath)
|
|
59
|
+
|
|
60
|
+
title = None
|
|
61
|
+
body_parts = []
|
|
62
|
+
|
|
63
|
+
for raw in lines:
|
|
64
|
+
line = raw.strip()
|
|
65
|
+
if not line:
|
|
66
|
+
continue
|
|
67
|
+
if line.startswith("{title:"):
|
|
68
|
+
title = line[len("{title:") :].rstrip("}")
|
|
69
|
+
continue
|
|
70
|
+
# Paragraph markers
|
|
71
|
+
line = line.replace("{p_end}", "")
|
|
72
|
+
line = re.sub(r"\{p[^}]*\}", "", line)
|
|
73
|
+
body_parts.append(_inline_to_markdown(line))
|
|
74
|
+
|
|
75
|
+
md_parts = [f"# Help for {current_file}"]
|
|
76
|
+
if title:
|
|
77
|
+
md_parts.append(f"\n## {title}\n")
|
|
78
|
+
md_parts.append("\n".join(part for part in body_parts if part).strip())
|
|
79
|
+
|
|
80
|
+
return "\n\n".join(part for part in md_parts if part).strip() + "\n"
|