diff --git a/Modules/DelphiFMX/tests/TestLoadProps.py b/Modules/DelphiFMX/tests/TestLoadProps.py new file mode 100644 index 00000000..c46d10a1 --- /dev/null +++ b/Modules/DelphiFMX/tests/TestLoadProps.py @@ -0,0 +1,608 @@ +""" +Comprehensive tests for LoadProps method in DelphiFMX module. + +Testing can be run with: + `python -m unittest discover -s tests -p 'TestLoadProps.py'` +or with pytest (for nicer output, though this requires pytest to be installed): + `pytest -v TestLoadProps.py` + +Tests cover: +- Valid inputs (str, bytes, PathLike objects) +- Invalid inputs (wrong types, non-existent files) +- UTF-8 path handling +- Edge cases and error conditions +- PathLike objects with unusual behavior + +Cross-platform support: Windows, Linux, macOS, Android +""" + +import unittest +import os +import sys +import tempfile +import shutil +import platform +from pathlib import Path + +# Ensure DelphiFMX module can be found +_test_dir = os.path.dirname(os.path.abspath(__file__)) +_module_dir = os.path.dirname(_test_dir) + +# Detect platform and architecture for cross-platform support +_system = platform.system() +_is_64bit = sys.maxsize > 2**32 + +# Detect Android (check for Android-specific indicators) +_is_android = False +if hasattr(sys, 'getandroidapilevel'): + _is_android = True +elif 'ANDROID_ROOT' in os.environ or 'ANDROID_DATA' in os.environ: + _is_android = True +elif _system == 'Linux' and os.path.exists('/system/build.prop'): + _is_android = True + +# Determine platform-specific paths and module extensions +if _is_android: + _platform_dir = 'Android64' if _is_64bit else 'Android' +elif _system == 'Windows': + _platform_dir = 'Win64' if _is_64bit else 'Win32' +elif _system == 'Linux': + _platform_dir = 'Linux64' +elif _system == 'Darwin': # macOS + _platform_dir = 'OSX64' if _is_64bit else 'OSX32' +else: + raise NotImplementedError(f"Unsupported platform: {_system}") + +# Try to find the module in the pyd directory +_pyd_dir = os.path.join(_module_dir, 'pyd', 'Release', _platform_dir) + +# Find and add the directory with the module +import importlib +for _module_ext in importlib.machinery.EXTENSION_SUFFIXES: + _module_file = os.path.join(_pyd_dir, f'DelphiFMX{_module_ext}') + if os.path.exists(_module_file): + if _pyd_dir not in sys.path: + sys.path.insert(0, _pyd_dir) + print(f"Module will be loaded from: {_module_file}") + break + +# Import DelphiFMX module - fail loudly if not available +try: + from DelphiFMX import Form +except ImportError as e: + raise ImportError( + f"Failed to import DelphiFMX module.\n" + f"Tried to load from: {_pyd_dir}\n" + f"Platform: {_system}, Android: {_is_android}, Architecture: {_platform_dir}, Extension: {_module_ext}\n" + f"Make sure DelphiFMX{_module_ext} is built and available at:\n" + f" {_module_file}\n" + f"Original error: {e}" + ) from e + + +class FormForTest(Form): + """Test form class - allows for adding subcomponents at LoadProps.""" + pass + + +class TestLoadProps(unittest.TestCase): + """Test suite for LoadProps method.""" + + # Path to the reference .fmx file in tests directory + _TEST_FMX_SOURCE = os.path.join(os.path.dirname(__file__), 'test_form.fmx') + + @classmethod + def setUpClass(cls): + """Set up test fixtures before all tests.""" + + if not os.path.exists(cls._TEST_FMX_SOURCE): + raise FileNotFoundError( + f"Test .fmx file not found: {cls._TEST_FMX_SOURCE}\n" + "This file must exist for tests to run." + ) + + # Create a temporary directory for test files + cls.test_dir = tempfile.mkdtemp(prefix='p4d_test_') + + # Copy the reference .fmx file to test directory + cls.valid_fmx = os.path.join(cls.test_dir, 'test_form.fmx') + shutil.copy2(cls._TEST_FMX_SOURCE, cls.valid_fmx) + + # Create UTF-8 path test directory and copy .fmx file there + utf8_dir = os.path.join(cls.test_dir, '测试_тест_🎉') + os.makedirs(utf8_dir, exist_ok=True) + cls.utf8_fmx = os.path.join(utf8_dir, 'form_测试.fmx') + shutil.copy2(cls._TEST_FMX_SOURCE, cls.utf8_fmx) + + @classmethod + def tearDownClass(cls): + """Clean up test fixtures after all tests.""" + try: + shutil.rmtree(cls.test_dir) + except: + pass + + def setUp(self): + """Set up before each test.""" + # Create a fresh form for each test + self.form = FormForTest(None) + + def tearDown(self): + """Clean up after each test.""" + try: + if hasattr(self, 'form') and self.form: + self.form.Release() + except: + pass + + def _copy_fmx_to_path(self, target_path): + """Helper to copy the test .fmx file to a specific path.""" + target_dir = os.path.dirname(target_path) + if target_dir and not os.path.exists(target_dir): + os.makedirs(target_dir, exist_ok=True) + shutil.copy2(self._TEST_FMX_SOURCE, target_path) + return target_path + + def _deny_read_access(self, path): + """Deny read access to a file or directory using platform-specific methods. + + Returns a context manager that restores permissions on exit. + Cross-platform: Windows uses win32security, Unix uses os.chmod(). + + Raises: + ImportError: If win32security is not available (Windows only) + Exception: If setting permissions fails + """ + is_windows = platform.system() == 'Windows' + is_directory = os.path.isdir(path) + + if is_windows: + try: + import win32security + import win32api + import ntsecuritycon as con + except ImportError as e: + raise ImportError( + f"win32security module (pywin32) is required for permission testing on Windows. " + f"Install it with: pip install pywin32. Original error: {e}" + ) from e + + class PermissionRestorer: + def __init__(self, path): + self.path = path + self.user_sid = win32security.LookupAccountName(None, win32api.GetUserName())[0] + + def __enter__(self): + dacl = win32security.ACL() + dacl.AddAccessDeniedAce(win32security.ACL_REVISION, con.GENERIC_READ, self.user_sid) + + # Use SetNamedSecurityInfo with PROTECTED_DACL to disable inheritance + win32security.SetNamedSecurityInfo(self.path, win32security.SE_FILE_OBJECT, + win32security.DACL_SECURITY_INFORMATION | win32security.PROTECTED_DACL_SECURITY_INFORMATION, + None, None, dacl, None) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + # Restore default permissions + dacl = win32security.ACL() + dacl.AddAccessAllowedAce(win32security.ACL_REVISION, con.GENERIC_ALL, self.user_sid) + win32security.SetNamedSecurityInfo(self.path, win32security.SE_FILE_OBJECT, + win32security.DACL_SECURITY_INFORMATION, + None, None, dacl, None) + return False # Don't suppress exceptions + + return PermissionRestorer(path) + else: + import stat + class PermissionRestorer: + def __init__(self, path, is_directory): + self.path = path + self.is_directory = is_directory + self.original_mode = os.stat(path).st_mode + + def __enter__(self): + # Remove read and execute permissions (execute needed to access files in directory) + os.chmod(self.path, stat.S_IWRITE) # Write-only + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + os.chmod(self.path, self.original_mode) + return False # Don't suppress exceptions + + return PermissionRestorer(path, is_directory) + + def _lock_file(self, file_path): + """Lock a file exclusively using Windows file locking. + + Returns a context manager that unlocks the file on exit. + Windows only - Unix file locking is advisory and not reliable for testing. + + Raises: + ImportError: If msvcrt is not available + Exception: If locking the file fails + """ + if platform.system() != 'Windows': + raise NotImplementedError("File locking test only available on Windows - Unix uses advisory locking which is not reliable") + + try: + import msvcrt + except ImportError as e: + raise ImportError( + f"msvcrt module is required for file locking on Windows. " + f"Original error: {e}" + ) from e + + class FileLocker: + def __init__(self, path): + self.path = path + self.handle = None + self.file_size = None + + def __enter__(self): + self.handle = open(self.path, 'r+b') + self.file_size = os.path.getsize(self.path) + # Lock the file exclusively (lock entire file: 0 to file size) + msvcrt.locking(self.handle.fileno(), msvcrt.LK_LOCK, self.file_size) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.handle: + try: + msvcrt.locking(self.handle.fileno(), msvcrt.LK_UNLCK, self.file_size) + finally: + self.handle.close() + return False # Don't suppress exceptions + + return FileLocker(file_path) + + def _verify_basic_properties_loaded(self, form, msg_prefix=""): + """Helper to verify that basic form properties were actually loaded from .fmx file. + + This ensures LoadProps didn't just return True without doing anything. + """ + self.assertEqual(form.Caption, 'Form1', + f"{msg_prefix}Caption should be 'Form1' after LoadProps") + self.assertEqual(form.ClientWidth, 624, + f"{msg_prefix}ClientWidth should be 624 after LoadProps") + self.assertEqual(form.ClientHeight, 441, + f"{msg_prefix}ClientHeight should be 441 after LoadProps") + + # ========== Valid Input Tests ========== + + def test_loadprops_with_string_path(self): + """Test LoadProps with a regular string path.""" + result = self.form.LoadProps(self.valid_fmx) + self.assertTrue(result, "LoadProps should return True for valid .fmx file") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_pathlib_path(self): + """Test LoadProps with pathlib.Path object.""" + path_obj = Path(self.valid_fmx) + result = self.form.LoadProps(path_obj) + self.assertTrue(result, "LoadProps should return True for valid Path object") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_utf8_string_path(self): + """Test LoadProps with UTF-8 characters in string path.""" + result = self.form.LoadProps(self.utf8_fmx) + self.assertTrue(result, "LoadProps should return True for UTF-8 path") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_utf8_pathlib_path(self): + """Test LoadProps with UTF-8 characters in Path object.""" + path_obj = Path(self.utf8_fmx) + result = self.form.LoadProps(path_obj) + self.assertTrue(result, "LoadProps should return True for UTF-8 Path object") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_absolute_path(self): + """Test LoadProps with absolute path.""" + abs_path = os.path.abspath(self.valid_fmx) + result = self.form.LoadProps(abs_path) + self.assertTrue(result, "LoadProps should work with absolute path") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_relative_path(self): + """Test LoadProps with relative path.""" + old_cwd = os.getcwd() + try: + os.chdir(self.test_dir) + rel_path = os.path.basename(self.valid_fmx) + result = self.form.LoadProps(rel_path) + self.assertTrue(result, "LoadProps should work with relative path") + self._verify_basic_properties_loaded(self.form) + finally: + os.chdir(old_cwd) + + + def test_loadprops_with_path_containing_spaces(self): + """Test LoadProps with path containing spaces.""" + space_dir = os.path.join(self.test_dir, 'path with spaces') + space_file = os.path.join(space_dir, 'test file.fmx') + self._copy_fmx_to_path(space_file) + + result = self.form.LoadProps(space_file) + self.assertTrue(result, "LoadProps should work with path containing spaces") + self._verify_basic_properties_loaded(self.form) + + + # ========== Invalid Input Tests ========== + + def test_loadprops_with_nonexistent_file(self): + """Test LoadProps with non-existent file path.""" + nonexistent = os.path.join(self.test_dir, 'nonexistent.fmx') + + with self.assertRaises(OSError) as context: + self.form.LoadProps(nonexistent) + self.assertIn(nonexistent, str(context.exception)) + self.assertIn('not found', str(context.exception)) + + + def test_loadprops_with_none(self): + """Test LoadProps with None (should raise TypeError).""" + with self.assertRaises(TypeError): + self.form.LoadProps(None) + + + def test_loadprops_with_empty_filename(self): + """Test LoadProps with empty string as filename.""" + with self.assertRaises(OSError) as context: + self.form.LoadProps('') + self.assertIn('not found', str(context.exception)) + + + def test_loadprops_with_integer(self): + """Test LoadProps with integer (wrong type).""" + with self.assertRaises(TypeError): + self.form.LoadProps(123) + + + def test_loadprops_with_wrong_file_content(self): + """Test LoadProps with file that exists but wrong content.""" + txt_file = os.path.join(self.test_dir, 'test_wrong_content.fmx') + with open(txt_file, 'w', encoding='utf-8') as f: + f.write('not a fmx file') + + with self.assertRaises(RuntimeError) as context: + self.form.LoadProps(txt_file) + self.assertIn('EParserError', str(context.exception)) + + + def test_loadprops_with_empty_file(self): + """Test LoadProps with empty file.""" + empty_file = os.path.join(self.test_dir, 'empty.fmx') + with open(empty_file, 'w', encoding='utf-8'): + pass + + with self.assertRaises(RuntimeError) as context: + self.form.LoadProps(empty_file) + self.assertIn('EReadError', str(context.exception)) + + + # ========== PathLike Object Edge Cases ========== + + def test_loadprops_with_custom_pathlike(self): + """Test LoadProps with custom PathLike object.""" + class CustomPathLike: + def __init__(self, path): + self.path = path + def __fspath__(self): + return self.path + + result = self.form.LoadProps(CustomPathLike(self.valid_fmx)) + self.assertTrue(result, "LoadProps should work with custom PathLike") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_pathlike_raising_exception(self): + """Test LoadProps with PathLike that raises exception in __fspath__.""" + class ExceptionPathLike: + def __fspath__(self): + raise ValueError("Custom exception from __fspath__") + + # The exception from __fspath__ should propagate + with self.assertRaises(ValueError) as context: + self.form.LoadProps(ExceptionPathLike()) + self.assertIn("Custom exception from __fspath__", str(context.exception)) + + + def test_loadprops_with_pathlike_returning_none(self): + """Test LoadProps with PathLike that returns None from __fspath__.""" + class NonePathLike: + def __fspath__(self): + return None + + with self.assertRaises(TypeError) as context: + self.form.LoadProps(NonePathLike()) + self.assertIn('Python function `__fspath__` should return value of following type(s): str or bytes. Instead type `NoneType` was returned.', str(context.exception)) + + + def test_loadprops_with_pathlike_returning_integer(self): + """Test LoadProps with PathLike that returns integer from __fspath__.""" + class IntPathLike: + def __fspath__(self): + return 42 + + with self.assertRaises(TypeError) as context: + self.form.LoadProps(IntPathLike()) + self.assertIn('Python function `__fspath__` should return value of following type(s): str or bytes. Instead type `int` was returned.', str(context.exception)) + + + def test_loadprops_with_pathlike_being_not_callable(self): + """Test LoadProps with PathLike that returns integer from __fspath__.""" + class NonCallablePathLike: + def __init__(self, path): + self.__fspath__ = path + + with self.assertRaises(TypeError) as context: + self.form.LoadProps(NonCallablePathLike(self.valid_fmx)) + self.assertIn('Expected argument type(s): str, bytes or os.PathLike', str(context.exception)) + + + def test_loadprops_with_pathlike_utf8(self): + """Test LoadProps with custom PathLike returning UTF-8 path.""" + class UTF8PathLike: + def __init__(self, path): + self.path = path + def __fspath__(self): + return self.path + + result = self.form.LoadProps(UTF8PathLike(self.utf8_fmx)) + self.assertTrue(result, "LoadProps should work with UTF-8 PathLike") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_bytes_path(self): + """Test LoadProps with bytes object as path.""" + bytes_path = self.valid_fmx.encode('utf-8') + result = self.form.LoadProps(bytes_path) + self.assertTrue(result, "LoadProps should work with bytes path") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_bytes_path_utf8(self): + """Test LoadProps with UTF-8 bytes path.""" + bytes_path = self.utf8_fmx.encode('utf-8') + result = self.form.LoadProps(bytes_path) + self.assertTrue(result, "LoadProps should work with UTF-8 bytes path") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_pathlike_returning_bytes(self): + """Test LoadProps with PathLike that returns bytes from __fspath__.""" + class BytesPathLike: + def __init__(self, path): + self.path = path + def __fspath__(self): + return self.path.encode('utf-8') + + bytes_pathlike = BytesPathLike(self.valid_fmx) + result = self.form.LoadProps(bytes_pathlike) + self.assertTrue(result, "LoadProps should work with PathLike returning bytes") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_pathlike_returning_bytes_utf8(self): + """Test LoadProps with PathLike returning UTF-8 bytes.""" + class UTF8BytesPathLike: + """PathLike that returns UTF-8 bytes.""" + def __init__(self, path): + self.path = path + def __fspath__(self): + return self.path.encode('utf-8') + + utf8_bytes_pathlike = UTF8BytesPathLike(self.utf8_fmx) + result = self.form.LoadProps(utf8_bytes_pathlike) + self.assertTrue(result, "LoadProps should work with PathLike returning UTF-8 bytes") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_pathlike_returning_bytes_invalid_encoding(self): + """Test LoadProps with PathLike returning bytes with invalid encoding.""" + + class NonUTF8BytesPathLike: + def __fspath__(self): + return b'\xff\xfe\x00\x01' + + if platform.system() == 'Windows': + with self.assertRaises(UnicodeDecodeError) as context: + self.form.LoadProps(NonUTF8BytesPathLike()) + self.assertEqual("'utf-8' codec can't decode byte 0xff in position 0: invalid start byte", str(context.exception)) + else: # On Linux this is actually valid path, so we actually dont find the file + with self.assertRaises(OSError) as context: + self.form.LoadProps(NonUTF8BytesPathLike()) + self.assertIn('not found', str(context.exception)) + self.assertIn(os.fsdecode(NonUTF8BytesPathLike().__fspath__()), str(context.exception)) + + + def test_loadprops_overwrites_existing_properties(self): + """Test that LoadProps overwrites existing form properties.""" + self.form.Caption = 'Initial Caption' + self.form.ClientWidth = 100 + self.form.ClientHeight = 100 + + result = self.form.LoadProps(self.valid_fmx) + self.assertTrue(result) + self._verify_basic_properties_loaded(self.form) + + def test_loadprops_with_file_no_read_permission(self): + """Test LoadProps with file that has no read permissions.""" + no_read_file = os.path.join(self.test_dir, 'no_read.fmx') + self._copy_fmx_to_path(no_read_file) + + with self._deny_read_access(no_read_file): + with self.assertRaises(OSError) as context: + self.form.LoadProps(no_read_file) + self.assertIn('denied', str(context.exception)) + self.assertIn('EFOpenError', str(context.exception)) + self.assertIn(no_read_file, str(context.exception)) + + def test_loadprops_with_directory_no_read_permission(self): + """Test LoadProps with file in directory that has no read permissions.""" + no_read_dir = os.path.join(self.test_dir, 'no_read_dir') + os.makedirs(no_read_dir, exist_ok=True) + file_in_no_read_dir = os.path.join(no_read_dir, 'test.fmx') + self._copy_fmx_to_path(file_in_no_read_dir) + + with self._deny_read_access(no_read_dir): + with self.assertRaises(OSError) as context: + self.form.LoadProps(file_in_no_read_dir) + # Not readable directory should lead to file not found or permission error + self.assertIn(file_in_no_read_dir, str(context.exception)) + self.assertIn('not found', str(context.exception)) + + def test_loadprops_with_locked_file(self): + """Test LoadProps with file that is locked by another process. + + Windows only - Unix file locking is advisory and not reliable for testing. + """ + if platform.system() != 'Windows': + self.skipTest("File locking test only available on Windows - Unix uses advisory locking") + + locked_file = os.path.join(self.test_dir, 'locked.fmx') + self._copy_fmx_to_path(locked_file) + + with self._lock_file(locked_file): + with self.assertRaises(OSError) as context: + self.form.LoadProps(locked_file) + self.assertIn(locked_file, str(context.exception)) + self.assertIn('EFOpenError', str(context.exception)) + + def test_loadprops_with_corrupted_binary_file(self): + """Test LoadProps with file that looks like binary but is corrupted.""" + corrupted_file = os.path.join(self.test_dir, 'corrupted.fmx') + # Write some binary data that might look like a FMX but is corrupted + with open(corrupted_file, 'wb') as f: + f.write(b'TPF0') # Valid signature + f.write(b'a' * 100) + + with self.assertRaises(RuntimeError) as context: + self.form.LoadProps(corrupted_file) + self.assertTrue(str(context.exception).startswith('EReadError'), f"Expected EReadError, got: {context.exception}") + + +def run_tests(): + """Run all tests.""" + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add all test classes + suite.addTests(loader.loadTestsFromTestCase(TestLoadProps)) + + # Run tests with default unittest runner + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Return exit code + return 0 if result.wasSuccessful() else 1 + + +if __name__ == '__main__': + sys.exit(run_tests()) + diff --git a/Modules/DelphiFMX/tests/test_form.fmx b/Modules/DelphiFMX/tests/test_form.fmx new file mode 100644 index 00000000..5c3f1692 --- /dev/null +++ b/Modules/DelphiFMX/tests/test_form.fmx @@ -0,0 +1,28 @@ +object Form1: TForm1 + Left = 0 + Top = 0 + Caption = 'Form1' + ClientHeight = 441 + ClientWidth = 624 + FormFactor.Width = 320 + FormFactor.Height = 480 + FormFactor.Devices = [Desktop] + DesignerMasterStyle = 0 + object Edit1: TEdit + Left = 80 + Top = 256 + Width = 121 + Height = 23 + TabOrder = 3 + Text = 'Edit1' + end + object Button1: TButton + Left = 184 + Top = 392 + Width = 75 + Height = 25 + Text = 'Button1' + TabOrder = 4 + end +end + diff --git a/Modules/DelphiVCL/tests/TestLoadProps.py b/Modules/DelphiVCL/tests/TestLoadProps.py new file mode 100644 index 00000000..4dd3a306 --- /dev/null +++ b/Modules/DelphiVCL/tests/TestLoadProps.py @@ -0,0 +1,568 @@ +""" +Comprehensive tests for LoadProps method in DelphiVCL module. + +Testing can be run with: + `python -m unittest discover -s tests -p 'TestLoadProps.py'` +or with pytest (for nicer output, though this requires pytest to be installed): + `pytest -v TestLoadProps.py` + +Tests cover: +- Valid inputs (str, bytes, PathLike objects) +- Invalid inputs (wrong types, non-existent files) +- UTF-8 path handling +- Edge cases and error conditions +- PathLike objects with unusual behavior +""" + +import unittest +import os +import sys +import tempfile +import shutil +from pathlib import Path + +# Ensure DelphiVCL .pyd can be found +_test_dir = os.path.dirname(os.path.abspath(__file__)) +_module_dir = os.path.dirname(_test_dir) + +# Detect platform architecture +_is_64bit = sys.maxsize > 2**32 +_platform_dir = 'Win64' if _is_64bit else 'Win32' +_pyd_dir = os.path.join(_module_dir, 'pyd', 'Release', _platform_dir) + +# Add pyd directory to sys.path if there is module within and not already there +import importlib +for _module_ext in importlib.machinery.EXTENSION_SUFFIXES: + _module_file = os.path.join(_pyd_dir, f'DelphiVCL{_module_ext}') + if os.path.exists(_module_file): + if _pyd_dir not in sys.path: + sys.path.insert(0, _pyd_dir) + print(f"Module will be loaded from: {_module_file}") + break + +try: + from DelphiVCL import Form +except ImportError as e: + raise ImportError( + f"Failed to import DelphiVCL module. " + f"Tried to load from: {_pyd_dir}\n" + f"Make sure DelphiVCL.pyd is built and available. " + f"Original error: {e}" + ) from e + + +class FormForTest(Form): + """Test form class - allows for adding subcomponents at LoadProps.""" + pass + + +class TestLoadProps(unittest.TestCase): + """Test suite for LoadProps method.""" + + # Path to the reference .dfm file in tests directory + _TEST_DFM_SOURCE = os.path.join(os.path.dirname(__file__), 'test_form.dfm') + + @classmethod + def setUpClass(cls): + """Set up test fixtures before all tests.""" + + if not os.path.exists(cls._TEST_DFM_SOURCE): + raise FileNotFoundError( + f"Test .dfm file not found: {cls._TEST_DFM_SOURCE}\n" + "This file must exist for tests to run." + ) + + # Create a temporary directory for test files + cls.test_dir = tempfile.mkdtemp(prefix='p4d_test_') + + # Copy the reference .dfm file to test directory + cls.valid_dfm = os.path.join(cls.test_dir, 'test_form.dfm') + shutil.copy2(cls._TEST_DFM_SOURCE, cls.valid_dfm) + + # Create UTF-8 path test directory and copy .dfm file there + utf8_dir = os.path.join(cls.test_dir, '测试_тест_🎉') + os.makedirs(utf8_dir, exist_ok=True) + cls.utf8_dfm = os.path.join(utf8_dir, 'form_测试.dfm') + shutil.copy2(cls._TEST_DFM_SOURCE, cls.utf8_dfm) + + @classmethod + def tearDownClass(cls): + """Clean up test fixtures after all tests.""" + try: + shutil.rmtree(cls.test_dir) + except: + pass + + def setUp(self): + """Set up before each test.""" + self.form = FormForTest(None) + + def tearDown(self): + """Clean up after each test.""" + try: + if hasattr(self, 'form') and self.form: + self.form.Release() + except: + pass + + def _copy_dfm_to_path(self, target_path): + """Helper to copy the test .dfm file to a specific path.""" + target_dir = os.path.dirname(target_path) + if target_dir and not os.path.exists(target_dir): + os.makedirs(target_dir, exist_ok=True) + shutil.copy2(self._TEST_DFM_SOURCE, target_path) + return target_path + + def _deny_read_access(self, path): + """Deny read access to a file or directory using Windows ACLs. + + Returns a context manager that restores permissions on exit. + Requires win32security (pywin32) module. + + Raises: + ImportError: If win32security is not available + Exception: If setting permissions fails + """ + try: + import win32security + import win32api + import ntsecuritycon as con + except ImportError as e: + raise ImportError( + f"win32security module (pywin32) is required for permission testing on Windows. " + f"Install it with: pip install pywin32. Original error: {e}" + ) from e + + # Return a context manager for cleanup + class PermissionRestorer: + def __init__(self, path): + self.path = path + self.user_sid = win32security.LookupAccountName(None, win32api.GetUserName())[0] + + def __enter__(self): + dacl = win32security.ACL() + dacl.AddAccessDeniedAce(win32security.ACL_REVISION, con.GENERIC_READ, self.user_sid) + + # Use SetNamedSecurityInfo with PROTECTED_DACL to disable inheritance + win32security.SetNamedSecurityInfo(self.path, win32security.SE_FILE_OBJECT, + win32security.DACL_SECURITY_INFORMATION | win32security.PROTECTED_DACL_SECURITY_INFORMATION, + None, None, dacl, None) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + # Restore default permissions + dacl = win32security.ACL() + dacl.AddAccessAllowedAce(win32security.ACL_REVISION, con.GENERIC_ALL, self.user_sid) + win32security.SetNamedSecurityInfo(self.path, win32security.SE_FILE_OBJECT, + win32security.DACL_SECURITY_INFORMATION, + None, None, dacl, None) + return False # Don't suppress exceptions + + return PermissionRestorer(path) + + def _lock_file(self, file_path): + """Lock a file exclusively using Windows file locking. + + Returns a context manager that unlocks the file on exit. + Requires msvcrt module (Windows only). + + Raises: + ImportError: If msvcrt is not available + Exception: If locking the file fails + """ + try: + import msvcrt + except ImportError as e: + raise ImportError( + f"msvcrt module is required for file locking on Windows. " + f"Original error: {e}" + ) from e + + class FileLocker: + def __init__(self, path): + self.path = path + self.handle = None + self.file_size = None + self.msvcrt = msvcrt + + def __enter__(self): + self.handle = open(self.path, 'r+b') + self.file_size = os.path.getsize(self.path) + # Lock the file exclusively (lock entire file: 0 to file size) + self.msvcrt.locking(self.handle.fileno(), self.msvcrt.LK_LOCK, self.file_size) + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.handle: + try: + # Unlock the file + self.msvcrt.locking(self.handle.fileno(), self.msvcrt.LK_UNLCK, self.file_size) + finally: + self.handle.close() + return False # Don't suppress exceptions + + return FileLocker(file_path) + + def _verify_basic_properties_loaded(self, form, msg_prefix=""): + """Helper to verify that basic form properties were actually loaded from .dfm file. + + This ensures LoadProps didn't just return True without doing anything. + """ + self.assertEqual(form.Caption, 'Form1', + f"{msg_prefix}Caption should be 'Form1' after LoadProps") + self.assertEqual(form.ClientWidth, 624, + f"{msg_prefix}ClientWidth should be 624 after LoadProps") + self.assertEqual(form.ClientHeight, 441, + f"{msg_prefix}ClientHeight should be 441 after LoadProps") + + # ========== Valid Input Tests ========== + + def test_loadprops_with_string_path(self): + """Test LoadProps with a regular string path.""" + result = self.form.LoadProps(self.valid_dfm) + self.assertTrue(result, "LoadProps should return True for valid .dfm file") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_pathlib_path(self): + """Test LoadProps with pathlib.Path object.""" + path_obj = Path(self.valid_dfm) + result = self.form.LoadProps(path_obj) + self.assertTrue(result, "LoadProps should return True for valid Path object") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_utf8_string_path(self): + """Test LoadProps with UTF-8 characters in string path.""" + result = self.form.LoadProps(self.utf8_dfm) + self.assertTrue(result, "LoadProps should return True for UTF-8 path") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_utf8_pathlib_path(self): + """Test LoadProps with UTF-8 characters in Path object.""" + path_obj = Path(self.utf8_dfm) + result = self.form.LoadProps(path_obj) + self.assertTrue(result, "LoadProps should return True for UTF-8 Path object") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_absolute_path(self): + """Test LoadProps with absolute path.""" + abs_path = os.path.abspath(self.valid_dfm) + result = self.form.LoadProps(abs_path) + self.assertTrue(result, "LoadProps should work with absolute path") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_relative_path(self): + """Test LoadProps with relative path.""" + old_cwd = os.getcwd() + try: + os.chdir(self.test_dir) + rel_path = os.path.basename(self.valid_dfm) + result = self.form.LoadProps(rel_path) + self.assertTrue(result, "LoadProps should work with relative path") + self._verify_basic_properties_loaded(self.form) + finally: + os.chdir(old_cwd) + + + def test_loadprops_with_path_containing_spaces(self): + """Test LoadProps with path containing spaces.""" + space_dir = os.path.join(self.test_dir, 'path with spaces') + space_file = os.path.join(space_dir, 'test file.dfm') + self._copy_dfm_to_path(space_file) + + result = self.form.LoadProps(space_file) + self.assertTrue(result, "LoadProps should work with path containing spaces") + self._verify_basic_properties_loaded(self.form) + + + # ========== Invalid Input Tests ========== + + def test_loadprops_with_nonexistent_file(self): + """Test LoadProps with non-existent file path.""" + nonexistent = os.path.join(self.test_dir, 'nonexistent.dfm') + + with self.assertRaises(OSError) as context: + self.form.LoadProps(nonexistent) + self.assertIn(nonexistent, str(context.exception)) + self.assertIn('not found', str(context.exception)) + + + def test_loadprops_with_none(self): + """Test LoadProps with None (should raise TypeError).""" + with self.assertRaises(TypeError): + self.form.LoadProps(None) + + + def test_loadprops_with_empty_filename(self): + """Test LoadProps with empty string as filename.""" + with self.assertRaises(OSError) as context: + self.form.LoadProps('') + self.assertIn('not found', str(context.exception).lower()) + + + def test_loadprops_with_integer(self): + """Test LoadProps with integer (wrong type).""" + with self.assertRaises(TypeError): + self.form.LoadProps(123) + + + def test_loadprops_with_wrong_file_content(self): + """Test LoadProps with file that exists but wrong content.""" + txt_file = os.path.join(self.test_dir, 'test_wrong_content.dfm') + with open(txt_file, 'w', encoding='utf-8') as f: + f.write('not a dfm file') + + with self.assertRaises(RuntimeError) as context: + self.form.LoadProps(txt_file) + self.assertIn('EParserError', str(context.exception)) + + + def test_loadprops_with_empty_file(self): + """Test LoadProps with empty file.""" + empty_file = os.path.join(self.test_dir, 'empty.dfm') + with open(empty_file, 'w', encoding='utf-8'): + pass + + with self.assertRaises(RuntimeError) as context: + self.form.LoadProps(empty_file) + self.assertIn('EReadError', str(context.exception)) + + + def test_loadprops_with_file_no_read_permission(self): + """Test LoadProps with file that has no read permissions.""" + no_read_file = os.path.join(self.test_dir, 'no_read.dfm') + self._copy_dfm_to_path(no_read_file) + + with self._deny_read_access(no_read_file): + with self.assertRaises(OSError) as context: + self.form.LoadProps(no_read_file) + self.assertIn('access is denied', str(context.exception).lower()) + self.assertIn('EFOpenError', str(context.exception)) + + + def test_loadprops_with_directory_no_read_permission(self): + """Test LoadProps with file in directory that has no read permissions.""" + no_read_dir = os.path.join(self.test_dir, 'no_read_dir') + os.makedirs(no_read_dir, exist_ok=True) + file_in_no_read_dir = os.path.join(no_read_dir, 'test.dfm') + self._copy_dfm_to_path(file_in_no_read_dir) + + with self._deny_read_access(no_read_dir): + with self.assertRaises(OSError) as context: + self.form.LoadProps(file_in_no_read_dir) + # Not readable directory should lead to file not found error + self.assertIn(file_in_no_read_dir, str(context.exception)) + self.assertIn('not found', str(context.exception)) + + + def test_loadprops_with_locked_file(self): + """Test LoadProps with file that is locked by another process.""" + locked_file = os.path.join(self.test_dir, 'locked.dfm') + self._copy_dfm_to_path(locked_file) + + with self._lock_file(locked_file): + with self.assertRaises(OSError) as context: + self.form.LoadProps(locked_file) + self.assertIn(locked_file, str(context.exception)) + self.assertIn('EFOpenError', str(context.exception)) + + + def test_loadprops_with_corrupted_binary_file(self): + """Test LoadProps with file that looks like binary but is corrupted.""" + corrupted_file = os.path.join(self.test_dir, 'corrupted.dfm') + # Write some binary data that might look like a DFM but is corrupted + with open(corrupted_file, 'wb') as f: + f.write(b'TPF0') # Valid signature + f.write(b'a' * 100) + + with self.assertRaises(RuntimeError) as context: + self.form.LoadProps(corrupted_file) + error_msg = str(context.exception).lower() + self.assertTrue(str(context.exception).startswith('EReadError'), f"Expected EReadError, got: {context.exception}") + + + + def test_loadprops_with_incomplete_text_file(self): + """Test LoadProps with text file that starts correctly but is incomplete.""" + incomplete_file = os.path.join(self.test_dir, 'incomplete.dfm') + # Write partial DFM text content + with open(incomplete_file, 'w', encoding='utf-8') as f: + f.write('object Form1: TForm1\n') + f.write(' Caption = \'Form1\'\n') + # Missing closing 'end' and proper structure + + with self.assertRaises(RuntimeError) as context: + self.form.LoadProps(incomplete_file) + # Should raise parsing error + error_msg = str(context.exception).lower() + self.assertTrue( + 'error' in error_msg or 'parse' in error_msg or 'eparsererror' in error_msg, + f"Expected parsing error for incomplete file, got: {context.exception}" + ) + + + # ========== PathLike Object Edge Cases ========== + + def test_loadprops_with_custom_pathlike(self): + """Test LoadProps with custom PathLike object.""" + class CustomPathLike: + def __init__(self, path): + self.path = path + def __fspath__(self): + return self.path + + result = self.form.LoadProps(CustomPathLike(self.valid_dfm)) + self.assertTrue(result, "LoadProps should work with custom PathLike") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_pathlike_raising_exception(self): + """Test LoadProps with PathLike that raises exception in __fspath__.""" + class ExceptionPathLike: + def __fspath__(self): + raise ValueError("Custom exception from __fspath__") + + # The exception from __fspath__ should propagate + with self.assertRaises(ValueError) as context: + self.form.LoadProps(ExceptionPathLike()) + self.assertIn("Custom exception from __fspath__", str(context.exception)) + + + def test_loadprops_with_pathlike_returning_none(self): + """Test LoadProps with PathLike that returns None from __fspath__.""" + class NonePathLike: + def __fspath__(self): + return None + + with self.assertRaises(TypeError) as context: + self.form.LoadProps(NonePathLike()) + self.assertIn('Python function `__fspath__` should return value of following type(s): str or bytes. Instead type `NoneType` was returned.', str(context.exception)) + + + def test_loadprops_with_pathlike_returning_integer(self): + """Test LoadProps with PathLike that returns integer from __fspath__.""" + class IntPathLike: + def __fspath__(self): + return 42 + + with self.assertRaises(TypeError) as context: + self.form.LoadProps(IntPathLike()) + self.assertIn('Python function `__fspath__` should return value of following type(s): str or bytes. Instead type `int` was returned.', str(context.exception)) + + + def test_loadprops_with_pathlike_being_not_callable(self): + """Test LoadProps with PathLike that returns integer from __fspath__.""" + class NonCallablePathLike: + def __init__(self, path): + self.__fspath__ = path + + with self.assertRaises(TypeError) as context: + self.form.LoadProps(NonCallablePathLike(self.valid_dfm)) + self.assertIn('Expected argument type(s): str, bytes or os.PathLike', str(context.exception)) + + + def test_loadprops_with_pathlike_utf8(self): + """Test LoadProps with custom PathLike returning UTF-8 path.""" + class UTF8PathLike: + def __init__(self, path): + self.path = path + def __fspath__(self): + return self.path + + result = self.form.LoadProps(UTF8PathLike(self.utf8_dfm)) + self.assertTrue(result, "LoadProps should work with UTF-8 PathLike") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_bytes_path(self): + """Test LoadProps with bytes object as path.""" + bytes_path = self.valid_dfm.encode('utf-8') + result = self.form.LoadProps(bytes_path) + self.assertTrue(result, "LoadProps should work with bytes path") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_bytes_path_utf8(self): + """Test LoadProps with UTF-8 bytes path.""" + bytes_path = self.utf8_dfm.encode('utf-8') + result = self.form.LoadProps(bytes_path) + self.assertTrue(result, "LoadProps should work with UTF-8 bytes path") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_pathlike_returning_bytes(self): + """Test LoadProps with PathLike that returns bytes from __fspath__.""" + class BytesPathLike: + def __init__(self, path): + self.path = path + def __fspath__(self): + return self.path.encode('utf-8') + + bytes_pathlike = BytesPathLike(self.valid_dfm) + result = self.form.LoadProps(bytes_pathlike) + self.assertTrue(result, "LoadProps should work with PathLike returning bytes") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_pathlike_returning_bytes_utf8(self): + """Test LoadProps with PathLike returning UTF-8 bytes.""" + class UTF8BytesPathLike: + """PathLike that returns UTF-8 bytes.""" + def __init__(self, path): + self.path = path + def __fspath__(self): + return self.path.encode('utf-8') + + utf8_bytes_pathlike = UTF8BytesPathLike(self.utf8_dfm) + result = self.form.LoadProps(utf8_bytes_pathlike) + self.assertTrue(result, "LoadProps should work with PathLike returning UTF-8 bytes") + self._verify_basic_properties_loaded(self.form) + + + def test_loadprops_with_pathlike_returning_bytes_invalid_encoding(self): + """Test LoadProps with PathLike returning bytes with invalid encoding.""" + class InvalidBytesPathLike: + def __fspath__(self): + # Return bytes that are not valid UTF-8 + return b'\xff\xfe\x00\x01' + + with self.assertRaises(UnicodeDecodeError) as context: + self.form.LoadProps(InvalidBytesPathLike()) + self.assertEqual("'utf-8' codec can't decode byte 0xff in position 0: invalid start byte", str(context.exception)) + + + def test_loadprops_overwrites_existing_properties(self): + """Test that LoadProps overwrites existing form properties.""" + self.form.Caption = 'Initial Caption' + self.form.ClientWidth = 100 + self.form.ClientHeight = 100 + + result = self.form.LoadProps(self.valid_dfm) + self.assertTrue(result) + self._verify_basic_properties_loaded(self.form) + + +def run_tests(): + """Run all tests.""" + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + # Add all test classes + suite.addTests(loader.loadTestsFromTestCase(TestLoadProps)) + + # Run tests with default unittest runner + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + + # Return exit code + return 0 if result.wasSuccessful() else 1 + + +if __name__ == '__main__': + sys.exit(run_tests()) + diff --git a/Modules/DelphiVCL/tests/test_form.dfm b/Modules/DelphiVCL/tests/test_form.dfm new file mode 100644 index 00000000..8fd0d090 --- /dev/null +++ b/Modules/DelphiVCL/tests/test_form.dfm @@ -0,0 +1,56 @@ +object Form1: TForm1 + Left = 0 + Top = 0 + Caption = 'Form1' + ClientHeight = 441 + ClientWidth = 624 + Color = clBtnFace + Font.Charset = DEFAULT_CHARSET + Font.Color = clWindowText + Font.Height = -12 + Font.Name = 'Segoe UI' + Font.Style = [] + TextHeight = 15 + object SpinEdit1: TSpinEdit + Left = 216 + Top = 328 + Width = 121 + Height = 24 + MaxValue = 0 + MinValue = 0 + TabOrder = 0 + Value = 0 + end + object ActivityIndicator1: TActivityIndicator + Left = 496 + Top = 328 + end + object LabeledEdit1: TLabeledEdit + Left = 80 + Top = 208 + Width = 121 + Height = 23 + EditLabel.Width = 67 + EditLabel.Height = 15 + EditLabel.Caption = 'LabeledEdit1' + TabOrder = 2 + Text = '' + end + object Edit1: TEdit + Left = 80 + Top = 256 + Width = 121 + Height = 23 + TabOrder = 3 + Text = 'Edit1' + end + object Button1: TButton + Left = 184 + Top = 392 + Width = 75 + Height = 25 + Caption = 'Button1' + TabOrder = 4 + end +end + diff --git a/Source/PythonEngine.pas b/Source/PythonEngine.pas index 9e0343da..a605d406 100644 --- a/Source/PythonEngine.pas +++ b/Source/PythonEngine.pas @@ -1251,10 +1251,26 @@ EPySystemExit = class (EPyException); EPyTypeError = class (EPyStandardError); EPyUnboundLocalError = class (EPyNameError); EPyValueError = class (EPyStandardError); - EPyUnicodeError = class (EPyValueError); + + // UnicodeError -> accepts any tuple, but dont map this to specific attributes, just pass it around + // UnicodeTranslateError -> PyArg_ParseTuple(args, "UnnU", &object, &start, &end, &reason) + // UnicodeDecodeError -> PyArg_ParseTuple(args, "UOnnU", &encoding, &object, &start, &end, &reason) + // UnicodeEncodeError -> PyArg_ParseTuple(args, "UUnnU", &encoding, &object, &start, &end, &reason) + EPyUnicodeError = class (EPyValueError) + public + EEncoding: UnicodeString; + EReason: UnicodeString; + EObjectRepr: UnicodeString; // String representation of the object (for delphi debugging/logging) + EStart: Integer; + EEnd: Integer; + EArgs: PPyObject; // of PyTuple_Type; original args for python exception constructor + constructor Create; + destructor Destroy; override; + end; UnicodeEncodeError = class (EPyUnicodeError); UnicodeDecodeError = class (EPyUnicodeError); UnicodeTranslateError = class (EPyUnicodeError); + EPyZeroDivisionError = class (EPyArithmeticError); EPyStopIteration = class(EPyException); EPyWarning = class (EPyException); @@ -1576,6 +1592,7 @@ TPythonInterface=class(TDynamicDll) PyErr_SetNone: procedure(value: PPyObject); cdecl; PyErr_SetObject: procedure (ob1, ob2 : PPyObject); cdecl; PyErr_SetString: procedure( ErrorObject: PPyObject; text: PAnsiChar); cdecl; + PyErr_Format: function(ErrorObject: PPyObject; format: PAnsiChar; obj: PPyObject {...}): PPyObject; cdecl varargs; PyErr_WarnEx: function (ob: PPyObject; text: PAnsiChar; stack_level: NativeInt): integer; cdecl; PyErr_WarnExplicit: function (ob: PPyObject; text: PAnsiChar; filename: PAnsiChar; lineno: integer; module: PAnsiChar; registry: PPyObject): integer; cdecl; PyImport_GetModuleDict: function: PPyObject; cdecl; @@ -1803,6 +1820,7 @@ TPythonInterface=class(TDynamicDll) PyUnicode_FromStringAndSize:function (s:PAnsiChar;i:NativeInt):PPyObject; cdecl; PyUnicode_FromKindAndData:function (kind:integer;const buffer:pointer;size:NativeInt):PPyObject; cdecl; PyUnicode_AsWideChar:function (unicode: PPyObject; w:PWCharT; size:NativeInt):integer; cdecl; + PyUnicode_AsWideCharString:function (unicode: PPyObject; size: PNativeInt):PWCharT; cdecl; PyUnicode_AsUTF8:function (unicode: PPyObject):PAnsiChar; cdecl; PyUnicode_AsUTF8AndSize:function (unicode: PPyObject; size: PNativeInt):PAnsiChar; cdecl; PyUnicode_Decode:function (const s:PAnsiChar; size: NativeInt; const encoding : PAnsiChar; const errors: PAnsiChar):PPyObject; cdecl; @@ -1810,6 +1828,7 @@ TPythonInterface=class(TDynamicDll) PyUnicode_AsEncodedString:function (unicode:PPyObject; const encoding:PAnsiChar; const errors:PAnsiChar):PPyObject; cdecl; PyUnicode_FromOrdinal:function (ordinal:integer):PPyObject; cdecl; PyUnicode_GetLength:function (unicode:PPyObject):NativeInt; cdecl; + PyUnicode_DecodeFSDefaultAndSize:function (const s:PAnsiChar; size: NativeInt): PPyObject; cdecl; PyWeakref_GetObject: function ( ref : PPyObject) : PPyObject; cdecl; PyWeakref_NewProxy: function ( ob, callback : PPyObject) : PPyObject; cdecl; PyWeakref_NewRef: function ( ob, callback : PPyObject) : PPyObject; cdecl; @@ -1954,6 +1973,7 @@ TPythonInterface=class(TDynamicDll) function PyWeakref_CheckProxy( obj : PPyObject ) : Boolean; function PyBool_Check( obj : PPyObject ) : Boolean; function PyEnum_Check( obj : PPyObject ) : Boolean; + function PyPathLike_Check( obj : PPyObject ) : Boolean; // The following are defined as non-exported inline functions in object.h function Py_Type(ob: PPyObject): PPyTypeObject; inline; @@ -2119,6 +2139,7 @@ TPythonEngine = class(TPythonInterface) function CheckExecSyntax( const str : AnsiString ) : Boolean; function CheckSyntax( const str : AnsiString; mode : Integer ) : Boolean; procedure RaiseError; + procedure SetPyErrFromException(E: Exception); function PyObjectAsString( obj : PPyObject ) : string; procedure DoRedirectIO; procedure AddClient( client : TEngineClient ); @@ -2172,6 +2193,11 @@ TPythonEngine = class(TPythonInterface) function PyBytesAsAnsiString( obj : PPyObject ) : AnsiString; function PyByteArrayAsAnsiString( obj : PPyObject ) : AnsiString; + { Filesystem strings conversion } + function PyBytesAsFSDecodedString( bytes : PPyObject): string; + function PyPathLikeObjectAsString( pathlike : PPyObject ) : string; + function PyFSPathObjectAsString( path : PPyObject ) : string; + // Public Properties property ClientCount : Integer read GetClientCount; property Clients[ idx : Integer ] : TEngineClient read GetClients; @@ -3112,6 +3138,8 @@ implementation SPyExcSystemError = 'Unhandled SystemExit exception. Code: %s'; SPyInitFailed = 'Python initialization failed: %s'; SPyInitFailedUnknown = 'Unknown initialization error'; +SPyWrongArgumentType = 'Expected argument type(s): %s, real type: %s'; +SPyReturnTypeError = 'Python function `%s` should return value of following type(s): %s. Instead type `%s` was returned.'; SCannotCreateMain = 'Run_CommandAsObject: can''t create __main__'; SRaiseError = 'RaiseError: couldn''t fetch last exception'; SMissingModuleDateTime = 'dcmToDatetime DatetimeConversionMode cannot be used with this version of python. Missing module datetime'; @@ -3949,6 +3977,7 @@ procedure TPythonInterface.MapDll; PyErr_Clear := Import('PyErr_Clear'); PyErr_Fetch := Import('PyErr_Fetch'); PyErr_SetString := Import('PyErr_SetString'); + PyErr_Format := Import('PyErr_Format'); PyErr_WarnEx := Import('PyErr_WarnEx'); PyErr_WarnExplicit := Import('PyErr_WarnExplicit'); PyEval_GetBuiltins := Import('PyEval_GetBuiltins'); @@ -4156,6 +4185,7 @@ procedure TPythonInterface.MapDll; PyUnicode_FromStringAndSize := Import('PyUnicode_FromStringAndSize'); PyUnicode_FromKindAndData := Import('PyUnicode_FromKindAndData'); PyUnicode_AsWideChar := Import('PyUnicode_AsWideChar'); + PyUnicode_AsWideCharString := Import('PyUnicode_AsWideCharString'); PyUnicode_AsUTF8 := Import('PyUnicode_AsUTF8'); PyUnicode_AsUTF8AndSize := Import('PyUnicode_AsUTF8AndSize'); PyUnicode_Decode := Import('PyUnicode_Decode'); @@ -4163,6 +4193,7 @@ procedure TPythonInterface.MapDll; PyUnicode_AsEncodedString := Import('PyUnicode_AsEncodedString'); PyUnicode_FromOrdinal := Import('PyUnicode_FromOrdinal'); PyUnicode_GetLength := Import('PyUnicode_GetLength'); + PyUnicode_DecodeFSDefaultAndSize := Import('PyUnicode_DecodeFSDefaultAndSize'); PyWeakref_GetObject := Import('PyWeakref_GetObject'); PyWeakref_NewProxy := Import('PyWeakref_NewProxy'); PyWeakref_NewRef := Import('PyWeakref_NewRef'); @@ -4445,6 +4476,27 @@ function TPythonInterface.PyEnum_Check( obj : PPyObject ) : Boolean; Result := Assigned( obj ) and (obj^.ob_type = PPyTypeObject(PyEnum_Type)); end; +function TPythonInterface.PyPathLike_Check( obj : PPyObject ) : Boolean; + var tmp: PPyObject; +begin + Result := False; + if not Assigned(obj) then + Exit; + + tmp := PyObject_GetAttrString(obj, '__fspath__'); + + if tmp = nil then begin + PyErr_Clear; + Exit; + end; + + try + Result := PyCallable_Check(tmp) <> 0; + finally + Py_XDECREF(tmp); + end; +end; + function TPythonInterface.Py_Type(ob: PPyObject): PPyTypeObject; begin Result := ob^.ob_type; @@ -5581,6 +5633,104 @@ procedure TPythonEngine.RaiseError; Result.Message := sType; end; + function SafeGetPyObjectAttr(const obj : PPyObject; const name : PAnsiChar): PPyObject; + begin + if PyObject_HasAttrString(obj, name) = 0 then + Exit(nil); + + Exit(PyObject_GetAttrString(obj, name)); + end; + + function DefineUnicodeError( E : EPyUnicodeError; const sType, sValue : UnicodeString; err_type, err_value : PPyObject ) : EPyUnicodeError; + var + tmp : PPyObject; + begin + Result := E; + Result.EName := sType; + Result.EValue := sValue; + Result.EEncoding := ''; + Result.EReason := ''; + Result.EObjectRepr := ''; + Result.EStart := 0; + Result.EEnd := 0; + + + // Get the args - arguments with which exception has been created. + tmp := SafeGetPyObjectAttr(err_value, 'args'); + if tmp <> nil then + begin + if PyTuple_Check(tmp) then + Result.EArgs := tmp + else begin + Py_XDECREF(tmp); + Result.EArgs := PyTuple_New(0); + end; + end; + + // For pure UnicodeError - following doesnt have sense + if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeError^) = 0) then + begin + // Get the reason + tmp := SafeGetPyObjectAttr(err_value, 'reason'); + if tmp <> nil then + begin + if PyUnicode_Check(tmp) then + Result.EReason := PyUnicodeAsString(tmp) + else if PyBytes_Check(tmp) then + Result.EReason := UnicodeString(PyBytesAsAnsiString(tmp)); + Py_XDECREF(tmp); + end; + + // Get the object (We will need it just EObjectRepr representation) + tmp := SafeGetPyObjectAttr(err_value, 'object'); + if tmp <> nil then + begin + Result.EObjectRepr := PyObjectAsString(tmp); + Py_XDECREF(tmp); + end; + + // Get the start index + tmp := SafeGetPyObjectAttr(err_value, 'start'); + if Assigned(tmp) and PyLong_Check(tmp) then + Result.EStart := PyLong_AsLong(tmp); + Py_XDECREF(tmp); + + // Get the end index + tmp := SafeGetPyObjectAttr(err_value, 'end'); + if Assigned(tmp) and PyLong_Check(tmp) then + Result.EEnd := PyLong_AsLong(tmp); + Py_XDECREF(tmp); + + + // Get the encoding (Not needed for Translate Error - always None) + if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeTranslateError^) = 0) then begin + tmp := SafeGetPyObjectAttr(err_value, 'encoding'); + if tmp <> nil then + begin + if PyUnicode_Check(tmp) then + Result.EEncoding := PyUnicodeAsString(tmp) + else if PyBytes_Check(tmp) then + Result.EEncoding := UnicodeString(PyBytesAsAnsiString(tmp)); + Py_XDECREF(tmp); + end; + end; + + end; // NOT pure UnicodeError + + // Populate the result + with Result do + begin + if ((sType<>'') and (sValue<>'')) then // Original text + Message := Format('%s: %s', [sType, sValue]) + else if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeError^) = 0) then + Message := Format('%s: %s (encoding: %s) (position: %d-%d) (source: %s)', + [sType, EReason, EEncoding, EStart, EEnd, EObjectRepr]) + else // basic UnicodeError + Message := 'UnicodeError: Unknown Reason.'; + end; + end; + + function GetTypeAsString( obj : PPyObject ) : string; begin if PyType_CheckExact( obj ) then @@ -5658,13 +5808,13 @@ procedure TPythonEngine.RaiseError; else if (PyErr_GivenExceptionMatches(err_type, PyExc_ArithmeticError^) <> 0) then raise Define( EPyArithmeticError.Create(''), s_type, s_value ) else if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeEncodeError^) <> 0) then - raise Define( UnicodeEncodeError.Create(''), s_type, s_value ) + raise DefineUnicodeError( UnicodeEncodeError.Create(), s_type, s_value, err_type, err_value ) else if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeDecodeError^) <> 0) then - raise Define( UnicodeDecodeError.Create(''), s_type, s_value ) + raise DefineUnicodeError( UnicodeDecodeError.Create(), s_type, s_value, err_type, err_value ) else if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeTranslateError^) <> 0) then - raise Define( UnicodeTranslateError.Create(''), s_type, s_value ) + raise DefineUnicodeError( UnicodeTranslateError.Create(), s_type, s_value, err_type, err_value ) else if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeError^) <> 0) then - raise Define( EPyUnicodeError.Create(''), s_type, s_value ) + raise DefineUnicodeError( EPyUnicodeError.Create(), s_type, s_value, err_type, err_value ) else if (PyErr_GivenExceptionMatches(err_type, PyExc_ValueError^) <> 0) then raise Define( EPyValueError.Create(''), s_type, s_value ) else if (PyErr_GivenExceptionMatches(err_type, PyExc_ReferenceError^) <> 0) then @@ -5699,6 +5849,202 @@ procedure TPythonEngine.RaiseError; raise EPythonError.Create(SRaiseError); end; +procedure TPythonEngine.SetPyErrFromException(E: Exception); + // This function translates Delphi Exception to Python exception. + // It actually mirrors RaiseError procedure AND translates + // some Delphi exceptions to pythons. + // + // The function is intended to simplify delphi exception handling on + // wrapped methods exposed in python. + + function ExtractErrorMessage(const E: Exception): UnicodeString; + begin + if (E is EPythonError) then + Exit(E.Message) + else if E.Message <> '' then + Exit(Format('%s: %s', [E.ClassName, E.Message])) + else + Exit(E.ClassName); + end; + + function MsgForPython(const E: Exception): AnsiString; + begin + Result := EncodeString(ExtractErrorMessage(E)); + end; + + + procedure SetPythonError(const PythonExc: PPyObject; const msg: AnsiString); + begin + PyErr_SetString(PythonExc, PAnsiChar(msg)); + end; + + procedure SetFSPythonError(const PythonExc: PPyObject; const E: EPyOSError); + // Filesystem error needs special handling, since in various platforms, + // filesystem objects names can be variously assembled bytes, that is actually + // invalid utf8 - so we need to handle it more carefuly. + // On the other side, this is not correct handling for all cases, since + // sometimes it could lead to not correct print of surrogate pairs (like 🍕), + // which COULD be printed as more characters, instead of one emoji. + + var + PyMsgObj: PPyObject; + msg: string; + begin + msg := ExtractErrorMessage(E); + + PyMsgObj := PyUnicode_FromKindAndData(SizeOf(WideChar), PWideChar(msg), Length(msg)); + + if PyMsgObj = nil then begin + WriteLn(ErrOutput, 'Error during creating Python exception: Constructing exception string failed.'); + if PyErr_Occurred <> nil then + PyErr_Print; + SetPythonError(PythonExc, EncodeString(E.ClassName)); + Exit; + end; + + try + // yes, this is uppercase `S` in format string, lowercase `s` doesnt work. + PyErr_Format(PythonExc, '%S', PyMsgObj); + finally + Py_XDECREF(PyMsgObj); + end; + end; + + procedure SetUnicodeError(const PythonExc: PPyObject; const UnicodeErr: EPyUnicodeError); + var + exc_instance: PPyObject; + begin + // Create exception instance with proper attributes + exc_instance := nil; + try + exc_instance := PyObject_CallObject(PythonExc, UnicodeErr.EArgs); + if exc_instance = nil then + begin + WriteLn(ErrOutput, 'Error during creating Python exception: Constructing exception failed.'); + if PyErr_Occurred <> nil then + PyErr_Print; + SetPythonError(PyExc_UnicodeError^, MsgForPython(UnicodeErr)); + Exit; + end; + + PyErr_SetObject(PythonExc, exc_instance); + finally + Py_XDECREF(exc_instance); + end; + end; + + +begin + // Don’t overwrite an already-set Python error. + // TODO: Consider more robust exception handling, + // with reporting another exception, while handling exception, and/or + // reporting unhandled python exceptions). + if PyErr_Occurred <> nil then + Exit; + + { ------------------------------------------------------------------------ + Mirror of RaiseError mapping order + ------------------------------------------------------------------------} + if (E is EPySystemExit) then + SetPythonError(PyExc_SystemExit^, MsgForPython(E)) + else if (E is EPyStopIteration) then + SetPythonError(PyExc_StopIteration^, MsgForPython(E)) + else if (E is EPyKeyboardInterrupt) then + SetPythonError(PyExc_KeyboardInterrupt^, MsgForPython(E)) + else if (E is EPyImportError) then + SetPythonError(PyExc_ImportError^, MsgForPython(E)) +{$IFDEF MSWINDOWS} + else if (E is EPyWindowsError) then + SetPythonError(PyExc_WindowsError^, MsgForPython(E)) +{$ENDIF} + else if (E is EPyIOError) then + SetFSPythonError(PyExc_IOError^, E as EPyOSError) + else if (E is EPyOSError) then + SetFSPythonError(PyExc_OSError^, E as EPyOSError) + else if (E is EPyEnvironmentError) then + SetPythonError(PyExc_EnvironmentError^, MsgForPython(E)) + else if (E is EPyEOFError) then + SetPythonError(PyExc_EOFError^, MsgForPython(E)) + else if (E is EPyNotImplementedError) then + SetPythonError(PyExc_NotImplementedError^, MsgForPython(E)) + else if (E is EPyRuntimeError) then + SetPythonError(PyExc_RuntimeError^, MsgForPython(E)) + else if (E is EPyUnboundLocalError) then + SetPythonError(PyExc_UnboundLocalError^, MsgForPython(E)) + else if (E is EPyNameError) then + SetPythonError(PyExc_NameError^, MsgForPython(E)) + else if (E is EPyAttributeError) then + SetPythonError(PyExc_AttributeError^, MsgForPython(E)) + else if (E is EPyTabError) then + SetPythonError(PyExc_TabError^, MsgForPython(E)) + else if (E is EPyIndentationError) then + SetPythonError(PyExc_IndentationError^, MsgForPython(E)) + else if (E is EPySyntaxError) then + SetPythonError(PyExc_SyntaxError^, MsgForPython(E)) + else if (E is EPyTypeError) then + SetPythonError(PyExc_TypeError^, MsgForPython(E)) + else if (E is EPyAssertionError) then + SetPythonError(PyExc_AssertionError^, MsgForPython(E)) + else if (E is EPyIndexError) then + SetPythonError(PyExc_IndexError^, MsgForPython(E)) + else if (E is EPyKeyError) then + SetPythonError(PyExc_KeyError^, MsgForPython(E)) + else if (E is EPyLookupError) then + SetPythonError(PyExc_LookupError^, MsgForPython(E)) + else if (E is EPyOverflowError) then + SetPythonError(PyExc_OverflowError^, MsgForPython(E)) + else if (E is EPyZeroDivisionError) then + SetPythonError(PyExc_ZeroDivisionError^, MsgForPython(E)) + else if (E is EPyFloatingPointError) then + SetPythonError(PyExc_FloatingPointError^, MsgForPython(E)) + else if (E is EPyArithmeticError) then + SetPythonError(PyExc_ArithmeticError^, MsgForPython(E)) + else if (E is UnicodeEncodeError) then + SetUnicodeError(PyExc_UnicodeEncodeError^, EPyUnicodeError(E)) + else if (E is UnicodeDecodeError) then + SetUnicodeError(PyExc_UnicodeDecodeError^, EPyUnicodeError(E)) + else if (E is UnicodeTranslateError) then + SetUnicodeError(PyExc_UnicodeTranslateError^, EPyUnicodeError(E)) + else if (E is EPyUnicodeError) then + SetUnicodeError(PyExc_UnicodeError^, EPyUnicodeError(E)) + else if (E is EPyValueError) then + SetPythonError(PyExc_ValueError^, MsgForPython(E)) + else if (E is EPyReferenceError) then + SetPythonError(PyExc_ReferenceError^, MsgForPython(E)) + else if (E is EPyBufferError) then + SetPythonError(PyExc_BufferError^, MsgForPython(E)) + else if (E is EPySystemError) then + SetPythonError(PyExc_SystemError^, MsgForPython(E)) + else if (E is EPyMemoryError) then + SetPythonError(PyExc_MemoryError^, MsgForPython(E)) + else if (E is EPyUserWarning) then + SetPythonError(PyExc_UserWarning^, MsgForPython(E)) + else if (E is EPyDeprecationWarning) then + SetPythonError(PyExc_DeprecationWarning^, MsgForPython(E)) + else if (E is EPySyntaxWarning) then + SetPythonError(PyExc_SyntaxWarning^, MsgForPython(E)) + else if (E is EPyRuntimeWarning) then + SetPythonError(PyExc_RuntimeWarning^, MsgForPython(E)) + else if (E is FutureWarning) then + SetPythonError(PyExc_FutureWarning^, MsgForPython(E)) + else if (E is PendingDeprecationWarning) then + SetPythonError(PyExc_PendingDeprecationWarning^, MsgForPython(E)) + else if (E is EPyWarning) then + SetPythonError(PyExc_Warning^, MsgForPython(E)) + else if (E is EPyException) then + SetPythonError(PyExc_Exception^, MsgForPython(E)) + else if (E is EPyExecError) then + SetPythonError(PyExc_Exception^, MsgForPython(E)) + + { ------------------------------------------------------------------------ + Native Delphi exceptions mapping + ------------------------------------------------------------------------} + else if (E is EFOpenError) then + SetPythonError(PyExc_OSError^, MsgForPython(E)) + else + SetPythonError(PyExc_RuntimeError^, MsgForPython(E)); +end; + function TPythonEngine.PyObjectAsString( obj : PPyObject ) : string; var S : PPyObject; @@ -6460,13 +6806,16 @@ function TPythonEngine.PyUnicodeAsString(obj : PPyObject): UnicodeString; Size: NativeInt; NewSize: Cardinal; begin + if PyUnicode_Check(obj) then begin // Size does not include the final #0 + Size := 0; // When Buffer is set to nil, Size could stay unintialized Buffer := PyUnicode_AsUTF8AndSize(obj, @Size); SetLength(Result, Size); + if (Size = 0) or (Buffer = nil) then - Exit; + Exit; // TODO: Consider RaiseError for Buffer=nil and PyErr_Occured // The second argument is the size of the destination (Result) including #0 NewSize := Utf8ToUnicode(PWideChar(Result), Cardinal(Size + 1), Buffer, Cardinal(Size)); @@ -6497,6 +6846,90 @@ function TPythonEngine.PyUnicodeAsUTF8String( obj : PPyObject ) : RawByteString; raise EPythonError.CreateFmt(SPyConvertionError, ['PyUnicodeAsUTF8String', 'Unicode']); end; +function TPythonEngine.PyBytesAsFSDecodedString( bytes : PPyObject) : string; +// Bytes with the meaning of FileSystem paths returned from python should have +// special treatment for decoding. Python provides this. +var + CharArray: PAnsiChar; + CharCount: NativeInt; + UnicodeObject: PPyObject; + WideBuffer: PWCharT; + + {$IFDEF POSIX} + function PWCharT2UCS4String(ABuffer: PWCharT; ACharCount: NativeInt) : UCS4String; + begin + SetLength(Result, ACharCount + 1); + Move(ABuffer^, Result[0], ACharCount * SizeOf(UCS4Char)); + Result[ACharCount] := 0; + end; + {$ENDIF} + +begin + if not PyBytes_Check(bytes) then + raise EPythonError.CreateFmt(SPyConvertionError, ['PyBytesAsFSDecodedString', 'Bytes']); + + if PyBytes_AsStringAndSize(bytes, CharArray, CharCount) <> 0 then + RaiseError; + + UnicodeObject := nil; + UnicodeObject := PyUnicode_DecodeFSDefaultAndSize(CharArray, CharCount); + if UnicodeObject = nil then RaiseError; + + try + WideBuffer := PyUnicode_AsWideCharString(UnicodeObject, @CharCount); + if WideBuffer = nil then RaiseError; + try + {$IFDEF POSIX} + Result := UCS4StringToWideString(PWCharT2UCS4String(WideBuffer, CharCount)); + {$ELSE} + SetString(Result, WideBuffer, CharCount); + {$ENDIF} + finally + PyMem_Free(WideBuffer); + end; + + finally + Py_XDECREF(UnicodeObject); + end; + +end; + +function TPythonEngine.PyPathLikeObjectAsString( pathlike : PPyObject ) : string; +var + tmp: PPyObject; +begin + tmp := PyObject_CallMethod(pathlike, '__fspath__', nil); + + if tmp = nil then + if PyErr_Occurred <> nil then + RaiseError // If call already set the exception, it will be propagated. + else + raise EPyTypeError.CreateFmt(SPyReturnTypeError, ['__fspath__', 'str or bytes', 'NULL']); + + try + if PyUnicode_Check(tmp) then + Exit(PyUnicodeAsString(tmp)) + else if PyBytes_Check(tmp) then + Exit(PyBytesAsFSDecodedString(tmp)) + else + raise EPyTypeError.CreateFmt(SPyReturnTypeError, ['__fspath__', 'str or bytes', tmp^.ob_type^.tp_name]); + finally + Py_XDECREF(tmp); + end; +end; + +function TPythonEngine.PyFSPathObjectAsString( path : PPyObject ) : string; +begin + if PyPathLike_Check(path) then + Exit(PyPathLikeObjectAsString(path)) + else if PyUnicode_Check(path) then + Exit(PyUnicodeAsString(path)) + else if PyBytes_Check(path) then + Exit(PyBytesAsFSDecodedString(path)) + else + raise EPyTypeError.CreateFmt(SPyWrongArgumentType, ['str, bytes or os.PathLike', path^.ob_type^.tp_name]); +end; + function TPythonEngine.PyUnicodeFromString(const AString : UnicodeString) : PPyObject; {$IFDEF POSIX} @@ -9248,6 +9681,26 @@ function TPythonType.GetMembersStartOffset : Integer; Result := Sizeof(PyObject); end; + +(*******************************************************) +(** **) +(** exception classes EPyException **) +(** **) +(*******************************************************) + +constructor EPyUnicodeError.Create; +begin + EArgs := nil; +end; + +destructor EPyUnicodeError.Destroy; +begin + with GetPythonEngine do + TPythonInterface.Py_XDECREF(EArgs); + + inherited; +end; + (*******************************************************) (** **) (** class TPythonDelphiVar **) diff --git a/Source/WrapDelphiClasses.pas b/Source/WrapDelphiClasses.pas index f7f57a86..10468df8 100644 --- a/Source/WrapDelphiClasses.pas +++ b/Source/WrapDelphiClasses.pas @@ -1096,8 +1096,8 @@ function TPyDelphiComponent.InternalReadComponent(const AResFile: string; LInput: TFileStream; LOutput: TMemoryStream; begin - if AResFile.IsEmpty or not FileExists(AResFile) then - Exit(false); + if not FileExists(AResFile) then + raise EPyOSError.CreateFmt('File `%s` not found.', [AResFile]); LInput := TFileStream.Create(AResFile, fmOpenRead); try diff --git a/Source/fmx/WrapFmxForms.pas b/Source/fmx/WrapFmxForms.pas index 906f14b5..18ca93bf 100644 --- a/Source/fmx/WrapFmxForms.pas +++ b/Source/fmx/WrapFmxForms.pas @@ -445,28 +445,24 @@ function TPyDelphiCommonCustomForm.HasFormRes(const AClass: TClass): boolean; function TPyDelphiCommonCustomForm.LoadProps_Wrapper( args: PPyObject): PPyObject; - - function FindResource(): string; - var - LStr: PAnsiChar; - begin - with GetPythonEngine() do begin - if PyArg_ParseTuple(args, 's:LoadProps', @LStr) <> 0 then begin - Result := string(LStr); - end else - Result := String.Empty; - end; - end; - +var + path: PPyObject; begin Adjust(@Self); try - if InternalReadComponent(FindResource(), DelphiObject) then - Exit(GetPythonEngine().ReturnTrue); + with GetPythonEngine() do begin + if PyArg_ParseTuple(args, 'O:LoadProps', @path) = 0 then + Exit(nil); // Python exception is already set. + if InternalReadComponent(PyFSPathObjectAsString(path), DelphiObject) then + Exit(ReturnTrue) + else + Exit(ReturnFalse); + end; except on E: Exception do - with GetPythonEngine do - PyErr_SetString(PyExc_RuntimeError^, PAnsiChar(Utf8Encode(E.Message))); + with GetPythonEngine() do begin + SetPyErrFromException(E); + end; end; Result := nil; end; diff --git a/Source/vcl/WrapVclForms.pas b/Source/vcl/WrapVclForms.pas index b3d77bec..2232bc23 100644 --- a/Source/vcl/WrapVclForms.pas +++ b/Source/vcl/WrapVclForms.pas @@ -549,30 +549,24 @@ function TPyDelphiCustomForm.Get_ModalResult(AContext: Pointer): PPyObject; end; function TPyDelphiCustomForm.LoadProps_Wrapper(args: PPyObject): PPyObject; - - function FindResource(): string; - var - LStr: PAnsiChar; - begin - with GetPythonEngine() do begin - if PyArg_ParseTuple(args, 's:LoadProps', @LStr) <> 0 then begin - Result := string(LStr); - end else - Result := String.Empty; - end; - end; - +var + path: PPyObject; begin Adjust(@Self); try - if InternalReadComponent(FindResource(), DelphiObject) then - Exit(GetPythonEngine().ReturnTrue) - else - Exit(GetPythonEngine().ReturnFalse); + with GetPythonEngine() do begin + if PyArg_ParseTuple(args, 'O:LoadProps', @path) = 0 then + Exit(nil); // Python exception is already set. + if InternalReadComponent(PyFSPathObjectAsString(path), DelphiObject) then + Exit(ReturnTrue) + else + Exit(ReturnFalse); + end; except on E: Exception do - with GetPythonEngine() do - PyErr_SetString(PyExc_RuntimeError^, PAnsiChar(EncodeString(E.Message))); + with GetPythonEngine() do begin + SetPyErrFromException(E); + end; end; Result := nil; end;