vulcan-core 1.1.4__py3-none-any.whl → 1.2.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.

Potentially problematic release.


This version of vulcan-core might be problematic. Click here for more details.

@@ -0,0 +1,595 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2025 Latchfield Technologies http://latchfield.com
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from datetime import UTC, datetime
9
+ from functools import partial
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ import yaml
13
+
14
+ from vulcan_core.conditions import AICondition, CompoundCondition, Condition
15
+
16
+ if TYPE_CHECKING: # pragma: no cover - not used at runtime
17
+ from collections.abc import Mapping
18
+ from vulcan_core.conditions import Expression
19
+ from vulcan_core.engine import Rule
20
+ from vulcan_core.models import ActionReturn, Fact
21
+
22
+ Primitive = int | float | bool | str | bytes | complex
23
+
24
+
25
+ class ReportGenerationError(RuntimeError):
26
+ """Raised when there is an error generating a report from the rule engine."""
27
+
28
+
29
+ class StopWatchError(RuntimeError):
30
+ """Raised when there is an error with the stopwatch operations."""
31
+
32
+
33
+ @dataclass(slots=True)
34
+ class StopWatch:
35
+ """A simple stopwatch for timing operations."""
36
+
37
+ _duration: float | None = field(default=None, init=False)
38
+ _timestamp: datetime | None = field(default=None, init=False)
39
+ _start_time: float | None = field(default=None, init=False)
40
+
41
+ @property
42
+ def duration(self) -> float:
43
+ """Get the duration between start and stopwatch in seconds."""
44
+ if self._duration is None:
45
+ msg = "No stopwatch measurement. Call start() then stop() before accessing duration."
46
+ raise StopWatchError(msg)
47
+
48
+ return self._duration
49
+
50
+ @property
51
+ def timestamp(self) -> datetime:
52
+ """Get the timestamp of when the stopwatch was started."""
53
+ if self._timestamp is None:
54
+ msg = "Stopwatch not started. Call start() first."
55
+ raise StopWatchError(msg)
56
+
57
+ return self._timestamp
58
+
59
+ def start(self) -> None:
60
+ """Start or restart the stopwatch."""
61
+ self._start_time = time.time()
62
+ self._timestamp = datetime.now(UTC)
63
+ self._duration = None
64
+
65
+ def stop(self) -> None:
66
+ """Stop the stopwatch and calculate duration."""
67
+ if self._start_time is None:
68
+ msg = "Stopwatch not started. Call start() first."
69
+ raise StopWatchError(msg)
70
+
71
+ self._duration = time.time() - self._start_time
72
+ self._start_time = None
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class RuleMatch:
77
+ """Represents a single rule match within an iteration."""
78
+
79
+ rule: str # Format: "id:name"
80
+ timestamp: datetime
81
+ elapsed: float # seconds with millisecond precision
82
+ evaluation: str # String representation of the evaluation
83
+ consequences: tuple[RuleConsequence, ...] = field(default_factory=tuple)
84
+ warnings: tuple[str, ...] = field(default_factory=tuple)
85
+ context: tuple[RuleContext, ...] = field(default_factory=tuple)
86
+ rationale: str | None = None
87
+
88
+ def to_dict(self) -> dict[str, Any]:
89
+ """Convert to dictionary for YAML serialization."""
90
+ # Format timestamp as 'YYYY-MM-DDTHH:MM:SS(.ffffff)Z' (no offset)
91
+ ts = self.timestamp
92
+ if ts.tzinfo is not None:
93
+ ts = ts.astimezone(UTC).replace(tzinfo=None)
94
+ result = {
95
+ "rule": self.rule,
96
+ "timestamp": ts.isoformat() + "Z",
97
+ "elapsed": round(self.elapsed, 3),
98
+ "evaluation": self.evaluation,
99
+ }
100
+
101
+ # Handle consequences
102
+ if self.consequences:
103
+ consequences_dict = {}
104
+ for consequence in self.consequences:
105
+ consequences_dict.update(consequence.to_dict())
106
+ result["consequences"] = consequences_dict
107
+ else:
108
+ result["consequences"] = None
109
+
110
+ # Add optional fields only if they have content
111
+ if self.warnings:
112
+ result["warnings"] = list(self.warnings)
113
+
114
+ if self.context:
115
+ context_list = [ctx.to_dict() for ctx in self.context]
116
+ result["context"] = context_list
117
+
118
+ if self.rationale:
119
+ result["rationale"] = self.rationale
120
+
121
+ return result
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class FactRecord:
126
+ """Tracks fact attribute changes within an iteration."""
127
+
128
+ rule_id: str
129
+ rule_name: str
130
+ value: Any
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class Iteration:
135
+ """Tracks iteration data during execution and provides serialization for reporting."""
136
+
137
+ id: int = field(default=-1)
138
+ stopwatch: StopWatch = field(default_factory=StopWatch, init=False)
139
+ matched_rules: list[RuleMatch] = field(default_factory=list, init=False)
140
+ updated_facts: dict[str, FactRecord] = field(default_factory=dict, init=False)
141
+
142
+ def __post_init__(self):
143
+ self.stopwatch.start()
144
+
145
+ def to_dict(self) -> dict[str, Any]:
146
+ """Convert to dictionary for YAML serialization."""
147
+ # Format timestamp as 'YYYY-MM-DDTHH:MM:SS(.ffffff)Z' (no offset)
148
+ ts = self.stopwatch.timestamp
149
+ if ts.tzinfo is not None:
150
+ ts = ts.astimezone(UTC).replace(tzinfo=None)
151
+ return {
152
+ "id": self.id,
153
+ "timestamp": ts.isoformat() + "Z",
154
+ "elapsed": round(self.stopwatch.duration, 3),
155
+ "matches": [match.to_dict() for match in self.matched_rules],
156
+ }
157
+
158
+
159
+ @dataclass(frozen=True, slots=True)
160
+ class RuleConsequence:
161
+ """Represents a consequences of a rule action."""
162
+
163
+ fact_name: str
164
+ attribute_name: str
165
+ value: Primitive | None = None
166
+
167
+ def to_dict(self) -> dict[str, Primitive | None]:
168
+ """Convert to dictionary for YAML serialization."""
169
+
170
+ if self.attribute_name:
171
+ return {f"{self.fact_name}.{self.attribute_name}": self.value}
172
+ else:
173
+ return {self.fact_name: self.value}
174
+
175
+
176
+ @dataclass(frozen=True, slots=True)
177
+ class RuleContext:
178
+ """Represents context information for values referenced in conditions."""
179
+
180
+ fact_attribute: str
181
+ value: str
182
+
183
+ def to_dict(self) -> dict[str, str]:
184
+ """Convert to dictionary for YAML serialization."""
185
+ return {self.fact_attribute: self.value}
186
+
187
+
188
+ @dataclass(frozen=True, slots=True)
189
+ class EvaluationReport:
190
+ """Represents the complete evaluation report."""
191
+
192
+ iterations: list[Iteration] = field(default_factory=list)
193
+
194
+ def to_dict(self) -> dict[str, Any]:
195
+ """Convert to dictionary for YAML serialization."""
196
+ return {"report": {"iterations": [iteration.to_dict() for iteration in self.iterations]}}
197
+
198
+ def to_yaml(self) -> str:
199
+ """Convert the report to YAML format."""
200
+
201
+ # Create a custom representer for None values
202
+ def represent_none(dumper: yaml.SafeDumper, data: None) -> yaml.ScalarNode:
203
+ return dumper.represent_scalar("tag:yaml.org,2002:str", "None")
204
+
205
+ # Create a custom dumper to avoid global state issues
206
+ class CustomDumper(yaml.SafeDumper):
207
+ pass
208
+
209
+ # Add the custom representer to our custom dumper
210
+ CustomDumper.add_representer(type(None), represent_none)
211
+
212
+ # Also prevent hard-line wrapping by setting a high width
213
+ return yaml.dump(
214
+ self.to_dict(),
215
+ Dumper=CustomDumper,
216
+ default_flow_style=False,
217
+ allow_unicode=True,
218
+ sort_keys=False,
219
+ width=1000000, # Very large width to prevent wrapping
220
+ )
221
+
222
+
223
+ @dataclass(slots=True)
224
+ class ActionReporter:
225
+ """Determines the consequences of an rule's action."""
226
+
227
+ action_result: ActionReturn | None
228
+ facts_dict: Mapping[str, Fact]
229
+ consequences: list[RuleConsequence] = field(default_factory=list, init=False)
230
+
231
+ def __post_init__(self):
232
+ self._transform()
233
+
234
+ def _transform(self):
235
+ """Transform the action result(s) into consequences."""
236
+ if self.action_result is None:
237
+ return
238
+
239
+ if isinstance(self.action_result, tuple):
240
+ # Handle multiple action results
241
+ for item in self.action_result:
242
+ self.consequences.extend(self._fact_to_consequence(item))
243
+ else:
244
+ self.consequences.extend(self._fact_to_consequence(self.action_result))
245
+
246
+ def _fact_to_consequence(self, fact: Fact | partial[Fact]) -> list[RuleConsequence]:
247
+ """Extract consequences from a single fact or a partial."""
248
+ consequences = []
249
+
250
+ if isinstance(fact, partial):
251
+ # Iterate over a partial's keywords to resolve attributes
252
+ fact_name = fact.func.__name__
253
+ attributes = fact.keywords.items()
254
+ else:
255
+ # For complete fact updates, report all attributes include default values
256
+ fact_name = fact.__class__.__name__
257
+ attributes = [(attr_name, getattr(fact, attr_name)) for attr_name in fact.__annotations__]
258
+
259
+ # Dereference values and append to the consequences list
260
+ for attr_name, value in attributes:
261
+ attr_value = self._dereference(value) if isinstance(fact, partial) else value
262
+ consequences.append(RuleConsequence(fact_name, attr_name, attr_value))
263
+
264
+ return consequences
265
+
266
+ def _dereference(self, value: Any) -> Primitive:
267
+ """Detects whether the value is reference and resolves it to the actual value."""
268
+
269
+ # FIXME: This needs to be replaced with a better typed solution. This will catch unintended str value cases.
270
+ if isinstance(value, str) and value.startswith("{") and value.endswith("}"):
271
+ # Assume this is a reference, such as "{FactName.attribute}"
272
+ template_content = value[1:-1] # Remove curly braces
273
+
274
+ if "." in template_content:
275
+ fact_name, attr_name = template_content.split(".", 1)
276
+ if fact_name in self.facts_dict:
277
+ fact_instance = self.facts_dict[fact_name]
278
+ return getattr(fact_instance, attr_name)
279
+
280
+ # If the value is not a primitive type, convert it to string
281
+ if not isinstance(value, Primitive):
282
+ value = str(value)
283
+
284
+ return value
285
+
286
+
287
+ @dataclass(frozen=True, slots=True)
288
+ class RuleFormatter:
289
+ """Formats rule data as strings for reporting."""
290
+
291
+ condition: Expression
292
+ fact_map: Mapping[str, Fact]
293
+ result: bool | None = None
294
+
295
+ _expression: str = field(default="", init=False)
296
+ _ai_rationale: str | None = field(default=None, init=False)
297
+ _context: tuple[RuleContext, ...] = field(default_factory=tuple, init=False)
298
+
299
+ @property
300
+ def expression(self) -> str:
301
+ """Return the formatted rule evaluation expression."""
302
+ return self._expression
303
+
304
+ @property
305
+ def ai_rationale(self) -> str | None:
306
+ """Return the AI rationale for the condition, if applicable."""
307
+ return self._ai_rationale
308
+
309
+ @property
310
+ def context(self) -> tuple[RuleContext, ...]:
311
+ """Return the context for long strings or multiline values."""
312
+ return self._context
313
+
314
+ def __post_init__(self):
315
+ expression = self._format_expression(self.condition, result=self.result)
316
+ ai_rationale = self._format_ai_rationale(self.condition)
317
+ context = self._format_context()
318
+
319
+ object.__setattr__(self, "_expression", expression)
320
+ object.__setattr__(self, "_ai_rationale", ai_rationale)
321
+ object.__setattr__(self, "_context", context)
322
+
323
+ def _format_context(self) -> tuple[RuleContext, ...]:
324
+ """Extract context for long strings (>25 chars or multiline) from evaluation - input data only."""
325
+ context = []
326
+
327
+ # Check condition facts for long strings - only extract input data for conditions
328
+ for fact_ref in self.condition.facts:
329
+ class_name, attr_name = fact_ref.split(".", 1)
330
+ if class_name in self.fact_map:
331
+ fact_instance = self.fact_map[class_name]
332
+ actual_value = getattr(fact_instance, attr_name)
333
+
334
+ if self._should_extract_to_context(actual_value):
335
+ context.append(RuleContext(fact_ref, actual_value))
336
+
337
+ return tuple(context)
338
+
339
+ def _should_extract_to_context(self, value) -> bool:
340
+ """Determine if a value should be extracted to context."""
341
+ if isinstance(value, str):
342
+ return len(value) > 25 or "\n" in value
343
+ return False
344
+
345
+ def _format_expression(self, condition: Expression, *, result: bool | None) -> str:
346
+ """Format the evaluation string showing the condition with fact values."""
347
+
348
+ # Format based on condition type
349
+ if isinstance(condition, AICondition):
350
+ expr = self._format_ai_condition(condition)
351
+ elif isinstance(condition, CompoundCondition):
352
+ expr = self._format_compound_condition(condition)
353
+ elif isinstance(condition, Condition):
354
+ expr = self._format_simple_condition(condition)
355
+ else:
356
+ msg = f"Unsupported expression type: {type(condition).__name__}"
357
+ raise ReportGenerationError(msg)
358
+
359
+ # Apply inversion if needed
360
+ if condition.inverted:
361
+ expr = f"not({expr})"
362
+
363
+ return f"{result} = {expr}"
364
+
365
+ def _format_ai_condition(self, condition: AICondition) -> str:
366
+ """Format an AI condition with its template."""
367
+ # For AI conditions, show the inquiry with fact values substituted
368
+ inquiry = condition.inquiry
369
+ for fact_ref in condition.facts:
370
+ class_name, attr_name = fact_ref.split(".", 1)
371
+ if class_name in self.fact_map:
372
+ fact_instance = self.fact_map[class_name]
373
+ actual_value = getattr(fact_instance, attr_name)
374
+
375
+ # Show the value inline if it is not a long string or multiline
376
+ if not self._should_extract_to_context(actual_value):
377
+ placeholder = f"{{{class_name}.{attr_name}}}"
378
+ inquiry = inquiry.replace(placeholder, f"{{{class_name}.{attr_name}|{actual_value}|}}")
379
+
380
+ return f"{inquiry}"
381
+
382
+ def _format_ai_rationale(self, condition: Expression) -> str | None:
383
+ """Extract rationale from AI conditions after evaluation."""
384
+
385
+ if isinstance(condition, AICondition):
386
+ return condition.last_rationale()
387
+ elif isinstance(condition, CompoundCondition):
388
+ # Check left and right sides for AI conditions
389
+ left_rationale = self._format_ai_rationale(condition.left)
390
+ right_rationale = self._format_ai_rationale(condition.right)
391
+
392
+ # Combine rationales if both exist
393
+ if left_rationale and right_rationale:
394
+ return f"{left_rationale}; {right_rationale}"
395
+ elif left_rationale:
396
+ return left_rationale
397
+ elif right_rationale:
398
+ return right_rationale
399
+
400
+ return None
401
+
402
+ def _format_simple_condition(self, condition: Condition) -> str:
403
+ """Format a simple lambda-based condition."""
404
+
405
+ expression = ""
406
+
407
+ if condition.func.__name__ != "<lambda>":
408
+ # Format decoratored function expressions
409
+ expression = f"{condition.func.__name__}()"
410
+
411
+ if condition.evaluated():
412
+ expression += f"|{condition.last_result()}|"
413
+ else:
414
+ expression += "|-|"
415
+
416
+ else:
417
+ # Format lambda expressions
418
+ source = condition.func.__source__
419
+ expression = source.split("lambda:")[1].strip()
420
+
421
+ # Replace fact references with values
422
+ for fact_ref in condition.facts:
423
+ class_name, attr_name = fact_ref.split(".", 1)
424
+ if class_name in self.fact_map:
425
+ fact_instance = self.fact_map[class_name]
426
+ actual_value = getattr(fact_instance, attr_name)
427
+ replacement = f"{class_name}.{attr_name}"
428
+
429
+ # Append the value if it is not a long string or multiline
430
+ if not self._should_extract_to_context(actual_value):
431
+ replacement += f"|{actual_value}|"
432
+
433
+ expression = expression.replace(f"{class_name}.{attr_name}", replacement)
434
+
435
+ # Wrap lambda expressions in parentheses
436
+ expression = f"({expression})"
437
+
438
+ return expression
439
+
440
+ def _format_compound_condition(self, condition: CompoundCondition) -> str:
441
+ """Format a compound condition with operators."""
442
+
443
+ # Evaluate each side to get the actual boolean results
444
+ left_result = condition.left.last_result()
445
+ right_result = condition.right.last_result()
446
+
447
+ # Format each side with their actual results
448
+ left_str = self._format_expression(condition.left, result=left_result)
449
+ right_str = self._format_expression(condition.right, result=right_result)
450
+
451
+ # Keep just the expression part (after the "= ")
452
+ left_expr, right_expr = [
453
+ value.split(" = ", 1)[1] if " = " in value else value for value in (left_str, right_str)
454
+ ]
455
+
456
+ # Format and return the compound expression
457
+ return f"{left_expr} {condition.operator.name.lower()} {right_expr}"
458
+
459
+
460
+ @dataclass(slots=True)
461
+ class Auditor:
462
+ """
463
+ Facility to capture runtime iteration and rule state information.
464
+ """
465
+
466
+ _iteration: Iteration = field(default_factory=Iteration, init=False)
467
+ _evaluation_report: EvaluationReport | None = field(default=None, init=False)
468
+ _rule_stopwatch: StopWatch = field(default_factory=StopWatch, init=False)
469
+
470
+ def evaluation_reset(self) -> None:
471
+ """Reset the reporter to start a new evaluation report."""
472
+ self._evaluation_report = EvaluationReport()
473
+
474
+ def generate_yaml_report(self) -> str:
475
+ """Generate YAML report of the tracked evaluation."""
476
+ if not self._evaluation_report:
477
+ msg = "No evaluation report available. Use evaluate(audit=True) to enable tracing."
478
+ raise RuntimeError(msg)
479
+
480
+ return self._evaluation_report.to_yaml()
481
+
482
+ def iteration_start(self) -> None:
483
+ """Start timing and auditing for a new iteration."""
484
+ self._iteration = Iteration(id=self._iteration.id + 1)
485
+
486
+ def iteration_end(self) -> None:
487
+ """End iteration timing and create report iteration."""
488
+ self._iteration.stopwatch.stop()
489
+
490
+ if self._iteration.matched_rules and self._evaluation_report is not None:
491
+ self._evaluation_report.iterations.append(self._iteration)
492
+
493
+ def rule_start(self) -> None:
494
+ """Start timing and auditing for rule execution."""
495
+ self._rule_stopwatch.start()
496
+
497
+ def rule_end(
498
+ self,
499
+ rule: Rule,
500
+ result: ActionReturn | None,
501
+ working_memory: Mapping[str, Fact],
502
+ *,
503
+ condition_result: bool,
504
+ ) -> None:
505
+ """
506
+ Create a RuleMatch from pre-evaluated rule data.
507
+
508
+ Args:
509
+ rule: The rule that was executed
510
+ resolved_facts: Facts that were resolved for the rule
511
+ result: The result of executing the action (or None if no action)
512
+ working_memory: Current facts dictionary for context
513
+ condition_result: The boolean result of the rule condition evaluation
514
+ """
515
+
516
+ self._rule_stopwatch.stop()
517
+ rule_name = rule.name or "None"
518
+ rule_id = str(rule.id)[:8]
519
+
520
+ # Process action results and track consequences
521
+ action = ActionReporter(result, working_memory)
522
+ warnings = ()
523
+
524
+ # If there was an action, generate warnings and update changed attribute tracking
525
+ if result is not None:
526
+ warnings = self._generate_warnings(result, rule_id)
527
+ self._update_fact_tracking(action.consequences, rule)
528
+
529
+ # Format various report components
530
+ formatter = RuleFormatter(rule.when, working_memory, result=condition_result)
531
+
532
+ # Add tne rule match to the report
533
+ self._iteration.matched_rules.append(
534
+ RuleMatch(
535
+ rule=f"{rule_id}:{rule_name}",
536
+ timestamp=self._rule_stopwatch.timestamp,
537
+ elapsed=self._rule_stopwatch.duration,
538
+ evaluation=formatter.expression,
539
+ consequences=tuple(action.consequences),
540
+ warnings=warnings,
541
+ context=formatter.context,
542
+ rationale=formatter.ai_rationale,
543
+ )
544
+ )
545
+
546
+ def _generate_warnings(self, action_result: ActionReturn | None, rule_id: str) -> tuple[str, ...]:
547
+ """Check for and report rule warnings"""
548
+ if action_result is None:
549
+ return ()
550
+
551
+ # Handle tuple of results (multiple actions)
552
+ warnings = []
553
+ results = action_result if isinstance(action_result, tuple) else (action_result,)
554
+
555
+ for result in results:
556
+ # Complete Fact replacement: not a partial
557
+ if not isinstance(result, partial):
558
+ fact_name = result.__class__.__name__
559
+ warning_msg = (
560
+ f"Fact Replacement | Rule:{rule_id} consequence replaces "
561
+ f"({fact_name}), potentially altering unintended attributes. "
562
+ f"Consider using a partial update to ensure only intended changes."
563
+ )
564
+ warnings.append(warning_msg)
565
+ else:
566
+ # Partial update: check for attribute overrides
567
+ fact_name = result.func.__name__
568
+ for attr_name, value in result.keywords.items():
569
+ fact_attr = f"{fact_name}.{attr_name}"
570
+ if fact_attr in self._iteration.updated_facts:
571
+ prev_fact_tracker = self._iteration.updated_facts[fact_attr]
572
+ warning_msg = (
573
+ f"Rule Ordering | Rule:{prev_fact_tracker.rule_id} consequence "
574
+ f"({fact_name}.{attr_name}|{prev_fact_tracker.value}|) "
575
+ f"was overridden by Rule:{rule_id} "
576
+ f"({fact_name}.{attr_name}|{value}|) "
577
+ f"within the same iteration"
578
+ )
579
+ warnings.append(warning_msg)
580
+ return tuple(warnings)
581
+
582
+ def _update_fact_tracking(
583
+ self,
584
+ consequences: list[RuleConsequence],
585
+ current_rule: Rule,
586
+ ) -> None:
587
+ """Update the attribute changes tracker with new consequences."""
588
+ rule_id = str(current_rule.id)[:8]
589
+ rule_name = current_rule.name or "None"
590
+
591
+ for consequence in consequences:
592
+ if consequence.attribute_name:
593
+ # This is a partial attribute update
594
+ fact_attr = f"{consequence.fact_name}.{consequence.attribute_name}"
595
+ self._iteration.updated_facts[fact_attr] = FactRecord(rule_id, rule_name, consequence.value)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: vulcan-core
3
- Version: 1.1.4
3
+ Version: 1.2.0
4
4
  Summary: AI-Hybrid Rules Engine for Logical Reasoning.
