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.
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/PKG-INFO +11 -2
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/README.md +10 -1
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pamd_helpers/__init__.py +271 -4
- pyact_cli-1.2.0/pyact/__init__.py +1 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact/cli.py +55 -31
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact/core.py +150 -44
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact_cli.egg-info/PKG-INFO +11 -2
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/setup.py +1 -1
- pyact_cli-1.0.0/pyact/__init__.py +0 -1
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact/conf_parser.py +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact/css2json.py +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact/linter.py +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact/mdTOword.py +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact/py2tex.py +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact/scaffold.py +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact_cli.egg-info/SOURCES.txt +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact_cli.egg-info/dependency_links.txt +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact_cli.egg-info/entry_points.txt +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact_cli.egg-info/requires.txt +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/pyact_cli.egg-info/top_level.txt +0 -0
- {pyact_cli-1.0.0 → pyact_cli-1.2.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pyact-cli
|
|
3
|
-
Version: 1.
|
|
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
|
|
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
|
|
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
|
-
|
|
59
|
+
print("Function 'equation()' is deprecated and will be removed, please use Calculation() class.")
|
|
21
60
|
value = func(*values)
|
|
22
|
-
name,
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 =
|
|
14
|
-
if
|
|
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 =
|
|
62
|
-
directory =
|
|
63
|
-
filename =
|
|
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 =
|
|
77
|
-
cache_file =
|
|
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
|
|
81
|
-
cache_mtime =
|
|
82
|
-
if is_cache_valid(
|
|
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(
|
|
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
|
-
|
|
100
|
-
|
|
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 =
|
|
106
|
-
with open(
|
|
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
|
|
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 =
|
|
131
|
-
if
|
|
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 =
|
|
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 =
|
|
166
|
+
conf_path = Path(args.conf).resolve()
|
|
143
167
|
else:
|
|
144
|
-
local_conf =
|
|
145
|
-
cwd_conf =
|
|
146
|
-
if
|
|
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
|
|
172
|
+
elif cwd_conf.exists():
|
|
149
173
|
conf_path = cwd_conf
|
|
150
174
|
|
|
151
|
-
if conf_path and
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
return [
|
|
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
|
-
|
|
43
|
-
|
|
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
|
|
49
|
-
cell_type = cell
|
|
50
|
-
source = cell
|
|
51
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
116
|
-
if not
|
|
181
|
+
global_path = Path(directory) / "global.py"
|
|
182
|
+
if not global_path.exists():
|
|
117
183
|
return {}
|
|
118
184
|
|
|
119
|
-
with open(
|
|
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
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
code_text, main_file = read_pamd_cells(
|
|
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(
|
|
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
|
-
|
|
234
|
-
|
|
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(
|
|
333
|
+
imports = get_imports(source_path, code_text)
|
|
239
334
|
for i in needed_imports:
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
raise ValueError(
|
|
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":
|
|
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.
|
|
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
|
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|