fluxrules 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.
- fluxrules/__init__.py +209 -0
- fluxrules/adapters/__init__.py +3 -0
- fluxrules/adapters/caches/__init__.py +100 -0
- fluxrules/adapters/loaders/__init__.py +5 -0
- fluxrules/adapters/loaders/yaml_loader.py +47 -0
- fluxrules/adapters/observability/__init__.py +3 -0
- fluxrules/adapters/observability/noop.py +27 -0
- fluxrules/adapters/persistence/__init__.py +7 -0
- fluxrules/adapters/persistence/sqlalchemy_adapter.py +168 -0
- fluxrules/adapters/queues/__init__.py +101 -0
- fluxrules/adapters/repository/__init__.py +15 -0
- fluxrules/adapters/repository/database.py +59 -0
- fluxrules/adapters/repository/in_memory.py +60 -0
- fluxrules/analytics/__init__.py +11 -0
- fluxrules/api/__init__.py +10 -0
- fluxrules/api/analytics/__init__.py +1 -0
- fluxrules/api/analytics/coverage_report.py +20 -0
- fluxrules/api/analytics/explanation_engine.py +21 -0
- fluxrules/api/analytics/metrics.py +23 -0
- fluxrules/api/analytics/store.py +149 -0
- fluxrules/api/app.py +108 -0
- fluxrules/api/config.py +136 -0
- fluxrules/api/database.py +172 -0
- fluxrules/api/deps.py +69 -0
- fluxrules/api/engines/__init__.py +16 -0
- fluxrules/api/engines/adapter.py +434 -0
- fluxrules/api/engines/engine_pool.py +229 -0
- fluxrules/api/engines/holder.py +0 -0
- fluxrules/api/handlers.py +110 -0
- fluxrules/api/models/__init__.py +7 -0
- fluxrules/api/models/audit.py +20 -0
- fluxrules/api/models/audit_policy.py +65 -0
- fluxrules/api/models/conflicted_rule.py +44 -0
- fluxrules/api/models/rule.py +72 -0
- fluxrules/api/models/user.py +24 -0
- fluxrules/api/routes/__init__.py +0 -0
- fluxrules/api/routes/admin.py +65 -0
- fluxrules/api/routes/analytics.py +53 -0
- fluxrules/api/routes/audit.py +56 -0
- fluxrules/api/routes/audit_policy.py +160 -0
- fluxrules/api/routes/auth.py +61 -0
- fluxrules/api/routes/brms_analyze.py +525 -0
- fluxrules/api/routes/dependency_graph.py +203 -0
- fluxrules/api/routes/engines.py +397 -0
- fluxrules/api/routes/evaluate.py +42 -0
- fluxrules/api/routes/evaluation.py +219 -0
- fluxrules/api/routes/events.py +97 -0
- fluxrules/api/routes/explain.py +25 -0
- fluxrules/api/routes/health.py +37 -0
- fluxrules/api/routes/metrics.py +49 -0
- fluxrules/api/routes/rules.py +1119 -0
- fluxrules/api/routes/rulesets.py +109 -0
- fluxrules/api/routes/sdk.py +109 -0
- fluxrules/api/routes/sessions.py +205 -0
- fluxrules/api/routes/simulate.py +28 -0
- fluxrules/api/routes/validate.py +34 -0
- fluxrules/api/schema_manager.py +98 -0
- fluxrules/api/schemas/__init__.py +30 -0
- fluxrules/api/schemas/analytics.py +57 -0
- fluxrules/api/schemas/audit_policy.py +79 -0
- fluxrules/api/schemas/event.py +15 -0
- fluxrules/api/schemas/rule.py +98 -0
- fluxrules/api/schemas/session.py +38 -0
- fluxrules/api/schemas/user.py +37 -0
- fluxrules/api/schemas.py +30 -0
- fluxrules/api/security.py +82 -0
- fluxrules/api/services/__init__.py +0 -0
- fluxrules/api/services/analytics_service.py +207 -0
- fluxrules/api/services/audit_runner.py +265 -0
- fluxrules/api/services/audit_scheduler.py +157 -0
- fluxrules/api/services/audit_service.py +118 -0
- fluxrules/api/services/auth_service.py +38 -0
- fluxrules/api/services/brms_service.py +135 -0
- fluxrules/api/services/rule_service.py +196 -0
- fluxrules/api/services/rule_validation_service.py +468 -0
- fluxrules/api/utils/__init__.py +0 -0
- fluxrules/api/utils/metrics.py +198 -0
- fluxrules/api/utils/redis_client.py +38 -0
- fluxrules/api/workers/__init__.py +0 -0
- fluxrules/api/workers/event_worker.py +83 -0
- fluxrules/api/workers/validation_worker.py +212 -0
- fluxrules/cli/__init__.py +0 -0
- fluxrules/cli/main.py +200 -0
- fluxrules/compat/__init__.py +3 -0
- fluxrules/compat/contracts.py +17 -0
- fluxrules/config/__init__.py +208 -0
- fluxrules/config/deployment.py +355 -0
- fluxrules/config/manager.py +77 -0
- fluxrules/core/__init__.py +11 -0
- fluxrules/di/__init__.py +334 -0
- fluxrules/domain/__init__.py +35 -0
- fluxrules/domain/actions.py +338 -0
- fluxrules/domain/dsl/__init__.py +179 -0
- fluxrules/domain/dsl/evaluator.py +180 -0
- fluxrules/domain/dsl/expression_parser.py +94 -0
- fluxrules/domain/dsl/parser.py +147 -0
- fluxrules/domain/dsl/validation.py +183 -0
- fluxrules/domain/errors.py +108 -0
- fluxrules/domain/factory.py +177 -0
- fluxrules/domain/models.py +236 -0
- fluxrules/domain/predicates.py +37 -0
- fluxrules/domain/rule_builder.py +462 -0
- fluxrules/domain/schemas.py +87 -0
- fluxrules/domain/unified_rule.py +456 -0
- fluxrules/engine/__init__.py +156 -0
- fluxrules/engine/base.py +339 -0
- fluxrules/engine/configuration.py +61 -0
- fluxrules/engine/cross_fact/__init__.py +48 -0
- fluxrules/engine/cross_fact/_engine.py +574 -0
- fluxrules/engine/cross_fact/models.py +324 -0
- fluxrules/engine/cross_fact/working_memory.py +133 -0
- fluxrules/engine/infrastructure/__init__.py +72 -0
- fluxrules/engine/infrastructure/agenda.py +111 -0
- fluxrules/engine/infrastructure/bitmask_linker.py +363 -0
- fluxrules/engine/infrastructure/engine_metrics.py +146 -0
- fluxrules/engine/infrastructure/evaluation_filter.py +26 -0
- fluxrules/engine/infrastructure/evaluation_result.py +15 -0
- fluxrules/engine/infrastructure/fact_store.py +184 -0
- fluxrules/engine/infrastructure/field_index.py +85 -0
- fluxrules/engine/infrastructure/global_rule_repository.py +329 -0
- fluxrules/engine/infrastructure/lazy_evaluation.py +173 -0
- fluxrules/engine/infrastructure/node_memory.py +178 -0
- fluxrules/engine/infrastructure/scope_guard.py +402 -0
- fluxrules/engine/infrastructure/segment_network.py +139 -0
- fluxrules/engine/infrastructure/token_propagator.py +62 -0
- fluxrules/engine/infrastructure/working_memory.py +141 -0
- fluxrules/engine/interfaces.py +13 -0
- fluxrules/engine/operator_registry.py +83 -0
- fluxrules/engine/operators.py +175 -0
- fluxrules/engine/phreak/__init__.py +34 -0
- fluxrules/engine/phreak/_engine.py +1333 -0
- fluxrules/engine/phreak/condition_compiler.py +204 -0
- fluxrules/engine/profiler.py +62 -0
- fluxrules/engine/runtime/__init__.py +43 -0
- fluxrules/engine/runtime/action_dispatcher.py +277 -0
- fluxrules/engine/runtime/dedup_cache.py +247 -0
- fluxrules/engine/runtime/worker_pool.py +120 -0
- fluxrules/exceptions.py +132 -0
- fluxrules/features.py +61 -0
- fluxrules/initialization.py +111 -0
- fluxrules/logging.py +70 -0
- fluxrules/persistence/__init__.py +27 -0
- fluxrules/persistence/database_config.py +141 -0
- fluxrules/persistence/db_connection_manager.py +201 -0
- fluxrules/persistence/execution_repository.py +67 -0
- fluxrules/persistence/mappers.py +178 -0
- fluxrules/persistence/persistence_manager.py +229 -0
- fluxrules/persistence/rule_repository.py +131 -0
- fluxrules/pipeline/__init__.py +156 -0
- fluxrules/pipeline/base.py +179 -0
- fluxrules/pipeline/core.py +410 -0
- fluxrules/pipeline/error_policy.py +106 -0
- fluxrules/pipeline/exporters.py +320 -0
- fluxrules/pipeline/loaders.py +173 -0
- fluxrules/pipeline/observability.py +206 -0
- fluxrules/pipeline/registry.py +96 -0
- fluxrules/pipeline/schema.py +118 -0
- fluxrules/pipeline/transforms/__init__.py +33 -0
- fluxrules/pipeline/transforms/builtin.py +240 -0
- fluxrules/pipeline/transforms/composed.py +196 -0
- fluxrules/pipeline/utils/__init__.py +13 -0
- fluxrules/pipeline/utils/dict_utils.py +189 -0
- fluxrules/pipeline/validators.py +128 -0
- fluxrules/pipeline/versioning.py +137 -0
- fluxrules/plugins/__init__.py +0 -0
- fluxrules/plugins/actions.py +446 -0
- fluxrules/plugins/discovery.py +106 -0
- fluxrules/plugins/operators.py +88 -0
- fluxrules/ports/__init__.py +19 -0
- fluxrules/ports/circuit_breaker.py +35 -0
- fluxrules/ports/execution_store.py +22 -0
- fluxrules/ports/health.py +33 -0
- fluxrules/ports/observability.py +30 -0
- fluxrules/ports/persistence.py +97 -0
- fluxrules/ports/queue.py +78 -0
- fluxrules/ports/repository.py +27 -0
- fluxrules/ports/rule.py +23 -0
- fluxrules/ports/rule_cache.py +69 -0
- fluxrules/ports/validation.py +44 -0
- fluxrules/prefilter/__init__.py +76 -0
- fluxrules/prefilter/pipeline.py +115 -0
- fluxrules/prefilter/predicate.py +304 -0
- fluxrules/prefilter/predicate_index.py +307 -0
- fluxrules/prefilter/sql_store.py +238 -0
- fluxrules/py.typed +0 -0
- fluxrules/services/__init__.py +4 -0
- fluxrules/services/audit_service.py +29 -0
- fluxrules/services/circuit_breaker.py +98 -0
- fluxrules/services/compilation/__init__.py +7 -0
- fluxrules/services/compilation/rule_compiler.py +81 -0
- fluxrules/services/evaluation_service.py +21 -0
- fluxrules/services/execution/__init__.py +25 -0
- fluxrules/services/execution/agenda.py +51 -0
- fluxrules/services/execution/scheduler.py +37 -0
- fluxrules/services/execution/session_context.py +187 -0
- fluxrules/services/execution/session_manager.py +242 -0
- fluxrules/services/execution/session_storage.py +227 -0
- fluxrules/services/execution/storage_backend.py +302 -0
- fluxrules/services/execution/working_memory.py +96 -0
- fluxrules/services/health.py +59 -0
- fluxrules/services/persistence_service.py +67 -0
- fluxrules/services/reference_evaluator.py +127 -0
- fluxrules/services/rule_loader.py +73 -0
- fluxrules/services/rule_service.py +229 -0
- fluxrules/services/session.py +105 -0
- fluxrules/services/validation/__init__.py +39 -0
- fluxrules/services/validation/_compiled_cache.py +230 -0
- fluxrules/services/validation/_interval_index.py +200 -0
- fluxrules/services/validation/_normalization.py +110 -0
- fluxrules/services/validation/conflict_detection.py +375 -0
- fluxrules/services/validation/coverage_analysis.py +55 -0
- fluxrules/services/validation/dead_rule_detection.py +75 -0
- fluxrules/services/validation/duplicate_detection.py +24 -0
- fluxrules/services/validation/gap_detection.py +40 -0
- fluxrules/services/validation/priority_collision_detection.py +26 -0
- fluxrules/services/validation/redundancy_detection.py +42 -0
- fluxrules/services/validation/sat_validation.py +281 -0
- fluxrules/services/validation_enforcer.py +142 -0
- fluxrules/services/validation_gateway.py +213 -0
- fluxrules/services/validation_service.py +115 -0
- fluxrules/services/validators/__init__.py +35 -0
- fluxrules/services/validators/action_validator.py +73 -0
- fluxrules/services/validators/base.py +207 -0
- fluxrules/services/validators/brms_validator.py +151 -0
- fluxrules/services/validators/duplicate_validator.py +139 -0
- fluxrules/services/validators/priority_validator.py +90 -0
- fluxrules/services/validators/referential_validator.py +83 -0
- fluxrules/services/validators/stateful_dsl_validator.py +195 -0
- fluxrules/services/validators/structural_validator.py +199 -0
- fluxrules/testing/__init__.py +193 -0
- fluxrules/utils/__init__.py +16 -0
- fluxrules/utils/dependency_graph.py +67 -0
- fluxrules/utils/dependency_graph_analyzer.py +114 -0
- fluxrules/utils/id_generators.py +379 -0
- fluxrules/utils/metrics.py +93 -0
- fluxrules/version.py +5 -0
- fluxrules-0.1.0.dist-info/METADATA +274 -0
- fluxrules-0.1.0.dist-info/RECORD +241 -0
- fluxrules-0.1.0.dist-info/WHEEL +4 -0
- fluxrules-0.1.0.dist-info/entry_points.txt +2 -0
- fluxrules-0.1.0.dist-info/licenses/LICENSE +21 -0
fluxrules/__init__.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Public package interface for fluxrules core.
|
|
2
|
+
|
|
3
|
+
Main API Functions:
|
|
4
|
+
evaluate() - Evaluate rules against facts
|
|
5
|
+
validate() - Validate rules
|
|
6
|
+
explain() - Get explanation for execution
|
|
7
|
+
|
|
8
|
+
Every evaluation path returns the one :class:`EvaluationResult` type, whose
|
|
9
|
+
``fired_rules`` holds the rules that matched::
|
|
10
|
+
|
|
11
|
+
from fluxrules import Rule, evaluate
|
|
12
|
+
|
|
13
|
+
rule = Rule(
|
|
14
|
+
name="adult",
|
|
15
|
+
condition_dsl={"type": "condition", "field": "age", "op": ">=", "value": 18},
|
|
16
|
+
action="allow",
|
|
17
|
+
)
|
|
18
|
+
evaluate(rule, {"age": 30}).fired_rules
|
|
19
|
+
|
|
20
|
+
For engine implementations, import directly from their packages:
|
|
21
|
+
from fluxrules.engine.phreak import PhreakEngine
|
|
22
|
+
|
|
23
|
+
Infrastructure:
|
|
24
|
+
from fluxrules.engine import BaseEngine
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from collections.abc import Iterable
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from fluxrules.config.deployment import (
|
|
31
|
+
ConfigValidationError,
|
|
32
|
+
DeploymentConfig,
|
|
33
|
+
DeploymentType,
|
|
34
|
+
)
|
|
35
|
+
from fluxrules.domain.errors import (
|
|
36
|
+
CorruptStoredRuleError,
|
|
37
|
+
CyclicDependencyError,
|
|
38
|
+
DSLValidationError,
|
|
39
|
+
EmptyRuleLogicError,
|
|
40
|
+
FluxRulesError,
|
|
41
|
+
InvalidRuleError,
|
|
42
|
+
LossyConditionRebuildError,
|
|
43
|
+
LossyConditionViewWarning,
|
|
44
|
+
UnknownOperatorError,
|
|
45
|
+
)
|
|
46
|
+
from fluxrules.domain.models import EngineRule as _EngineRule
|
|
47
|
+
from fluxrules.domain.models import EvaluationResult, Ruleset
|
|
48
|
+
from fluxrules.domain.rule_builder import ConditionBuilder, RuleBuilder
|
|
49
|
+
from fluxrules.domain.unified_rule import Rule
|
|
50
|
+
from fluxrules.engine import (
|
|
51
|
+
BaseEngine,
|
|
52
|
+
get_available_engines,
|
|
53
|
+
get_engine,
|
|
54
|
+
register_engine,
|
|
55
|
+
unregister_engine,
|
|
56
|
+
)
|
|
57
|
+
from fluxrules.engine.configuration import EngineConfig
|
|
58
|
+
from fluxrules.engine.operator_registry import OperatorRegistry, register_operator
|
|
59
|
+
from fluxrules.engine.phreak import PhreakEngine
|
|
60
|
+
from fluxrules.initialization import (
|
|
61
|
+
initialize_fluxrules,
|
|
62
|
+
initialize_persistence,
|
|
63
|
+
)
|
|
64
|
+
from fluxrules.ports.persistence import RulePersistencePort
|
|
65
|
+
from fluxrules.ports.validation import ValidationIssue, Validator
|
|
66
|
+
from fluxrules.services.audit_service import AuditService
|
|
67
|
+
from fluxrules.services.evaluation_service import EvaluationService
|
|
68
|
+
from fluxrules.services.persistence_service import PersistenceService
|
|
69
|
+
from fluxrules.services.rule_service import RuleService
|
|
70
|
+
from fluxrules.services.validation_service import ValidationService
|
|
71
|
+
from fluxrules.utils.id_generators import IDStrategy, RuleIDGenerator
|
|
72
|
+
from fluxrules.version import __version__
|
|
73
|
+
|
|
74
|
+
_DEFAULT_SERVICE = RuleService.create()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
#: What :func:`evaluate` and :func:`validate` accept: one rule, several rules,
|
|
78
|
+
#: or an already-built :class:`Ruleset`.
|
|
79
|
+
Rules = Ruleset | Rule | Iterable[Rule]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _coerce_facts(facts: object) -> dict[str, Any]:
|
|
83
|
+
"""Return *facts* as a plain dict, accepting Pydantic models too."""
|
|
84
|
+
if isinstance(facts, dict):
|
|
85
|
+
return facts
|
|
86
|
+
if hasattr(facts, "model_dump"): # Pydantic v2
|
|
87
|
+
return facts.model_dump()
|
|
88
|
+
if hasattr(facts, "dict"): # Pydantic v1
|
|
89
|
+
return facts.dict()
|
|
90
|
+
raise TypeError(
|
|
91
|
+
f"facts must be a dict or a Pydantic model, got {type(facts).__name__}. "
|
|
92
|
+
"Convert your object to a dict first."
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _as_ruleset(rules: Rules, *, group: str = "default") -> Ruleset:
|
|
97
|
+
"""Normalise whatever the caller passed into a :class:`Ruleset`.
|
|
98
|
+
|
|
99
|
+
A ``Ruleset`` passes through. A single :class:`Rule` or an iterable of them
|
|
100
|
+
is converted here, so callers never have to reach for the internal
|
|
101
|
+
``to_engine_rule()`` adapter just to evaluate a list of rules.
|
|
102
|
+
"""
|
|
103
|
+
if isinstance(rules, Ruleset):
|
|
104
|
+
return rules
|
|
105
|
+
if isinstance(rules, Rule):
|
|
106
|
+
rules = [rules]
|
|
107
|
+
|
|
108
|
+
engine_rules: list[_EngineRule] = []
|
|
109
|
+
for rule in rules:
|
|
110
|
+
if isinstance(rule, Rule):
|
|
111
|
+
engine_rules.append(rule.to_engine_rule())
|
|
112
|
+
elif isinstance(rule, _EngineRule):
|
|
113
|
+
engine_rules.append(rule)
|
|
114
|
+
else:
|
|
115
|
+
raise TypeError(
|
|
116
|
+
"Expected a Rule, a Ruleset, or an iterable of Rules; got "
|
|
117
|
+
f"{type(rule).__name__}. Build rules with fluxrules.Rule(...) "
|
|
118
|
+
"or fluxrules.RuleBuilder."
|
|
119
|
+
)
|
|
120
|
+
return Ruleset(group=group, rules=tuple(engine_rules))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def evaluate(rules: Rules, facts: object) -> EvaluationResult:
|
|
124
|
+
"""Evaluate rules against a fact map with default in-memory services.
|
|
125
|
+
|
|
126
|
+
*rules* may be a single :class:`Rule`, any iterable of them, or a
|
|
127
|
+
:class:`Ruleset`::
|
|
128
|
+
|
|
129
|
+
rule = Rule(name="adult", condition_dsl={...}, action="allow")
|
|
130
|
+
result = evaluate(rule, {"age": 30})
|
|
131
|
+
result.fired_rules # the rules that matched
|
|
132
|
+
|
|
133
|
+
*facts* may be a plain ``dict``, a Pydantic model, or any object with a
|
|
134
|
+
``model_dump()`` / ``dict()`` method.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
An :class:`EvaluationResult` whose ``fired_rules`` lists what matched.
|
|
138
|
+
"""
|
|
139
|
+
result = _DEFAULT_SERVICE.evaluate_inline(_as_ruleset(rules), _coerce_facts(facts))
|
|
140
|
+
_DEFAULT_SERVICE.execution_store.save(result)
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def validate(rules: Rules) -> list[str]:
|
|
145
|
+
"""Validate rules and return human-readable issues.
|
|
146
|
+
|
|
147
|
+
Accepts the same shapes as :func:`evaluate`. An empty list means valid.
|
|
148
|
+
"""
|
|
149
|
+
return ValidationService().validate_ruleset(_as_ruleset(rules))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def explain(execution_id: str) -> dict[str, object]:
|
|
153
|
+
"""Return a transport-safe explanation payload for callers."""
|
|
154
|
+
execution_result = _DEFAULT_SERVICE.explain(execution_id)
|
|
155
|
+
return {
|
|
156
|
+
"execution_id": execution_result.execution_id,
|
|
157
|
+
"fired_rules": execution_result.fired_rules,
|
|
158
|
+
"actions": execution_result.actions,
|
|
159
|
+
"trace": execution_result.trace,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
__all__ = [
|
|
164
|
+
"AuditService",
|
|
165
|
+
"BaseEngine",
|
|
166
|
+
"ConditionBuilder",
|
|
167
|
+
"ConfigValidationError",
|
|
168
|
+
"CorruptStoredRuleError",
|
|
169
|
+
# Error taxonomy (see fluxrules.domain.errors). Every one derives from
|
|
170
|
+
# FluxRulesError, so `except FluxRulesError` catches the whole family.
|
|
171
|
+
"CyclicDependencyError",
|
|
172
|
+
"DSLValidationError",
|
|
173
|
+
"DeploymentConfig",
|
|
174
|
+
"DeploymentType",
|
|
175
|
+
"EmptyRuleLogicError",
|
|
176
|
+
"EngineConfig",
|
|
177
|
+
"EvaluationResult",
|
|
178
|
+
"EvaluationService",
|
|
179
|
+
"FluxRulesError",
|
|
180
|
+
"IDStrategy",
|
|
181
|
+
"InvalidRuleError",
|
|
182
|
+
"LossyConditionRebuildError",
|
|
183
|
+
"LossyConditionViewWarning",
|
|
184
|
+
"OperatorRegistry",
|
|
185
|
+
"PersistenceService",
|
|
186
|
+
"PhreakEngine",
|
|
187
|
+
"Rule",
|
|
188
|
+
"RuleBuilder",
|
|
189
|
+
"RuleIDGenerator",
|
|
190
|
+
"RulePersistencePort",
|
|
191
|
+
"RuleService",
|
|
192
|
+
"Rules",
|
|
193
|
+
"Ruleset",
|
|
194
|
+
"UnknownOperatorError",
|
|
195
|
+
"ValidationIssue",
|
|
196
|
+
"ValidationService",
|
|
197
|
+
"Validator",
|
|
198
|
+
"__version__",
|
|
199
|
+
"evaluate",
|
|
200
|
+
"explain",
|
|
201
|
+
"get_available_engines",
|
|
202
|
+
"get_engine",
|
|
203
|
+
"initialize_fluxrules",
|
|
204
|
+
"initialize_persistence",
|
|
205
|
+
"register_engine",
|
|
206
|
+
"register_operator",
|
|
207
|
+
"unregister_engine",
|
|
208
|
+
"validate",
|
|
209
|
+
]
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""In-memory rule cache adapter for testing and embedded mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from fluxrules.ports.rule_cache import RuleCachePort
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InMemoryRuleCacheAdapter(RuleCachePort):
|
|
12
|
+
"""In-memory LRU-style cache for compiled rule networks.
|
|
13
|
+
|
|
14
|
+
Supports TTL-based expiration. Suitable for testing and single-process mode.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, max_size: int = 1000) -> None:
|
|
18
|
+
self._cache: dict[str, tuple[Any, float]] = {} # key -> (value, expire_time)
|
|
19
|
+
self._max_size = max_size
|
|
20
|
+
self._stats = {"hits": 0, "misses": 0}
|
|
21
|
+
self._closed = False
|
|
22
|
+
|
|
23
|
+
def _make_key(self, ruleset_id: int, version: int) -> str:
|
|
24
|
+
return f"{ruleset_id}:{version}"
|
|
25
|
+
|
|
26
|
+
def get(self, ruleset_id: int, version: int) -> Any | None:
|
|
27
|
+
"""Get compiled rule network from cache."""
|
|
28
|
+
if self._closed:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
key = self._make_key(ruleset_id, version)
|
|
32
|
+
entry = self._cache.get(key)
|
|
33
|
+
|
|
34
|
+
if entry is None:
|
|
35
|
+
self._stats["misses"] += 1
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
value, expire_time = entry
|
|
39
|
+
if time.time() > expire_time:
|
|
40
|
+
del self._cache[key]
|
|
41
|
+
self._stats["misses"] += 1
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
self._stats["hits"] += 1
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
def set(
|
|
48
|
+
self,
|
|
49
|
+
ruleset_id: int,
|
|
50
|
+
version: int,
|
|
51
|
+
network: Any,
|
|
52
|
+
ttl_seconds: int = 3600,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Store compiled rule network with TTL."""
|
|
55
|
+
if self._closed:
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# Evict oldest if at capacity
|
|
59
|
+
if len(self._cache) >= self._max_size:
|
|
60
|
+
oldest_key = next(iter(self._cache))
|
|
61
|
+
del self._cache[oldest_key]
|
|
62
|
+
|
|
63
|
+
key = self._make_key(ruleset_id, version)
|
|
64
|
+
self._cache[key] = (network, time.time() + ttl_seconds)
|
|
65
|
+
|
|
66
|
+
def invalidate(self, ruleset_id: int) -> None:
|
|
67
|
+
"""Invalidate all versions of a ruleset."""
|
|
68
|
+
prefix = f"{ruleset_id}:"
|
|
69
|
+
keys_to_delete = [k for k in self._cache if k.startswith(prefix)]
|
|
70
|
+
for key in keys_to_delete:
|
|
71
|
+
del self._cache[key]
|
|
72
|
+
|
|
73
|
+
def get_stats(self) -> dict[str, Any]:
|
|
74
|
+
"""Return cache statistics."""
|
|
75
|
+
total = self._stats["hits"] + self._stats["misses"]
|
|
76
|
+
hit_rate = self._stats["hits"] / total if total > 0 else 0.0
|
|
77
|
+
return {
|
|
78
|
+
"hits": self._stats["hits"],
|
|
79
|
+
"misses": self._stats["misses"],
|
|
80
|
+
"hit_rate": hit_rate,
|
|
81
|
+
"size": len(self._cache),
|
|
82
|
+
"max_size": self._max_size,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def health_check(self) -> tuple[bool, str]:
|
|
86
|
+
"""Always healthy for in-memory."""
|
|
87
|
+
if self._closed:
|
|
88
|
+
return False, "Cache adapter is closed"
|
|
89
|
+
return True, "In-memory cache is healthy"
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
"""Mark as closed."""
|
|
93
|
+
self._closed = True
|
|
94
|
+
self._cache.clear()
|
|
95
|
+
|
|
96
|
+
def reset(self) -> None:
|
|
97
|
+
"""Reset all state (for testing)."""
|
|
98
|
+
self._cache.clear()
|
|
99
|
+
self._stats = {"hits": 0, "misses": 0}
|
|
100
|
+
self._closed = False
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""YAML rule loader - load rules from a YAML file.
|
|
2
|
+
|
|
3
|
+
YAML schema: a list of rule mappings (or a single mapping) with the same
|
|
4
|
+
schema as the JSON/dict format used by ``RuleEngine.add_rules()``.
|
|
5
|
+
|
|
6
|
+
Requires: ``pip install pyyaml``
|
|
7
|
+
|
|
8
|
+
Example YAML::
|
|
9
|
+
|
|
10
|
+
- id: 1
|
|
11
|
+
name: high_value
|
|
12
|
+
priority: 10
|
|
13
|
+
action: flag_for_review
|
|
14
|
+
enabled: true
|
|
15
|
+
condition_dsl:
|
|
16
|
+
type: condition
|
|
17
|
+
field: amount
|
|
18
|
+
op: ">"
|
|
19
|
+
value: 1000
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_rules_from_yaml(path: str) -> list[dict[str, Any]]:
|
|
28
|
+
"""Load rules from a YAML file.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
path: Path to the YAML file.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
A list of rule dicts ready to pass to ``RuleEngine.add_rules()``.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ImportError: When ``pyyaml`` is not installed.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
import yaml # type: ignore[import]
|
|
41
|
+
except ImportError as exc:
|
|
42
|
+
raise ImportError(
|
|
43
|
+
"pyyaml is required for YAML rule loading. Install it with: pip install pyyaml"
|
|
44
|
+
) from exc
|
|
45
|
+
with open(path) as f:
|
|
46
|
+
rules = yaml.safe_load(f)
|
|
47
|
+
return rules if isinstance(rules, list) else [rules]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""No-operation tracer for when observability is not needed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fluxrules.ports.observability import TracerPort
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NoopTracer(TracerPort):
|
|
11
|
+
"""A no-op tracer that discards all tracing data."""
|
|
12
|
+
|
|
13
|
+
def on_evaluation_start(self, ruleset_id: int | str) -> None:
|
|
14
|
+
"""Called at the start of ruleset evaluation (no-op)."""
|
|
15
|
+
|
|
16
|
+
def on_evaluation_end(self, ruleset_id: int | str, matched_count: int) -> None:
|
|
17
|
+
"""Called at the end of ruleset evaluation (no-op)."""
|
|
18
|
+
|
|
19
|
+
def start_span(self, name: str, attributes: dict[str, Any] | None = None) -> Any:
|
|
20
|
+
"""Start a span (no-op)."""
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
def end_span(self, span: Any) -> None:
|
|
24
|
+
"""End a span (no-op)."""
|
|
25
|
+
|
|
26
|
+
def record_event(self, span: Any, name: str, attributes: dict[str, Any] | None = None) -> None:
|
|
27
|
+
"""Record an event (no-op)."""
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""SQLAlchemy-backed persistence adapter.
|
|
2
|
+
|
|
3
|
+
Isolates all ORM imports (fluxrules.api.models.*) to this adapter layer,
|
|
4
|
+
keeping the core and services layers free of database coupling.
|
|
5
|
+
|
|
6
|
+
Supports two construction modes:
|
|
7
|
+
|
|
8
|
+
1. **Lazy session binding** (preferred for DI / ServiceFactory)::
|
|
9
|
+
|
|
10
|
+
adapter = SQLAlchemyPersistenceAdapter(
|
|
11
|
+
session_provider=lambda: SessionLocal(),
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
The *session_provider* callable is invoked each time a database
|
|
15
|
+
session is needed, enabling request-scoped session management in
|
|
16
|
+
web frameworks like FastAPI.
|
|
17
|
+
|
|
18
|
+
2. **Direct session** (legacy / simple scripts)::
|
|
19
|
+
|
|
20
|
+
adapter = SQLAlchemyPersistenceAdapter(db=my_session)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
|
|
29
|
+
from sqlalchemy.orm import Session
|
|
30
|
+
|
|
31
|
+
from fluxrules.domain.models import EngineRule, Ruleset
|
|
32
|
+
from fluxrules.persistence.mappers import domain_rule_to_orm, orm_rule_to_domain
|
|
33
|
+
from fluxrules.ports.persistence import RulePersistencePort
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("fluxrules.adapters.persistence")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SQLAlchemyPersistenceAdapter(RulePersistencePort):
|
|
39
|
+
"""Concrete persistence implementation using SQLAlchemy ORM.
|
|
40
|
+
|
|
41
|
+
All ORM model imports are isolated here, so the core engine
|
|
42
|
+
and services never depend on the API/database layer directly.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
db: A pre-existing SQLAlchemy session (legacy mode).
|
|
46
|
+
session_provider: A callable returning a SQLAlchemy ``Session``.
|
|
47
|
+
When provided, the adapter calls it each time it needs a
|
|
48
|
+
session. This enables **lazy session binding** - the
|
|
49
|
+
recommended pattern for ServiceFactory / DI usage.
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If neither *db* nor *session_provider* is given.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
db: Session | None = None,
|
|
58
|
+
*,
|
|
59
|
+
session_provider: Callable[[], Session] | None = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
if db is None and session_provider is None:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"SQLAlchemyPersistenceAdapter requires either 'db' "
|
|
64
|
+
"(a Session) or 'session_provider' (a callable)"
|
|
65
|
+
)
|
|
66
|
+
self._db = db
|
|
67
|
+
self._session_provider = session_provider
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def db(self) -> Session:
|
|
71
|
+
"""Return the active database session.
|
|
72
|
+
|
|
73
|
+
When a *session_provider* was supplied at construction time the
|
|
74
|
+
provider is called each time this property is accessed, enabling
|
|
75
|
+
request-scoped or lazy session semantics.
|
|
76
|
+
"""
|
|
77
|
+
if self._session_provider is not None:
|
|
78
|
+
return self._session_provider()
|
|
79
|
+
assert self._db is not None
|
|
80
|
+
return self._db
|
|
81
|
+
|
|
82
|
+
# ── Write operations ─────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def save_rule(self, rule: EngineRule, group: str = "", created_by: int | None = None) -> int:
|
|
85
|
+
orm_rule = domain_rule_to_orm(rule, group=group, created_by=created_by)
|
|
86
|
+
self.db.add(orm_rule)
|
|
87
|
+
self.db.commit()
|
|
88
|
+
self.db.refresh(orm_rule)
|
|
89
|
+
return orm_rule.id
|
|
90
|
+
|
|
91
|
+
def save_ruleset(self, ruleset: Ruleset, created_by: int | None = None) -> list[int]:
|
|
92
|
+
ids: list[int] = []
|
|
93
|
+
for rule in ruleset.rules:
|
|
94
|
+
rid = self.save_rule(rule, group=ruleset.group, created_by=created_by)
|
|
95
|
+
ids.append(rid)
|
|
96
|
+
return ids
|
|
97
|
+
|
|
98
|
+
def update_rule(self, rule_id: int, **kwargs) -> EngineRule | None:
|
|
99
|
+
from fluxrules.api.models.rule import Rule as OrmRule
|
|
100
|
+
|
|
101
|
+
orm_rule = self.db.query(OrmRule).filter(OrmRule.id == rule_id).first()
|
|
102
|
+
if orm_rule is None:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
for key, value in kwargs.items():
|
|
106
|
+
if key == "conditions" and isinstance(value, (list, tuple)):
|
|
107
|
+
# Store a DSL tree, not a list of condition dicts: the column
|
|
108
|
+
# holds exactly one shape.
|
|
109
|
+
from fluxrules.domain.dsl.evaluator import conditions_to_dsl
|
|
110
|
+
|
|
111
|
+
orm_rule.condition_dsl = conditions_to_dsl(value)
|
|
112
|
+
elif key == "condition_dsl":
|
|
113
|
+
orm_rule.condition_dsl = value
|
|
114
|
+
elif key == "actions" and isinstance(value, (list, tuple)):
|
|
115
|
+
orm_rule.action = "\n".join(value)
|
|
116
|
+
elif hasattr(orm_rule, key):
|
|
117
|
+
setattr(orm_rule, key, value)
|
|
118
|
+
|
|
119
|
+
orm_rule.updated_at = datetime.now(timezone.utc)
|
|
120
|
+
self.db.commit()
|
|
121
|
+
self.db.refresh(orm_rule)
|
|
122
|
+
return orm_rule_to_domain(orm_rule)
|
|
123
|
+
|
|
124
|
+
def delete_rule(self, rule_id: int) -> bool:
|
|
125
|
+
from fluxrules.api.models.rule import Rule as OrmRule
|
|
126
|
+
|
|
127
|
+
orm_rule = self.db.query(OrmRule).filter(OrmRule.id == rule_id).first()
|
|
128
|
+
if orm_rule is None:
|
|
129
|
+
return False
|
|
130
|
+
self.db.delete(orm_rule)
|
|
131
|
+
self.db.commit()
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
# ── Read operations ──────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
def load_rule(self, rule_id: int) -> EngineRule | None:
|
|
137
|
+
from fluxrules.api.models.rule import Rule as OrmRule
|
|
138
|
+
|
|
139
|
+
orm_rule = self.db.query(OrmRule).filter(OrmRule.id == rule_id).first()
|
|
140
|
+
if orm_rule is None:
|
|
141
|
+
return None
|
|
142
|
+
return orm_rule_to_domain(orm_rule)
|
|
143
|
+
|
|
144
|
+
def load_all_rules(self) -> list[EngineRule]:
|
|
145
|
+
from fluxrules.api.models.rule import Rule as OrmRule
|
|
146
|
+
|
|
147
|
+
orm_rules = self.db.query(OrmRule).order_by(OrmRule.priority.desc()).all()
|
|
148
|
+
return [orm_rule_to_domain(r) for r in orm_rules]
|
|
149
|
+
|
|
150
|
+
def load_ruleset(self, group: str) -> Ruleset:
|
|
151
|
+
from fluxrules.api.models.rule import Rule as OrmRule
|
|
152
|
+
|
|
153
|
+
orm_rules = (
|
|
154
|
+
self.db.query(OrmRule)
|
|
155
|
+
.filter(OrmRule.group == group)
|
|
156
|
+
.order_by(OrmRule.priority.desc())
|
|
157
|
+
.all()
|
|
158
|
+
)
|
|
159
|
+
rules = [orm_rule_to_domain(r) for r in orm_rules]
|
|
160
|
+
return Ruleset(group=group, rules=tuple(rules))
|
|
161
|
+
|
|
162
|
+
def load_all_rulesets(self) -> dict[str, Ruleset]:
|
|
163
|
+
all_rules = self.load_all_rules()
|
|
164
|
+
groups: dict[str, list[EngineRule]] = {}
|
|
165
|
+
for r in all_rules:
|
|
166
|
+
g = r.group or ""
|
|
167
|
+
groups.setdefault(g, []).append(r)
|
|
168
|
+
return {g: Ruleset(group=g, rules=tuple(rs)) for g, rs in groups.items()}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""In-memory queue adapter for testing and embedded mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from fluxrules.ports.queue import QueuePort
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InMemoryQueueAdapter(QueuePort):
|
|
14
|
+
"""In-memory queue implementation for testing and single-process mode.
|
|
15
|
+
|
|
16
|
+
Thread-safe for single-process usage. Does not persist across restarts.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
self._topics: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
|
21
|
+
self._offsets: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int))
|
|
22
|
+
self._closed = False
|
|
23
|
+
|
|
24
|
+
def enqueue(
|
|
25
|
+
self,
|
|
26
|
+
topic: str,
|
|
27
|
+
partition_key: str,
|
|
28
|
+
message: dict[str, Any],
|
|
29
|
+
priority: int = 0,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Enqueue a message to a topic."""
|
|
32
|
+
if self._closed:
|
|
33
|
+
raise RuntimeError("Queue adapter is closed")
|
|
34
|
+
|
|
35
|
+
message_id = str(uuid.uuid4())
|
|
36
|
+
enriched = {
|
|
37
|
+
"id": message_id,
|
|
38
|
+
"type": message.get("type", "unknown"),
|
|
39
|
+
"payload": message.get("payload", {}),
|
|
40
|
+
"source": message.get("source", "api"),
|
|
41
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
42
|
+
"priority": priority,
|
|
43
|
+
"partition_key": partition_key,
|
|
44
|
+
"retry_count": 0,
|
|
45
|
+
}
|
|
46
|
+
self._topics[topic].append(enriched)
|
|
47
|
+
return message_id
|
|
48
|
+
|
|
49
|
+
def dequeue(
|
|
50
|
+
self,
|
|
51
|
+
topic: str,
|
|
52
|
+
consumer_group: str,
|
|
53
|
+
timeout_ms: int = 1000,
|
|
54
|
+
) -> list[dict[str, Any]]:
|
|
55
|
+
"""Dequeue messages from a topic."""
|
|
56
|
+
if self._closed:
|
|
57
|
+
raise RuntimeError("Queue adapter is closed")
|
|
58
|
+
|
|
59
|
+
current_offset = self._offsets[topic][consumer_group]
|
|
60
|
+
messages = self._topics[topic][current_offset:]
|
|
61
|
+
|
|
62
|
+
result = []
|
|
63
|
+
for i, msg in enumerate(messages):
|
|
64
|
+
result.append(
|
|
65
|
+
{
|
|
66
|
+
"payload": msg,
|
|
67
|
+
"topic": topic,
|
|
68
|
+
"partition": 0,
|
|
69
|
+
"offset": current_offset + i,
|
|
70
|
+
"timestamp": msg["timestamp"],
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
def commit_offset(self, topic: str, partition: int, offset: int, consumer_group: str) -> None:
|
|
77
|
+
"""Commit offset after successful processing."""
|
|
78
|
+
self._offsets[topic][consumer_group] = offset + 1
|
|
79
|
+
|
|
80
|
+
def peek(self, topic: str, partition: int, offset: int) -> dict[str, Any] | None:
|
|
81
|
+
"""Peek at a message without consuming."""
|
|
82
|
+
messages = self._topics.get(topic, [])
|
|
83
|
+
if 0 <= offset < len(messages):
|
|
84
|
+
return messages[offset]
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
def health_check(self) -> tuple[bool, str]:
|
|
88
|
+
"""Always healthy for in-memory."""
|
|
89
|
+
if self._closed:
|
|
90
|
+
return False, "Queue adapter is closed"
|
|
91
|
+
return True, "In-memory queue is healthy"
|
|
92
|
+
|
|
93
|
+
def close(self) -> None:
|
|
94
|
+
"""Mark as closed."""
|
|
95
|
+
self._closed = True
|
|
96
|
+
|
|
97
|
+
def reset(self) -> None:
|
|
98
|
+
"""Reset all state (for testing)."""
|
|
99
|
+
self._topics.clear()
|
|
100
|
+
self._offsets.clear()
|
|
101
|
+
self._closed = False
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Repository adapter implementations."""
|
|
2
|
+
|
|
3
|
+
from fluxrules.adapters.repository.database import (
|
|
4
|
+
DatabaseRulesetRepository,
|
|
5
|
+
)
|
|
6
|
+
from fluxrules.adapters.repository.in_memory import (
|
|
7
|
+
InMemoryExecutionStore,
|
|
8
|
+
InMemoryRulesetRepository,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DatabaseRulesetRepository",
|
|
13
|
+
"InMemoryExecutionStore",
|
|
14
|
+
"InMemoryRulesetRepository",
|
|
15
|
+
]
|