5
5
  License: Apache-2.0
6
6
  Keywords: rules,logic,reasoning,ai,artificial intelligence,RAG,LLM
@@ -15,10 +15,11 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
15
  Provides-Extra: openai
16
16
  Requires-Dist: langchain ; extra == "openai"
17
17
  Requires-Dist: langchain-openai ; extra == "openai"
18
- Requires-Dist: pydantic (>=2.11.5,<2.12.0)
18
+ Requires-Dist: pydantic (>=2.11.7,<3.0.0)
19
+ Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
19
20
  Project-URL: Documentation, https://latchfield.com/vulcan/docs
20
21
  Project-URL: Homepage, https://latchfield.com/vulcan
21
- Project-URL: Repository, https://github.com/latchfield/vulcan_core
22
+ Project-URL: Repository, https://github.com/latchfield/vulcan-core
22
23
  Description-Content-Type: text/markdown
23
24
 
24
25
  <!-- SPDX-License-Identifier: Apache-2.0 -->
@@ -0,0 +1,13 @@
1
+ vulcan_core/__init__.py,sha256=pjCnmbMjrsp672WXRQeOV0aSKUEoA_mj0o7q_ouMWs8,1057
2
+ vulcan_core/actions.py,sha256=JeX71MOsNww234vFFJAPTY0kCz-1AhVVZFyrVArKwno,1009
3
+ vulcan_core/ast_utils.py,sha256=U862t03zZOlJzNTYYx4LVtOufPAqxpPB9LjoX5bMGDk,21154
4
+ vulcan_core/conditions.py,sha256=jGr83f3ve6hesltWbkMRHQoeg7wxx_GOyMNYxjomRho,18794
5
+ vulcan_core/engine.py,sha256=W2ki0zR5NuCcxKrM8ii_1uABFAXczIM5sWUNxTJu6dY,11386
6
+ vulcan_core/models.py,sha256=XzeKih2WzKB6Ql_EvAeuVqulrBOmK_Of-0JivATCXaI,8972
7
+ vulcan_core/reporting.py,sha256=p7s5YaGchXdpOHmFLlCpdGppjvWHME5r7iW9DJvPaMQ,22660
8
+ vulcan_core/util.py,sha256=Uq5uWhrfWd8fNv6IeeTFZRGeLBAECPZUx63UjbbSMrA,3420
9
+ vulcan_core-1.2.0.dist-info/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
10
+ vulcan_core-1.2.0.dist-info/METADATA,sha256=aBN7VyUmtKcns3jeupbTzFtJExfmvosVDO6nDcixCB8,4463
11
+ vulcan_core-1.2.0.dist-info/NOTICE,sha256=UN1_Gd_Snmu8T62mxExNUdIF2o7DMdGu8bI8rKqhVnc,244
12
+ vulcan_core-1.2.0.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
13
+ vulcan_core-1.2.0.dist-info/RECORD,,
@@ -1,12 +0,0 @@
1
- vulcan_core/__init__.py,sha256=pjCnmbMjrsp672WXRQeOV0aSKUEoA_mj0o7q_ouMWs8,1057
2
- vulcan_core/actions.py,sha256=RO5w5X-drxtDY_mVv0xR2njasWkGPt1AZo9RXsBi8X0,917
3
- vulcan_core/ast_utils.py,sha256=E0QOr2t49U8kCKtl52KTI8GJCTuBBWYfVLwCzRjZFn0,21340
4
- vulcan_core/conditions.py,sha256=ZK4plEO2dB7gq0esroEhL29naB_qAsoU4AVSv0rXClk,15670
5
- vulcan_core/engine.py,sha256=WjayTDEjKaIEVkkSZyDjdbu1Xy1XIvPewI83l6Sjo9g,9672
6
- vulcan_core/models.py,sha256=7um3u-rAy9gg2otTnZFGlKfHJKfsvGEosU3mGq_0jyg,8964
7
- vulcan_core/util.py,sha256=Uq5uWhrfWd8fNv6IeeTFZRGeLBAECPZUx63UjbbSMrA,3420
8
- vulcan_core-1.1.4.dist-info/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
9
- vulcan_core-1.1.4.dist-info/METADATA,sha256=ppV9WmJ7nFE7zBaBbtkCK7VGAaZCM2rD0I35Ck6OtfY,4425
10
- vulcan_core-1.1.4.dist-info/NOTICE,sha256=UN1_Gd_Snmu8T62mxExNUdIF2o7DMdGu8bI8rKqhVnc,244
11
- vulcan_core-1.1.4.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
12
- vulcan_core-1.1.4.dist-info/RECORD,,