pyact-cli 1.0.0__tar.gz → 1.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyact-cli
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: A CLI tool to compile .pamd files to Markdown.
5
5
  Home-page: https://github.com/Abstergo2003/PyAct
6
6
  Author: Abstergo2003
@@ -37,7 +37,8 @@ pip install pyact-cli
37
37
 
38
38
  ## 📖 How it works
39
39
 
40
- A `.pamd` file is split into a **Python cell** and a **Markdown cell**.
40
+ A `.pamd` file contains exactly one **Markdown cell** and may contain multiple
41
+ **Python cells**. Python cells are concatenated in order before execution.
41
42
 
42
43
  ### 1. Write your Python logic
43
44
  Define a `context()` function that returns the variables you want to inject. You can even use the built-in `pamd_helpers` to do heavy lifting!
@@ -86,6 +87,14 @@ Run the compiler from your terminal to generate your final Markdown document:
86
87
  pyact path/to/your_file.pamd -o output.md
87
88
  ```
88
89
 
90
+ Use `--no-cache` to force a rebuild without reading cached Markdown, or
91
+ `--clear-cache` to remove the project's `.pamd-cache` directory before building:
92
+
93
+ ```bash
94
+ pyact path/to/your_file.pamd --no-cache
95
+ pyact path/to/your_file.pamd --clear-cache
96
+ ```
97
+
89
98
  ### 5. Compile to Word (DOCX)
90
99
  You can also compile straight to a native Microsoft Word Document, complete with CSS styling!
91
100
  ```bash
@@ -14,7 +14,8 @@ pip install pyact-cli
14
14
 
15
15
  ## 📖 How it works
16
16
 
17
- A `.pamd` file is split into a **Python cell** and a **Markdown cell**.
17
+ A `.pamd` file contains exactly one **Markdown cell** and may contain multiple
18
+ **Python cells**. Python cells are concatenated in order before execution.
18
19
 
19
20
  ### 1. Write your Python logic
20
21
  Define a `context()` function that returns the variables you want to inject. You can even use the built-in `pamd_helpers` to do heavy lifting!
@@ -63,6 +64,14 @@ Run the compiler from your terminal to generate your final Markdown document:
63
64
  pyact path/to/your_file.pamd -o output.md
64
65
  ```
65
66
 
67
+ Use `--no-cache` to force a rebuild without reading cached Markdown, or
68
+ `--clear-cache` to remove the project's `.pamd-cache` directory before building:
69
+
70
+ ```bash
71
+ pyact path/to/your_file.pamd --no-cache
72
+ pyact path/to/your_file.pamd --clear-cache
73
+ ```
74
+
66
75
  ### 5. Compile to Word (DOCX)
67
76
  You can also compile straight to a native Microsoft Word Document, complete with CSS styling!
68
77
  ```bash
@@ -1,7 +1,46 @@
1
1
  import inspect
2
+ import ast
3
+ import textwrap
2
4
  from typing import Callable, List, Any
5
+ from enum import Enum
3
6
  from pyact.py2tex import py2tex
4
7
 
8
+
9
+ def _extract_lambda_parts(func: Callable) -> tuple[str, list[str], str]:
10
+ source = textwrap.dedent(inspect.getsource(func))
11
+ tree = ast.parse(source)
12
+ lambda_node = next(
13
+ (node for node in ast.walk(tree) if isinstance(node, ast.Lambda)),
14
+ None,
15
+ )
16
+ if lambda_node is None:
17
+ raise ValueError("Expected a lambda function.")
18
+
19
+ expression = ast.get_source_segment(source, lambda_node.body)
20
+ if expression is None:
21
+ raise ValueError("Could not read the lambda expression.")
22
+
23
+ name = getattr(func, "__name__", "calculation")
24
+ assignment = next(
25
+ (
26
+ node
27
+ for node in ast.walk(tree)
28
+ if isinstance(node, ast.Assign)
29
+ and any(isinstance(target, ast.Name) for target in node.targets)
30
+ and lambda_node in ast.walk(node.value)
31
+ ),
32
+ None,
33
+ )
34
+ if assignment is not None:
35
+ name = next(
36
+ target.id
37
+ for target in assignment.targets
38
+ if isinstance(target, ast.Name)
39
+ )
40
+
41
+ variables = [argument.arg for argument in lambda_node.args.args]
42
+ return name, variables, expression
43
+
5
44
  def equation(func: Callable, values: List[Any]) -> str:
6
45
  """
7
46
  Converts a Python lambda or simple function into a LaTeX math equation string.
@@ -17,10 +56,9 @@ def equation(func: Callable, values: List[Any]) -> str:
17
56
  Outputs:
18
57
  str: A Markdown math string formatted as `$$ Name = Equation = Value $$`.
19
58
  """
