diff --git a/.gitignore b/.gitignore index 92481d5..9e33315 100644 --- a/.gitignore +++ b/.gitignore @@ -65,4 +65,5 @@ target/ pyosmo/config.py.bak .pytest_cache -.hypothesis \ No newline at end of file +.hypothesis +models \ No newline at end of file diff --git a/model-creator/README.md b/model-creator/README.md index d944ee1..e46e547 100644 --- a/model-creator/README.md +++ b/model-creator/README.md @@ -116,9 +116,13 @@ def after(self): ### Step Methods (Actions) +The generated models use PyOsmo's decorator syntax for steps and guards: + Form submission: ```python -def step_submit_login(self): +@step +@guard(lambda self: not self.logged_in) +def submit_login(self): """Submit the login form.""" data = { "username": "testuser", @@ -136,31 +140,73 @@ def step_submit_login(self): Navigation: ```python -def step_navigate_to_about(self): +@step +def navigate_to_about(self): """Navigate to about page.""" self.response = self.session.get("https://example.com/about") self.current_page = "about" print("Navigated to: about") ``` -### Guard Methods (Preconditions) +Steps with authentication requirements: +```python +@step +@guard(lambda self: self.logged_in) +def navigate_to_dashboard(self): + """Navigate to dashboard (requires login).""" + self.response = self.session.get("https://example.com/dashboard") + self.current_page = "dashboard" + print("Navigated to: dashboard") +``` + +### Main Function +Generated models include a `main()` function for easy execution: ```python -def guard_submit_login(self): - """Guard for login - can only login when not logged in.""" - return not self.logged_in +def main(): + """Run the model with PyOsmo.""" + model = WebsiteModel() + + osmo = ( + Osmo(model) + .weighted_algorithm() + .stop_after_steps(100) + .run_tests(1) + ) + + print(f"Starting test generation for {model.base_url}") + osmo.generate() + + # Print summary + stats = osmo.history.statistics() + print("\n" + "=" * 50) + print("Test generation complete!") + print(f"Steps executed: {stats.total_steps}") + print(f"Unique steps: {len(stats.step_coverage)}") + -def guard_submit_logout(self): - """Guard for logout - can only logout when logged in.""" - return self.logged_in +if __name__ == "__main__": + main() +``` + +You can run the generated model directly: +```bash +python models/example_model.py ``` ## Running Generated Models -Once you have a generated model, you can run it with PyOsmo: +Generated models include a `main()` function that can be run directly: + +```bash +# Run the model directly (uses built-in defaults) +python models/example_model.py +``` + +Or use PyOsmo's CLI for more control: ```bash -# Run model exploration +# Run model exploration with custom settings python -m osmo.explorer -m models/example_model.py:WebsiteModel # Run with specific seed for reproducibility @@ -268,10 +314,12 @@ Generated models can be customized after creation: 1. **Add custom verification logic** in the `after()` method 2. **Customize default values** in form submission steps -3. **Add weights** to control action frequency: +3. **Add weights** to control action frequency using the `weight_value` parameter: ```python - def weight_submit_login(self): - return 5 # Higher weight = more frequent + @step(weight_value=5) # Higher weight = more frequent + @guard(lambda self: not self.logged_in) + def submit_login(self): + ... ``` 4. **Add invariants** for state checking 5. **Customize guards** for complex preconditions diff --git a/model-creator/examples/example_generated_model.py b/model-creator/examples/example_generated_model.py index 730487b..c8f2c8e 100644 --- a/model-creator/examples/example_generated_model.py +++ b/model-creator/examples/example_generated_model.py @@ -11,6 +11,9 @@ except ImportError: raise ImportError('Model requires requests library. Install with: pip install requests') +from pyosmo import Osmo +from pyosmo.decorators import guard, step + class ExampleWebsiteModel: """ @@ -50,14 +53,15 @@ def after(self): # Verify response is valid assert self.response.status_code < 500, f'Server error: {self.response.status_code}' - # Check for error messages if we expect success + # Could add more sophisticated error checking here if self.response.status_code == 200: - # Could add more sophisticated error checking here pass # --- Form Submission Actions --- - def step_submit_login(self): + @step + @guard(lambda self: not self.logged_in) + def submit_login(self): """Submit the login form.""" data = { 'username': 'testuser', @@ -77,12 +81,9 @@ def step_submit_login(self): self.current_page = 'login' print('Executed: submit_login') - def guard_submit_login(self): - """Guard for submit_login.""" - # Can only login when not logged in - return not self.logged_in - - def step_submit_register(self): + @step + @guard(lambda self: not self.logged_in) + def submit_register(self): """Submit the registration form.""" data = { 'username': 'testuser', @@ -99,12 +100,9 @@ def step_submit_register(self): self.current_page = 'register' print('Executed: submit_register') - def guard_submit_register(self): - """Guard for submit_register.""" - # Can only register when not logged in - return not self.logged_in - - def step_submit_logout(self): + @step + @guard(lambda self: self.logged_in) + def submit_logout(self): """Submit the logout form.""" data = {} @@ -119,12 +117,8 @@ def step_submit_logout(self): self.current_page = 'logout' print('Executed: submit_logout') - def guard_submit_logout(self): - """Guard for submit_logout.""" - # Can only logout when logged in - return self.logged_in - - def step_submit_contact(self): + @step + def submit_contact(self): """Submit the contact form.""" data = { 'name': 'Test User', @@ -140,11 +134,8 @@ def step_submit_contact(self): self.current_page = 'contact' print('Executed: submit_contact') - def guard_submit_contact(self): - """Guard for submit_contact.""" - return True - - def step_submit_search(self): + @step + def submit_search(self): """Submit the search form.""" data = { 'q': 'test_q', @@ -158,70 +149,62 @@ def step_submit_search(self): self.current_page = 'search' print('Executed: submit_search') - def guard_submit_search(self): - """Guard for submit_search.""" - return True - # --- Navigation Actions --- - def step_navigate_to_home(self): + @step + def navigate_to_home(self): """Navigate to home.""" self.response = self.session.get('https://example.com') self.current_page = 'home' print('Navigated to: home') - def guard_navigate_to_home(self): - """Guard for navigate_to_home.""" - return True - - def step_navigate_to_about(self): + @step + def navigate_to_about(self): """Navigate to about.""" self.response = self.session.get('https://example.com/about') self.current_page = 'about' print('Navigated to: about') - def guard_navigate_to_about(self): - """Guard for navigate_to_about.""" - return True - - def step_navigate_to_contact(self): + @step + def navigate_to_contact(self): """Navigate to contact.""" self.response = self.session.get('https://example.com/contact') self.current_page = 'contact' print('Navigated to: contact') - def guard_navigate_to_contact(self): - """Guard for navigate_to_contact.""" - return True - - def step_navigate_to_login(self): + @step + @guard(lambda self: not self.logged_in) + def navigate_to_login(self): """Navigate to login.""" self.response = self.session.get('https://example.com/login') self.current_page = 'login' print('Navigated to: login') - def guard_navigate_to_login(self): - """Guard for navigate_to_login.""" - # Only show login page when not logged in - return not self.logged_in - - def step_navigate_to_dashboard(self): + @step + @guard(lambda self: self.logged_in) + def navigate_to_dashboard(self): """Navigate to dashboard.""" self.response = self.session.get('https://example.com/dashboard') self.current_page = 'dashboard' print('Navigated to: dashboard') - def guard_navigate_to_dashboard(self): - """Guard for navigate_to_dashboard.""" - # Dashboard requires authentication - return self.logged_in - # --- Optional: Custom Methods --- +def main(): + """Run the model with PyOsmo.""" + model = ExampleWebsiteModel() + + osmo = Osmo(model).weighted_algorithm().stop_after_steps(100).run_tests(1) + + print(f'Starting test generation for {model.base_url}') + osmo.generate() + + # Print summary + stats = osmo.history.statistics() + print('\n' + '=' * 50) + print('Test generation complete!') + print(f'Steps executed: {stats.total_steps}') + print(f'Unique steps: {len(stats.step_coverage)}') - def weight_submit_login(self): - """Make login more likely when not logged in.""" - return 10 if not self.logged_in else 1 - def weight_navigate_to_dashboard(self): - """Dashboard is important - visit it more often when logged in.""" - return 15 if self.logged_in else 0 +if __name__ == '__main__': + main() diff --git a/model-creator/generator.py b/model-creator/generator.py index cf92660..a74ddad 100644 --- a/model-creator/generator.py +++ b/model-creator/generator.py @@ -203,6 +203,9 @@ def generate_model_class(self, class_name: str = 'WebsiteModel') -> str: ' "Model requires requests library. Install with: pip install requests"', ' )', '', + 'from pyosmo import Osmo', + 'from pyosmo.decorators import step, guard', + '', '', ] ) @@ -268,6 +271,9 @@ def generate_model_class(self, class_name: str = 'WebsiteModel') -> str: lines.extend(self._generate_step_method(action)) lines.append('') + # Generate main function + lines.extend(self._generate_main_function(class_name)) + return '\n'.join(line for line in lines if line is not None) def _generate_step_method(self, action: dict) -> list[str]: @@ -282,13 +288,27 @@ def _generate_step_method(self, action: dict) -> list[str]: return lines def _generate_form_step(self, action: dict) -> list[str]: - """Generate a form submission step.""" + """Generate a form submission step with decorators.""" lines = [] method_name = action['name'] form = action['form'] - # Method signature - lines.append(f' def step_{method_name}(self):') + # Determine guard condition + if action['is_login']: + guard_condition = 'not self.logged_in' + elif action['is_logout']: + guard_condition = 'self.logged_in' + else: + page = self.pages.get(action['page_url']) + guard_condition = 'self.logged_in' if page and page.requires_auth else None + + # Add decorators + lines.append(' @step') + if guard_condition: + lines.append(f' @guard(lambda self: {guard_condition})') + + # Method signature (no step_ prefix) + lines.append(f' def {method_name}(self):') lines.append(f' """Submit the {method_name.replace("_", " ")} form."""') # Prepare form data @@ -326,33 +346,23 @@ def _generate_form_step(self, action: dict) -> list[str]: lines.append(f' print(f"Executed: {method_name}")') - # Guard method - lines.append('') - lines.append(f' def guard_{method_name}(self):') - lines.append(f' """Guard for {method_name}."""') - - if action['is_login']: - lines.append(' # Can only login when not logged in') - lines.append(' return not self.logged_in') - elif action['is_logout']: - lines.append(' # Can only logout when logged in') - lines.append(' return self.logged_in') - else: - # Check if requires login - page = self.pages.get(action['page_url']) - if page and page.requires_auth: - lines.append(' return self.logged_in') - else: - lines.append(' return True') - return lines def _generate_navigation_step(self, action: dict) -> list[str]: - """Generate a navigation step.""" + """Generate a navigation step with decorators.""" lines = [] method_name = action['name'] - lines.append(f' def step_{method_name}(self):') + # Determine guard condition + guard_condition = 'self.logged_in' if action['requires_auth'] else None + + # Add decorators + lines.append(' @step') + if guard_condition: + lines.append(f' @guard(lambda self: {guard_condition})') + + # Method signature (no step_ prefix) + lines.append(f' def {method_name}(self):') lines.append(f' """Navigate to {action["target_page"]}."""') lines.append(f' self.response = self.session.get("{action["target_url"]}")') @@ -361,16 +371,6 @@ def _generate_navigation_step(self, action: dict) -> list[str]: lines.append(f' print(f"Navigated to: {action["target_page"]}")') - # Guard - lines.append('') - lines.append(f' def guard_{method_name}(self):') - lines.append(f' """Guard for {method_name}."""') - - if action['requires_auth']: - lines.append(' return self.logged_in') - else: - lines.append(' return True') - return lines def _get_default_value(self, field: FormField) -> str: @@ -399,6 +399,36 @@ def _get_default_value(self, field: FormField) -> str: return f'"{field.options[0]}"' return f'"test_{field.name}"' + def _generate_main_function(self, class_name: str) -> list[str]: + """Generate main function for running the model with PyOsmo.""" + return [ + '', + '', + 'def main():', + ' """Run the model with PyOsmo."""', + f' model = {class_name}()', + '', + ' osmo = (', + ' Osmo(model)', + ' .weighted_algorithm()', + ' .stop_after_steps(100)', + ' .run_tests(1)', + ' )', + '', + ' print(f"Starting test generation for {model.base_url}")', + ' osmo.generate()', + '', + ' # Print summary', + ' stats = osmo.history.statistics()', + ' print("\\n" + "=" * 50)', + ' print("Test generation complete!")', + ' print(stats)', + '', + '', + 'if __name__ == "__main__":', + ' main()', + ] + def save_model(self, output_path: Path, class_name: str = 'WebsiteModel'): """ Generate and save the model to a file. diff --git a/model-creator/tests/test_generator.py b/model-creator/tests/test_generator.py index ad23c67..eeb273f 100644 --- a/model-creator/tests/test_generator.py +++ b/model-creator/tests/test_generator.py @@ -197,9 +197,16 @@ def test_generate_model_class(self): # Check for imports self.assertIn('import requests', code) self.assertIn('import random', code) + self.assertIn('from pyosmo import Osmo', code) + self.assertIn('from pyosmo.decorators import step, guard', code) + + # Check for main function + self.assertIn('def main():', code) + self.assertIn('if __name__ == "__main__":', code) + self.assertIn('Osmo(model)', code) def test_generate_form_step(self): - """Test form step generation.""" + """Test form step generation with decorators.""" action = { 'type': 'form_submit', 'name': 'submit_login', @@ -222,9 +229,12 @@ def test_generate_form_step(self): lines = self.generator._generate_form_step(action) code = '\n'.join(lines) - # Check method definition - self.assertIn('def step_submit_login(self):', code) - self.assertIn('def guard_submit_login(self):', code) + # Check decorators + self.assertIn('@step', code) + self.assertIn('@guard(lambda self: not self.logged_in)', code) + + # Check method definition (no step_ prefix) + self.assertIn('def submit_login(self):', code) # Check form data preparation self.assertIn('data = {', code) @@ -237,11 +247,8 @@ def test_generate_form_step(self): # Check login state update self.assertIn('self.logged_in = True', code) - # Check guard for login - self.assertIn('return not self.logged_in', code) - def test_generate_navigation_step(self): - """Test navigation step generation.""" + """Test navigation step generation with decorators.""" action = { 'type': 'navigation', 'name': 'navigate_to_about', @@ -254,16 +261,37 @@ def test_generate_navigation_step(self): lines = self.generator._generate_navigation_step(action) code = '\n'.join(lines) - # Check method definition - self.assertIn('def step_navigate_to_about(self):', code) - self.assertIn('def guard_navigate_to_about(self):', code) + # Check decorator (no guard since requires_auth is False) + self.assertIn('@step', code) + self.assertNotIn('@guard', code) + + # Check method definition (no step_ prefix) + self.assertIn('def navigate_to_about(self):', code) # Check navigation self.assertIn('self.response = self.session.get(', code) self.assertIn('https://example.com/about', code) - # Check guard - self.assertIn('return True', code) + def test_generate_navigation_step_with_auth(self): + """Test navigation step generation with auth requirement.""" + action = { + 'type': 'navigation', + 'name': 'navigate_to_dashboard', + 'target_url': 'https://example.com/dashboard', + 'target_page': 'dashboard', + 'link_text': 'Dashboard', + 'requires_auth': True, + } + + lines = self.generator._generate_navigation_step(action) + code = '\n'.join(lines) + + # Check decorators + self.assertIn('@step', code) + self.assertIn('@guard(lambda self: self.logged_in)', code) + + # Check method definition (no step_ prefix) + self.assertIn('def navigate_to_dashboard(self):', code) def test_get_statistics(self): """Test statistics generation.""" diff --git a/pyosmo/model.py b/pyosmo/model.py index d60a7d6..adc604c 100644 --- a/pyosmo/model.py +++ b/pyosmo/model.py @@ -18,7 +18,7 @@ def default_weight(self) -> float: try: return float(self.object_instance.weight) # type: ignore[attr-defined] except AttributeError: - return 0.0 + return 1.0 # Default weight of 1.0 for all steps @property def func(self) -> Callable[[], Any]: