pactown 0.1.4__py3-none-any.whl → 0.1.47__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.
pactown/generator.py CHANGED
@@ -5,15 +5,16 @@ from __future__ import annotations
5
5
  import re
6
6
  from pathlib import Path
7
7
  from typing import Optional
8
+
8
9
  import yaml
9
10
 
10
- from markpact import parse_blocks
11
+ from .markpact_blocks import parse_blocks
11
12
 
12
13
 
13
14
  def scan_readme(readme_path: Path) -> dict:
14
15
  """
15
16
  Scan a README.md and extract service configuration.
16
-
17
+
17
18
  Returns dict with:
18
19
  - name: service name (from folder or heading)
19
20
  - readme: relative path to README
@@ -24,12 +25,12 @@ def scan_readme(readme_path: Path) -> dict:
24
25
  """
25
26
  content = readme_path.read_text()
26
27
  blocks = parse_blocks(content)
27
-
28
+
28
29
  # Extract service name from folder or first heading
29
30
  folder_name = readme_path.parent.name
30
31
  heading_match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
31
32
  name = folder_name
32
-
33
+
33
34
  # Detect port from run command
34
35
  port = None
35
36
  port_patterns = [
@@ -38,12 +39,12 @@ def scan_readme(readme_path: Path) -> dict:
38
39
  r':(\d+)',
39
40
  r'PORT[=:-]+(\d+)',
40
41
  ]
41
-
42
+
42
43
  # Detect health check endpoint
43
44
  health_check = None
44
45
  has_run = False
45
46
  deps = []
46
-
47
+
47
48
  for block in blocks:
48
49
  if block.kind == "run":
49
50
  has_run = True
@@ -52,17 +53,17 @@ def scan_readme(readme_path: Path) -> dict:
52
53
  if match:
53
54
  port = int(match.group(1))
54
55
  break
55
-
56
+
56
57
  if block.kind == "deps":
57
58
  deps = [d.strip() for d in block.body.strip().split('\n') if d.strip()]
58
-
59
+
59
60
  if block.kind == "test":
60
61
  # Look for health check in tests
61
62
  if "/health" in block.body:
62
63
  health_check = "/health"
63
64
  elif "GET /" in block.body:
64
65
  health_check = "/"