20
- source = inspect.getsource(func)
59
+ print("Function 'equation()' is deprecated and will be removed, please use Calculation() class.")
21
60
  value = func(*values)
22
- name, sep, tail = source.rpartition("=")
23
- head, sep, equ = tail.rpartition(":")
61
+ name, _, equ = _extract_lambda_parts(func)
24
62
 
25
63
  name = name.strip()
26
64
  if "_" in name:
@@ -139,6 +177,7 @@ def table(headers: List[str], values: List[List[Any]], caption: str, add_no: boo
139
177
  Outputs:
140
178
  str: A formatted Markdown string containing the caption and the table.
141
179
  """
180
+ print("Function 'table()' is deprecated and will be removed, please use Table() class.")
142
181
  if add_no:
143
182
  headers_string = f"|No.|"
144
183
  breaker_line = "|---|"
@@ -192,6 +231,7 @@ def unordered_list(items: List[str]) -> str:
192
231
  Outputs:
193
232
  str: A Markdown string where each item is prefixed with `* `.
194
233
  """
234
+ print("Function 'unordered_list()' is deprecated and will be removed, please use CList() class.")
195
235
  list_string = ""
196
236
  for i in items:
197
237
  list_string += f"* {i}\n"
@@ -210,6 +250,7 @@ def ordered_list(items: List[str]) -> str:
210
250
  Outputs:
211
251
  str: A Markdown string where each item is numbered sequentially (1. 2. 3.).
212
252
  """
253
+ print("Function 'ordered_list()' is deprecated and will be removed, please use CList() class.")
213
254
  list_string = ""
214
255
  for i, item in enumerate(items):
215
256
  list_string += f"{i+1}. {item}\n"
@@ -228,6 +269,7 @@ def checklist(items: List[str]) -> str:
228
269
  Outputs:
229
270
  str: A Markdown string where each item is prefixed with `- [ ] `.
230
271
  """
272
+ print("Function 'checklist()' is deprecated and will be removed, please use CList() class.")
231
273
  list_string = ""
232
274
  for i in items:
233
275
  list_string += f"- [ ] {i}\n"
@@ -251,6 +293,9 @@ class Footnote:
251
293
  self.number = number
252
294
  self.text = text
253
295
 
296
+ def __repr__(self) -> str:
297
+ return self.define_string()
298
+
254
299
  def define_string(self) -> str:
255
300
  """
256
301
  Outputs (str): The full footnote definition block to place at the bottom of the document.
@@ -282,6 +327,7 @@ def table_to_dxf(headers: List[str], values: List[List[Any]], filename: str, cel
282
327
  Outputs:
283
328
  str: A string confirming the export, formatted as a Markdown link.
284
329
  """
330
+ print("Function 'table_to_dfx()' is deprecated and will be removed, please use Table() class.")
285
331
  try:
286
332
  import ezdxf
287
333
  except ImportError:
@@ -319,4 +365,225 @@ def table_to_dxf(headers: List[str], values: List[List[Any]], filename: str, cel
319
365
  ).set_placement((x, y), align=ezdxf.enums.TextEntityAlignment.MIDDLE_CENTER)
320
366
 
321
367
  doc.saveas(filename)
322
- return f"*[Successfully exported table to {filename}]({filename})*"
368
+ return f"*[Successfully exported table to {filename}]({filename})*"
369
+
370
+ class Calculation:
371
+ def __init__(self, func: Callable, values: List[float|int], repl: bool = False, precision: int = 3):
372
+ self.func = func
373
+ self.values = values
374
+ self.replace = repl
375
+ self.precision = precision
376
+ self.value = round(func(*values), self.precision)
377
+ self.name = None
378
+ self.vars = None
379
+ self.repr = self.get_representation()
380
+
381
+ def __repr__(self):
382
+ return self.repr
383
+
384
+ def recalculate(self, values, precision: int = None):
385
+ if precision is None:
386
+ return Calculation(self.func, values, self.replace, self.precision)
387
+ else:
388
+ return Calculation(self.func, values, self.replace, precision)
389
+
390
+ def get_representation(self):
391
+ name, self.vars, equ = _extract_lambda_parts(self.func)
392
+
393
+ name = name.strip()
394
+ self.name = f"$ {py2tex(name)} $"
395
+ if "_" in name:
396
+ parts = name.split("_", 1)
397
+ name = f"{parts[0]}_{{{parts[1]}}}"
398
+
399
+ import re
400
+ greek_pattern = r'(?<![a-zA-Z])(alpha|beta|gamma|delta|epsilon|zeta|eta|theta|iota|kappa|lambda|mu|nu|xi|omicron|pi|rho|sigma|tau|upsilon|phi|chi|psi|omega|Alpha|Beta|Gamma|Delta|Epsilon|Zeta|Eta|Theta|Iota|Kappa|Lambda|Mu|Nu|Xi|Omicron|Pi|Rho|Sigma|Tau|Upsilon|Phi|Chi|Psi|Omega)(?![a-zA-Z])'
401
+ name = re.sub(greek_pattern, r'\\\1', name)
402
+ texified = py2tex(equ.replace(chr(10), ''))
403
+ replaced = texified
404
+ for i, vr in enumerate(self.vars):
405
+ replaced = replaced.replace(py2tex(vr.replace(",", "")), f"{self.values[i]}")
406
+
407
+ if self.replace:
408
+ return f"$$ {name} = {texified} = {replaced} = {self.value} $$"
409
+ else:
410
+ return f"$$ {name} = {texified} = {self.value} $$"
411
+
412
+ def _extract(self, other) -> float:
413
+ return float(other.value) if isinstance(other, Calculation) else float(other)
414
+
415
+ def __add__(self, other):
416
+ return float(self.value) + self._extract(other)
417
+
418
+ def __radd__(self, other):
419
+ return self._extract(other) + float(self.value)
420
+
421
+ def __sub__(self, other):
422
+ return float(self.value) - self._extract(other)
423
+
424
+ def __rsub__(self, other):
425
+ return self._extract(other) - float(self.value)
426
+
427
+ def __mul__(self, other):
428
+ return float(self.value) * self._extract(other)
429
+
430
+ def __rmul__(self, other):
431
+ return self._extract(other) * float(self.value)
432
+
433
+ def __truediv__(self, other):
434
+ return float(self.value) / self._extract(other)
435
+
436
+ def __rtruediv__(self, other):
437
+ return self._extract(other) / float(self.value)
438
+
439
+ def __pow__(self, power):
440
+ return float(self.value) ** self._extract(power)
441
+
442
+ def __neg__(self):
443
+ return -float(self.value)
444
+
445
+ def __abs__(self):
446
+ return abs(float(self.value))
447
+
448
+ class Table():
449
+ def __init__(self, headers: List[str], values: List[List[Any]], caption: str, add_no: bool = False, starting_number: int = 1):
450
+ self.headers = headers
451
+ self.values = values
452
+ self.caption = caption
453
+ self.add_no = add_no
454
+ self.starting_number = starting_number
455
+ self.repr = self.get_representation()
456
+
457
+ def __repr__(self):
458
+ return self.repr
459
+
460
+ def get_representation(self):
461
+ if self.add_no:
462
+ headers_string = f"|No.|"
463
+ breaker_line = "|---|"
464
+ else:
465
+ headers_string = f"|"
466
+ breaker_line = "|"
467
+
468
+ for i in self.headers:
469
+ headers_string += f"{i}|"
470
+ breaker_line += "-"*len(i)+"|"
471
+
472
+ table_content = ""
473
+ for i, row in enumerate(self.values):
474
+ if self.add_no:
475
+ table_content += f"|{i+self.starting_number}|"
476
+ else:
477
+ table_content += "|"
478
+ for value in row:
479
+ table_content += f"{value}|"
480
+ table_content += "\n"
481
+
482
+ caption_html = f"<span style='text-align: center; display: block; font-style: italic;'>{self.caption}</span>"
483
+ return f"{caption_html} \n {headers_string}\n{breaker_line}\n{table_content}"
484
+
485
+ def toDXF(self, filename: str, cell_width: float = 50.0, cell_height: float = 10.0):
486
+ try:
487
+ import ezdxf
488
+ except ImportError:
489
+ return f"*(Error: ezdxf library is not installed. Cannot generate {filename})*"
490
+
491
+ doc = ezdxf.new('R2010')
492
+ msp = doc.modelspace()
493
+
494
+ # Construct full grid data
495
+ rows = [self.headers] + self.values
496
+ num_rows = len(rows)
497
+ num_cols = len(self.headers)
498
+
499
+ # Draw horizontal grid lines
500
+ for i in range(num_rows + 1):
501
+ y = -i * cell_height
502
+ msp.add_line((0, y), (num_cols * cell_width, y))
503
+
504
+ # Draw vertical grid lines
505
+ for j in range(num_cols + 1):
506
+ x = j * cell_width
507
+ msp.add_line((x, 0), (x, -num_rows * cell_height))
508
+
509
+ # Draw text centered in each cell
510
+ for i, row in enumerate(rows):
511
+ y = -i * cell_height - (cell_height / 2)
512
+ for j, cell_val in enumerate(row):
513
+ if j >= num_cols: continue # Safety bounds
514
+ x = j * cell_width + (cell_width / 2)
515
+ msp.add_text(
516
+ str(cell_val),
517
+ dxfattribs={
518
+ 'height': cell_height * 0.4,
519
+ }
520
+ ).set_placement((x, y), align=ezdxf.enums.TextEntityAlignment.MIDDLE_CENTER)
521
+
522
+ doc.saveas(filename)
523
+ return f"*[Successfully exported table to {filename}]({filename})*"
524
+
525
+ class ListType(Enum):
526
+ ORDERED = 1
527
+ UNORDERED = 2
528
+ CHECKLIST = 3
529
+
530
+ class CList():
531
+ def __init__(self, items: List[Any], type: ListType):
532
+ self.items = items
533
+ self.type = type
534
+ self.repr = self.get_representation()
535
+
536
+ def __repr__(self):
537
+ return self.repr
538
+
539
+ def get_representation(self):
540
+ if self.type == ListType.ORDERED:
541
+ return self.get_ordered()
542
+ elif self.type == ListType.UNORDERED:
543
+ return self.get_unordered()
544
+ elif self.type == ListType.CHECKLIST:
545
+ return self.get_checklist()
546
+
547
+ def get_ordered(self):
548
+ list_string = ""
549
+ for i, item in enumerate(self.items):
550
+ list_string += f"{i+1}. {item}\n"
551
+ return list_string
552
+ def get_unordered(self):
553
+ list_string = ""
554
+ for i in self.items:
555
+ list_string += f"* {i}\n"
556
+ return list_string
557
+ def get_checklist(self):
558
+ list_string = ""
559
+ for i in self.items:
560
+ list_string += f"- [ ] {i}\n"
561
+ return list_string
562
+
563
+ # feat:: footnote list
564
+ # class FootnoteList():
565
+ # def __init__(self, footnotes: List[Footnote]):
566
+ # self.footnotes = footnotes
567
+ # self.repr = self.get_adnotations()
568
+
569
+ # def __repr__(self):
570
+ # return self.repr
571
+
572
+ # def append(self, footnote: Footnote|List[Footnote]):
573
+ # if isinstance(footnote, List):
574
+ # self.footnotes = self.footnotes + footnote
575
+ # else:
576
+ # self.footnotes = self.footnotes.append(footnote)
577
+ # def get_adnotations(self):
578
+ # adnots = ""
579
+ # for fn in self.footnotes:
580
+ # adnots = adnots + f"{fn.adnotation}\n"
581
+
582
+ if __name__ == "__main__":
583
+ # func_w = lambda x_1, y, z: x_1+y+z
584
+ # func_eq = Calculation(func_w, [1,2,3], True)
585
+ # print(func_eq.vars)
586
+
587
+
588
+
589
+ print(["lol4", "lol5"][-1])
@@ -0,0 +1 @@
1
+ __version__ = "1.2.0"
@@ -1,17 +1,32 @@
1
1
  import argparse
2
2
  import sys
3
- import os
4
3
  import time
4
+ import shutil
5
+ from pathlib import Path
5
6
  from .core import map_content, process_content, build_logs, generate_markdown_toc
6
7
 
7
- def is_cache_valid(build_tree, cache_mtime, root_dir=None):
8
- if root_dir is not None:
9
- global_file = os.path.join(root_dir, "global.py")
10
- if os.path.exists(global_file) and os.path.getmtime(global_file) > cache_mtime:
8
+ def _cache_dependencies(root_dir: Path) -> list[Path]:
9
+ dependencies = [
10
+ root_dir / "global.py",
11
+ root_dir / "pamd.conf",
12
+ root_dir / "style.css",
13
+ ]
14
+ helper_dir = Path(__file__).parents[1] / "pamd_helpers"
15
+ if helper_dir.exists():
16
+ dependencies.extend(helper_dir.glob("*.py"))
17
+ return dependencies
18
+
19
+
20
+ def is_cache_valid(build_tree, cache_mtime, root_dir=None, dependencies=None):
21
+ root_dir = Path(root_dir) if root_dir is not None else None
22
+ if dependencies is None and root_dir is not None:
23
+ dependencies = _cache_dependencies(root_dir)
24
+ for dependency in dependencies or []:
25
+ if dependency.exists() and dependency.stat().st_mtime > cache_mtime:
11
26
  return False
12
27
 
13
- file_path = os.path.join(build_tree.get("path", ""), build_tree.get("name", "") + ".pamd")
14
- if os.path.exists(file_path) and os.path.getmtime(file_path) > cache_mtime:
28
+ file_path = Path(build_tree["source_path"])
29
+ if file_path.exists() and file_path.stat().st_mtime > cache_mtime:
15
30
  return False
16
31
  for t in build_tree.get("templates", []):
17
32
  if not is_cache_valid(t, cache_mtime):
@@ -55,12 +70,14 @@ def main():
55
70
  parser.add_argument("--css", help="Optional CSS file path to style the DOCX")
56
71
  parser.add_argument("--conf", help="Optional path to pamd.conf configuration file")
57
72
  parser.add_argument("--lint", action="store_true", help="Run the linter on the project to check for unused variables and files")
73
+ parser.add_argument("--no-cache", action="store_true", help="Rebuild the document without reading the cache")
74
+ parser.add_argument("--clear-cache", action="store_true", help="Remove the project cache before compiling")
58
75
 
59
76
  args = parser.parse_args()
60
77
 
61
- input_path = os.path.abspath(args.input)
62
- directory = os.path.dirname(input_path)
63
- filename = os.path.basename(input_path)
78
+ input_path = Path(args.input).resolve()
79
+ directory = input_path.parent
80
+ filename = input_path.name
64
81
 
65
82
  if filename.endswith(".pamd"):
66
83
  filename = filename[:-5]
@@ -73,18 +90,26 @@ def main():
73
90
  try:
74
91
  build_tree = map_content(filename, directory)
75
92
 
76
- cache_dir = os.path.join(directory, ".pamd-cache")
77
- cache_file = os.path.join(cache_dir, f"{filename}.cache.md")
93
+ cache_dir = directory / ".pamd-cache"
94
+ cache_file = cache_dir / f"{filename}.cache.md"
78
95
  use_cache = False
96
+
97
+ if args.clear_cache and cache_dir.exists():
98
+ shutil.rmtree(cache_dir)
79
99
 
80
- if os.path.exists(cache_file):
81
- cache_mtime = os.path.getmtime(cache_file)
82
- if is_cache_valid(build_tree, cache_mtime, directory):
100
+ if not args.no_cache and cache_file.exists():
101
+ cache_mtime = cache_file.stat().st_mtime
102
+ if is_cache_valid(
103
+ build_tree,
104
+ cache_mtime,
105
+ directory,
106
+ _cache_dependencies(directory),
107
+ ):
83
108
  use_cache = True
84
109
 
85
110
  if use_cache:
86
111
  print("pamd-cache: Using cached markdown...")
87
- with open(cache_file, "r", encoding="utf-8") as f:
112
+ with cache_file.open("r", encoding="utf-8") as f:
88
113
  content = f.read()
89
114
  else:
90
115
  print("pamd-cache: Cache invalid or missing. Rebuilding...")
@@ -96,14 +121,13 @@ def main():
96
121
  total_ms = (total_end - total_start) * 1000
97
122
 
98
123
  # Write cache
99
- if not os.path.exists(cache_dir):
100
- os.makedirs(cache_dir)
101
- with open(cache_file, "w", encoding="utf-8") as f:
124
+ cache_dir.mkdir(parents=True, exist_ok=True)
125
+ with cache_file.open("w", encoding="utf-8") as f:
102
126
  f.write(content)
103
127
 
104
128
  # Write build log
105
- log_file = os.path.join(cache_dir, "build.log")
106
- with open(log_file, "w", encoding="utf-8") as f:
129
+ log_file = cache_dir / "build.log"
130
+ with log_file.open("w", encoding="utf-8") as f:
107
131
  f.write("PyAct Build Log\n")
108
132
  f.write("================\n\n")
109
133
  f.write("File Execution Times:\n")
@@ -113,7 +137,7 @@ def main():
113
137
 
114
138
  md_output = generate_markdown_toc(content)
115
139
  if args.output:
116
- with open(args.output, "w", encoding="utf-8") as f:
140
+ with Path(args.output).open("w", encoding="utf-8") as f:
117
141
  f.write(md_output)
118
142
  print(f"Successfully compiled to {args.output}")
119
143
  else:
@@ -127,28 +151,28 @@ def main():
127
151
  styles = style_parser(args.css)
128
152
  else:
129
153
  # Try to look for style.css in the active directory
130
- local_css = os.path.join(directory, "style.css")
131
- if os.path.exists(local_css):
154
+ local_css = directory / "style.css"
155
+ if local_css.exists():
132
156
  styles = style_parser(local_css)
133
157
  else:
134
158
  # Fallback to the default one packaged in pyact
135
- default_css = os.path.join(os.path.dirname(__file__), "style.css")
159
+ default_css = Path(__file__).parent / "style.css"
136
160
  styles = style_parser(default_css)
137
161
 
138
162
  # Load pamd.conf configuration
139
163
  conf = {}
140
164
  conf_path = None
141
165
  if args.conf:
142
- conf_path = os.path.abspath(args.conf)
166
+ conf_path = Path(args.conf).resolve()
143
167
  else:
144
- local_conf = os.path.join(directory, "pamd.conf")
145
- cwd_conf = os.path.join(os.getcwd(), "pamd.conf")
146
- if os.path.exists(local_conf):
168
+ local_conf = directory / "pamd.conf"
169
+ cwd_conf = Path.cwd() / "pamd.conf"
170
+ if local_conf.exists():
147
171
  conf_path = local_conf
148
- elif os.path.exists(cwd_conf):
172
+ elif cwd_conf.exists():
149
173
  conf_path = cwd_conf
150
174
 
151
- if conf_path and os.path.exists(conf_path):
175
+ if conf_path and conf_path.exists():
152
176
  from .conf_parser import conf_parser
153
177
  conf = conf_parser(conf_path)
154
178
 
@@ -1,12 +1,65 @@
1
1
  import re
2
2
  import json
3
3
  import time
4
- import os
5
4
  import linecache
5
+ from pathlib import Path
6
+ from typing import Any, Mapping
6
7
 
7
8
  build_logs = []
8
9
 
9
- def get_directory(path: str) -> list:
10
+
11
+ def validate_pamd_document(data: Any, file_path: str | Path) -> list[dict]:
12
+ """Validate the structural contract of a PAMD notebook document."""
13
+ path = Path(file_path)
14
+ if not isinstance(data, dict):
15
+ raise ValueError(f"Invalid PAMD file {path}: the root must be a JSON object.")
16
+
17
+ cells = data.get("cells")
18
+ if not isinstance(cells, list):
19
+ raise ValueError(f"Invalid PAMD file {path}: 'cells' must be a JSON array.")
20
+
21
+ validated_cells = []
22
+ markdown_count = 0
23
+ for index, cell in enumerate(cells):
24
+ if not isinstance(cell, dict):
25
+ raise ValueError(
26
+ f"Invalid PAMD file {path}: cell {index} must be a JSON object."
27
+ )
28
+
29
+ cell_type = cell.get("cell_type")
30
+ if cell_type not in {"code", "markdown"}:
31
+ raise ValueError(
32
+ f"Invalid PAMD file {path}: cell {index} has unsupported "
33
+ f"cell_type {cell_type!r}; expected 'code' or 'markdown'."
34
+ )
35
+
36
+ source = cell.get("source")
37
+ if isinstance(source, str):
38
+ normalized_source = source
39
+ elif isinstance(source, list) and all(isinstance(line, str) for line in source):
40
+ normalized_source = "".join(source)
41
+ else:
42
+ raise ValueError(
43
+ f"Invalid PAMD file {path}: cell {index} 'source' must be "
44
+ "a string or an array of strings."
45
+ )
46
+
47
+ if cell_type == "markdown":
48
+ markdown_count += 1
49
+ validated_cells.append(
50
+ {"cell_type": cell_type, "source": normalized_source}
51
+ )
52
+
53
+ if markdown_count != 1:
54
+ raise ValueError(
55
+ f"Invalid PAMD file {path}: expected exactly one markdown cell, "
56
+ f"found {markdown_count}."
57
+ )
58
+
59
+ return validated_cells
60
+
61
+
62
+ def get_directory(path: str | Path) -> list:
10
63
  """
11
64
  Splits a full file path into its directory component and its filename component.
12
65
 
@@ -21,8 +74,8 @@ def get_directory(path: str) -> list:
21
74
  Outputs:
22
75
  list: A two-element list `[directory_path, filename]`.
23
76
  """
24
- head, sep, tail = path.rpartition('/')
25
- return [head + sep, tail]
77
+ path = Path(path)
78
+ return [path.parent, path.name]
26
79
 
27
80
  def read_pamd_cells(file_path: str):
28
81
  """
@@ -39,26 +92,39 @@ def read_pamd_cells(file_path: str):
39
92
  Outputs:
40
93
  tuple (str, str): A tuple containing `(code_content, markdown_content)`.
41
94
  """
42
- with open(file_path, 'r', encoding='utf-8') as f:
43
- data = json.load(f)
95
+ path = Path(file_path)
96
+ try:
97
+ with path.open("r", encoding="utf-8") as f:
98
+ data = json.load(f)
99
+ except json.JSONDecodeError as error:
100
+ raise ValueError(
101
+ f"Invalid PAMD file {path}: malformed JSON at line "
102
+ f"{error.lineno}, column {error.colno}: {error.msg}."
103
+ ) from error
104
+ except OSError as error:
105
+ raise OSError(f"Could not read PAMD file {path}: {error}") from error
106
+
107
+ cells = validate_pamd_document(data, path)
44
108
 
45
109
  code_content = ""
46
110
  markdown_content = ""
47
111
 
48
- for cell in data.get("cells", []):
49
- cell_type = cell.get("cell_type", "")
50
- source = cell.get("source", "")
51
- if isinstance(source, list):
52
- source = "".join(source)
53
-
112
+ for cell in cells:
113
+ cell_type = cell["cell_type"]
114
+ source = cell["source"]
115
+
54
116
  if cell_type == "code":
55
- code_content += source + "\n"
117
+ code_content += source
118
+ if source and not source.endswith("\n"):
119
+ code_content += "\n"
56
120
  elif cell_type == "markdown":
57
- markdown_content += source + "\n"
121
+ markdown_content += source
122
+ if source and not source.endswith("\n"):
123
+ markdown_content += "\n"
58
124
 
59
125
  return code_content, markdown_content
60
126
 
61
- def get_imports(file: str, code_text: str):
127
+ def get_imports(file: str | Path, code_text: str):
62
128
  """
63
129
  Executes the Python code from a .pamd file and retrieves the context variables.
64
130
 
@@ -75,7 +141,7 @@ def get_imports(file: str, code_text: str):
75
141
  Outputs:
76
142
  dict: The dictionary of variables returned by the user's `context()` function.
77
143
  """
78
- filename = f"{file}.pamd"
144
+ filename = str(Path(file).with_suffix(".pamd"))
79
145
  file_namespace = {"__file__": filename}
80
146
  if not code_text.strip():
81
147
  raise ValueError(f"The file {filename} needs a 'context()' function but has no code cell.")
@@ -108,20 +174,20 @@ def get_imports(file: str, code_text: str):
108
174
 
109
175
  return result_dict
110
176
 
111
- def get_globals(directory: str):
177
+ def get_globals(directory: str | Path):
112
178
  """
113
179
  Reads global.py and executes its global_context() function to get global variables.
114
180
  """
115
- global_path = os.path.join(directory, "global.py")
116
- if not os.path.exists(global_path):
181
+ global_path = Path(directory) / "global.py"
182
+ if not global_path.exists():
117
183
  return {}
118
184
 
119
- with open(global_path, "r", encoding="utf-8") as f:
185
+ with global_path.open("r", encoding="utf-8") as f:
120
186
  code_text = f.read()
121
187
 
122
- file_namespace = {"__file__": global_path}
188
+ file_namespace = {"__file__": str(global_path)}
123
189
  try:
124
- compiled_code = compile(code_text, global_path, "exec")
190
+ compiled_code = compile(code_text, str(global_path), "exec")
125
191
  exec(compiled_code, file_namespace)
126
192
  except Exception as e:
127
193
  raise RuntimeError(f"Error executing global.py: {e}")
@@ -178,7 +244,20 @@ def find_templates(text: str):
178
244
  pattern = r"<tmp>(.*?)</tmp>"
179
245
  return re.findall(pattern, text)
180
246
 
181
- def map_content(file: str, path: str):
247
+ def _template_path(template: str | Path, directory: str | Path) -> Path:
248
+ template_path = Path(template)
249
+ if template_path.suffix != ".pamd":
250
+ template_path = template_path.with_suffix(".pamd")
251
+ if not template_path.is_absolute():
252
+ template_path = Path(directory) / template_path
253
+ return template_path
254
+
255
+
256
+ def map_content(
257
+ file: str | Path,
258
+ path: str | Path,
259
+ _active_paths: tuple[Path, ...] = (),
260
+ ):
182
261
  """
183
262
  Recursively maps the dependency tree of a `.pamd` file.
184
263
 
@@ -195,32 +274,42 @@ def map_content(file: str, path: str):
195
274
  dict: A recursive "build tree" dictionary containing metadata, required imports,
196
275
  and the build trees of all nested templates.
197
276
  """
198
- if path and not path.endswith('/'):
199
- full_path = f"{path}/{file}"
200
- elif path.endswith('/'):
201
- full_path = f"{path}{file}"
202
- else:
203
- full_path = file
204
-
205
- code_text, main_file = read_pamd_cells(f"{full_path}.pamd")
277
+ source_path = _template_path(file, path).resolve()
278
+ if source_path in _active_paths:
279
+ cycle_start = _active_paths.index(source_path)
280
+ cycle = (*_active_paths[cycle_start:], source_path)
281
+ cycle_display = " -> ".join(str(item) for item in cycle)
282
+ raise ValueError(f"Recursive <tmp> template cycle detected: {cycle_display}")
283
+
284
+ code_text, main_file = read_pamd_cells(str(source_path))
206
285
  templates = find_templates(main_file)
207
286
  imports = find_imports(main_file)
208
287
  globals_req = find_globals(main_file)
209
- meta = get_directory(full_path)
288
+ meta = get_directory(source_path.with_suffix(""))
210
289
 
211
290
  ready_t = []
212
291
  for i in templates:
213
- ready_t.append(map_content(i, meta[0]))
292
+ ready_t.append(map_content(i, meta[0], (*_active_paths, source_path)))
214
293
 
215
294
  return {
216
- "tag_name": file,
217
- "name": meta[1],
295
+ "tag_name": str(file),
296
+ "name": str(meta[1]),
218
297
  "path": meta[0],
298
+ "source_path": source_path,
219
299
  "imports": imports,
220
300
  "globals": globals_req,
221
301
  "templates": ready_t
222
302
  }
223
303
 
304
+ def _resolve_value(values: Mapping[str, Any], key: str) -> Any:
305
+ current: Any = values
306
+ for part in key.split("."):
307
+ if not isinstance(current, Mapping) or part not in current:
308
+ raise KeyError(key)
309
+ current = current[part]
310
+ return current
311
+
312
+
224
313
  def process_content(build_tree: dict, globals_dict: dict = None):
225
314
  """
226
315
  Compiles a mapped build tree into a final flat Markdown string.
@@ -230,21 +319,38 @@ def process_content(build_tree: dict, globals_dict: dict = None):
230
319
  if globals_dict is None:
231
320
  globals_dict = get_globals(build_tree.get("path", ""))
232
321
 
233
- file_path_base = build_tree.get("path", "") + build_tree.get("name", "")
234
- code_text, file_text = read_pamd_cells(file_path_base + ".pamd")
322
+ source_path = build_tree.get("source_path")
323
+ if source_path is None:
324
+ source_path = _template_path(
325
+ build_tree.get("name", ""),
326
+ build_tree.get("path", ""),
327
+ )
328
+ source_path = Path(source_path)
329
+ code_text, file_text = read_pamd_cells(str(source_path))
235
330
 
236
331
  needed_imports = build_tree.get("imports", [])
237
332
  if needed_imports:
238
- imports = get_imports(file_path_base, code_text)
333
+ imports = get_imports(source_path, code_text)
239
334
  for i in needed_imports:
240
- file_text = file_text.replace(f"<ctx>{i}</ctx>", str(imports.get(i, '')))
335
+ try:
336
+ value = _resolve_value(imports, i)
337
+ except KeyError:
338
+ raise ValueError(
339
+ f"Context variable '{i}' requested in {source_path.name} "
340
+ "but not returned by context()."
341
+ ) from None
342
+ file_text = file_text.replace(f"<ctx>{i}</ctx>", str(value))
241
343
 
242
344
  needed_globals = build_tree.get("globals", [])
243
345
  for g in needed_globals:
244
- if g in globals_dict:
245
- file_text = file_text.replace(f"<glb>{g}</glb>", str(globals_dict[g]))
246
- else:
247
- raise ValueError(f"Global variable '{g}' requested via <glb> but not found in global.py's global_context().")
346
+ try:
347
+ value = _resolve_value(globals_dict, g)
348
+ except KeyError:
349
+ raise ValueError(
350
+ f"Global variable '{g}' requested via <glb> but not found "
351
+ "in global.py's global_context()."
352
+ ) from None
353
+ file_text = file_text.replace(f"<glb>{g}</glb>", str(value))
248
354
 
249
355
  for i in build_tree.get("templates", []):
250
356
  file_text = file_text.replace(f"<tmp>{i.get('tag_name')}</tmp>", process_content(i, globals_dict))
@@ -253,7 +359,7 @@ def process_content(build_tree: dict, globals_dict: dict = None):
253
359
  elapsed_ms = (end_time - start_time) * 1000
254
360
 
255
361
  build_logs.append({
256
- "file": f"{build_tree.get('name', '')}.pamd",
362
+ "file": source_path.name,
257
363
  "time_ms": elapsed_ms
258
364
  })
259
365
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyact-cli
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: A CLI tool to compile .pamd files to Markdown.
5
5
  Home-page: https://github.com/Abstergo2003/PyAct
6
6
  Author: Abstergo2003
@@ -37,7 +37,8 @@ pip install pyact-cli
37
37
 
38
38
  ## 📖 How it works
39
39
 
40
- A `.pamd` file is split into a **Python cell** and a **Markdown cell**.
40
+ A `.pamd` file contains exactly one **Markdown cell** and may contain multiple
41
+ **Python cells**. Python cells are concatenated in order before execution.
41
42
 
42
43
  ### 1. Write your Python logic
43
44
  Define a `context()` function that returns the variables you want to inject. You can even use the built-in `pamd_helpers` to do heavy lifting!
@@ -86,6 +87,14 @@ Run the compiler from your terminal to generate your final Markdown document:
86
87
  pyact path/to/your_file.pamd -o output.md
87
88
  ```
88
89
 
90
+ Use `--no-cache` to force a rebuild without reading cached Markdown, or
91
+ `--clear-cache` to remove the project's `.pamd-cache` directory before building:
92
+
93
+ ```bash
94
+ pyact path/to/your_file.pamd --no-cache
95
+ pyact path/to/your_file.pamd --clear-cache
96
+ ```
97
+
89
98
  ### 5. Compile to Word (DOCX)
90
99
  You can also compile straight to a native Microsoft Word Document, complete with CSS styling!
91
100
  ```bash
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name="pyact-cli",
8
- version="1.0.0",
8
+ version="1.2.0",
9
9
  author="Abstergo2003",
10
10
  author_email="",
11
11
  description="A CLI tool to compile .pamd files to Markdown.",
@@ -1 +0,0 @@
1
- __version__ = "1.0.0"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes