psforge 0.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.
psforge/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """PSForge - AI-Powered Photoshop Automation via MCP."""
2
+
3
+ from psforge.app import __app_name__, __description__, __version__
4
+
5
+ __all__ = ["__version__", "__app_name__", "__description__"]
psforge/app.py ADDED
@@ -0,0 +1,5 @@
1
+ """PSForge application metadata."""
2
+
3
+ __version__ = "0.2.0"
4
+ __app_name__ = "PSForge"
5
+ __description__ = "AI-Powered Photoshop Automation via MCP"
psforge/decorators.py ADDED
@@ -0,0 +1,91 @@
1
+ """MCP tool decorators for logging, debugging, and error handling."""
2
+
3
+ import functools
4
+ import traceback
5
+ from typing import Any, Callable
6
+
7
+ from loguru import logger
8
+
9
+
10
+ def log_tool_call(func: Callable) -> Callable:
11
+ """Log tool call entry and exit with parameters and results.
12
+
13
+ Args:
14
+ func: The tool function to wrap.
15
+
16
+ Returns:
17
+ Wrapped function with logging.
18
+ """
19
+
20
+ @functools.wraps(func)
21
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
22
+ tool_name = func.__name__
23
+ logger.info(f"Tool called: {tool_name}")
24
+ logger.debug(f"Tool params: args={args}, kwargs={kwargs}")
25
+
26
+ try:
27
+ result = func(*args, **kwargs)
28
+ if isinstance(result, dict) and "success" in result:
29
+ status = "SUCCESS" if result["success"] else "FAILED"
30
+ logger.info(f"Tool {tool_name} {status}")
31
+ else:
32
+ logger.info(f"Tool {tool_name} completed")
33
+ return result
34
+ except Exception as e:
35
+ logger.error(f"Tool {tool_name} raised exception: {e}")
36
+ raise
37
+
38
+ return wrapper
39
+
40
+
41
+ def debug_tool(func: Callable) -> Callable:
42
+ """Enhanced error handling decorator that ensures consistent error format.
43
+
44
+ All errors are caught and converted to standardized dict format:
45
+ {
46
+ "success": False,
47
+ "error": "Brief error message",
48
+ "detailed_error": "Full error with traceback",
49
+ "context": {...} # Current PS state (if available)
50
+ }
51
+
52
+ Args:
53
+ func: The tool function to wrap.
54
+
55
+ Returns:
56
+ Wrapped function with enhanced error handling.
57
+ """
58
+
59
+ @functools.wraps(func)
60
+ def wrapper(*args: Any, **kwargs: Any) -> dict:
61
+ try:
62
+ result = func(*args, **kwargs)
63
+ # If function returns dict with success field, pass through
64
+ if isinstance(result, dict):
65
+ return result
66
+ # Otherwise wrap in success format
67
+ return {"success": True, "result": result}
68
+ except Exception as e:
69
+ error_msg = str(e)
70
+ detailed_error = traceback.format_exc()
71
+
72
+ logger.error(f"Tool {func.__name__} failed: {error_msg}")
73
+ logger.debug(f"Full traceback:\n{detailed_error}")
74
+
75
+ # Try to get context even on error
76
+ context = None
77
+ try:
78
+ from psforge.ps_adapter.context import get_context_info
79
+
80
+ context = get_context_info()
81
+ except Exception as ctx_error:
82
+ logger.debug(f"Could not retrieve context after error: {ctx_error}")
83
+
84
+ return {
85
+ "success": False,
86
+ "error": error_msg,
87
+ "detailed_error": detailed_error,
88
+ "context": context,
89
+ }
90
+
91
+ return wrapper
@@ -0,0 +1,12 @@
1
+ """Photoshop adapter layer - manages PS connection, execution, and context."""
2
+
3
+ from psforge.ps_adapter.application import PhotoshopApp
4
+ from psforge.ps_adapter.context import get_context_info
5
+ from psforge.ps_adapter.process_guard import check_photoshop_alive, restart_photoshop
6
+
7
+ __all__ = [
8
+ "PhotoshopApp",
9
+ "get_context_info",
10
+ "check_photoshop_alive",
11
+ "restart_photoshop",
12
+ ]
@@ -0,0 +1,5 @@
1
+ """Action Manager interface for Photoshop's Descriptor API.
2
+
3
+ Currently unused placeholder. Reserved for future state-caching
4
+ or advanced descriptor-based operations.
5
+ """
@@ -0,0 +1,149 @@
1
+ """Photoshop application singleton and connection management."""
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ from loguru import logger
7
+ from photoshop import Session
8
+
9
+ from psforge.ps_adapter.utils import retry_on_ps_error
10
+
11
+
12
+ class PhotoshopApp:
13
+ """Singleton class for managing Photoshop application connection."""
14
+
15
+ _instance = None
16
+ _session = None
17
+ _app = None
18
+ _operation_count = 0
19
+ _max_operations_before_restart = 1000
20
+
21
+ def __new__(cls):
22
+ """Ensure only one instance exists (singleton pattern)."""
23
+ if cls._instance is None:
24
+ cls._instance = super().__new__(cls)
25
+ return cls._instance
26
+
27
+ def __init__(self):
28
+ """Initialize Photoshop connection."""
29
+ if self._app is None:
30
+ self._connect()
31
+
32
+ def _connect(self) -> None:
33
+ """Establish connection to Photoshop."""
34
+ try:
35
+ logger.info("Connecting to Photoshop...")
36
+
37
+ # Create session
38
+ self._session = Session()
39
+ self._session.__enter__()
40
+ self._app = self._session.app
41
+
42
+ # Disable all dialogs to prevent blocking
43
+ self._execute_javascript_internal("app.displayDialogs = DialogModes.NO;")
44
+
45
+ logger.info("Successfully connected to Photoshop")
46
+
47
+ except Exception as e:
48
+ logger.error(f"Failed to connect to Photoshop: {e}")
49
+ raise ConnectionError(f"Could not connect to Photoshop: {e}")
50
+
51
+ def _disconnect(self) -> None:
52
+ """Disconnect from Photoshop."""
53
+ try:
54
+ if self._session:
55
+ self._session.__exit__(None, None, None)
56
+ self._session = None
57
+ self._app = None
58
+ logger.info("Disconnected from Photoshop")
59
+ except Exception as e:
60
+ logger.warning(f"Error during disconnect: {e}")
61
+
62
+ def reconnect(self) -> None:
63
+ """Reconnect to Photoshop (useful after restart)."""
64
+ logger.info("Reconnecting to Photoshop...")
65
+ self._disconnect()
66
+ time.sleep(2) # Give PS time to fully start
67
+ self._connect()
68
+ self._operation_count = 0
69
+
70
+ @property
71
+ def app(self):
72
+ """Get the Photoshop Application object."""
73
+ if self._app is None:
74
+ self._connect()
75
+ return self._app
76
+
77
+ def _execute_javascript_internal(self, script: str) -> Any:
78
+ """Internal JavaScript execution (single attempt, retry handled by tenacity).
79
+
80
+ Args:
81
+ script: JavaScript/ExtendScript code to execute.
82
+
83
+ Returns:
84
+ Result from JavaScript execution.
85
+ """
86
+ if self._app is None:
87
+ self._connect()
88
+
89
+ return self._app.doJavaScript(script)
90
+
91
+ @retry_on_ps_error(max_attempts=3, base_wait=1.0)
92
+ def execute_javascript(self, script: str) -> Any:
93
+ """Execute JavaScript/ExtendScript in Photoshop with retry logic.
94
+
95
+ Args:
96
+ script: JavaScript/ExtendScript code to execute.
97
+
98
+ Returns:
99
+ Result from JavaScript execution.
100
+ """
101
+ # Increment operation counter
102
+ self._operation_count += 1
103
+
104
+ # Optional: Auto-restart PS after many operations to prevent memory leaks
105
+ # Uncomment if you experience stability issues
106
+ # if self._operation_count >= self._max_operations_before_restart:
107
+ # logger.info(f"Reached {self._operation_count} operations, considering restart...")
108
+ # from psforge.ps_adapter.process_guard import restart_photoshop
109
+ # restart_photoshop()
110
+ # self.reconnect()
111
+
112
+ return self._execute_javascript_internal(script)
113
+
114
+ def get_active_document(self):
115
+ """Get the currently active document.
116
+
117
+ Returns:
118
+ Active document object, or None if no document is open.
119
+ """
120
+ try:
121
+ if self.app.documents.length == 0:
122
+ return None
123
+ return self.app.activeDocument
124
+ except Exception as e:
125
+ logger.error(f"Failed to get active document: {e}")
126
+ return None
127
+
128
+ def has_active_document(self) -> bool:
129
+ """Check if there is an active document.
130
+
131
+ Returns:
132
+ True if a document is open and active, False otherwise.
133
+ """
134
+ try:
135
+ return self.app.documents.length > 0
136
+ except Exception:
137
+ return False
138
+
139
+ def get_photoshop_version(self) -> str:
140
+ """Get Photoshop version string.
141
+
142
+ Returns:
143
+ Version string, or 'Unknown' if cannot be determined.
144
+ """
145
+ try:
146
+ return str(self.app.version)
147
+ except Exception as e:
148
+ logger.error(f"Failed to get PS version: {e}")
149
+ return "Unknown"
@@ -0,0 +1,219 @@
1
+ """Context tracking for Photoshop state - documents, layers, selections."""
2
+
3
+ from typing import Any
4
+
5
+ from loguru import logger
6
+
7
+ from psforge.ps_adapter.application import PhotoshopApp
8
+
9
+
10
+ def get_context_info() -> dict[str, Any]:
11
+ """Get comprehensive context information about current Photoshop state.
12
+
13
+ This function executes JavaScript in Photoshop to retrieve:
14
+ - Whether a document is open
15
+ - Document details (name, dimensions, resolution, color mode, layer count, selection status)
16
+ - Active layer details (name, type, opacity, blend mode, visibility, bounds, etc.)
17
+
18
+ Returns:
19
+ Dictionary containing context information with structure:
20
+ {
21
+ "has_document": bool,
22
+ "document": {
23
+ "name": str,
24
+ "width": int,
25
+ "height": int,
26
+ "resolution": float,
27
+ "color_mode": str,
28
+ "bit_depth": int,
29
+ "layer_count": int,
30
+ "has_selection": bool,
31
+ } or None,
32
+ "active_layer": {
33
+ "name": str,
34
+ "kind": str,
35
+ "opacity": float,
36
+ "blend_mode": str,
37
+ "visible": bool,
38
+ "locked": bool,
39
+ "is_background": bool,
40
+ "bounds": {"left": int, "top": int, "right": int, "bottom": int}
41
+ } or None
42
+ }
43
+ """
44
+ ps_app = PhotoshopApp()
45
+
46
+ # JavaScript to extract context information
47
+ context_script = """
48
+ (function() {
49
+ var context = {
50
+ has_document: false,
51
+ document: null,
52
+ active_layer: null
53
+ };
54
+
55
+ // Check if any document is open
56
+ if (app.documents.length === 0) {
57
+ return JSON.stringify(context);
58
+ }
59
+
60
+ context.has_document = true;
61
+ var doc = app.activeDocument;
62
+
63
+ // Get document information
64
+ var colorModeMap = {
65
+ 1: "BITMAP",
66
+ 2: "GRAYSCALE",
67
+ 3: "INDEXED",
68
+ 4: "RGB",
69
+ 5: "CMYK",
70
+ 7: "MULTICHANNEL",
71
+ 8: "DUOTONE",
72
+ 9: "LAB"
73
+ };
74
+
75
+ var bitDepthMap = {
76
+ 1: 1,
77
+ 8: 8,
78
+ 16: 16,
79
+ 32: 32
80
+ };
81
+
82
+ context.document = {
83
+ name: doc.name,
84
+ width: parseInt(doc.width),
85
+ height: parseInt(doc.height),
86
+ resolution: parseFloat(doc.resolution),
87
+ color_mode: colorModeMap[doc.mode] || "UNKNOWN",
88
+ bit_depth: bitDepthMap[doc.bitsPerChannel] || 8,
89
+ layer_count: doc.layers.length,
90
+ has_selection: false
91
+ };
92
+
93
+ // Check for active selection
94
+ try {
95
+ var selBounds = doc.selection.bounds;
96
+ if (selBounds) {
97
+ context.document.has_selection = true;
98
+ }
99
+ } catch(e) {
100
+ context.document.has_selection = false;
101
+ }
102
+
103
+ // Get active layer information
104
+ if (doc.activeLayer) {
105
+ var layer = doc.activeLayer;
106
+
107
+ var layerKindMap = {
108
+ 1: "NORMAL",
109
+ 2: "TEXT",
110
+ 3: "SOLIDFILL",
111
+ 4: "GRADIENTFILL",
112
+ 5: "PATTERNFILL",
113
+ 6: "LEVELS",
114
+ 7: "CURVES",
115
+ 8: "COLORBALANCE",
116
+ 9: "BRIGHTNESSCONTRAST",
117
+ 10: "HUESATURATION",
118
+ 11: "SELECTIVECOLOR",
119
+ 12: "CHANNELMIXER",
120
+ 13: "GRADIENTMAP",
121
+ 14: "INVERT",
122
+ 15: "THRESHOLD",
123
+ 16: "POSTERIZE",
124
+ 17: "SMARTOBJECT",
125
+ 18: "PHOTOFILTER",
126
+ 19: "EXPOSURE",
127
+ 20: "VIBRANCE",
128
+ 21: "VIDEO",
129
+ 22: "3D",
130
+ 23: "LAYER3D"
131
+ };
132
+
133
+ var blendModeMap = {
134
+ "NORMAL": "NORMAL",
135
+ "DISSOLVE": "DISSOLVE",
136
+ "DARKEN": "DARKEN",
137
+ "MULTIPLY": "MULTIPLY",
138
+ "COLORBURN": "COLORBURN",
139
+ "LINEARBURN": "LINEARBURN",
140
+ "DARKERCOLOR": "DARKERCOLOR",
141
+ "LIGHTEN": "LIGHTEN",
142
+ "SCREEN": "SCREEN",
143
+ "COLORDODGE": "COLORDODGE",
144
+ "LINEARDODGE": "LINEARDODGE",
145
+ "LIGHTERCOLOR": "LIGHTERCOLOR",
146
+ "OVERLAY": "OVERLAY",
147
+ "SOFTLIGHT": "SOFTLIGHT",
148
+ "HARDLIGHT": "HARDLIGHT",
149
+ "VIVIDLIGHT": "VIVIDLIGHT",
150
+ "LINEARLIGHT": "LINEARLIGHT",
151
+ "PINLIGHT": "PINLIGHT",
152
+ "HARDMIX": "HARDMIX",
153
+ "DIFFERENCE": "DIFFERENCE",
154
+ "EXCLUSION": "EXCLUSION",
155
+ "SUBTRACT": "SUBTRACT",
156
+ "DIVIDE": "DIVIDE",
157
+ "HUE": "HUE",
158
+ "SATURATION": "SATURATION",
159
+ "COLOR": "COLOR",
160
+ "LUMINOSITY": "LUMINOSITY"
161
+ };
162
+
163
+ var isBackground = false;
164
+ try {
165
+ isBackground = layer.isBackgroundLayer;
166
+ } catch(e) {
167
+ isBackground = false;
168
+ }
169
+
170
+ var bounds = {left: 0, top: 0, right: 0, bottom: 0};
171
+ try {
172
+ bounds = {
173
+ left: parseInt(layer.bounds[0]),
174
+ top: parseInt(layer.bounds[1]),
175
+ right: parseInt(layer.bounds[2]),
176
+ bottom: parseInt(layer.bounds[3])
177
+ };
178
+ } catch(e) {}
179
+
180
+ context.active_layer = {
181
+ name: layer.name,
182
+ kind: layerKindMap[layer.kind] || "UNKNOWN",
183
+ opacity: parseFloat(layer.opacity),
184
+ blend_mode: blendModeMap[layer.blendMode.toString()] || "NORMAL",
185
+ visible: layer.visible,
186
+ locked: layer.allLocked || layer.pixelsLocked,
187
+ is_background: isBackground,
188
+ bounds: bounds
189
+ };
190
+ }
191
+
192
+ return JSON.stringify(context);
193
+ })();
194
+ """
195
+
196
+ try:
197
+ result = ps_app.execute_javascript(context_script)
198
+
199
+ # Parse JSON result
200
+ import json
201
+
202
+ if isinstance(result, str):
203
+ context = json.loads(result)
204
+ else:
205
+ context = result
206
+
207
+ logger.debug(f"Context retrieved: {context}")
208
+ return context
209
+
210
+ except Exception as e:
211
+ logger.error(f"Failed to get context info: {e}")
212
+
213
+ # Return minimal error context
214
+ return {
215
+ "has_document": False,
216
+ "document": None,
217
+ "active_layer": None,
218
+ "error": str(e),
219
+ }
@@ -0,0 +1,109 @@
1
+ """Process guard for Photoshop - health checks and restart."""
2
+
3
+ import os
4
+ import subprocess
5
+ import time
6
+
7
+ import psutil
8
+ from loguru import logger
9
+
10
+
11
+ def check_photoshop_alive() -> bool:
12
+ """Check if Photoshop process is running.
13
+
14
+ Returns:
15
+ True if Photoshop.exe is running, False otherwise.
16
+ """
17
+ try:
18
+ for proc in psutil.process_iter(["name"]):
19
+ if proc.info["name"] and "photoshop.exe" in proc.info["name"].lower():
20
+ return True
21
+ return False
22
+ except Exception as e:
23
+ logger.error(f"Failed to check Photoshop process: {e}")
24
+ return False
25
+
26
+
27
+ def kill_photoshop_process() -> bool:
28
+ """Force kill all Photoshop processes.
29
+
30
+ Returns:
31
+ True if any process was killed, False otherwise.
32
+ """
33
+ killed = False
34
+
35
+ try:
36
+ for proc in psutil.process_iter(["name", "pid"]):
37
+ if proc.info["name"] and "photoshop.exe" in proc.info["name"].lower():
38
+ logger.warning(f"Force killing Photoshop process (PID: {proc.info['pid']})")
39
+ try:
40
+ process = psutil.Process(proc.info["pid"])
41
+ process.kill()
42
+ killed = True
43
+ except Exception as e:
44
+ logger.error(f"Failed to kill process {proc.info['pid']}: {e}")
45
+
46
+ if killed:
47
+ time.sleep(2) # Wait for processes to fully terminate
48
+
49
+ except Exception as e:
50
+ logger.error(f"Error while killing Photoshop: {e}")
51
+
52
+ return killed
53
+
54
+
55
+ def restart_photoshop(photoshop_path: str | None = None) -> bool:
56
+ """Restart Photoshop by killing existing processes and starting a new one.
57
+
58
+ Args:
59
+ photoshop_path: Optional path to Photoshop executable.
60
+ If not provided, attempts to find it automatically.
61
+
62
+ Returns:
63
+ True if restart was successful, False otherwise.
64
+ """
65
+ logger.info("Restarting Photoshop...")
66
+
67
+ # Kill existing processes
68
+ kill_photoshop_process()
69
+
70
+ # Try to start Photoshop
71
+ if photoshop_path is None:
72
+ # Common Photoshop installation paths
73
+ possible_paths = [
74
+ r"C:\Program Files\Adobe\Adobe Photoshop 2024\Photoshop.exe",
75
+ r"C:\Program Files\Adobe\Adobe Photoshop 2023\Photoshop.exe",
76
+ r"C:\Program Files\Adobe\Adobe Photoshop 2022\Photoshop.exe",
77
+ r"C:\Program Files\Adobe\Adobe Photoshop CC 2019\Photoshop.exe",
78
+ ]
79
+
80
+ for path in possible_paths:
81
+ if os.path.exists(path):
82
+ photoshop_path = path
83
+ break
84
+
85
+ if photoshop_path and os.path.exists(photoshop_path):
86
+ try:
87
+ logger.info(f"Starting Photoshop from: {photoshop_path}")
88
+ subprocess.Popen([photoshop_path], shell=False)
89
+
90
+ # Wait for Photoshop to start
91
+ max_wait = 30
92
+ for i in range(max_wait):
93
+ time.sleep(1)
94
+ if check_photoshop_alive():
95
+ logger.info("Photoshop started successfully")
96
+ time.sleep(3) # Additional wait for full initialization
97
+ return True
98
+
99
+ logger.error(f"Photoshop did not start within {max_wait} seconds")
100
+ return False
101
+
102
+ except Exception as e:
103
+ logger.error(f"Failed to start Photoshop: {e}")
104
+ return False
105
+ else:
106
+ logger.warning("Could not find Photoshop executable path")
107
+ logger.info("Please start Photoshop manually")
108
+ return False
109
+
@@ -0,0 +1,80 @@
1
+ """Utility functions and decorators for PS adapter."""
2
+
3
+ import functools
4
+ from typing import Any, Callable
5
+
6
+ from loguru import logger
7
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
8
+
9
+
10
+ def retry_on_ps_error(max_attempts: int = 3, base_wait: float = 1.0) -> Callable:
11
+ """Retry decorator for Photoshop operations that may fail transiently.
12
+
13
+ Args:
14
+ max_attempts: Maximum number of retry attempts.
15
+ base_wait: Base wait time in seconds (exponential backoff).
16
+
17
+ Returns:
18
+ Decorator function.
19
+ """
20
+
21
+ def decorator(func: Callable) -> Callable:
22
+ @functools.wraps(func)
23
+ @retry(
24
+ retry=retry_if_exception_type((ConnectionError, TimeoutError, RuntimeError)),
25
+ stop=stop_after_attempt(max_attempts),
26
+ wait=wait_exponential(multiplier=base_wait, min=base_wait, max=10),
27
+ reraise=True,
28
+ )
29
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
30
+ try:
31
+ return func(*args, **kwargs)
32
+ except Exception as e:
33
+ logger.warning(f"PS operation {func.__name__} failed, will retry: {e}")
34
+ raise
35
+
36
+ return wrapper
37
+
38
+ return decorator
39
+
40
+
41
+ def validate_numeric_range(value: float, min_val: float, max_val: float, param_name: str) -> None:
42
+ """Validate that a numeric parameter is within the allowed range.
43
+
44
+ Args:
45
+ value: The value to validate.
46
+ min_val: Minimum allowed value.
47
+ max_val: Maximum allowed value.
48
+ param_name: Parameter name for error messages.
49
+
50
+ Raises:
51
+ ValueError: If value is out of range.
52
+ """
53
+ if not (min_val <= value <= max_val):
54
+ raise ValueError(f"{param_name} must be between {min_val} and {max_val}, got {value}")
55
+
56
+
57
+ def validate_color_channel(value: int, channel_name: str = "color") -> None:
58
+ """Validate that a color channel value is in valid range (0-255).
59
+
60
+ Args:
61
+ value: The color channel value.
62
+ channel_name: Channel name for error messages.
63
+
64
+ Raises:
65
+ ValueError: If value is out of range.
66
+ """
67
+ if not (0 <= value <= 255):
68
+ raise ValueError(f"{channel_name} must be between 0 and 255, got {value}")
69
+
70
+
71
+ def js_escape_string(text: str) -> str:
72
+ """Escape a string for safe use in JavaScript/ExtendScript.
73
+
74
+ Args:
75
+ text: The string to escape.
76
+
77
+ Returns:
78
+ Escaped string safe for JavaScript.
79
+ """
80
+ return text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n").replace("\r", "\\r")