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 +2 -0
- rulea/cli.py +27 -0
- rulea/engine.py +21 -0
- rulea/evaluator.py +51 -0
- rulea/model.py +1 -0
- rulea/parser.py +225 -0
- rulea/stdlib.py +1 -0
- rulea-1.0.0.dist-info/METADATA +93 -0
- rulea-1.0.0.dist-info/RECORD +13 -0
- rulea-1.0.0.dist-info/WHEEL +5 -0
- rulea-1.0.0.dist-info/entry_points.txt +2 -0
- rulea-1.0.0.dist-info/licenses/LICENSE +19 -0
- rulea-1.0.0.dist-info/top_level.txt +1 -0
rulea/__init__.py
ADDED
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,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
|