marktripy 1.0.3__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.
marktripy/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ # this_file: marktripy/__init__.py
2
+ """marktripy: A Python package for converting Markdown to AST and back to Markdown."""
3
+
4
+ __version__ = "0.1.0"
5
+ __author__ = "Adam"
6
+
7
+ from loguru import logger
8
+
9
+ # Configure default logger
10
+ logger.disable("marktripy") # Disabled by default, users can enable
marktripy/_version.py ADDED
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '1.0.3'
21
+ __version_tuple__ = version_tuple = (1, 0, 3)
@@ -0,0 +1,7 @@
1
+ # this_file: marktripy/core/__init__.py
2
+ """Core abstractions and interfaces for marktripy."""
3
+
4
+ from marktripy.core.ast import ASTNode
5
+ from marktripy.core.parser import Parser
6
+
7
+ __all__ = ["ASTNode", "Parser"]
marktripy/core/ast.py ADDED
@@ -0,0 +1,274 @@
1
+ # this_file: marktripy/core/ast.py
2
+ """Abstract Syntax Tree node definitions for marktripy."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from abc import ABC
7
+ from dataclasses import dataclass, field
8
+ from typing import Any
9
+
10
+ from loguru import logger
11
+
12
+
13
+ @dataclass
14
+ class ASTNode(ABC):
15
+ """Base class for all AST nodes.
16
+
17
+ This represents a node in the Markdown abstract syntax tree. Each node
18
+ has a type, optional children, attributes, content, and metadata.
19
+
20
+ Attributes:
21
+ type: The type of node (e.g., 'heading', 'paragraph', 'list')
22
+ children: List of child nodes
23
+ attrs: Dictionary of attributes (e.g., 'id', 'class')
24
+ content: Optional text content for leaf nodes
25
+ meta: Parser-specific metadata (e.g., source position)
26
+ """
27
+
28
+ type: str
29
+ children: list[ASTNode] = field(default_factory=list)
30
+ attrs: dict[str, Any] = field(default_factory=dict)
31
+ content: str | None = None
32
+ meta: dict[str, Any] = field(default_factory=dict)
33
+
34
+ def __post_init__(self) -> None:
35
+ """Validate node after initialization."""
36
+ if not self.type:
37
+ raise ValueError("Node type cannot be empty")
38
+ logger.debug(f"Created ASTNode: type={self.type}, children={len(self.children)}")
39
+
40
+ def add_child(self, child: ASTNode) -> None:
41
+ """Add a child node."""
42
+ self.children.append(child)
43
+ logger.debug(f"Added child {child.type} to {self.type}")
44
+
45
+ def remove_child(self, child: ASTNode) -> None:
46
+ """Remove a child node."""
47
+ self.children.remove(child)
48
+ logger.debug(f"Removed child {child.type} from {self.type}")
49
+
50
+ def get_attr(self, name: str, default: Any = None) -> Any:
51
+ """Get an attribute value."""
52
+ return self.attrs.get(name, default)
53
+
54
+ def set_attr(self, name: str, value: Any) -> None:
55
+ """Set an attribute value."""
56
+ self.attrs[name] = value
57
+ logger.debug(f"Set attribute {name}={value} on {self.type}")
58
+
59
+ def walk(self) -> list[ASTNode]:
60
+ """Walk the tree and return all nodes in depth-first order."""
61
+ nodes = [self]
62
+ for child in self.children:
63
+ nodes.extend(child.walk())
64
+ return nodes
65
+
66
+ def find_all(self, node_type: str) -> list[ASTNode]:
67
+ """Find all nodes of a specific type in the subtree."""
68
+ return [node for node in self.walk() if node.type == node_type]
69
+
70
+ def replace_child(self, old_child: ASTNode, new_child: ASTNode) -> None:
71
+ """Replace a child node with another."""
72
+ try:
73
+ index = self.children.index(old_child)
74
+ self.children[index] = new_child
75
+ logger.debug(f"Replaced child {old_child.type} with {new_child.type}")
76
+ except ValueError as e:
77
+ raise ValueError(f"Child {old_child.type} not found in {self.type}") from e
78
+
79
+ def clone(self) -> ASTNode:
80
+ """Create a deep copy of this node and its subtree."""
81
+ import copy
82
+
83
+ # Create a shallow copy of the node
84
+ cloned = copy.copy(self)
85
+ # Deep copy the mutable attributes
86
+ cloned.children = [child.clone() for child in self.children]
87
+ cloned.attrs = self.attrs.copy()
88
+ cloned.meta = self.meta.copy()
89
+ return cloned
90
+
91
+
92
+ # Concrete node types
93
+ @dataclass
94
+ class Document(ASTNode):
95
+ """Root document node."""
96
+
97
+ def __init__(self, **kwargs):
98
+ super().__init__(type="document", **kwargs)
99
+
100
+
101
+ @dataclass
102
+ class Heading(ASTNode):
103
+ """Heading node with level."""
104
+
105
+ level: int = 1
106
+
107
+ def __init__(self, level: int = 1, **kwargs):
108
+ super().__init__(type="heading", **kwargs)
109
+ self.level = level
110
+ if not 1 <= level <= 6:
111
+ raise ValueError(f"Heading level must be 1-6, got {level}")
112
+
113
+
114
+ @dataclass
115
+ class Paragraph(ASTNode):
116
+ """Paragraph node."""
117
+
118
+ def __init__(self, **kwargs):
119
+ super().__init__(type="paragraph", **kwargs)
120
+
121
+
122
+ @dataclass
123
+ class Text(ASTNode):
124
+ """Text leaf node."""
125
+
126
+ def __init__(self, content: str, **kwargs):
127
+ super().__init__(type="text", content=content, **kwargs)
128
+
129
+
130
+ @dataclass
131
+ class Emphasis(ASTNode):
132
+ """Emphasis (italic) node."""
133
+
134
+ def __init__(self, **kwargs):
135
+ super().__init__(type="emphasis", **kwargs)
136
+
137
+
138
+ @dataclass
139
+ class Strong(ASTNode):
140
+ """Strong (bold) node."""
141
+
142
+ def __init__(self, **kwargs):
143
+ super().__init__(type="strong", **kwargs)
144
+
145
+
146
+ @dataclass
147
+ class Link(ASTNode):
148
+ """Link node with href and title."""
149
+
150
+ href: str = ""
151
+ title: str | None = None
152
+
153
+ def __init__(self, href: str = "", title: str | None = None, **kwargs):
154
+ super().__init__(type="link", **kwargs)
155
+ self.href = href
156
+ self.title = title
157
+ self.set_attr("href", href)
158
+ if title:
159
+ self.set_attr("title", title)
160
+
161
+
162
+ @dataclass
163
+ class Image(ASTNode):
164
+ """Image node with src, alt, and title."""
165
+
166
+ src: str = ""
167
+ alt: str = ""
168
+ title: str | None = None
169
+
170
+ def __init__(self, src: str = "", alt: str = "", title: str | None = None, **kwargs):
171
+ super().__init__(type="image", **kwargs)
172
+ self.src = src
173
+ self.alt = alt
174
+ self.title = title
175
+ self.set_attr("src", src)
176
+ self.set_attr("alt", alt)
177
+ if title:
178
+ self.set_attr("title", title)
179
+
180
+
181
+ @dataclass
182
+ class CodeBlock(ASTNode):
183
+ """Code block with optional language."""
184
+
185
+ language: str | None = None
186
+
187
+ def __init__(self, content: str, language: str | None = None, **kwargs):
188
+ super().__init__(type="code_block", content=content, **kwargs)
189
+ self.language = language
190
+ if language:
191
+ self.set_attr("language", language)
192
+
193
+
194
+ @dataclass
195
+ class InlineCode(ASTNode):
196
+ """Inline code node."""
197
+
198
+ def __init__(self, content: str, **kwargs):
199
+ super().__init__(type="inline_code", content=content, **kwargs)
200
+
201
+
202
+ @dataclass
203
+ class List(ASTNode):
204
+ """List node (ordered or unordered)."""
205
+
206
+ ordered: bool = False
207
+ start: int | None = None
208
+ tight: bool = True
209
+
210
+ def __init__(
211
+ self, ordered: bool = False, start: int | None = None, tight: bool = True, **kwargs
212
+ ):
213
+ super().__init__(type="list", **kwargs)
214
+ self.ordered = ordered
215
+ self.start = start
216
+ self.tight = tight
217
+ # Don't set ordered as an HTML attribute - it's determined by tag type
218
+ if start is not None and start != 1:
219
+ self.set_attr("start", start)
220
+
221
+
222
+ @dataclass
223
+ class ListItem(ASTNode):
224
+ """List item node."""
225
+
226
+ def __init__(self, **kwargs):
227
+ super().__init__(type="list_item", **kwargs)
228
+
229
+
230
+ @dataclass
231
+ class BlockQuote(ASTNode):
232
+ """Block quote node."""
233
+
234
+ def __init__(self, **kwargs):
235
+ super().__init__(type="blockquote", **kwargs)
236
+
237
+
238
+ @dataclass
239
+ class HorizontalRule(ASTNode):
240
+ """Horizontal rule node."""
241
+
242
+ def __init__(self, **kwargs):
243
+ super().__init__(type="horizontal_rule", **kwargs)
244
+
245
+
246
+ @dataclass
247
+ class Table(ASTNode):
248
+ """Table node."""
249
+
250
+ def __init__(self, **kwargs):
251
+ super().__init__(type="table", **kwargs)
252
+
253
+
254
+ @dataclass
255
+ class TableRow(ASTNode):
256
+ """Table row node."""
257
+
258
+ def __init__(self, **kwargs):
259
+ super().__init__(type="table_row", **kwargs)
260
+
261
+
262
+ @dataclass
263
+ class TableCell(ASTNode):
264
+ """Table cell node."""
265
+
266
+ header: bool = False
267
+ align: str | None = None
268
+
269
+ def __init__(self, header: bool = False, align: str | None = None, **kwargs):
270
+ super().__init__(type="table_cell", **kwargs)
271
+ self.header = header
272
+ self.align = align
273
+ if align:
274
+ self.set_attr("align", align)
@@ -0,0 +1,199 @@
1
+ # this_file: marktripy/core/parser.py
2
+ """Abstract parser interface for marktripy."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from abc import ABC, abstractmethod
7
+ from typing import Any
8
+
9
+ from loguru import logger
10
+
11
+ from marktripy.core.ast import Document
12
+
13
+
14
+ class Parser(ABC):
15
+ """Abstract base class for Markdown parsers.
16
+
17
+ This interface defines the contract that all parser implementations
18
+ must follow. Parsers convert Markdown text into an AST representation.
19
+ """
20
+
21
+ def __init__(self, config: dict[str, Any] | None = None):
22
+ """Initialize parser with optional configuration.
23
+
24
+ Args:
25
+ config: Parser-specific configuration options
26
+ """
27
+ self.config = config or {}
28
+ logger.debug(f"Initialized {self.__class__.__name__} with config: {self.config}")
29
+
30
+ @abstractmethod
31
+ def parse(self, text: str) -> Document:
32
+ """Parse Markdown text into an AST.
33
+
34
+ Args:
35
+ text: The Markdown text to parse
36
+
37
+ Returns:
38
+ The root Document node of the AST
39
+ """
40
+
41
+ @abstractmethod
42
+ def get_capabilities(self) -> dict[str, bool]:
43
+ """Get parser capabilities.
44
+
45
+ Returns a dictionary indicating which features this parser supports.
46
+
47
+ Returns:
48
+ Dictionary with capability flags like:
49
+ - 'tables': Table support
50
+ - 'strikethrough': Strikethrough syntax
51
+ - 'task_lists': Task list checkboxes
52
+ - 'footnotes': Footnote references
53
+ - 'definition_lists': Definition list syntax
54
+ - 'math': Math expressions
55
+ - 'smart_quotes': Smart typography
56
+ - 'custom_extensions': Custom extension support
57
+ """
58
+
59
+ def validate_markdown(self, text: str) -> list[str]:
60
+ """Validate Markdown syntax and return any errors.
61
+
62
+ Default implementation just tries to parse and catches exceptions.
63
+ Subclasses can override for more detailed validation.
64
+
65
+ Args:
66
+ text: The Markdown text to validate
67
+
68
+ Returns:
69
+ List of validation error messages (empty if valid)
70
+ """
71
+ errors = []
72
+ try:
73
+ self.parse(text)
74
+ except Exception as e:
75
+ errors.append(str(e))
76
+ return errors
77
+
78
+ def preprocess(self, text: str) -> str:
79
+ """Preprocess Markdown text before parsing.
80
+
81
+ Can be overridden by subclasses to normalize text, handle
82
+ special cases, or apply transformations.
83
+
84
+ Args:
85
+ text: Raw Markdown text
86
+
87
+ Returns:
88
+ Preprocessed text ready for parsing
89
+ """
90
+ # Normalize line endings
91
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
92
+
93
+ # Ensure text ends with newline
94
+ if text and not text.endswith("\n"):
95
+ text += "\n"
96
+
97
+ logger.debug(f"Preprocessed text: {len(text)} characters")
98
+ return text
99
+
100
+ def postprocess(self, ast: Document) -> Document:
101
+ """Postprocess AST after parsing.
102
+
103
+ Can be overridden by subclasses to apply transformations,
104
+ validate the AST structure, or enrich nodes with metadata.
105
+
106
+ Args:
107
+ ast: The parsed AST
108
+
109
+ Returns:
110
+ The postprocessed AST
111
+ """
112
+ logger.debug(f"Postprocessed AST with {len(ast.children)} top-level nodes")
113
+ return ast
114
+
115
+
116
+ class ParserError(Exception):
117
+ """Base exception for parser errors."""
118
+
119
+ def __init__(self, message: str, line: int | None = None, column: int | None = None):
120
+ """Initialize parser error.
121
+
122
+ Args:
123
+ message: Error description
124
+ line: Line number where error occurred (1-based)
125
+ column: Column number where error occurred (1-based)
126
+ """
127
+ self.line = line
128
+ self.column = column
129
+
130
+ if line is not None and column is not None:
131
+ full_message = f"{message} at line {line}, column {column}"
132
+ elif line is not None:
133
+ full_message = f"{message} at line {line}"
134
+ else:
135
+ full_message = message
136
+
137
+ super().__init__(full_message)
138
+
139
+
140
+ class ParserRegistry:
141
+ """Registry for available parser implementations."""
142
+
143
+ _parsers: dict[str, type[Parser]] = {}
144
+
145
+ @classmethod
146
+ def register(cls, name: str, parser_class: type[Parser]) -> None:
147
+ """Register a parser implementation.
148
+
149
+ Args:
150
+ name: Name to register the parser under
151
+ parser_class: Parser class to register
152
+ """
153
+ if not issubclass(parser_class, Parser):
154
+ raise TypeError(f"{parser_class} must be a subclass of Parser")
155
+
156
+ cls._parsers[name] = parser_class
157
+ logger.info(f"Registered parser: {name} -> {parser_class.__name__}")
158
+
159
+ @classmethod
160
+ def get(cls, name: str) -> type[Parser]:
161
+ """Get a registered parser class.
162
+
163
+ Args:
164
+ name: Name of the parser to retrieve
165
+
166
+ Returns:
167
+ The parser class
168
+
169
+ Raises:
170
+ KeyError: If parser name is not registered
171
+ """
172
+ if name not in cls._parsers:
173
+ raise KeyError(
174
+ f"Parser '{name}' not registered. Available: {list(cls._parsers.keys())}"
175
+ )
176
+ return cls._parsers[name]
177
+
178
+ @classmethod
179
+ def create(cls, name: str, config: dict[str, Any] | None = None) -> Parser:
180
+ """Create a parser instance.
181
+
182
+ Args:
183
+ name: Name of the parser to create
184
+ config: Parser configuration
185
+
186
+ Returns:
187
+ Parser instance
188
+ """
189
+ parser_class = cls.get(name)
190
+ return parser_class(config)
191
+
192
+ @classmethod
193
+ def list_parsers(cls) -> list[str]:
194
+ """List all registered parser names.
195
+
196
+ Returns:
197
+ List of parser names
198
+ """
199
+ return list(cls._parsers.keys())