Skip to content

Commit bd261ed

Browse files
committed
Refactor PyOsmo core to simplify error handling, improve registries, and enhance algorithm initialization.
- Centralize error handling for "failure_in_suite" to reuse "failure_in_test." - Consolidate registries (algorithms, end conditions, error strategies) into a unified `_registries` structure for maintainability. - Add `_ensure_initialized` method in algorithm base to validate initialization. - Simplify history counts and logs using comprehensions. - Standardize string styles and simplify Playwright-based model scripts. - Remove redundant error handling methods.
1 parent a5a62f7 commit bd261ed

16 files changed

Lines changed: 152 additions & 266 deletions

File tree

examples/ai_web_agent/example_output/todo_app_model.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,16 @@ def __init__(self, page: Page, url: str):
2222
def before_test(self):
2323
"""Navigate to app and reset state."""
2424
self.page.goto(self.url)
25-
self.page.wait_for_selector(".new-todo")
25+
self.page.wait_for_selector('.new-todo')
2626
self.todo_count = 0
2727
self.completed_count = 0
2828

2929
# --- Add a todo item ---
3030

3131
def step_add_todo(self):
32-
input_field = self.page.locator(".new-todo")
33-
input_field.fill(f"Task {self.todo_count + 1}")
34-
input_field.press("Enter")
32+
input_field = self.page.locator('.new-todo')
33+
input_field.fill(f'Task {self.todo_count + 1}')
34+
input_field.press('Enter')
3535
self.todo_count += 1
3636

3737
def guard_add_todo(self):
@@ -43,7 +43,7 @@ def weight_add_todo(self):
4343
# --- Toggle a todo as complete ---
4444

4545
def step_toggle_todo(self):
46-
items = self.page.locator(".todo-list li:not(.completed) .toggle")
46+
items = self.page.locator('.todo-list li:not(.completed) .toggle')
4747
items.first.click()
4848
self.completed_count += 1
4949

@@ -54,9 +54,9 @@ def guard_toggle_todo(self):
5454
# --- Delete a todo item ---
5555

5656
def step_delete_todo(self):
57-
item = self.page.locator(".todo-list li").first
57+
item = self.page.locator('.todo-list li').first
5858
item.hover()
59-
item.locator(".destroy").click()
59+
item.locator('.destroy').click()
6060
self.todo_count -= 1
6161

6262
def guard_delete_todo(self):
@@ -92,18 +92,18 @@ def after(self):
9292
"""Verify todo count display matches model state."""
9393
active_count = self.todo_count - self.completed_count
9494
if active_count > 0:
95-
count_text = self.page.locator(".todo-count").text_content()
95+
count_text = self.page.locator('.todo-count').text_content()
9696
assert str(active_count) in count_text
9797

9898

99-
if __name__ == "__main__":
99+
if __name__ == '__main__':
100100
from playwright.sync_api import sync_playwright
101101

102102
with sync_playwright() as p:
103103
browser = p.chromium.launch(headless=False)
104104
page = browser.new_page()
105105

106-
model = TodoAppModel(page=page, url="https://todomvc.com/examples/react/dist/")
106+
model = TodoAppModel(page=page, url='https://todomvc.com/examples/react/dist/')
107107

108108
osmo = Osmo(model)
109109
osmo.test_end_condition = Length(30)

examples/ai_web_agent/generate_model.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import argparse
1313
import asyncio
1414
import sys
15-
from pathlib import Path
1615

1716
from prompt_template import PYOSMO_MODEL_REFERENCE
1817

@@ -21,17 +20,17 @@ async def generate_model(url: str, output_path: str) -> None:
2120
try:
2221
from claude_agent_sdk import Agent, AgentConfig, MCPServer
2322
except ImportError:
24-
print("Error: claude-agent-sdk is required. Install with: pip install claude-agent-sdk")
23+
print('Error: claude-agent-sdk is required. Install with: pip install claude-agent-sdk')
2524
sys.exit(1)
2625

2726
playwright_mcp = MCPServer(
28-
name="playwright",
29-
command="npx",
30-
args=["@playwright/mcp@latest"],
27+
name='playwright',
28+
command='npx',
29+
args=['@playwright/mcp@latest'],
3130
)
3231

3332
agent = Agent(
34-
model="claude-sonnet-4-5-20250929",
33+
model='claude-sonnet-4-5-20250929',
3534
config=AgentConfig(
3635
system_prompt=PYOSMO_MODEL_REFERENCE,
3736
mcp_servers=[playwright_mcp],
@@ -51,20 +50,22 @@ async def generate_model(url: str, output_path: str) -> None:
5150
Save the model to: {output_path}
5251
"""
5352

54-
print(f"Exploring {url} and generating model...")
53+
print(f'Exploring {url} and generating model...')
5554
result = await agent.run(user_prompt)
56-
print(f"Agent completed. Model saved to {output_path}")
57-
print(f"Result: {result}")
55+
print(f'Agent completed. Model saved to {output_path}')
56+
print(f'Result: {result}')
5857

5958

6059
def main() -> None:
61-
parser = argparse.ArgumentParser(description="Generate a PyOsmo model from a web application")
62-
parser.add_argument("url", help="URL of the web application to model")
63-
parser.add_argument("--output", "-o", default="generated_model.py", help="Output file path (default: generated_model.py)")
60+
parser = argparse.ArgumentParser(description='Generate a PyOsmo model from a web application')
61+
parser.add_argument('url', help='URL of the web application to model')
62+
parser.add_argument(
63+
'--output', '-o', default='generated_model.py', help='Output file path (default: generated_model.py)'
64+
)
6465
args = parser.parse_args()
6566

6667
asyncio.run(generate_model(args.url, args.output))
6768

6869

69-
if __name__ == "__main__":
70+
if __name__ == '__main__':
7071
main()

examples/ai_web_agent/refine_model.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,20 +32,20 @@ def run_model_and_get_history(model_path: str, url: str, steps: int) -> tuple[st
3232
source = Path(model_path).read_text()
3333

3434
# Dynamically load the model module
35-
spec = importlib.util.spec_from_file_location("generated_model", model_path)
35+
spec = importlib.util.spec_from_file_location('generated_model', model_path)
3636
module = importlib.util.module_from_spec(spec)
3737
spec.loader.exec_module(module)
3838

3939
# Find the model class (first class with step_* methods)
4040
model_class = None
4141
for attr_name in dir(module):
4242
attr = getattr(module, attr_name)
43-
if isinstance(attr, type) and any(m.startswith("step_") for m in dir(attr)):
43+
if isinstance(attr, type) and any(m.startswith('step_') for m in dir(attr)):
4444
model_class = attr
4545
break
4646

4747
if model_class is None:
48-
print("Error: No PyOsmo model class found in the file")
48+
print('Error: No PyOsmo model class found in the file')
4949
sys.exit(1)
5050

5151
with sync_playwright() as p:
@@ -70,18 +70,18 @@ async def refine_model(model_path: str, url: str, steps: int) -> None:
7070
try:
7171
from claude_agent_sdk import Agent, AgentConfig
7272
except ImportError:
73-
print("Error: claude-agent-sdk is required. Install with: pip install claude-agent-sdk")
73+
print('Error: claude-agent-sdk is required. Install with: pip install claude-agent-sdk')
7474
sys.exit(1)
7575

7676
from prompt_template import REFINEMENT_PROMPT
7777

78-
print(f"Running model {model_path}...")
78+
print(f'Running model {model_path}...')
7979
source, history_json = run_model_and_get_history(model_path, url, steps)
8080

81-
print("Analyzing results and refining model...")
81+
print('Analyzing results and refining model...')
8282

8383
agent = Agent(
84-
model="claude-sonnet-4-5-20250929",
84+
model='claude-sonnet-4-5-20250929',
8585
config=AgentConfig(system_prompt=REFINEMENT_PROMPT),
8686
)
8787

@@ -107,22 +107,22 @@ async def refine_model(model_path: str, url: str, steps: int) -> None:
107107

108108
# Write refined model back
109109
Path(model_path).write_text(result)
110-
print(f"Refined model written to {model_path}")
110+
print(f'Refined model written to {model_path}')
111111

112112

113113
def main() -> None:
114-
parser = argparse.ArgumentParser(description="Refine a PyOsmo model based on test results")
115-
parser.add_argument("model_file", help="Path to the PyOsmo model file")
116-
parser.add_argument("--url", required=True, help="URL of the web application")
117-
parser.add_argument("--steps", type=int, default=20, help="Steps per test (default: 20)")
118-
parser.add_argument("--iterations", type=int, default=1, help="Refinement iterations (default: 1)")
114+
parser = argparse.ArgumentParser(description='Refine a PyOsmo model based on test results')
115+
parser.add_argument('model_file', help='Path to the PyOsmo model file')
116+
parser.add_argument('--url', required=True, help='URL of the web application')
117+
parser.add_argument('--steps', type=int, default=20, help='Steps per test (default: 20)')
118+
parser.add_argument('--iterations', type=int, default=1, help='Refinement iterations (default: 1)')
119119
args = parser.parse_args()
120120

121121
for i in range(args.iterations):
122122
if args.iterations > 1:
123-
print(f"\n--- Refinement iteration {i + 1}/{args.iterations} ---")
123+
print(f'\n--- Refinement iteration {i + 1}/{args.iterations} ---')
124124
asyncio.run(refine_model(args.model_file, args.url, args.steps))
125125

126126

127-
if __name__ == "__main__":
127+
if __name__ == '__main__':
128128
main()

pyosmo/algorithm/balancing.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,13 @@ class BalancingRandomAlgorithm(OsmoAlgorithm):
88
In practise rare steps gets more priority when those are available"""
99

1010
def choose(self, history: OsmoHistory, choices: list[TestStep]) -> TestStep:
11-
if self.random is None:
12-
raise RuntimeError('Algorithm not initialized. Call initialize() first.')
11+
rng = self._ensure_initialized()
1312
if len(choices) == 1:
1413
return choices[0]
1514
history_counts = [history.get_step_count(choice) for choice in choices]
1615
weights = [(sum(history_counts) - h) for h in history_counts]
1716
weights = [w - min(weights) + 1 for w in weights]
18-
return self.random.choices(choices, weights=weights)[0]
17+
return rng.choices(choices, weights=weights)[0]
1918

2019

2120
class BalancingAlgorithm(OsmoAlgorithm):

pyosmo/algorithm/base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ def initialize(self, random: Random, model: OsmoModelCollector) -> None:
2222
self.random = random
2323
self.model = model
2424

25+
def _ensure_initialized(self) -> Random:
26+
"""Return the Random instance, raising if not yet initialized."""
27+
if self.random is None:
28+
raise RuntimeError('Algorithm not initialized. Call initialize() first.')
29+
return self.random
30+
2531
@abstractmethod
2632
def choose(self, history: OsmoHistory, choices: list[TestStep]) -> TestStep:
2733
raise Exception('This is just abstract class, not implementation')

pyosmo/algorithm/random.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,4 @@ class RandomAlgorithm(OsmoAlgorithm):
77
"""Fully random algorithm"""
88

99
def choose(self, history: OsmoHistory, choices: list[TestStep]) -> TestStep:
10-
if self.random is None:
11-
raise RuntimeError('Algorithm not initialized. Call initialize() first.')
12-
return self.random.choice(choices)
10+
return self._ensure_initialized().choice(choices)

pyosmo/algorithm/weighted.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,20 @@ class WeightedAlgorithm(OsmoAlgorithm):
77
"""Weighted random algorithm"""
88

99
def choose(self, history: OsmoHistory, choices: list[TestStep]) -> TestStep:
10-
if self.random is None:
11-
raise RuntimeError('Algorithm not initialized. Call initialize() first.')
12-
return self.random.choices(choices, weights=[c.weight for c in choices])[0]
10+
return self._ensure_initialized().choices(choices, weights=[c.weight for c in choices])[0]
1311

1412

1513
class WeightedBalancingAlgorithm(OsmoAlgorithm):
1614
"""Weighted algorithm which balances based on history"""
1715

1816
def choose(self, history: OsmoHistory, choices: list[TestStep]) -> TestStep:
19-
if self.random is None:
20-
raise RuntimeError('Algorithm not initialized. Call initialize() first.')
17+
rng = self._ensure_initialized()
2118
weights = [c.weight for c in choices]
2219
normalized_weights = [float(i) / max(weights) for i in weights]
2320

2421
history_counts = [history.get_step_count(choice) for choice in choices]
2522
if max(history_counts) == 0:
26-
return self.random.choices(choices, weights=normalized_weights)[0]
23+
return rng.choices(choices, weights=normalized_weights)[0]
2724

2825
history_normalized_weights = [float(i) / max(history_counts) for i in history_counts]
2926

@@ -36,4 +33,4 @@ def choose(self, history: OsmoHistory, choices: list[TestStep]) -> TestStep:
3633
temp_add = (abs(sum(total_weights)) + 0.2) / len(total_weights)
3734
total_weights = [temp_add + x for x in total_weights]
3835

39-
return self.random.choices(choices, weights=total_weights)[0]
36+
return rng.choices(choices, weights=total_weights)[0]

pyosmo/config.py

Lines changed: 31 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -25,66 +25,45 @@ class ConfigValidator:
2525
"""Validates configuration values with comprehensive error messages."""
2626

2727
@staticmethod
28-
def validate_algorithm(algorithm: Any) -> None:
29-
"""Validate algorithm configuration.
30-
31-
Args:
32-
algorithm: Algorithm to validate
33-
34-
Raises:
35-
ConfigurationError: If algorithm is invalid
36-
"""
37-
if algorithm is None:
38-
raise ConfigurationError('Algorithm cannot be None. Please provide a valid OsmoAlgorithm instance.')
39-
40-
if not isinstance(algorithm, OsmoAlgorithm):
28+
def _validate_type(value: Any, expected_type: type, name: str, available: str) -> None:
29+
"""Generic type validation for configuration values."""
30+
if value is None:
4131
raise ConfigurationError(
42-
f'Algorithm must be an instance of OsmoAlgorithm, '
43-
f'got {type(algorithm).__name__}. '
44-
f'Available algorithms: RandomAlgorithm, WeightedAlgorithm, BalancingAlgorithm.'
32+
f'{name} cannot be None. Please provide a valid {expected_type.__name__} instance.'
4533
)
46-
47-
@staticmethod
48-
def validate_end_condition(condition: Any, name: str = 'End condition') -> None:
49-
"""Validate end condition configuration.
50-
51-
Args:
52-
condition: End condition to validate
53-
name: Name of the condition for error messages
54-
55-
Raises:
56-
ConfigurationError: If end condition is invalid
57-
"""
58-
if condition is None:
59-
raise ConfigurationError(f'{name} cannot be None. Please provide a valid OsmoEndCondition instance.')
60-
61-
if not isinstance(condition, OsmoEndCondition):
34+
if not isinstance(value, expected_type):
6235
raise ConfigurationError(
63-
f'{name} must be an instance of OsmoEndCondition, '
64-
f'got {type(condition).__name__}. '
65-
f'Available conditions: Length, Time, StepCoverage, Endless, And, Or.'
36+
f'{name} must be an instance of {expected_type.__name__}, '
37+
f'got {type(value).__name__}. '
38+
f'Available: {available}.'
6639
)
6740

6841
@staticmethod
69-
def validate_error_strategy(strategy: Any, name: str = 'Error strategy') -> None:
70-
"""Validate error strategy configuration.
71-
72-
Args:
73-
strategy: Error strategy to validate
74-
name: Name of the strategy for error messages
42+
def validate_algorithm(algorithm: Any) -> None:
43+
ConfigValidator._validate_type(
44+
algorithm,
45+
OsmoAlgorithm,
46+
'Algorithm',
47+
'RandomAlgorithm, WeightedAlgorithm, BalancingAlgorithm',
48+
)
7549

76-
Raises:
77-
ConfigurationError: If error strategy is invalid
78-
"""
79-
if strategy is None:
80-
raise ConfigurationError(f'{name} cannot be None. Please provide a valid OsmoErrorStrategy instance.')
50+
@staticmethod
51+
def validate_end_condition(condition: Any, name: str = 'End condition') -> None:
52+
ConfigValidator._validate_type(
53+
condition,
54+
OsmoEndCondition,
55+
name,
56+
'Length, Time, StepCoverage, Endless, And, Or',
57+
)
8158

82-
if not isinstance(strategy, OsmoErrorStrategy):
83-
raise ConfigurationError(
84-
f'{name} must be an instance of OsmoErrorStrategy, '
85-
f'got {type(strategy).__name__}. '
86-
f'Available strategies: AlwaysRaise, AlwaysIgnore, IgnoreAsserts, AllowCount.'
87-
)
59+
@staticmethod
60+
def validate_error_strategy(strategy: Any, name: str = 'Error strategy') -> None:
61+
ConfigValidator._validate_type(
62+
strategy,
63+
OsmoErrorStrategy,
64+
name,
65+
'AlwaysRaise, AlwaysIgnore, IgnoreAsserts, AllowCount',
66+
)
8867

8968
@staticmethod
9069
def validate_seed(seed: Any) -> None:

pyosmo/error_strategy/always_ignore.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,3 @@ class AlwaysIgnore(OsmoErrorStrategy):
1010

1111
def failure_in_test(self, history: OsmoHistory, model: OsmoModelCollector, error: BaseException) -> None:
1212
pass
13-
14-
def failure_in_suite(self, history: OsmoHistory, model: OsmoModelCollector, error: BaseException) -> None:
15-
pass

pyosmo/error_strategy/always_raise.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,3 @@ class AlwaysRaise(OsmoErrorStrategy):
1010

1111
def failure_in_test(self, history: OsmoHistory, model: OsmoModelCollector, error: BaseException) -> None:
1212
raise error
13-
14-
def failure_in_suite(self, history: OsmoHistory, model: OsmoModelCollector, error: BaseException) -> None:
15-
raise error

0 commit comments

Comments
 (0)