From 46f89d0a11add03cc8a6b872548cb5486ea28a52 Mon Sep 17 00:00:00 2001 From: majkl Date: Sat, 27 Dec 2025 13:29:53 +0100 Subject: [PATCH 1/3] Unicode Support for LoadProps --- Modules/DelphiVCL/tests/TestLoadProps.py | 401 +++++++++++++++++++++++ Modules/DelphiVCL/tests/test_form.dfm | 56 ++++ Source/PythonEngine.pas | 390 +++++++++++++++++++++- Source/WrapDelphiClasses.pas | 5 +- Source/vcl/WrapVclForms.pas | 31 +- 5 files changed, 856 insertions(+), 27 deletions(-) create mode 100644 Modules/DelphiVCL/tests/TestLoadProps.py create mode 100644 Modules/DelphiVCL/tests/test_form.dfm diff --git a/Modules/DelphiVCL/tests/TestLoadProps.py b/Modules/DelphiVCL/tests/TestLoadProps.py new file mode 100644 index 00000000..bac55db0 --- /dev/null +++ b/Modules/DelphiVCL/tests/TestLoadProps.py @@ -0,0 +1,401 @@ +""" +Comprehensive tests for LoadProps method in DelphiVCL module. + +Tests cover: +- Valid inputs (str, 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 +import platform +from pathlib import Path + +# Ensure DelphiVCL .pyd can be found +# This is a minimal solution for test environment - in production, the module +# should be properly installed or PYTHONPATH should be set +_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 it exists and not already there +if os.path.exists(_pyd_dir) and _pyd_dir not in sys.path: + sys.path.insert(0, _pyd_dir) + +# Import DelphiVCL module - fail loudly if not available +try: + from DelphiVCL import Form, Application +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 TestForm(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.""" + # Create a fresh form for each test + self.form = TestForm(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 _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.""" + # Change to test directory and use 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('not found', str(context.exception).lower()) + + 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_string(self): + """Test LoadProps with empty string.""" + 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 extension.""" + # Create a text file with wrong extension + 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)) + + # ========== 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_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()) + + + # ========== Edge Cases ========== + + def test_loadprops_with_very_long_path(self): + """Test LoadProps with very long path.""" + # Create a path with many nested directories + long_path = self.test_dir + for i in range(10): + long_path = os.path.join(long_path, f'dir_{i}' * 20) + os.makedirs(long_path, exist_ok=True) + + long_file = os.path.join(long_path, 'test.dfm') + self._copy_dfm_to_path(long_file) + + # Should work if path length is within system limits + try: + result = self.form.LoadProps(long_file) + self.assertTrue(result, "LoadProps should work with long path if within system limits") + self._verify_basic_properties_loaded(self.form) + except (OSError, RuntimeError) as e: + # Very long paths might fail on some systems - that's acceptable + error_msg = str(e).lower() + if any(term in error_msg for term in ['too long', 'path', 'filename']): + # Expected failure for path length limits + pass + else: + # Unexpected error - re-raise + raise + + def test_loadprops_with_unicode_normalization(self): + """Test LoadProps handles Unicode normalization correctly.""" + # Test with different Unicode representations + # Create directory with combining characters + if sys.platform == 'win32': + # Windows may normalize Unicode differently + # Test with é (U+00E9) vs e + U+0301 (combining acute) + normalized_dir = os.path.join(self.test_dir, 'café') + normalized_file = os.path.join(normalized_dir, 'form.dfm') + self._copy_dfm_to_path(normalized_file) + + result = self.form.LoadProps(normalized_file) + self.assertTrue(result, "LoadProps should handle Unicode normalization") + self._verify_basic_properties_loaded(self.form) + + 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..3b44a5c8 100644 --- a/Source/PythonEngine.pas +++ b/Source/PythonEngine.pas @@ -1251,7 +1251,15 @@ EPySystemExit = class (EPyException); EPyTypeError = class (EPyStandardError); EPyUnboundLocalError = class (EPyNameError); EPyValueError = class (EPyStandardError); - EPyUnicodeError = class (EPyValueError); + EPyUnicodeError = class (EPyValueError) + public + EEncoding: UnicodeString; + EReason: UnicodeString; + EObject: RawByteString; // The object as bytes (for simple reconstruction) + EObjectRepr: UnicodeString; // String representation of the object (for debugging/logging) + EStart: Integer; + EEnd: Integer; + end; UnicodeEncodeError = class (EPyUnicodeError); UnicodeDecodeError = class (EPyUnicodeError); UnicodeTranslateError = class (EPyUnicodeError); @@ -1810,6 +1818,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 +1963,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 +2129,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 +2183,11 @@ TPythonEngine = class(TPythonInterface) function PyBytesAsAnsiString( obj : PPyObject ) : AnsiString; function PyByteArrayAsAnsiString( obj : PPyObject ) : AnsiString; + { Filesystem strings conversion } + function PyBytesAsFSDecodedString(bytesObj: 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 +3128,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'; @@ -4163,6 +4181,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 +4464,11 @@ 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; +begin + Result := Assigned(obj) and (PyObject_HasAttrString(obj, '__fspath__') <> 0); +end; + function TPythonInterface.Py_Type(ob: PPyObject): PPyTypeObject; begin Result := ob^.ob_type; @@ -5581,6 +5605,119 @@ 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 + s_value : UnicodeString; + s_encoding : UnicodeString; + s_reason : UnicodeString; + s_object_repr : UnicodeString; + obj_bytes : RawByteString; + i_start : Integer; + i_end : Integer; + tmp : PPyObject; + buffer : PAnsiChar; + size : NativeInt; + begin + Result := E; + Result.EName := sType; + Result.EValue := sValue; + s_value := ''; + s_encoding := ''; + s_reason := ''; + s_object_repr := ''; + obj_bytes := ''; + i_start := 0; + i_end := 0; + // Sometimes there's a tuple instead of instance... + if PyTuple_Check(err_value) and (PyTuple_Size(err_value) >= 2) then + begin + s_value := PyObjectAsString(PyTuple_GetItem(err_value, 0)); + err_value := PyTuple_GetItem(err_value, 1); + // Legacy tuple format may not have all UnicodeError attributes + end else + // Is it an instance of the UnicodeError class ? + if (PyType_IsSubtype(PPyTypeObject(err_type), PPyTypeObject(PyExc_UnicodeError^)) = 1) + and IsType(err_value, PPyTypeObject(err_type)) + then + begin + // Get the encoding + tmp := SafeGetPyObjectAttr(err_value, 'encoding'); + if tmp <> nil then + begin + if PyUnicode_Check(tmp) then + s_encoding := PyUnicodeAsString(tmp) + else if PyBytes_Check(tmp) then + s_encoding := UnicodeString(PyBytesAsAnsiString(tmp)); + Py_XDECREF(tmp); + end; + // Get the reason + tmp := SafeGetPyObjectAttr(err_value, 'reason'); + if tmp <> nil then + begin + if PyUnicode_Check(tmp) then + s_reason := PyUnicodeAsString(tmp) + else if PyBytes_Check(tmp) then + s_reason := UnicodeString(PyBytesAsAnsiString(tmp)); + Py_XDECREF(tmp); + end; + // Get the object (as bytes for reconstruction) + tmp := SafeGetPyObjectAttr(err_value, 'object'); + if tmp <> nil then + begin + if PyBytes_Check(tmp) then + begin + PyBytes_AsStringAndSize(tmp, buffer, size); + SetString(obj_bytes, buffer, size); + end + else if PyByteArray_Check(tmp) then + begin + buffer := PyByteArray_AsString(tmp); + size := PyByteArray_Size(tmp); + if Assigned(buffer) and (size > 0) then + SetString(obj_bytes, buffer, size); + end; + // Get string representation for EObjectRepr + s_object_repr := PyObjectAsString(tmp); + Py_XDECREF(tmp); + end; + // Get the start index + tmp := SafeGetPyObjectAttr(err_value, 'start'); + if Assigned(tmp) and PyLong_Check(tmp) then + i_start := PyLong_AsLong(tmp); + Py_XDECREF(tmp); + // Get the end index + tmp := SafeGetPyObjectAttr(err_value, 'end'); + if Assigned(tmp) and PyLong_Check(tmp) then + i_end := PyLong_AsLong(tmp); + Py_XDECREF(tmp); + end; + // Populate the result + with Result do + begin + EEncoding := s_encoding; + EReason := s_reason; + EObject := obj_bytes; + EObjectRepr := s_object_repr; + EStart := i_start; + EEnd := i_end; + + if ((sType<>'') and (sValue<>'')) then + Message := Format('%s: %s', [sType, sValue]) // Original text + else + Message := Format('%s: %s (encoding: %s) (position: %d-%d) (source: %s)', + [sType, s_reason, s_encoding, i_start, i_end, s_object_repr]); + end; + end; + + function GetTypeAsString( obj : PPyObject ) : string; begin if PyType_CheckExact( obj ) then @@ -5657,14 +5794,14 @@ procedure TPythonEngine.RaiseError; raise Define( EPyFloatingPointError.Create(''), s_type, s_value ) 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 ) + else if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeEncodeError^) <> 0) then + 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 +5836,185 @@ procedure TPythonEngine.RaiseError; raise EPythonError.Create(SRaiseError); end; +procedure TPythonEngine.SetPyErrFromException(E: Exception); + // This function actually mirrors RaiseError procedure. Its intended to easily + // create python exception from existing delphi exception. + // + // The function is intended to simplify delphi exception handling on + // wrapped mehods exposed in python. + function MsgForPython(const E: Exception): AnsiString; + begin + if (E is EPythonError) then + Result := EncodeString(E.Message) + else if E.Message <> '' then + Result := EncodeString(Format('%s: %s', [E.ClassName, E.Message])) + else + Result := EncodeString(E.ClassName); + end; + + procedure SetPythonError(const PythonExc: PPyObject; const msg: AnsiString); + begin + PyErr_SetString(PythonExc, PAnsiChar(msg)); + end; + + procedure SetUnicodeError(const PythonExc: PPyObject; const UnicodeErr: EPyUnicodeError); + var + exc_instance, args_tuple, encoding_obj, reason_obj, object_obj, start_obj, end_obj: PPyObject; + encoding_str, reason_str: AnsiString; + begin + // Create exception instance with proper attributes + // UnicodeError(encoding, reason, object, start, end) + exc_instance := nil; + args_tuple := nil; + encoding_obj := nil; + reason_obj := nil; + object_obj := nil; + start_obj := nil; + end_obj := nil; + + try + // Prepare encoding + encoding_obj := PyUnicode_FromString(PAnsiChar(EncodeString(UnicodeErr.EEncoding))); + + // Prepare reason + reason_obj := PyUnicode_FromString(PAnsiChar(EncodeString(UnicodeErr.EReason))); + + // Prepare object (from EObject bytes) + object_obj := PyBytes_FromStringAndSize(PAnsiChar(UnicodeErr.EObject), Length(UnicodeErr.EObject)); + + // Prepare start and end + start_obj := PyLong_FromLong(UnicodeErr.EStart); + end_obj := PyLong_FromLong(UnicodeErr.EEnd); + + // MakePyTuple will INCREF all objects, so we need to DECREF them after + args_tuple := MakePyTuple([encoding_obj, object_obj, start_obj, end_obj, reason_obj]); + if args_tuple = nil then + begin + // Can't create tuple - fall back to ValueError (parent class) which accepts string message + // UnicodeError family requires specific parameters, can't use SetPythonError + SetPythonError(PyExc_ValueError^, EncodeString(UnicodeErr.Message)); + Exit; + end; + + exc_instance := PyObject_CallObject(PythonExc, args_tuple); + if exc_instance = nil then + begin + SetPythonError(PyExc_ValueError^, EncodeString(UnicodeErr.Message)); + end; + + PyErr_SetObject(PythonExc, exc_instance); + finally + Py_XDECREF(exc_instance); + Py_XDECREF(encoding_obj); + Py_XDECREF(reason_obj); + Py_XDECREF(object_obj); + Py_XDECREF(start_obj); + Py_XDECREF(end_obj); + Py_XDECREF(args_tuple); + end; + end; + + +begin + // KOKOT Don’t overwrite an already-set Python error (important for Python-callback failures) + 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 + SetPythonError(PyExc_IOError^, MsgForPython(E)) + else if (E is EPyOSError) then + SetPythonError(PyExc_OSError^, MsgForPython(E)) + 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)) + else + SetPythonError(PyExc_RuntimeError^, MsgForPython(E)); +end; + function TPythonEngine.PyObjectAsString( obj : PPyObject ) : string; var S : PPyObject; @@ -6497,6 +6813,68 @@ function TPythonEngine.PyUnicodeAsUTF8String( obj : PPyObject ) : RawByteString; raise EPythonError.CreateFmt(SPyConvertionError, ['PyUnicodeAsUTF8String', 'Unicode']); end; +function TPythonEngine.PyBytesAsFSDecodedString(bytesObj: PPyObject): string; +// Bytes with the meaning of FileSystem paths returned from python should have +// special treatment for decoding. Python provides this. +var + p: PAnsiChar; + n: NativeInt; + u: PPyObject; +begin + if not PyBytes_Check(bytesObj) then + raise EPythonError.CreateFmt(SPyConvertionError, ['PyBytesAsFSDecodedString', 'Bytes']); + + p := PyBytes_AsString(bytesObj); + n := PyBytes_Size(bytesObj); + + u := PyUnicode_DecodeFSDefaultAndSize(p, n); + if u = nil then + if PyErr_Occurred <> nil then + RaiseError + else + raise EPyValueError.Create('FS name bytes are not valid unicode.'); + + try + Result := PyUnicodeAsString(u); + finally + Py_DecRef(u); + end; +end; + +function TPythonEngine.PyPathLikeObjectAsString( pathlike : PPyObject ) : string; +begin + var 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_DecRef(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} diff --git a/Source/WrapDelphiClasses.pas b/Source/WrapDelphiClasses.pas index f7f57a86..bd3ac130 100644 --- a/Source/WrapDelphiClasses.pas +++ b/Source/WrapDelphiClasses.pas @@ -1096,8 +1096,9 @@ function TPyDelphiComponent.InternalReadComponent(const AResFile: string; LInput: TFileStream; LOutput: TMemoryStream; begin - if AResFile.IsEmpty or not FileExists(AResFile) then - Exit(false); + if AResFile.IsEmpty or not FileExists(AResFile) then begin + raise EPyOSError.CreateFmt('File `%s` not found.', [AResFile]); + end; LInput := TFileStream.Create(AResFile, fmOpenRead); try diff --git a/Source/vcl/WrapVclForms.pas b/Source/vcl/WrapVclForms.pas index b3d77bec..dc0985dd 100644 --- a/Source/vcl/WrapVclForms.pas +++ b/Source/vcl/WrapVclForms.pas @@ -549,30 +549,23 @@ 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; - begin Adjust(@Self); try - if InternalReadComponent(FindResource(), DelphiObject) then - Exit(GetPythonEngine().ReturnTrue) - else - Exit(GetPythonEngine().ReturnFalse); + with GetPythonEngine() do begin + var path: PPyObject; + 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; From 15a2187e8589313b3d7538bfe916e5d764532f7a Mon Sep 17 00:00:00 2001 From: majkl Date: Sun, 28 Dec 2025 09:25:57 +0100 Subject: [PATCH 2/3] Add support for FMX Small fixes Fix unicode for linux --- Modules/DelphiFMX/tests/TestLoadProps.py | 601 +++++++++++++++++++++++ Modules/DelphiFMX/tests/test_form.fmx | 12 + Modules/DelphiVCL/tests/TestLoadProps.py | 333 +++++++++---- Source/PythonEngine.pas | 247 +++++----- Source/WrapDelphiClasses.pas | 4 +- Source/fmx/WrapFmxForms.pas | 30 +- Source/vcl/WrapVclForms.pas | 3 +- 7 files changed, 1006 insertions(+), 224 deletions(-) create mode 100644 Modules/DelphiFMX/tests/TestLoadProps.py create mode 100644 Modules/DelphiFMX/tests/test_form.fmx diff --git a/Modules/DelphiFMX/tests/TestLoadProps.py b/Modules/DelphiFMX/tests/TestLoadProps.py new file mode 100644 index 00000000..2ba3fe4e --- /dev/null +++ b/Modules/DelphiFMX/tests/TestLoadProps.py @@ -0,0 +1,601 @@ +""" +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 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_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('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.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..b67dfe90 --- /dev/null +++ b/Modules/DelphiFMX/tests/test_form.fmx @@ -0,0 +1,12 @@ +object Form1: TForm1 + Left = 0 + Top = 0 + Caption = 'Form1' + ClientHeight = 441 + ClientWidth = 624 + FormFactor.Width = 320 + FormFactor.Height = 480 + FormFactor.Devices = [Desktop] + DesignerMasterStyle = 0 +end + diff --git a/Modules/DelphiVCL/tests/TestLoadProps.py b/Modules/DelphiVCL/tests/TestLoadProps.py index bac55db0..4dd3a306 100644 --- a/Modules/DelphiVCL/tests/TestLoadProps.py +++ b/Modules/DelphiVCL/tests/TestLoadProps.py @@ -1,8 +1,13 @@ """ 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, PathLike objects) +- Valid inputs (str, bytes, PathLike objects) - Invalid inputs (wrong types, non-existent files) - UTF-8 path handling - Edge cases and error conditions @@ -14,12 +19,9 @@ import sys import tempfile import shutil -import platform from pathlib import Path # Ensure DelphiVCL .pyd can be found -# This is a minimal solution for test environment - in production, the module -# should be properly installed or PYTHONPATH should be set _test_dir = os.path.dirname(os.path.abspath(__file__)) _module_dir = os.path.dirname(_test_dir) @@ -28,13 +30,18 @@ _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 it exists and not already there -if os.path.exists(_pyd_dir) and _pyd_dir not in sys.path: - sys.path.insert(0, _pyd_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 -# Import DelphiVCL module - fail loudly if not available try: - from DelphiVCL import Form, Application + from DelphiVCL import Form except ImportError as e: raise ImportError( f"Failed to import DelphiVCL module. " @@ -44,7 +51,7 @@ ) from e -class TestForm(Form): +class FormForTest(Form): """Test form class - allows for adding subcomponents at LoadProps.""" pass @@ -88,8 +95,7 @@ def tearDownClass(cls): def setUp(self): """Set up before each test.""" - # Create a fresh form for each test - self.form = TestForm(None) + self.form = FormForTest(None) def tearDown(self): """Clean up after each test.""" @@ -107,6 +113,97 @@ def _copy_dfm_to_path(self, target_path): 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. @@ -126,38 +223,41 @@ def test_loadprops_with_string_path(self): 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.""" - # Change to test directory and use relative path - old_cwd = os.getcwd() try: os.chdir(self.test_dir) @@ -167,7 +267,8 @@ def test_loadprops_with_relative_path(self): 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') @@ -179,7 +280,6 @@ def test_loadprops_with_path_containing_spaces(self): self._verify_basic_properties_loaded(self.form) - # ========== Invalid Input Tests ========== def test_loadprops_with_nonexistent_file(self): @@ -188,27 +288,31 @@ def test_loadprops_with_nonexistent_file(self): with self.assertRaises(OSError) as context: self.form.LoadProps(nonexistent) - self.assertIn('not found', str(context.exception).lower()) - + 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_string(self): - """Test LoadProps with empty string.""" + + + 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 extension.""" - # Create a text file with wrong extension + """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') @@ -216,9 +320,94 @@ def test_loadprops_with_wrong_file_content(self): 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: @@ -242,7 +431,8 @@ def __fspath__(self): 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: @@ -252,7 +442,8 @@ def __fspath__(self): 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: @@ -262,7 +453,19 @@ def __fspath__(self): 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: @@ -274,21 +477,24 @@ def __fspath__(self): 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: @@ -301,7 +507,8 @@ def __fspath__(self): 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: @@ -315,7 +522,8 @@ def __fspath__(self): 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: @@ -325,51 +533,9 @@ def __fspath__(self): 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)) - - # ========== Edge Cases ========== - - def test_loadprops_with_very_long_path(self): - """Test LoadProps with very long path.""" - # Create a path with many nested directories - long_path = self.test_dir - for i in range(10): - long_path = os.path.join(long_path, f'dir_{i}' * 20) - os.makedirs(long_path, exist_ok=True) - - long_file = os.path.join(long_path, 'test.dfm') - self._copy_dfm_to_path(long_file) - - # Should work if path length is within system limits - try: - result = self.form.LoadProps(long_file) - self.assertTrue(result, "LoadProps should work with long path if within system limits") - self._verify_basic_properties_loaded(self.form) - except (OSError, RuntimeError) as e: - # Very long paths might fail on some systems - that's acceptable - error_msg = str(e).lower() - if any(term in error_msg for term in ['too long', 'path', 'filename']): - # Expected failure for path length limits - pass - else: - # Unexpected error - re-raise - raise - - def test_loadprops_with_unicode_normalization(self): - """Test LoadProps handles Unicode normalization correctly.""" - # Test with different Unicode representations - # Create directory with combining characters - if sys.platform == 'win32': - # Windows may normalize Unicode differently - # Test with é (U+00E9) vs e + U+0301 (combining acute) - normalized_dir = os.path.join(self.test_dir, 'café') - normalized_file = os.path.join(normalized_dir, 'form.dfm') - self._copy_dfm_to_path(normalized_file) - - result = self.form.LoadProps(normalized_file) - self.assertTrue(result, "LoadProps should handle Unicode normalization") - self._verify_basic_properties_loaded(self.form) - + def test_loadprops_overwrites_existing_properties(self): """Test that LoadProps overwrites existing form properties.""" self.form.Caption = 'Initial Caption' @@ -399,3 +565,4 @@ def run_tests(): if __name__ == '__main__': sys.exit(run_tests()) + diff --git a/Source/PythonEngine.pas b/Source/PythonEngine.pas index 3b44a5c8..005a1616 100644 --- a/Source/PythonEngine.pas +++ b/Source/PythonEngine.pas @@ -1251,18 +1251,26 @@ EPySystemExit = class (EPyException); EPyTypeError = class (EPyStandardError); EPyUnboundLocalError = class (EPyNameError); EPyValueError = class (EPyStandardError); + + // 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; - EObject: RawByteString; // The object as bytes (for simple reconstruction) - EObjectRepr: UnicodeString; // String representation of the object (for debugging/logging) + 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); @@ -4465,8 +4473,24 @@ function TPythonInterface.PyEnum_Check( obj : PPyObject ) : Boolean; end; function TPythonInterface.PyPathLike_Check( obj : PPyObject ) : Boolean; + var tmp: PPyObject; begin - Result := Assigned(obj) and (PyObject_HasAttrString(obj, '__fspath__') <> 0); + 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; @@ -5615,105 +5639,93 @@ procedure TPythonEngine.RaiseError; function DefineUnicodeError( E : EPyUnicodeError; const sType, sValue : UnicodeString; err_type, err_value : PPyObject ) : EPyUnicodeError; var - s_value : UnicodeString; - s_encoding : UnicodeString; - s_reason : UnicodeString; - s_object_repr : UnicodeString; - obj_bytes : RawByteString; - i_start : Integer; - i_end : Integer; tmp : PPyObject; buffer : PAnsiChar; size : NativeInt; + obj_bytes : PPyObject; begin Result := E; Result.EName := sType; Result.EValue := sValue; - s_value := ''; - s_encoding := ''; - s_reason := ''; - s_object_repr := ''; - obj_bytes := ''; - i_start := 0; - i_end := 0; - // Sometimes there's a tuple instead of instance... - if PyTuple_Check(err_value) and (PyTuple_Size(err_value) >= 2) then + Result.EEncoding := ''; + Result.EReason := ''; + Result.EObjectRepr := ''; + Result.EStart := 0; + Result.EEnd := 0; +// Result.EArgs := PyTuple_New(0); + + // Get the args - arguments with which exception has been created. + tmp := SafeGetPyObjectAttr(err_value, 'args'); + if tmp <> nil then begin - s_value := PyObjectAsString(PyTuple_GetItem(err_value, 0)); - err_value := PyTuple_GetItem(err_value, 1); - // Legacy tuple format may not have all UnicodeError attributes - end else - // Is it an instance of the UnicodeError class ? - if (PyType_IsSubtype(PPyTypeObject(err_type), PPyTypeObject(PyExc_UnicodeError^)) = 1) - and IsType(err_value, PPyTypeObject(err_type)) - then - begin - // Get the encoding - tmp := SafeGetPyObjectAttr(err_value, 'encoding'); - if tmp <> nil then - begin - if PyUnicode_Check(tmp) then - s_encoding := PyUnicodeAsString(tmp) - else if PyBytes_Check(tmp) then - s_encoding := UnicodeString(PyBytesAsAnsiString(tmp)); + 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 - s_reason := PyUnicodeAsString(tmp) + Result.EReason := PyUnicodeAsString(tmp) else if PyBytes_Check(tmp) then - s_reason := UnicodeString(PyBytesAsAnsiString(tmp)); + Result.EReason := UnicodeString(PyBytesAsAnsiString(tmp)); Py_XDECREF(tmp); end; - // Get the object (as bytes for reconstruction) + + // Get the object (We will need it just EObjectRepr representation) tmp := SafeGetPyObjectAttr(err_value, 'object'); if tmp <> nil then begin - if PyBytes_Check(tmp) then - begin - PyBytes_AsStringAndSize(tmp, buffer, size); - SetString(obj_bytes, buffer, size); - end - else if PyByteArray_Check(tmp) then - begin - buffer := PyByteArray_AsString(tmp); - size := PyByteArray_Size(tmp); - if Assigned(buffer) and (size > 0) then - SetString(obj_bytes, buffer, size); - end; - // Get string representation for EObjectRepr - s_object_repr := PyObjectAsString(tmp); + 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 - i_start := PyLong_AsLong(tmp); + 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 - i_end := PyLong_AsLong(tmp); + Result.EEnd := PyLong_AsLong(tmp); Py_XDECREF(tmp); - end; + + + // 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 - EEncoding := s_encoding; - EReason := s_reason; - EObject := obj_bytes; - EObjectRepr := s_object_repr; - EStart := i_start; - EEnd := i_end; - - if ((sType<>'') and (sValue<>'')) then - Message := Format('%s: %s', [sType, sValue]) // Original text - else + 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, s_reason, s_encoding, i_start, i_end, s_object_repr]); + [sType, EReason, EEncoding, EStart, EEnd, EObjectRepr]) + else // basic UnicodeError + Message := 'UnicodeError: Unknown Reason.'; end; end; @@ -5794,14 +5806,14 @@ procedure TPythonEngine.RaiseError; raise Define( EPyFloatingPointError.Create(''), s_type, s_value ) 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 DefineUnicodeError( UnicodeEncodeError.Create(''), s_type, s_value, err_type, err_value ) + else if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeEncodeError^) <> 0) then + raise DefineUnicodeError( UnicodeEncodeError.Create(), s_type, s_value, err_type, err_value ) else if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeDecodeError^) <> 0) then - raise DefineUnicodeError( UnicodeDecodeError.Create(''), s_type, s_value, err_type, err_value ) + raise DefineUnicodeError( UnicodeDecodeError.Create(), s_type, s_value, err_type, err_value ) else if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeTranslateError^) <> 0) then - raise DefineUnicodeError( UnicodeTranslateError.Create(''), s_type, s_value, err_type, err_value ) + raise DefineUnicodeError( UnicodeTranslateError.Create(), s_type, s_value, err_type, err_value ) else if (PyErr_GivenExceptionMatches(err_type, PyExc_UnicodeError^) <> 0) then - raise DefineUnicodeError( EPyUnicodeError.Create(''), s_type, s_value, err_type, err_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 @@ -5837,11 +5849,12 @@ procedure TPythonEngine.RaiseError; end; procedure TPythonEngine.SetPyErrFromException(E: Exception); - // This function actually mirrors RaiseError procedure. Its intended to easily - // create python exception from existing delphi 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 mehods exposed in python. + // wrapped methods exposed in python. function MsgForPython(const E: Exception): AnsiString; begin if (E is EPythonError) then @@ -5859,68 +5872,37 @@ procedure TPythonEngine.SetPyErrFromException(E: Exception); procedure SetUnicodeError(const PythonExc: PPyObject; const UnicodeErr: EPyUnicodeError); var - exc_instance, args_tuple, encoding_obj, reason_obj, object_obj, start_obj, end_obj: PPyObject; - encoding_str, reason_str: AnsiString; + exc_instance: PPyObject; + begin // Create exception instance with proper attributes - // UnicodeError(encoding, reason, object, start, end) - exc_instance := nil; - args_tuple := nil; - encoding_obj := nil; - reason_obj := nil; - object_obj := nil; - start_obj := nil; - end_obj := nil; - try - // Prepare encoding - encoding_obj := PyUnicode_FromString(PAnsiChar(EncodeString(UnicodeErr.EEncoding))); - - // Prepare reason - reason_obj := PyUnicode_FromString(PAnsiChar(EncodeString(UnicodeErr.EReason))); - - // Prepare object (from EObject bytes) - object_obj := PyBytes_FromStringAndSize(PAnsiChar(UnicodeErr.EObject), Length(UnicodeErr.EObject)); - - // Prepare start and end - start_obj := PyLong_FromLong(UnicodeErr.EStart); - end_obj := PyLong_FromLong(UnicodeErr.EEnd); - - // MakePyTuple will INCREF all objects, so we need to DECREF them after - args_tuple := MakePyTuple([encoding_obj, object_obj, start_obj, end_obj, reason_obj]); - if args_tuple = nil then - begin - // Can't create tuple - fall back to ValueError (parent class) which accepts string message - // UnicodeError family requires specific parameters, can't use SetPythonError - SetPythonError(PyExc_ValueError^, EncodeString(UnicodeErr.Message)); - Exit; - end; - - exc_instance := PyObject_CallObject(PythonExc, args_tuple); + exc_instance := PyObject_CallObject(PythonExc, UnicodeErr.EArgs); if exc_instance = nil then begin - SetPythonError(PyExc_ValueError^, EncodeString(UnicodeErr.Message)); + WriteLn(ErrOutput, 'Error during creating Python exception: Constructing excetion failed.'); + SetPythonError(PyExc_UnicodeError^, MsgForPython(UnicodeErr)); + Exit; end; - + PyErr_SetObject(PythonExc, exc_instance); finally Py_XDECREF(exc_instance); - Py_XDECREF(encoding_obj); - Py_XDECREF(reason_obj); - Py_XDECREF(object_obj); - Py_XDECREF(start_obj); - Py_XDECREF(end_obj); - Py_XDECREF(args_tuple); end; end; begin - // KOKOT Don’t overwrite an already-set Python error (important for Python-callback failures) + // Don’t overwrite an already-set Python error. + // TODO: Consider more robust exception handling, + // with reporting even exception, while handling exception, and/or + // reporting unhandled python exceptions). if PyErr_Occurred <> nil then Exit; - // Mirror of RaiseError mapping order + { ------------------------------------------------------------------------ + Mirror of RaiseError mapping order + ------------------------------------------------------------------------} if (E is EPySystemExit) then SetPythonError(PyExc_SystemExit^, MsgForPython(E)) else if (E is EPyStopIteration) then @@ -6011,6 +5993,12 @@ procedure TPythonEngine.SetPyErrFromException(E: Exception); 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; @@ -6837,7 +6825,7 @@ function TPythonEngine.PyBytesAsFSDecodedString(bytesObj: PPyObject): string; try Result := PyUnicodeAsString(u); finally - Py_DecRef(u); + Py_XDECREF(u); end; end; @@ -9626,6 +9614,23 @@ function TPythonType.GetMembersStartOffset : Integer; Result := Sizeof(PyObject); end; + +(*******************************************************) +(** **) +(** exception classes EPyException **) +(** **) +(*******************************************************) + +constructor EPyUnicodeError.Create; +begin + EArgs := nil; +end; + +destructor EPyUnicodeError.Destroy; +begin + TPythonInterface.Py_XDECREF(EArgs); +end; + (*******************************************************) (** **) (** class TPythonDelphiVar **) diff --git a/Source/WrapDelphiClasses.pas b/Source/WrapDelphiClasses.pas index bd3ac130..aac44c8c 100644 --- a/Source/WrapDelphiClasses.pas +++ b/Source/WrapDelphiClasses.pas @@ -1096,9 +1096,9 @@ function TPyDelphiComponent.InternalReadComponent(const AResFile: string; LInput: TFileStream; LOutput: TMemoryStream; begin - if AResFile.IsEmpty or not FileExists(AResFile) then begin +// if AResFile.IsEmpty or not FileExists(AResFile) then begin + if not FileExists(AResFile) then raise EPyOSError.CreateFmt('File `%s` not found.', [AResFile]); - end; 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 dc0985dd..2232bc23 100644 --- a/Source/vcl/WrapVclForms.pas +++ b/Source/vcl/WrapVclForms.pas @@ -549,11 +549,12 @@ function TPyDelphiCustomForm.Get_ModalResult(AContext: Pointer): PPyObject; end; function TPyDelphiCustomForm.LoadProps_Wrapper(args: PPyObject): PPyObject; +var + path: PPyObject; begin Adjust(@Self); try with GetPythonEngine() do begin - var path: PPyObject; if PyArg_ParseTuple(args, 'O:LoadProps', @path) = 0 then Exit(nil); // Python exception is already set. if InternalReadComponent(PyFSPathObjectAsString(path), DelphiObject) then From 20de49d9f646f90533240caf262907c87ccbc64c Mon Sep 17 00:00:00 2001 From: majkl Date: Wed, 7 Jan 2026 12:16:37 +0100 Subject: [PATCH 3/3] Fix unicode handling for linux --- Modules/DelphiFMX/tests/TestLoadProps.py | 23 ++-- Modules/DelphiFMX/tests/test_form.fmx | 16 +++ Source/PythonEngine.pas | 140 +++++++++++++++++------ Source/WrapDelphiClasses.pas | 1 - 4 files changed, 136 insertions(+), 44 deletions(-) diff --git a/Modules/DelphiFMX/tests/TestLoadProps.py b/Modules/DelphiFMX/tests/TestLoadProps.py index 2ba3fe4e..c46d10a1 100644 --- a/Modules/DelphiFMX/tests/TestLoadProps.py +++ b/Modules/DelphiFMX/tests/TestLoadProps.py @@ -505,16 +505,22 @@ def __fspath__(self): def test_loadprops_with_pathlike_returning_bytes_invalid_encoding(self): """Test LoadProps with PathLike returning bytes with invalid encoding.""" - class InvalidBytesPathLike: + + class NonUTF8BytesPathLike: 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)) - - + 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' @@ -533,8 +539,9 @@ def test_loadprops_with_file_no_read_permission(self): 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('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.""" diff --git a/Modules/DelphiFMX/tests/test_form.fmx b/Modules/DelphiFMX/tests/test_form.fmx index b67dfe90..5c3f1692 100644 --- a/Modules/DelphiFMX/tests/test_form.fmx +++ b/Modules/DelphiFMX/tests/test_form.fmx @@ -8,5 +8,21 @@ object Form1: TForm1 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/Source/PythonEngine.pas b/Source/PythonEngine.pas index 005a1616..a605d406 100644 --- a/Source/PythonEngine.pas +++ b/Source/PythonEngine.pas @@ -1592,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; @@ -1819,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; @@ -2192,7 +2194,7 @@ TPythonEngine = class(TPythonInterface) function PyByteArrayAsAnsiString( obj : PPyObject ) : AnsiString; { Filesystem strings conversion } - function PyBytesAsFSDecodedString(bytesObj: PPyObject): string; + function PyBytesAsFSDecodedString( bytes : PPyObject): string; function PyPathLikeObjectAsString( pathlike : PPyObject ) : string; function PyFSPathObjectAsString( path : PPyObject ) : string; @@ -3975,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'); @@ -4182,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'); @@ -5640,9 +5644,6 @@ procedure TPythonEngine.RaiseError; function DefineUnicodeError( E : EPyUnicodeError; const sType, sValue : UnicodeString; err_type, err_value : PPyObject ) : EPyUnicodeError; var tmp : PPyObject; - buffer : PAnsiChar; - size : NativeInt; - obj_bytes : PPyObject; begin Result := E; Result.EName := sType; @@ -5652,7 +5653,7 @@ procedure TPythonEngine.RaiseError; Result.EObjectRepr := ''; Result.EStart := 0; Result.EEnd := 0; -// Result.EArgs := PyTuple_New(0); + // Get the args - arguments with which exception has been created. tmp := SafeGetPyObjectAttr(err_value, 'args'); @@ -5855,32 +5856,73 @@ procedure TPythonEngine.SetPyErrFromException(E: Exception); // // The function is intended to simplify delphi exception handling on // wrapped methods exposed in python. - function MsgForPython(const E: Exception): AnsiString; + + function ExtractErrorMessage(const E: Exception): UnicodeString; begin if (E is EPythonError) then - Result := EncodeString(E.Message) + Exit(E.Message) else if E.Message <> '' then - Result := EncodeString(Format('%s: %s', [E.ClassName, E.Message])) + Exit(Format('%s: %s', [E.ClassName, E.Message])) else - Result := EncodeString(E.ClassName); + 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 excetion failed.'); + WriteLn(ErrOutput, 'Error during creating Python exception: Constructing exception failed.'); + if PyErr_Occurred <> nil then + PyErr_Print; SetPythonError(PyExc_UnicodeError^, MsgForPython(UnicodeErr)); Exit; end; @@ -5895,7 +5937,7 @@ procedure TPythonEngine.SetPyErrFromException(E: Exception); begin // Don’t overwrite an already-set Python error. // TODO: Consider more robust exception handling, - // with reporting even exception, while handling exception, and/or + // with reporting another exception, while handling exception, and/or // reporting unhandled python exceptions). if PyErr_Occurred <> nil then Exit; @@ -5916,9 +5958,9 @@ procedure TPythonEngine.SetPyErrFromException(E: Exception); SetPythonError(PyExc_WindowsError^, MsgForPython(E)) {$ENDIF} else if (E is EPyIOError) then - SetPythonError(PyExc_IOError^, MsgForPython(E)) + SetFSPythonError(PyExc_IOError^, E as EPyOSError) else if (E is EPyOSError) then - SetPythonError(PyExc_OSError^, MsgForPython(E)) + SetFSPythonError(PyExc_OSError^, E as EPyOSError) else if (E is EPyEnvironmentError) then SetPythonError(PyExc_EnvironmentError^, MsgForPython(E)) else if (E is EPyEOFError) then @@ -6764,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)); @@ -6801,37 +6846,59 @@ function TPythonEngine.PyUnicodeAsUTF8String( obj : PPyObject ) : RawByteString; raise EPythonError.CreateFmt(SPyConvertionError, ['PyUnicodeAsUTF8String', 'Unicode']); end; -function TPythonEngine.PyBytesAsFSDecodedString(bytesObj: PPyObject): string; +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 - p: PAnsiChar; - n: NativeInt; - u: PPyObject; + 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(bytesObj) then + if not PyBytes_Check(bytes) then raise EPythonError.CreateFmt(SPyConvertionError, ['PyBytesAsFSDecodedString', 'Bytes']); - p := PyBytes_AsString(bytesObj); - n := PyBytes_Size(bytesObj); + if PyBytes_AsStringAndSize(bytes, CharArray, CharCount) <> 0 then + RaiseError; - u := PyUnicode_DecodeFSDefaultAndSize(p, n); - if u = nil then - if PyErr_Occurred <> nil then - RaiseError - else - raise EPyValueError.Create('FS name bytes are not valid unicode.'); + UnicodeObject := nil; + UnicodeObject := PyUnicode_DecodeFSDefaultAndSize(CharArray, CharCount); + if UnicodeObject = nil then RaiseError; try - Result := PyUnicodeAsString(u); + 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(u); + Py_XDECREF(UnicodeObject); end; + end; function TPythonEngine.PyPathLikeObjectAsString( pathlike : PPyObject ) : string; +var + tmp: PPyObject; begin - var tmp := PyObject_CallMethod(pathlike, '__fspath__', nil); + tmp := PyObject_CallMethod(pathlike, '__fspath__', nil); if tmp = nil then if PyErr_Occurred <> nil then @@ -6845,9 +6912,9 @@ function TPythonEngine.PyPathLikeObjectAsString( pathlike : PPyObject ) : string else if PyBytes_Check(tmp) then Exit(PyBytesAsFSDecodedString(tmp)) else - raise EPyTypeError.CreateFmt(SPyReturnTypeError, ['__fspath__', 'str or bytes', tmp.ob_type.tp_name]); + raise EPyTypeError.CreateFmt(SPyReturnTypeError, ['__fspath__', 'str or bytes', tmp^.ob_type^.tp_name]); finally - Py_DecRef(tmp); + Py_XDECREF(tmp); end; end; @@ -6860,7 +6927,7 @@ function TPythonEngine.PyFSPathObjectAsString( path : PPyObject ) : string; else if PyBytes_Check(path) then Exit(PyBytesAsFSDecodedString(path)) else - raise EPyTypeError.CreateFmt(SPyWrongArgumentType, ['str, bytes or os.PathLike', path.ob_type.tp_name]); + raise EPyTypeError.CreateFmt(SPyWrongArgumentType, ['str, bytes or os.PathLike', path^.ob_type^.tp_name]); end; @@ -9628,7 +9695,10 @@ constructor EPyUnicodeError.Create; destructor EPyUnicodeError.Destroy; begin - TPythonInterface.Py_XDECREF(EArgs); + with GetPythonEngine do + TPythonInterface.Py_XDECREF(EArgs); + + inherited; end; (*******************************************************) diff --git a/Source/WrapDelphiClasses.pas b/Source/WrapDelphiClasses.pas index aac44c8c..10468df8 100644 --- a/Source/WrapDelphiClasses.pas +++ b/Source/WrapDelphiClasses.pas @@ -1096,7 +1096,6 @@ function TPyDelphiComponent.InternalReadComponent(const AResFile: string; LInput: TFileStream; LOutput: TMemoryStream; begin -// if AResFile.IsEmpty or not FileExists(AResFile) then begin if not FileExists(AResFile) then raise EPyOSError.CreateFmt('File `%s` not found.', [AResFile]);