soleprint init commit

This commit is contained in:
buenosairesam
2025-12-24 05:38:37 -03:00
commit 329c401ff5
96 changed files with 11564 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Gherkin integration for tester."""

View File

@@ -0,0 +1,175 @@
"""
Map tests to Gherkin scenarios based on metadata.
Tests can declare their Gherkin metadata via docstrings:
```python
def test_coverage_check(self):
'''
Feature: Reservar turno veterinario
Scenario: Verificar cobertura en zona disponible
Tags: @smoke @coverage
'''
```
Or via class docstrings:
```python
class TestCoverageFlow(ContractHTTPTestCase):
"""
Feature: Reservar turno veterinario
Tags: @coverage
"""
```
"""
import re
from typing import Optional
from dataclasses import dataclass
@dataclass
class TestGherkinMetadata:
"""Gherkin metadata extracted from a test."""
feature: Optional[str] = None
scenario: Optional[str] = None
tags: list[str] = None
def __post_init__(self):
if self.tags is None:
self.tags = []
def extract_gherkin_metadata(docstring: Optional[str]) -> TestGherkinMetadata:
"""
Extract Gherkin metadata from a test docstring.
Looks for:
- Feature: <name>
- Scenario: <name>
- Tags: @tag1 @tag2
Args:
docstring: Test or class docstring
Returns:
TestGherkinMetadata with extracted info
"""
if not docstring:
return TestGherkinMetadata()
# Extract Feature
feature = None
feature_match = re.search(r"Feature:\s*(.+)", docstring)
if feature_match:
feature = feature_match.group(1).strip()
# Extract Scenario (also try Spanish: Escenario)
scenario = None
scenario_match = re.search(r"(Scenario|Escenario):\s*(.+)", docstring)
if scenario_match:
scenario = scenario_match.group(2).strip()
# Extract Tags
tags = []
tags_match = re.search(r"Tags:\s*(.+)", docstring)
if tags_match:
tags_str = tags_match.group(1).strip()
tags = re.findall(r"@[\w-]+", tags_str)
return TestGherkinMetadata(
feature=feature,
scenario=scenario,
tags=tags
)
def has_gherkin_metadata(docstring: Optional[str]) -> bool:
"""Check if a docstring contains Gherkin metadata."""
if not docstring:
return False
return bool(
re.search(r"Feature:\s*", docstring) or
re.search(r"Scenario:\s*", docstring) or
re.search(r"Escenario:\s*", docstring) or
re.search(r"Tags:\s*@", docstring)
)
def match_test_to_feature(
test_metadata: TestGherkinMetadata,
feature_names: list[str]
) -> Optional[str]:
"""
Match a test's feature metadata to an actual feature name.
Uses fuzzy matching if exact match not found.
Args:
test_metadata: Extracted test metadata
feature_names: List of available feature names
Returns:
Matched feature name or None
"""
if not test_metadata.feature:
return None
# Exact match
if test_metadata.feature in feature_names:
return test_metadata.feature
# Case-insensitive match
test_feature_lower = test_metadata.feature.lower()
for feature_name in feature_names:
if feature_name.lower() == test_feature_lower:
return feature_name
# Partial match (feature name contains test feature or vice versa)
for feature_name in feature_names:
if test_feature_lower in feature_name.lower():
return feature_name
if feature_name.lower() in test_feature_lower:
return feature_name
return None
def match_test_to_scenario(
test_metadata: TestGherkinMetadata,
scenario_names: list[str]
) -> Optional[str]:
"""
Match a test's scenario metadata to an actual scenario name.
Uses fuzzy matching if exact match not found.
Args:
test_metadata: Extracted test metadata
scenario_names: List of available scenario names
Returns:
Matched scenario name or None
"""
if not test_metadata.scenario:
return None
# Exact match
if test_metadata.scenario in scenario_names:
return test_metadata.scenario
# Case-insensitive match
test_scenario_lower = test_metadata.scenario.lower()
for scenario_name in scenario_names:
if scenario_name.lower() == test_scenario_lower:
return scenario_name
# Partial match
for scenario_name in scenario_names:
if test_scenario_lower in scenario_name.lower():
return scenario_name
if scenario_name.lower() in test_scenario_lower:
return scenario_name
return None

View File

@@ -0,0 +1,231 @@
"""
Parse Gherkin .feature files.
Simple parser without external dependencies - parses the subset we need.
For full Gherkin support, could use gherkin-python package later.
"""
import re
from pathlib import Path
from typing import Optional
from dataclasses import dataclass, field
@dataclass
class GherkinScenario:
"""A Gherkin scenario."""
name: str
description: str
tags: list[str] = field(default_factory=list)
steps: list[str] = field(default_factory=list)
examples: dict = field(default_factory=dict)
scenario_type: str = "Scenario" # or "Scenario Outline" / "Esquema del escenario"
@dataclass
class GherkinFeature:
"""A parsed Gherkin feature file."""
name: str
description: str
file_path: str
language: str = "en" # or "es"
tags: list[str] = field(default_factory=list)
background: Optional[dict] = None
scenarios: list[GherkinScenario] = field(default_factory=list)
def parse_feature_file(file_path: Path) -> Optional[GherkinFeature]:
"""
Parse a Gherkin .feature file.
Supports both English and Spanish keywords.
Extracts: Feature name, scenarios, tags, steps.
"""
if not file_path.exists():
return None
try:
content = file_path.read_text(encoding='utf-8')
except Exception:
return None
# Detect language
language = "en"
if re.search(r"#\s*language:\s*es", content):
language = "es"
# Keywords by language
if language == "es":
feature_kw = r"Característica"
scenario_kw = r"Escenario"
outline_kw = r"Esquema del escenario"
background_kw = r"Antecedentes"
examples_kw = r"Ejemplos"
given_kw = r"Dado"
when_kw = r"Cuando"
then_kw = r"Entonces"
and_kw = r"Y"
but_kw = r"Pero"
else:
feature_kw = r"Feature"
scenario_kw = r"Scenario"
outline_kw = r"Scenario Outline"
background_kw = r"Background"
examples_kw = r"Examples"
given_kw = r"Given"
when_kw = r"When"
then_kw = r"Then"
and_kw = r"And"
but_kw = r"But"
lines = content.split('\n')
# Extract feature
feature_name = None
feature_desc = []
feature_tags = []
scenarios = []
current_scenario = None
current_tags = []
i = 0
while i < len(lines):
line = lines[i].strip()
# Skip comments and empty lines
if not line or line.startswith('#'):
i += 1
continue
# Tags
if line.startswith('@'):
tags = re.findall(r'@[\w-]+', line)
current_tags.extend(tags)
i += 1
continue
# Feature
feature_match = re.match(rf"^{feature_kw}:\s*(.+)", line)
if feature_match:
feature_name = feature_match.group(1).strip()
feature_tags = current_tags.copy()
current_tags = []
# Read feature description
i += 1
while i < len(lines):
line = lines[i].strip()
if not line or line.startswith('#'):
i += 1
continue
# Stop at scenario or background
if re.match(rf"^({scenario_kw}|{outline_kw}|{background_kw}):", line):
break
feature_desc.append(line)
i += 1
continue
# Scenario
scenario_match = re.match(rf"^({scenario_kw}|{outline_kw}):\s*(.+)", line)
if scenario_match:
# Save previous scenario
if current_scenario:
scenarios.append(current_scenario)
scenario_type = scenario_match.group(1)
scenario_name = scenario_match.group(2).strip()
current_scenario = GherkinScenario(
name=scenario_name,
description="",
tags=current_tags.copy(),
steps=[],
scenario_type=scenario_type
)
current_tags = []
# Read scenario steps
i += 1
while i < len(lines):
line = lines[i].strip()
# Empty or comment
if not line or line.startswith('#'):
i += 1
continue
# New scenario or feature-level element
if re.match(rf"^({scenario_kw}|{outline_kw}|{examples_kw}):", line):
break
# Tags (start of next scenario)
if line.startswith('@'):
break
# Step keywords
if re.match(rf"^({given_kw}|{when_kw}|{then_kw}|{and_kw}|{but_kw})\s+", line):
current_scenario.steps.append(line)
i += 1
continue
i += 1
# Add last scenario
if current_scenario:
scenarios.append(current_scenario)
if not feature_name:
return None
return GherkinFeature(
name=feature_name,
description=" ".join(feature_desc),
file_path=str(file_path),
language=language,
tags=feature_tags,
scenarios=scenarios
)
def discover_features(features_dir: Path) -> list[GherkinFeature]:
"""
Discover all .feature files in the features directory.
"""
if not features_dir.exists():
return []
features = []
for feature_file in features_dir.rglob("*.feature"):
parsed = parse_feature_file(feature_file)
if parsed:
features.append(parsed)
return features
def extract_tags_from_features(features: list[GherkinFeature]) -> set[str]:
"""Extract all unique tags from features."""
tags = set()
for feature in features:
tags.update(feature.tags)
for scenario in feature.scenarios:
tags.update(scenario.tags)
return tags
def get_feature_names(features: list[GherkinFeature]) -> list[str]:
"""Get list of feature names."""
return [f.name for f in features]
def get_scenario_names(features: list[GherkinFeature]) -> list[str]:
"""Get list of all scenario names across all features."""
scenarios = []
for feature in features:
for scenario in feature.scenarios:
scenarios.append(scenario.name)
return scenarios

View File

@@ -0,0 +1,93 @@
"""
Sync Gherkin feature files from album/book/gherkin-samples/ to tester/features/.
"""
import shutil
from pathlib import Path
from typing import Optional
def sync_features_from_album(
album_path: Optional[Path] = None,
tester_path: Optional[Path] = None
) -> dict:
"""
Sync .feature files from album/book/gherkin-samples/ to ward/tools/tester/features/.
Args:
album_path: Path to album/book/gherkin-samples/ (auto-detected if None)
tester_path: Path to ward/tools/tester/features/ (auto-detected if None)
Returns:
Dict with sync stats: {synced: int, skipped: int, errors: int}
"""
# Auto-detect paths if not provided
if tester_path is None:
tester_path = Path(__file__).parent.parent / "features"
if album_path is None:
# Attempt to find album in pawprint
pawprint_root = Path(__file__).parent.parent.parent.parent
album_path = pawprint_root / "album" / "book" / "gherkin-samples"
# Ensure paths exist
if not album_path.exists():
return {
"synced": 0,
"skipped": 0,
"errors": 1,
"message": f"Album path not found: {album_path}"
}
tester_path.mkdir(parents=True, exist_ok=True)
# Sync stats
synced = 0
skipped = 0
errors = 0
# Find all .feature files in album
for feature_file in album_path.rglob("*.feature"):
# Get relative path from album root
relative_path = feature_file.relative_to(album_path)
# Destination path
dest_file = tester_path / relative_path
try:
# Create parent directories
dest_file.parent.mkdir(parents=True, exist_ok=True)
# Copy file
shutil.copy2(feature_file, dest_file)
synced += 1
except Exception as e:
errors += 1
return {
"synced": synced,
"skipped": skipped,
"errors": errors,
"message": f"Synced {synced} feature files from {album_path}"
}
def clean_features_dir(features_dir: Optional[Path] = None):
"""
Clean the features directory (remove all .feature files).
Useful before re-syncing to ensure no stale files.
"""
if features_dir is None:
features_dir = Path(__file__).parent.parent / "features"
if not features_dir.exists():
return
# Remove all .feature files
for feature_file in features_dir.rglob("*.feature"):
try:
feature_file.unlink()
except Exception:
pass