rulea 1.0.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.
rulea/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ from .parser import parse_rule_file
2
+ from .engine import RuleEngine
rulea/cli.py ADDED
@@ -0,0 +1,27 @@
1
+ import argparse
2
+ from .engine import RuleEngine
3
+ import json
4
+ import sys
5
+
6
+ def main():
7
+ parser = argparse.ArgumentParser(description="Rulea CLI - check rules.")
8
+ parser.add_argument("rule_file", help=".rulea file path")
9
+ parser.add_argument("rule_name", help="Rule name to check")
10
+ parser.add_argument("--context", "-c", help="JSON string of context variables", default="{}")
11
+
12
+ args = parser.parse_args()
13
+
14
+ try:
15
+ context = json.loads(args.context)
16
+ except Exception as e:
17
+ print(f"Invalid JSON context: {e}")
18
+ sys.exit(1)
19
+
20
+ engine = RuleEngine(args.rule_file)
21
+ ok, reason = engine.check(args.rule_name, context)
22
+ if ok:
23
+ print("Rule passed ✅")
24
+ sys.exit(0)
25
+ else:
26
+ print(f"Rule denied ❌ Reason: {reason}")
27
+ sys.exit(1)
rulea/engine.py ADDED
@@ -0,0 +1,21 @@
1
+ from .evaluator import SafeEvaluator
2
+ from .parser import parse_rule_file
3
+
4
+ class RuleEngine:
5
+ def __init__(self, rule_path):
6
+ self.rules = parse_rule_file(rule_path)
7
+
8
+ def check(self, rule_name, context):
9
+ rule = self.rules.get(rule_name)
10
+ if not rule:
11
+ return False, "No such rule"
12
+
13
+ expr = rule.get("when")
14
+ reason = rule.get("reason", "Permission denied")
15
+
16
+ try:
17
+ evaluator = SafeEvaluator(context)
18
+ result = evaluator.eval(expr)
19
+ return result, None if result else reason
20
+ except Exception as e:
21
+ return False, f"Rule error: {e}"
rulea/evaluator.py ADDED
@@ -0,0 +1,51 @@
1
+ import ast
2
+
3
+ class SafeEvaluator(ast.NodeVisitor):
4
+ ALLOWED = {
5
+ ast.Expression, ast.BoolOp, ast.BinOp, ast.UnaryOp,
6
+ ast.Compare, ast.Name, ast.Load, ast.Constant,
7
+ ast.And, ast.Or, ast.Not, ast.Eq, ast.NotEq, ast.Gt, ast.Lt,
8
+ ast.GtE, ast.LtE
9
+ }
10
+
11
+ def __init__(self, context):
12
+ self.context = context
13
+
14
+ def eval(self, expr):
15
+ tree = ast.parse(expr, mode='eval')
16
+ return self.visit(tree.body)
17
+
18
+ def visit(self, node):
19
+ if type(node) not in self.ALLOWED:
20
+ raise ValueError(f"Unsupported node: {type(node).__name__}")
21
+ return super().visit(node)
22
+
23
+ def visit_Name(self, node):
24
+ return self.context.get(node.id, None)
25
+
26
+ def visit_Constant(self, node):
27
+ return node.value
28
+
29
+ def visit_BoolOp(self, node):
30
+ vals = [self.visit(v) for v in node.values]
31
+ return all(vals) if isinstance(node.op, ast.And) else any(vals)
32
+
33
+ def visit_Compare(self, node):
34
+ left = self.visit(node.left)
35
+ for op, comp in zip(node.ops, node.comparators):
36
+ right = self.visit(comp)
37
+ if isinstance(op, ast.Eq) and not left == right:
38
+ return False
39
+ if isinstance(op, ast.NotEq) and not left != right:
40
+ return False
41
+ if isinstance(op, ast.Gt) and not left > right:
42
+ return False
43
+ if isinstance(op, ast.Lt) and not left < right:
44
+ return False
45
+ return True
46
+
47
+ def visit_UnaryOp(self, node):
48
+ operand = self.visit(node.operand)
49
+ if isinstance(node.op, ast.Not):
50
+ return not operand
51
+ raise ValueError("Unsupported unary operation")
rulea/model.py ADDED
@@ -0,0 +1 @@
1
+ ⠀
rulea/parser.py ADDED
@@ -0,0 +1,225 @@
1
+ # parser.py
2
+ def parse_rule_file(path: str):
3
+ rules = {}
4
+ current_rule = None
5
+ current_block = None
6
+ with open(path, 'r') as f:
7
+ for line in f:
8
+ line = line.strip()
9
+ if not line or line.startswith("#"):
10
+ continue
11
+
12
+ if line.startswith("rule "):
13
+ rule_name = line.split()[1].rstrip(":")
14
+ current_rule = {
15
+ "when": "",
16
+ "reason": "",
17
+ "imports": [],
18
+ "description": "",
19
+ "tags": [],
20
+ "priority": 100,
21
+ "action": "",
22
+ "extends": None,
23
+ "enabled": True
24
+ }
25
+ rules[rule_name] = current_rule
26
+
27
+ elif current_rule is None:
28
+ continue # ファイル先頭にルール外の行があった場合スキップ
29
+
30
+ elif line.startswith("when:"):
31
+ current_block = "when"
32
+ current_rule["when"] = line[len("when:"):].strip()
33
+
34
+ elif line.startswith("reason:"):
35
+ current_block = "reason"
36
+ current_rule["reason"] = line[len("reason:"):].strip()
37
+
38
+ elif line.startswith("description:"):
39
+ current_rule["description"] = line[len("description:"):].strip()
40
+
41
+ elif line.startswith("tags:"):
42
+ current_rule["tags"] = [tag.strip() for tag in line[len("tags:"):].split(",")]
43
+
44
+ elif line.startswith("priority:"):
45
+ current_rule["priority"] = int(line[len("priority:"):].strip())
46
+
47
+ elif line.startswith("action:"):
48
+ current_block = "action"
49
+ current_rule["action"] = line[len("action:"):].strip()
50
+
51
+ elif line.startswith("import:"):
52
+ current_rule["imports"] = [x.strip() for x in line[len("import:"):].split(",")]
53
+
54
+ elif line.startswith("extends:"):
55
+ current_rule["extends"] = line[len("extends:"):].strip()
56
+
57
+ elif line.startswith("enabled:"):
58
+ val = line[len("enabled:"):].strip().lower()
59
+ current_rule["enabled"] = val in ("true", "yes", "1")
60
+
61
+ else:
62
+ if current_block:
63
+ current_rule[current_block] += "\n" + line
64
+ return rules
65
+
66
+
67
+ # evaluator.py
68
+ import ast
69
+
70
+ class SafeEvaluator(ast.NodeVisitor):
71
+ ALLOWED = {
72
+ ast.Expression, ast.BoolOp, ast.BinOp, ast.UnaryOp,
73
+ ast.Compare, ast.Name, ast.Load, ast.Constant,
74
+ ast.And, ast.Or, ast.Not, ast.Eq, ast.NotEq, ast.Gt, ast.Lt,
75
+ ast.GtE, ast.LtE
76
+ }
77
+
78
+ def __init__(self, context):
79
+ self.context = context
80
+
81
+ def eval(self, expr):
82
+ tree = ast.parse(expr, mode='eval')
83
+ return self.visit(tree.body)
84
+
85
+ def visit(self, node):
86
+ if type(node) not in self.ALLOWED:
87
+ raise ValueError(f"Unsupported node: {type(node).__name__}")
88
+ return super().visit(node)
89
+
90
+ def visit_Name(self, node):
91
+ if node.id not in self.context:
92
+ raise ValueError(f"Undefined variable: {node.id}")
93
+ return self.context[node.id]
94
+
95
+ def visit_Constant(self, node):
96
+ return node.value
97
+
98
+ def visit_BoolOp(self, node):
99
+ vals = [self.visit(v) for v in node.values]
100
+ if isinstance(node.op, ast.And):
101
+ return all(vals)
102
+ elif isinstance(node.op, ast.Or):
103
+ return any(vals)
104
+ else:
105
+ raise ValueError("Unsupported BoolOp")
106
+
107
+ def visit_Compare(self, node):
108
+ left = self.visit(node.left)
109
+ for op, comp in zip(node.ops, node.comparators):
110
+ right = self.visit(comp)
111
+ if isinstance(op, ast.Eq) and not left == right:
112
+ return False
113
+ elif isinstance(op, ast.NotEq) and not left != right:
114
+ return False
115
+ elif isinstance(op, ast.Gt) and not left > right:
116
+ return False
117
+ elif isinstance(op, ast.Lt) and not left < right:
118
+ return False
119
+ elif isinstance(op, ast.GtE) and not left >= right:
120
+ return False
121
+ elif isinstance(op, ast.LtE) and not left <= right:
122
+ return False
123
+ else:
124
+ raise ValueError("Unsupported comparison operator")
125
+ return True
126
+
127
+ def visit_UnaryOp(self, node):
128
+ operand = self.visit(node.operand)
129
+ if isinstance(node.op, ast.Not):
130
+ return not operand
131
+ else:
132
+ raise ValueError("Unsupported unary operation")
133
+
134
+
135
+ # action_registry.py
136
+ import ast
137
+
138
+ class ActionRegistry:
139
+ def __init__(self):
140
+ self._actions = {}
141
+
142
+ def register(self, name, func):
143
+ self._actions[name] = func
144
+
145
+ def execute(self, expr: str, context: dict):
146
+ tree = ast.parse(expr, mode='eval')
147
+ call = tree.body
148
+
149
+ if not isinstance(call, ast.Call):
150
+ raise ValueError("Action は関数呼び出し形式である必要があります")
151
+
152
+ if not isinstance(call.func, ast.Name):
153
+ raise ValueError("関数名が不正です")
154
+
155
+ func_name = call.func.id
156
+ if func_name not in self._actions:
157
+ raise ValueError(f"許可されていないアクション: {func_name}")
158
+
159
+ args = []
160
+ for arg in call.args:
161
+ if isinstance(arg, ast.Name):
162
+ if arg.id not in context:
163
+ raise ValueError(f"アクションの引数変数が未定義: {arg.id}")
164
+ args.append(context[arg.id])
165
+ elif isinstance(arg, ast.Constant):
166
+ args.append(arg.value)
167
+ else:
168
+ raise ValueError("アクションの引数が不正です")
169
+
170
+ return self._actions[func_name](*args)
171
+
172
+
173
+ # engine.py
174
+ from .evaluator import SafeEvaluator
175
+ from .parser import parse_rule_file
176
+ from .action_registry import ActionRegistry
177
+
178
+ class RuleEngine:
179
+ def __init__(self, rule_path, action_registry=None):
180
+ self.rules = parse_rule_file(rule_path)
181
+ self.registry = action_registry or ActionRegistry()
182
+
183
+ def resolve_rule(self, rule_name, visited=None):
184
+ visited = visited or set()
185
+ if rule_name in visited:
186
+ raise ValueError(f"循環 extends 検出: {rule_name}")
187
+ visited.add(rule_name)
188
+
189
+ rule = self.rules.get(rule_name)
190
+ if not rule:
191
+ raise ValueError(f"ルールが見つかりません: {rule_name}")
192
+
193
+ if rule.get("extends"):
194
+ parent = self.resolve_rule(rule["extends"], visited)
195
+ # 親ルールの内容をコピーしつつ、子ルールの値で上書き
196
+ combined = dict(parent)
197
+ combined.update(rule)
198
+ return combined
199
+
200
+ return rule
201
+
202
+ def check(self, rule_name, context):
203
+ try:
204
+ rule = self.resolve_rule(rule_name)
205
+ except Exception as e:
206
+ return False, f"継承エラー: {e}"
207
+
208
+ if not rule.get("enabled", True):
209
+ return False, "ルールは無効化されています"
210
+
211
+ expr = rule.get("when")
212
+ reason = rule.get("reason", "アクセス拒否")
213
+
214
+ try:
215
+ evaluator = SafeEvaluator(context)
216
+ result = evaluator.eval(expr)
217
+ if result:
218
+ action_expr = rule.get("action")
219
+ if action_expr:
220
+ self.registry.execute(action_expr, context)
221
+ return True, None
222
+ else:
223
+ return False, reason
224
+ except Exception as e:
225
+ return False, f"評価エラー: {e}"
rulea/stdlib.py ADDED
@@ -0,0 +1 @@
1
+ ⠀
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: rulea
3
+ Version: 1.0.0
4
+ Summary: Rule-based programmable language in Python
5
+ Home-page: https://github.com/tanuki276/rulea
6
+ Author: あなた
7
+ Author-email: soln <you@example.com>
8
+ License: MIT
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Dynamic: license-file
16
+
17
+ ==============================
18
+ rulea - Syntax and Usage
19
+
20
+ Overview:
21
+
22
+ rulea is a rule-based language that clearly defines "who can do what."
23
+ With Python-like syntax, access or operation conditions are written in external .rulea files,
24
+ which can then be evaluated from within an application.
25
+
26
+ Basic Rule File Structure:
27
+
28
+ rule <rule_name>:
29
+ when: <condition_expression>
30
+ reason: "<reason if denied>"
31
+
32
+ The when: section accepts both Python expressions and if statements.
33
+
34
+ Multi-line conditions are supported.
35
+
36
+
37
+ Examples:
38
+
39
+ rule edit:
40
+ when: user == owner and status != "locked"
41
+ reason: "Only the owner can edit unlocked items"
42
+
43
+ rule delete:
44
+ when:
45
+ if role == "admin":
46
+ True
47
+ elif user == owner:
48
+ status != "archived"
49
+ else:
50
+ False
51
+ reason: "Only admins or the owner (if not archived) can delete"
52
+
53
+ Parameters are passed in externally as a context and are evaluated as variables.
54
+
55
+
56
+ Using in Python:
57
+
58
+ from rulea import RuleEngine
59
+
60
+ engine = RuleEngine("access.rulea")
61
+ ok, reason = engine.check("edit", {
62
+ "user": "alice",
63
+ "owner": "alice",
64
+ "status": "active"
65
+ })
66
+
67
+ if ok:
68
+ print("Allowed")
69
+ else:
70
+ print("Denied:", reason)
71
+
72
+ Using in CLI:
73
+
74
+ rulea access.rulea edit \
75
+ --context '{"user": "alice", "owner": "bob", "status": "locked"}'
76
+
77
+ Exit Codes:
78
+
79
+ 0 = Rule passed (allowed)
80
+
81
+ 1 = Rule failed (denied)
82
+
83
+
84
+ Notes:
85
+
86
+ when: supports expressions and if statements
87
+
88
+ reason: is returned when the rule is denied
89
+
90
+ Conditions are evaluated in a safe Python environment
91
+
92
+ .rulea files must be saved in UTF-8 encoding
93
+
@@ -0,0 +1,13 @@
1
+ rulea/__init__.py,sha256=2bpD61hCYDZ2NmFdTYTgvhtmesAGNVFJS3t2mNy5AhI,67
2
+ rulea/cli.py,sha256=OFuPIHENxlDkWF8H_dV32MKySaJdOnI7-3Pu2-pAKoc,800
3
+ rulea/engine.py,sha256=ALQsOOH1bGiDH-DqAfGyk_FaofOnVF0XfZrDfh3wWk8,642
4
+ rulea/evaluator.py,sha256=dw8HPJbBjETrGeFFxzx5TvZvkWav2IwrRzw0fSvMhMQ,1689
5
+ rulea/model.py,sha256=yKRYuT8mvl17xkYXxSphbTZ8pyDmJrFLYzuoTtN48XU,3
6
+ rulea/parser.py,sha256=7BWLmtZslAETlvRXjg3XoDvIdDv3zJ6jF9PFsG4F8Kw,7609
7
+ rulea/stdlib.py,sha256=yKRYuT8mvl17xkYXxSphbTZ8pyDmJrFLYzuoTtN48XU,3
8
+ rulea-1.0.0.dist-info/licenses/LICENSE,sha256=eRpi5QuAFrW1F-5_o6_1T7N90JE06bG5buvX_tkGKGc,557
9
+ rulea-1.0.0.dist-info/METADATA,sha256=yTTzx1gszTl--1Mhnd3K8MlYGsXnHwcu76XShDVGsmY,2176
10
+ rulea-1.0.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
11
+ rulea-1.0.0.dist-info/entry_points.txt,sha256=X-ZhiQZ6jVigZxcCnlcBx7ZVZ7IaCN8H8jFV3Cv6F6I,41
12
+ rulea-1.0.0.dist-info/top_level.txt,sha256=MnAKoQOQHinSsfvOEx0_HSW60u1z4SxAtdQPc7CvIhU,6
13
+ rulea-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ rulea = rulea.cli:main
@@ -0,0 +1,19 @@
1
+ Copyright (c) 2025 soln
2
+
3
+ All rights reserved.
4
+
5
+ ---
6
+
7
+ Unauthorized copying, reproduction, distribution, or use of this software and all associated code (hereinafter "the Software") is strictly prohibited.
8
+
9
+ Use of the Software is only permitted with explicit prior written permission from the copyright holder.
10
+
11
+ The copyright holder reserves the right to take legal action against any unauthorized use, reproduction, or distribution of the Software.
12
+
13
+ ---
14
+
15
+ The Software is provided "as is," without any express or implied warranty.
16
+
17
+ ---
18
+
19
+ [Contact Information]
@@ -0,0 +1 @@
1
+ rulea