diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index c3c77c0..0000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,224 +0,0 @@ -# Pyrox AI Coding Guidelines - -## Architecture Overview - -Pyrox is a Python framework providing core services, models, and abstractions for building industrial automation applications. It uses an **interface-driven, mixin-based architecture** with clear separation between interfaces, models, services, and tasks. - -### Key Components - -- **Interfaces** (`pyrox/interfaces/`): Abstract contracts (`IApplication`, `IApplicationTask`, `IWorkspace`, `IScene`, `ISceneObject`, etc.) using `Protocol` and `ABCMeta`. No implementation code belongs here. -- **Models** (`pyrox/models/`): Concrete implementations of interfaces. Protocol implementations live in `models/protocols/`. GUI widgets in `models/gui/`. Physics bodies in `models/physics/`. Scene graph in `models/scene/`. -- **Services** (`pyrox/services/`): Static-class utilities (`EnvManager`, `GuiManager`, `LoggingManager`, `SceneRunnerService`, event buses, etc.). Consumed via `ServicesRunnableMixin` properties. -- **Tasks** (`pyrox/tasks/`): Modular application extensions (`ApplicationTask` subclasses) that inject menus and frames into a running application. - -### Core Patterns - -**ServicesRunnableMixin**: The foundation for classes needing service access. Composed of focused sub-mixins: -- `self.env` / `self.env_keys` — `EnvManager` + `EnvironmentKeys` -- `self.gui` — `GuiManager` (PyQt6-based) -- `self.logging` / `self.log()` — `LoggingManager` -- `self.directory` — `PlatformDirectoryService` -- `self.root_window` / `self.root_menu` / `self.file_menu` etc. — convenience accessors on `GuiManager` - -**Factory Pattern**: `MetaFactory` enables type registration for extensible object creation. Subclasses auto-initialize their own `_registered_types` dict via `__init_subclass__`: -```python -class MyFactory(MetaFactory): - pass # _registered_types auto-created by MetaFactory.__init_subclass__ -``` - -**`FactoryTypeMeta`**: Metaclass used on `ApplicationTask` to auto-register subclasses with `ApplicationTaskFactory`. Tasks are built automatically at app startup. - -**Application Tasks**: Subclass `ApplicationTask` (not the interface directly). Override `create_task_frame()` and call `create_or_raise_frame()` to show/raise windows: -```python -class MyTask(ApplicationTask): - def __init__(self, application: IApplication) -> None: - super().__init__(application) - self.register_menu_command(menu=self.file_menu, ...) - - def create_task_frame(self) -> TaskFrame: - return MyTaskFrame(self.application.workspace.workspace_area) -``` - -**Event Bus**: Decouple components via typed event buses: -```python -class MyEventBus(EventBus[MyEventType, MyEvent]): - pass - -MyEventBus.subscribe(MyEventType.SOMETHING, callback) -MyEventBus.publish(MyEvent(event_type=MyEventType.SOMETHING, ...)) -``` - -**`MenuRegistry`**: Centralized registry for all menu items. Use `register_menu_command()` from `ApplicationTask` which auto-registers. Enables `MenuRegistry.enable_item("id")` / `set_command("id", fn)` from anywhere. - -## Development Workflows - -### Installation & Setup -```bash -# Use install.sh (handles Python 3.13 check, venv, dependencies, git hooks) -./install.sh - -# Manual alternative -pip install -e . --upgrade -python utils/setup_hooks.py # Set up pre-commit hooks -``` - -### Key Commands -- **Sync README badges**: `python utils/sync_readme.py` (auto-runs via git hooks) -- **Extract TODOs**: `python utils/extract_todos.py` (generates code_todos.md) -- **Run tests**: `pytest pyrox/services/test/` or `pytest pyrox/test/` - -### Environment Configuration -Pyrox uses `.env` files loaded via `EnvManager`: -```python -self.env.get(self.env_keys.core.APP_NAME, 'Default', str) -``` - -## Critical Code Conventions - -### Typing -- Use **built-in generic types** — `list[str]`, `dict[str, int]`, `tuple[int, ...]` — never `List`, `Dict`, `Tuple` from `typing`. -- Use `X | Y` union syntax instead of `Optional[X]` or `Union[X, Y]`. -- **No forward references** (string literals like `'MyClass'`). Restructure imports or use `TYPE_CHECKING` guards if needed. -- `from __future__ import annotations` is present in some legacy files but should not be added to new files. - -### Logging -Use the `log` helper imported from `pyrox.services.logging`: -```python -from pyrox.services.logging import log -log(self).debug('Message') # uses class name as logger name -log(MyClass).warning('...') # pass class when outside instance context -``` - -### GUI — PyQt6 -The GUI layer is **PyQt6**. Never import `tkinter` in new or modified code. -- Root window and menus are managed by the static `GuiManager`. -- Access via `ServicesRunnableMixin` properties: `self.root_window`, `self.file_menu`, etc. -- All custom widgets subclass `TaskFrame` (from `pyrox.models.gui.frame`) and expose a `.root: QWidget` property for placement in splitters/workspaces. -- The `Workspace` widget provides a VSCode-style layout: vertical icon sidebar (`QTabWidget`), central workspace area for `TaskFrame` panels, a log window, and a status bar. - -### Abstract Base Classes / Protocols -Interfaces use `Protocol` with `@runtime_checkable` for structural contracts, and `ABCMeta`/`ABC` for explicit abstract hierarchies: -```python -from typing import Protocol, runtime_checkable -@runtime_checkable -class IMyProtocol(Protocol): - def my_method(self) -> None: ... -``` - -### RuntimeDict Pattern -For dictionaries with automatic save callbacks: -```python -self.data = RuntimeDict(callback=self.save) -self.data['key'] = 'value' # triggers callback automatically -``` - -### GUI State vs .env -Use `GuiStateService` (not `EnvManager`/`EnvironmentKeys`) to persist any GUI layout preferences between sessions — splitter positions, sidebar width, log window height, etc. State is stored as JSON in the platform user-data directory. - -```python -# Read layout state (always merge before writing back) -width = float(GuiStateService.get_geometry_state().get('sidebar_width', 0.33)) - -# Write layout state (get-merge-capture-save pattern) -state = GuiStateService.get_geometry_state() -state['sidebar_width'] = new_width -GuiStateService.capture_geometry_state(state) -GuiStateService.save() -``` - -`GuiStateService` is loaded at startup via `GuiStateService.load()` and saved explicitly after state changes. Use `PlatformDirectoryService` for resolving user data, log, and config directories — never hardcode paths or rely on `.env` for per-installation directory discovery. - -## File Organization - -``` -pyrox/ -├── interfaces/ # Abstract interfaces only — no implementations -│ ├── protocols/ # Structural Protocol definitions (INameable, IRunnable, etc.) -│ ├── scene/ # Scene/object/bridge interface contracts -│ ├── gui/ # IWorkspace, IViewport -│ ├── application.py # IApplication, IApplicationTask -│ ├── constants.py # EnvironmentKeys -│ └── services.py # IEnvironmentManager, ILogger, etc. -├── models/ # Concrete implementations -│ ├── protocols/ # Implementations of interface protocols (CoreMixin, Nameable, etc.) -│ ├── factory.py # MetaFactory, FactoryTypeMeta -│ ├── runtime.py # RuntimeDict -│ ├── meta.py # PyroxObject, SnowFlake, SliceableInt -│ ├── list.py # HashList, SafeList, TrackedList, Subscribable -│ ├── services.py # ServicesRunnableMixin (composed of Supports* mixins) -│ ├── task.py # ApplicationTask, ApplicationTaskFactory -│ ├── scene/ # Scene, SceneObject, SceneGroup, etc. -│ ├── physics/ # BasePhysicsBody, ConveyorBody, CrateBody, etc. -│ └── gui/ # All PyQt6 widgets -│ ├── frame.py # TaskFrame base class -│ ├── workspace.py # Workspace (VSCode-style layout) -│ ├── commandbar.py # CommandBar, CommandButton, CommandDropdown -│ ├── treeview.py # AttributeTreeView (lazy object introspection) -│ ├── objectexplorer.py # ObjectExplorer (scene object list) -│ ├── propertypanel.py # PropertyPanel (IHasProperties display/edit) -│ ├── logframe.py # LogFrame -│ ├── theme.py # DefaultTheme (dark theme constants) -│ ├── yamleditor.py # PyroxYamlEditor -│ ├── texteditor.py # TextEditorFrame -│ ├── contextmenu.py # PyroxContextMenu -│ └── sceneviewer/ # Full scene viewer (viewer, explorer, properties, bridge, toolbar) -├── services/ # Static-class utilities -│ ├── logging.py # log(), LoggingManager, StreamCapture -│ ├── env.py # EnvManager -│ ├── gui.py # GuiManager (PyQt6) -│ ├── gui_state.py # GuiStateService -│ ├── menu_registry.py # MenuRegistry, MenuItemDescriptor -│ ├── bus.py # EventBus, Event, EventType base classes -│ ├── status.py # StatusUpdateEventBus -│ ├── scene.py # SceneRunnerService, SceneEventBus, HasSceneMixin -│ ├── file.py # get_open_file, get_save_file, PlatformDirectoryService -│ ├── theme.py # ThemeManager -│ ├── collision.py # CollisionService, SpatialGrid -│ ├── physics.py # PhysicsEngineService -│ └── environment.py # EnvironmentService -├── tasks/ # Built-in ApplicationTask implementations -│ ├── builtin.py # FileTask, HelpTask, ToolsTask, ViewTask -│ └── sceneviewer.py # SceneviewerApplicationTask -└── utils/ # Build/dev utilities (not runtime code) -``` - -## Testing - -- Tests live in `services/test/`, `models/test/`, or adjacent `test/` dirs -- Use **pytest with fixtures** for all tests; configuration in `pytest.ini_options` in `pyproject.toml` -- Write tests as plain functions or pytest-style classes — **never** `unittest.TestCase` -- Use `pytest.fixture` (with appropriate `scope`) for shared setup and teardown -- Use `monkeypatch` or `unittest.mock.patch` for isolation; prefer `monkeypatch` for simple attribute/env overrides -- Assert with plain `assert` statements and `pytest.raises` — never `self.assert*` methods - -## Dependencies - -**Required**: Python 3.13.9+ (specified in `pyproject.toml`) -**Key libs**: `PyQt6`, `lxml`, `pandas`, `Pillow`, `platformdirs`, `pdfplumber`, `python-dotenv`, `pyyaml`, `pylogix`, `PyMuPDF`, `tomli` - -## Common Gotchas - -1. **No `tkinter`**: The GUI backend is PyQt6. `notebook.py` is legacy and should not be used in new code. -2. **Static services**: `GuiManager`, `EnvManager`, `LoggingManager`, `MenuRegistry` etc. are static classes — never instantiate them. -3. **Task auto-registration**: Any `ApplicationTask` subclass is auto-registered via `FactoryTypeMeta` and built at startup. Don't manually add tasks to the factory. -4. **`TaskFrame.root`**: Always add `task_frame.root` to a Qt layout/splitter — not the `TaskFrame` object itself. -5. **Git hooks**: Use `utils/setup_hooks.py` to sync README badges with `pyproject.toml` version. -6. **Virtual environment**: Always activate `.venv` before development. -7. **Relative imports**: Use relative imports within the `pyrox` package; absolute for external dependencies. - -## Version Management - -Version tracked in `pyproject.toml` and auto-synced to `README.md` badges via pre-commit hook. - -## When Extending Pyrox - -1. **New application type**: Subclass `Application` from `pyrox.application` -2. **New task**: Subclass `ApplicationTask`; implement `create_task_frame()` and register menu commands in `__init__` -3. **New service**: Add a static class to `pyrox/services/`; expose via `pyrox/services/__init__.py` -4. **New GUI widget**: Subclass `TaskFrame` in `models/gui/`; expose `.root: QWidget` for layout integration -5. **New interface**: Add a `Protocol` or `ABC` to `interfaces/` with no implementation code -6. **New event**: Subclass `EventType` (enum) and `Event` (dataclass), then `EventBus` — one class per domain - -## Cross-Project Context - -ControlRox builds on Pyrox — changes to interfaces or `ServicesRunnableMixin` affect all downstream applications. Maintain backward compatibility in interfaces. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 6fc2d14..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,158 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -```bash -# Install (handles Python 3.13 check, venv, dependencies, git hooks) -./install.sh - -# Manual install -pip install -e . --upgrade && python utils/setup_hooks.py - -# Run all tests -pytest - -# Run tests for a specific module -pytest pyrox/models/test/ -pytest pyrox/services/test/ - -# Run a single test file or test -pytest pyrox/models/test/test_factory.py -pytest pyrox/models/test/test_factory.py::TestMetaFactory::test_init_subclass_initializes_registered_types - -# Headless (CI) — required when PyQt6 widgets are involved -QT_QPA_PLATFORM=offscreen pytest - -# Build distributable -./build.sh - -# Extract TODOs to code_todos.md -python utils/extract_todos.py -``` - -Version is tracked in `pyproject.toml` and auto-synced to `README.md` badges via a pre-commit hook installed by `setup_hooks.py`. - -## Architecture - -Pyrox is a PyQt6-based application framework for industrial automation tooling. It follows a strict **interface → model → service → task** layering: - -``` -pyrox/interfaces/ # Protocols and ABCs only — zero implementation code -pyrox/models/ # Concrete implementations of interfaces -pyrox/services/ # Static utility classes (never instantiated) -pyrox/tasks/ # ApplicationTask subclasses (built-in features) -``` - -**Downstream applications** (e.g. ControlRox) depend on all four layers. Changes to `interfaces/` or `ServicesRunnableMixin` are breaking changes downstream. - -### ServicesRunnableMixin - -The base mixin for any class that needs framework services. Composed of focused sub-mixins that expose static service classes as properties: - -| Property | Provides | -|---|---| -| `self.env` / `self.env_keys` | `EnvManager` + `EnvironmentKeys` | -| `self.logging` / `self.log()` | `LoggingManager` | -| `self.gui` | `GuiManager` (PyQt6) | -| `self.root_window`, `self.file_menu`, etc. | Convenience accessors on `GuiManager` | -| `self.directory` | `PlatformDirectoryService` | - -`GuiManager`, `EnvManager`, `LoggingManager`, and `MenuRegistry` are static classes — never instantiate them. - -### ApplicationTask (plugin system) - -Subclass `ApplicationTask` to add a new panel/feature. `FactoryTypeMeta` auto-registers every subclass at class-definition time; they are built automatically at app startup — no manual factory registration needed. - -```python -class MyTask(ApplicationTask): - def __init__(self, application: IApplication) -> None: - super().__init__(application) - self.register_menu_command(menu=self.file_menu, ...) - - def create_task_frame(self) -> TaskFrame: - return MyTaskFrame(self.application.workspace.workspace_area) -``` - -Call `create_or_raise_frame()` (not `create_task_frame()` directly) to show/raise the window. - -### TaskFrame (GUI widgets) - -All custom widgets subclass `TaskFrame` (`pyrox.models.gui.frame`). Always add `task_frame.root` (the inner `QWidget`) to Qt layouts and splitters — not the `TaskFrame` object itself. - -`Workspace` (`pyrox.models.gui.workspace`) provides a VSCode-style layout: vertical icon sidebar, central area for `TaskFrame` panels, log window, and status bar. - -### Event Bus - -Decouple components with typed, domain-scoped buses: - -```python -class MyEventBus(EventBus[MyEventType, MyEvent]): - pass - -MyEventBus.subscribe(MyEventType.SOMETHING, callback) -MyEventBus.publish(MyEvent(event_type=MyEventType.SOMETHING, ...)) -``` - -Each `EventBus` subclass owns its own `_subscribers` dict. - -### GUI state persistence - -Use `GuiStateService` (not `.env`) to persist layout preferences (splitter ratios, sidebar widths, etc.) between sessions. State is stored as JSON via `PlatformDirectoryService` — never hardcode paths. - -```python -# Get-merge-capture-save pattern -state = GuiStateService.get_geometry_state() -state['sidebar_width'] = new_width -GuiStateService.capture_geometry_state(state) -GuiStateService.save() -``` - -### RuntimeDict - -A `dict` subclass that fires a callback on every write — use it for data that must auto-save: - -```python -self.data = RuntimeDict(callback=self.save) -self.data['key'] = 'value' # triggers self.save() automatically -``` - -## Code Conventions - -**Typing** — built-in generics only: -- `list[str]`, `dict[str, int]`, `X | Y` — never `List`, `Dict`, `Optional`, `Union` -- No forward-reference strings. Use `TYPE_CHECKING` guards instead. -- Do **not** add `from __future__ import annotations` to new files (some legacy files have it). - -**Logging**: -```python -from pyrox.services.logging import log -log(self).info('message') # inside instance methods -log(MyClass).warning('...') # outside instance context -``` - -**Interfaces** — use `Protocol` with `@runtime_checkable` for structural contracts; `ABC`/`ABCMeta` for explicit hierarchies. No implementation code in `interfaces/`. - -**GUI** — PyQt6 only. `notebook.py` in `models/gui/` is legacy tkinter; do not use it in new code. - -**Imports** — use relative imports within the `pyrox` package; absolute imports for external dependencies. - -## Testing Conventions - -- Plain pytest functions or classes — **never** `unittest.TestCase` -- `@pytest.fixture` with appropriate `scope` for shared setup -- `monkeypatch` for simple attribute/env overrides; `unittest.mock.patch` for complex mocks -- Plain `assert` statements and `pytest.raises` — no `self.assert*` -- Test files live adjacent to their module in a `test/` subdirectory - -## Extending Pyrox - -| Goal | Approach | -|---|---| -| New application entry point | Subclass `Application` from `pyrox.application` | -| New feature panel | Subclass `ApplicationTask`; implement `create_task_frame()` | -| New service | Static class in `pyrox/services/`; export from `pyrox/services/__init__.py` | -| New GUI widget | Subclass `TaskFrame` in `models/gui/`; expose `.root: QWidget` | -| New interface | `Protocol` or `ABC` in `interfaces/` with no implementation | -| New event domain | Subclass `EventType`, `Event`, and `EventBus` — one set per domain | \ No newline at end of file diff --git a/README.md b/README.md index 593976d..286ec80 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Python Version](https://img.shields.io/badge/python-3.13+-blue.svg)](https://www.python.org/downloads/) [![License](https://img.shields.io/badge/license-GPL--3.0-green.svg)](LICENSE) ![Development Status](https://img.shields.io/badge/status-beta-orange.svg) -![Version](https://img.shields.io/badge/version-3.6.7-blue.svg) +![Version](https://img.shields.io/badge/version-3.6.8-blue.svg) **Pyrox** is a Python back-end engine and application framework built on **PyQt6**. It provides a rich set of interfaces, models, services, and abstractions for building industrial automation and desktop applications. Pyrox is designed to be used as a foundation — downstream projects like [ControlRox](https://github.com/iroxusux/ControlRox) build their entire application layer on top of it. diff --git a/pyproject.toml b/pyproject.toml index 930913e..76fe0be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "pyrox" -version = "3.6.7" +version = "3.6.8" authors = [{ name = "Brian LaFond", email = "Brian.L.LaFond@gmail.com" }] description = "Python based irox engine." readme = "README.md" @@ -47,7 +47,7 @@ dependencies = [ ] license = { file = "LICENSE" } -keywords = ["python", "tkinter", "api"] +keywords = ["python", "pyqt6", "api"] [tool.hatch.build.targets.wheel] include = ["pyrox/**"] diff --git a/pyrox/models/task.py b/pyrox/models/task.py index a86c7f5..f13b47d 100644 --- a/pyrox/models/task.py +++ b/pyrox/models/task.py @@ -137,7 +137,7 @@ def register_menu_command( menu_path=registry_path, menu_widget=menu, menu_index=index, - owner=self.__class__.__name__, + owner=type(self).__name__, action=action, command=command, category=category, diff --git a/pyrox/services/menu_registry.py b/pyrox/services/menu_registry.py index a0848ba..89f884d 100644 --- a/pyrox/services/menu_registry.py +++ b/pyrox/services/menu_registry.py @@ -283,6 +283,20 @@ def clear(cls) -> None: cls._owner_index.clear() log(cls).debug("Cleared menu registry") + @classmethod + def clear_by_owner(cls, owner: str) -> None: + """Clear the registry of a specific owner's items. + Useful for testing. + """ + if owner not in cls._owner_index: + return + + for _, item in cls._registry.copy().items(): + if item.owner == owner: + del cls._registry + + del cls._owner_index[owner] + @classmethod def get_all_items(cls) -> Dict[str, MenuItemDescriptor]: """Get all registered menu items.