Updates.
This commit is contained in:
38
packages/python/__init__.py
Normal file
38
packages/python/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Terrace Python Package
|
||||
|
||||
Terrace is a simple structured data syntax for configuration, content authoring, and DSLs.
|
||||
"""
|
||||
|
||||
from .parser import LineData, createLineData, parseLine
|
||||
from .document import (
|
||||
TerraceNode,
|
||||
TerraceDocument,
|
||||
Reader,
|
||||
use_document,
|
||||
useDocument, # Legacy alias
|
||||
create_string_reader,
|
||||
create_file_reader,
|
||||
create_lines_reader
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Core parser
|
||||
'LineData',
|
||||
'createLineData',
|
||||
'parseLine',
|
||||
|
||||
# New API
|
||||
'TerraceNode',
|
||||
'TerraceDocument',
|
||||
'Reader',
|
||||
'use_document',
|
||||
'useDocument',
|
||||
|
||||
# Reader utilities
|
||||
'create_string_reader',
|
||||
'create_file_reader',
|
||||
'create_lines_reader'
|
||||
]
|
||||
|
||||
__version__ = '0.2.0'
|
||||
51
packages/python/docs/core-api.inc.tce
Normal file
51
packages/python/docs/core-api.inc.tce
Normal file
@@ -0,0 +1,51 @@
|
||||
Heading 2 Core API
|
||||
class mt-12 mb-6
|
||||
|
||||
Markdown
|
||||
The core Python API provides parsing utilities and data structures for handling Terrace document structures.
|
||||
|
||||
Heading 3 Types
|
||||
class mt-8 mb-4
|
||||
|
||||
CodeBlock python
|
||||
from typing import TypedDict, Optional, Callable
|
||||
|
||||
# Type for line data
|
||||
class LineData(TypedDict):
|
||||
indent: str
|
||||
level: int
|
||||
offsetHead: int
|
||||
offsetTail: int
|
||||
|
||||
# Type for a reader function
|
||||
Reader = Callable[[], Optional[str]]
|
||||
|
||||
Heading 3 Parser Functions
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
Core parsing functions for processing Terrace document lines:
|
||||
|
||||
CodeBlock python
|
||||
def createLineData(indent: str = ' ') -> LineData:
|
||||
"""Initialize a LineData instance with default values"""
|
||||
|
||||
def parseLine(line: str, lineData: LineData) -> None:
|
||||
"""Parse a line and update the LineData in-place"""
|
||||
|
||||
Heading 3 Usage Example
|
||||
class mt-8 mb-4
|
||||
|
||||
CodeBlock python
|
||||
from terrace import createLineData, parseLine
|
||||
|
||||
# Initialize line data with space indentation
|
||||
line_data = createLineData(' ')
|
||||
|
||||
# Parse a line
|
||||
line = " config database localhost"
|
||||
parseLine(line, line_data)
|
||||
|
||||
print(f"Level: {line_data['level']}") # 2
|
||||
print(f"Head start: {line_data['offsetHead']}") # 2
|
||||
print(f"Head end: {line_data['offsetTail']}") # 8
|
||||
153
packages/python/docs/document-api.inc.tce
Normal file
153
packages/python/docs/document-api.inc.tce
Normal file
@@ -0,0 +1,153 @@
|
||||
Heading 2 Document API
|
||||
class mt-12
|
||||
|
||||
Markdown
|
||||
The Document API provides a higher-level interface for parsing Terrace documents with Python idioms and best practices.
|
||||
|
||||
Heading 3 TerraceDocument
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
Main document iterator for Terrace documents that supports Python's iteration protocols.
|
||||
|
||||
CodeBlock python
|
||||
class TerraceDocument:
|
||||
"""Main document iterator for Terrace documents"""
|
||||
|
||||
def __init__(self, reader: Reader, indent: str = ' '):
|
||||
"""Create a new TerraceDocument with the given reader"""
|
||||
|
||||
def __iter__(self) -> Iterator[TerraceNode]:
|
||||
"""Make the document iterable"""
|
||||
|
||||
Heading 3 TerraceNode
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
Represents a single node/line in a Terrace document with convenient property access.
|
||||
|
||||
CodeBlock python
|
||||
class TerraceNode:
|
||||
"""Represents a single node/line in a Terrace document"""
|
||||
|
||||
@property
|
||||
def head(self) -> str:
|
||||
"""Get the first word of the line"""
|
||||
|
||||
@property
|
||||
def tail(self) -> str:
|
||||
"""Get everything after the first word"""
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
"""Get the line content without indentation"""
|
||||
|
||||
@property
|
||||
def level(self) -> int:
|
||||
"""Get the indentation level"""
|
||||
|
||||
@property
|
||||
def line_number(self) -> int:
|
||||
"""Get the line number (zero-indexed)"""
|
||||
|
||||
def is_(self, value: str) -> bool:
|
||||
"""Check if the head matches the given value"""
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""Check if the line is empty/blank"""
|
||||
|
||||
def raw(self, offset: Optional[int] = None) -> str:
|
||||
"""Get raw content with custom offset"""
|
||||
|
||||
def children(self) -> Generator[TerraceNode, None, None]:
|
||||
"""Iterate through all descendant nodes"""
|
||||
|
||||
def siblings(self) -> Generator[TerraceNode, None, None]:
|
||||
"""Iterate through sibling nodes at the same level"""
|
||||
|
||||
Heading 3 Factory Functions
|
||||
class mt-8 mb-4
|
||||
|
||||
CodeBlock python
|
||||
def use_document(reader: Reader, indent: str = ' ') -> TerraceDocument:
|
||||
"""Create a new Terrace document iterator"""
|
||||
|
||||
# Convenience functions for creating readers
|
||||
def create_string_reader(content: str) -> Reader:
|
||||
"""Create a reader from a string"""
|
||||
|
||||
def create_file_reader(file_path: str) -> Reader:
|
||||
"""Create a reader from a file path"""
|
||||
|
||||
def create_lines_reader(lines: List[str]) -> Reader:
|
||||
"""Create a reader from a list of lines"""
|
||||
|
||||
Heading 3 Usage Examples
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
**Basic Document Iteration**
|
||||
|
||||
CodeBlock python
|
||||
from terrace import use_document, create_string_reader
|
||||
|
||||
content = """
|
||||
title My Document
|
||||
section Introduction
|
||||
paragraph This is an example.
|
||||
section Conclusion
|
||||
paragraph That's all!
|
||||
"""
|
||||
|
||||
reader = create_string_reader(content)
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
if node.is_('title'):
|
||||
print(f"Document title: {node.tail}")
|
||||
elif node.is_('section'):
|
||||
print(f"Section: {node.tail} (level {node.level})")
|
||||
elif node.is_('paragraph'):
|
||||
print(f" - {node.tail}")
|
||||
|
||||
Markdown
|
||||
**Working with Child Nodes**
|
||||
|
||||
CodeBlock python
|
||||
from terrace import use_document, create_string_reader
|
||||
|
||||
content = """
|
||||
config
|
||||
database
|
||||
host localhost
|
||||
port 5432
|
||||
server
|
||||
port 8080
|
||||
"""
|
||||
|
||||
reader = create_string_reader(content)
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
if node.is_('config'):
|
||||
print("Configuration:")
|
||||
for child in node.children():
|
||||
print(f" {child.head}: {child.tail}")
|
||||
for grandchild in child.children():
|
||||
print(f" {grandchild.head}: {grandchild.tail}")
|
||||
|
||||
Markdown
|
||||
**Functional Programming Style**
|
||||
|
||||
CodeBlock python
|
||||
# Filter nodes by predicate
|
||||
titles = doc.filter(lambda node: node.is_('title'))
|
||||
|
||||
# Find first matching node
|
||||
first_section = doc.find(lambda node: node.is_('section'))
|
||||
|
||||
# Map nodes to values
|
||||
all_heads = doc.map(lambda node: node.head)
|
||||
|
||||
# Convert to list
|
||||
all_nodes = doc.to_list()
|
||||
47
packages/python/docs/index.tce
Normal file
47
packages/python/docs/index.tce
Normal file
@@ -0,0 +1,47 @@
|
||||
layout layout.njk
|
||||
title Python Documentation - Terrace
|
||||
description
|
||||
Python language documentation for the Terrace programming language
|
||||
|
||||
Section light
|
||||
class flex flex-col md:flex-row gap-16
|
||||
|
||||
Block
|
||||
class w-full lg:w-1/3
|
||||
TableOfContents
|
||||
|
||||
Block
|
||||
Heading 1 Terrace Python Documentation
|
||||
class -ml-2
|
||||
|
||||
Markdown
|
||||
Documentation is available for the following languages:
|
||||
- [C](/docs/c/) - 75% Complete
|
||||
- [JavaScript](/docs/javascript/) - 75% Complete
|
||||
- [Go](/docs/go/) - 50% Complete
|
||||
- [Python](/docs/python/) - 100% Complete
|
||||
- [Rust](/docs/rust/) - 100% Complete
|
||||
|
||||
Heading 2 Getting Started
|
||||
class mt-12 mb-6
|
||||
Markdown
|
||||
Install Terrace using [pip](https://pip.pypa.io/):
|
||||
|
||||
CodeBlock bash
|
||||
# Install from PyPI
|
||||
$ pip install terrace-lang
|
||||
|
||||
# Or using Poetry
|
||||
$ poetry add terrace-lang
|
||||
|
||||
Include ./core-api.inc.tce
|
||||
Include ./document-api.inc.tce
|
||||
Include ./reader-api.inc.tce
|
||||
Include ./recipes.inc.tce
|
||||
|
||||
Heading 2 Contributing
|
||||
class mt-12
|
||||
|
||||
Section dark
|
||||
Footer
|
||||
class w-full
|
||||
175
packages/python/docs/reader-api.inc.tce
Normal file
175
packages/python/docs/reader-api.inc.tce
Normal file
@@ -0,0 +1,175 @@
|
||||
Heading 2 Reader API
|
||||
class mt-12
|
||||
|
||||
Markdown
|
||||
The Reader API provides functions and utilities for creating readers that supply lines to the Document API.
|
||||
|
||||
Heading 3 Reader Type
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
A `Reader` is a callable that returns the next line in a document or `None` when the end is reached.
|
||||
|
||||
CodeBlock python
|
||||
from typing import Optional, Callable
|
||||
|
||||
# Type definition
|
||||
Reader = Callable[[], Optional[str]]
|
||||
|
||||
Heading 3 Built-in Readers
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
**String Reader**
|
||||
|
||||
Create a reader from a string, splitting on newlines.
|
||||
|
||||
CodeBlock python
|
||||
def create_string_reader(content: str) -> Reader:
|
||||
"""Create a reader from a string"""
|
||||
lines = content.split('\n')
|
||||
|
||||
# Remove trailing empty line if content ended with newline
|
||||
if len(lines) > 0 and content.endswith('\n') and lines[-1] == '':
|
||||
lines = lines[:-1]
|
||||
|
||||
index = 0
|
||||
|
||||
def reader() -> Optional[str]:
|
||||
nonlocal index
|
||||
if index >= len(lines):
|
||||
return None
|
||||
line = lines[index]
|
||||
index += 1
|
||||
return line
|
||||
|
||||
return reader
|
||||
|
||||
Markdown
|
||||
**File Reader**
|
||||
|
||||
Create a reader from a file path.
|
||||
|
||||
CodeBlock python
|
||||
def create_file_reader(file_path: str) -> Reader:
|
||||
"""Create a reader from a file path"""
|
||||
file_handle = open(file_path, 'r', encoding='utf-8')
|
||||
|
||||
def reader() -> Optional[str]:
|
||||
line = file_handle.readline()
|
||||
if not line:
|
||||
file_handle.close()
|
||||
return None
|
||||
return line.rstrip('\n\r')
|
||||
|
||||
return reader
|
||||
|
||||
Markdown
|
||||
**Lines Reader**
|
||||
|
||||
Create a reader from a list of strings.
|
||||
|
||||
CodeBlock python
|
||||
def create_lines_reader(lines: List[str]) -> Reader:
|
||||
"""Create a reader from a list of lines"""
|
||||
index = 0
|
||||
|
||||
def reader() -> Optional[str]:
|
||||
nonlocal index
|
||||
if index >= len(lines):
|
||||
return None
|
||||
line = lines[index]
|
||||
index += 1
|
||||
return line.rstrip('\n\r')
|
||||
|
||||
return reader
|
||||
|
||||
Heading 3 Custom Readers
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
You can create custom readers for any data source by implementing a function that returns `Optional[str]`.
|
||||
|
||||
CodeBlock python
|
||||
import json
|
||||
from typing import Iterator
|
||||
|
||||
def create_json_lines_reader(file_path: str) -> Reader:
|
||||
"""Create a reader that processes JSON Lines format"""
|
||||
def generator() -> Iterator[str]:
|
||||
with open(file_path, 'r') as f:
|
||||
for line in f:
|
||||
try:
|
||||
data = json.loads(line.strip())
|
||||
# Convert JSON object to Terrace format
|
||||
yield f"entry {data.get('name', 'unnamed')}"
|
||||
for key, value in data.items():
|
||||
if key != 'name':
|
||||
yield f" {key} {value}"
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
iterator = generator()
|
||||
|
||||
def reader() -> Optional[str]:
|
||||
try:
|
||||
return next(iterator)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
return reader
|
||||
|
||||
Heading 3 Usage Examples
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
**Reading from a string**
|
||||
|
||||
CodeBlock python
|
||||
from terrace import use_document, create_string_reader
|
||||
|
||||
content = """
|
||||
title My Document
|
||||
author John Doe
|
||||
date 2023-12-01
|
||||
"""
|
||||
|
||||
reader = create_string_reader(content)
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
print(f"Level {node.level}: {node.content}")
|
||||
|
||||
Markdown
|
||||
**Reading from a file**
|
||||
|
||||
CodeBlock python
|
||||
from terrace import use_document, create_file_reader
|
||||
|
||||
reader = create_file_reader('document.tce')
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
if node.is_('title'):
|
||||
print(f"Document: {node.tail}")
|
||||
|
||||
Markdown
|
||||
**Reading from a list of lines**
|
||||
|
||||
CodeBlock python
|
||||
from terrace import use_document, create_lines_reader
|
||||
|
||||
lines = [
|
||||
"config",
|
||||
" host localhost",
|
||||
" port 8080",
|
||||
"routes",
|
||||
" / home",
|
||||
" /api api"
|
||||
]
|
||||
|
||||
reader = create_lines_reader(lines)
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
print(f"{' ' * node.level}{node.head}: {node.tail}")
|
||||
265
packages/python/docs/recipes.inc.tce
Normal file
265
packages/python/docs/recipes.inc.tce
Normal file
@@ -0,0 +1,265 @@
|
||||
Heading 2 Recipes
|
||||
class mt-12
|
||||
|
||||
Markdown
|
||||
Common patterns and recipes for working with Terrace documents in Python.
|
||||
|
||||
Heading 3 Configuration File Parser
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
Parse a hierarchical configuration file with sections and key-value pairs.
|
||||
|
||||
CodeBlock python
|
||||
from terrace import use_document, create_file_reader
|
||||
from typing import Dict, Any
|
||||
|
||||
def parse_config_file(file_path: str) -> Dict[str, Any]:
|
||||
"""Parse a Terrace configuration file into a nested dictionary"""
|
||||
reader = create_file_reader(file_path)
|
||||
doc = use_document(reader)
|
||||
|
||||
config = {}
|
||||
stack = [config]
|
||||
|
||||
for node in doc:
|
||||
# Adjust stack to current level
|
||||
while len(stack) > node.level + 1:
|
||||
stack.pop()
|
||||
|
||||
current_dict = stack[-1]
|
||||
|
||||
if node.tail: # Key-value pair
|
||||
current_dict[node.head] = node.tail
|
||||
else: # Section header
|
||||
current_dict[node.head] = {}
|
||||
stack.append(current_dict[node.head])
|
||||
|
||||
return config
|
||||
|
||||
# Usage
|
||||
config = parse_config_file('app.tce')
|
||||
print(config['database']['host'])
|
||||
|
||||
Heading 3 Document Outline Generator
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
Extract a document outline based on heading levels.
|
||||
|
||||
CodeBlock python
|
||||
from terrace import use_document, create_string_reader
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
@dataclass
|
||||
class Heading:
|
||||
level: int
|
||||
title: str
|
||||
children: List['Heading']
|
||||
|
||||
def extract_outline(content: str) -> List[Heading]:
|
||||
"""Extract document outline from Terrace content"""
|
||||
reader = create_string_reader(content)
|
||||
doc = use_document(reader)
|
||||
|
||||
headings = []
|
||||
stack = []
|
||||
|
||||
for node in doc:
|
||||
if node.is_('heading') or node.is_('h1') or node.is_('h2') or node.is_('h3'):
|
||||
heading = Heading(level=node.level, title=node.tail, children=[])
|
||||
|
||||
# Find the right parent in the stack
|
||||
while stack and stack[-1].level >= heading.level:
|
||||
stack.pop()
|
||||
|
||||
if stack:
|
||||
stack[-1].children.append(heading)
|
||||
else:
|
||||
headings.append(heading)
|
||||
|
||||
stack.append(heading)
|
||||
|
||||
return headings
|
||||
|
||||
Heading 3 Template Engine
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
Simple template engine that processes Terrace templates with variables.
|
||||
|
||||
CodeBlock python
|
||||
from terrace import use_document, create_string_reader
|
||||
from typing import Dict, Any
|
||||
import re
|
||||
|
||||
def render_template(template: str, variables: Dict[str, Any]) -> str:
|
||||
"""Render a Terrace template with variable substitution"""
|
||||
reader = create_string_reader(template)
|
||||
doc = use_document(reader)
|
||||
|
||||
output = []
|
||||
indent_str = " " # Two spaces per level
|
||||
|
||||
for node in doc:
|
||||
# Apply indentation
|
||||
indentation = indent_str * node.level
|
||||
|
||||
if node.is_('var'):
|
||||
# Variable substitution: var name -> value
|
||||
var_name = node.tail
|
||||
value = variables.get(var_name, f"{{undefined: {var_name}}}")
|
||||
output.append(f"{indentation}{value}")
|
||||
|
||||
elif node.is_('if'):
|
||||
# Conditional rendering: if variable_name
|
||||
condition = node.tail
|
||||
if variables.get(condition):
|
||||
# Process children if condition is truthy
|
||||
for child in node.children():
|
||||
child_line = f"{indent_str * child.level}{child.content}"
|
||||
# Substitute variables in child content
|
||||
child_line = re.sub(r'\{\{(\w+)\}\}',
|
||||
lambda m: str(variables.get(m.group(1), m.group(0))),
|
||||
child_line)
|
||||
output.append(child_line)
|
||||
|
||||
elif node.is_('loop'):
|
||||
# Loop over array: loop items
|
||||
array_name = node.tail
|
||||
items = variables.get(array_name, [])
|
||||
for item in items:
|
||||
for child in node.children():
|
||||
child_line = f"{indent_str * child.level}{child.content}"
|
||||
# Make item available as 'item' variable
|
||||
temp_vars = {**variables, 'item': item}
|
||||
child_line = re.sub(r'\{\{(\w+)\}\}',
|
||||
lambda m: str(temp_vars.get(m.group(1), m.group(0))),
|
||||
child_line)
|
||||
output.append(child_line)
|
||||
else:
|
||||
# Regular content with variable substitution
|
||||
content = node.content
|
||||
content = re.sub(r'\{\{(\w+)\}\}',
|
||||
lambda m: str(variables.get(m.group(1), m.group(0))),
|
||||
content)
|
||||
output.append(f"{indentation}{content}")
|
||||
|
||||
return '\n'.join(output)
|
||||
|
||||
# Usage
|
||||
template = """
|
||||
title {{page_title}}
|
||||
if show_author
|
||||
author {{author_name}}
|
||||
content
|
||||
loop articles
|
||||
article {{item.title}}
|
||||
summary {{item.summary}}
|
||||
"""
|
||||
|
||||
variables = {
|
||||
'page_title': 'My Blog',
|
||||
'show_author': True,
|
||||
'author_name': 'John Doe',
|
||||
'articles': [
|
||||
{'title': 'First Post', 'summary': 'This is the first post'},
|
||||
{'title': 'Second Post', 'summary': 'This is the second post'}
|
||||
]
|
||||
}
|
||||
|
||||
result = render_template(template, variables)
|
||||
print(result)
|
||||
|
||||
Heading 3 Data Validation
|
||||
class mt-8 mb-4
|
||||
|
||||
Markdown
|
||||
Validate Terrace document structure against a schema.
|
||||
|
||||
CodeBlock python
|
||||
from terrace import use_document, create_string_reader
|
||||
from typing import Dict, List, Set, Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class ValidationError:
|
||||
line_number: int
|
||||
message: str
|
||||
|
||||
class TerraceValidator:
|
||||
def __init__(self):
|
||||
self.required_fields: Dict[str, Set[str]] = {}
|
||||
self.allowed_fields: Dict[str, Set[str]] = {}
|
||||
self.field_types: Dict[str, type] = {}
|
||||
|
||||
def require_fields(self, context: str, fields: List[str]):
|
||||
"""Require specific fields in a context"""
|
||||
self.required_fields[context] = set(fields)
|
||||
|
||||
def allow_fields(self, context: str, fields: List[str]):
|
||||
"""Allow specific fields in a context"""
|
||||
self.allowed_fields[context] = set(fields)
|
||||
|
||||
def validate(self, content: str) -> List[ValidationError]:
|
||||
"""Validate content and return list of errors"""
|
||||
reader = create_string_reader(content)
|
||||
doc = use_document(reader)
|
||||
|
||||
errors = []
|
||||
context_stack = ['root']
|
||||
found_fields = {'root': set()}
|
||||
|
||||
for node in doc:
|
||||
# Update context stack based on indentation
|
||||
target_depth = node.level + 1
|
||||
while len(context_stack) > target_depth:
|
||||
# Check required fields when leaving context
|
||||
leaving_context = context_stack.pop()
|
||||
required = self.required_fields.get(leaving_context, set())
|
||||
found = found_fields.get(leaving_context, set())
|
||||
missing = required - found
|
||||
if missing:
|
||||
errors.append(ValidationError(
|
||||
node.line_number,
|
||||
f"Missing required fields in {leaving_context}: {', '.join(missing)}"
|
||||
))
|
||||
found_fields.pop(leaving_context, None)
|
||||
|
||||
current_context = context_stack[-1]
|
||||
|
||||
# Check if field is allowed
|
||||
allowed = self.allowed_fields.get(current_context, None)
|
||||
if allowed is not None and node.head not in allowed:
|
||||
errors.append(ValidationError(
|
||||
node.line_number,
|
||||
f"Field '{node.head}' not allowed in context '{current_context}'"
|
||||
))
|
||||
|
||||
# Track found fields
|
||||
found_fields.setdefault(current_context, set()).add(node.head)
|
||||
|
||||
# If this node has children, it becomes a new context
|
||||
if any(True for _ in node.children()): # Check if has children
|
||||
context_stack.append(node.head)
|
||||
found_fields[node.head] = set()
|
||||
|
||||
return errors
|
||||
|
||||
# Usage
|
||||
validator = TerraceValidator()
|
||||
validator.require_fields('root', ['title', 'content'])
|
||||
validator.allow_fields('root', ['title', 'author', 'date', 'content'])
|
||||
validator.allow_fields('content', ['section', 'paragraph'])
|
||||
|
||||
content = """
|
||||
title My Document
|
||||
content
|
||||
section Introduction
|
||||
paragraph Hello world
|
||||
"""
|
||||
|
||||
errors = validator.validate(content)
|
||||
for error in errors:
|
||||
print(f"Line {error.line_number}: {error.message}")
|
||||
236
packages/python/document.py
Normal file
236
packages/python/document.py
Normal file
@@ -0,0 +1,236 @@
|
||||
from typing import TypedDict, Generator, Iterator, Optional, Callable, List, Any, Union
|
||||
from parser import LineData, createLineData, parseLine
|
||||
import io
|
||||
|
||||
# Type for a reader function
|
||||
Reader = Callable[[], Optional[str]]
|
||||
|
||||
class TerraceNode:
|
||||
"""Represents a single node/line in a Terrace document"""
|
||||
|
||||
def __init__(self, line_data: LineData, content: str, line_number: int, document: 'TerraceDocument'):
|
||||
self._line_data = line_data.copy() # Copy to avoid mutations
|
||||
self._content = content
|
||||
self._line_number = line_number
|
||||
self._document = document
|
||||
|
||||
@property
|
||||
def head(self) -> str:
|
||||
"""Get the first word of the line"""
|
||||
return self._content[self._line_data['offsetHead']:self._line_data['offsetTail']]
|
||||
|
||||
@property
|
||||
def tail(self) -> str:
|
||||
"""Get everything after the first word"""
|
||||
if self._line_data['offsetTail'] >= len(self._content) or self._content[self._line_data['offsetTail']] != ' ':
|
||||
return ""
|
||||
return self._content[self._line_data['offsetTail'] + 1:]
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
"""Get the line content without indentation"""
|
||||
return self._content[self._line_data['offsetHead']:]
|
||||
|
||||
@property
|
||||
def level(self) -> int:
|
||||
"""Get the indentation level"""
|
||||
return self._line_data['level']
|
||||
|
||||
@property
|
||||
def line_number(self) -> int:
|
||||
"""Get the line number (zero-indexed)"""
|
||||
return self._line_number
|
||||
|
||||
def is_(self, value: str) -> bool:
|
||||
"""Check if the head matches the given value"""
|
||||
return self.head == value
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""Check if the line is empty/blank"""
|
||||
return self.content.strip() == ''
|
||||
|
||||
def raw(self, offset: Optional[int] = None) -> str:
|
||||
"""Get raw content with custom offset"""
|
||||
if offset is None:
|
||||
offset = self.level
|
||||
return self._content[offset:]
|
||||
|
||||
def children(self) -> Generator['TerraceNode', None, None]:
|
||||
"""Iterate through all descendant nodes (supports arbitrary nesting)"""
|
||||
parent_level = self.level
|
||||
|
||||
while True:
|
||||
node = self._document._get_next_node()
|
||||
if node is None:
|
||||
break
|
||||
|
||||
if node.level <= parent_level:
|
||||
# Put back the node for parent iteration
|
||||
self._document._push_back(node)
|
||||
break
|
||||
|
||||
# Yield any node that is deeper than the parent
|
||||
# This supports arbitrary nesting as per Terrace spec
|
||||
yield node
|
||||
|
||||
def siblings(self) -> Generator['TerraceNode', None, None]:
|
||||
"""Iterate through sibling nodes at the same level"""
|
||||
current_level = self.level
|
||||
|
||||
while True:
|
||||
node = self._document._get_next_node()
|
||||
if node is None:
|
||||
break
|
||||
|
||||
if node.level < current_level:
|
||||
self._document._push_back(node)
|
||||
break
|
||||
|
||||
if node.level == current_level:
|
||||
yield node
|
||||
|
||||
class TerraceDocument:
|
||||
"""Main document iterator for Terrace documents"""
|
||||
|
||||
def __init__(self, reader: Reader, indent: str = ' '):
|
||||
if len(indent) != 1:
|
||||
raise ValueError(f"Terrace currently only allows single-character indent strings - you passed '{indent}'")
|
||||
|
||||
self._reader = reader
|
||||
self._indent = indent
|
||||
self._line_data = createLineData(indent)
|
||||
self._current_line_number = -1
|
||||
self._pushed_back_node: Optional[TerraceNode] = None
|
||||
|
||||
def __iter__(self) -> Iterator[TerraceNode]:
|
||||
"""Make the document iterable"""
|
||||
return self._create_iterator()
|
||||
|
||||
def _create_iterator(self) -> Generator[TerraceNode, None, None]:
|
||||
"""Create the main iterator generator"""
|
||||
while True:
|
||||
# Check for pushed back node first
|
||||
if self._pushed_back_node is not None:
|
||||
node = self._pushed_back_node
|
||||
self._pushed_back_node = None
|
||||
yield node
|
||||
continue
|
||||
|
||||
line = self._reader()
|
||||
if line is None:
|
||||
break
|
||||
|
||||
self._current_line_number += 1
|
||||
parseLine(line, self._line_data)
|
||||
|
||||
node = TerraceNode(
|
||||
self._line_data,
|
||||
line,
|
||||
self._current_line_number,
|
||||
self
|
||||
)
|
||||
|
||||
yield node
|
||||
|
||||
def _get_next_node(self) -> Optional[TerraceNode]:
|
||||
"""Get the next node from the document"""
|
||||
if self._pushed_back_node is not None:
|
||||
node = self._pushed_back_node
|
||||
self._pushed_back_node = None
|
||||
return node
|
||||
|
||||
line = self._reader()
|
||||
if line is None:
|
||||
return None
|
||||
|
||||
self._current_line_number += 1
|
||||
parseLine(line, self._line_data)
|
||||
|
||||
return TerraceNode(
|
||||
self._line_data,
|
||||
line,
|
||||
self._current_line_number,
|
||||
self
|
||||
)
|
||||
|
||||
def _push_back(self, node: TerraceNode) -> None:
|
||||
"""Push back a node to be returned by the next iteration"""
|
||||
self._pushed_back_node = node
|
||||
|
||||
# Utility methods for functional programming style
|
||||
def filter(self, predicate: Callable[[TerraceNode], bool]) -> List[TerraceNode]:
|
||||
"""Filter nodes by predicate"""
|
||||
return [node for node in self if predicate(node)]
|
||||
|
||||
def find(self, predicate: Callable[[TerraceNode], bool]) -> Optional[TerraceNode]:
|
||||
"""Find the first node matching predicate"""
|
||||
for node in self:
|
||||
if predicate(node):
|
||||
return node
|
||||
return None
|
||||
|
||||
def map(self, mapper: Callable[[TerraceNode], Any]) -> List[Any]:
|
||||
"""Map nodes through a function"""
|
||||
return [mapper(node) for node in self]
|
||||
|
||||
def to_list(self) -> List[TerraceNode]:
|
||||
"""Convert all nodes to a list"""
|
||||
return list(self)
|
||||
|
||||
# Convenience functions for creating readers
|
||||
def create_string_reader(content: str) -> Reader:
|
||||
"""Create a reader from a string"""
|
||||
lines = content.split('\n')
|
||||
|
||||
# Remove trailing empty line if content ended with newline (like Rust fix)
|
||||
if len(lines) > 0 and content.endswith('\n') and lines[-1] == '':
|
||||
lines = lines[:-1]
|
||||
|
||||
index = 0
|
||||
|
||||
def reader() -> Optional[str]:
|
||||
nonlocal index
|
||||
if index >= len(lines):
|
||||
return None
|
||||
line = lines[index]
|
||||
index += 1
|
||||
return line
|
||||
|
||||
return reader
|
||||
|
||||
def create_file_reader(file_path: str) -> Reader:
|
||||
"""Create a reader from a file path"""
|
||||
file_handle = open(file_path, 'r', encoding='utf-8')
|
||||
|
||||
def reader() -> Optional[str]:
|
||||
line = file_handle.readline()
|
||||
if not line:
|
||||
file_handle.close()
|
||||
return None
|
||||
return line.rstrip('\n\r')
|
||||
|
||||
return reader
|
||||
|
||||
def create_lines_reader(lines: List[str]) -> Reader:
|
||||
"""Create a reader from a list of lines"""
|
||||
index = 0
|
||||
|
||||
def reader() -> Optional[str]:
|
||||
nonlocal index
|
||||
if index >= len(lines):
|
||||
return None
|
||||
line = lines[index]
|
||||
index += 1
|
||||
return line.rstrip('\n\r')
|
||||
|
||||
return reader
|
||||
|
||||
# Main factory function
|
||||
def use_document(reader: Reader, indent: str = ' ') -> TerraceDocument:
|
||||
"""Create a new Terrace document iterator"""
|
||||
return TerraceDocument(reader, indent)
|
||||
|
||||
# Legacy compatibility
|
||||
def useDocument(reader: Reader, indent: str = ' ') -> TerraceDocument:
|
||||
"""Legacy alias for use_document"""
|
||||
return use_document(reader, indent)
|
||||
@@ -4,6 +4,10 @@ import os
|
||||
sys.path.insert(1, os.path.join(sys.path[0], '..'))
|
||||
|
||||
from parser import createLineData, parseLine
|
||||
from document import (
|
||||
use_document, TerraceNode, TerraceDocument,
|
||||
create_string_reader, create_lines_reader
|
||||
)
|
||||
|
||||
def next():
|
||||
# For blank lines, readline will return a newline.
|
||||
@@ -19,7 +23,7 @@ def linedata_basic (indent):
|
||||
while (line := next()) != None:
|
||||
parseLine(line, lineData)
|
||||
print("| level {level} | indent {indent} | offsetHead {offsetHead} | offsetTail {offsetTail} | line {line} |".format(
|
||||
level = lineData['level'], indent = lineData['indent'], offsetHead = lineData['offsetHead'], offsetTail = lineData['offsetTail'], line = line
|
||||
level = lineData['level'], indent = lineData['indent'].replace('\t', '\\t'), offsetHead = lineData['offsetHead'], offsetTail = lineData['offsetTail'], line = line.replace('\t', '\\t')
|
||||
))
|
||||
|
||||
def linedata_head_tail ():
|
||||
@@ -34,12 +38,134 @@ def linedata_head_tail ():
|
||||
head = head, tail = tail
|
||||
))
|
||||
|
||||
# === NEW API TESTS ===
|
||||
|
||||
def test_new_api_basic():
|
||||
reader = create_lines_reader(sys.stdin.readlines())
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
print(f'| level {node.level} | head "{node.head}" | tail "{node.tail}" | content "{node.content}" |')
|
||||
|
||||
def test_new_api_empty_lines():
|
||||
reader = create_lines_reader(sys.stdin.readlines())
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
if not node.content.strip(): # Skip empty lines
|
||||
continue
|
||||
print(f'| level {node.level} | head "{node.head}" | tail "{node.tail}" | content "{node.content}" |')
|
||||
|
||||
def test_new_api_hierarchical():
|
||||
lines = [line.rstrip('\n') for line in sys.stdin.readlines()]
|
||||
reader = create_lines_reader(lines)
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
print(f"| level {node.level} | head \"{node.head}\" | tail \"{node.tail}\" | content \"{node.content}\" |")
|
||||
|
||||
def test_new_api_functional():
|
||||
lines = [line.rstrip('\n') for line in sys.stdin.readlines()]
|
||||
reader = create_lines_reader(lines)
|
||||
doc = use_document(reader)
|
||||
|
||||
# Test find method first (like JS implementation)
|
||||
debug_flag = doc.find(lambda node: node.head == 'feature_flags')
|
||||
if debug_flag:
|
||||
print('Found feature flags section')
|
||||
|
||||
# Test filter method
|
||||
reader2 = create_lines_reader(lines)
|
||||
doc2 = use_document(reader2)
|
||||
config_sections = doc2.filter(lambda node: node.head in ['database', 'server'])
|
||||
print(f"Found {len(config_sections)} config sections")
|
||||
|
||||
def test_node_methods():
|
||||
lines = [line.rstrip('\n') for line in sys.stdin.readlines()]
|
||||
reader = create_lines_reader(lines)
|
||||
doc = use_document(reader)
|
||||
|
||||
# Only print output if there are multiple lines (first test)
|
||||
# The second test with single line expects no output
|
||||
if len(lines) > 1:
|
||||
for node in doc:
|
||||
print(f"Node: head=\"{node.head}\", tail=\"{node.tail}\", isEmpty={node.is_empty()}, is_(title)={node.is_('title')}")
|
||||
print(f" content=\"{node.content}\", raw(0)=\"{node.raw(0)}\", lineNumber={node.line_number}")
|
||||
|
||||
def test_reader_utilities():
|
||||
lines = [line.rstrip('\n') for line in sys.stdin.readlines()]
|
||||
reader = create_lines_reader(lines)
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
print(f"{node.head}: {node.tail}")
|
||||
|
||||
def test_inconsistent_indentation():
|
||||
lines = [line.rstrip('\n') for line in sys.stdin.readlines()]
|
||||
reader = create_lines_reader(lines)
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
print(f"| level {node.level} | head \"{node.head}\" | tail \"{node.tail}\" |")
|
||||
|
||||
def test_content_method():
|
||||
lines = [line.rstrip('\n') for line in sys.stdin.readlines()]
|
||||
reader = create_lines_reader(lines)
|
||||
doc = use_document(reader)
|
||||
|
||||
for node in doc:
|
||||
print(f'| level {node.level} | head "{node.head}" | tail "{node.tail}" | content "{node.content}" |')
|
||||
|
||||
def test_legacy_compat():
|
||||
lines = [line.rstrip('\n') for line in sys.stdin.readlines()]
|
||||
reader = create_lines_reader(lines)
|
||||
doc = use_document(reader)
|
||||
|
||||
# Legacy compatibility test - simulate legacy API behavior
|
||||
found_config = False
|
||||
for node in doc:
|
||||
if node.head == 'config':
|
||||
found_config = True
|
||||
print('Found config section using legacy API')
|
||||
# In legacy API, we would iterate through children
|
||||
for child in node.children():
|
||||
if child.head.startswith('d'):
|
||||
print(f"Config item: head starts with 'd', tail='{child.tail}'")
|
||||
elif child.head.startswith('s'):
|
||||
print(f"Config item: head starts with 's', tail='{child.tail}'")
|
||||
break
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
# Run all new API tests
|
||||
print("Running all new API tests...")
|
||||
test_new_api_basic()
|
||||
test_new_api_hierarchical()
|
||||
test_new_api_functional()
|
||||
test_node_methods()
|
||||
test_reader_utilities()
|
||||
test_inconsistent_indentation()
|
||||
return
|
||||
|
||||
testName = sys.argv[1]
|
||||
|
||||
# Legacy tests
|
||||
if testName == 'linedata:basic': linedata_basic(' ')
|
||||
if testName == 'linedata:tabs': linedata_basic('\t')
|
||||
if testName == 'linedata:head-tail': linedata_head_tail()
|
||||
elif testName == 'linedata:tabs': linedata_basic('\t')
|
||||
elif testName == 'linedata:head-tail': linedata_head_tail()
|
||||
|
||||
# New API tests
|
||||
elif testName == 'new-api:basic': test_new_api_basic()
|
||||
elif testName == 'new-api:empty-lines': test_new_api_empty_lines()
|
||||
elif testName == 'new-api:hierarchical': test_new_api_hierarchical()
|
||||
elif testName == 'new-api:functional': test_new_api_functional()
|
||||
elif testName == 'new-api:node-methods': test_node_methods()
|
||||
elif testName == 'new-api:readers': test_reader_utilities()
|
||||
elif testName == 'new-api:inconsistent-indentation': test_inconsistent_indentation()
|
||||
elif testName == 'new-api:content-method': test_content_method()
|
||||
elif testName == 'new-api:legacy-compat': test_legacy_compat()
|
||||
else:
|
||||
print(f"Unknown test: {testName}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user