prismaa 0.1.0__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.
- prismaa/__init__.py +1 -0
- prismaa/cli.py +14 -0
- prismaa/engine/__init__.py +0 -0
- prismaa/generator/__init__.py +0 -0
- prismaa/parser/__init__.py +31 -0
- prismaa/parser/ast.py +146 -0
- prismaa/parser/lexer.py +103 -0
- prismaa/parser/parser.py +364 -0
- prismaa/py.typed +0 -0
- prismaa/types/__init__.py +0 -0
- prismaa-0.1.0.dist-info/METADATA +51 -0
- prismaa-0.1.0.dist-info/RECORD +15 -0
- prismaa-0.1.0.dist-info/WHEEL +4 -0
- prismaa-0.1.0.dist-info/entry_points.txt +2 -0
- prismaa-0.1.0.dist-info/licenses/LICENSE +21 -0
prismaa/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
prismaa/cli.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@click.group()
|
|
5
|
+
def main() -> None:
|
|
6
|
+
"""Prismaa — Python Prisma client generator."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@main.command()
|
|
10
|
+
@click.option("--schema", default="schema.prisma", show_default=True, help="Path to schema.prisma")
|
|
11
|
+
@click.option("--output", default=None, help="Output directory (overrides schema generator.output)")
|
|
12
|
+
def generate(schema: str, output: str | None) -> None:
|
|
13
|
+
"""Generate the Python client from a Prisma schema."""
|
|
14
|
+
raise NotImplementedError("Generator not yet implemented — see PLAN.md Phase 3")
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from .ast import (
|
|
2
|
+
Attribute,
|
|
3
|
+
AttributeArg,
|
|
4
|
+
AttributeValue,
|
|
5
|
+
Datasource,
|
|
6
|
+
Field,
|
|
7
|
+
FunctionCall,
|
|
8
|
+
Generator,
|
|
9
|
+
Model,
|
|
10
|
+
Schema,
|
|
11
|
+
)
|
|
12
|
+
from .lexer import LexError, Token, TokenKind, tokenize
|
|
13
|
+
from .parser import ParseError, parse
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"parse",
|
|
17
|
+
"tokenize",
|
|
18
|
+
"Schema",
|
|
19
|
+
"Datasource",
|
|
20
|
+
"Generator",
|
|
21
|
+
"Model",
|
|
22
|
+
"Field",
|
|
23
|
+
"Attribute",
|
|
24
|
+
"AttributeArg",
|
|
25
|
+
"AttributeValue",
|
|
26
|
+
"FunctionCall",
|
|
27
|
+
"Token",
|
|
28
|
+
"TokenKind",
|
|
29
|
+
"LexError",
|
|
30
|
+
"ParseError",
|
|
31
|
+
]
|
prismaa/parser/ast.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class FunctionCall:
|
|
8
|
+
"""Represents a zero-argument function call in an attribute, e.g. autoincrement(), now()."""
|
|
9
|
+
|
|
10
|
+
name: str
|
|
11
|
+
|
|
12
|
+
def __repr__(self) -> str:
|
|
13
|
+
return f"{self.name}()"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Valid types for attribute argument values.
|
|
17
|
+
AttributeValue = str | int | float | bool | list[str] | FunctionCall
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AttributeArg:
|
|
22
|
+
"""A single argument inside an attribute's parentheses."""
|
|
23
|
+
|
|
24
|
+
name: str | None # None for positional arguments
|
|
25
|
+
value: AttributeValue
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Attribute:
|
|
30
|
+
"""A field-level (@) or block-level (@@) attribute."""
|
|
31
|
+
|
|
32
|
+
name: str # without leading @ or @@
|
|
33
|
+
args: list[AttributeArg] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
def arg(self, name: str) -> AttributeValue | None:
|
|
36
|
+
"""Return the value of a named argument, or None if absent."""
|
|
37
|
+
for a in self.args:
|
|
38
|
+
if a.name == name:
|
|
39
|
+
return a.value
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def first_positional(self) -> AttributeValue | None:
|
|
43
|
+
"""Return the first positional argument value, or None."""
|
|
44
|
+
for a in self.args:
|
|
45
|
+
if a.name is None:
|
|
46
|
+
return a.value
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Field:
|
|
52
|
+
name: str
|
|
53
|
+
type: str # e.g. "String", "Int", "Company", "Unsupported"
|
|
54
|
+
is_list: bool
|
|
55
|
+
is_optional: bool
|
|
56
|
+
# For Unsupported("native_type") fields, holds the native type string.
|
|
57
|
+
native_type: str | None = None
|
|
58
|
+
attributes: list[Attribute] = field(default_factory=list)
|
|
59
|
+
doc_comment: str | None = None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_unsupported(self) -> bool:
|
|
63
|
+
return self.type == "Unsupported"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_relation(self) -> bool:
|
|
67
|
+
return any(a.name == "relation" for a in self.attributes)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def column_name(self) -> str:
|
|
71
|
+
"""Actual DB column name, respecting @map."""
|
|
72
|
+
for attr in self.attributes:
|
|
73
|
+
if attr.name == "map":
|
|
74
|
+
v = attr.first_positional()
|
|
75
|
+
if isinstance(v, str):
|
|
76
|
+
return v
|
|
77
|
+
return self.name
|
|
78
|
+
|
|
79
|
+
def has_attr(self, name: str) -> bool:
|
|
80
|
+
return any(a.name == name for a in self.attributes)
|
|
81
|
+
|
|
82
|
+
def get_attr(self, name: str) -> Attribute | None:
|
|
83
|
+
for a in self.attributes:
|
|
84
|
+
if a.name == name:
|
|
85
|
+
return a
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Model:
|
|
91
|
+
name: str
|
|
92
|
+
fields: list[Field] = field(default_factory=list)
|
|
93
|
+
block_attributes: list[Attribute] = field(default_factory=list)
|
|
94
|
+
doc_comment: str | None = None
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def is_ignored(self) -> bool:
|
|
98
|
+
return any(a.name == "ignore" for a in self.block_attributes)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def table_name(self) -> str:
|
|
102
|
+
"""Actual DB table name, respecting @@map."""
|
|
103
|
+
for attr in self.block_attributes:
|
|
104
|
+
if attr.name == "map":
|
|
105
|
+
v = attr.first_positional()
|
|
106
|
+
if isinstance(v, str):
|
|
107
|
+
return v
|
|
108
|
+
return self.name
|
|
109
|
+
|
|
110
|
+
def scalar_fields(self) -> list[Field]:
|
|
111
|
+
"""Non-relation, non-unsupported fields."""
|
|
112
|
+
return [f for f in self.fields if not f.is_relation and not f.is_unsupported]
|
|
113
|
+
|
|
114
|
+
def relation_fields(self) -> list[Field]:
|
|
115
|
+
return [f for f in self.fields if f.is_relation]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Generator:
|
|
120
|
+
name: str
|
|
121
|
+
properties: dict[str, str] = field(default_factory=dict)
|
|
122
|
+
|
|
123
|
+
def get(self, key: str, default: str = "") -> str:
|
|
124
|
+
return self.properties.get(key, default)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class Datasource:
|
|
129
|
+
name: str
|
|
130
|
+
provider: str
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class Schema:
|
|
135
|
+
datasource: Datasource | None = None
|
|
136
|
+
generators: list[Generator] = field(default_factory=list)
|
|
137
|
+
models: list[Model] = field(default_factory=list)
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def generator(self) -> Generator | None:
|
|
141
|
+
"""The first generator block, or None."""
|
|
142
|
+
return self.generators[0] if self.generators else None
|
|
143
|
+
|
|
144
|
+
def active_models(self) -> list[Model]:
|
|
145
|
+
"""Models that are not marked @@ignore."""
|
|
146
|
+
return [m for m in self.models if not m.is_ignored]
|
prismaa/parser/lexer.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum, auto
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TokenKind(Enum):
|
|
9
|
+
IDENT = auto()
|
|
10
|
+
STRING = auto()
|
|
11
|
+
NUMBER = auto()
|
|
12
|
+
DOUBLE_AT = auto()
|
|
13
|
+
AT = auto()
|
|
14
|
+
LBRACE = auto()
|
|
15
|
+
RBRACE = auto()
|
|
16
|
+
LPAREN = auto()
|
|
17
|
+
RPAREN = auto()
|
|
18
|
+
LBRACKET = auto()
|
|
19
|
+
RBRACKET = auto()
|
|
20
|
+
COLON = auto()
|
|
21
|
+
COMMA = auto()
|
|
22
|
+
EQUALS = auto()
|
|
23
|
+
QUESTION = auto()
|
|
24
|
+
DOC_COMMENT = auto()
|
|
25
|
+
NEWLINE = auto()
|
|
26
|
+
EOF = auto()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Token:
|
|
31
|
+
kind: TokenKind
|
|
32
|
+
value: str
|
|
33
|
+
line: int
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
return f"Token({self.kind.name}, {self.value!r}, line={self.line})"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class LexError(Exception):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Rules are tested in order; first match wins.
|
|
44
|
+
# None means "skip this token" (whitespace, regular comments).
|
|
45
|
+
_RULES: list[tuple[TokenKind | None, str]] = [
|
|
46
|
+
(TokenKind.DOC_COMMENT, r"///[^\n]*"),
|
|
47
|
+
(None, r"//[^\n]*"), # regular comment — discard
|
|
48
|
+
(None, r"[ \t]+"), # horizontal whitespace — discard
|
|
49
|
+
(TokenKind.NEWLINE, r"\n"),
|
|
50
|
+
(TokenKind.STRING, r'"[^"\\]*(?:\\.[^"\\]*)*"'),
|
|
51
|
+
(TokenKind.NUMBER, r"\d+\.\d+|\d+"),
|
|
52
|
+
(TokenKind.DOUBLE_AT, r"@@"), # must precede AT
|
|
53
|
+
(TokenKind.AT, r"@"),
|
|
54
|
+
(TokenKind.LBRACE, r"\{"),
|
|
55
|
+
(TokenKind.RBRACE, r"\}"),
|
|
56
|
+
(TokenKind.LPAREN, r"\("),
|
|
57
|
+
(TokenKind.RPAREN, r"\)"),
|
|
58
|
+
(TokenKind.LBRACKET, r"\["),
|
|
59
|
+
(TokenKind.RBRACKET, r"\]"),
|
|
60
|
+
(TokenKind.COLON, r":"),
|
|
61
|
+
(TokenKind.COMMA, r","),
|
|
62
|
+
(TokenKind.EQUALS, r"="),
|
|
63
|
+
(TokenKind.QUESTION, r"\?"),
|
|
64
|
+
(TokenKind.IDENT, r"[A-Za-z_][A-Za-z0-9_]*"),
|
|
65
|
+
(None, r"\r"), # bare carriage return — discard
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
_MASTER = re.compile(
|
|
69
|
+
"|".join(f"(?P<r{i}>{pattern})" for i, (_, pattern) in enumerate(_RULES)),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tokenize(source: str) -> list[Token]:
|
|
74
|
+
"""Lex *source* into a flat token list ending with an EOF token."""
|
|
75
|
+
tokens: list[Token] = []
|
|
76
|
+
line = 1
|
|
77
|
+
|
|
78
|
+
for m in _MASTER.finditer(source):
|
|
79
|
+
group_index = int(m.lastgroup[1:]) # "r7" → 7
|
|
80
|
+
kind, _ = _RULES[group_index]
|
|
81
|
+
value = m.group()
|
|
82
|
+
|
|
83
|
+
if value == "\n":
|
|
84
|
+
tokens.append(Token(TokenKind.NEWLINE, value, line))
|
|
85
|
+
line += 1
|
|
86
|
+
elif kind is not None:
|
|
87
|
+
tokens.append(Token(kind, value, line))
|
|
88
|
+
# kind is None → skip (whitespace / comments)
|
|
89
|
+
|
|
90
|
+
# Verify the entire source was consumed (no unexpected characters).
|
|
91
|
+
matched_len = sum(len(m.group()) for m in _MASTER.finditer(source))
|
|
92
|
+
if matched_len != len(source):
|
|
93
|
+
# Find the first unmatched character to give a useful error.
|
|
94
|
+
pos = 0
|
|
95
|
+
for m in _MASTER.finditer(source):
|
|
96
|
+
if m.start() != pos:
|
|
97
|
+
break
|
|
98
|
+
pos = m.end()
|
|
99
|
+
ctx = source[pos : pos + 20].replace("\n", "\\n")
|
|
100
|
+
raise LexError(f"Unexpected character at line {line}: {ctx!r}")
|
|
101
|
+
|
|
102
|
+
tokens.append(Token(TokenKind.EOF, "", line))
|
|
103
|
+
return tokens
|
prismaa/parser/parser.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .ast import (
|
|
4
|
+
Attribute,
|
|
5
|
+
AttributeArg,
|
|
6
|
+
AttributeValue,
|
|
7
|
+
Datasource,
|
|
8
|
+
Field,
|
|
9
|
+
FunctionCall,
|
|
10
|
+
Generator,
|
|
11
|
+
Model,
|
|
12
|
+
Schema,
|
|
13
|
+
)
|
|
14
|
+
from .lexer import Token, TokenKind, tokenize
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ParseError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Parser:
|
|
22
|
+
def __init__(self, tokens: list[Token]) -> None:
|
|
23
|
+
self._tokens = tokens
|
|
24
|
+
self._pos = 0
|
|
25
|
+
|
|
26
|
+
# ------------------------------------------------------------------
|
|
27
|
+
# Token stream primitives
|
|
28
|
+
# ------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def _peek(self) -> Token:
|
|
31
|
+
return self._tokens[self._pos]
|
|
32
|
+
|
|
33
|
+
def _advance(self) -> Token:
|
|
34
|
+
tok = self._tokens[self._pos]
|
|
35
|
+
if tok.kind != TokenKind.EOF:
|
|
36
|
+
self._pos += 1
|
|
37
|
+
return tok
|
|
38
|
+
|
|
39
|
+
def _at(self, kind: TokenKind) -> bool:
|
|
40
|
+
return self._peek().kind == kind
|
|
41
|
+
|
|
42
|
+
def _expect(self, kind: TokenKind) -> Token:
|
|
43
|
+
tok = self._peek()
|
|
44
|
+
if tok.kind != kind:
|
|
45
|
+
raise ParseError(
|
|
46
|
+
f"Expected {kind.name} but got {tok.kind.name} ({tok.value!r}) at line {tok.line}"
|
|
47
|
+
)
|
|
48
|
+
return self._advance()
|
|
49
|
+
|
|
50
|
+
def _skip_newlines(self) -> None:
|
|
51
|
+
while self._at(TokenKind.NEWLINE):
|
|
52
|
+
self._advance()
|
|
53
|
+
|
|
54
|
+
def _at_end_of_statement(self) -> bool:
|
|
55
|
+
return self._at(TokenKind.NEWLINE) or self._at(TokenKind.RBRACE) or self._at(TokenKind.EOF)
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
# Top-level
|
|
59
|
+
# ------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
def parse(self) -> Schema:
|
|
62
|
+
schema = Schema()
|
|
63
|
+
pending_doc: str | None = None
|
|
64
|
+
|
|
65
|
+
while not self._at(TokenKind.EOF):
|
|
66
|
+
if self._at(TokenKind.NEWLINE):
|
|
67
|
+
self._advance()
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if self._at(TokenKind.DOC_COMMENT):
|
|
71
|
+
# Accumulate consecutive doc comment lines.
|
|
72
|
+
lines = []
|
|
73
|
+
while self._at(TokenKind.DOC_COMMENT):
|
|
74
|
+
lines.append(self._advance().value[3:].strip())
|
|
75
|
+
self._skip_newlines()
|
|
76
|
+
pending_doc = "\n".join(lines)
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if not self._at(TokenKind.IDENT):
|
|
80
|
+
self._advance()
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
keyword = self._peek().value
|
|
84
|
+
if keyword == "generator":
|
|
85
|
+
schema.generators.append(self._parse_generator())
|
|
86
|
+
elif keyword == "datasource":
|
|
87
|
+
schema.datasource = self._parse_datasource()
|
|
88
|
+
elif keyword == "model":
|
|
89
|
+
model = self._parse_model()
|
|
90
|
+
model.doc_comment = pending_doc
|
|
91
|
+
schema.models.append(model)
|
|
92
|
+
else:
|
|
93
|
+
# Unknown top-level block (e.g. "enum", "type") — skip entirely.
|
|
94
|
+
self._skip_block()
|
|
95
|
+
|
|
96
|
+
pending_doc = None
|
|
97
|
+
|
|
98
|
+
return schema
|
|
99
|
+
|
|
100
|
+
# ------------------------------------------------------------------
|
|
101
|
+
# Blocks
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
def _parse_generator(self) -> Generator:
|
|
105
|
+
self._expect(TokenKind.IDENT) # consume "generator"
|
|
106
|
+
name = self._expect(TokenKind.IDENT).value
|
|
107
|
+
self._skip_newlines()
|
|
108
|
+
self._expect(TokenKind.LBRACE)
|
|
109
|
+
props: dict[str, str] = {}
|
|
110
|
+
while not self._at(TokenKind.RBRACE) and not self._at(TokenKind.EOF):
|
|
111
|
+
self._skip_newlines()
|
|
112
|
+
if self._at(TokenKind.RBRACE):
|
|
113
|
+
break
|
|
114
|
+
key = self._expect(TokenKind.IDENT).value
|
|
115
|
+
self._expect(TokenKind.EQUALS)
|
|
116
|
+
value = self._parse_scalar_value()
|
|
117
|
+
props[key] = str(value)
|
|
118
|
+
self._skip_newlines()
|
|
119
|
+
self._expect(TokenKind.RBRACE)
|
|
120
|
+
return Generator(name=name, properties=props)
|
|
121
|
+
|
|
122
|
+
def _parse_datasource(self) -> Datasource:
|
|
123
|
+
self._expect(TokenKind.IDENT) # consume "datasource"
|
|
124
|
+
name = self._expect(TokenKind.IDENT).value
|
|
125
|
+
self._skip_newlines()
|
|
126
|
+
self._expect(TokenKind.LBRACE)
|
|
127
|
+
provider = "unknown"
|
|
128
|
+
while not self._at(TokenKind.RBRACE) and not self._at(TokenKind.EOF):
|
|
129
|
+
self._skip_newlines()
|
|
130
|
+
if self._at(TokenKind.RBRACE):
|
|
131
|
+
break
|
|
132
|
+
key = self._expect(TokenKind.IDENT).value
|
|
133
|
+
self._expect(TokenKind.EQUALS)
|
|
134
|
+
value = self._parse_scalar_value()
|
|
135
|
+
if key == "provider":
|
|
136
|
+
provider = str(value)
|
|
137
|
+
self._skip_newlines()
|
|
138
|
+
self._expect(TokenKind.RBRACE)
|
|
139
|
+
return Datasource(name=name, provider=provider)
|
|
140
|
+
|
|
141
|
+
def _parse_model(self) -> Model:
|
|
142
|
+
self._expect(TokenKind.IDENT) # consume "model"
|
|
143
|
+
name = self._expect(TokenKind.IDENT).value
|
|
144
|
+
self._skip_newlines()
|
|
145
|
+
self._expect(TokenKind.LBRACE)
|
|
146
|
+
self._skip_newlines()
|
|
147
|
+
|
|
148
|
+
fields: list[Field] = []
|
|
149
|
+
block_attrs: list[Attribute] = []
|
|
150
|
+
pending_doc: str | None = None
|
|
151
|
+
|
|
152
|
+
while not self._at(TokenKind.RBRACE) and not self._at(TokenKind.EOF):
|
|
153
|
+
if self._at(TokenKind.NEWLINE):
|
|
154
|
+
self._advance()
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
if self._at(TokenKind.DOC_COMMENT):
|
|
158
|
+
lines = []
|
|
159
|
+
while self._at(TokenKind.DOC_COMMENT):
|
|
160
|
+
lines.append(self._advance().value[3:].strip())
|
|
161
|
+
self._skip_newlines()
|
|
162
|
+
pending_doc = "\n".join(lines)
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if self._at(TokenKind.DOUBLE_AT):
|
|
166
|
+
block_attrs.append(self._parse_block_attribute())
|
|
167
|
+
pending_doc = None
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
if self._at(TokenKind.IDENT):
|
|
171
|
+
f = self._parse_field()
|
|
172
|
+
f.doc_comment = pending_doc
|
|
173
|
+
fields.append(f)
|
|
174
|
+
pending_doc = None
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
# Unexpected token inside model block — skip to next newline.
|
|
178
|
+
self._advance()
|
|
179
|
+
|
|
180
|
+
self._expect(TokenKind.RBRACE)
|
|
181
|
+
return Model(name=name, fields=fields, block_attributes=block_attrs)
|
|
182
|
+
|
|
183
|
+
def _skip_block(self) -> None:
|
|
184
|
+
"""Skip an unknown top-level block (keyword name { ... })."""
|
|
185
|
+
self._advance() # keyword
|
|
186
|
+
if self._at(TokenKind.IDENT):
|
|
187
|
+
self._advance() # name
|
|
188
|
+
self._skip_newlines()
|
|
189
|
+
if not self._at(TokenKind.LBRACE):
|
|
190
|
+
return
|
|
191
|
+
self._advance() # {
|
|
192
|
+
depth = 1
|
|
193
|
+
while depth > 0 and not self._at(TokenKind.EOF):
|
|
194
|
+
tok = self._advance()
|
|
195
|
+
if tok.kind == TokenKind.LBRACE:
|
|
196
|
+
depth += 1
|
|
197
|
+
elif tok.kind == TokenKind.RBRACE:
|
|
198
|
+
depth -= 1
|
|
199
|
+
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
# Fields
|
|
202
|
+
# ------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
def _parse_field(self) -> Field:
|
|
205
|
+
name = self._expect(TokenKind.IDENT).value
|
|
206
|
+
type_name, native_type, is_list, is_optional = self._parse_field_type()
|
|
207
|
+
attributes: list[Attribute] = []
|
|
208
|
+
while self._at(TokenKind.AT) and not self._at_end_of_statement():
|
|
209
|
+
attributes.append(self._parse_field_attribute())
|
|
210
|
+
# Consume the trailing newline (or leave RBRACE for the caller).
|
|
211
|
+
if self._at(TokenKind.NEWLINE):
|
|
212
|
+
self._advance()
|
|
213
|
+
return Field(
|
|
214
|
+
name=name,
|
|
215
|
+
type=type_name,
|
|
216
|
+
is_list=is_list,
|
|
217
|
+
is_optional=is_optional,
|
|
218
|
+
native_type=native_type,
|
|
219
|
+
attributes=attributes,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _parse_field_type(self) -> tuple[str, str | None, bool, bool]:
|
|
223
|
+
"""Return (type_name, native_type, is_list, is_optional)."""
|
|
224
|
+
type_name = self._expect(TokenKind.IDENT).value
|
|
225
|
+
native_type: str | None = None
|
|
226
|
+
|
|
227
|
+
# Handle Unsupported("native_type")
|
|
228
|
+
if type_name == "Unsupported" and self._at(TokenKind.LPAREN):
|
|
229
|
+
self._advance()
|
|
230
|
+
native_type = self._expect(TokenKind.STRING).value.strip('"')
|
|
231
|
+
self._expect(TokenKind.RPAREN)
|
|
232
|
+
|
|
233
|
+
is_list = False
|
|
234
|
+
is_optional = False
|
|
235
|
+
|
|
236
|
+
if self._at(TokenKind.LBRACKET):
|
|
237
|
+
self._advance()
|
|
238
|
+
self._expect(TokenKind.RBRACKET)
|
|
239
|
+
is_list = True
|
|
240
|
+
elif self._at(TokenKind.QUESTION):
|
|
241
|
+
self._advance()
|
|
242
|
+
is_optional = True
|
|
243
|
+
|
|
244
|
+
return type_name, native_type, is_list, is_optional
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# Attributes
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def _parse_field_attribute(self) -> Attribute:
|
|
251
|
+
"""Parse a @name or @name(...) field attribute."""
|
|
252
|
+
self._expect(TokenKind.AT)
|
|
253
|
+
name = self._expect(TokenKind.IDENT).value
|
|
254
|
+
args = self._parse_attribute_args() if self._at(TokenKind.LPAREN) else []
|
|
255
|
+
return Attribute(name=name, args=args)
|
|
256
|
+
|
|
257
|
+
def _parse_block_attribute(self) -> Attribute:
|
|
258
|
+
"""Parse a @@name or @@name(...) block attribute."""
|
|
259
|
+
self._expect(TokenKind.DOUBLE_AT)
|
|
260
|
+
name = self._expect(TokenKind.IDENT).value
|
|
261
|
+
args = self._parse_attribute_args() if self._at(TokenKind.LPAREN) else []
|
|
262
|
+
if self._at(TokenKind.NEWLINE):
|
|
263
|
+
self._advance()
|
|
264
|
+
return Attribute(name=name, args=args)
|
|
265
|
+
|
|
266
|
+
def _parse_attribute_args(self) -> list[AttributeArg]:
|
|
267
|
+
self._expect(TokenKind.LPAREN)
|
|
268
|
+
args: list[AttributeArg] = []
|
|
269
|
+
while not self._at(TokenKind.RPAREN) and not self._at(TokenKind.EOF):
|
|
270
|
+
args.append(self._parse_attribute_arg())
|
|
271
|
+
if self._at(TokenKind.COMMA):
|
|
272
|
+
self._advance()
|
|
273
|
+
self._expect(TokenKind.RPAREN)
|
|
274
|
+
return args
|
|
275
|
+
|
|
276
|
+
def _parse_attribute_arg(self) -> AttributeArg:
|
|
277
|
+
"""Parse one argument: either `name: value` or a bare `value`."""
|
|
278
|
+
# Named argument: IDENT COLON value
|
|
279
|
+
if self._at(TokenKind.IDENT) and self._tokens[self._pos + 1].kind == TokenKind.COLON:
|
|
280
|
+
name = self._advance().value
|
|
281
|
+
self._advance() # consume ':'
|
|
282
|
+
value = self._parse_attr_value()
|
|
283
|
+
return AttributeArg(name=name, value=value)
|
|
284
|
+
# Positional argument
|
|
285
|
+
return AttributeArg(name=None, value=self._parse_attr_value())
|
|
286
|
+
|
|
287
|
+
def _parse_attr_value(self) -> AttributeValue:
|
|
288
|
+
tok = self._peek()
|
|
289
|
+
|
|
290
|
+
if tok.kind == TokenKind.STRING:
|
|
291
|
+
self._advance()
|
|
292
|
+
return tok.value[1:-1] # strip surrounding quotes
|
|
293
|
+
|
|
294
|
+
if tok.kind == TokenKind.NUMBER:
|
|
295
|
+
self._advance()
|
|
296
|
+
return float(tok.value) if "." in tok.value else int(tok.value)
|
|
297
|
+
|
|
298
|
+
if tok.kind == TokenKind.LBRACKET:
|
|
299
|
+
return self._parse_array()
|
|
300
|
+
|
|
301
|
+
if tok.kind == TokenKind.IDENT:
|
|
302
|
+
name = self._advance().value
|
|
303
|
+
if name == "true":
|
|
304
|
+
return True
|
|
305
|
+
if name == "false":
|
|
306
|
+
return False
|
|
307
|
+
# Function call: name()
|
|
308
|
+
if self._at(TokenKind.LPAREN):
|
|
309
|
+
self._advance()
|
|
310
|
+
self._expect(TokenKind.RPAREN)
|
|
311
|
+
return FunctionCall(name)
|
|
312
|
+
# Bare identifier (e.g. enum value, env("VAR") key)
|
|
313
|
+
return name
|
|
314
|
+
|
|
315
|
+
raise ParseError(f"Unexpected token in attribute value: {tok!r}")
|
|
316
|
+
|
|
317
|
+
def _parse_array(self) -> list[str]:
|
|
318
|
+
"""Parse [ident, ident, ...] — used in @@unique, @relation fields/references."""
|
|
319
|
+
self._expect(TokenKind.LBRACKET)
|
|
320
|
+
items: list[str] = []
|
|
321
|
+
while not self._at(TokenKind.RBRACKET) and not self._at(TokenKind.EOF):
|
|
322
|
+
items.append(self._expect(TokenKind.IDENT).value)
|
|
323
|
+
if self._at(TokenKind.COMMA):
|
|
324
|
+
self._advance()
|
|
325
|
+
self._expect(TokenKind.RBRACKET)
|
|
326
|
+
return items
|
|
327
|
+
|
|
328
|
+
# ------------------------------------------------------------------
|
|
329
|
+
# Shared value parsing (generator / datasource properties)
|
|
330
|
+
# ------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
def _parse_scalar_value(self) -> AttributeValue:
|
|
333
|
+
"""Parse a simple key = value pair value (string, number, bool, or env(...))."""
|
|
334
|
+
tok = self._peek()
|
|
335
|
+
if tok.kind == TokenKind.STRING:
|
|
336
|
+
self._advance()
|
|
337
|
+
return tok.value[1:-1]
|
|
338
|
+
if tok.kind == TokenKind.NUMBER:
|
|
339
|
+
self._advance()
|
|
340
|
+
return float(tok.value) if "." in tok.value else int(tok.value)
|
|
341
|
+
if tok.kind == TokenKind.IDENT:
|
|
342
|
+
name = self._advance().value
|
|
343
|
+
if name == "true":
|
|
344
|
+
return True
|
|
345
|
+
if name == "false":
|
|
346
|
+
return False
|
|
347
|
+
# env("VAR") — return the raw identifier; resolution happens at runtime
|
|
348
|
+
if self._at(TokenKind.LPAREN):
|
|
349
|
+
self._advance()
|
|
350
|
+
inner = self._peek()
|
|
351
|
+
value = inner.value[1:-1] if inner.kind == TokenKind.STRING else inner.value
|
|
352
|
+
self._advance()
|
|
353
|
+
self._expect(TokenKind.RPAREN)
|
|
354
|
+
return value
|
|
355
|
+
return name
|
|
356
|
+
if tok.kind == TokenKind.LBRACKET:
|
|
357
|
+
return self._parse_array()
|
|
358
|
+
raise ParseError(f"Unexpected token in value: {tok!r}")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def parse(source: str) -> Schema:
|
|
362
|
+
"""Parse a Prisma schema string and return a :class:`Schema` AST."""
|
|
363
|
+
tokens = tokenize(source)
|
|
364
|
+
return Parser(tokens).parse()
|
prismaa/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: prismaa
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A production-grade Python Prisma client
|
|
5
|
+
Project-URL: Homepage, https://github.com/laszlosragner/prismaa
|
|
6
|
+
Project-URL: Documentation, https://laszlosragner.github.io/prismaa
|
|
7
|
+
Project-URL: Repository, https://github.com/laszlosragner/prismaa
|
|
8
|
+
Project-URL: Issues, https://github.com/laszlosragner/prismaa/issues
|
|
9
|
+
Author-email: Laszlo Sragner <laszlo@hypergolic.co.uk>
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 xLaszlo
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: async,database,orm,prisma,sqlalchemy
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Framework :: AsyncIO
|
|
35
|
+
Classifier: Intended Audience :: Developers
|
|
36
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
37
|
+
Classifier: Programming Language :: Python :: 3
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Database
|
|
42
|
+
Requires-Python: >=3.11
|
|
43
|
+
Requires-Dist: aiosqlite>=0.19
|
|
44
|
+
Requires-Dist: click>=8.0
|
|
45
|
+
Requires-Dist: jinja2>=3.1
|
|
46
|
+
Requires-Dist: pydantic>=2.0
|
|
47
|
+
Requires-Dist: sqlalchemy>=2.0
|
|
48
|
+
Description-Content-Type: text/markdown
|
|
49
|
+
|
|
50
|
+
# prismaa
|
|
51
|
+
Prisma python client with SQLAlchemy backend
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
prismaa/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
prismaa/cli.py,sha256=3gw4WZkrgzflwpjyHfJkqWf2xOZLZ2UITE8Mx9lqevE,524
|
|
3
|
+
prismaa/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
prismaa/engine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
prismaa/generator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
prismaa/parser/__init__.py,sha256=isNtMRhVmQHTfpGIAIP-brqM03g_v7N2kzexn5f2iuc,513
|
|
7
|
+
prismaa/parser/ast.py,sha256=VP5doDijft5xE_ixJdZIRjZUzfQcogdnptMz6BsqV1Y,4091
|
|
8
|
+
prismaa/parser/lexer.py,sha256=sWeXpQUO9PiaPnQjLc4XuDsBqMpNNL1CVCKKhOyY9Wk,2924
|
|
9
|
+
prismaa/parser/parser.py,sha256=-OSiI_qGmZtt-BN1X9ZiN-kCdKPn4dF9UVnNohLwnhg,13292
|
|
10
|
+
prismaa/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
prismaa-0.1.0.dist-info/METADATA,sha256=Kgzhb_gLNP8dYtLVafk0cXgQqVN8RuMBmjvPgfmxe5Q,2391
|
|
12
|
+
prismaa-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
13
|
+
prismaa-0.1.0.dist-info/entry_points.txt,sha256=wCauZPJ2MHIwy2WgtgEoF46cQ9-1--_HcQM82Q0HSH8,45
|
|
14
|
+
prismaa-0.1.0.dist-info/licenses/LICENSE,sha256=8_nxY_VIKgaz42SM-7ZACEIOnvNXVFth6YJG9n51_vs,1064
|
|
15
|
+
prismaa-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xLaszlo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|