sapwright 0.0.1__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.
sapwright/__init__.py ADDED
@@ -0,0 +1,213 @@
1
+ import logging
2
+ from collections.abc import Callable
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from types import TracebackType
6
+ from typing import final
7
+
8
+ from sapwright._utils import default_password_generator, launch_saplogon
9
+ from sapwright.connection_manager import ConnectionManager
10
+ from sapwright.exceptions import SAPLoginError
11
+ from sapwright.objects import GuiSession
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @dataclass
17
+ class SAPLoginScreenElements:
18
+ client = "wnd[0]/usr/txtRSYST-MANDT"
19
+ username = "wnd[0]/usr/txtRSYST-BNAME"
20
+ password = "wnd[0]/usr/pwdRSYST-BCODE"
21
+ language = "wnd[0]/usr/txtRSYST-LANGU"
22
+ terminate_other_sessions_radio = "wnd[1]/usr/radMULTI_LOGON_OPT1"
23
+ reset_password = r"wnd[1]/usr/lblRSYST-NCODE_TEXT"
24
+ new_password = "wnd[1]/usr/pwdRSYST-NCODE"
25
+ repeat_password = "wnd[1]/usr/pwdRSYST-NCOD2"
26
+
27
+
28
+ @final
29
+ class Sapwright:
30
+ def __init__(
31
+ self,
32
+ username: str,
33
+ password: str,
34
+ connection_string: str | None = None,
35
+ connection_name: str | None = None,
36
+ client: int | None = None,
37
+ language: str | None = None,
38
+ terminate_other_sessions: bool = True,
39
+ exe_path: str | Path | None = None,
40
+ password_generator: Callable[[], str] | None = None,
41
+ ):
42
+ """
43
+ First preference is given to the connection_string, then connection_name.
44
+
45
+ password_generator is called to produce a new password when SAP asks
46
+ for a password change during login. Defaults to month and year of
47
+ today's date, e.g. "Jan@2026".
48
+ """
49
+ if not connection_string and not connection_name:
50
+ raise ValueError("connection_string or connection_name must be provided.")
51
+
52
+ self._username = username
53
+ self._password = password
54
+ self._connection_string = connection_string
55
+ self._connection_name = connection_name
56
+ self._client = client
57
+ self._language = language
58
+ self._terminate_other_sessions = terminate_other_sessions
59
+ self._exe_path = exe_path
60
+ self._password_generator = password_generator or default_password_generator
61
+ self._connection_mgr = ConnectionManager()
62
+ self._session: GuiSession | None = None
63
+
64
+ def __enter__(self):
65
+ return self.connect()
66
+
67
+ def __exit__(
68
+ self,
69
+ exc_type: type[BaseException] | None,
70
+ exc_value: BaseException | None,
71
+ traceback: TracebackType | None,
72
+ ) -> None:
73
+ if self._session:
74
+ self._session.close()
75
+
76
+ def connect(self) -> GuiSession:
77
+ """Attach to the user's existing connection, or open a new one if none is found.
78
+
79
+ When opening, connection_string takes precedence over connection_name.
80
+ """
81
+ session = self._attach_existing() or self._open_new()
82
+ self._login(session)
83
+ self._session = session
84
+ return session
85
+
86
+ def close_connection(self):
87
+ """Closes the SAP connection including all its sessions.
88
+
89
+ Logs off by sending "/nex" to the session, falling back to
90
+ connection.CloseConnection() if the command fails.
91
+ """
92
+ session, self._session = self._session, None
93
+ if session is None:
94
+ self._connection_mgr.close_connection()
95
+ return
96
+
97
+ try:
98
+ session.send_command("/nex")
99
+ except Exception as e:
100
+ logger.debug(f"'/nex' failed, closing connection directly: {e}")
101
+ self._connection_mgr.close_connection()
102
+
103
+ def _attach_existing(self) -> GuiSession | None:
104
+ # find existing connections of the user
105
+ connection = self._connection_mgr.find_connection_by_user(
106
+ self._username,
107
+ connection_string=self._connection_string,
108
+ connection_name=self._connection_name,
109
+ )
110
+ if not connection:
111
+ return None
112
+
113
+ logger.debug(
114
+ f"Attaching to existing connection '{connection.Description}' for user: {self._username}"
115
+ )
116
+ return GuiSession(connection.Children(0))
117
+
118
+ def _open_new(self) -> GuiSession:
119
+ logger.info(
120
+ f"No existing connection with given connection_string or connection_name found for user: {self._username}, opening a new one"
121
+ )
122
+ launch_saplogon(self._exe_path)
123
+ return self._connection_mgr.open_connection(
124
+ connection_string=self._connection_string,
125
+ connection_name=self._connection_name,
126
+ )
127
+
128
+ def _login(self, session: GuiSession):
129
+ """Performs SAP login with provided credentials
130
+
131
+ This method performs SAP login with the provided credentials, handling
132
+ multiple login attempts, incorrect password attempts, terminating other
133
+ sessions logged in other computers, and dismissing any additional popups
134
+ that may appear after login.
135
+
136
+ Raises
137
+ ------
138
+ SAPLoginError
139
+ If the login process fails
140
+ """
141
+ user_field = session.find_by_id(
142
+ SAPLoginScreenElements.username, raise_error=False
143
+ )
144
+ if not user_field:
145
+ logger.debug("Login screen not found, assuming user is already logged in")
146
+ return
147
+
148
+ logger.debug(f"Logging in as: {self._username}")
149
+ user_field.text = self._username
150
+ session.find_by_id(SAPLoginScreenElements.password).text = self._password
151
+
152
+ if self._client:
153
+ session.find_by_id(SAPLoginScreenElements.client).text = self._client
154
+
155
+ if self._language:
156
+ session.find_by_id(SAPLoginScreenElements.language).text = self._language
157
+
158
+ session.press_enter()
159
+
160
+ # Check for immediate login errors (e.g invalid credentials)
161
+ status = session.raise_for_status(
162
+ message="Login failed", exception=SAPLoginError
163
+ )
164
+
165
+ # Handle multi-logon
166
+ if status.text and "already logged on" in status.text.lower():
167
+ self._handle_multi_logon(session)
168
+
169
+ # Handle new/reset password dialog
170
+ if session.find_by_id(SAPLoginScreenElements.reset_password, False):
171
+ self._change_password(session)
172
+
173
+ # Dismiss any other popups
174
+ session.dismiss_popups(limit=10)
175
+
176
+ def _change_password(self, session: GuiSession):
177
+ logger.debug("Password change requested")
178
+ new_password = self._password_generator()
179
+ if not new_password:
180
+ raise SAPLoginError("password_generator must return a non-empty string.")
181
+
182
+ session.find_by_id(SAPLoginScreenElements.new_password).text = new_password
183
+ session.find_by_id(SAPLoginScreenElements.repeat_password).text = new_password
184
+ session.press_enter(1)
185
+
186
+ session.raise_for_status(
187
+ message="Password change failed", exception=SAPLoginError
188
+ )
189
+ if session.find_by_id(SAPLoginScreenElements.reset_password, False):
190
+ raise SAPLoginError("Password change failed: new password was rejected.")
191
+
192
+ self._password = new_password
193
+ logger.info(f"Password changed for user: {self._username}")
194
+
195
+ def _handle_multi_logon(self, session: GuiSession):
196
+ # TODO: If server supports multi-logon, allow user to multi-logon
197
+
198
+ logger.debug("Multi-logon detected")
199
+ if not self._terminate_other_sessions:
200
+ raise SAPLoginError("User already logged on in some other session.")
201
+
202
+ logger.debug("Terminating other sessions")
203
+ try:
204
+ rb = session.find_by_id(
205
+ SAPLoginScreenElements.terminate_other_sessions_radio, raise_error=False
206
+ )
207
+ if rb:
208
+ rb.select()
209
+ except Exception as e:
210
+ logger.warning(
211
+ f"Could not find multi-logon terminate radio button, attempting to press enter key: {e}"
212
+ )
213
+ session.press_enter(1)
sapwright/_utils.py ADDED
@@ -0,0 +1,140 @@
1
+ import datetime
2
+ import logging
3
+ import sys
4
+ import winreg
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import psutil
9
+ import pywintypes
10
+ import win32com.client as win32
11
+ from pywinauto import Application
12
+ from pywinauto.timings import TimeoutError as PywinautoTimeoutError
13
+
14
+ from sapwright.exceptions import SAPLogonError
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ SAPLOGON_EXE = "saplogon.exe"
19
+ DEFAULT_TIMEOUT = 60
20
+ REGISTRY_KEYS = (
21
+ r"SOFTWARE\SAP\SAP Shared",
22
+ r"SOFTWARE\WOW6432Node\SAP\SAP Shared",
23
+ )
24
+
25
+
26
+ def launch_saplogon(exe_path: str | Path | None = None, timeout: int = 60) -> None:
27
+ """Launches the SAP Logon Pad executable and waits for it to become ready.
28
+ If no exe_path is provided, saplogon.exe is resolved from windows registry.
29
+
30
+ Parameters
31
+ ----------
32
+ exe_path : str | Path | None, optional
33
+ Path to the saplogon executable, by default None
34
+ timeout : int, optional
35
+ Maximum time to wait for the application to become ready, by default 30
36
+
37
+ Raises
38
+ ------
39
+ NotImplementedError
40
+ If called on non-windows platform
41
+ FileNotFoundError
42
+ If the saplogon executable cannot be found
43
+ TimeoutError
44
+ If timeout waiting for the main window after launching.
45
+ SAPLogonError
46
+ If the SAP Logon fails to launch
47
+ """
48
+ if sys.platform != "win32":
49
+ raise NotImplementedError("Launch SAP Logon only supported on Windows")
50
+
51
+ if is_process_running(SAPLOGON_EXE):
52
+ logger.debug("SAP Logon is already running")
53
+ return
54
+
55
+ path = Path(exe_path) if exe_path else get_saplogon_path()
56
+ if path is None or not path.is_file():
57
+ raise FileNotFoundError(
58
+ f"SAP Logon executable not found at {path or 'registry'}"
59
+ )
60
+
61
+ logger.debug(f"Launching SAP Logon from '{path}'")
62
+ try:
63
+ process = psutil.Popen([path])
64
+ app = Application().connect(process=process.pid, timeout=timeout)
65
+ app.top_window().wait("ready", timeout=timeout)
66
+ except PywinautoTimeoutError as e:
67
+ raise TimeoutError(f"Timeout waiting for SAP Logon to become ready: {e}") from e
68
+ except Exception as e:
69
+ msg = f"Failed to connect to SAP Logon: {e}"
70
+ logger.error(msg)
71
+ raise SAPLogonError(msg) from e
72
+
73
+ logger.debug("SAP Logon launched and ready")
74
+
75
+
76
+ def is_process_running(process_name: str) -> bool:
77
+ """Checks if a process with the given name is running.
78
+
79
+ Parameters
80
+ ----------
81
+ process_name : str
82
+ Name of the process
83
+
84
+ Returns
85
+ -------
86
+ bool
87
+ True if the process is running
88
+
89
+ Example
90
+ -------
91
+ >>> is_process_running("saplogon.exe")
92
+ True
93
+ """
94
+ target = process_name.lower()
95
+ for proc in psutil.process_iter(["name"]):
96
+ if (proc.info["name"] or "").lower() == target:
97
+ return True
98
+ return False
99
+
100
+
101
+ def get_saplogon_path() -> Path | None:
102
+ """Finds the saplogon executable path from the windows registry, or None if not found.
103
+
104
+ Returns
105
+ -------
106
+ Path | None
107
+ Path to the saplogon.exe or None if not found
108
+ """
109
+ for key_path in REGISTRY_KEYS:
110
+ try:
111
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, key_path) as key:
112
+ install_dir, _ = winreg.QueryValueEx(key, "SAPsysdir")
113
+ except FileNotFoundError:
114
+ continue
115
+
116
+ path = Path(install_dir) / SAPLOGON_EXE
117
+ if path.is_file():
118
+ return path
119
+
120
+ return None
121
+
122
+
123
+ def default_password_generator() -> str:
124
+ """Generates a password from the current month abbreviation and year.
125
+
126
+ Example
127
+ -------
128
+ >>> default_password_generator() # on 2026-01-15
129
+ 'Jan@2026'
130
+ """
131
+ return datetime.date.today().strftime("%b@%Y") # noqa: DTZ011
132
+
133
+
134
+ def get_scripting_engine() -> Any | None:
135
+ """Returns the SAP GUI scripting engine, or None if SAP Logon is not running."""
136
+ try:
137
+ rot_entry = win32.GetObject("SAPGUI")
138
+ except pywintypes.com_error:
139
+ return None
140
+ return rot_entry.GetScriptingEngine
@@ -0,0 +1,125 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ from sapwright._utils import get_scripting_engine
5
+ from sapwright.exceptions import SAPConnectionError, SAPLogonError, SAPScriptingDisabled
6
+ from sapwright.objects import GuiSession
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def _normalize(value: object) -> str:
12
+ return str(value).strip().lower()
13
+
14
+
15
+ def _matches(
16
+ connection: Any, connection_string: str | None, connection_name: str | None
17
+ ) -> bool:
18
+ """Match by connection string (substring) if given, otherwise by description."""
19
+ if connection_string:
20
+ # connection_string returned by SAP logon contains a lot more text and other details
21
+ # we would only want to check for a substring match
22
+ return connection_string.lower() in str(connection.ConnectionString).lower()
23
+
24
+ # Case-insensitive connection name matching
25
+ return _normalize(connection.Description) == _normalize(connection_name)
26
+
27
+
28
+ class ConnectionManager:
29
+ def __init__(self) -> None:
30
+ self._connection: Any = None
31
+
32
+ def find_connection_by_user(
33
+ self,
34
+ username: str,
35
+ connection_string: str | None = None,
36
+ connection_name: str | None = None,
37
+ ) -> Any | None:
38
+ """Check if a connection for the specified user is already open.
39
+
40
+ Returns
41
+ -------
42
+ object or None
43
+ The underlying native GuiConnection COM object if a matching connection
44
+ has a session logged in as the user, otherwise None
45
+
46
+ Raises
47
+ ------
48
+ ValueError
49
+ If neither connection_string nor connection_name is provided
50
+ SAPScriptingDisabled
51
+ If SAP GUI Scripting is disabled by the server
52
+ """
53
+ if not connection_string and not connection_name:
54
+ raise ValueError("connection_string or connection_name must be provided.")
55
+
56
+ application = get_scripting_engine()
57
+ if application is None:
58
+ return None
59
+
60
+ user = _normalize(username)
61
+ for i in range(application.Children.Count):
62
+ connection = application.Children(i)
63
+ if connection.DisabledByServer:
64
+ raise SAPScriptingDisabled(
65
+ "SAP GUI Scripting is disabled by server. Contact your SAP basis team to enable it."
66
+ )
67
+ if not _matches(connection, connection_string, connection_name):
68
+ continue
69
+
70
+ sessions = connection.Children
71
+ for j in range(sessions.Count):
72
+ if _normalize(sessions(j).Info.User) == user:
73
+ self._connection = connection
74
+ return connection
75
+
76
+ return None
77
+
78
+ def open_connection(
79
+ self,
80
+ connection_string: str | None = None,
81
+ connection_name: str | None = None,
82
+ ) -> GuiSession:
83
+ """Open a new connection and return its first session.
84
+
85
+ connection_string takes precedence over connection_name.
86
+
87
+ Raises
88
+ ------
89
+ SAPLogonError
90
+ If SAP Logon is not running
91
+ SAPConnectionError
92
+ If the connection could not be opened
93
+ """
94
+ application = get_scripting_engine()
95
+ if application is None:
96
+ raise SAPLogonError("SAP Logon is not running")
97
+
98
+ try:
99
+ if connection_string:
100
+ connection = application.OpenConnectionByConnectionString(
101
+ connection_string, True, True
102
+ )
103
+ else:
104
+ connection = application.OpenConnection(connection_name, True, True)
105
+
106
+ self._connection = connection
107
+ return GuiSession(connection.Children(0))
108
+ except Exception as e:
109
+ msg = f"Failed to open SAP connection: {e}"
110
+ logger.error(msg)
111
+ raise SAPConnectionError(msg) from e
112
+
113
+ def close_connection(self):
114
+ """
115
+ Closes the SAP connection including all sessions.
116
+ This method closes the SAP connection and all associated sessions,
117
+ and cleans up the internal references to the SAP GUI objects.
118
+ """
119
+ try:
120
+ self._connection.CloseConnection()
121
+ logger.debug("SAP connection closed")
122
+ except Exception as e:
123
+ logger.error(f"Error while closing connection: {e}")
124
+
125
+ self._connection = None
@@ -0,0 +1,46 @@
1
+ class SAPGuiError(Exception):
2
+ """Base class for all SAP GUI related errors"""
3
+
4
+
5
+ class SAPLogonError(SAPGuiError):
6
+ """Raised when SAP Logon fails to launch"""
7
+
8
+
9
+ class SAPConnectionError(SAPGuiError):
10
+ """Raised when a connection to SAP Logon fails"""
11
+
12
+
13
+ class SAPScriptingDisabled(SAPGuiError):
14
+ """Raised when SAP GUI Scripting is disabled by server"""
15
+
16
+
17
+ class SAPElementNotFound(SAPGuiError):
18
+ """Raised when a SAP GUI element is not found"""
19
+
20
+
21
+ class SAPElementNotChangeable(SAPGuiError):
22
+ """Raised when attempting to modify a read-only element."""
23
+
24
+
25
+ class SAPComboBoxOptionNotFound(SAPGuiError):
26
+ """Raised when a specified option is not available in a ComboBox."""
27
+
28
+
29
+ class SAPStatusBarError(SAPGuiError):
30
+ """Raised when there is an error in the status bar."""
31
+
32
+
33
+ class SAPTransactionError(SAPGuiError):
34
+ """Raised when a transaction fails to start or encounters a critical error."""
35
+
36
+
37
+ class SAPLoginError(SAPGuiError):
38
+ """Raised when the login process fails."""
39
+
40
+
41
+ class SAPElementTypeMismatch(SAPGuiError):
42
+ """Raised when the element type does not match the expected type."""
43
+
44
+
45
+ class SAPTableConfigurationError(SAPGuiError):
46
+ """Raised when there is a mismatch or error in table configuration/population."""
sapwright/models.py ADDED
@@ -0,0 +1,41 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class StatusBarMsg(BaseModel):
5
+ """The status bar message"""
6
+
7
+ id: str
8
+ type: str
9
+ text: str
10
+ number: str
11
+ has_longtext: int
12
+ is_popup: bool | None
13
+
14
+
15
+ class SessionInfo(BaseModel):
16
+ """Session information"""
17
+
18
+ application_server: str = ""
19
+ client: str = ""
20
+ codepage: int | None = None
21
+ flushes: int | None = None
22
+ group: str = ""
23
+ gui_codepage: int | None = None
24
+ i18n_mode: bool | None = None
25
+ interpretation_time: int | None = None
26
+ is_low_speed_connection: bool | None = None
27
+ language: str = ""
28
+ message_server: str = ""
29
+ program: str = ""
30
+ response_time: int | None = None
31
+ round_trips: int | None = None
32
+ screen_number: int | None = None
33
+ scripting_mode_force_notification: bool | None = None
34
+ scripting_mode_read_only: bool | None = None
35
+ scripting_mode_recording_disabled: bool | None = None
36
+ system_name: str = ""
37
+ session_number: int | None = None
38
+ system_session_id: str = ""
39
+ transaction: str = ""
40
+ ui_guideline: int | None = None
41
+ user: str = ""
@@ -0,0 +1,16 @@
1
+ from sapwright.objects.component import GuiComponent, GuiComponentCollection
2
+ from sapwright.objects.scrollbar import GuiScrollbar
3
+ from sapwright.objects.session import GuiSession
4
+ from sapwright.objects.shell import GuiShell
5
+ from sapwright.objects.table_control import GuiTableColumn, GuiTableControl, GuiTableRow
6
+
7
+ __all__ = [
8
+ "GuiComponent",
9
+ "GuiComponentCollection",
10
+ "GuiScrollbar",
11
+ "GuiSession",
12
+ "GuiShell",
13
+ "GuiTableColumn",
14
+ "GuiTableControl",
15
+ "GuiTableRow",
16
+ ]