65
-
66
+
66
67
  return {
67
68
  "name": name,
68
69
  "readme": str(readme_path),
@@ -81,23 +82,23 @@ def scan_folder(
81
82
  ) -> list[dict]:
82
83
  """
83
84
  Scan a folder for README.md files and extract service configs.
84
-
85
+
85
86
  Args:
86
87
  folder: Root folder to scan
87
88
  recursive: Whether to scan subdirectories
88
89
  pattern: Filename pattern to match
89
-
90
+
90
91
  Returns:
91
92
  List of service configurations
92
93
  """
93
94
  folder = Path(folder)
94
95
  services = []
95
-
96
+
96
97
  if recursive:
97
98
  readme_files = list(folder.rglob(pattern))
98
99
  else:
99
100
  readme_files = list(folder.glob(pattern))
100
-
101
+
101
102
  for readme_path in readme_files:
102
103
  try:
103
104
  config = scan_readme(readme_path)
@@ -105,7 +106,7 @@ def scan_folder(
105
106
  services.append(config)
106
107
  except Exception as e:
107
108
  print(f"Warning: Failed to parse {readme_path}: {e}")
108
-
109
+
109
110
  return services
110
111
 
111
112
 
@@ -117,22 +118,22 @@ def generate_config(
117
118
  ) -> dict:
118
119
  """
119
120
  Generate a pactown ecosystem configuration from a folder.
120
-
121
+
121
122
  Args:
122
123
  folder: Folder to scan for services
123
124
  name: Ecosystem name (default: folder name)
124
125
  base_port: Starting port for auto-assignment
125
126
  output: Optional path to write YAML file
126
-
127
+
127
128
  Returns:
128
129
  Generated configuration dict
129
130
  """
130
131
  folder = Path(folder)
131
132
  services = scan_folder(folder)
132
-
133
+
133
134
  if not services:
134
135
  raise ValueError(f"No runnable services found in {folder}")
135
-
136
+
136
137
  # Build config
137
138
  config = {
138
139
  "name": name or folder.name,
@@ -146,13 +147,13 @@ def generate_config(
146
147
  },
147
148
  "services": {},
148
149
  }
149
-
150
+
150
151
  # Assign ports and build service configs
151
152
  next_port = base_port
152
153
  for svc in services:
153
154
  port = svc["port"] or next_port
154
155
  next_port = max(next_port, port) + 1
155
-
156
+
156
157
  # Make readme path relative to output folder
157
158
  readme_rel = svc["readme"]
158
159
  if output:
@@ -160,24 +161,24 @@ def generate_config(
160
161
  readme_rel = str(Path(svc["readme"]).relative_to(output.parent))
161
162
  except ValueError:
162
163
  pass
163
-
164
+
164
165
  service_config = {
165
166
  "readme": readme_rel,
166
167
  "port": port,
167
168
  }
168
-
169
+
169
170
  if svc["health_check"]:
170
171
  service_config["health_check"] = svc["health_check"]
171
-
172
+
172
173
  config["services"][svc["name"]] = service_config
173
-
174
+
174
175
  # Write to file if output specified
175
176
  if output:
176
177
  output = Path(output)
177
178
  with open(output, "w") as f:
178
179
  yaml.dump(config, f, default_flow_style=False, sort_keys=False)
179
180
  print(f"Generated: {output}")
180
-
181
+
181
182
  return config
182
183
 
183
184
 
@@ -185,21 +186,21 @@ def print_scan_results(folder: Path) -> None:
185
186
  """Print scan results in a readable format."""
186
187
  from rich.console import Console
187
188
  from rich.table import Table
188
-
189
+
189
190
  console = Console()
190
191
  services = scan_folder(folder)
191
-
192
+
192
193
  if not services:
193
194
  console.print(f"[yellow]No runnable services found in {folder}[/yellow]")
194
195
  return
195
-
196
+
196
197
  table = Table(title=f"Services found in {folder}")
197
198
  table.add_column("Name", style="cyan")
198
199
  table.add_column("Title")
199
200
  table.add_column("Port", style="blue")
200
201
  table.add_column("Health")
201
202
  table.add_column("Deps", style="dim")
202
-
203
+
203
204
  for svc in services:
204
205
  table.add_row(
205
206
  svc["name"],
@@ -208,5 +209,5 @@ def print_scan_results(folder: Path) -> None:
208
209
  svc["health_check"] or "-",
209
210
  str(len(svc["deps"])),
210
211
  )
211
-
212
+
212
213
  console.print(table)
pactown/llm.py ADDED
@@ -0,0 +1,450 @@
1
+ """
2
+ LLM Integration for Pactown.
3
+
4
+ Provides LLM rotation, fallback, and management using the lolm library.
5
+ This module enables AI-powered features in pactown with automatic
6
+ provider rotation when rate limits are hit.
7
+ """
8
+
9
+ import os
10
+ import importlib
11
+ import inspect
12
+ from typing import Any, Callable, Dict, List, Optional
13
+ from pathlib import Path
14
+
15
+ LOLM_AVAILABLE = False
16
+ LOLM_VERSION: Optional[str] = None
17
+ LOLM_IMPORT_ERROR: Optional[str] = None
18
+
19
+ ROTATION_AVAILABLE = False
20
+ ROTATION_IMPORT_ERROR: Optional[str] = None
21
+
22
+ LLMManager = None
23
+ RotationQueue = None
24
+ ProviderHealth = None
25
+ ProviderState = None
26
+ RateLimitInfo = None
27
+ RateLimitType = None
28
+ LLMRotationManager = None
29
+ LLMRateLimitError = None
30
+ parse_rate_limit_headers = None
31
+ is_rate_limit_error = None
32
+ create_rotation_manager = None
33
+
34
+ get_client = None
35
+ list_available_providers = None
36
+ load_lolm_config = None
37
+ save_lolm_config = None
38
+ LLMConfig = None
39
+
40
+ try:
41
+ _lolm = importlib.import_module("lolm")
42
+ LOLM_VERSION = getattr(_lolm, "__version__", None)
43
+
44
+ # Core APIs (exist in older lolm versions too)
45
+ from lolm import LLMManager as _LLMManager # type: ignore
46
+ from lolm import get_client as _get_client # type: ignore
47
+ from lolm import list_available_providers as _list_available_providers # type: ignore
48
+
49
+ LLMManager = _LLMManager
50
+ get_client = _get_client
51
+ list_available_providers = _list_available_providers
52
+ LOLM_AVAILABLE = True
53
+
54
+ # Optional config helpers
55
+ try:
56
+ from lolm import load_config as _load_lolm_config # type: ignore
57
+ from lolm import save_config as _save_lolm_config # type: ignore
58
+ from lolm import LLMConfig as _LLMConfig # type: ignore
59
+
60
+ load_lolm_config = _load_lolm_config
61
+ save_lolm_config = _save_lolm_config
62
+ LLMConfig = _LLMConfig
63
+ except Exception:
64
+ pass
65
+
66
+ # Rotation APIs (may not exist in older lolm versions)
67
+ try:
68
+ from lolm.rotation import ( # type: ignore
69
+ RotationQueue as _RotationQueue,
70
+ ProviderHealth as _ProviderHealth,
71
+ ProviderState as _ProviderState,
72
+ RateLimitInfo as _RateLimitInfo,
73
+ RateLimitType as _RateLimitType,
74
+ LLMRotationManager as _LLMRotationManager,
75
+ parse_rate_limit_headers as _parse_rate_limit_headers,
76
+ is_rate_limit_error as _is_rate_limit_error,
77
+ create_rotation_manager as _create_rotation_manager,
78
+ )
79
+
80
+ RotationQueue = _RotationQueue
81
+ ProviderHealth = _ProviderHealth
82
+ ProviderState = _ProviderState
83
+ RateLimitInfo = _RateLimitInfo
84
+ RateLimitType = _RateLimitType
85
+ LLMRotationManager = _LLMRotationManager
86
+ parse_rate_limit_headers = _parse_rate_limit_headers
87
+ is_rate_limit_error = _is_rate_limit_error
88
+ create_rotation_manager = _create_rotation_manager
89
+ ROTATION_AVAILABLE = True
90
+ except Exception as e:
91
+ ROTATION_AVAILABLE = False
92
+ ROTATION_IMPORT_ERROR = str(e)
93
+
94
+ # Optional rate limit error type
95
+ try:
96
+ from lolm.clients import LLMRateLimitError as _LLMRateLimitError # type: ignore
97
+
98
+ LLMRateLimitError = _LLMRateLimitError
99
+ except Exception:
100
+ pass
101
+
102
+ except Exception as e:
103
+ LOLM_AVAILABLE = False
104
+ LOLM_IMPORT_ERROR = str(e)
105
+
106
+
107
+ class PactownLLMError(Exception):
108
+ """Base exception for Pactown LLM errors."""
109
+ pass
110
+
111
+
112
+ class LLMNotAvailableError(PactownLLMError):
113
+ """Raised when no LLM provider is available."""
114
+ pass
115
+
116
+
117
+ class PactownLLM:
118
+ """
119
+ Pactown LLM Manager with rotation and fallback support.
120
+
121
+ Integrates with the lolm library for multi-provider LLM management
122
+ with automatic rotation when rate limits are hit.
123
+
124
+ Usage:
125
+ llm = PactownLLM()
126
+ llm.initialize()
127
+
128
+ # Simple generation
129
+ response = llm.generate("Explain this code")
130
+
131
+ # With rotation (automatic failover on rate limits)
132
+ response = llm.generate_with_rotation("Explain this code")
133
+
134
+ # Check status
135
+ status = llm.get_status()
136
+ """
137
+
138
+ _instance: Optional['PactownLLM'] = None
139
+
140
+ def __init__(self, verbose: bool = False):
141
+ if not LOLM_AVAILABLE:
142
+ raise ImportError(
143
+ "lolm library not available. Install with: pip install -U pactown[llm]"
144
+ )
145
+
146
+ # lolm versions differ; detect whether rotation is supported and whether
147
+ # the LLMManager supports `enable_rotation`.
148
+ enable_rotation = ROTATION_AVAILABLE
149
+ try:
150
+ sig = inspect.signature(LLMManager.__init__) # type: ignore[union-attr]
151
+ if "enable_rotation" in sig.parameters:
152
+ self._manager = LLMManager(verbose=verbose, enable_rotation=enable_rotation)
153
+ else:
154
+ self._manager = LLMManager(verbose=verbose)
155
+ except Exception:
156
+ self._manager = LLMManager(verbose=verbose)
157
+ self._verbose = verbose
158
+ self._initialized = False
159
+
160
+ # Callbacks for events
161
+ self._on_rate_limit: Optional[Callable[[str, Dict], None]] = None
162
+ self._on_rotation: Optional[Callable[[str, str], None]] = None
163
+ self._on_provider_unavailable: Optional[Callable[[str, str], None]] = None
164
+
165
+ @classmethod
166
+ def get_instance(cls, verbose: bool = False) -> 'PactownLLM':
167
+ """Get or create the global PactownLLM instance."""
168
+ if cls._instance is None:
169
+ cls._instance = cls(verbose=verbose)
170
+ return cls._instance
171
+
172
+ @classmethod
173
+ def set_instance(cls, instance: 'PactownLLM') -> None:
174
+ """Set the global PactownLLM instance."""
175
+ cls._instance = instance
176
+
177
+ def initialize(self) -> None:
178
+ """Initialize the LLM manager and all providers."""
179
+ if self._initialized:
180
+ return
181
+
182
+ self._manager.initialize()
183
+
184
+ # Set up rotation queue callbacks (only if supported by this lolm)
185
+ get_queue = getattr(self._manager, "get_rotation_queue", None)
186
+ queue = get_queue() if callable(get_queue) else None
187
+ if queue:
188
+ if self._on_rate_limit:
189
+ queue.on_rate_limit(lambda name, info: self._on_rate_limit(name, info.to_dict() if hasattr(info, 'to_dict') else {}))
190
+ if self._on_rotation:
191
+ queue.on_rotation(self._on_rotation)
192
+ if self._on_provider_unavailable:
193
+ queue.on_provider_unavailable(self._on_provider_unavailable)
194
+
195
+ self._initialized = True
196
+
197
+ @property
198
+ def is_available(self) -> bool:
199
+ """Check if any LLM provider is available."""
200
+ if not self._initialized:
201
+ self.initialize()
202
+ return self._manager.is_available
203
+
204
+ def generate(
205
+ self,
206
+ prompt: str,
207
+ system: str = None,
208
+ max_tokens: int = 4000,
209
+ provider: str = None
210
+ ) -> str:
211
+ """
212
+ Generate completion using available provider.
213
+
214
+ Args:
215
+ prompt: User prompt
216
+ system: System prompt
217
+ max_tokens: Maximum tokens
218
+ provider: Specific provider to use (optional)
219
+
220
+ Returns:
221
+ Generated text
222
+
223
+ Raises:
224
+ LLMNotAvailableError: If no provider is available
225
+ """
226
+ if not self._initialized:
227
+ self.initialize()
228
+
229
+ if not self.is_available:
230
+ raise LLMNotAvailableError("No LLM provider available. Run: lolm status")
231
+
232
+ return self._manager.generate(prompt, system=system, max_tokens=max_tokens, provider=provider)
233
+
234
+ def generate_with_rotation(
235
+ self,
236
+ prompt: str,
237
+ system: str = None,
238
+ max_tokens: int = 4000,
239
+ max_retries: int = 3
240
+ ) -> str:
241
+ """
242
+ Generate with intelligent rotation based on provider health.
243
+
244
+ Automatically rotates to next available provider when one
245
+ hits rate limits or becomes unavailable.
246
+
247
+ Args:
248
+ prompt: User prompt
249
+ system: System prompt
250
+ max_tokens: Maximum tokens
251
+ max_retries: Maximum number of providers to try
252
+
253
+ Returns:
254
+ Generated text
255
+ """
256
+ if not self._initialized:
257
+ self.initialize()
258
+
259
+ gen_rot = getattr(self._manager, "generate_with_rotation", None)
260
+ if callable(gen_rot):
261
+ return gen_rot(prompt, system=system, max_tokens=max_tokens, max_retries=max_retries)
262
+
263
+ # Older lolm: no rotation method; fall back.
264
+ return self._manager.generate_with_fallback(prompt, system=system, max_tokens=max_tokens)
265
+
266
+ def generate_with_fallback(
267
+ self,
268
+ prompt: str,
269
+ system: str = None,
270
+ max_tokens: int = 4000,
271
+ providers: List[str] = None
272
+ ) -> str:
273
+ """
274
+ Generate with fallback to other providers on failure.
275
+
276
+ Args:
277
+ prompt: User prompt
278
+ system: System prompt
279
+ max_tokens: Maximum tokens
280
+ providers: List of providers to try (in order)
281
+
282
+ Returns:
283
+ Generated text from first successful provider
284
+ """
285
+ if not self._initialized:
286
+ self.initialize()
287
+
288
+ return self._manager.generate_with_fallback(
289
+ prompt, system=system, max_tokens=max_tokens, providers=providers
290
+ )
291
+
292
+ def get_status(self) -> Dict[str, Any]:
293
+ """Get status of all providers including health info."""
294
+ if not self._initialized:
295
+ self.initialize()
296
+
297
+ status = self._manager.get_status()
298
+ get_health = getattr(self._manager, "get_provider_health", None)
299
+ health = get_health() if callable(get_health) else {}
300
+
301
+ # Merge status with health info
302
+ for name in status:
303
+ if name in health:
304
+ status[name]['health'] = health[name]
305
+
306
+ return {
307
+ 'providers': status,
308
+ 'available_providers': list_available_providers() if callable(list_available_providers) else [],
309
+ 'is_available': self.is_available,
310
+ 'lolm_version': LOLM_VERSION,
311
+ 'rotation_available': ROTATION_AVAILABLE,
312
+ 'rotation_import_error': ROTATION_IMPORT_ERROR,
313
+ }
314
+
315
+ def get_provider_health(self, name: str = None) -> Dict:
316
+ """Get health info for providers."""
317
+ if not self._initialized:
318
+ self.initialize()
319
+ get_health = getattr(self._manager, "get_provider_health", None)
320
+ if callable(get_health):
321
+ return get_health(name)
322
+ return {}
323
+
324
+ def set_provider_priority(self, name: str, priority: int) -> bool:
325
+ """Set priority for a provider (lower = higher priority)."""
326
+ if not self._initialized:
327
+ self.initialize()
328
+ return self._manager.set_provider_priority(name, priority)
329
+
330
+ def reset_provider(self, name: str) -> bool:
331
+ """Reset a provider's health metrics."""
332
+ if not self._initialized:
333
+ self.initialize()
334
+ return self._manager.reset_provider(name)
335
+
336
+ def get_rotation_queue(self) -> Optional[RotationQueue]:
337
+ """Get the rotation queue for advanced control."""
338
+ return self._manager.get_rotation_queue()
339
+
340
+ # Event handlers
341
+ def on_rate_limit(self, callback: Callable[[str, Dict], None]) -> None:
342
+ """Set callback for rate limit events."""
343
+ self._on_rate_limit = callback
344
+
345
+ def on_rotation(self, callback: Callable[[str, str], None]) -> None:
346
+ """Set callback for provider rotation events."""
347
+ self._on_rotation = callback
348
+
349
+ def on_provider_unavailable(self, callback: Callable[[str, str], None]) -> None:
350
+ """Set callback for when a provider becomes unavailable."""
351
+ self._on_provider_unavailable = callback
352
+
353
+
354
+ # Global instance accessor
355
+ def get_llm(verbose: bool = False) -> PactownLLM:
356
+ """Get the global PactownLLM instance."""
357
+ return PactownLLM.get_instance(verbose=verbose)
358
+
359
+
360
+ def is_lolm_available() -> bool:
361
+ """Check if lolm library is available."""
362
+ return LOLM_AVAILABLE
363
+
364
+
365
+ def get_lolm_info() -> Dict[str, Any]:
366
+ """Get diagnostic info about lolm availability/features."""
367
+ return {
368
+ "lolm_installed": LOLM_AVAILABLE,
369
+ "lolm_version": LOLM_VERSION,
370
+ "lolm_import_error": LOLM_IMPORT_ERROR,
371
+ "rotation_available": ROTATION_AVAILABLE,
372
+ "rotation_import_error": ROTATION_IMPORT_ERROR,
373
+ }
374
+
375
+
376
+ # Convenience functions
377
+ def generate(
378
+ prompt: str,
379
+ system: str = None,
380
+ max_tokens: int = 4000,
381
+ with_rotation: bool = True
382
+ ) -> str:
383
+ """
384
+ Generate completion using the global LLM instance.
385
+
386
+ Args:
387
+ prompt: User prompt
388
+ system: System prompt
389
+ max_tokens: Maximum tokens
390
+ with_rotation: Use rotation for automatic failover
391
+
392
+ Returns:
393
+ Generated text
394
+ """
395
+ llm = get_llm()
396
+ if with_rotation:
397
+ return llm.generate_with_rotation(prompt, system=system, max_tokens=max_tokens)
398
+ return llm.generate(prompt, system=system, max_tokens=max_tokens)
399
+
400
+
401
+ def get_llm_status() -> Dict[str, Any]:
402
+ """Get status of all LLM providers."""
403
+ if not LOLM_AVAILABLE:
404
+ return {
405
+ 'lolm_installed': False,
406
+ 'is_available': False,
407
+ 'error': 'lolm library not available in this environment',
408
+ 'install': 'pip install -U pactown[llm] # or: pip install -U lolm',
409
+ 'lolm_import_error': LOLM_IMPORT_ERROR,
410
+ }
411
+
412
+ try:
413
+ llm = get_llm()
414
+ status = llm.get_status()
415
+ status.setdefault('lolm_installed', True)
416
+ status.setdefault('lolm_version', LOLM_VERSION)
417
+ status.setdefault('rotation_available', ROTATION_AVAILABLE)
418
+ status.setdefault('rotation_import_error', ROTATION_IMPORT_ERROR)
419
+ return status
420
+ except Exception as e:
421
+ return {
422
+ 'lolm_installed': True,
423
+ 'is_available': False,
424
+ 'error': str(e),
425
+ 'lolm_version': LOLM_VERSION,
426
+ 'rotation_available': ROTATION_AVAILABLE,
427
+ 'rotation_import_error': ROTATION_IMPORT_ERROR,
428
+ }
429
+
430
+
431
+ def set_provider_priority(name: str, priority: int) -> bool:
432
+ """Set priority for an LLM provider."""
433
+ if not LOLM_AVAILABLE:
434
+ return False
435
+ llm = get_llm()
436
+ set_prio = getattr(llm, "set_provider_priority", None)
437
+ if callable(set_prio):
438
+ return set_prio(name, priority)
439
+ return False
440
+
441
+
442
+ def reset_provider(name: str) -> bool:
443
+ """Reset an LLM provider's health metrics."""
444
+ if not LOLM_AVAILABLE:
445
+ return False
446
+ llm = get_llm()
447
+ reset = getattr(llm, "reset_provider", None)
448
+ if callable(reset):
449
+ return reset(name)
450
+ return False
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+
6
+ # New format: ```python markpact:file path=main.py
7
+ CODEBLOCK_NEW_RE = re.compile(
8
+ r"```(?P<lang>\w+)\s+markpact:(?P<kind>\w+)(?:[ \t]+(?P<meta>[^\n]*))?\n(?P<body>[\s\S]*?)\n```",
9
+ )
10
+
11
+ # Old format: ```markpact:file python path=main.py
12
+ CODEBLOCK_OLD_RE = re.compile(
13
+ r"```markpact:(?P<kind>\w+)(?:[ \t]+(?P<meta>[^\n]*))?\n(?P<body>[\s\S]*?)\n```",
14
+ )
15
+
16
+
17
+ @dataclass
18
+ class Block:
19
+ kind: str
20
+ meta: str
21
+ body: str
22
+ lang: str = ""
23
+
24
+ def get_path(self) -> str | None:
25
+ m = re.search(r"\bpath=(\S+)", self.meta)
26
+ return m[1] if m else None
27
+
28
+
29
+ def parse_blocks(text: str) -> list[Block]:
30
+ blocks = []
31
+
32
+ # Parse new format: ```python markpact:file path=main.py
33
+ for m in CODEBLOCK_NEW_RE.finditer(text):
34
+ blocks.append(Block(
35
+ kind=m.group("kind"),
36
+ meta=(m.group("meta") or "").strip(),
37
+ body=m.group("body").strip(),
38
+ lang=(m.group("lang") or "").strip(),
39
+ ))
40
+
41
+ # Parse old format: ```markpact:file python path=main.py
42
+ for m in CODEBLOCK_OLD_RE.finditer(text):
43
+ blocks.append(Block(
44
+ kind=m.group("kind"),
45
+ meta=(m.group("meta") or "").strip(),
46
+ body=m.group("body").strip(),
47
+ lang="", # Old format doesn't have separate lang
48
+ ))
49
+
50
+ return blocks